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:
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.
|
||||
// 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";
|
||||
|
||||
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