feat(mandant): Betriebsmodus, Kundenverwaltung und Einstellungen (#22)
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:
@@ -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 },
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user