From a9666ff96cfc245529cde2e9903fdfd80689fbfb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 08:58:56 +0200 Subject: [PATCH] Workstream 2: Datenbankschema & Migrationen (Phase 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- drizzle/0000_opposite_santa_claus.sql | 187 +++ drizzle/meta/0000_snapshot.json | 1474 ++++++++++++++++++ drizzle/meta/_journal.json | 13 + package-lock.json | 529 ++++++- package.json | 5 +- scripts/migrate.ts | 33 + src/db/client.ts | 2 +- src/db/index.ts | 4 + src/db/schema/__tests__/db-roundtrip.test.ts | 158 ++ src/db/schema/__tests__/migration.test.ts | 77 + src/db/schema/__tests__/schema.test.ts | 236 +++ src/db/schema/assets.ts | 68 + src/db/schema/audit.ts | 34 + src/db/schema/auth-rate-limit.ts | 28 + src/db/schema/brigades.ts | 42 + src/db/schema/enums.ts | 36 + src/db/schema/equipment-categories.ts | 37 + src/db/schema/index.ts | 18 +- src/db/schema/merkmal-values.ts | 36 + src/db/schema/merkmale.ts | 63 + src/db/schema/relations.ts | 143 ++ src/db/schema/templates.ts | 71 + src/db/schema/users.ts | 27 + 23 files changed, 3291 insertions(+), 30 deletions(-) create mode 100644 drizzle/0000_opposite_santa_claus.sql create mode 100644 drizzle/meta/0000_snapshot.json create mode 100644 drizzle/meta/_journal.json create mode 100644 scripts/migrate.ts create mode 100644 src/db/index.ts create mode 100644 src/db/schema/__tests__/db-roundtrip.test.ts create mode 100644 src/db/schema/__tests__/migration.test.ts create mode 100644 src/db/schema/__tests__/schema.test.ts create mode 100644 src/db/schema/assets.ts create mode 100644 src/db/schema/audit.ts create mode 100644 src/db/schema/auth-rate-limit.ts create mode 100644 src/db/schema/brigades.ts create mode 100644 src/db/schema/enums.ts create mode 100644 src/db/schema/equipment-categories.ts create mode 100644 src/db/schema/merkmal-values.ts create mode 100644 src/db/schema/merkmale.ts create mode 100644 src/db/schema/relations.ts create mode 100644 src/db/schema/templates.ts create mode 100644 src/db/schema/users.ts diff --git a/drizzle/0000_opposite_santa_claus.sql b/drizzle/0000_opposite_santa_claus.sql new file mode 100644 index 0000000..c6287ee --- /dev/null +++ b/drizzle/0000_opposite_santa_claus.sql @@ -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"); \ No newline at end of file diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..60e3808 --- /dev/null +++ b/drizzle/meta/0000_snapshot.json @@ -0,0 +1,1474 @@ +{ + "id": "39709e14-70f1-49c8-8586-af0d48d734dd", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.brigades": { + "name": "brigades", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "art": { + "name": "art", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'FF'" + }, + "strasse": { + "name": "strasse", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plz": { + "name": "plz", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ort": { + "name": "ort", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bundesland": { + "name": "bundesland", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Niederösterreich'" + }, + "lat": { + "name": "lat", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "lng": { + "name": "lng", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "geocode_query": { + "name": "geocode_query", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "geocoded_at": { + "name": "geocoded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "geocode_status": { + "name": "geocode_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "funkrufname_schema": { + "name": "funkrufname_schema", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "wehrfuehrer": { + "name": "wehrfuehrer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "telefon": { + "name": "telefon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "aktiv": { + "name": "aktiv", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "erstellt_am": { + "name": "erstellt_am", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "brigades_latlng_idx": { + "name": "brigades_latlng_idx", + "columns": [ + { + "expression": "lat", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lng", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "brigade_id": { + "name": "brigade_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "rolle": { + "name": "rolle", + "type": "role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "auth_typ": { + "name": "auth_typ", + "type": "auth_typ", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "passwort_hash": { + "name": "passwort_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "aktiv": { + "name": "aktiv", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "erstellt_von": { + "name": "erstellt_von", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "erstellt_am": { + "name": "erstellt_am", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "users_brigade_id_brigades_id_fk": { + "name": "users_brigade_id_brigades_id_fk", + "tableFrom": "users", + "tableTo": "brigades", + "columnsFrom": [ + "brigade_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_uq": { + "name": "users_email_uq", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.merkmal_optionen": { + "name": "merkmal_optionen", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "merkmal_id": { + "name": "merkmal_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "wert": { + "name": "wert", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reihenfolge": { + "name": "reihenfolge", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "merkmal_optionen_merkmal_idx": { + "name": "merkmal_optionen_merkmal_idx", + "columns": [ + { + "expression": "merkmal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "merkmal_optionen_merkmal_id_merkmale_id_fk": { + "name": "merkmal_optionen_merkmal_id_merkmale_id_fk", + "tableFrom": "merkmal_optionen", + "tableTo": "merkmale", + "columnsFrom": [ + "merkmal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "merkmal_optionen_uq": { + "name": "merkmal_optionen_uq", + "nullsNotDistinct": false, + "columns": [ + "merkmal_id", + "wert" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.merkmale": { + "name": "merkmale", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "typ": { + "name": "typ", + "type": "merkmal_typ", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "einheit": { + "name": "einheit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "geltungsbereich": { + "name": "geltungsbereich", + "type": "geltungsbereich", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "merkmal_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'proposed'" + }, + "vorgeschlagen_von_brigade_id": { + "name": "vorgeschlagen_von_brigade_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "erstellt_am": { + "name": "erstellt_am", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "merkmale_status_idx": { + "name": "merkmale_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "merkmale_active_name_uq": { + "name": "merkmale_active_name_uq", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"merkmale\".\"status\" = 'active'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "merkmale_vorgeschlagen_von_brigade_id_brigades_id_fk": { + "name": "merkmale_vorgeschlagen_von_brigade_id_brigades_id_fk", + "tableFrom": "merkmale", + "tableTo": "brigades", + "columnsFrom": [ + "vorgeschlagen_von_brigade_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "merkmale_slug_uq": { + "name": "merkmale_slug_uq", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.vehicle_template_aliasse": { + "name": "vehicle_template_aliasse", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "template_id": { + "name": "template_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "alias": { + "name": "alias", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bestaetigt": { + "name": "bestaetigt", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "vehicle_template_aliasse_template_id_vehicle_templates_id_fk": { + "name": "vehicle_template_aliasse_template_id_vehicle_templates_id_fk", + "tableFrom": "vehicle_template_aliasse", + "tableTo": "vehicle_templates", + "columnsFrom": [ + "template_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "vehicle_template_aliasse_uq": { + "name": "vehicle_template_aliasse_uq", + "nullsNotDistinct": false, + "columns": [ + "template_id", + "alias" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.vehicle_template_merkmale": { + "name": "vehicle_template_merkmale", + "schema": "", + "columns": { + "template_id": { + "name": "template_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "merkmal_id": { + "name": "merkmal_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "vorgabewert_num": { + "name": "vorgabewert_num", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "vorgabewert_text": { + "name": "vorgabewert_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vorgabewert_bool": { + "name": "vorgabewert_bool", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "pflicht": { + "name": "pflicht", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "reihenfolge": { + "name": "reihenfolge", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "vtm_merkmal_idx": { + "name": "vtm_merkmal_idx", + "columns": [ + { + "expression": "merkmal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "vehicle_template_merkmale_template_id_vehicle_templates_id_fk": { + "name": "vehicle_template_merkmale_template_id_vehicle_templates_id_fk", + "tableFrom": "vehicle_template_merkmale", + "tableTo": "vehicle_templates", + "columnsFrom": [ + "template_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "vehicle_template_merkmale_merkmal_id_merkmale_id_fk": { + "name": "vehicle_template_merkmale_merkmal_id_merkmale_id_fk", + "tableFrom": "vehicle_template_merkmale", + "tableTo": "merkmale", + "columnsFrom": [ + "merkmal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "vehicle_template_merkmale_template_id_merkmal_id_pk": { + "name": "vehicle_template_merkmale_template_id_merkmal_id_pk", + "columns": [ + "template_id", + "merkmal_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.vehicle_templates": { + "name": "vehicle_templates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "beschreibung": { + "name": "beschreibung", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reihenfolge": { + "name": "reihenfolge", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "vehicle_templates_code_uq": { + "name": "vehicle_templates_code_uq", + "nullsNotDistinct": false, + "columns": [ + "code" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.equipment_categories": { + "name": "equipment_categories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reihenfolge": { + "name": "reihenfolge", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "equipment_categories_name_uq": { + "name": "equipment_categories_name_uq", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.equipment_category_merkmale": { + "name": "equipment_category_merkmale", + "schema": "", + "columns": { + "category_id": { + "name": "category_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "merkmal_id": { + "name": "merkmal_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "reihenfolge": { + "name": "reihenfolge", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "ecm_merkmal_idx": { + "name": "ecm_merkmal_idx", + "columns": [ + { + "expression": "merkmal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "equipment_category_merkmale_category_id_equipment_categories_id_fk": { + "name": "equipment_category_merkmale_category_id_equipment_categories_id_fk", + "tableFrom": "equipment_category_merkmale", + "tableTo": "equipment_categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "equipment_category_merkmale_merkmal_id_merkmale_id_fk": { + "name": "equipment_category_merkmale_merkmal_id_merkmale_id_fk", + "tableFrom": "equipment_category_merkmale", + "tableTo": "merkmale", + "columnsFrom": [ + "merkmal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "equipment_category_merkmale_category_id_merkmal_id_pk": { + "name": "equipment_category_merkmale_category_id_merkmal_id_pk", + "columns": [ + "category_id", + "merkmal_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.equipment": { + "name": "equipment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "brigade_id": { + "name": "brigade_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "category_id": { + "name": "category_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "vehicle_id": { + "name": "vehicle_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "asset_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'einsatzbereit'" + }, + "erstellt_am": { + "name": "erstellt_am", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "equipment_brigade_idx": { + "name": "equipment_brigade_idx", + "columns": [ + { + "expression": "brigade_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "equipment_category_idx": { + "name": "equipment_category_idx", + "columns": [ + { + "expression": "category_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "equipment_vehicle_idx": { + "name": "equipment_vehicle_idx", + "columns": [ + { + "expression": "vehicle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "equipment_brigade_id_brigades_id_fk": { + "name": "equipment_brigade_id_brigades_id_fk", + "tableFrom": "equipment", + "tableTo": "brigades", + "columnsFrom": [ + "brigade_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "equipment_category_id_equipment_categories_id_fk": { + "name": "equipment_category_id_equipment_categories_id_fk", + "tableFrom": "equipment", + "tableTo": "equipment_categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "equipment_vehicle_id_vehicles_id_fk": { + "name": "equipment_vehicle_id_vehicles_id_fk", + "tableFrom": "equipment", + "tableTo": "vehicles", + "columnsFrom": [ + "vehicle_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.vehicles": { + "name": "vehicles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "brigade_id": { + "name": "brigade_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "template_id": { + "name": "template_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "funkrufname": { + "name": "funkrufname", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "asset_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'einsatzbereit'" + }, + "notiz": { + "name": "notiz", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "erstellt_am": { + "name": "erstellt_am", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "vehicles_brigade_idx": { + "name": "vehicles_brigade_idx", + "columns": [ + { + "expression": "brigade_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "vehicles_template_idx": { + "name": "vehicles_template_idx", + "columns": [ + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "vehicles_brigade_id_brigades_id_fk": { + "name": "vehicles_brigade_id_brigades_id_fk", + "tableFrom": "vehicles", + "tableTo": "brigades", + "columnsFrom": [ + "brigade_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "vehicles_template_id_vehicle_templates_id_fk": { + "name": "vehicles_template_id_vehicle_templates_id_fk", + "tableFrom": "vehicles", + "tableTo": "vehicle_templates", + "columnsFrom": [ + "template_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.merkmal_values": { + "name": "merkmal_values", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "merkmal_id": { + "name": "merkmal_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "entity_typ": { + "name": "entity_typ", + "type": "entity_typ", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "value_num": { + "name": "value_num", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "value_text": { + "name": "value_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "value_bool": { + "name": "value_bool", + "type": "boolean", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "mv_merkmal_num_idx": { + "name": "mv_merkmal_num_idx", + "columns": [ + { + "expression": "merkmal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "value_num", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mv_merkmal_bool_idx": { + "name": "mv_merkmal_bool_idx", + "columns": [ + { + "expression": "merkmal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "value_bool", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mv_merkmal_text_idx": { + "name": "mv_merkmal_text_idx", + "columns": [ + { + "expression": "merkmal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "value_text", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mv_entity_idx": { + "name": "mv_entity_idx", + "columns": [ + { + "expression": "entity_typ", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "merkmal_values_merkmal_id_merkmale_id_fk": { + "name": "merkmal_values_merkmal_id_merkmale_id_fk", + "tableFrom": "merkmal_values", + "tableTo": "merkmale", + "columnsFrom": [ + "merkmal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.login_attempts": { + "name": "login_attempts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "erfolg": { + "name": "erfolg", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "zeitpunkt": { + "name": "zeitpunkt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "login_attempts_key_zeit_idx": { + "name": "login_attempts_key_zeit_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "zeitpunkt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "aktion": { + "name": "aktion", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ziel_typ": { + "name": "ziel_typ", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ziel_id": { + "name": "ziel_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "zeitpunkt": { + "name": "zeitpunkt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_zeitpunkt_idx": { + "name": "audit_log_zeitpunkt_idx", + "columns": [ + { + "expression": "zeitpunkt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_aktion_idx": { + "name": "audit_log_aktion_idx", + "columns": [ + { + "expression": "aktion", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_actor_user_id_users_id_fk": { + "name": "audit_log_actor_user_id_users_id_fk", + "tableFrom": "audit_log", + "tableTo": "users", + "columnsFrom": [ + "actor_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.asset_status": { + "name": "asset_status", + "schema": "public", + "values": [ + "einsatzbereit", + "wartung", + "ausser_dienst" + ] + }, + "public.auth_typ": { + "name": "auth_typ", + "schema": "public", + "values": [ + "authentik", + "local" + ] + }, + "public.entity_typ": { + "name": "entity_typ", + "schema": "public", + "values": [ + "vehicle", + "equipment" + ] + }, + "public.geltungsbereich": { + "name": "geltungsbereich", + "schema": "public", + "values": [ + "vehicle", + "equipment", + "both" + ] + }, + "public.merkmal_status": { + "name": "merkmal_status", + "schema": "public", + "values": [ + "active", + "proposed" + ] + }, + "public.merkmal_typ": { + "name": "merkmal_typ", + "schema": "public", + "values": [ + "number", + "enum", + "boolean", + "text" + ] + }, + "public.role": { + "name": "role", + "schema": "public", + "values": [ + "platform_admin", + "wehr_admin", + "wehr_read" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json new file mode 100644 index 0000000..ea82730 --- /dev/null +++ b/drizzle/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1780936243787, + "tag": "0000_opposite_santa_claus", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 88a8e25..007fe93 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,6 +39,7 @@ "prettier": "^3.5.2", "prettier-plugin-tailwindcss": "^0.6.11", "tailwindcss": "^3.4.17", + "tsx": "^4.19.2", "typescript": "^5.7.3", "vitest": "^3.0.7" } @@ -62,29 +63,6 @@ "dev": true, "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": { "version": "1.2.1", "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", "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -8746,6 +8725,509 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "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": { "version": "0.4.0", "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", "integrity": "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==", "dev": true, + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", diff --git a/package.json b/package.json index 5503c62..36b4c1e 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,9 @@ "test": "vitest run", "test:watch": "vitest", "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" }, "dependencies": { @@ -47,6 +49,7 @@ "prettier": "^3.5.2", "prettier-plugin-tailwindcss": "^0.6.11", "tailwindcss": "^3.4.17", + "tsx": "^4.19.2", "typescript": "^5.7.3", "vitest": "^3.0.7" } diff --git a/scripts/migrate.ts b/scripts/migrate.ts new file mode 100644 index 0000000..fb1c915 --- /dev/null +++ b/scripts/migrate.ts @@ -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 { + 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); +}); diff --git a/src/db/client.ts b/src/db/client.ts index ed9d939..772a7d5 100644 --- a/src/db/client.ts +++ b/src/db/client.ts @@ -18,7 +18,7 @@ function createPool(): Pool { return new Pool({ connectionString: env.DATABASE_URL }); } -const pool = +export const pool = env.NODE_ENV === "production" ? createPool() : (globalThis.__floriannetzPool ??= createPool()); diff --git a/src/db/index.ts b/src/db/index.ts new file mode 100644 index 0000000..2330eb0 --- /dev/null +++ b/src/db/index.ts @@ -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"; diff --git a/src/db/schema/__tests__/db-roundtrip.test.ts b/src/db/schema/__tests__/db-roundtrip.test.ts new file mode 100644 index 0000000..af91cfb --- /dev/null +++ b/src/db/schema/__tests__/db-roundtrip.test.ts @@ -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 | 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) => + (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"); + }); +}); diff --git a/src/db/schema/__tests__/migration.test.ts b/src/db/schema/__tests__/migration.test.ts new file mode 100644 index 0000000..6568312 --- /dev/null +++ b/src/db/schema/__tests__/migration.test.ts @@ -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); + }); +}); diff --git a/src/db/schema/__tests__/schema.test.ts b/src/db/schema/__tests__/schema.test.ts new file mode 100644 index 0000000..9206eb2 --- /dev/null +++ b/src/db/schema/__tests__/schema.test.ts @@ -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); + }); +}); diff --git a/src/db/schema/assets.ts b/src/db/schema/assets.ts new file mode 100644 index 0000000..c170b90 --- /dev/null +++ b/src/db/schema/assets.ts @@ -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), + }), +); diff --git a/src/db/schema/audit.ts b/src/db/schema/audit.ts new file mode 100644 index 0000000..f4c0a00 --- /dev/null +++ b/src/db/schema/audit.ts @@ -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), + }), +); diff --git a/src/db/schema/auth-rate-limit.ts b/src/db/schema/auth-rate-limit.ts new file mode 100644 index 0000000..92b9e40 --- /dev/null +++ b/src/db/schema/auth-rate-limit.ts @@ -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), + }), +); diff --git a/src/db/schema/brigades.ts b/src/db/schema/brigades.ts new file mode 100644 index 0000000..c5b99df --- /dev/null +++ b/src/db/schema/brigades.ts @@ -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), + }), +); diff --git a/src/db/schema/enums.ts b/src/db/schema/enums.ts new file mode 100644 index 0000000..5552b47 --- /dev/null +++ b/src/db/schema/enums.ts @@ -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"]); diff --git a/src/db/schema/equipment-categories.ts b/src/db/schema/equipment-categories.ts new file mode 100644 index 0000000..6ae45e9 --- /dev/null +++ b/src/db/schema/equipment-categories.ts @@ -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), + }), +); diff --git a/src/db/schema/index.ts b/src/db/schema/index.ts index 5c0e933..da29518 100644 --- a/src/db/schema/index.ts +++ b/src/db/schema/index.ts @@ -1,5 +1,13 @@ -// Barrel für das Datenbankschema. -// Wird vom Datenbank-Workstream (Workstream 2) mit Tabellen, Enums und Indizes -// gefüllt. Dieser Workstream (Fundament) definiert bewusst KEINE fachlichen -// Tabellen und ist NICHT Migrations-Eigentümer. -export {}; +// Barrel für das gesamte Datenbankschema (Workstream 2 — alleiniger +// Schema-Eigentümer). Feature-Workstreams IMPORTIEREN nur von hier. +export * from "./enums"; +export * from "./brigades"; +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"; diff --git a/src/db/schema/merkmal-values.ts b/src/db/schema/merkmal-values.ts new file mode 100644 index 0000000..7e08f8b --- /dev/null +++ b/src/db/schema/merkmal-values.ts @@ -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), + }), +); diff --git a/src/db/schema/merkmale.ts b/src/db/schema/merkmale.ts new file mode 100644 index 0000000..1285a9e --- /dev/null +++ b/src/db/schema/merkmale.ts @@ -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), + }), +); diff --git a/src/db/schema/relations.ts b/src/db/schema/relations.ts new file mode 100644 index 0000000..b6eb224 --- /dev/null +++ b/src/db/schema/relations.ts @@ -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], + }), +})); diff --git a/src/db/schema/templates.ts b/src/db/schema/templates.ts new file mode 100644 index 0000000..d49e82a --- /dev/null +++ b/src/db/schema/templates.ts @@ -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), + }), +); diff --git a/src/db/schema/users.ts b/src/db/schema/users.ts new file mode 100644 index 0000000..03c26c0 --- /dev/null +++ b/src/db/schema/users.ts @@ -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) }), +);