feat(mandant): Betriebsmodus, Kundenverwaltung und Einstellungen (#22)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

Zwei Betriebsmodi, ein Codepfad. src/server/mandant.ts ist die eine Stelle,
an der die Mandantenregel steht.

Der Review fand drei Blocker: die Lesemodelle wandten die Mandantensicht nicht
an, der Protokolleintrag des Moduswechsels stand außerhalb der Transaktion, und
Prüfung und Vollzug lagen so weit auseinander, dass zwei gleichzeitige Wechsel
beide durchliefen. Alle drei geschlossen, der letzte mit einem Advisory Lock.

336 Tests.

Closes #8

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit was merged in pull request #22.
This commit is contained in:
2026-09-25 05:03:11 +00:00
co-authored by Claude Opus 5
parent 40e95dbe5f
commit ecc3c579df
43 changed files with 2729 additions and 53 deletions
+312
View File
@@ -0,0 +1,312 @@
import { z } from "zod";
import type { Prisma } from "@/generated/prisma/client";
import { Betriebsmodus } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
import { betriebsmodus, eigeneOrganisation } from "@/server/mandant";
import { kuerzelPruefen } from "@/server/services/kunden";
/**
* Einstellungen der Anwendung und der Wechsel des Betriebsmodus (#008).
*
* Der Modus liegt in der Datenbank, nicht in der Umgebung – der Admin schaltet
* ohne Neustart um. Der Wechsel ist keine Migration, sondern eine
* Datenaenderung: jede kundenbezogene Zeile traegt ohnehin schon eine `kundeId`.
*/
export class EinstellungsFehler extends Error {
constructor(nachricht: string) {
super(nachricht);
this.name = "EinstellungsFehler";
}
}
/**
* Vorlagen fuer abgeleitete Werte. Die Platzhalter darin (`{vorname}`,
* `{nachname}`, `{v}`, `{nnn}`, `{kundenkuerzel}`) loest #012 auf; hier werden
* sie nur gespeichert und bearbeitet.
*/
export const NamensregelnSchema = z.object({
upn: z.string().trim().max(200).optional(),
sam: z.string().trim().max(200).optional(),
mail: z.string().trim().max(200).optional(),
hostname: z.string().trim().max(200).optional(),
});
export type Namensregeln = z.infer<typeof NamensregelnSchema>;
export type Einstellungen = {
readonly modus: Betriebsmodus;
readonly namensregeln: Namensregeln;
/** Ziel-OU fuer deaktivierte Konten, Vorbelegung im Offboarding. */
readonly gesperrteOu: string | null;
readonly eigeneOrganisation: { readonly id: string; readonly name: string; readonly kuerzel: string };
/** Nur zur Anzeige: sie steckt in den QR-Codes und kommt aus der Umgebung. */
readonly appUrl: string | null;
};
/** Ein unbrauchbarer Json-Inhalt darf die Einstellungsseite nicht sprengen. */
function namensregelnLesen(roh: unknown): Namensregeln {
const gelesen = NamensregelnSchema.safeParse(roh);
return gelesen.success ? gelesen.data : {};
}
export async function einstellungenHolen(): Promise<Einstellungen> {
const [einstellung, organisation] = await Promise.all([
prisma.einstellung.findUnique({ where: { id: "global" } }),
eigeneOrganisation(),
]);
return {
modus: einstellung?.modus ?? Betriebsmodus.INTERN,
namensregeln: namensregelnLesen(einstellung?.namensregeln),
gesperrteOu: einstellung?.gesperrteOu ?? null,
eigeneOrganisation: organisation,
appUrl: process.env["APP_URL"]?.trim() || null,
};
}
export type EinstellungsAenderung = {
readonly eigeneOrganisationName?: string;
readonly namensregeln?: Namensregeln;
readonly gesperrteOu?: string | null;
};
export async function einstellungenSpeichern(
aenderung: EinstellungsAenderung,
durchBenutzerId: string,
): Promise<void> {
const vorher = await einstellungenHolen();
if (aenderung.eigeneOrganisationName !== undefined) {
const name = aenderung.eigeneOrganisationName.trim();
if (!name) {
throw new EinstellungsFehler("Die eigene Organisation braucht einen Namen.");
}
if (name !== vorher.eigeneOrganisation.name) {
await prisma.kunde.update({ where: { id: vorher.eigeneOrganisation.id }, data: { name } });
await prisma.protokoll.create({
data: {
entitaet: "Kunde",
entitaetId: vorher.eigeneOrganisation.id,
aktion: "Name der eigenen Organisation geändert",
benutzerId: durchBenutzerId,
vorher: { name: vorher.eigeneOrganisation.name },
nachher: { name },
},
});
}
}
const neueRegeln = aenderung.namensregeln ?? vorher.namensregeln;
const neueOu =
aenderung.gesperrteOu === undefined
? vorher.gesperrteOu
: (aenderung.gesperrteOu?.trim() ?? null) || null;
const regelnGeaendert = JSON.stringify(neueRegeln) !== JSON.stringify(vorher.namensregeln);
const ouGeaendert = neueOu !== vorher.gesperrteOu;
if (!regelnGeaendert && !ouGeaendert) return;
await prisma.einstellung.upsert({
where: { id: "global" },
create: { id: "global", modus: vorher.modus, namensregeln: neueRegeln, gesperrteOu: neueOu },
update: { namensregeln: neueRegeln, gesperrteOu: neueOu },
});
await prisma.protokoll.create({
data: {
entitaet: "Einstellung",
entitaetId: "global",
aktion: "Einstellungen geändert",
benutzerId: durchBenutzerId,
vorher: { namensregeln: vorher.namensregeln, gesperrteOu: vorher.gesperrteOu },
nachher: { namensregeln: neueRegeln, gesperrteOu: neueOu },
},
});
}
/**
* Feste Kennung fuer das Vorhaengeschloss des Moduswechsels.
*
* Ohne Schloss lassen sich zwei Wechsel gleichzeitig vollziehen, oder ein Kunde
* entsteht zwischen Pruefung und Vollzug — dann stuende der Modus auf INTERN,
* obwohl es zwei nicht archivierte Kunden gibt. Ein Advisory Lock auf
* Transaktionsebene wird am Ende automatisch freigegeben, auch bei einem Abbruch.
*/
const WECHSEL_SCHLOSS = 815_423_100;
export type WechselPruefung = {
readonly moeglich: boolean;
/** Warum nicht – nur gesetzt, wenn `moeglich` falsch ist. */
readonly grund?: string;
/** Braucht der Wechsel Name und Kuerzel vom Admin? */
readonly brauchtAngaben: boolean;
/** Was der Admin im Bestaetigungsdialog wissen muss. */
readonly folgen: readonly string[];
};
/**
* Was passiert beim Wechsel, und geht er ueberhaupt?
*
* Getrennt vom Vollzug, damit der Bestaetigungsdialog dieselbe Auskunft zeigt,
* die der Wechsel danach durchsetzt – und nicht eine nachgebaute zweite Wahrheit.
*/
export async function modusWechselPruefen(ziel: Betriebsmodus): Promise<WechselPruefung> {
return pruefungMit(prisma, ziel, await betriebsmodus());
}
/**
* Dieselbe Pruefung, aber gegen einen beliebigen Client – damit der Vollzug sie
* **innerhalb** seiner Transaktion wiederholen kann. Der Dialog und der Wechsel
* benutzen denselben Code; eine nachgebaute zweite Wahrheit waere genau die
* Sorte Fehler, die man erst im Betrieb bemerkt.
*/
async function pruefungMit(
client: Prisma.TransactionClient | typeof prisma,
ziel: Betriebsmodus,
aktuell: Betriebsmodus,
): Promise<WechselPruefung> {
if (aktuell === ziel) {
return {
moeglich: false,
grund: "Dieser Modus ist bereits eingestellt.",
brauchtAngaben: false,
folgen: [],
};
}
if (ziel === Betriebsmodus.DIENSTLEISTUNG) {
const organisation = await eigeneOrganisation();
return {
moeglich: true,
brauchtAngaben: true,
folgen: [
`Die bisherige eigene Organisation „${organisation.name}" wird zum ersten Kunden.`,
"Alle Geräte, Personen, Abteilungen und Läufe bleiben daran hängen.",
"Kunden werden zu einer sichtbaren Ebene: Kundenliste, Kundenwahl beim Start eines Laufs, Kundenkürzel auf den Etiketten.",
],
};
}
const offene = await client.kunde.count({ where: { archiviert: false } });
if (offene !== 1) {
return {
moeglich: false,
grund:
`Zurück geht es nur mit genau einem nicht archivierten Kunden, vorhanden sind ${offene}. ` +
"Archiviere die übrigen Kunden, dann ist der Wechsel möglich.",
brauchtAngaben: false,
folgen: [],
};
}
const einziger = await client.kunde.findFirstOrThrow({ where: { archiviert: false } });
return {
moeglich: true,
brauchtAngaben: false,
folgen: [
`„${einziger.name}" wird zur eigenen Organisation und verschwindet aus der Oberfläche.`,
"Alle Daten bleiben erhalten, nur die Kundenebene wird ausgeblendet.",
"Archivierte Kunden bleiben archiviert und tauchen nicht wieder auf.",
],
};
}
export type WechselAngaben = {
/** Name der eigenen Organisation als erster Kunde – nur Richtung DIENSTLEISTUNG. */
readonly name?: string;
readonly kuerzel?: string;
};
/**
* Vollzieht den Wechsel.
*
* Alles in einer Transaktion: ein halb gewechselter Zustand waere schlimmer als
* gar keiner.
*/
export async function modusWechseln(
ziel: Betriebsmodus,
angaben: WechselAngaben,
durchBenutzerId: string,
): Promise<void> {
const organisation = await eigeneOrganisation();
// Alles in einer Transaktion: Schloss, Pruefung, Aenderung und Protokoll.
// Frueher stand das Protokoll dahinter – schlug es fehl, war der Modus schon
// gewechselt und der Vorgang stand nirgends.
await prisma.$transaction(async (tx) => {
await tx.$executeRaw`SELECT pg_advisory_xact_lock(${WECHSEL_SCHLOSS})`;
const einstellung = await tx.einstellung.findUnique({
where: { id: "global" },
select: { modus: true },
});
const vorher = einstellung?.modus ?? Betriebsmodus.INTERN;
// Innerhalb des Schlosses erneut pruefen: zwischen Dialog und Klick kann ein
// Kunde entstanden sein oder ein zweiter Admin schon gewechselt haben.
const pruefung = await pruefungMit(tx, ziel, vorher);
if (!pruefung.moeglich) {
throw new EinstellungsFehler(pruefung.grund ?? "Der Wechsel ist nicht möglich.");
}
let nachherOrganisation: { name: string; kuerzel: string };
if (ziel === Betriebsmodus.DIENSTLEISTUNG) {
const name = angaben.name?.trim() || organisation.name;
// Dieselbe Regel und dieselbe Meldung wie in der Kundenverwaltung.
const kuerzel = kuerzelPruefen(angaben.kuerzel?.trim() || organisation.kuerzel);
const belegt = await tx.kunde.findFirst({
where: { kuerzel, id: { not: organisation.id } },
select: { name: true, archiviert: true },
});
if (belegt) {
throw new EinstellungsFehler(
`Das Kürzel ${kuerzel} gehört bereits zu ${belegt.name}${belegt.archiviert ? " (archiviert)" : ""}.`,
);
}
// Die Zeile bleibt dieselbe und behaelt istEigeneOrganisation: sie ist
// weiterhin die eigene Firma, nur jetzt als sichtbarer Kunde. Deshalb
// haengt auch nichts um.
await tx.kunde.update({ where: { id: organisation.id }, data: { name, kuerzel } });
nachherOrganisation = { name, kuerzel };
} else {
const einziger = await tx.kunde.findFirstOrThrow({ where: { archiviert: false } });
if (!einziger.istEigeneOrganisation) {
// Kann heute nicht vorkommen, weil sich die eigene Organisation nicht
// archivieren laesst. Steht hier, damit die Regel "der einzige
// verbleibende Kunde wird zur eigenen Organisation" auch dann traegt,
// wenn sich daran je etwas aendert. Erst loesen, dann setzen – der
// partielle Index laesst nur eine true-Zeile zu.
await tx.kunde.updateMany({
where: { istEigeneOrganisation: true },
data: { istEigeneOrganisation: false },
});
await tx.kunde.update({ where: { id: einziger.id }, data: { istEigeneOrganisation: true } });
}
nachherOrganisation = { name: einziger.name, kuerzel: einziger.kuerzel };
}
await tx.einstellung.upsert({
where: { id: "global" },
create: { id: "global", modus: ziel },
update: { modus: ziel },
});
// Der Eintrag haelt den Zustand fest, nicht die rohe Eingabe: sonst stuende
// dort ein kleingeschriebenes Kuerzel, das so nie gespeichert wurde.
await tx.protokoll.create({
data: {
entitaet: "Einstellung",
entitaetId: "global",
aktion: "Betriebsmodus gewechselt",
benutzerId: durchBenutzerId,
vorher: {
modus: vorher,
organisation: { name: organisation.name, kuerzel: organisation.kuerzel },
},
nachher: { modus: ziel, organisation: nachherOrganisation },
},
});
});
}
+235
View File
@@ -0,0 +1,235 @@
import type { Kunde } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
/**
* Regeln der Kundenverwaltung (#008).
*
* Ein Kunde ist die Mandantengrenze: jede kundenbezogene Zeile traegt eine
* `kundeId`, auch im Modus `INTERN`. Dort gibt es genau einen Kunden mit
* `istEigeneOrganisation = true`, der in der Oberflaeche nirgends erscheint.
* Deshalb sind hier zwei Dinge tabu: die eigene Organisation anlegen und sie
* archivieren – beides wuerde den Modus `INTERN` zerlegen.
*
* Rechte werden hier nicht geprueft, das tut die Server Action darueber.
*/
export class KundenFehler extends Error {
constructor(nachricht: string) {
super(nachricht);
this.name = "KundenFehler";
}
}
export type NeuerKunde = {
readonly name: string;
readonly kuerzel: string;
readonly notizen?: string;
};
export type KundenAenderung = {
readonly name?: string;
readonly kuerzel?: string;
/** `null` loescht die Notizen; ein fehlendes Feld laesst sie unangetastet. */
readonly notizen?: string | null;
};
/** 2 bis 6 Zeichen, nur A-Z und 0-9 – dieselbe Form erzwingt `Kunde_kuerzel_form`. */
const KUERZEL_FORM = /^[A-Z0-9]{2,6}$/;
const KUERZEL_ERWARTUNG = "Das Kürzel besteht aus 2 bis 6 Zeichen, nur Großbuchstaben A-Z und Ziffern 0-9.";
/**
* Normalisiert und prueft ein Kuerzel; wirft KundenFehler mit verstaendlicher Meldung.
*
* Getrimmt und in Grossbuchstaben: `mus ` wird zu `MUS`. Die Datenbank haelt
* dieselbe Form ueber die Bedingung `Kunde_kuerzel_form` fest – diese Pruefung
* existiert fuer die bessere Meldung, nicht als einzige Schranke.
*/
export function kuerzelPruefen(roh: string): string {
const kuerzel = roh.trim().toUpperCase();
if (!KUERZEL_FORM.test(kuerzel)) {
throw new KundenFehler(
kuerzel.length === 0
? `Ein Kürzel fehlt. ${KUERZEL_ERWARTUNG}`
: `Kürzel "${kuerzel}" passt nicht. ${KUERZEL_ERWARTUNG}`,
);
}
return kuerzel;
}
function namePruefen(roh: string): string {
const name = roh.trim();
if (name.length === 0) {
throw new KundenFehler("Der Name des Kunden fehlt.");
}
return name;
}
/** Leere Notizen werden zu null, damit "" und null nicht beides herumliegen. */
function notizenNormalisieren(roh: string | null | undefined): string | null {
const notizen = roh?.trim() ?? "";
return notizen.length === 0 ? null : notizen;
}
/**
* Stellt sicher, dass das Kuerzel frei ist – auch gegen archivierte Kunden.
*
* Der Prisma-Fehler des eindeutigen Index wird absichtlich nicht abgefangen: er
* wuesste nicht, wem das Kuerzel gehoert, und genau das braucht die Meldung. Ein
* archivierter Kunde wird mitgenannt, sonst sucht man den Konflikt in einer
* Liste, die ihn gar nicht zeigt.
*/
async function kuerzelFreiPruefen(kuerzel: string, ausserId?: string): Promise<void> {
const vorhanden = await prisma.kunde.findUnique({
where: { kuerzel },
select: { id: true, name: true, archiviert: true },
});
if (!vorhanden || vorhanden.id === ausserId) return;
throw new KundenFehler(
`Das Kürzel ${kuerzel} gehört schon zu "${vorhanden.name}"` +
(vorhanden.archiviert ? " (archiviert)." : "."),
);
}
/**
* Legt einen Kunden an.
*
* `istEigeneOrganisation` wird hier nie gesetzt, auch nicht, wenn es in der
* Eingabe steht: die eigene Organisation entsteht nur im Seed und beim
* Moduswechsel, und ein partieller Index laesst ohnehin nur eine zu.
*/
export async function kundeAnlegen(eingabe: NeuerKunde, durchBenutzerId: string): Promise<Kunde> {
const name = namePruefen(eingabe.name);
const kuerzel = kuerzelPruefen(eingabe.kuerzel);
await kuerzelFreiPruefen(kuerzel);
const kunde = await prisma.kunde.create({
data: {
name,
kuerzel,
notizen: notizenNormalisieren(eingabe.notizen),
istEigeneOrganisation: false,
},
});
await prisma.protokoll.create({
data: {
entitaet: "Kunde",
entitaetId: kunde.id,
aktion: "Kunde angelegt",
benutzerId: durchBenutzerId,
nachher: { name: kunde.name, kuerzel: kunde.kuerzel },
},
});
return kunde;
}
/** Deutsche Beschriftung der aenderbaren Felder, fuer die Aktion im Protokoll. */
const FELD_TEXT = { name: "Name", kuerzel: "Kürzel", notizen: "Notizen" } as const;
/**
* Aendert Name, Kuerzel und Notizen.
*
* `istEigeneOrganisation` ist nicht aenderbar – weder hinein noch heraus. Wer
* die eigene Organisation zum Kunden macht, tut das ueber den Moduswechsel, der
* die ganze Umstellung kennt.
*/
export async function kundeBearbeiten(
id: string,
aenderung: KundenAenderung,
durchBenutzerId: string,
): Promise<Kunde> {
const vorher = await kundeLaden(id);
const name = aenderung.name === undefined ? vorher.name : namePruefen(aenderung.name);
const kuerzel = aenderung.kuerzel === undefined ? vorher.kuerzel : kuerzelPruefen(aenderung.kuerzel);
const notizen = aenderung.notizen === undefined ? vorher.notizen : notizenNormalisieren(aenderung.notizen);
// Nur die wirklich geaenderten Felder landen im Protokoll – so steht dort,
// was jemand getan hat, und nicht, was das Formular alles mitgeschickt hat.
const felder: (keyof typeof FELD_TEXT)[] = [];
const vorherWerte: Record<string, string | null> = {};
const nachherWerte: Record<string, string | null> = {};
if (name !== vorher.name) {
felder.push("name");
vorherWerte["name"] = vorher.name;
nachherWerte["name"] = name;
}
if (kuerzel !== vorher.kuerzel) {
felder.push("kuerzel");
vorherWerte["kuerzel"] = vorher.kuerzel;
nachherWerte["kuerzel"] = kuerzel;
}
if (notizen !== vorher.notizen) {
felder.push("notizen");
vorherWerte["notizen"] = vorher.notizen;
nachherWerte["notizen"] = notizen;
}
// Ohne Unterschied wird nichts geschrieben, sonst fuellt jedes geoeffnete
// Formular das Protokoll mit leeren Aenderungen.
if (felder.length === 0) return vorher;
if (kuerzel !== vorher.kuerzel) {
await kuerzelFreiPruefen(kuerzel, id);
}
const kunde = await prisma.kunde.update({
where: { id },
data: { name, kuerzel, notizen },
});
await prisma.protokoll.create({
data: {
entitaet: "Kunde",
entitaetId: id,
aktion: `${felder.map((feld) => FELD_TEXT[feld]).join(" und ")} geändert`,
benutzerId: durchBenutzerId,
vorher: vorherWerte,
nachher: nachherWerte,
},
});
return kunde;
}
/**
* Archiviert einen Kunden oder gibt ihn wieder frei.
*
* Geloescht wird nichts: an einem Kunden haengen Laeufe, und die sind Archiv.
*/
export async function kundeArchivieren(
id: string,
archiviert: boolean,
durchBenutzerId: string,
): Promise<void> {
const vorher = await kundeLaden(id);
if (archiviert && vorher.istEigeneOrganisation) {
throw new KundenFehler(
"Die eigene Organisation ist im Modus INTERN der einzige Kunde; sie zu archivieren würde " +
"die Anwendung unbenutzbar machen.",
);
}
if (vorher.archiviert === archiviert) return;
await prisma.kunde.update({ where: { id }, data: { archiviert } });
await prisma.protokoll.create({
data: {
entitaet: "Kunde",
entitaetId: id,
aktion: archiviert ? "Kunde archiviert" : "Kunde wieder freigegeben",
benutzerId: durchBenutzerId,
vorher: { archiviert: vorher.archiviert },
nachher: { archiviert },
},
});
}
/** Eigene Meldung statt eines Prisma-Fehlers, falls die Kennung ins Leere zeigt. */
async function kundeLaden(id: string): Promise<Kunde> {
const kunde = await prisma.kunde.findUnique({ where: { id } });
if (!kunde) {
throw new KundenFehler("Diesen Kunden gibt es nicht (mehr).");
}
return kunde;
}