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 { 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 { 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 { 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 { 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 { 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 }, }, }); }); }