Files
Laufzettel/src/server/services/einstellungen.ts
T
florian.wilde eec5de404a
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
feat(platzhalter): Aufloesung, Namensregeln und Shell-Maskierung (#26)
2026-09-25 13:30:31 +00:00

308 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { Prisma } from "@/generated/prisma/client";
import { Betriebsmodus } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
import { NamensregelnSchema, type Namensregeln } from "@/lib/namensregeln";
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";
}
}
/**
* Schema und Typ der Namensregeln liegen seit #012 in `src/lib/namensregeln.ts`,
* zusammen mit der Aufloesung der Platzhalter darin (`{vorname}`, `{nachname}`,
* `{v}`, `{nnn}`, `{kundenkuerzel}`). Hier werden sie nur gespeichert und
* bearbeitet — weitergereicht werden sie, damit die Server Action und das
* Formular ihren Import nicht aendern muessen.
*/
export { NamensregelnSchema, type Namensregeln };
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 },
},
});
});
}