Phase 10: Mandantenfaehigkeit

Ein Server, mehrere Wehren. Ein Konto, mehrere Zugehoerigkeiten. Eine
Kreisebene, die zusieht.

Datenmodell und RLS trugen seit Phase 0 mehrere Wehren. Was fehlte, war
alles darueber: bis hierher war die Personenakte zugleich das Anmeldekonto,
und die gehoert genau einer Wehr. Wer in zweien gefuehrt wurde, brauchte
zwei Passwoerter, und die Anmeldung per E-Mail war mehrdeutig.

Neu sind `Benutzer` (das Konto) und `Mitgliedschaft` (die Zugehoerigkeit).
Das Konto traegt bewusst keine Mandantenkennung und kann keine tragen - es
ist der Angelpunkt, an dem ein Mensch zu mehreren Wehren gehoert. Geschuetzt
wird es deshalb ueber Rechte statt ueber RLS, wie `Berechtigung` seit Phase
0. Was es in einer Wehr darf, entscheidet seine Mitgliedschaft.

Die Sitzung behaelt ihre Form und entsteht nur anders - aus der aktiven
Mitgliedschaft statt aus der Person. Von 414 Fundstellen in 61 Dateien
musste dadurch keine angefasst werden. Die eine Ausnahme ist Absicht:
`personId` ist nullbar geworden, weil eine Aufsichtssitzung keine Akte hat,
und der Typechecker hat daraus genau die fuenf Stellen gemacht, an denen
die Kreisaufsicht nichts verloren hat.

Ihre Rechte werden gefiltert statt aufgezaehlt: alles, was `.lesen` heisst,
minus Kasse, Audit-Log, KI-Protokoll und Personenexport. So bekommt sie
neue Leserechte automatisch und neue Schreibrechte nie.

Die Migration ist datenerhaltend und bricht ab, statt einen halben Zustand
zu hinterlassen.

Nebenbei behoben: `db:test-reset` liess die Nachbarwehr stehen, der
Fehlversuch-Test sperrte das gemeinsame Testkonto, ein Testaufbau war
nicht wiederholbar, und die Wartelogik der Bildschirmfotos konnte
ausloesen, bevor eine Seite ueberhaupt geladen war.

