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

Laufzettel läuft jetzt in zwei Betriebsmodi, aber nicht in zwei Codepfaden. Jede
kundenbezogene Zeile trägt ohnehin schon eine Kunden-Zuordnung; im Modus INTERN
ist das die eine Organisation, die in der Oberfläche nirgends erscheint. Der
Wechsel ist deshalb eine Datenänderung und keine Migration — dieselbe Zeile
bleibt, nichts hängt um.

src/server/mandant.ts ist die eine Stelle, an der die Mandantenregel steht:
kundeErmitteln verwirft im Modus INTERN eine mitgeschickte kundeId, verlangt im
Modus DIENSTLEISTUNG eine Wahl und prüft, ob der Benutzer den Kunden überhaupt
sehen darf. Die Sichtbarkeit steckte zunächst in einer zweiten Funktion daneben,
an die jeder künftige Aufrufer selbst hätte denken müssen; da #009 diese Signatur
übernimmt, ist sie jetzt eingebaut.

Der unabhängige Review fand drei Blocker, alle behoben.

Die Lesemodelle wandten die Mandantensicht nicht an. kundenListe gab jedem alle
Kunden zurück, und sichtbareKundenIds hatte in der Produktion keinen einzigen
Aufrufer — ein gebundener Techniker hätte Namen, Notizen und Bestandszahlen
fremder Kunden gesehen. Die Funktionen verlangen jetzt den Benutzer und filtern
selbst, wie CLAUDE.md es verlangt.

Mein Kommentar "Alles in einer Transaktion" war falsch: der Protokolleintrag
stand dahinter. Schlug er fehl, war der Modus gewechselt und der Vorgang stand
nirgends. Und Prüfung und Vollzug lagen auseinander, sodass zwei gleichzeitige
Wechsel beide durchliefen und ein zwischendurch angelegter Kunde zu Modus INTERN
bei zwei Kunden führte. Beides reproduziert, beides geschlossen: alles in einer
Transaktion, mit pg_advisory_xact_lock als erster Anweisung.

Dazu sechs kleinere Befunde: ASCII-Faltung in Texten, die Menschen lesen (die
Regel gilt für Bezeichner, nicht für Fließtext — steht jetzt so in CLAUDE.md);
eine Erfolgsmeldung, die nach dem Wechsel unsichtbar blieb; eine Zusage im
Dialog, die der Code nicht durchsetzt; fehlende Fehlerbehandlung beim
Archivieren; ein Handy-Screenshot, der das Navigationsmenü statt des Dialogs
zeigte.

Der Wechsel wurde nicht nur im Service geprüft, sondern per Playwright durch die
Oberfläche gefahren: anmelden, Dialog aufklappen, bestätigen, und danach steht
die Navigation auf Kunden.

336 Tests.

