// ============================================================================= // FLORIAN — Datenmodell, Phase 0 (Fundament) // // Grundregeln, die für JEDES Modell gelten: // 1. Mandantentrennung: jede Tabelle trägt organisationId. Postgres Row Level // Security setzt das durch (siehe migrations/.../rls). Ausnahme ist der // systemweite Berechtigungskatalog, siehe Kommentar dort. // 2. Jede schreibende Operation erzeugt einen AuditLog-Eintrag (lib/audit.ts). // 3. Prüf-, Wartungs- und Buchungsdaten werden nie hart gelöscht: // geloeschtAm setzen statt DELETE. // 4. Alle Zeitstempel in UTC speichern, Anzeige in Europe/Berlin. // // Bezeichner sind englisch, wo es Technik ist, und deutsch, wo es Fachlichkeit // ist — so wie es in der Wehr auch gesprochen wird. // ============================================================================= generator client { provider = "prisma-client-js" binaryTargets = ["native", "linux-musl-openssl-3.0.x", "debian-openssl-3.0.x"] previewFeatures = ["postgresqlExtensions"] } datasource db { provider = "postgresql" url = env("DATABASE_URL") directUrl = env("DIRECT_DATABASE_URL") extensions = [postgis, pgcrypto, citext, unaccent, pg_trgm(map: "pg_trgm")] } // ============================================================================= // Aufzählungen // ============================================================================= /// Rollen im Sinne der Feuerwehrorganisation. Die Berechtigungen dahinter /// stehen in lib/authz.ts und werden beim Seed in die Datenbank gespiegelt. enum RolleSchluessel { SUPERADMIN KOMMANDANT GERAETEWART ATEMSCHUTZGERAETEWART KASSIER SCHRIFTFUEHRER AUSBILDER MITGLIED KIOSK } /// Art des Ausweises, mit dem sich jemand am Terminal im Gerätehaus anmeldet. enum AusweisTokenTyp { PIN RFID } /// Wie sich jemand angemeldet hat — landet im Audit-Log. enum AnmeldeArt { LOKAL OIDC KIOSK_PIN KIOSK_RFID SYSTEM } /// Aktionen im Audit-Log. enum AuditAktion { ERSTELLT GEAENDERT GELOESCHT WIEDERHERGESTELLT ANGEMELDET ABGEMELDET ANMELDUNG_FEHLGESCHLAGEN EXPORTIERT DATEI_HOCHGELADEN DATEI_ABGERUFEN BERECHTIGUNG_GEAENDERT } /// Wer die Aktion ausgelöst hat. enum AkteurTyp { PERSON SYSTEM WORKER KIOSK API } /// Betriebsstatus eines Geräts nach DGUV-Logik. enum GeraetStatus { EINSATZBEREIT PRUEFUNG_FAELLIG GESPERRT AUSGESONDERT IN_REPARATUR } /// Grobe Fahrzeugkategorie. Die vollständige DIN-Typisierung kommt in Phase 1. enum FahrzeugArt { LOESCHFAHRZEUG DREHLEITER RUESTWAGEN GERAETEWAGEN MANNSCHAFTSTRANSPORT EINSATZLEITWAGEN WECHSELLADER ANHAENGER SONSTIGES } /// Dienstgrad nach bayerischer Feuerwehr-Dienstgradverordnung (Auswahl). enum Dienstgrad { FEUERWEHRANWAERTER FEUERWEHRMANN OBERFEUERWEHRMANN HAUPTFEUERWEHRMANN LOESCHMEISTER OBERLOESCHMEISTER HAUPTLOESCHMEISTER BRANDMEISTER OBERBRANDMEISTER HAUPTBRANDMEISTER KEIN } // ============================================================================= // Mandant // ============================================================================= /// Eine Feuerwehr. Oberste Klammer — an ihr hängt alles andere. model Organisation { id String @id @default(uuid()) @db.Uuid /// Kurzform für URLs und Konfiguration, z. B. "ff-musterhausen". slug String @unique @db.VarChar(64) name String @db.VarChar(200) kurzname String @db.VarChar(60) strasse String? @db.VarChar(200) plz String? @db.VarChar(10) ort String? @db.VarChar(120) landkreis String? @db.VarChar(120) bundesland String @default("Bayern") @db.VarChar(60) telefon String? @db.VarChar(40) email String? @db.VarChar(200) /// Kennung im Einsatzleitsystem der Integrierten Leitstelle. ilsKennung String? @db.VarChar(40) /// Funkrufname-Präfix der Wehr, z. B. "Florian Musterhausen". funkPraefix String? @db.VarChar(80) zeitzone String @default("Europe/Berlin") @db.VarChar(60) aktiv Boolean @default(true) erstelltAm DateTime @default(now()) @db.Timestamptz(3) geaendertAm DateTime @updatedAt @db.Timestamptz(3) geloeschtAm DateTime? @db.Timestamptz(3) personen Person[] rollen Rolle[] personRollen PersonRolle[] rolleRechte RolleBerechtigung[] ausweisTokens AusweisToken[] auditEintraege AuditLog[] dateien Datei[] fahrzeuge Fahrzeug[] geraete Geraet[] @@index([aktiv]) @@map("organisation") } // ============================================================================= // Personen und Rechte // ============================================================================= /// Ein Mitglied der Wehr. Gleichzeitig der Anmeldebenutzer — es gibt bewusst /// keine getrennte "User"-Tabelle, damit Dienstgrad, Qualifikation und Zugang /// nicht auseinanderlaufen. model Person { id String @id @default(uuid()) @db.Uuid organisationId String @db.Uuid vorname String @db.VarChar(100) nachname String @db.VarChar(100) /// Fortlaufende Mitgliedsnummer innerhalb der Wehr. mitgliedsnummer String? @db.VarChar(30) geburtsdatum DateTime? @db.Date dienstgrad Dienstgrad @default(KEIN) email String? @db.VarChar(200) telefon String? @db.VarChar(40) mobil String? @db.VarChar(40) strasse String? @db.VarChar(200) plz String? @db.VarChar(10) ort String? @db.VarChar(120) eintrittsdatum DateTime? @db.Date austrittsdatum DateTime? @db.Date /// Aktiver Dienst, passiv, Jugendfeuerwehr, Ehrenmitglied. abteilung String? @db.VarChar(60) /// bcrypt-Hash für den lokalen Login. Null, wenn nur OIDC genutzt wird. passwortHash String? @db.VarChar(120) passwortGeaendertAm DateTime? @db.Timestamptz(3) /// "sub"-Claim aus authentik. Verknüpft die Person mit dem OIDC-Konto. oidcSubject String? @db.VarChar(255) /// Gruppen aus dem letzten OIDC-Login — nur zur Nachvollziehbarkeit. oidcGruppen String[] @default([]) letzteAnmeldung DateTime? @db.Timestamptz(3) fehlversuche Int @default(0) gesperrtBis DateTime? @db.Timestamptz(3) aktiv Boolean @default(true) /// Profilbild. Liegt wie alles andere in MinIO. avatarDateiId String? @db.Uuid erstelltAm DateTime @default(now()) @db.Timestamptz(3) geaendertAm DateTime @updatedAt @db.Timestamptz(3) geloeschtAm DateTime? @db.Timestamptz(3) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) avatar Datei? @relation("PersonAvatar", fields: [avatarDateiId], references: [id], onDelete: SetNull) rollen PersonRolle[] @relation("PersonRollenInhaber") vergebeneRollen PersonRolle[] @relation("PersonRollenVergeber") ausweisTokens AusweisToken[] auditEintraege AuditLog[] hochgeladeneDateien Datei[] @relation("DateiHochgeladenVon") /// E-Mail und OIDC-Subject sind je Wehr eindeutig, nicht global — dieselbe /// Person kann in zwei Wehren Mitglied sein. @@unique([organisationId, email]) @@unique([organisationId, oidcSubject]) @@unique([organisationId, mitgliedsnummer]) @@index([organisationId, nachname, vorname]) @@index([organisationId, aktiv]) @@index([oidcSubject]) @@map("person") } /// Eine Rolle innerhalb einer Wehr. Die Systemrollen legt der Seed an; eigene /// Rollen kann der Kommandant ergänzen. model Rolle { id String @id @default(uuid()) @db.Uuid organisationId String @db.Uuid schluessel RolleSchluessel name String @db.VarChar(100) beschreibung String? @db.VarChar(500) /// Systemrollen dürfen nicht gelöscht werden. systemRolle Boolean @default(false) erstelltAm DateTime @default(now()) @db.Timestamptz(3) geaendertAm DateTime @updatedAt @db.Timestamptz(3) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) personen PersonRolle[] rechte RolleBerechtigung[] @@unique([organisationId, schluessel]) @@index([organisationId]) @@map("rolle") } /// Zuweisung Person zu Rolle. Eine Person kann mehrere Rollen tragen — der /// Gerätewart ist meist auch aktives Mitglied. model PersonRolle { id String @id @default(uuid()) @db.Uuid organisationId String @db.Uuid personId String @db.Uuid rolleId String @db.Uuid zugewiesenAm DateTime @default(now()) @db.Timestamptz(3) zugewiesenVonId String? @db.Uuid /// Befristete Zuweisung, z. B. Vertretung während Urlaub. gueltigBis DateTime? @db.Timestamptz(3) /// true, wenn die Rolle aus einer authentik-Gruppe vorbelegt wurde. Sobald /// jemand sie in FLORIAN ändert, steht hier false und OIDC überschreibt nicht /// mehr. ausOidcGruppe Boolean @default(false) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) person Person @relation("PersonRollenInhaber", fields: [personId], references: [id], onDelete: Cascade) rolle Rolle @relation(fields: [rolleId], references: [id], onDelete: Cascade) zugewiesenVon Person? @relation("PersonRollenVergeber", fields: [zugewiesenVonId], references: [id], onDelete: SetNull) @@unique([personId, rolleId]) @@index([organisationId, personId]) @@index([organisationId, rolleId]) @@map("person_rolle") } /// Systemweiter Katalog der Einzelrechte, z. B. "geraet.bearbeiten". /// /// BEWUSSTE AUSNAHME von der organisationId-Regel: das hier sind keine /// Mandantendaten, sondern eine Liste dessen, was die Software überhaupt kann. /// Der Katalog wird vom Seed aus lib/authz.ts befüllt und ist für alle Wehren /// identisch. Mandantenbezogen wird es erst durch RolleBerechtigung, und die /// trägt organisationId. Die Tabelle ist deshalb für alle lesbar und für die /// Anwendungsrolle nicht beschreibbar. model Berechtigung { id String @id @default(uuid()) @db.Uuid schluessel String @unique @db.VarChar(80) /// Fachbereich für die Gruppierung in der Oberfläche. bereich String @db.VarChar(60) bezeichnung String @db.VarChar(160) beschreibung String? @db.VarChar(500) erstelltAm DateTime @default(now()) @db.Timestamptz(3) rollen RolleBerechtigung[] @@index([bereich]) @@map("berechtigung") } /// Welche Rolle welches Recht hat — je Wehr getrennt, damit eine Wehr dem /// Gerätewart mehr erlauben kann als eine andere. model RolleBerechtigung { id String @id @default(uuid()) @db.Uuid organisationId String @db.Uuid rolleId String @db.Uuid berechtigungId String @db.Uuid /// false setzt ein Recht gezielt ab, das die Rolle sonst hätte. gewaehrt Boolean @default(true) erstelltAm DateTime @default(now()) @db.Timestamptz(3) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) rolle Rolle @relation(fields: [rolleId], references: [id], onDelete: Cascade) berechtigung Berechtigung @relation(fields: [berechtigungId], references: [id], onDelete: Cascade) @@unique([rolleId, berechtigungId]) @@index([organisationId, rolleId]) @@map("rolle_berechtigung") } /// Anmeldemittel für das Terminal im Gerätehaus: PIN oder RFID-Karte. /// Gespeichert wird immer nur ein Hash, nie die PIN oder die UID im Klartext. model AusweisToken { id String @id @default(uuid()) @db.Uuid organisationId String @db.Uuid personId String @db.Uuid typ AusweisTokenTyp /// bcrypt-Hash der PIN bzw. der RFID-UID. tokenHash String @db.VarChar(120) /// Letzte vier Stellen der Karten-UID, damit man Karten unterscheiden kann. kennzeichen String? @db.VarChar(20) bezeichnung String? @db.VarChar(120) aktiv Boolean @default(true) gueltigAb DateTime @default(now()) @db.Timestamptz(3) gueltigBis DateTime? @db.Timestamptz(3) letzteNutzung DateTime? @db.Timestamptz(3) fehlversuche Int @default(0) gesperrtBis DateTime? @db.Timestamptz(3) erstelltAm DateTime @default(now()) @db.Timestamptz(3) geaendertAm DateTime @updatedAt @db.Timestamptz(3) geloeschtAm DateTime? @db.Timestamptz(3) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) person Person @relation(fields: [personId], references: [id], onDelete: Cascade) /// Je Person und Art höchstens ein aktives Mittel dieser Bezeichnung. @@index([organisationId, personId]) @@index([organisationId, typ, aktiv]) @@map("ausweis_token") } // ============================================================================= // Nachvollziehbarkeit // ============================================================================= /// Append-only. Es gibt keine UPDATE- oder DELETE-Policy für die Anwendung; /// zusätzlich verhindert ein Trigger Änderungen auch für den Eigentümer. model AuditLog { id String @id @default(uuid()) @db.Uuid organisationId String @db.Uuid zeitpunkt DateTime @default(now()) @db.Timestamptz(3) akteurTyp AkteurTyp @default(PERSON) akteurPersonId String? @db.Uuid /// Klarname zum Zeitpunkt der Aktion — bleibt lesbar, auch wenn die Person /// später anonymisiert wird. akteurName String? @db.VarChar(200) anmeldeArt AnmeldeArt? aktion AuditAktion /// Modellname, z. B. "Geraet". entitaet String @db.VarChar(80) entitaetId String? @db.Uuid /// Menschenlesbare Kurzfassung für die Oberfläche. beschreibung String? @db.VarChar(500) vorher Json? @db.JsonB nachher Json? @db.JsonB /// Zusatzangaben: Anfrage-ID, Endpunkt, betroffene Felder. kontext Json? @db.JsonB ipAdresse String? @db.Inet userAgent String? @db.VarChar(400) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) akteur Person? @relation(fields: [akteurPersonId], references: [id], onDelete: SetNull) @@index([organisationId, zeitpunkt(sort: Desc)]) @@index([organisationId, entitaet, entitaetId]) @@index([organisationId, akteurPersonId]) @@map("audit_log") } // ============================================================================= // Dateien // ============================================================================= /// Metadaten zu einem Objekt in MinIO. Der Inhalt liegt NIE im Dateisystem und /// nie in der Datenbank — hier steht nur, wo er zu finden ist. model Datei { id String @id @default(uuid()) @db.Uuid organisationId String @db.Uuid bucket String @db.VarChar(80) /// Objektschlüssel in MinIO, z. B. "ff-musterhausen/geraet//foto.jpg". objektSchluessel String @db.VarChar(500) dateiname String @db.VarChar(300) mimeTyp String @db.VarChar(160) groesseBytes BigInt /// SHA-256 des Inhalts. Erkennt Dubletten und belegt Unverändertheit. pruefsumme String? @db.VarChar(64) /// Woran die Datei hängt (z. B. "Geraet") und an welchem Datensatz. entitaet String? @db.VarChar(80) entitaetId String? @db.Uuid /// Freie Einordnung: "pruefprotokoll", "foto", "rechnung", "export". kategorie String? @db.VarChar(60) hochgeladenVonId String? @db.Uuid erstelltAm DateTime @default(now()) @db.Timestamptz(3) geaendertAm DateTime @updatedAt @db.Timestamptz(3) /// Soft-Delete: das Objekt in MinIO räumt erst der Aufbewahrungs-Job ab. geloeschtAm DateTime? @db.Timestamptz(3) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) hochgeladenVon Person? @relation("DateiHochgeladenVon", fields: [hochgeladenVonId], references: [id], onDelete: SetNull) alsAvatarGenutzt Person[] @relation("PersonAvatar") @@unique([bucket, objektSchluessel]) @@index([organisationId, entitaet, entitaetId]) @@index([organisationId, kategorie]) @@map("datei") } // ============================================================================= // Vorgriff auf Phase 1 // Diese beiden Modelle sind bewusst schmal gehalten. Sie existieren, damit das // Seed-Skript eine realistische Wehr aufbauen kann und die Mandantentrennung // an echten Daten prüfbar ist. Phase 1 erweitert sie um Funkrufname-Systematik, // DIN-Typisierung, Geräteräume, Prüfintervalle und Lebensdauerkategorien. // ============================================================================= /// Ein Einsatzfahrzeug der Wehr. model Fahrzeug { id String @id @default(uuid()) @db.Uuid organisationId String @db.Uuid /// Funkrufname, z. B. "Florian Musterhausen 40/1". funkrufname String @db.VarChar(120) bezeichnung String @db.VarChar(120) art FahrzeugArt @default(LOESCHFAHRZEUG) kennzeichen String? @db.VarChar(20) hersteller String? @db.VarChar(120) baujahr Int? /// Nächste Hauptuntersuchung. tuevBis DateTime? @db.Date aktiv Boolean @default(true) erstelltAm DateTime @default(now()) @db.Timestamptz(3) geaendertAm DateTime @updatedAt @db.Timestamptz(3) geloeschtAm DateTime? @db.Timestamptz(3) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) geraete Geraet[] @@unique([organisationId, funkrufname]) @@index([organisationId, aktiv]) @@map("fahrzeug") } /// Ein prüfpflichtiges Gerät — Pressluftatmer, Schlauch, Leiter, Motorsäge. model Geraet { id String @id @default(uuid()) @db.Uuid organisationId String @db.Uuid inventarnummer String @db.VarChar(60) bezeichnung String @db.VarChar(160) hersteller String? @db.VarChar(120) seriennummer String? @db.VarChar(120) herstellungsdatum DateTime? @db.Date indienststellung DateTime? @db.Date status GeraetStatus @default(EINSATZBEREIT) /// Prüfintervall in Monaten aus Vorschrift oder Herstellerangabe. pruefintervallMonate Int? naechstePruefung DateTime? @db.Date /// Maximale Nutzungsdauer in Jahren (Helme, Seile, Schutzkleidung). lebensdauerJahre Int? /// Verlastet auf einem Fahrzeug — oder im Gerätehaus, dann null. fahrzeugId String? @db.Uuid lagerort String? @db.VarChar(160) erstelltAm DateTime @default(now()) @db.Timestamptz(3) geaendertAm DateTime @updatedAt @db.Timestamptz(3) geloeschtAm DateTime? @db.Timestamptz(3) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) fahrzeug Fahrzeug? @relation(fields: [fahrzeugId], references: [id], onDelete: SetNull) @@unique([organisationId, inventarnummer]) @@index([organisationId, status]) @@index([organisationId, naechstePruefung]) @@index([organisationId, fahrzeugId]) @@map("geraet") }