581 Unit-Tests und 266 E2E-Tests gruen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-05 13:43:24 +02:00
co-authored by Claude Opus 5
parent 2b53556175
commit ff40437a1a
98 changed files with 3810 additions and 201 deletions
+245 -3
View File
@@ -57,6 +57,7 @@ import {
LebensdauerBezug,
LebensdauerKategorie,
MangelPrioritaet,
MitgliedschaftArt,
MangelStatus,
PrismaClient,
PruefErgebnis,
@@ -198,7 +199,13 @@ async function rollenAnlegen(organisationId: string, katalog: Map<string, string
const rollenKarte = new Map<string, string>();
for (const schluessel of ROLLEN) {
// AUFSICHT ist keine vergebbare Rolle und steht deshalb auch nicht im
// Prisma-Enum. Sie ergibt sich seit Phase 10 daraus, dass ein Konto einer
// Wehr als Kreisaufsicht zugeordnet ist — sie hier anzulegen hiesse, dass
// jemand sie innerhalb einer Wehr vergeben könnte.
const vergebbar = ROLLEN.filter((schluessel) => schluessel !== "AUFSICHT");
for (const schluessel of vergebbar) {
const rolle = await prisma.rolle.upsert({
where: {
organisationId_schluessel: {
@@ -499,12 +506,31 @@ async function adminAnlegen(organisationId: string, rollenKarte: Map<string, str
nachname: "Administrator",
email,
dienstgrad: Dienstgrad.KEIN,
passwortHash: await bcrypt.hash(passwort, RUNDEN),
passwortGeaendertAm: new Date(),
abteilung: "Verwaltung",
},
});
// Das Konto seit Phase 10 getrennt von der Akte. Die E-Mail ist global
// eindeutig — wird derselbe Seed für eine zweite Wehr gefahren, entsteht
// deshalb kein zweites Konto, sondern eine zweite Mitgliedschaft. Genau so
// sieht Doppelmitgliedschaft in echt aus.
const konto = await prisma.benutzer.upsert({
where: { email },
update: {},
create: {
email,
passwortHash: await bcrypt.hash(passwort, RUNDEN),
passwortGeaendertAm: new Date(),
istVerwalter: true,
},
});
await prisma.mitgliedschaft.upsert({
where: { benutzerId_organisationId: { benutzerId: konto.id, organisationId } },
update: {},
create: { organisationId, benutzerId: konto.id, personId: admin.id },
});
const rolleId = rollenKarte.get(RolleSchluessel.SUPERADMIN);
if (rolleId) {
await prisma.personRolle.upsert({
@@ -3897,6 +3923,219 @@ async function kassendatenAnlegen(organisationId: string): Promise<void> {
}
// -----------------------------------------------------------------------------
// Phase 10 — Mandantenfähigkeit
//
// Eine zweite, kleine Wehr und ein Konto, das beide sieht.
//
// Ohne die zweite Wehr lässt sich die ganze Phase nicht ausprobieren: der
// Wehrwähler erscheint erst, wenn es etwas zu wählen gibt, und eine
// Mandantentrennung, die nie zwei Mandanten hatte, ist keine geprüfte.
//
// Die Geschichte ist die übliche in Bayern: der Kommandant von Musterhausen
// ist zugleich Kreisbrandinspektor und betreut die Nachbarwehr. In seiner
// eigenen Wehr führt er alles, in der betreuten sieht er zu — mehr nicht.
//
// Nachbarshausen bleibt bewusst dünn: drei Leute, ein Fahrzeug. Es soll
// zeigen, dass die Trennung hält, und nicht die Beispieldaten verdoppeln.
// -----------------------------------------------------------------------------
/**
* Die Nachbarwehr leitet sich aus der Wehr ab, die gerade angelegt wird.
*
* Fest verdrahtet wäre sie das, was Phase 8 gerade beseitigt hat: der
* Testlauf und die Beispielwehr griffen auf denselben Datensatz zu. So bekommt
* jeder Mandant sein eigenes Paar — und die Trennung gilt auch für das, womit
* sie geprüft wird.
*/
const NACHBAR_SLUG = `${ORGANISATION_SLUG}-nachbar`;
const NACHBAR_NAME = `${ORGANISATION_NAME} — Nachbarwehr`;
const NACHBAR_KURZNAME = `${ORGANISATION_KURZNAME} Nachbar`;
const KBI_EMAIL = process.env.SEED_KBI_EMAIL ?? `kbi@${ORGANISATION_SLUG}.de`;
async function kreisebeneAnlegen(
heimatOrganisationId: string,
katalog: Map<string, string>,
): Promise<void> {
/**
* Zwei Teile, zwei Prüfungen — und das ist wichtiger, als es aussieht.
*
* Ein gemeinsamer Ausstieg am Anfang hätte heißen: steht die Nachbarwehr,
* geschieht gar nichts mehr. Genau das ist beim Zurücksetzen der Testwehr
* passiert — die Nachbarwehr blieb stehen, die Mitgliedschaft in der neuen
* Testwehr wurde nie wieder angelegt, und der Wehrwähler zeigte nur noch
* die betreute Wehr. Gefunden hat das ein E2E-Test.
*/
const nachbar =
(await prisma.organisation.findUnique({ where: { slug: NACHBAR_SLUG } })) ??
(await nachbarwehrAnlegen(katalog));
// --- Das Konto, das beide Wehren sieht ------------------------------------
await kreiskontoAnlegen(heimatOrganisationId, nachbar.id);
}
/** Die Nachbarwehr: dünn besetzt, ein Fahrzeug. Sie soll zeigen, dass die
* Trennung hält, und nicht die Beispieldaten verdoppeln. */
async function nachbarwehrAnlegen(katalog: Map<string, string>) {
const nachbar = await prisma.organisation.create({
data: {
slug: NACHBAR_SLUG,
name: NACHBAR_NAME,
kurzname: NACHBAR_KURZNAME,
strasse: "Dorfstraße 3",
plz: "91316",
ort: "Nachbarshausen",
landkreis: "Musterkreis",
funkPraefix: "Florian Nachbarshausen",
},
});
const nachbarRollen = await rollenAnlegen(nachbar.id, katalog);
const besetzung: Array<{
vorname: string;
nachname: string;
nummer: string;
dienstgrad: Dienstgrad;
rolle: RolleSchluessel;
}> = [
{
vorname: "Georg",
nachname: "Sailer",
nummer: "0001",
dienstgrad: Dienstgrad.BRANDMEISTER,
rolle: RolleSchluessel.KOMMANDANT,
},
{
vorname: "Marianne",
nachname: "Kraus",
nummer: "0002",
dienstgrad: Dienstgrad.LOESCHMEISTER,
rolle: RolleSchluessel.GERAETEWART,
},
{
vorname: "Tobias",
nachname: "Ostermeier",
nummer: "0003",
dienstgrad: Dienstgrad.FEUERWEHRMANN,
rolle: RolleSchluessel.MITGLIED,
},
];
for (const eintrag of besetzung) {
const person = await prisma.person.create({
data: {
organisationId: nachbar.id,
vorname: eintrag.vorname,
nachname: eintrag.nachname,
mitgliedsnummer: eintrag.nummer,
dienstgrad: eintrag.dienstgrad,
abteilung: "Aktive",
eintrittsdatum: datum(2012, 4, 1),
},
});
const rolleId = nachbarRollen.get(eintrag.rolle);
if (rolleId) {
await prisma.personRolle.create({
data: { organisationId: nachbar.id, personId: person.id, rolleId },
});
}
}
const standort = await prisma.standort.create({
data: {
organisationId: nachbar.id,
name: "Gerätehaus Nachbarshausen",
art: StandortArt.GERAETEHAUS,
strasse: "Dorfstraße 3",
plz: "91316",
ort: "Nachbarshausen",
},
});
await prisma.fahrzeug.create({
data: {
organisationId: nachbar.id,
funkrufname: "Florian Nachbarshausen 42/1",
bezeichnung: "TSF-W",
art: FahrzeugArt.LOESCHFAHRZEUG,
kennzeichen: "MK-FW 421",
baujahr: 2009,
standortId: standort.id,
},
});
log(`Nachbarwehr angelegt: ${besetzung.length} Personen, 1 Fahrzeug.`);
return nachbar;
}
/** Das Konto der Kreisebene: Mitglied in der Heimatwehr, Aufsicht nebenan. */
async function kreiskontoAnlegen(heimatOrganisationId: string, nachbarId: string) {
const kommandant = await prisma.person.findFirst({
where: {
organisationId: heimatOrganisationId,
rollen: { some: { rolle: { schluessel: RolleSchluessel.KOMMANDANT } } },
},
orderBy: { mitgliedsnummer: "asc" },
});
if (!kommandant) {
log("Kein Kommandant gefunden — die Kreisebene wird übersprungen.");
return;
}
const email = KBI_EMAIL;
const passwort = process.env.SEED_KBI_PASSWORT ?? "Feuerwehr2026";
const konto = await prisma.benutzer.upsert({
where: { email },
update: {},
create: {
email,
passwortHash: await bcrypt.hash(passwort, RUNDEN),
passwortGeaendertAm: new Date(),
},
});
// In der eigenen Wehr: Mitglied mit allem, was seine Rollen hergeben.
await prisma.mitgliedschaft.upsert({
where: {
benutzerId_organisationId: {
benutzerId: konto.id,
organisationId: heimatOrganisationId,
},
},
update: {},
create: {
organisationId: heimatOrganisationId,
benutzerId: konto.id,
personId: kommandant.id,
art: MitgliedschaftArt.MITGLIED,
},
});
// In der Nachbarwehr: Aufsicht. Ohne Akte — er gehört ihr nicht an und
// steht deshalb auch in keiner ihrer Mitgliederlisten.
await prisma.mitgliedschaft.upsert({
where: {
benutzerId_organisationId: { benutzerId: konto.id, organisationId: nachbarId },
},
update: {},
create: {
organisationId: nachbarId,
benutzerId: konto.id,
art: MitgliedschaftArt.AUFSICHT,
},
});
log(
`Kreisebene: ${email} — Mitglied in ${ORGANISATION_KURZNAME}, ` +
`Aufsicht in ${NACHBAR_KURZNAME}.`,
);
}
// -----------------------------------------------------------------------------
// Ablauf
// -----------------------------------------------------------------------------
@@ -3946,6 +4185,9 @@ async function main(): Promise<void> {
// Phase 9 — Kassenverwaltung
await kassendatenAnlegen(organisation.id);
// Phase 10 — Mandantenfähigkeit: eine zweite Wehr und die Kreisebene
await kreisebeneAnlegen(organisation.id, katalog);
log("Fertig.");
log("");
log(`Wehr: ${ORGANISATION_NAME} (${ORGANISATION_SLUG})`);