Workstream 2: Datenbankschema & Migrationen (Phase 1)

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

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

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

View File

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

View File

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

View File

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

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

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

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

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

View File

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

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

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

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

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

View File

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

View File

@@ -1,5 +1,13 @@
// Barrel für das Datenbankschema.
// 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";

View File

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

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

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

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

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

View File

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

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

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