Legt den kompletten Unterbau an, auf dem die Fachmodule aufsetzen. Betrieb: - docker-compose.yml mit Traefik, App, Worker, PostgreSQL 17 + PostGIS, Redis, MinIO und Gotenberg; Entwicklungs-Override ohne Traefik, dafuer mit Mailpit und Adminer - Mehrstufiges Dockerfile: eigener Benutzer, tini als PID 1, Healthcheck - .env.example mit einem Kommentar zu jeder Variable Mandantentrennung: - Row Level Security auf allen mandantenpflichtigen Tabellen - Zwei getrennte Datenbankverbindungen: florian_app mit aktiver RLS fuer die Anwendung, Eigentuemer-Zugang nur fuer Migration, Seed und Anmeldung - fuerOrganisation() als einziger Weg zu Fachdaten Nachvollziehbarkeit: - AuditLog append-only per Trigger; UPDATE ist dauerhaft gesperrt, DELETE nur im gekennzeichneten Aufbewahrungslauf - Geheimnisse werden vor dem Schreiben entfernt Anmeldung: - OIDC gegen ein extern betriebenes authentik, Rollen aus Gruppen nur als Vorbelegung beim ersten Login - Lokaler E-Mail/Passwort-Login als Rueckfallweg - Terminal per PIN und RFID mit zwei Schranken (Geraet, dann Person) Weiteres: - lib/authz.ts mit 9 Rollen und 38 Einzelrechten - lib/storage.ts (MinIO), lib/audit.ts, lib/warteschlange.ts (BullMQ) - Integrationen fuer Mail, Telegram und LLM hinter abschaltbaren Interfaces - tRPC v11, Basis-Layout mit Seitenleiste und dunkler Darstellung - Seed mit einer Beispielwehr: 16 Personen, 3 Fahrzeuge, 40 Geraete - 32 Vitest-Tests (davon 8 gegen eine echte Datenbank), 10 Playwright-Tests - README, Anleitung mit Screenshots, Roadmap, Aufwandsschaetzung, Devlog, Versionierungsregeln und Changelog Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
596 lines
19 KiB
TypeScript
596 lines
19 KiB
TypeScript
/**
|
|
* 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<Map<string, string>> {
|
|
const { RECHTE } = await import("../src/lib/authz");
|
|
|
|
const karte = new Map<string, string>();
|
|
|
|
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<string, string>) {
|
|
const { ROLLEN, ROLLEN_BEZEICHNUNG, ROLLEN_BESCHREIBUNG, ROLLEN_RECHTE, ALLE_RECHTE } =
|
|
await import("../src/lib/authz");
|
|
|
|
const rollenKarte = new Map<string, string>();
|
|
|
|
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<typeof schluessel, "SUPERADMIN">] ?? []);
|
|
|
|
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<string, string>,
|
|
): Promise<void> {
|
|
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<string, string>) {
|
|
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<void> {
|
|
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<void> {
|
|
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();
|
|
});
|