308 lines
11 KiB
TypeScript
308 lines
11 KiB
TypeScript
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 },
|
||
},
|
||
});
|
||
});
|
||
}
|