Workstream 2: Datenbankschema & Migrationen (Phase 1)
Vollständiges Drizzle-Schema (alle Tabellen/Enums/Indizes aus Spec §6): brigades, users, merkmale(+optionen), vehicle_templates(+merkmale,+aliasse), equipment_categories(+merkmale), vehicles, equipment, merkmal_values (EAV mit typisierten Spalten + 4 Indizes), login_attempts, audit_log. Einzige initiale Migration 0000 (idempotent: enum-DO-Blöcke, IF NOT EXISTS), scripts/migrate.ts, db:* npm-Scripts. Verifiziert (offline): tsc --noEmit OK; drizzle-kit check 'Everything's fine'; Migration 7 CREATE TYPE / 14 CREATE TABLE / 17 CREATE INDEX / 32 IF NOT EXISTS. DEFERRED (kein Postgres im Sandbox — Ursache des vorherigen Stalls): live db:migrate und DB-abhängige Schema-Tests; laufen in CI/Deploy mit Postgres. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
187
drizzle/0000_opposite_santa_claus.sql
Normal file
187
drizzle/0000_opposite_santa_claus.sql
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
DO $$ BEGIN CREATE TYPE "public"."asset_status" AS ENUM('einsatzbereit', 'wartung', 'ausser_dienst'); EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
|
||||||
|
DO $$ BEGIN CREATE TYPE "public"."auth_typ" AS ENUM('authentik', 'local'); EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
|
||||||
|
DO $$ BEGIN CREATE TYPE "public"."entity_typ" AS ENUM('vehicle', 'equipment'); EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
|
||||||
|
DO $$ BEGIN CREATE TYPE "public"."geltungsbereich" AS ENUM('vehicle', 'equipment', 'both'); EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
|
||||||
|
DO $$ BEGIN CREATE TYPE "public"."merkmal_status" AS ENUM('active', 'proposed'); EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
|
||||||
|
DO $$ BEGIN CREATE TYPE "public"."merkmal_typ" AS ENUM('number', 'enum', 'boolean', 'text'); EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
|
||||||
|
DO $$ BEGIN CREATE TYPE "public"."role" AS ENUM('platform_admin', 'wehr_admin', 'wehr_read'); EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "brigades" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"art" text DEFAULT 'FF' NOT NULL,
|
||||||
|
"strasse" text,
|
||||||
|
"plz" text,
|
||||||
|
"ort" text,
|
||||||
|
"bundesland" text DEFAULT 'Niederösterreich' NOT NULL,
|
||||||
|
"lat" double precision,
|
||||||
|
"lng" double precision,
|
||||||
|
"geocode_query" text,
|
||||||
|
"geocoded_at" timestamp with time zone,
|
||||||
|
"geocode_status" text,
|
||||||
|
"funkrufname_schema" text,
|
||||||
|
"wehrfuehrer" text,
|
||||||
|
"telefon" text,
|
||||||
|
"email" text,
|
||||||
|
"aktiv" boolean DEFAULT true NOT NULL,
|
||||||
|
"erstellt_am" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "users" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"brigade_id" uuid,
|
||||||
|
"rolle" "role" NOT NULL,
|
||||||
|
"auth_typ" "auth_typ" NOT NULL,
|
||||||
|
"email" text NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"passwort_hash" text,
|
||||||
|
"aktiv" boolean DEFAULT true NOT NULL,
|
||||||
|
"erstellt_von" uuid,
|
||||||
|
"erstellt_am" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "users_email_uq" UNIQUE("email")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "merkmal_optionen" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"merkmal_id" uuid NOT NULL,
|
||||||
|
"wert" text NOT NULL,
|
||||||
|
"label" text NOT NULL,
|
||||||
|
"reihenfolge" integer DEFAULT 0 NOT NULL,
|
||||||
|
CONSTRAINT "merkmal_optionen_uq" UNIQUE("merkmal_id","wert")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "merkmale" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"slug" text NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"typ" "merkmal_typ" NOT NULL,
|
||||||
|
"einheit" text,
|
||||||
|
"geltungsbereich" "geltungsbereich" NOT NULL,
|
||||||
|
"status" "merkmal_status" DEFAULT 'proposed' NOT NULL,
|
||||||
|
"vorgeschlagen_von_brigade_id" uuid,
|
||||||
|
"erstellt_am" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "merkmale_slug_uq" UNIQUE("slug")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "vehicle_template_aliasse" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"template_id" uuid NOT NULL,
|
||||||
|
"alias" text NOT NULL,
|
||||||
|
"bestaetigt" boolean DEFAULT false NOT NULL,
|
||||||
|
CONSTRAINT "vehicle_template_aliasse_uq" UNIQUE("template_id","alias")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "vehicle_template_merkmale" (
|
||||||
|
"template_id" uuid NOT NULL,
|
||||||
|
"merkmal_id" uuid NOT NULL,
|
||||||
|
"vorgabewert_num" double precision,
|
||||||
|
"vorgabewert_text" text,
|
||||||
|
"vorgabewert_bool" boolean,
|
||||||
|
"pflicht" boolean DEFAULT false NOT NULL,
|
||||||
|
"reihenfolge" integer DEFAULT 0 NOT NULL,
|
||||||
|
CONSTRAINT "vehicle_template_merkmale_template_id_merkmal_id_pk" PRIMARY KEY("template_id","merkmal_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "vehicle_templates" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"code" text NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"beschreibung" text,
|
||||||
|
"reihenfolge" integer DEFAULT 0 NOT NULL,
|
||||||
|
CONSTRAINT "vehicle_templates_code_uq" UNIQUE("code")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "equipment_categories" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"reihenfolge" integer DEFAULT 0 NOT NULL,
|
||||||
|
CONSTRAINT "equipment_categories_name_uq" UNIQUE("name")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "equipment_category_merkmale" (
|
||||||
|
"category_id" uuid NOT NULL,
|
||||||
|
"merkmal_id" uuid NOT NULL,
|
||||||
|
"reihenfolge" integer DEFAULT 0 NOT NULL,
|
||||||
|
CONSTRAINT "equipment_category_merkmale_category_id_merkmal_id_pk" PRIMARY KEY("category_id","merkmal_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "equipment" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"brigade_id" uuid NOT NULL,
|
||||||
|
"category_id" uuid NOT NULL,
|
||||||
|
"vehicle_id" uuid,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"status" "asset_status" DEFAULT 'einsatzbereit' NOT NULL,
|
||||||
|
"erstellt_am" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "vehicles" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"brigade_id" uuid NOT NULL,
|
||||||
|
"template_id" uuid,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"funkrufname" text,
|
||||||
|
"status" "asset_status" DEFAULT 'einsatzbereit' NOT NULL,
|
||||||
|
"notiz" text,
|
||||||
|
"erstellt_am" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "merkmal_values" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"merkmal_id" uuid NOT NULL,
|
||||||
|
"entity_typ" "entity_typ" NOT NULL,
|
||||||
|
"entity_id" uuid NOT NULL,
|
||||||
|
"value_num" double precision,
|
||||||
|
"value_text" text,
|
||||||
|
"value_bool" boolean
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "login_attempts" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"key" text NOT NULL,
|
||||||
|
"erfolg" boolean NOT NULL,
|
||||||
|
"zeitpunkt" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "audit_log" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"actor_user_id" uuid,
|
||||||
|
"aktion" text NOT NULL,
|
||||||
|
"ziel_typ" text,
|
||||||
|
"ziel_id" uuid,
|
||||||
|
"details" jsonb,
|
||||||
|
"zeitpunkt" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN ALTER TABLE "users" ADD CONSTRAINT "users_brigade_id_brigades_id_fk" FOREIGN KEY ("brigade_id") REFERENCES "public"."brigades"("id") ON DELETE restrict ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
|
||||||
|
DO $$ BEGIN ALTER TABLE "merkmal_optionen" ADD CONSTRAINT "merkmal_optionen_merkmal_id_merkmale_id_fk" FOREIGN KEY ("merkmal_id") REFERENCES "public"."merkmale"("id") ON DELETE cascade ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
|
||||||
|
DO $$ BEGIN ALTER TABLE "merkmale" ADD CONSTRAINT "merkmale_vorgeschlagen_von_brigade_id_brigades_id_fk" FOREIGN KEY ("vorgeschlagen_von_brigade_id") REFERENCES "public"."brigades"("id") ON DELETE set null ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
|
||||||
|
DO $$ BEGIN ALTER TABLE "vehicle_template_aliasse" ADD CONSTRAINT "vehicle_template_aliasse_template_id_vehicle_templates_id_fk" FOREIGN KEY ("template_id") REFERENCES "public"."vehicle_templates"("id") ON DELETE cascade ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
|
||||||
|
DO $$ BEGIN ALTER TABLE "vehicle_template_merkmale" ADD CONSTRAINT "vehicle_template_merkmale_template_id_vehicle_templates_id_fk" FOREIGN KEY ("template_id") REFERENCES "public"."vehicle_templates"("id") ON DELETE cascade ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
|
||||||
|
DO $$ BEGIN ALTER TABLE "vehicle_template_merkmale" ADD CONSTRAINT "vehicle_template_merkmale_merkmal_id_merkmale_id_fk" FOREIGN KEY ("merkmal_id") REFERENCES "public"."merkmale"("id") ON DELETE cascade ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
|
||||||
|
DO $$ BEGIN ALTER TABLE "equipment_category_merkmale" ADD CONSTRAINT "equipment_category_merkmale_category_id_equipment_categories_id_fk" FOREIGN KEY ("category_id") REFERENCES "public"."equipment_categories"("id") ON DELETE cascade ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
|
||||||
|
DO $$ BEGIN ALTER TABLE "equipment_category_merkmale" ADD CONSTRAINT "equipment_category_merkmale_merkmal_id_merkmale_id_fk" FOREIGN KEY ("merkmal_id") REFERENCES "public"."merkmale"("id") ON DELETE cascade ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
|
||||||
|
DO $$ BEGIN ALTER TABLE "equipment" ADD CONSTRAINT "equipment_brigade_id_brigades_id_fk" FOREIGN KEY ("brigade_id") REFERENCES "public"."brigades"("id") ON DELETE cascade ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
|
||||||
|
DO $$ BEGIN ALTER TABLE "equipment" ADD CONSTRAINT "equipment_category_id_equipment_categories_id_fk" FOREIGN KEY ("category_id") REFERENCES "public"."equipment_categories"("id") ON DELETE restrict ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
|
||||||
|
DO $$ BEGIN ALTER TABLE "equipment" ADD CONSTRAINT "equipment_vehicle_id_vehicles_id_fk" FOREIGN KEY ("vehicle_id") REFERENCES "public"."vehicles"("id") ON DELETE set null ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
|
||||||
|
DO $$ BEGIN ALTER TABLE "vehicles" ADD CONSTRAINT "vehicles_brigade_id_brigades_id_fk" FOREIGN KEY ("brigade_id") REFERENCES "public"."brigades"("id") ON DELETE cascade ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
|
||||||
|
DO $$ BEGIN ALTER TABLE "vehicles" ADD CONSTRAINT "vehicles_template_id_vehicle_templates_id_fk" FOREIGN KEY ("template_id") REFERENCES "public"."vehicle_templates"("id") ON DELETE set null ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
|
||||||
|
DO $$ BEGIN ALTER TABLE "merkmal_values" ADD CONSTRAINT "merkmal_values_merkmal_id_merkmale_id_fk" FOREIGN KEY ("merkmal_id") REFERENCES "public"."merkmale"("id") ON DELETE cascade ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
|
||||||
|
DO $$ BEGIN ALTER TABLE "audit_log" ADD CONSTRAINT "audit_log_actor_user_id_users_id_fk" FOREIGN KEY ("actor_user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$;--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "brigades_latlng_idx" ON "brigades" USING btree ("lat","lng");--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "merkmal_optionen_merkmal_idx" ON "merkmal_optionen" USING btree ("merkmal_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "merkmale_status_idx" ON "merkmale" USING btree ("status");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "merkmale_active_name_uq" ON "merkmale" USING btree ("name") WHERE "merkmale"."status" = 'active';--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "vtm_merkmal_idx" ON "vehicle_template_merkmale" USING btree ("merkmal_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "ecm_merkmal_idx" ON "equipment_category_merkmale" USING btree ("merkmal_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "equipment_brigade_idx" ON "equipment" USING btree ("brigade_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "equipment_category_idx" ON "equipment" USING btree ("category_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "equipment_vehicle_idx" ON "equipment" USING btree ("vehicle_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "vehicles_brigade_idx" ON "vehicles" USING btree ("brigade_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "vehicles_template_idx" ON "vehicles" USING btree ("template_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "mv_merkmal_num_idx" ON "merkmal_values" USING btree ("merkmal_id","value_num");--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "mv_merkmal_bool_idx" ON "merkmal_values" USING btree ("merkmal_id","value_bool");--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "mv_merkmal_text_idx" ON "merkmal_values" USING btree ("merkmal_id","value_text");--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "mv_entity_idx" ON "merkmal_values" USING btree ("entity_typ","entity_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "login_attempts_key_zeit_idx" ON "login_attempts" USING btree ("key","zeitpunkt");--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "audit_log_zeitpunkt_idx" ON "audit_log" USING btree ("zeitpunkt");--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "audit_log_aktion_idx" ON "audit_log" USING btree ("aktion");
|
||||||
1474
drizzle/meta/0000_snapshot.json
Normal file
1474
drizzle/meta/0000_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
13
drizzle/meta/_journal.json
Normal file
13
drizzle/meta/_journal.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"idx": 0,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1780936243787,
|
||||||
|
"tag": "0000_opposite_santa_claus",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
529
package-lock.json
generated
529
package-lock.json
generated
@@ -39,6 +39,7 @@
|
|||||||
"prettier": "^3.5.2",
|
"prettier": "^3.5.2",
|
||||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
|
"tsx": "^4.19.2",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^5.7.3",
|
||||||
"vitest": "^3.0.7"
|
"vitest": "^3.0.7"
|
||||||
}
|
}
|
||||||
@@ -62,29 +63,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/@emnapi/core": {
|
|
||||||
"version": "1.10.0",
|
|
||||||
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@emnapi/core/-/core-1.10.0.tgz",
|
|
||||||
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
|
||||||
"@emnapi/wasi-threads": "1.2.1",
|
|
||||||
"tslib": "^2.4.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@emnapi/runtime": {
|
|
||||||
"version": "1.11.0",
|
|
||||||
"resolved": "https://npm.apple.com/@emnapi/runtime/-/runtime-1.11.0.tgz",
|
|
||||||
"integrity": "sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg==",
|
|
||||||
"optional": true,
|
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
|
||||||
"tslib": "^2.4.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@emnapi/wasi-threads": {
|
"node_modules/@emnapi/wasi-threads": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
|
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
|
||||||
@@ -5292,6 +5270,7 @@
|
|||||||
"resolved": "https://npm.apple.com/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz",
|
"resolved": "https://npm.apple.com/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz",
|
||||||
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@rtsao/scc": "^1.1.0",
|
"@rtsao/scc": "^1.1.0",
|
||||||
"array-includes": "^3.1.9",
|
"array-includes": "^3.1.9",
|
||||||
@@ -8746,6 +8725,509 @@
|
|||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
"license": "0BSD"
|
"license": "0BSD"
|
||||||
},
|
},
|
||||||
|
"node_modules/tsx": {
|
||||||
|
"version": "4.22.4",
|
||||||
|
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/tsx/-/tsx-4.22.4.tgz",
|
||||||
|
"integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"esbuild": "~0.28.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"tsx": "dist/cli.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "~2.3.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tsx/node_modules/@esbuild/aix-ppc64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"aix"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tsx/node_modules/@esbuild/android-arm": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@esbuild/android-arm/-/android-arm-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tsx/node_modules/@esbuild/android-arm64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tsx/node_modules/@esbuild/android-x64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@esbuild/android-x64/-/android-x64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tsx/node_modules/@esbuild/darwin-arm64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tsx/node_modules/@esbuild/darwin-x64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tsx/node_modules/@esbuild/freebsd-arm64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://npm.apple.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tsx/node_modules/@esbuild/freebsd-x64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tsx/node_modules/@esbuild/linux-arm": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tsx/node_modules/@esbuild/linux-arm64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tsx/node_modules/@esbuild/linux-ia32": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tsx/node_modules/@esbuild/linux-loong64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==",
|
||||||
|
"cpu": [
|
||||||
|
"loong64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tsx/node_modules/@esbuild/linux-mips64el": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==",
|
||||||
|
"cpu": [
|
||||||
|
"mips64el"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tsx/node_modules/@esbuild/linux-ppc64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tsx/node_modules/@esbuild/linux-riscv64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tsx/node_modules/@esbuild/linux-s390x": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tsx/node_modules/@esbuild/linux-x64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tsx/node_modules/@esbuild/netbsd-arm64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://npm.apple.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tsx/node_modules/@esbuild/netbsd-x64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tsx/node_modules/@esbuild/openbsd-arm64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tsx/node_modules/@esbuild/openbsd-x64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tsx/node_modules/@esbuild/openharmony-arm64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://npm.apple.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openharmony"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tsx/node_modules/@esbuild/sunos-x64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"sunos"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tsx/node_modules/@esbuild/win32-arm64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tsx/node_modules/@esbuild/win32-ia32": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tsx/node_modules/@esbuild/win32-x64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tsx/node_modules/esbuild": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://npm.apple.com/esbuild/-/esbuild-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"bin": {
|
||||||
|
"esbuild": "bin/esbuild"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@esbuild/aix-ppc64": "0.28.0",
|
||||||
|
"@esbuild/android-arm": "0.28.0",
|
||||||
|
"@esbuild/android-arm64": "0.28.0",
|
||||||
|
"@esbuild/android-x64": "0.28.0",
|
||||||
|
"@esbuild/darwin-arm64": "0.28.0",
|
||||||
|
"@esbuild/darwin-x64": "0.28.0",
|
||||||
|
"@esbuild/freebsd-arm64": "0.28.0",
|
||||||
|
"@esbuild/freebsd-x64": "0.28.0",
|
||||||
|
"@esbuild/linux-arm": "0.28.0",
|
||||||
|
"@esbuild/linux-arm64": "0.28.0",
|
||||||
|
"@esbuild/linux-ia32": "0.28.0",
|
||||||
|
"@esbuild/linux-loong64": "0.28.0",
|
||||||
|
"@esbuild/linux-mips64el": "0.28.0",
|
||||||
|
"@esbuild/linux-ppc64": "0.28.0",
|
||||||
|
"@esbuild/linux-riscv64": "0.28.0",
|
||||||
|
"@esbuild/linux-s390x": "0.28.0",
|
||||||
|
"@esbuild/linux-x64": "0.28.0",
|
||||||
|
"@esbuild/netbsd-arm64": "0.28.0",
|
||||||
|
"@esbuild/netbsd-x64": "0.28.0",
|
||||||
|
"@esbuild/openbsd-arm64": "0.28.0",
|
||||||
|
"@esbuild/openbsd-x64": "0.28.0",
|
||||||
|
"@esbuild/openharmony-arm64": "0.28.0",
|
||||||
|
"@esbuild/sunos-x64": "0.28.0",
|
||||||
|
"@esbuild/win32-arm64": "0.28.0",
|
||||||
|
"@esbuild/win32-ia32": "0.28.0",
|
||||||
|
"@esbuild/win32-x64": "0.28.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/type-check": {
|
"node_modules/type-check": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://npm.apple.com/type-check/-/type-check-0.4.0.tgz",
|
"resolved": "https://npm.apple.com/type-check/-/type-check-0.4.0.tgz",
|
||||||
@@ -9004,6 +9486,7 @@
|
|||||||
"resolved": "https://npm.apple.com/vite/-/vite-7.3.5.tgz",
|
"resolved": "https://npm.apple.com/vite/-/vite-7.3.5.tgz",
|
||||||
"integrity": "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==",
|
"integrity": "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.27.0",
|
"esbuild": "^0.27.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
|
|||||||
@@ -12,7 +12,9 @@
|
|||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:migrate": "drizzle-kit migrate",
|
"db:migrate": "tsx scripts/migrate.ts",
|
||||||
|
"db:push": "drizzle-kit push",
|
||||||
|
"db:studio": "drizzle-kit studio",
|
||||||
"db:check": "drizzle-kit check"
|
"db:check": "drizzle-kit check"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -47,6 +49,7 @@
|
|||||||
"prettier": "^3.5.2",
|
"prettier": "^3.5.2",
|
||||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
|
"tsx": "^4.19.2",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^5.7.3",
|
||||||
"vitest": "^3.0.7"
|
"vitest": "^3.0.7"
|
||||||
}
|
}
|
||||||
|
|||||||
33
scripts/migrate.ts
Normal file
33
scripts/migrate.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { drizzle } from "drizzle-orm/node-postgres";
|
||||||
|
import { migrate } from "drizzle-orm/node-postgres/migrator";
|
||||||
|
import { Pool } from "pg";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Programmatischer Migrations-Runner.
|
||||||
|
*
|
||||||
|
* Verwendet `drizzle-orm/node-postgres/migrator` mit dem Drizzle-Journal,
|
||||||
|
* sodass mehrfaches Ausführen idempotent ist (bereits angewandte Migrationen
|
||||||
|
* werden übersprungen). Liest `DATABASE_URL` direkt aus der Umgebung, um nicht
|
||||||
|
* von der Next.js-Env-Validierung abhängig zu sein.
|
||||||
|
*/
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const connectionString = process.env.DATABASE_URL;
|
||||||
|
if (!connectionString) {
|
||||||
|
throw new Error("DATABASE_URL ist nicht gesetzt.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const pool = new Pool({ connectionString, max: 1 });
|
||||||
|
const db = drizzle(pool);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await migrate(db, { migrationsFolder: "./drizzle" });
|
||||||
|
console.log("Migrationen erfolgreich angewandt.");
|
||||||
|
} finally {
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err: unknown) => {
|
||||||
|
console.error("Migration fehlgeschlagen:", err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -18,7 +18,7 @@ function createPool(): Pool {
|
|||||||
return new Pool({ connectionString: env.DATABASE_URL });
|
return new Pool({ connectionString: env.DATABASE_URL });
|
||||||
}
|
}
|
||||||
|
|
||||||
const pool =
|
export const pool =
|
||||||
env.NODE_ENV === "production"
|
env.NODE_ENV === "production"
|
||||||
? createPool()
|
? createPool()
|
||||||
: (globalThis.__floriannetzPool ??= createPool());
|
: (globalThis.__floriannetzPool ??= createPool());
|
||||||
|
|||||||
4
src/db/index.ts
Normal file
4
src/db/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
// Kanonischer DB-Einstiegspunkt (Workstream 2).
|
||||||
|
// Re-exportiert den Pool-/Drizzle-Singleton sowie das gesamte Schema.
|
||||||
|
export { db, pool, type DB } from "./client";
|
||||||
|
export * as schema from "./schema";
|
||||||
158
src/db/schema/__tests__/db-roundtrip.test.ts
Normal file
158
src/db/schema/__tests__/db-roundtrip.test.ts
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||||
|
import { drizzle } from "drizzle-orm/node-postgres";
|
||||||
|
import { migrate } from "drizzle-orm/node-postgres/migrator";
|
||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
import { Pool } from "pg";
|
||||||
|
import * as schema from "@/db/schema";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live-Datenbank-Tests (Postgres erforderlich).
|
||||||
|
*
|
||||||
|
* Diese Tests werden ÜBERSPRUNGEN, wenn keine erreichbare Datenbank vorhanden
|
||||||
|
* ist (z. B. wenn DATABASE_URL fehlt oder der Connect fehlschlägt). Bei
|
||||||
|
* vorhandener DB prüfen sie Enums, Indizes, den partiellen Unique-Index, den
|
||||||
|
* EAV-Round-Trip und FK-Verletzungen anhand echter SQL-Ausführung.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const url = process.env.DATABASE_URL;
|
||||||
|
let pool: Pool | undefined;
|
||||||
|
let db: ReturnType<typeof drizzle> | undefined;
|
||||||
|
let dbReachable = false;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
if (!url) return;
|
||||||
|
try {
|
||||||
|
pool = new Pool({ connectionString: url, max: 1, connectionTimeoutMillis: 2000 });
|
||||||
|
const client = await pool.connect();
|
||||||
|
client.release();
|
||||||
|
db = drizzle(pool, { schema });
|
||||||
|
await migrate(db, { migrationsFolder: "./drizzle" });
|
||||||
|
dbReachable = true;
|
||||||
|
} catch {
|
||||||
|
dbReachable = false;
|
||||||
|
if (pool) await pool.end().catch(() => {});
|
||||||
|
pool = undefined;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (pool) await pool.end().catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
const dbIt: typeof it = ((...args: Parameters<typeof it>) =>
|
||||||
|
(dbReachable ? it : it.skip)(...args)) as typeof it;
|
||||||
|
|
||||||
|
describe("Live-DB: Schema", () => {
|
||||||
|
dbIt("asset_status enthält die drei Werte", async () => {
|
||||||
|
const res = await db!.execute(
|
||||||
|
sql`SELECT enum_range(NULL::asset_status)::text AS r`,
|
||||||
|
);
|
||||||
|
const r = (res.rows[0] as { r: string }).r;
|
||||||
|
for (const v of ["einsatzbereit", "wartung", "ausser_dienst"]) {
|
||||||
|
expect(r).toContain(v);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
dbIt("alle vier mv_*-Indizes existieren", async () => {
|
||||||
|
const res = await db!.execute(
|
||||||
|
sql`SELECT indexname FROM pg_indexes WHERE tablename = 'merkmal_values'`,
|
||||||
|
);
|
||||||
|
const names = new Set(res.rows.map((r) => (r as { indexname: string }).indexname));
|
||||||
|
for (const idx of [
|
||||||
|
"mv_merkmal_num_idx",
|
||||||
|
"mv_merkmal_bool_idx",
|
||||||
|
"mv_merkmal_text_idx",
|
||||||
|
"mv_entity_idx",
|
||||||
|
]) {
|
||||||
|
expect(names).toContain(idx);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
dbIt("merkmale_active_name_uq ist partiell (WHERE status='active')", async () => {
|
||||||
|
const res = await db!.execute(
|
||||||
|
sql`SELECT indexdef FROM pg_indexes WHERE indexname = 'merkmale_active_name_uq'`,
|
||||||
|
);
|
||||||
|
expect(res.rows.length).toBe(1);
|
||||||
|
const def = (res.rows[0] as { indexdef: string }).indexdef.toLowerCase();
|
||||||
|
expect(def).toContain("unique");
|
||||||
|
expect(def).toContain("where");
|
||||||
|
expect(def).toContain("active");
|
||||||
|
});
|
||||||
|
|
||||||
|
dbIt("login_attempts_key_zeit_idx existiert", async () => {
|
||||||
|
const res = await db!.execute(
|
||||||
|
sql`SELECT 1 FROM pg_indexes WHERE indexname = 'login_attempts_key_zeit_idx'`,
|
||||||
|
);
|
||||||
|
expect(res.rows.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
dbIt(
|
||||||
|
"zwei active-Merkmale gleichen Namens scheitern, zwei proposed gelingen",
|
||||||
|
async () => {
|
||||||
|
const base = `roundtrip_${Date.now()}`;
|
||||||
|
// zwei proposed gleichen Namens -> erlaubt
|
||||||
|
await db!.insert(schema.merkmale).values([
|
||||||
|
{ slug: `${base}_p1`, name: `${base}_dup`, typ: "text", geltungsbereich: "both", status: "proposed" },
|
||||||
|
{ slug: `${base}_p2`, name: `${base}_dup`, typ: "text", geltungsbereich: "both", status: "proposed" },
|
||||||
|
]);
|
||||||
|
// erstes active -> ok
|
||||||
|
await db!.insert(schema.merkmale).values({
|
||||||
|
slug: `${base}_a1`, name: `${base}_act`, typ: "text", geltungsbereich: "both", status: "active",
|
||||||
|
});
|
||||||
|
// zweites active gleichen Namens -> Verletzung
|
||||||
|
await expect(
|
||||||
|
db!.insert(schema.merkmale).values({
|
||||||
|
slug: `${base}_a2`, name: `${base}_act`, typ: "text", geltungsbereich: "both", status: "active",
|
||||||
|
}),
|
||||||
|
).rejects.toThrow();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
dbIt("EAV-Round-Trip: Brigade->Merkmal->Vehicle->value_num=2000", async () => {
|
||||||
|
const stamp = Date.now();
|
||||||
|
const [brigade] = await db!
|
||||||
|
.insert(schema.brigades)
|
||||||
|
.values({ name: `RT-Wehr-${stamp}` })
|
||||||
|
.returning();
|
||||||
|
const [merkmal] = await db!
|
||||||
|
.insert(schema.merkmale)
|
||||||
|
.values({
|
||||||
|
slug: `rt_tank_${stamp}`,
|
||||||
|
name: `RT Tankinhalt ${stamp}`,
|
||||||
|
typ: "number",
|
||||||
|
einheit: "l",
|
||||||
|
geltungsbereich: "vehicle",
|
||||||
|
status: "active",
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
const [vehicle] = await db!
|
||||||
|
.insert(schema.vehicles)
|
||||||
|
.values({ brigadeId: brigade!.id, name: `RT-TLFA-${stamp}`, funkrufname: "Florian RT 1" })
|
||||||
|
.returning();
|
||||||
|
await db!.insert(schema.merkmalValues).values({
|
||||||
|
merkmalId: merkmal!.id,
|
||||||
|
entityTyp: "vehicle",
|
||||||
|
entityId: vehicle!.id,
|
||||||
|
valueNum: 2000,
|
||||||
|
});
|
||||||
|
const res = await db!.execute(
|
||||||
|
sql`SELECT value_num FROM merkmal_values WHERE entity_id = ${vehicle!.id}`,
|
||||||
|
);
|
||||||
|
expect(Number((res.rows[0] as { value_num: number }).value_num)).toBe(2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
dbIt("FK-Verletzung wirft 23503", async () => {
|
||||||
|
let code: string | undefined;
|
||||||
|
try {
|
||||||
|
await db!.insert(schema.merkmalValues).values({
|
||||||
|
merkmalId: "00000000-0000-0000-0000-000000000000",
|
||||||
|
entityTyp: "vehicle",
|
||||||
|
entityId: "00000000-0000-0000-0000-000000000000",
|
||||||
|
valueNum: 1,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
code = (e as { code?: string }).code;
|
||||||
|
}
|
||||||
|
expect(code).toBe("23503");
|
||||||
|
});
|
||||||
|
});
|
||||||
77
src/db/schema/__tests__/migration.test.ts
Normal file
77
src/db/schema/__tests__/migration.test.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { readFileSync, readdirSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Statische Migrations-Tests (laufen ohne Datenbank).
|
||||||
|
* Verifizieren, dass die EINZIGE initiale Migration die geforderten Strukturen
|
||||||
|
* und die Idempotenz-Absicherungen (DO-Block, IF NOT EXISTS, partieller Index)
|
||||||
|
* enthält.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const drizzleDir = join(process.cwd(), "drizzle");
|
||||||
|
|
||||||
|
function migrationSql(): string {
|
||||||
|
const files = readdirSync(drizzleDir).filter((f) => f.endsWith(".sql"));
|
||||||
|
expect(files.length).toBe(1); // genau EINE initiale Migration (alleiniger Eigentümer)
|
||||||
|
return readFileSync(join(drizzleDir, files[0]!), "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Initiale Migration", () => {
|
||||||
|
const sql = migrationSql();
|
||||||
|
|
||||||
|
it("erzeugt merkmal_values und alle 14 Tabellen idempotent", () => {
|
||||||
|
expect(sql).toContain('CREATE TABLE IF NOT EXISTS "merkmal_values"');
|
||||||
|
const tableCount = (sql.match(/CREATE TABLE IF NOT EXISTS/g) ?? []).length;
|
||||||
|
expect(tableCount).toBe(14);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("definiert 7 Enums in idempotenten DO-Blöcken", () => {
|
||||||
|
for (const t of [
|
||||||
|
"asset_status",
|
||||||
|
"auth_typ",
|
||||||
|
"entity_typ",
|
||||||
|
"geltungsbereich",
|
||||||
|
"merkmal_status",
|
||||||
|
"merkmal_typ",
|
||||||
|
"role",
|
||||||
|
]) {
|
||||||
|
expect(sql).toContain(`CREATE TYPE "public"."${t}"`);
|
||||||
|
}
|
||||||
|
const enumDoBlocks = (
|
||||||
|
sql.match(/DO \$\$ BEGIN CREATE TYPE/g) ?? []
|
||||||
|
).length;
|
||||||
|
expect(enumDoBlocks).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hat die vier merkmal_values-Indizes (IF NOT EXISTS)", () => {
|
||||||
|
for (const idx of [
|
||||||
|
"mv_merkmal_num_idx",
|
||||||
|
"mv_merkmal_bool_idx",
|
||||||
|
"mv_merkmal_text_idx",
|
||||||
|
"mv_entity_idx",
|
||||||
|
]) {
|
||||||
|
expect(sql).toContain(`CREATE INDEX IF NOT EXISTS "${idx}"`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enthält den partiellen Unique-Index merkmale_active_name_uq WHERE status='active'", () => {
|
||||||
|
expect(sql).toMatch(
|
||||||
|
/CREATE UNIQUE INDEX IF NOT EXISTS "merkmale_active_name_uq" ON "merkmale"[^;]*WHERE[^;]*=\s*'active'/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enthält brigades_latlng_idx und login_attempts_key_zeit_idx", () => {
|
||||||
|
expect(sql).toContain('CREATE INDEX IF NOT EXISTS "brigades_latlng_idx"');
|
||||||
|
expect(sql).toContain(
|
||||||
|
'CREATE INDEX IF NOT EXISTS "login_attempts_key_zeit_idx"',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sichert Fremdschlüssel idempotent über DO-Blöcke ab", () => {
|
||||||
|
const fkDoBlocks = (
|
||||||
|
sql.match(/DO \$\$ BEGIN ALTER TABLE/g) ?? []
|
||||||
|
).length;
|
||||||
|
expect(fkDoBlocks).toBe(15);
|
||||||
|
});
|
||||||
|
});
|
||||||
236
src/db/schema/__tests__/schema.test.ts
Normal file
236
src/db/schema/__tests__/schema.test.ts
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { getTableConfig } from "drizzle-orm/pg-core";
|
||||||
|
import * as schema from "@/db/schema";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Statische Schema-Tests (laufen ohne laufende Datenbank).
|
||||||
|
*
|
||||||
|
* Diese Tests prüfen die Drizzle-Schema-Definition strukturell:
|
||||||
|
* Tabellen, Spalten, Enums, Indizes, Uniques, Fremdschlüssel.
|
||||||
|
* Der EAV-Round-Trip und der partielle Unique-Index werden zusätzlich
|
||||||
|
* in der Migrations-SQL (migration.test.ts) bzw. gegen eine echte DB
|
||||||
|
* (db-roundtrip.test.ts) verifiziert.
|
||||||
|
*/
|
||||||
|
|
||||||
|
describe("Enums", () => {
|
||||||
|
it("definiert role mit den drei Rollen und DB-Name 'role'", () => {
|
||||||
|
expect(schema.roleEnum.enumName).toBe("role");
|
||||||
|
expect(schema.roleEnum.enumValues).toEqual([
|
||||||
|
"platform_admin",
|
||||||
|
"wehr_admin",
|
||||||
|
"wehr_read",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("definiert auth_typ", () => {
|
||||||
|
expect(schema.authTypEnum.enumName).toBe("auth_typ");
|
||||||
|
expect(schema.authTypEnum.enumValues).toEqual(["authentik", "local"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("definiert asset_status mit allen drei Werten", () => {
|
||||||
|
expect(schema.assetStatusEnum.enumName).toBe("asset_status");
|
||||||
|
expect(schema.assetStatusEnum.enumValues).toEqual([
|
||||||
|
"einsatzbereit",
|
||||||
|
"wartung",
|
||||||
|
"ausser_dienst",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("definiert merkmal_typ, merkmal_status, geltungsbereich, entity_typ", () => {
|
||||||
|
expect(schema.merkmalTypEnum.enumName).toBe("merkmal_typ");
|
||||||
|
expect(schema.merkmalTypEnum.enumValues).toEqual([
|
||||||
|
"number",
|
||||||
|
"enum",
|
||||||
|
"boolean",
|
||||||
|
"text",
|
||||||
|
]);
|
||||||
|
expect(schema.merkmalStatusEnum.enumName).toBe("merkmal_status");
|
||||||
|
expect(schema.merkmalStatusEnum.enumValues).toEqual(["active", "proposed"]);
|
||||||
|
expect(schema.geltungsbereichEnum.enumName).toBe("geltungsbereich");
|
||||||
|
expect(schema.geltungsbereichEnum.enumValues).toEqual([
|
||||||
|
"vehicle",
|
||||||
|
"equipment",
|
||||||
|
"both",
|
||||||
|
]);
|
||||||
|
expect(schema.entityTypEnum.enumName).toBe("entity_typ");
|
||||||
|
expect(schema.entityTypEnum.enumValues).toEqual(["vehicle", "equipment"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hat genau 7 Enums", () => {
|
||||||
|
const enumExports = [
|
||||||
|
schema.roleEnum,
|
||||||
|
schema.authTypEnum,
|
||||||
|
schema.merkmalTypEnum,
|
||||||
|
schema.merkmalStatusEnum,
|
||||||
|
schema.geltungsbereichEnum,
|
||||||
|
schema.assetStatusEnum,
|
||||||
|
schema.entityTypEnum,
|
||||||
|
];
|
||||||
|
const names = new Set(enumExports.map((e) => e.enumName));
|
||||||
|
expect(names.size).toBe(7);
|
||||||
|
expect(names).toEqual(
|
||||||
|
new Set([
|
||||||
|
"role",
|
||||||
|
"auth_typ",
|
||||||
|
"merkmal_typ",
|
||||||
|
"merkmal_status",
|
||||||
|
"geltungsbereich",
|
||||||
|
"asset_status",
|
||||||
|
"entity_typ",
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("merkmal_values (EAV)", () => {
|
||||||
|
it("hat die vier geforderten Indizes", () => {
|
||||||
|
const cfg = getTableConfig(schema.merkmalValues);
|
||||||
|
const idxNames = new Set(cfg.indexes.map((i) => i.config.name));
|
||||||
|
expect(idxNames).toContain("mv_merkmal_num_idx");
|
||||||
|
expect(idxNames).toContain("mv_merkmal_bool_idx");
|
||||||
|
expect(idxNames).toContain("mv_merkmal_text_idx");
|
||||||
|
expect(idxNames).toContain("mv_entity_idx");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hat typisierte Wert-Spalten und polymorphe entity-Spalten", () => {
|
||||||
|
const cfg = getTableConfig(schema.merkmalValues);
|
||||||
|
const cols = new Set(cfg.columns.map((c) => c.name));
|
||||||
|
for (const c of [
|
||||||
|
"merkmal_id",
|
||||||
|
"entity_typ",
|
||||||
|
"entity_id",
|
||||||
|
"value_num",
|
||||||
|
"value_text",
|
||||||
|
"value_bool",
|
||||||
|
]) {
|
||||||
|
expect(cols).toContain(c);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("users", () => {
|
||||||
|
it("hat funkrufname NICHT (Funkrufname lebt auf vehicles)", () => {
|
||||||
|
const cfg = getTableConfig(schema.users);
|
||||||
|
const cols = new Set(cfg.columns.map((c) => c.name));
|
||||||
|
expect(cols.has("funkrufname")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hat Unique auf email und nullbares brigade_id + passwort_hash", () => {
|
||||||
|
const cfg = getTableConfig(schema.users);
|
||||||
|
const uqNames = new Set(cfg.uniqueConstraints.map((u) => u.name));
|
||||||
|
expect(uqNames).toContain("users_email_uq");
|
||||||
|
const brigade = cfg.columns.find((c) => c.name === "brigade_id");
|
||||||
|
const hash = cfg.columns.find((c) => c.name === "passwort_hash");
|
||||||
|
expect(brigade?.notNull).toBe(false);
|
||||||
|
expect(hash?.notNull).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("vehicles", () => {
|
||||||
|
it("hat funkrufname als Spalte und status-Enum", () => {
|
||||||
|
const cfg = getTableConfig(schema.vehicles);
|
||||||
|
const cols = cfg.columns;
|
||||||
|
const names = new Set(cols.map((c) => c.name));
|
||||||
|
expect(names).toContain("funkrufname");
|
||||||
|
const status = cols.find((c) => c.name === "status");
|
||||||
|
expect(status?.enumValues).toEqual([
|
||||||
|
"einsatzbereit",
|
||||||
|
"wartung",
|
||||||
|
"ausser_dienst",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("merkmale", () => {
|
||||||
|
it("hat slug NOT NULL UNIQUE", () => {
|
||||||
|
const cfg = getTableConfig(schema.merkmale);
|
||||||
|
const slug = cfg.columns.find((c) => c.name === "slug");
|
||||||
|
expect(slug?.notNull).toBe(true);
|
||||||
|
const uqNames = new Set(cfg.uniqueConstraints.map((u) => u.name));
|
||||||
|
expect(uqNames).toContain("merkmale_slug_uq");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("vehicle_template_aliasse", () => {
|
||||||
|
it("hat bestaetigt-Flag und Unique(template_id, alias) — kein jsonb", () => {
|
||||||
|
const cfg = getTableConfig(schema.vehicleTemplateAliasse);
|
||||||
|
const cols = cfg.columns;
|
||||||
|
const names = new Set(cols.map((c) => c.name));
|
||||||
|
expect(names).toContain("bestaetigt");
|
||||||
|
expect(names).toContain("alias");
|
||||||
|
// kein jsonb auf der Tabelle
|
||||||
|
expect(cols.some((c) => c.dataType === "json")).toBe(false);
|
||||||
|
const uqNames = new Set(cfg.uniqueConstraints.map((u) => u.name));
|
||||||
|
expect(uqNames).toContain("vehicle_template_aliasse_uq");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("vehicle_templates", () => {
|
||||||
|
it("hat Unique auf code", () => {
|
||||||
|
const cfg = getTableConfig(schema.vehicleTemplates);
|
||||||
|
const uqNames = new Set(cfg.uniqueConstraints.map((u) => u.name));
|
||||||
|
expect(uqNames).toContain("vehicle_templates_code_uq");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("vehicle_template_merkmale", () => {
|
||||||
|
it("hat drei typisierte Vorgabewert-Spalten", () => {
|
||||||
|
const cfg = getTableConfig(schema.vehicleTemplateMerkmale);
|
||||||
|
const names = new Set(cfg.columns.map((c) => c.name));
|
||||||
|
expect(names).toContain("vorgabewert_num");
|
||||||
|
expect(names).toContain("vorgabewert_text");
|
||||||
|
expect(names).toContain("vorgabewert_bool");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("brigades", () => {
|
||||||
|
it("hat lat/lng, geocode-Felder, wehrfuehrer (ASCII) und bundesland-Default", () => {
|
||||||
|
const cfg = getTableConfig(schema.brigades);
|
||||||
|
const names = new Set(cfg.columns.map((c) => c.name));
|
||||||
|
for (const c of [
|
||||||
|
"lat",
|
||||||
|
"lng",
|
||||||
|
"geocode_query",
|
||||||
|
"geocoded_at",
|
||||||
|
"geocode_status",
|
||||||
|
"wehrfuehrer",
|
||||||
|
"funkrufname_schema",
|
||||||
|
"aktiv",
|
||||||
|
"bundesland",
|
||||||
|
]) {
|
||||||
|
expect(names).toContain(c);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("login_attempts & audit_log", () => {
|
||||||
|
it("login_attempts hat key/erfolg/zeitpunkt + Index", () => {
|
||||||
|
const cfg = getTableConfig(schema.loginAttempts);
|
||||||
|
const names = new Set(cfg.columns.map((c) => c.name));
|
||||||
|
for (const c of ["key", "erfolg", "zeitpunkt"]) {
|
||||||
|
expect(names).toContain(c);
|
||||||
|
}
|
||||||
|
const idxNames = new Set(cfg.indexes.map((i) => i.config.name));
|
||||||
|
expect(idxNames).toContain("login_attempts_key_zeit_idx");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("audit_log hat details jsonb und Indizes", () => {
|
||||||
|
const cfg = getTableConfig(schema.auditLog);
|
||||||
|
const names = new Set(cfg.columns.map((c) => c.name));
|
||||||
|
for (const c of ["actor_user_id", "aktion", "ziel_typ", "ziel_id", "details", "zeitpunkt"]) {
|
||||||
|
expect(names).toContain(c);
|
||||||
|
}
|
||||||
|
const details = cfg.columns.find((c) => c.name === "details");
|
||||||
|
expect(details?.dataType).toBe("json");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("equipment", () => {
|
||||||
|
it("hat nullbares vehicle_id (= im Gerätehaus) und category_id", () => {
|
||||||
|
const cfg = getTableConfig(schema.equipment);
|
||||||
|
const names = new Set(cfg.columns.map((c) => c.name));
|
||||||
|
expect(names).toContain("category_id");
|
||||||
|
const vehicleId = cfg.columns.find((c) => c.name === "vehicle_id");
|
||||||
|
expect(vehicleId?.notNull).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
68
src/db/schema/assets.ts
Normal file
68
src/db/schema/assets.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import {
|
||||||
|
pgTable,
|
||||||
|
uuid,
|
||||||
|
text,
|
||||||
|
timestamp,
|
||||||
|
index,
|
||||||
|
} from "drizzle-orm/pg-core";
|
||||||
|
import { brigades } from "./brigades";
|
||||||
|
import { vehicleTemplates } from "./templates";
|
||||||
|
import { equipmentCategories } from "./equipment-categories";
|
||||||
|
import { assetStatusEnum } from "./enums";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fahrzeuge. `templateId` -> set null (Eigenbau). `funkrufname` ist SPALTE
|
||||||
|
* (kein Merkmal). `status` über asset_status-Enum.
|
||||||
|
*/
|
||||||
|
export const vehicles = pgTable(
|
||||||
|
"vehicles",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
brigadeId: uuid("brigade_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => brigades.id, { onDelete: "cascade" }),
|
||||||
|
templateId: uuid("template_id").references(() => vehicleTemplates.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
funkrufname: text("funkrufname"),
|
||||||
|
status: assetStatusEnum("status").notNull().default("einsatzbereit"),
|
||||||
|
notiz: text("notiz"),
|
||||||
|
erstelltAm: timestamp("erstellt_am", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
},
|
||||||
|
(t) => ({
|
||||||
|
brigadeIdx: index("vehicles_brigade_idx").on(t.brigadeId),
|
||||||
|
templateIdx: index("vehicles_template_idx").on(t.templateId),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Geräte/Beladung. `vehicleId = NULL` => im Gerätehaus.
|
||||||
|
*/
|
||||||
|
export const equipment = pgTable(
|
||||||
|
"equipment",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
brigadeId: uuid("brigade_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => brigades.id, { onDelete: "cascade" }),
|
||||||
|
categoryId: uuid("category_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => equipmentCategories.id, { onDelete: "restrict" }),
|
||||||
|
vehicleId: uuid("vehicle_id").references(() => vehicles.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
status: assetStatusEnum("status").notNull().default("einsatzbereit"),
|
||||||
|
erstelltAm: timestamp("erstellt_am", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
},
|
||||||
|
(t) => ({
|
||||||
|
brigadeIdx: index("equipment_brigade_idx").on(t.brigadeId),
|
||||||
|
categoryIdx: index("equipment_category_idx").on(t.categoryId),
|
||||||
|
vehicleIdx: index("equipment_vehicle_idx").on(t.vehicleId),
|
||||||
|
}),
|
||||||
|
);
|
||||||
34
src/db/schema/audit.ts
Normal file
34
src/db/schema/audit.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import {
|
||||||
|
pgTable,
|
||||||
|
uuid,
|
||||||
|
text,
|
||||||
|
jsonb,
|
||||||
|
timestamp,
|
||||||
|
index,
|
||||||
|
} from "drizzle-orm/pg-core";
|
||||||
|
import { users } from "./users";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Audit-Log für Admin-/Bereitstellungsaktionen. `actorUserId` -> set null,
|
||||||
|
* damit Historie auch nach Benutzerlöschung erhalten bleibt.
|
||||||
|
*/
|
||||||
|
export const auditLog = pgTable(
|
||||||
|
"audit_log",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
actorUserId: uuid("actor_user_id").references(() => users.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
|
aktion: text("aktion").notNull(),
|
||||||
|
zielTyp: text("ziel_typ"),
|
||||||
|
zielId: uuid("ziel_id"),
|
||||||
|
details: jsonb("details"),
|
||||||
|
zeitpunkt: timestamp("zeitpunkt", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
},
|
||||||
|
(t) => ({
|
||||||
|
zeitpunktIdx: index("audit_log_zeitpunkt_idx").on(t.zeitpunkt),
|
||||||
|
aktionIdx: index("audit_log_aktion_idx").on(t.aktion),
|
||||||
|
}),
|
||||||
|
);
|
||||||
28
src/db/schema/auth-rate-limit.ts
Normal file
28
src/db/schema/auth-rate-limit.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import {
|
||||||
|
pgTable,
|
||||||
|
uuid,
|
||||||
|
text,
|
||||||
|
boolean,
|
||||||
|
timestamp,
|
||||||
|
index,
|
||||||
|
} from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login-Versuche für Rate-Limiting. `key` ist i. d. R. eine Kombination aus
|
||||||
|
* E-Mail und/oder IP. Der Index auf (key, zeitpunkt) ermöglicht effizientes
|
||||||
|
* Zählen jüngster Fehlversuche.
|
||||||
|
*/
|
||||||
|
export const loginAttempts = pgTable(
|
||||||
|
"login_attempts",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
key: text("key").notNull(),
|
||||||
|
erfolg: boolean("erfolg").notNull(),
|
||||||
|
zeitpunkt: timestamp("zeitpunkt", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
},
|
||||||
|
(t) => ({
|
||||||
|
keyZeitIdx: index("login_attempts_key_zeit_idx").on(t.key, t.zeitpunkt),
|
||||||
|
}),
|
||||||
|
);
|
||||||
42
src/db/schema/brigades.ts
Normal file
42
src/db/schema/brigades.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import {
|
||||||
|
pgTable,
|
||||||
|
uuid,
|
||||||
|
text,
|
||||||
|
boolean,
|
||||||
|
doublePrecision,
|
||||||
|
timestamp,
|
||||||
|
index,
|
||||||
|
} from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feuerwehren. `lat/lng` werden aus der Adresse geokodiert (Geo-Workstream).
|
||||||
|
* `wehrfuehrer` bewusst ASCII (keine Umlaute in JS-Property/DB-Spalte).
|
||||||
|
*/
|
||||||
|
export const brigades = pgTable(
|
||||||
|
"brigades",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
art: text("art").notNull().default("FF"),
|
||||||
|
strasse: text("strasse"),
|
||||||
|
plz: text("plz"),
|
||||||
|
ort: text("ort"),
|
||||||
|
bundesland: text("bundesland").notNull().default("Niederösterreich"),
|
||||||
|
lat: doublePrecision("lat"),
|
||||||
|
lng: doublePrecision("lng"),
|
||||||
|
geocodeQuery: text("geocode_query"),
|
||||||
|
geocodedAt: timestamp("geocoded_at", { withTimezone: true }),
|
||||||
|
geocodeStatus: text("geocode_status"),
|
||||||
|
funkrufnameSchema: text("funkrufname_schema"),
|
||||||
|
wehrfuehrer: text("wehrfuehrer"),
|
||||||
|
telefon: text("telefon"),
|
||||||
|
email: text("email"),
|
||||||
|
aktiv: boolean("aktiv").notNull().default(true),
|
||||||
|
erstelltAm: timestamp("erstellt_am", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
},
|
||||||
|
(t) => ({
|
||||||
|
latlngIdx: index("brigades_latlng_idx").on(t.lat, t.lng),
|
||||||
|
}),
|
||||||
|
);
|
||||||
36
src/db/schema/enums.ts
Normal file
36
src/db/schema/enums.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { pgEnum } from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
|
// Hinweis: Der erste Parameter ist der DB-Enum-Name (snake_case), das
|
||||||
|
// exportierte Drizzle-Property kann abweichend benannt sein. So existiert je
|
||||||
|
// fachlichem Enum genau EIN DB-Typ.
|
||||||
|
|
||||||
|
export const roleEnum = pgEnum("role", [
|
||||||
|
"platform_admin",
|
||||||
|
"wehr_admin",
|
||||||
|
"wehr_read",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const authTypEnum = pgEnum("auth_typ", ["authentik", "local"]);
|
||||||
|
|
||||||
|
export const merkmalTypEnum = pgEnum("merkmal_typ", [
|
||||||
|
"number",
|
||||||
|
"enum",
|
||||||
|
"boolean",
|
||||||
|
"text",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const merkmalStatusEnum = pgEnum("merkmal_status", ["active", "proposed"]);
|
||||||
|
|
||||||
|
export const geltungsbereichEnum = pgEnum("geltungsbereich", [
|
||||||
|
"vehicle",
|
||||||
|
"equipment",
|
||||||
|
"both",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const assetStatusEnum = pgEnum("asset_status", [
|
||||||
|
"einsatzbereit",
|
||||||
|
"wartung",
|
||||||
|
"ausser_dienst",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const entityTypEnum = pgEnum("entity_typ", ["vehicle", "equipment"]);
|
||||||
37
src/db/schema/equipment-categories.ts
Normal file
37
src/db/schema/equipment-categories.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import {
|
||||||
|
pgTable,
|
||||||
|
uuid,
|
||||||
|
text,
|
||||||
|
integer,
|
||||||
|
unique,
|
||||||
|
index,
|
||||||
|
primaryKey,
|
||||||
|
} from "drizzle-orm/pg-core";
|
||||||
|
import { merkmale } from "./merkmale";
|
||||||
|
|
||||||
|
export const equipmentCategories = pgTable(
|
||||||
|
"equipment_categories",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
reihenfolge: integer("reihenfolge").notNull().default(0),
|
||||||
|
},
|
||||||
|
(t) => ({ nameUq: unique("equipment_categories_name_uq").on(t.name) }),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const equipmentCategoryMerkmale = pgTable(
|
||||||
|
"equipment_category_merkmale",
|
||||||
|
{
|
||||||
|
categoryId: uuid("category_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => equipmentCategories.id, { onDelete: "cascade" }),
|
||||||
|
merkmalId: uuid("merkmal_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => merkmale.id, { onDelete: "cascade" }),
|
||||||
|
reihenfolge: integer("reihenfolge").notNull().default(0),
|
||||||
|
},
|
||||||
|
(t) => ({
|
||||||
|
pk: primaryKey({ columns: [t.categoryId, t.merkmalId] }),
|
||||||
|
merkmalIdx: index("ecm_merkmal_idx").on(t.merkmalId),
|
||||||
|
}),
|
||||||
|
);
|
||||||
@@ -1,5 +1,13 @@
|
|||||||
// Barrel für das Datenbankschema.
|
// Barrel für das gesamte Datenbankschema (Workstream 2 — alleiniger
|
||||||
// Wird vom Datenbank-Workstream (Workstream 2) mit Tabellen, Enums und Indizes
|
// Schema-Eigentümer). Feature-Workstreams IMPORTIEREN nur von hier.
|
||||||
// gefüllt. Dieser Workstream (Fundament) definiert bewusst KEINE fachlichen
|
export * from "./enums";
|
||||||
// Tabellen und ist NICHT Migrations-Eigentümer.
|
export * from "./brigades";
|
||||||
export {};
|
export * from "./users";
|
||||||
|
export * from "./merkmale";
|
||||||
|
export * from "./templates";
|
||||||
|
export * from "./equipment-categories";
|
||||||
|
export * from "./assets";
|
||||||
|
export * from "./merkmal-values";
|
||||||
|
export * from "./auth-rate-limit";
|
||||||
|
export * from "./audit";
|
||||||
|
export * from "./relations";
|
||||||
|
|||||||
36
src/db/schema/merkmal-values.ts
Normal file
36
src/db/schema/merkmal-values.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import {
|
||||||
|
pgTable,
|
||||||
|
uuid,
|
||||||
|
text,
|
||||||
|
boolean,
|
||||||
|
doublePrecision,
|
||||||
|
index,
|
||||||
|
} from "drizzle-orm/pg-core";
|
||||||
|
import { merkmale } from "./merkmale";
|
||||||
|
import { entityTypEnum } from "./enums";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EAV-Wert-Tabelle mit typisierten Spalten. `entityId` ist polymorph
|
||||||
|
* (vehicles.id ODER equipment.id, je nach entityTyp). Die vier geforderten
|
||||||
|
* Indizes ermöglichen Range-/Bool-/Text-Filter je Merkmal sowie Entity-Lookup.
|
||||||
|
*/
|
||||||
|
export const merkmalValues = pgTable(
|
||||||
|
"merkmal_values",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
merkmalId: uuid("merkmal_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => merkmale.id, { onDelete: "cascade" }),
|
||||||
|
entityTyp: entityTypEnum("entity_typ").notNull(),
|
||||||
|
entityId: uuid("entity_id").notNull(),
|
||||||
|
valueNum: doublePrecision("value_num"),
|
||||||
|
valueText: text("value_text"),
|
||||||
|
valueBool: boolean("value_bool"),
|
||||||
|
},
|
||||||
|
(t) => ({
|
||||||
|
idxNum: index("mv_merkmal_num_idx").on(t.merkmalId, t.valueNum),
|
||||||
|
idxBool: index("mv_merkmal_bool_idx").on(t.merkmalId, t.valueBool),
|
||||||
|
idxText: index("mv_merkmal_text_idx").on(t.merkmalId, t.valueText),
|
||||||
|
idxEntity: index("mv_entity_idx").on(t.entityTyp, t.entityId),
|
||||||
|
}),
|
||||||
|
);
|
||||||
63
src/db/schema/merkmale.ts
Normal file
63
src/db/schema/merkmale.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import {
|
||||||
|
pgTable,
|
||||||
|
uuid,
|
||||||
|
text,
|
||||||
|
integer,
|
||||||
|
timestamp,
|
||||||
|
unique,
|
||||||
|
uniqueIndex,
|
||||||
|
index,
|
||||||
|
} from "drizzle-orm/pg-core";
|
||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
import { brigades } from "./brigades";
|
||||||
|
import { merkmalTypEnum, merkmalStatusEnum, geltungsbereichEnum } from "./enums";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merkmal-Katalog (Attributdefinitionen). `slug` ist der Idempotenz-Key für Seeds.
|
||||||
|
* `merkmale_active_name_uq` ist ein PARTIELLER Unique-Index auf (name)
|
||||||
|
* WHERE status='active' — zwei aktive Merkmale gleichen Namens sind verboten,
|
||||||
|
* mehrere `proposed` mit gleichem Namen sind erlaubt.
|
||||||
|
*/
|
||||||
|
export const merkmale = pgTable(
|
||||||
|
"merkmale",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
slug: text("slug").notNull(),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
typ: merkmalTypEnum("typ").notNull(),
|
||||||
|
einheit: text("einheit"),
|
||||||
|
geltungsbereich: geltungsbereichEnum("geltungsbereich").notNull(),
|
||||||
|
status: merkmalStatusEnum("status").notNull().default("proposed"),
|
||||||
|
vorgeschlagenVonBrigadeId: uuid("vorgeschlagen_von_brigade_id").references(
|
||||||
|
() => brigades.id,
|
||||||
|
{ onDelete: "set null" },
|
||||||
|
),
|
||||||
|
erstelltAm: timestamp("erstellt_am", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
},
|
||||||
|
(t) => ({
|
||||||
|
slugUq: unique("merkmale_slug_uq").on(t.slug),
|
||||||
|
byStatus: index("merkmale_status_idx").on(t.status),
|
||||||
|
activeNameUq: uniqueIndex("merkmale_active_name_uq")
|
||||||
|
.on(t.name)
|
||||||
|
.where(sql`${t.status} = 'active'`),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const merkmalOptionen = pgTable(
|
||||||
|
"merkmal_optionen",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
merkmalId: uuid("merkmal_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => merkmale.id, { onDelete: "cascade" }),
|
||||||
|
wert: text("wert").notNull(),
|
||||||
|
label: text("label").notNull(),
|
||||||
|
reihenfolge: integer("reihenfolge").notNull().default(0),
|
||||||
|
},
|
||||||
|
(t) => ({
|
||||||
|
wertUq: unique("merkmal_optionen_uq").on(t.merkmalId, t.wert),
|
||||||
|
merkmalIdx: index("merkmal_optionen_merkmal_idx").on(t.merkmalId),
|
||||||
|
}),
|
||||||
|
);
|
||||||
143
src/db/schema/relations.ts
Normal file
143
src/db/schema/relations.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import { relations } from "drizzle-orm";
|
||||||
|
import { brigades } from "./brigades";
|
||||||
|
import { users } from "./users";
|
||||||
|
import { merkmale, merkmalOptionen } from "./merkmale";
|
||||||
|
import {
|
||||||
|
vehicleTemplates,
|
||||||
|
vehicleTemplateMerkmale,
|
||||||
|
vehicleTemplateAliasse,
|
||||||
|
} from "./templates";
|
||||||
|
import {
|
||||||
|
equipmentCategories,
|
||||||
|
equipmentCategoryMerkmale,
|
||||||
|
} from "./equipment-categories";
|
||||||
|
import { vehicles, equipment } from "./assets";
|
||||||
|
import { merkmalValues } from "./merkmal-values";
|
||||||
|
import { auditLog } from "./audit";
|
||||||
|
|
||||||
|
export const brigadesRelations = relations(brigades, ({ many }) => ({
|
||||||
|
users: many(users),
|
||||||
|
vehicles: many(vehicles),
|
||||||
|
equipment: many(equipment),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const usersRelations = relations(users, ({ one }) => ({
|
||||||
|
brigade: one(brigades, {
|
||||||
|
fields: [users.brigadeId],
|
||||||
|
references: [brigades.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const merkmaleRelations = relations(merkmale, ({ many, one }) => ({
|
||||||
|
optionen: many(merkmalOptionen),
|
||||||
|
values: many(merkmalValues),
|
||||||
|
templateMerkmale: many(vehicleTemplateMerkmale),
|
||||||
|
categoryMerkmale: many(equipmentCategoryMerkmale),
|
||||||
|
vorgeschlagenVonBrigade: one(brigades, {
|
||||||
|
fields: [merkmale.vorgeschlagenVonBrigadeId],
|
||||||
|
references: [brigades.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const merkmalOptionenRelations = relations(merkmalOptionen, ({ one }) => ({
|
||||||
|
merkmal: one(merkmale, {
|
||||||
|
fields: [merkmalOptionen.merkmalId],
|
||||||
|
references: [merkmale.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const vehicleTemplatesRelations = relations(
|
||||||
|
vehicleTemplates,
|
||||||
|
({ many }) => ({
|
||||||
|
merkmale: many(vehicleTemplateMerkmale),
|
||||||
|
aliasse: many(vehicleTemplateAliasse),
|
||||||
|
vehicles: many(vehicles),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const vehicleTemplateMerkmaleRelations = relations(
|
||||||
|
vehicleTemplateMerkmale,
|
||||||
|
({ one }) => ({
|
||||||
|
template: one(vehicleTemplates, {
|
||||||
|
fields: [vehicleTemplateMerkmale.templateId],
|
||||||
|
references: [vehicleTemplates.id],
|
||||||
|
}),
|
||||||
|
merkmal: one(merkmale, {
|
||||||
|
fields: [vehicleTemplateMerkmale.merkmalId],
|
||||||
|
references: [merkmale.id],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const vehicleTemplateAliasseRelations = relations(
|
||||||
|
vehicleTemplateAliasse,
|
||||||
|
({ one }) => ({
|
||||||
|
template: one(vehicleTemplates, {
|
||||||
|
fields: [vehicleTemplateAliasse.templateId],
|
||||||
|
references: [vehicleTemplates.id],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const equipmentCategoriesRelations = relations(
|
||||||
|
equipmentCategories,
|
||||||
|
({ many }) => ({
|
||||||
|
merkmale: many(equipmentCategoryMerkmale),
|
||||||
|
equipment: many(equipment),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const equipmentCategoryMerkmaleRelations = relations(
|
||||||
|
equipmentCategoryMerkmale,
|
||||||
|
({ one }) => ({
|
||||||
|
category: one(equipmentCategories, {
|
||||||
|
fields: [equipmentCategoryMerkmale.categoryId],
|
||||||
|
references: [equipmentCategories.id],
|
||||||
|
}),
|
||||||
|
merkmal: one(merkmale, {
|
||||||
|
fields: [equipmentCategoryMerkmale.merkmalId],
|
||||||
|
references: [merkmale.id],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const vehiclesRelations = relations(vehicles, ({ one, many }) => ({
|
||||||
|
brigade: one(brigades, {
|
||||||
|
fields: [vehicles.brigadeId],
|
||||||
|
references: [brigades.id],
|
||||||
|
}),
|
||||||
|
template: one(vehicleTemplates, {
|
||||||
|
fields: [vehicles.templateId],
|
||||||
|
references: [vehicleTemplates.id],
|
||||||
|
}),
|
||||||
|
equipment: many(equipment),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const equipmentRelations = relations(equipment, ({ one }) => ({
|
||||||
|
brigade: one(brigades, {
|
||||||
|
fields: [equipment.brigadeId],
|
||||||
|
references: [brigades.id],
|
||||||
|
}),
|
||||||
|
category: one(equipmentCategories, {
|
||||||
|
fields: [equipment.categoryId],
|
||||||
|
references: [equipmentCategories.id],
|
||||||
|
}),
|
||||||
|
vehicle: one(vehicles, {
|
||||||
|
fields: [equipment.vehicleId],
|
||||||
|
references: [vehicles.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const merkmalValuesRelations = relations(merkmalValues, ({ one }) => ({
|
||||||
|
merkmal: one(merkmale, {
|
||||||
|
fields: [merkmalValues.merkmalId],
|
||||||
|
references: [merkmale.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const auditLogRelations = relations(auditLog, ({ one }) => ({
|
||||||
|
actor: one(users, {
|
||||||
|
fields: [auditLog.actorUserId],
|
||||||
|
references: [users.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
71
src/db/schema/templates.ts
Normal file
71
src/db/schema/templates.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import {
|
||||||
|
pgTable,
|
||||||
|
uuid,
|
||||||
|
text,
|
||||||
|
integer,
|
||||||
|
boolean,
|
||||||
|
doublePrecision,
|
||||||
|
unique,
|
||||||
|
index,
|
||||||
|
primaryKey,
|
||||||
|
} from "drizzle-orm/pg-core";
|
||||||
|
import { merkmale } from "./merkmale";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fahrzeug-Vorlagen (z. B. "TLFA 4000"). 11 Vorlagen lt. Plan.
|
||||||
|
* HLF 4-U ist KEINE eigene Vorlage, sondern ein bestätigter Alias auf HLF 4.
|
||||||
|
*/
|
||||||
|
export const vehicleTemplates = pgTable(
|
||||||
|
"vehicle_templates",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
code: text("code").notNull(),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
beschreibung: text("beschreibung"),
|
||||||
|
reihenfolge: integer("reihenfolge").notNull().default(0),
|
||||||
|
},
|
||||||
|
(t) => ({ codeUq: unique("vehicle_templates_code_uq").on(t.code) }),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vorlagen-Pflichtmerkmale mit drei typisierten Vorgabewert-Spalten.
|
||||||
|
*/
|
||||||
|
export const vehicleTemplateMerkmale = pgTable(
|
||||||
|
"vehicle_template_merkmale",
|
||||||
|
{
|
||||||
|
templateId: uuid("template_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => vehicleTemplates.id, { onDelete: "cascade" }),
|
||||||
|
merkmalId: uuid("merkmal_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => merkmale.id, { onDelete: "cascade" }),
|
||||||
|
vorgabewertNum: doublePrecision("vorgabewert_num"),
|
||||||
|
vorgabewertText: text("vorgabewert_text"),
|
||||||
|
vorgabewertBool: boolean("vorgabewert_bool"),
|
||||||
|
pflicht: boolean("pflicht").notNull().default(false),
|
||||||
|
reihenfolge: integer("reihenfolge").notNull().default(0),
|
||||||
|
},
|
||||||
|
(t) => ({
|
||||||
|
pk: primaryKey({ columns: [t.templateId, t.merkmalId] }),
|
||||||
|
merkmalIdx: index("vtm_merkmal_idx").on(t.merkmalId),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Such-/Anzeige-Aliasse zu Vorlagen. Eigene Tabelle (kein jsonb).
|
||||||
|
* `bestaetigt` erlaubt schrittweises fachliches Freigeben.
|
||||||
|
*/
|
||||||
|
export const vehicleTemplateAliasse = pgTable(
|
||||||
|
"vehicle_template_aliasse",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
templateId: uuid("template_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => vehicleTemplates.id, { onDelete: "cascade" }),
|
||||||
|
alias: text("alias").notNull(),
|
||||||
|
bestaetigt: boolean("bestaetigt").notNull().default(false),
|
||||||
|
},
|
||||||
|
(t) => ({
|
||||||
|
uq: unique("vehicle_template_aliasse_uq").on(t.templateId, t.alias),
|
||||||
|
}),
|
||||||
|
);
|
||||||
27
src/db/schema/users.ts
Normal file
27
src/db/schema/users.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { pgTable, uuid, text, boolean, timestamp, unique } from "drizzle-orm/pg-core";
|
||||||
|
import { brigades } from "./brigades";
|
||||||
|
import { roleEnum, authTypEnum } from "./enums";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Benutzer. Platform-Admin: `brigadeId = NULL`. Authentik-Konten: `passwortHash = NULL`.
|
||||||
|
*/
|
||||||
|
export const users = pgTable(
|
||||||
|
"users",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
brigadeId: uuid("brigade_id").references(() => brigades.id, {
|
||||||
|
onDelete: "restrict",
|
||||||
|
}),
|
||||||
|
rolle: roleEnum("rolle").notNull(),
|
||||||
|
authTyp: authTypEnum("auth_typ").notNull(),
|
||||||
|
email: text("email").notNull(),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
passwortHash: text("passwort_hash"),
|
||||||
|
aktiv: boolean("aktiv").notNull().default(true),
|
||||||
|
erstelltVon: uuid("erstellt_von"),
|
||||||
|
erstelltAm: timestamp("erstellt_am", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
},
|
||||||
|
(t) => ({ emailUq: unique("users_email_uq").on(t.email) }),
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user