/** * Seed — legt eine vollständige Beispielwehr an. * * Inhalt: Berechtigungskatalog, 9 Systemrollen, 15 Personen mit Rollen, * 3 Fahrzeuge, 40 Geräte, PIN und RFID-Ausweise für das Terminal. * * Das Skript läuft über DIRECT_DATABASE_URL, also mit dem Eigentümer-Zugang, * der Row Level Security umgehen darf. Anders ginge es nicht: der Seed legt * die Organisation ja erst an, deren Kontext RLS bräuchte. * * Es ist idempotent — mehrfaches Ausführen ändert nichts an bereits * vorhandenen Daten. * * npm run db:seed */ import "dotenv/config"; import { Dienstgrad, FahrzeugArt, GeraetStatus, PrismaClient, RolleSchluessel, AusweisTokenTyp, } from "@prisma/client"; import bcrypt from "bcryptjs"; const prisma = new PrismaClient({ datasources: { db: { url: process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL ?? "" }, }, }); const RUNDEN = Number(process.env.AUTH_BCRYPT_ROUNDS ?? 12); const ORGANISATION_SLUG = "ff-musterhausen"; function log(nachricht: string): void { console.info(`[seed] ${nachricht}`); } /** Deterministisches Datum, damit zwei Läufe dieselben Daten erzeugen. */ function datum(jahr: number, monat: number, tag: number): Date { return new Date(Date.UTC(jahr, monat - 1, tag, 12, 0, 0)); } /** Verschiebt ein Datum um Monate — für Prüffristen. */ function plusMonate(basis: Date, monate: number): Date { const neu = new Date(basis); neu.setUTCMonth(neu.getUTCMonth() + monate); return neu; } // ----------------------------------------------------------------------------- // Berechtigungskatalog // ----------------------------------------------------------------------------- /** * Der Katalog wird aus lib/authz.ts gespiegelt. Der Import geht bewusst über * einen relativen Pfad, weil das Seed-Skript außerhalb des Next.js-Alias läuft. */ async function katalogAbgleichen(): Promise> { const { RECHTE } = await import("../src/lib/authz"); const karte = new Map(); for (const [schluessel, angaben] of Object.entries(RECHTE)) { const eintrag = await prisma.berechtigung.upsert({ where: { schluessel }, update: { bereich: angaben.bereich, bezeichnung: angaben.bezeichnung }, create: { schluessel, bereich: angaben.bereich, bezeichnung: angaben.bezeichnung, }, }); karte.set(schluessel, eintrag.id); } log(`Berechtigungskatalog: ${karte.size} Einzelrechte`); return karte; } // ----------------------------------------------------------------------------- // Organisation und Rollen // ----------------------------------------------------------------------------- async function organisationAnlegen() { const organisation = await prisma.organisation.upsert({ where: { slug: ORGANISATION_SLUG }, update: {}, create: { slug: ORGANISATION_SLUG, name: "Freiwillige Feuerwehr Musterhausen", kurzname: "FF Musterhausen", strasse: "Hauptstraße 12", plz: "85354", ort: "Musterhausen", landkreis: "Freising", bundesland: "Bayern", telefon: "08161 123456", email: "kommandant@ff-musterhausen.de", ilsKennung: "FS-MUS-01", funkPraefix: "Florian Musterhausen", }, }); log(`Organisation: ${organisation.name} (${organisation.id})`); return organisation; } async function rollenAnlegen(organisationId: string, katalog: Map) { const { ROLLEN, ROLLEN_BEZEICHNUNG, ROLLEN_BESCHREIBUNG, ROLLEN_RECHTE, ALLE_RECHTE } = await import("../src/lib/authz"); const rollenKarte = new Map(); for (const schluessel of ROLLEN) { const rolle = await prisma.rolle.upsert({ where: { organisationId_schluessel: { organisationId, schluessel: schluessel as RolleSchluessel, }, }, update: { name: ROLLEN_BEZEICHNUNG[schluessel], beschreibung: ROLLEN_BESCHREIBUNG[schluessel], }, create: { organisationId, schluessel: schluessel as RolleSchluessel, name: ROLLEN_BEZEICHNUNG[schluessel], beschreibung: ROLLEN_BESCHREIBUNG[schluessel], systemRolle: true, }, }); rollenKarte.set(schluessel, rolle.id); // Rechte der Rolle spiegeln — der Administrator bekommt alles. const rechte = schluessel === "SUPERADMIN" ? ALLE_RECHTE : (ROLLEN_RECHTE[schluessel as Exclude] ?? []); for (const recht of rechte) { const berechtigungId = katalog.get(recht); if (!berechtigungId) continue; await prisma.rolleBerechtigung.upsert({ where: { rolleId_berechtigungId: { rolleId: rolle.id, berechtigungId } }, update: { gewaehrt: true }, create: { organisationId, rolleId: rolle.id, berechtigungId, gewaehrt: true }, }); } } log(`Rollen: ${rollenKarte.size} Systemrollen samt Rechtezuordnung`); return rollenKarte; } // ----------------------------------------------------------------------------- // Personen // ----------------------------------------------------------------------------- interface PersonVorlage { vorname: string; nachname: string; dienstgrad: Dienstgrad; rollen: RolleSchluessel[]; eintritt: Date; abteilung: string; /** PIN fürs Terminal. Nur im Seed im Klartext, in der Datenbank gehasht. */ pin?: string; /** RFID-Kennung der Ausweiskarte. */ rfid?: string; } const PERSONEN: PersonVorlage[] = [ { vorname: "Andreas", nachname: "Huber", dienstgrad: Dienstgrad.HAUPTBRANDMEISTER, rollen: [RolleSchluessel.KOMMANDANT], eintritt: datum(1998, 4, 1), abteilung: "Aktive", pin: "471203", rfid: "04A3B7C1D2", }, { vorname: "Bernhard", nachname: "Maier", dienstgrad: Dienstgrad.OBERBRANDMEISTER, rollen: [RolleSchluessel.KOMMANDANT], eintritt: datum(2001, 9, 15), abteilung: "Aktive", pin: "938157", }, { vorname: "Christian", nachname: "Bauer", dienstgrad: Dienstgrad.HAUPTLOESCHMEISTER, rollen: [RolleSchluessel.GERAETEWART], eintritt: datum(2005, 3, 1), abteilung: "Aktive", pin: "620394", rfid: "04B8C2D3E4", }, { vorname: "Daniela", nachname: "Schmid", dienstgrad: Dienstgrad.OBERLOESCHMEISTER, rollen: [RolleSchluessel.ATEMSCHUTZGERAETEWART], eintritt: datum(2008, 7, 1), abteilung: "Aktive", pin: "184926", rfid: "04C9D3E4F5", }, { vorname: "Elisabeth", nachname: "Wagner", dienstgrad: Dienstgrad.LOESCHMEISTER, rollen: [RolleSchluessel.KASSIER], eintritt: datum(2010, 1, 15), abteilung: "Aktive", pin: "573081", }, { vorname: "Franz", nachname: "Fischer", dienstgrad: Dienstgrad.LOESCHMEISTER, rollen: [RolleSchluessel.SCHRIFTFUEHRER], eintritt: datum(2009, 5, 20), abteilung: "Aktive", pin: "306159", }, { vorname: "Gabriele", nachname: "Weber", dienstgrad: Dienstgrad.BRANDMEISTER, rollen: [RolleSchluessel.AUSBILDER], eintritt: datum(2004, 11, 1), abteilung: "Aktive", pin: "849265", }, { vorname: "Hans", nachname: "Becker", dienstgrad: Dienstgrad.HAUPTFEUERWEHRMANN, rollen: [RolleSchluessel.GERAETEWART, RolleSchluessel.AUSBILDER], eintritt: datum(2012, 2, 1), abteilung: "Aktive", pin: "715384", }, { vorname: "Ingrid", nachname: "Hofmann", dienstgrad: Dienstgrad.OBERFEUERWEHRMANN, rollen: [RolleSchluessel.MITGLIED], eintritt: datum(2015, 6, 1), abteilung: "Aktive", }, { vorname: "Josef", nachname: "Schneider", dienstgrad: Dienstgrad.OBERFEUERWEHRMANN, rollen: [RolleSchluessel.MITGLIED], eintritt: datum(2016, 4, 1), abteilung: "Aktive", }, { vorname: "Katharina", nachname: "Koch", dienstgrad: Dienstgrad.FEUERWEHRMANN, rollen: [RolleSchluessel.MITGLIED], eintritt: datum(2019, 9, 1), abteilung: "Aktive", }, { vorname: "Ludwig", nachname: "Richter", dienstgrad: Dienstgrad.FEUERWEHRMANN, rollen: [RolleSchluessel.MITGLIED], eintritt: datum(2020, 3, 1), abteilung: "Aktive", }, { vorname: "Martina", nachname: "Klein", dienstgrad: Dienstgrad.FEUERWEHRANWAERTER, rollen: [RolleSchluessel.MITGLIED], eintritt: datum(2024, 1, 15), abteilung: "Aktive", }, { vorname: "Nikolaus", nachname: "Wolf", dienstgrad: Dienstgrad.FEUERWEHRANWAERTER, rollen: [RolleSchluessel.MITGLIED], eintritt: datum(2025, 2, 1), abteilung: "Jugendfeuerwehr", }, { vorname: "Otto", nachname: "Neumann", dienstgrad: Dienstgrad.HAUPTBRANDMEISTER, rollen: [RolleSchluessel.MITGLIED], eintritt: datum(1975, 5, 1), abteilung: "Passive", }, ]; async function personenAnlegen( organisationId: string, rollenKarte: Map, ): Promise { let nummer = 1; for (const vorlage of PERSONEN) { const mitgliedsnummer = String(nummer).padStart(4, "0"); const email = `${vorlage.vorname}.${vorlage.nachname}` .toLowerCase() .replace(/ä/g, "ae") .replace(/ö/g, "oe") .replace(/ü/g, "ue") .replace(/ß/g, "ss") + "@ff-musterhausen.de"; const person = await prisma.person.upsert({ where: { organisationId_mitgliedsnummer: { organisationId, mitgliedsnummer } }, update: {}, create: { organisationId, mitgliedsnummer, vorname: vorlage.vorname, nachname: vorlage.nachname, dienstgrad: vorlage.dienstgrad, email, telefon: `08161 ${100000 + nummer * 7}`, strasse: `Beispielweg ${nummer}`, plz: "85354", ort: "Musterhausen", eintrittsdatum: vorlage.eintritt, abteilung: vorlage.abteilung, aktiv: vorlage.abteilung !== "Passive", }, }); for (const schluessel of vorlage.rollen) { const rolleId = rollenKarte.get(schluessel); if (!rolleId) continue; await prisma.personRolle.upsert({ where: { personId_rolleId: { personId: person.id, rolleId } }, update: {}, create: { organisationId, personId: person.id, rolleId }, }); } // Terminal-Ausweise: PIN und, wo vorhanden, RFID-Karte. if (vorlage.pin) { const vorhanden = await prisma.ausweisToken.findFirst({ where: { organisationId, personId: person.id, typ: AusweisTokenTyp.PIN }, }); if (!vorhanden) { await prisma.ausweisToken.create({ data: { organisationId, personId: person.id, typ: AusweisTokenTyp.PIN, tokenHash: await bcrypt.hash(vorlage.pin, RUNDEN), bezeichnung: "Terminal-PIN", }, }); } } if (vorlage.rfid) { const vorhanden = await prisma.ausweisToken.findFirst({ where: { organisationId, personId: person.id, typ: AusweisTokenTyp.RFID }, }); if (!vorhanden) { await prisma.ausweisToken.create({ data: { organisationId, personId: person.id, typ: AusweisTokenTyp.RFID, tokenHash: await bcrypt.hash(vorlage.rfid, RUNDEN), kennzeichen: vorlage.rfid.slice(-4), bezeichnung: "Ausweiskarte", }, }); } } nummer += 1; } log(`Personen: ${PERSONEN.length} Mitglieder samt Rollen und Terminal-Ausweisen`); } /** * Der erste Administrator. Anders als die Mitglieder hat er ein Passwort für * den lokalen Login — damit kommt man beim Erststart überhaupt hinein, bevor * authentik eingerichtet ist. */ async function adminAnlegen(organisationId: string, rollenKarte: Map) { const email = (process.env.SEED_ADMIN_EMAIL ?? "admin@beispielwehr.de").toLowerCase(); const passwort = process.env.SEED_ADMIN_PASSWORT ?? "Feuerwehr2026"; const admin = await prisma.person.upsert({ where: { organisationId_email: { organisationId, email } }, update: {}, create: { organisationId, mitgliedsnummer: "0000", vorname: "System", nachname: "Administrator", email, dienstgrad: Dienstgrad.KEIN, passwortHash: await bcrypt.hash(passwort, RUNDEN), passwortGeaendertAm: new Date(), abteilung: "Verwaltung", }, }); const rolleId = rollenKarte.get(RolleSchluessel.SUPERADMIN); if (rolleId) { await prisma.personRolle.upsert({ where: { personId_rolleId: { personId: admin.id, rolleId } }, update: {}, create: { organisationId, personId: admin.id, rolleId }, }); } log(`Administrator: ${email} (Passwort aus SEED_ADMIN_PASSWORT)`); return admin; } // ----------------------------------------------------------------------------- // Fahrzeuge und Geräte // ----------------------------------------------------------------------------- const FAHRZEUGE = [ { funkrufname: "Florian Musterhausen 40/1", bezeichnung: "LF 20 (Löschgruppenfahrzeug)", art: FahrzeugArt.LOESCHFAHRZEUG, kennzeichen: "FS-MU 401", hersteller: "MAN / Rosenbauer", baujahr: 2018, tuevBis: datum(2027, 5, 31), }, { funkrufname: "Florian Musterhausen 42/1", bezeichnung: "TSF-W (Tragkraftspritzenfahrzeug Wasser)", art: FahrzeugArt.LOESCHFAHRZEUG, kennzeichen: "FS-MU 421", hersteller: "Mercedes-Benz / Ziegler", baujahr: 2011, tuevBis: datum(2026, 11, 30), }, { funkrufname: "Florian Musterhausen 11/1", bezeichnung: "MTW (Mannschaftstransportwagen)", art: FahrzeugArt.MANNSCHAFTSTRANSPORT, kennzeichen: "FS-MU 111", hersteller: "Volkswagen", baujahr: 2021, tuevBis: datum(2027, 3, 31), }, ]; /** * 40 Geräte mit realistischen Prüfintervallen. Die Fristen sind bewusst * gemischt: einige liegen in der Vergangenheit, damit der Status * "Prüfung fällig" in der Oberfläche auch sichtbar ist. */ const GERAETE_VORLAGEN: Array<{ bezeichnung: string; hersteller: string; anzahl: number; intervallMonate: number; lebensdauerJahre?: number; aufFahrzeug?: number; lagerort?: string; }> = [ { bezeichnung: "Pressluftatmer PA 94", hersteller: "Dräger", anzahl: 4, intervallMonate: 12, lebensdauerJahre: 20, aufFahrzeug: 0 }, { bezeichnung: "Atemschutzmaske FPS 7000", hersteller: "Dräger", anzahl: 6, intervallMonate: 6, lebensdauerJahre: 10, lagerort: "Atemschutzwerkstatt" }, { bezeichnung: "Atemluftflasche 6,8 l / 300 bar", hersteller: "Luxfer", anzahl: 8, intervallMonate: 60, lebensdauerJahre: 20, aufFahrzeug: 0 }, { bezeichnung: "Feuerwehrhelm F220", hersteller: "Schuberth", anzahl: 6, intervallMonate: 12, lebensdauerJahre: 15, lagerort: "Umkleide" }, { bezeichnung: "Feuerwehrleine 30 m", hersteller: "Gustav Bauer", anzahl: 3, intervallMonate: 12, lebensdauerJahre: 8, aufFahrzeug: 0 }, { bezeichnung: "Steckleiterteil", hersteller: "Iturri", anzahl: 4, intervallMonate: 12, lebensdauerJahre: 25, aufFahrzeug: 0 }, { bezeichnung: "Tragkraftspritze PFPN 10-1000", hersteller: "Rosenbauer", anzahl: 1, intervallMonate: 12, aufFahrzeug: 1 }, { bezeichnung: "Hydraulischer Rettungssatz", hersteller: "Weber Rescue", anzahl: 1, intervallMonate: 12, aufFahrzeug: 0 }, { bezeichnung: "Motorkettensäge MS 261", hersteller: "Stihl", anzahl: 2, intervallMonate: 12, aufFahrzeug: 0 }, { bezeichnung: "Stromerzeuger 8 kVA", hersteller: "Endress", anzahl: 1, intervallMonate: 12, aufFahrzeug: 0 }, { bezeichnung: "Wärmebildkamera", hersteller: "Dräger", anzahl: 1, intervallMonate: 12, aufFahrzeug: 0 }, { bezeichnung: "Handsprechfunkgerät TPH900", hersteller: "Airbus", anzahl: 3, intervallMonate: 24, lagerort: "Funkraum" }, ]; async function fahrzeugeUndGeraeteAnlegen(organisationId: string): Promise { const fahrzeugIds: string[] = []; for (const vorlage of FAHRZEUGE) { const fahrzeug = await prisma.fahrzeug.upsert({ where: { organisationId_funkrufname: { organisationId, funkrufname: vorlage.funkrufname }, }, update: {}, create: { organisationId, ...vorlage }, }); fahrzeugIds.push(fahrzeug.id); } log(`Fahrzeuge: ${fahrzeugIds.length}`); const heute = new Date(); let laufend = 1; let angelegt = 0; for (const vorlage of GERAETE_VORLAGEN) { for (let index = 0; index < vorlage.anzahl; index += 1) { const inventarnummer = `INV-${String(laufend).padStart(4, "0")}`; // Ein Drittel der Geräte bekommt eine bereits überschrittene Frist, // damit die Statusanzeige nicht nur grün ist. const ueberfaellig = laufend % 3 === 0; const indienststellung = datum(2015 + (laufend % 9), ((laufend % 12) + 1), 15); const naechstePruefung = ueberfaellig ? plusMonate(heute, -(1 + (laufend % 5))) : plusMonate(heute, 1 + (laufend % vorlage.intervallMonate || 1)); const status = ueberfaellig ? GeraetStatus.PRUEFUNG_FAELLIG : laufend % 17 === 0 ? GeraetStatus.GESPERRT : GeraetStatus.EINSATZBEREIT; await prisma.geraet.upsert({ where: { organisationId_inventarnummer: { organisationId, inventarnummer } }, update: {}, create: { organisationId, inventarnummer, bezeichnung: vorlage.bezeichnung, hersteller: vorlage.hersteller, seriennummer: `SN-${vorlage.hersteller.slice(0, 3).toUpperCase()}-${100000 + laufend}`, herstellungsdatum: datum(indienststellung.getUTCFullYear() - 1, 6, 1), indienststellung, status, pruefintervallMonate: vorlage.intervallMonate, naechstePruefung, lebensdauerJahre: vorlage.lebensdauerJahre ?? null, fahrzeugId: vorlage.aufFahrzeug !== undefined ? (fahrzeugIds[vorlage.aufFahrzeug] ?? null) : null, lagerort: vorlage.lagerort ?? null, }, }); laufend += 1; angelegt += 1; } } log(`Geräte: ${angelegt}`); } // ----------------------------------------------------------------------------- // Ablauf // ----------------------------------------------------------------------------- async function main(): Promise { log("Starte ..."); const katalog = await katalogAbgleichen(); const organisation = await organisationAnlegen(); const rollen = await rollenAnlegen(organisation.id, katalog); await adminAnlegen(organisation.id, rollen); await personenAnlegen(organisation.id, rollen); await fahrzeugeUndGeraeteAnlegen(organisation.id); log("Fertig."); log(""); log("Anmeldung für den Erststart:"); log(` E-Mail: ${process.env.SEED_ADMIN_EMAIL ?? "admin@beispielwehr.de"}`); log(` Passwort: ${process.env.SEED_ADMIN_PASSWORT ?? "Feuerwehr2026"}`); log(""); log("Terminal-PIN zum Ausprobieren (Mitgliedsnummer 0001, Andreas Huber): 471203"); } main() .catch((fehler) => { console.error("[seed] Abbruch:", fehler); process.exit(1); }) .finally(async () => { await prisma.$disconnect(); });