Closes #8

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-25 07:01:46 +02:00
co-authored by Claude Opus 5
parent 40e95dbe5f
commit 8e67591912
43 changed files with 2729 additions and 53 deletions
+43
View File
@@ -0,0 +1,43 @@
import Link from "next/link";
import { headers } from "next/headers";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
/**
* Der Verwaltungsbereich. Die Rechtepruefung steht hier **und** in jeder Seite
* darunter: Next rendert ein Layout bei Navigation innerhalb desselben Layouts
* nicht neu, es ist also das zweite Netz und nicht das erste (siehe CLAUDE.md).
*/
const REITER = [
{ pfad: "/admin", beschriftung: "Einstellungen" },
{ pfad: "/admin/benutzer", beschriftung: "Benutzer" },
] as const;
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
await sitzungErforderlich("verwaltung", "/admin");
const pfad = (await headers()).get("x-pfad") ?? "/admin";
return (
<>
<nav aria-label="Verwaltung" className="mb-8 flex flex-wrap gap-2">
{REITER.map((reiter) => {
const aktiv = pfad === reiter.pfad;
return (
<Link
key={reiter.pfad}
href={reiter.pfad}
aria-current={aktiv ? "page" : undefined}
className={`flex min-h-11 items-center rounded-pille px-4 text-sm transition-colors md:min-h-9 ${
aktiv
? "bg-akzent-flaeche font-semibold text-akzent"
: "border border-linie text-nebentext hover:bg-flaeche-hoch hover:text-text"
}`}
>
{reiter.beschriftung}
</Link>
);
})}
</nav>
{children}
</>
);
}
+90
View File
@@ -0,0 +1,90 @@
import { Betriebsmodus } from "@/generated/prisma/enums";
import { EinstellungsFormular } from "@/components/einstellungen-formular";
import { ModusWechsel } from "@/components/modus-wechsel";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { einstellungenHolen, modusWechselPruefen } from "@/server/services/einstellungen";
const MODUS_NAME: Record<Betriebsmodus, string> = {
INTERN: "Intern",
DIENSTLEISTUNG: "Dienstleistung",
};
const MODUS_ERKLAERUNG: Record<Betriebsmodus, string> = {
INTERN:
"Einsatz in einem Unternehmen für die eigenen Mitarbeiter. Ein Verzeichnis per LDAP, keine Kundenebene.",
DIENSTLEISTUNG:
"Einsatz als IT-Dienstleister für mehrere Kunden. Geräte, Personen und Abteilungen hängen an einem Kunden; kein LDAP zu Kunden-Verzeichnissen.",
};
export default async function EinstellungenSeite() {
await sitzungErforderlich("verwaltung", "/admin");
const einstellungen = await einstellungenHolen();
const gegenteil =
einstellungen.modus === Betriebsmodus.INTERN ? Betriebsmodus.DIENSTLEISTUNG : Betriebsmodus.INTERN;
const pruefung = await modusWechselPruefen(gegenteil);
return (
<>
<h1 className="text-2xl font-bold">Einstellungen</h1>
<section className="mt-8 rounded-karte border border-linie bg-flaeche p-5">
<div className="flex flex-wrap items-baseline gap-3">
<h2 className="text-lg font-semibold">Betriebsmodus</h2>
<span className="rounded-pille bg-akzent-flaeche px-3 py-1 text-xs font-semibold text-akzent">
{MODUS_NAME[einstellungen.modus]}
</span>
</div>
<p className="mt-2 mb-5 max-w-prose text-sm text-nebentext">
{MODUS_ERKLAERUNG[einstellungen.modus]}
</p>
<ModusWechsel
auskunft={{
zielModus: gegenteil,
zielName: MODUS_NAME[gegenteil],
moeglich: pruefung.moeglich,
...(pruefung.grund === undefined ? {} : { grund: pruefung.grund }),
brauchtAngaben: pruefung.brauchtAngaben,
folgen: pruefung.folgen,
vorschlagName: einstellungen.eigeneOrganisation.name,
vorschlagKuerzel: einstellungen.eigeneOrganisation.kuerzel,
}}
/>
</section>
<section className="mt-6 rounded-karte border border-linie bg-flaeche p-5">
<EinstellungsFormular
werte={{
eigeneOrganisationName: einstellungen.eigeneOrganisation.name,
gesperrteOu: einstellungen.gesperrteOu ?? "",
regeln: {
upn: einstellungen.namensregeln.upn ?? "",
sam: einstellungen.namensregeln.sam ?? "",
mail: einstellungen.namensregeln.mail ?? "",
hostname: einstellungen.namensregeln.hostname ?? "",
},
kundenEbeneSichtbar: einstellungen.modus === Betriebsmodus.DIENSTLEISTUNG,
}}
/>
</section>
<section className="mt-6 rounded-karte border border-linie bg-flaeche p-5">
<h2 className="text-lg font-semibold">Adresse für die QR-Codes</h2>
<p className="mt-1 mb-3 max-w-prose text-sm text-nebentext">
Jeder gedruckte QR-Code enthält diese Adresse plus den Tag-Code. Sie kommt aus der Umgebung (
<code className="mono">APP_URL</code>) und lässt sich hier nicht ändern — gedruckte Aufkleber würden
sonst ins Leere zeigen.
</p>
<p className="mono rounded-karte border border-linie bg-grund px-3 py-2 text-sm">
{einstellungen.appUrl ? (
`${einstellungen.appUrl}/t/LZ-7K3F9Q`
) : (
<span className="text-faellig">
APP_URL ist nicht gesetzt — Etiketten können noch nicht gedruckt werden.
</span>
)}
</p>
</section>
</>
);
}
+105
View File
@@ -0,0 +1,105 @@
import { redirect } from "next/navigation";
import { KundeAnlegen } from "@/components/kunde-anlegen";
import { KundeBearbeiten } from "@/components/kunde-bearbeiten";
import { KundeArchivieren } from "@/components/kunde-archivieren";
import { darf } from "@/server/auth/rollen";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { kundenEbeneSichtbar } from "@/server/mandant";
import { kundenListe, type KundenZeile } from "@/server/queries/kunden";
/**
* Kundenliste – nur im Modus Dienstleistung.
*
* Im Modus Intern gibt es genau einen Kunden, und der erscheint nirgends. Die
* Seite leitet dann auf die Uebersicht, statt eine leere Liste zu zeigen.
*/
function Zaehlung({ zeile }: { zeile: KundenZeile }) {
const teile = [
[zeile.anzahlGeraete, "Gerät", "Geräte"],
[zeile.anzahlMitarbeiter, "Person", "Personen"],
[zeile.anzahlLaeufe, "Lauf", "Läufe"],
] as const;
return (
<p className="mt-1 text-xs text-nebentext">
{teile.map(([anzahl, eins, viele]) => `${anzahl} ${anzahl === 1 ? eins : viele}`).join(" · ")}
</p>
);
}
export default async function KundenSeite() {
const { benutzer } = await sitzungErforderlich("lesen", "/kunden");
if (!(await kundenEbeneSichtbar())) {
redirect("/");
}
const darfVerwalten = darf(benutzer.rolle, "verwaltung");
const zeilen = await kundenListe(benutzer, true);
return (
<>
<h1 className="text-2xl font-bold">Kunden</h1>
<p className="mt-2 max-w-prose text-nebentext">
Geräte, Personen, Abteilungen und Läufe hängen an einem Kunden. Kunden werden archiviert, nicht
gelöscht — sonst verlöre das Archiv seine Bezüge.
</p>
<ul className="mt-8 flex flex-col gap-3">
{zeilen.map((zeile) => (
<li key={zeile.id} className="rounded-karte border border-linie bg-flaeche">
<div className="flex flex-col gap-3 p-4 md:flex-row md:items-center md:justify-between">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="mono rounded-pille bg-flaeche-hoch px-2.5 py-1 text-xs text-nebentext">
{zeile.kuerzel}
</span>
<p className={zeile.archiviert ? "text-nebentext line-through" : "font-medium"}>
{zeile.name}
</p>
{zeile.istEigeneOrganisation ? (
<span className="rounded-pille bg-akzent-flaeche px-2.5 py-1 text-xs text-akzent">
eigene Organisation
</span>
) : null}
</div>
<Zaehlung zeile={zeile} />
{zeile.notizen ? (
<p className="mt-2 max-w-prose text-sm text-nebentext">{zeile.notizen}</p>
) : null}
</div>
{darfVerwalten && !zeile.istEigeneOrganisation ? (
<KundeArchivieren kundeId={zeile.id} archiviert={zeile.archiviert} />
) : null}
</div>
{darfVerwalten ? (
<details className="group">
<summary className="flex min-h-11 cursor-pointer list-none items-center px-4 text-sm text-nebentext hover:text-text">
Bearbeiten
</summary>
<KundeBearbeiten
kunde={{
id: zeile.id,
name: zeile.name,
kuerzel: zeile.kuerzel,
notizen: zeile.notizen,
}}
/>
</details>
) : null}
</li>
))}
</ul>
{darfVerwalten ? (
<section className="mt-8 rounded-karte border border-linie bg-flaeche p-5">
<h2 className="text-lg font-semibold">Kunde anlegen</h2>
<p className="mt-1 mb-5 max-w-prose text-sm text-nebentext">
Das Kürzel erscheint auf den Etiketten und lässt sich in der Hostname-Regel als{" "}
<code className="mono">{"{kundenkuerzel}"}</code> verwenden.
</p>
<KundeAnlegen />
</section>
) : null}
</>
);
}
+5 -1
View File
@@ -1,5 +1,6 @@
import { headers } from "next/headers";
import { Huelle } from "@/components/huelle";
import { betriebsmodus } from "@/server/mandant";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
/**
@@ -16,9 +17,12 @@ export default async function AppLayout({ children }: { children: React.ReactNod
// /laeufe?filter=offen kommt, soll auch dorthin zurueck und nicht auf /laeufe.
const ziel = pfad + (kopfzeilen.get("x-abfrage") ?? "");
const { benutzer } = await sitzungErforderlich(undefined, ziel);
// Der Modus steuert Navigation und Beschriftungen – aber nur ein- und
// ausgeblendete Eintraege, nicht zwei Codepfade.
const modus = await betriebsmodus();
return (
<Huelle aktiv={pfad} benutzer={{ name: benutzer.name, rolle: benutzer.rolle }}>
<Huelle aktiv={pfad} modus={modus} benutzer={{ name: benutzer.name, rolle: benutzer.rolle }}>
{children}
</Huelle>
);
+130
View File
@@ -0,0 +1,130 @@
"use client";
import { useActionState } from "react";
import { Button } from "@/components/ui/button";
import { einstellungenSpeichernAktion, type AktionsErgebnis } from "@/server/actions/einstellungen";
const FELD =
"min-h-11 rounded-karte border border-linie bg-grund px-3 text-sm text-text outline-none focus-visible:border-akzent";
export type EinstellungsWerte = {
readonly eigeneOrganisationName: string;
readonly gesperrteOu: string;
readonly regeln: {
readonly upn: string;
readonly sam: string;
readonly mail: string;
readonly hostname: string;
};
/** Im Modus Dienstleistung heisst die Organisation in der Oberflaeche "Kunde". */
readonly kundenEbeneSichtbar: boolean;
};
const REGELN = [
{
name: "regel_upn",
beschriftung: "UPN",
schluessel: "upn",
beispiel: "{vorname}.{nachname}@firma.de",
},
{ name: "regel_sam", beschriftung: "Anmeldename (sam)", schluessel: "sam", beispiel: "{v}{nachname}" },
{ name: "regel_mail", beschriftung: "Mail", schluessel: "mail", beispiel: "{vorname}.{nachname}@firma.de" },
{ name: "regel_hostname", beschriftung: "Hostname", schluessel: "hostname", beispiel: "PC-{nnn}" },
] as const;
export function EinstellungsFormular({ werte }: { werte: EinstellungsWerte }) {
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(
einstellungenSpeichernAktion,
{},
);
return (
<form action={aktion} className="flex flex-col gap-8">
<section>
<h2 className="text-lg font-semibold">
{werte.kundenEbeneSichtbar ? "Eigene Organisation" : "Organisation"}
</h2>
<p className="mt-1 mb-4 max-w-prose text-sm text-nebentext">
{werte.kundenEbeneSichtbar
? "Der eigene Betrieb, der im Modus Dienstleistung als erster Kunde geführt wird."
: "Im Modus Intern erscheint dieser Name nirgends in der Oberfläche. Er wird erst sichtbar, wenn du auf Dienstleistung umstellst."}
</p>
<label className="flex max-w-md flex-col gap-1.5 text-sm">
<span className="text-nebentext">Name</span>
<input
name="eigeneOrganisationName"
defaultValue={werte.eigeneOrganisationName}
required
className={FELD}
/>
</label>
</section>
<section>
<h2 className="text-lg font-semibold">Namensregeln</h2>
<p className="mt-1 mb-4 max-w-prose text-sm text-nebentext">
Vorlagen für UPN, Anmeldename, Mail und Hostname. Beim Start eines Laufs werden daraus Vorschläge
berechnet, die der Techniker überschreiben kann. Platzhalter:{" "}
<code className="mono">{"{vorname}"}</code>, <code className="mono">{"{nachname}"}</code>,{" "}
<code className="mono">{"{v}"}</code> für den ersten Buchstaben des Vornamens,{" "}
<code className="mono">{"{nnn}"}</code> für eine fortlaufende Nummer
{werte.kundenEbeneSichtbar ? (
<>
{" "}
und <code className="mono">{"{kundenkuerzel}"}</code>
</>
) : null}
.
</p>
<div className="grid gap-4 sm:grid-cols-2">
{REGELN.map((regel) => (
<label key={regel.name} className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">{regel.beschriftung}</span>
<input
name={regel.name}
defaultValue={werte.regeln[regel.schluessel]}
placeholder={regel.beispiel}
className={`${FELD} mono`}
/>
</label>
))}
</div>
</section>
<section>
<h2 className="text-lg font-semibold">Sperr-OU</h2>
<p className="mt-1 mb-4 max-w-prose text-sm text-nebentext">
Ziel-OU für deaktivierte Konten. Wird beim Offboarding vorbelegt.
</p>
<label className="flex max-w-lg flex-col gap-1.5 text-sm">
<span className="text-nebentext">Organisationseinheit</span>
<input
name="gesperrteOu"
defaultValue={werte.gesperrteOu}
placeholder="OU=Gesperrt,OU=Benutzer,DC=firma,DC=local"
className={`${FELD} mono`}
/>
</label>
</section>
{ergebnis.fehler ? (
<p role="alert" className="text-sm text-fehler">
{ergebnis.fehler}
</p>
) : null}
{ergebnis.erfolg ? (
<p role="status" className="text-sm text-erledigt">
{ergebnis.erfolg}
</p>
) : null}
<Button
type="submit"
disabled={laeuft}
className="h-11 self-start rounded-pille px-5 text-sm font-semibold"
>
{laeuft ? "Wird gespeichert …" : "Speichern"}
</Button>
</form>
);
}
+6 -2
View File
@@ -137,12 +137,16 @@ function BenutzerBlock({ benutzer }: { benutzer: AngemeldeterBenutzer }) {
*/
export function Huelle({
children,
modus = "INTERN",
modus,
aktiv = "/",
benutzer,
}: {
children: React.ReactNode;
modus?: Betriebsmodus;
/**
* Pflicht, ohne stillen Standard: ein vergessener Parameter wuerde sonst die
* Kundenebene ausblenden, statt aufzufallen.
*/
modus: Betriebsmodus;
aktiv?: string;
benutzer?: AngemeldeterBenutzer;
}) {
+50
View File
@@ -0,0 +1,50 @@
"use client";
import { useActionState } from "react";
import { Button } from "@/components/ui/button";
import { kundeAnlegenAktion, type AktionsErgebnis } from "@/server/actions/kunden";
const FELD =
"min-h-11 rounded-karte border border-linie bg-grund px-3 text-sm text-text outline-none focus-visible:border-akzent";
export function KundeAnlegen() {
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(kundeAnlegenAktion, {});
return (
<form action={aktion} className="flex flex-col gap-4">
<div className="grid gap-4 sm:grid-cols-[1fr_10rem]">
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Name</span>
<input name="name" required className={FELD} />
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Kürzel (2–6)</span>
<input name="kuerzel" required pattern="[A-Za-z0-9]{2,6}" className={`${FELD} mono uppercase`} />
</label>
</div>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Notizen (optional)</span>
<textarea name="notizen" rows={2} className={`${FELD} py-2`} />
</label>
{ergebnis.fehler ? (
<p role="alert" className="text-sm text-fehler">
{ergebnis.fehler}
</p>
) : null}
{ergebnis.erfolg ? (
<p role="status" className="text-sm text-erledigt">
{ergebnis.erfolg}
</p>
) : null}
<Button
type="submit"
disabled={laeuft}
className="h-11 self-start rounded-pille px-5 text-sm font-semibold"
>
{laeuft ? "Wird angelegt …" : "Kunde anlegen"}
</Button>
</form>
);
}
+40
View File
@@ -0,0 +1,40 @@
"use client";
import { useActionState } from "react";
import { kundeArchivierenAktion, type AktionsErgebnis } from "@/server/actions/kunden";
/**
* Archivieren und Freigeben eines Kunden.
*
* Als Client-Komponente, damit die Begruendung sichtbar wird, wenn der Service
* ablehnt — etwa beim Versuch, die eigene Organisation zu archivieren. Zuvor
* endete das in einer Fehlerseite.
*/
export function KundeArchivieren({ kundeId, archiviert }: { kundeId: string; archiviert: boolean }) {
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(kundeArchivierenAktion, {});
return (
<div className="shrink-0">
<form action={aktion}>
<input type="hidden" name="id" value={kundeId} />
<input type="hidden" name="archiviert" value={archiviert ? "false" : "true"} />
<button
type="submit"
disabled={laeuft}
className={`min-h-11 rounded-pille px-3 text-sm md:min-h-9 ${
archiviert
? "bg-flaeche-hoch text-nebentext hover:text-text"
: "border border-linie text-nebentext hover:bg-flaeche-hoch hover:text-text"
}`}
>
{archiviert ? "wieder freigeben" : "archivieren"}
</button>
</form>
{ergebnis.fehler ? (
<p role="alert" className="mt-2 max-w-xs text-xs text-fehler">
{ergebnis.fehler}
</p>
) : null}
</div>
);
}
+67
View File
@@ -0,0 +1,67 @@
"use client";
import { useActionState } from "react";
import { Button } from "@/components/ui/button";
import { kundeBearbeitenAktion, type AktionsErgebnis } from "@/server/actions/kunden";
const FELD =
"min-h-11 rounded-karte border border-linie bg-grund px-3 text-sm text-text outline-none focus-visible:border-akzent";
export function KundeBearbeiten({
kunde,
}: {
kunde: {
readonly id: string;
readonly name: string;
readonly kuerzel: string;
readonly notizen: string | null;
};
}) {
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(kundeBearbeitenAktion, {});
return (
<form action={aktion} className="flex flex-col gap-3 border-t border-linie p-4">
<input type="hidden" name="id" value={kunde.id} />
<div className="grid gap-3 sm:grid-cols-[1fr_10rem]">
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Name</span>
<input name="name" defaultValue={kunde.name} required className={FELD} />
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Kürzel</span>
<input
name="kuerzel"
defaultValue={kunde.kuerzel}
required
pattern="[A-Za-z0-9]{2,6}"
className={`${FELD} mono uppercase`}
/>
</label>
</div>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Notizen</span>
<textarea name="notizen" defaultValue={kunde.notizen ?? ""} rows={2} className={`${FELD} py-2`} />
</label>
{ergebnis.fehler ? (
<p role="alert" className="text-sm text-fehler">
{ergebnis.fehler}
</p>
) : null}
{ergebnis.erfolg ? (
<p role="status" className="text-sm text-erledigt">
{ergebnis.erfolg}
</p>
) : null}
<Button
type="submit"
disabled={laeuft}
variant="outline"
className="h-11 self-start rounded-pille px-4 text-sm md:h-9"
>
{laeuft ? "Wird gespeichert …" : "Speichern"}
</Button>
</form>
);
}
+132
View File
@@ -0,0 +1,132 @@
"use client";
import { useActionState } from "react";
import { AlertTriangle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { modusWechselnAktion, type AktionsErgebnis } from "@/server/actions/einstellungen";
const FELD =
"min-h-11 rounded-karte border border-linie bg-grund px-3 text-sm text-text outline-none focus-visible:border-akzent";
export type WechselAuskunft = {
readonly zielModus: "INTERN" | "DIENSTLEISTUNG";
readonly zielName: string;
readonly moeglich: boolean;
readonly grund?: string;
readonly brauchtAngaben: boolean;
readonly folgen: readonly string[];
readonly vorschlagName: string;
readonly vorschlagKuerzel: string;
};
/**
* Bestaetigungsdialog fuer den Moduswechsel.
*
* Die Folgen kommen aus derselben Pruefung, die der Wechsel danach durchsetzt –
* keine zweite, nachgebaute Wahrheit. Aufgeklappt wird mit `details`, damit der
* Dialog auch ohne JavaScript zugaenglich ist; das Haekchen ist Pflicht, weil
* der Wechsel aendert, was die ganze Anwendung zeigt.
*/
export function ModusWechsel({ auskunft }: { auskunft: WechselAuskunft }) {
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(modusWechselnAktion, {});
// Die Rueckmeldung steht ausserhalb der beiden Zweige: nach einem gelungenen
// Wechsel zeigt die Seite die Gegenrichtung, und die ist meist gesperrt – der
// Erfolgstext waere sonst genau dann verschwunden, wenn er gebraucht wird.
const rueckmeldung = (
<>
{ergebnis.fehler ? (
<p role="alert" className="text-sm text-fehler">
{ergebnis.fehler}
</p>
) : null}
{ergebnis.erfolg ? (
<p role="status" className="text-sm text-erledigt">
{ergebnis.erfolg}
</p>
) : null}
</>
);
if (!auskunft.moeglich) {
return (
<div className="flex flex-col gap-3">
{rueckmeldung}
<div className="rounded-karte border border-linie bg-flaeche-hoch p-4">
<p className="text-sm text-nebentext">
<span className="font-medium text-text">
Wechsel zu {auskunft.zielName} gerade nicht möglich.
</span>{" "}
{auskunft.grund}
</p>
</div>
</div>
);
}
return (
<div className="flex flex-col gap-3">
{ergebnis.erfolg || ergebnis.fehler ? rueckmeldung : null}
<details className="group rounded-karte border border-linie bg-flaeche-hoch">
<summary className="flex min-h-11 cursor-pointer list-none items-center gap-2 px-4 py-3 text-sm font-medium">
<AlertTriangle className="size-[18px] text-faellig" strokeWidth={1.75} aria-hidden="true" />
Zu {auskunft.zielName} wechseln
<span className="ml-auto text-xs text-nebentext group-open:hidden">aufklappen</span>
</summary>
<form action={aktion} className="flex flex-col gap-4 border-t border-linie p-4">
<input type="hidden" name="ziel" value={auskunft.zielModus} />
<div>
<p className="text-sm font-medium">Das passiert:</p>
<ul className="mt-2 flex list-disc flex-col gap-1 pl-5 text-sm text-nebentext">
{auskunft.folgen.map((folge) => (
<li key={folge}>{folge}</li>
))}
</ul>
</div>
{auskunft.brauchtAngaben ? (
<div className="grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Name als erster Kunde</span>
<input name="name" defaultValue={auskunft.vorschlagName} required className={FELD} />
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Kürzel (2–6 Zeichen)</span>
<input
name="kuerzel"
defaultValue={auskunft.vorschlagKuerzel}
required
pattern="[A-Za-z0-9]{2,6}"
className={`${FELD} mono uppercase`}
/>
</label>
</div>
) : null}
<label className="flex items-start gap-3 text-sm">
<input
type="checkbox"
name="bestaetigt"
value="ja"
required
className="mt-1 size-4 accent-[var(--color-akzent)]"
/>
<span>Ja, den Betriebsmodus jetzt auf {auskunft.zielName} umstellen.</span>
</label>
{rueckmeldung}
<Button
type="submit"
disabled={laeuft}
className="h-11 self-start rounded-pille px-5 text-sm font-semibold"
>
{laeuft ? "Wird umgestellt …" : `Auf ${auskunft.zielName} umstellen`}
</Button>
</form>
</details>
</div>
);
}
+2 -17
View File
@@ -12,8 +12,6 @@ export type Betriebsmodus = "INTERN" | "DIENSTLEISTUNG";
export type NavEintrag = {
readonly pfad: string;
readonly beschriftung: string;
/** Beschriftung im Modus Dienstleistung, falls sie abweicht. */
readonly beschriftungDienstleistung?: string;
/** Nur in diesem Modus zeigen. Fehlt der Wert, gilt der Eintrag in beiden. */
readonly nurModus?: Betriebsmodus;
readonly symbol: string;
@@ -41,19 +39,10 @@ export const NAVIGATION: readonly NavEintrag[] = [
beschriftung: "Kunden",
nurModus: "DIENSTLEISTUNG",
symbol: "Building2",
verfuegbar: false,
ausIssue: 8,
},
{ pfad: "/archiv", beschriftung: "Archiv", symbol: "Archive", verfuegbar: false, ausIssue: 15 },
{
// Zeigt vorerst direkt auf die Benutzerverwaltung; mit #008 kommen die
// Einstellungen dazu und daraus wird ein eigener Bereich.
pfad: "/admin/benutzer",
beschriftung: "Admin",
symbol: "Settings",
recht: "verwaltung",
verfuegbar: true,
},
{ pfad: "/archiv", beschriftung: "Archiv", symbol: "Archive", verfuegbar: false, ausIssue: 15 },
{ pfad: "/admin", beschriftung: "Admin", symbol: "Settings", recht: "verwaltung", verfuegbar: true },
];
/**
@@ -70,9 +59,5 @@ export function navigationFuer(
return NAVIGATION.filter(
(e) =>
(e.nurModus === undefined || e.nurModus === modus) && (e.recht === undefined || darfEintrag(e.recht)),
).map((e) =>
modus === "DIENSTLEISTUNG" && e.beschriftungDienstleistung
? { ...e, beschriftung: e.beschriftungDienstleistung }
: e,
);
}
+106
View File
@@ -0,0 +1,106 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { Betriebsmodus } from "@/generated/prisma/enums";
import {
EinstellungsFehler,
NamensregelnSchema,
einstellungenSpeichern,
modusWechseln,
} from "@/server/services/einstellungen";
import { rechtErforderlich } from "@/server/auth/sitzungspflicht";
/** Server Actions der Einstellungen (#008). Recht prüfen, Zod, Service rufen. */
export type AktionsErgebnis = { readonly fehler?: string; readonly erfolg?: string };
const EinstellungsSchema = z.object({
eigeneOrganisationName: z.string().trim().min(1, "Die eigene Organisation braucht einen Namen."),
gesperrteOu: z.string().trim().optional(),
namensregeln: NamensregelnSchema,
});
export async function einstellungenSpeichernAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const { benutzer } = await rechtErforderlich("verwaltung");
const eingabe = EinstellungsSchema.safeParse({
eigeneOrganisationName: formular.get("eigeneOrganisationName"),
gesperrteOu: formular.get("gesperrteOu") ?? undefined,
namensregeln: {
upn: formular.get("regel_upn") ?? undefined,
sam: formular.get("regel_sam") ?? undefined,
mail: formular.get("regel_mail") ?? undefined,
hostname: formular.get("regel_hostname") ?? undefined,
},
});
if (!eingabe.success) {
return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
}
try {
await einstellungenSpeichern(
{
eigeneOrganisationName: eingabe.data.eigeneOrganisationName,
namensregeln: eingabe.data.namensregeln,
gesperrteOu: eingabe.data.gesperrteOu ?? null,
},
benutzer.id,
);
revalidatePath("/admin");
return { erfolg: "Gespeichert." };
} catch (fehler) {
if (fehler instanceof EinstellungsFehler) return { fehler: fehler.message };
console.error("Einstellungen speichern fehlgeschlagen:", fehler);
return { fehler: "Die Einstellungen konnten nicht gespeichert werden." };
}
}
const WechselSchema = z.object({
ziel: z.enum(Betriebsmodus),
// Der Bestaetigungsdialog verlangt ein ausdrueckliches Haekchen: der Wechsel
// aendert, was die ganze Anwendung zeigt.
bestaetigt: z.literal("ja", { message: "Bitte den Wechsel ausdrücklich bestätigen." }),
name: z.string().trim().optional(),
kuerzel: z.string().trim().optional(),
});
export async function modusWechselnAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const { benutzer } = await rechtErforderlich("verwaltung");
const eingabe = WechselSchema.safeParse({
ziel: formular.get("ziel"),
bestaetigt: formular.get("bestaetigt"),
name: formular.get("name") ?? undefined,
kuerzel: formular.get("kuerzel") ?? undefined,
});
if (!eingabe.success) {
return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
}
try {
await modusWechseln(
eingabe.data.ziel,
{ name: eingabe.data.name, kuerzel: eingabe.data.kuerzel },
benutzer.id,
);
// Der Modus aendert Navigation und Beschriftungen ueberall.
revalidatePath("/", "layout");
return {
erfolg:
eingabe.data.ziel === Betriebsmodus.DIENSTLEISTUNG
? "Der Betriebsmodus ist jetzt Dienstleistung. Kunden sind ab sofort eine sichtbare Ebene."
: "Der Betriebsmodus ist jetzt Intern. Die Kundenebene ist ausgeblendet.",
};
} catch (fehler) {
if (fehler instanceof EinstellungsFehler) return { fehler: fehler.message };
console.error("Moduswechsel fehlgeschlagen:", fehler);
return { fehler: "Der Moduswechsel ist fehlgeschlagen." };
}
}
+109
View File
@@ -0,0 +1,109 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { KundenFehler, kundeAnlegen, kundeArchivieren, kundeBearbeiten } from "@/server/services/kunden";
import { rechtErforderlich } from "@/server/auth/sitzungspflicht";
/** Server Actions der Kundenverwaltung (#008). Recht prüfen, Zod, Service rufen. */
export type AktionsErgebnis = { readonly fehler?: string; readonly erfolg?: string };
const NeuSchema = z.object({
name: z.string().trim().min(1, "Bitte einen Namen angeben."),
kuerzel: z.string().trim().min(1, "Bitte ein Kürzel angeben."),
notizen: z.string().trim().optional(),
});
export async function kundeAnlegenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const { benutzer } = await rechtErforderlich("verwaltung");
const eingabe = NeuSchema.safeParse({
name: formular.get("name"),
kuerzel: formular.get("kuerzel"),
notizen: formular.get("notizen") ?? undefined,
});
if (!eingabe.success) {
return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
}
try {
const kunde = await kundeAnlegen(eingabe.data, benutzer.id);
revalidatePath("/kunden");
return { erfolg: `${kunde.name} (${kunde.kuerzel}) wurde angelegt.` };
} catch (fehler) {
if (fehler instanceof KundenFehler) return { fehler: fehler.message };
console.error("Kunde anlegen fehlgeschlagen:", fehler);
return { fehler: "Der Kunde konnte nicht angelegt werden." };
}
}
const BearbeitenSchema = z.object({
id: z.string().min(1),
name: z.string().trim().min(1, "Bitte einen Namen angeben."),
kuerzel: z.string().trim().min(1, "Bitte ein Kürzel angeben."),
notizen: z.string().trim().optional(),
});
export async function kundeBearbeitenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const { benutzer } = await rechtErforderlich("verwaltung");
const eingabe = BearbeitenSchema.safeParse({
id: formular.get("id"),
name: formular.get("name"),
kuerzel: formular.get("kuerzel"),
notizen: formular.get("notizen") ?? undefined,
});
if (!eingabe.success) {
return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
}
try {
await kundeBearbeiten(
eingabe.data.id,
{
name: eingabe.data.name,
kuerzel: eingabe.data.kuerzel,
notizen: eingabe.data.notizen ?? null,
},
benutzer.id,
);
revalidatePath("/kunden");
return { erfolg: "Gespeichert." };
} catch (fehler) {
if (fehler instanceof KundenFehler) return { fehler: fehler.message };
console.error("Kunde bearbeiten fehlgeschlagen:", fehler);
return { fehler: "Die Änderung konnte nicht gespeichert werden." };
}
}
export async function kundeArchivierenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const { benutzer } = await rechtErforderlich("verwaltung");
const eingabe = z
.object({ id: z.string().min(1), archiviert: z.enum(["true", "false"]) })
.safeParse({ id: formular.get("id"), archiviert: formular.get("archiviert") });
if (!eingabe.success) {
return { fehler: "Die Eingabe ist unvollständig." };
}
try {
await kundeArchivieren(eingabe.data.id, eingabe.data.archiviert === "true", benutzer.id);
revalidatePath("/kunden");
return {};
} catch (fehler) {
// Der Versuch, die eigene Organisation zu archivieren, hat eine sorgfaeltig
// formulierte Begruendung – die soll der Admin sehen und keine Fehlerseite.
if (fehler instanceof KundenFehler) return { fehler: fehler.message };
console.error("Kunde archivieren fehlgeschlagen:", fehler);
return { fehler: "Die Änderung konnte nicht gespeichert werden." };
}
}
+2 -2
View File
@@ -174,7 +174,7 @@ export async function benutzerAusOidcUebernehmen(anspruecke: OidcAnspruecke): Pr
data: {
entitaet: "Benutzer",
entitaetId: benutzer.id,
aktion: "Rolle aus den OIDC-Gruppen geaendert",
aktion: "Rolle aus den OIDC-Gruppen geändert",
benutzerId: benutzer.id,
vorher: { rolle: vorhanden.rolle },
nachher: { rolle },
@@ -287,7 +287,7 @@ export async function rolleSetzen(benutzerId: string, rolle: Rolle, durchBenutze
data: {
entitaet: "Benutzer",
entitaetId: benutzerId,
aktion: "Rolle geaendert",
aktion: "Rolle geändert",
benutzerId: durchBenutzerId,
vorher: { rolle: vorher.rolle },
nachher: { rolle },
+152
View File
@@ -0,0 +1,152 @@
import { cache } from "react";
import { Betriebsmodus, type Rolle } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
/**
* Die Mandantengrenze (#008).
*
* Laufzettel laeuft in zwei Betriebsmodi, aber **nicht** in zwei Codepfaden.
* Jede kundenbezogene Zeile traegt immer eine `kundeId`; im Modus INTERN ist das
* die eine Organisation mit `istEigeneOrganisation = true`, die in der
* Oberflaeche nirgends erscheint. Dadurch ist der Wechsel in den Modus
* DIENSTLEISTUNG eine Datenaenderung und keine Migration.
*
* Die Trennung nach Kunden ist eine Sicherheitsgrenze. Sie steht deshalb an drei
* Stellen: hier, als zusammengesetzter Fremdschluessel in der Datenbank (siehe
* docs/architektur.md) und in den Lesemodellen, die ihre Sicht selbst einengen.
*/
export class MandantFehler extends Error {
constructor(nachricht: string) {
super(nachricht);
this.name = "MandantFehler";
}
}
/**
* Der Betriebsmodus. Liegt in der Datenbank und nicht in der Umgebung, damit der
* Admin ohne Neustart umschalten kann.
*
* `cache` teilt die Abfrage innerhalb einer Anfrage; ueber Anfragen hinweg wird
* bewusst nicht gecacht, sonst wuerde ein Wechsel erst nach einem Neustart
* wirken.
*/
export const betriebsmodus = cache(async (): Promise<Betriebsmodus> => {
const einstellung = await prisma.einstellung.findUnique({
where: { id: "global" },
select: { modus: true },
});
// Ohne Einstellung ist INTERN der sichere Standard: keine Kundenebene, nichts
// faellt versehentlich auseinander.
return einstellung?.modus ?? Betriebsmodus.INTERN;
});
export type EigeneOrganisation = {
readonly id: string;
readonly name: string;
readonly kuerzel: string;
};
export const eigeneOrganisation = cache(async (): Promise<EigeneOrganisation> => {
const kunde = await prisma.kunde.findFirst({
where: { istEigeneOrganisation: true },
select: { id: true, name: true, kuerzel: true },
});
if (!kunde) {
throw new MandantFehler("Es gibt keine eigene Organisation. Der Seed legt sie an: pnpm db:seed.");
}
return kunde;
});
export type HandelnderBenutzer = {
readonly id: string;
readonly rolle: Rolle;
};
/**
* Welcher Kunde gilt fuer diesen Vorgang?
*
* Im Modus INTERN **immer** die eigene Organisation. Eine vom Client
* mitgeschickte `kundeId` wird dann nicht etwa abgelehnt, sondern schlicht
* verworfen — es gibt in diesem Modus keine Kundenwahl, also auch nichts zu
* entscheiden.
*
* Im Modus DIENSTLEISTUNG ist die Angabe Pflicht, muss auf einen nicht
* archivierten Kunden zeigen **und** fuer diesen Benutzer sichtbar sein. Die
* Sichtbarkeit steht bewusst hier und nicht in einer zweiten Funktion, an die
* jeder Aufrufer selbst denken muesste: das ist die eine Stelle, an der die
* Mandantenregel steht, und jeder Service, der eine kundenbezogene Zeile
* schreibt, ruft sie.
*/
export async function kundeErmitteln(
kundeIdAusEingabe: string | null | undefined,
benutzer: HandelnderBenutzer,
): Promise<string> {
if ((await betriebsmodus()) === Betriebsmodus.INTERN) {
return (await eigeneOrganisation()).id;
}
const gewuenscht = kundeIdAusEingabe?.trim();
if (!gewuenscht) {
throw new MandantFehler("Im Modus Dienstleistung muss ein Kunde gewählt werden.");
}
const kunde = await prisma.kunde.findUnique({
where: { id: gewuenscht },
select: { id: true, archiviert: true, name: true },
});
if (!kunde) {
throw new MandantFehler("Diesen Kunden gibt es nicht.");
}
if (kunde.archiviert) {
throw new MandantFehler(`${kunde.name} ist archiviert und kann nichts Neues aufnehmen.`);
}
await kundeZugriffPruefen(kunde.id, benutzer);
return kunde.id;
}
/**
* Die Kunden, die dieser Benutzer sehen darf – oder `undefined` fuer "alle".
*
* `undefined` und eine leere Liste bedeuten Verschiedenes: `undefined` heisst
* "keine Einschraenkung", eine leere Liste heisst "sieht nichts". Ein Aufrufer,
* der beides verwechselt, zeigt entweder zu viel oder zu wenig.
*
* Eingeschraenkt wird nur im Modus DIENSTLEISTUNG, nur fuer TECHNIKER und LESER
* und nur, wenn ueberhaupt eine Bindung gepflegt ist (OIDC_CUSTOMER_GROUP_PREFIX).
*/
export async function sichtbareKundenIds(
benutzer: HandelnderBenutzer,
): Promise<readonly string[] | undefined> {
if ((await betriebsmodus()) === Betriebsmodus.INTERN) return undefined;
if (benutzer.rolle === "ADMIN" || benutzer.rolle === "REDAKTEUR") return undefined;
if (!process.env["OIDC_CUSTOMER_GROUP_PREFIX"]?.trim()) return undefined;
const bindungen = await prisma.benutzerKunde.findMany({
where: { benutzerId: benutzer.id },
select: { kundeId: true },
});
return bindungen.map((bindung) => bindung.kundeId);
}
/**
* Darf dieser Benutzer auf diesen Kunden? Wirft, wenn nicht.
*
* `kundeErmitteln` ruft das selbst. Einzeln gebraucht wird es von Services, die
* eine vorhandene Zeile aendern: dort steht die `kundeId` schon fest.
*/
export async function kundeZugriffPruefen(kundeId: string, benutzer: HandelnderBenutzer): Promise<void> {
const sichtbar = await sichtbareKundenIds(benutzer);
if (sichtbar === undefined) return;
if (!sichtbar.includes(kundeId)) {
throw new MandantFehler("Für diesen Kunden fehlt dir die Berechtigung.");
}
}
/**
* Im Modus INTERN wird die Kundenebene nirgends gezeigt – keine Liste, kein
* Filter, kein Formular, kein Etikett.
*/
export async function kundenEbeneSichtbar(): Promise<boolean> {
return (await betriebsmodus()) === Betriebsmodus.DIENSTLEISTUNG;
}
+117
View File
@@ -0,0 +1,117 @@
import { prisma } from "@/lib/db";
import { sichtbareKundenIds, type HandelnderBenutzer } from "@/server/mandant";
/**
* Lesemodelle der Kundenverwaltung (#008).
*
* Sichtbar sind diese Listen nur im Modus `DIENSTLEISTUNG`; im Modus `INTERN`
* gibt es genau die eigene Organisation, und die erscheint nirgends. Welcher
* Modus gilt, entscheidet der Aufrufer.
*
* Die Mandantensicht wenden diese Funktionen aber **selbst** an, statt sie dem
* Aufrufer zu ueberlassen: ein vergessener Filter waere hier kein Schoenheits-,
* sondern ein Datenschutzfehler. Deshalb verlangt jede von ihnen den Benutzer.
*/
/**
* `undefined` heisst "keine Einschraenkung", eine leere Liste heisst "sieht
* nichts". Wer das verwechselt, zeigt entweder alles oder gar nichts — deshalb
* steht die Umsetzung an einer Stelle.
*/
async function sichtbarkeitsFilter(benutzer: HandelnderBenutzer) {
const sichtbar = await sichtbareKundenIds(benutzer);
return sichtbar === undefined ? {} : { id: { in: [...sichtbar] } };
}
export type KundenZeile = {
readonly id: string;
readonly name: string;
readonly kuerzel: string;
readonly istEigeneOrganisation: boolean;
readonly archiviert: boolean;
readonly notizen: string | null;
/** Fuer die Kundenliste: woran haengt der Kunde? */
readonly anzahlGeraete: number;
readonly anzahlMitarbeiter: number;
readonly anzahlLaeufe: number;
};
/**
* Die Zaehlungen kommen als `_count` aus derselben Abfrage.
*
* In einer Schleife nachzuzaehlen waere eine Abfrage je Kunde – bei einem
* Dienstleister mit dreissig Kunden also einundneunzig statt einer.
*/
const AUSWAHL = {
id: true,
name: true,
kuerzel: true,
istEigeneOrganisation: true,
archiviert: true,
notizen: true,
_count: { select: { geraete: true, mitarbeiter: true, laeufe: true } },
} as const;
type RohZeile = {
id: string;
name: string;
kuerzel: string;
istEigeneOrganisation: boolean;
archiviert: boolean;
notizen: string | null;
_count: { geraete: number; mitarbeiter: number; laeufe: number };
};
function zeileBauen(roh: RohZeile): KundenZeile {
return {
id: roh.id,
name: roh.name,
kuerzel: roh.kuerzel,
istEigeneOrganisation: roh.istEigeneOrganisation,
archiviert: roh.archiviert,
notizen: roh.notizen,
anzahlGeraete: roh._count.geraete,
anzahlMitarbeiter: roh._count.mitarbeiter,
anzahlLaeufe: roh._count.laeufe,
};
}
/**
* Alle Kunden, nicht archivierte zuerst, dann nach Namen.
*
* Archivierte bleiben standardmaessig aussen vor: die Liste ist der
* Alltagsbestand, das Archiv ist ein ausdruecklicher Griff.
*/
export async function kundenListe(
benutzer: HandelnderBenutzer,
auchArchivierte = false,
): Promise<readonly KundenZeile[]> {
const zeilen = await prisma.kunde.findMany({
where: {
...(await sichtbarkeitsFilter(benutzer)),
...(auchArchivierte ? {} : { archiviert: false }),
},
orderBy: [{ archiviert: "asc" }, { name: "asc" }],
select: AUSWAHL,
});
return zeilen.map(zeileBauen);
}
export async function kundeHolen(id: string, benutzer: HandelnderBenutzer): Promise<KundenZeile | undefined> {
const zeile = await prisma.kunde.findFirst({
where: { id, ...(await sichtbarkeitsFilter(benutzer)) },
select: AUSWAHL,
});
return zeile ? zeileBauen(zeile) : undefined;
}
/** Fuer Auswahlfelder: id, name, kuerzel nicht archivierter Kunden, nach Namen. */
export async function kundenZurAuswahl(
benutzer: HandelnderBenutzer,
): Promise<readonly { id: string; name: string; kuerzel: string }[]> {
return prisma.kunde.findMany({
where: { archiviert: false, ...(await sichtbarkeitsFilter(benutzer)) },
orderBy: { name: "asc" },
select: { id: true, name: true, kuerzel: true },
});
}
+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;
}