Merge branch 'phase/008-hosts-gruppen'

This commit is contained in:
2026-10-12 09:51:58 +02:00
31 changed files with 2649 additions and 39 deletions
+37
View File
@@ -0,0 +1,37 @@
import type { Benutzer } from "@/generated/prisma/client";
import { BestaetigungFehler, RechteFehler } from "@/server/auth/sitzungspflicht";
import { HostFehler, type Akteur } from "@/server/services/hosts";
/**
* Gemeinsames fuer Formular-Aktionen (#008). Kein "use server": diese Datei exportiert
* Hilfen, keine Aktionen – sonst waeren sie vom Browser aus aufrufbar.
*/
export type FormularZustand = {
readonly fehler?: string;
readonly feldfehler?: Record<string, string>;
};
/** Fachliche Fehler werden zur Meldung im Formular; alles andere bleibt ein echter Fehler. */
export function fehlerZustand(fehler: unknown): FormularZustand {
if (fehler instanceof HostFehler) {
return { fehler: fehler.message, feldfehler: fehler.feldfehler };
}
if (fehler instanceof RechteFehler || fehler instanceof BestaetigungFehler) {
return { fehler: fehler.message };
}
throw fehler;
}
export function akteurVon(benutzer: Pick<Benutzer, "id" | "name">): Akteur {
return { id: benutzer.id, name: benutzer.name };
}
/** Einzelnes Textfeld; fehlend bleibt `undefined`, damit Standardwerte des Schemas greifen. */
export function textfeld(formular: FormData, name: string): string | undefined {
const wert = formular.get(name);
return typeof wert === "string" && wert !== "" ? wert : undefined;
}
export function listenfeld(formular: FormData, name: string): string[] {
return formular.getAll(name).filter((w): w is string => typeof w === "string" && w !== "");
}
+68
View File
@@ -0,0 +1,68 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { feldfehlerAus, hostgruppeEingabeSchema, type HostgruppeEingabe } from "@/lib/hosts";
import {
akteurVon,
fehlerZustand,
listenfeld,
textfeld,
type FormularZustand,
} from "@/server/actions/formular";
import { rechtErforderlich } from "@/server/auth/sitzungspflicht";
import { hostgruppeAnlegen, hostgruppeBearbeiten } from "@/server/services/hostgruppen";
/** Formular-Aktionen fuer Hostgruppen (#008). Duenn wie die fuer Hosts. */
function gruppenFelder(formular: FormData): HostgruppeEingabe {
return {
name: textfeld(formular, "name") ?? "",
beschreibung: textfeld(formular, "beschreibung"),
hostIds: listenfeld(formular, "hostIds"),
};
}
function vorpruefen(eingabe: HostgruppeEingabe): FormularZustand | undefined {
const ergebnis = hostgruppeEingabeSchema.safeParse(eingabe);
if (ergebnis.success) return undefined;
return { fehler: "Bitte die markierten Felder prüfen.", feldfehler: feldfehlerAus(ergebnis.error) };
}
export async function hostgruppeAnlegenAktion(
_vorher: FormularZustand,
formular: FormData,
): Promise<FormularZustand> {
let id: string;
try {
const { benutzer } = await rechtErforderlich("hostsVerwalten");
const eingabe = gruppenFelder(formular);
const ungueltig = vorpruefen(eingabe);
if (ungueltig) return ungueltig;
({ id } = await hostgruppeAnlegen(eingabe, akteurVon(benutzer)));
} catch (fehler) {
return fehlerZustand(fehler);
}
revalidatePath("/hostgruppen");
revalidatePath("/hosts", "layout");
redirect(`/hostgruppen/${id}`);
}
export async function hostgruppeBearbeitenAktion(
_vorher: FormularZustand,
formular: FormData,
): Promise<FormularZustand> {
const id = textfeld(formular, "id") ?? "";
try {
const { benutzer } = await rechtErforderlich("hostsVerwalten");
const eingabe = gruppenFelder(formular);
const ungueltig = vorpruefen(eingabe);
if (ungueltig) return ungueltig;
await hostgruppeBearbeiten(id, eingabe, akteurVon(benutzer));
} catch (fehler) {
return fehlerZustand(fehler);
}
revalidatePath("/hostgruppen", "layout");
revalidatePath("/hosts", "layout");
redirect(`/hostgruppen/${id}`);
}
+93
View File
@@ -0,0 +1,93 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { feldfehlerAus, hostEingabeSchema, type HostEingabe } from "@/lib/hosts";
import {
akteurVon,
fehlerZustand,
listenfeld,
textfeld,
type FormularZustand,
} from "@/server/actions/formular";
import { rechtErforderlich } from "@/server/auth/sitzungspflicht";
import { hostAnlegen, hostArchivieren, hostBearbeiten } from "@/server/services/hosts";
/**
* Formular-Aktionen fuer Hosts (#008). Duenn: Recht pruefen, Eingabe pruefen, Service rufen,
* Seiten neu laden lassen, weiterleiten. `redirect` steht ausserhalb von try/catch, weil es
* per Ausnahme arbeitet.
*/
function hostFelder(formular: FormData): HostEingabe {
return {
name: textfeld(formular, "name") ?? "",
adresse: textfeld(formular, "adresse") ?? "",
port: textfeld(formular, "port"),
tags: textfeld(formular, "tags") ?? "",
beschreibung: textfeld(formular, "beschreibung"),
kundeName: textfeld(formular, "kundeName"),
anmeldeart: textfeld(formular, "anmeldeart") as HostEingabe["anmeldeart"],
gruppenIds: listenfeld(formular, "gruppenIds"),
};
}
function vorpruefen(eingabe: HostEingabe): FormularZustand | undefined {
const ergebnis = hostEingabeSchema.safeParse(eingabe);
if (ergebnis.success) return undefined;
return { fehler: "Bitte die markierten Felder prüfen.", feldfehler: feldfehlerAus(ergebnis.error) };
}
export async function hostAnlegenAktion(
_vorher: FormularZustand,
formular: FormData,
): Promise<FormularZustand> {
let id: string;
try {
const { benutzer } = await rechtErforderlich("hostsVerwalten");
const eingabe = hostFelder(formular);
const ungueltig = vorpruefen(eingabe);
if (ungueltig) return ungueltig;
({ id } = await hostAnlegen(eingabe, akteurVon(benutzer)));
} catch (fehler) {
return fehlerZustand(fehler);
}
revalidatePath("/hosts");
revalidatePath("/hostgruppen", "layout");
redirect(`/hosts/${id}`);
}
export async function hostBearbeitenAktion(
_vorher: FormularZustand,
formular: FormData,
): Promise<FormularZustand> {
const id = textfeld(formular, "id") ?? "";
try {
const { benutzer } = await rechtErforderlich("hostsVerwalten");
const eingabe = hostFelder(formular);
const ungueltig = vorpruefen(eingabe);
if (ungueltig) return ungueltig;
await hostBearbeiten(id, eingabe, akteurVon(benutzer));
} catch (fehler) {
return fehlerZustand(fehler);
}
revalidatePath("/hosts", "layout");
revalidatePath("/hostgruppen", "layout");
redirect(`/hosts/${id}`);
}
export async function hostArchivierenAktion(
_vorher: FormularZustand,
formular: FormData,
): Promise<FormularZustand> {
const id = textfeld(formular, "id") ?? "";
try {
const { benutzer } = await rechtErforderlich("hostsVerwalten");
await hostArchivieren(id, akteurVon(benutzer));
} catch (fehler) {
return fehlerZustand(fehler);
}
revalidatePath("/hosts", "layout");
revalidatePath("/hostgruppen", "layout");
redirect("/hosts");
}
+252
View File
@@ -0,0 +1,252 @@
import type { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { hostKeyZusammenfassen, hostStatus } from "@/lib/hosts";
/**
* Lesemodelle fuer Hosts und Hostgruppen (#008).
*
* Alles hier darf jede angemeldete Rolle sehen (Recht `lesen`); die Seiten pruefen das per
* `sitzungErforderlich()`. Archivierte Hosts erscheinen in keiner Liste, nur ueber ihre
* Detailadresse – fuer Verweise aus dem Protokoll.
*/
const OFFEN = { behobenAm: null } satisfies Prisma.AbweichungWhereInput;
export type HostFilter = { readonly q?: string; readonly gruppeId?: string };
export async function hostListe(filter: HostFilter = {}) {
const q = filter.q?.trim().slice(0, 100);
const where: Prisma.HostWhereInput = {
archiviertAm: null,
...(filter.gruppeId ? { gruppen: { some: { hostgruppeId: filter.gruppeId } } } : {}),
...(q
? {
OR: [
{ name: { contains: q, mode: "insensitive" } },
{ adresse: { contains: q, mode: "insensitive" } },
{ tags: { has: q.toLowerCase() } },
],
}
: {}),
};
const hosts = await prisma.host.findMany({
where,
orderBy: { name: "asc" },
select: {
id: true,
name: true,
adresse: true,
port: true,
tags: true,
erreichbar: true,
driftZustand: true,
letzterAbgleichAm: true,
kunde: { select: { name: true } },
gruppen: { select: { hostgruppe: { select: { id: true, name: true } } } },
hostSchluessel: {
where: { status: { in: ["VERTRAUT", "AUSSTEHEND"] } },
select: { status: true },
},
_count: { select: { abweichungen: { where: OFFEN } } },
},
});
return hosts.map((h) => ({
id: h.id,
name: h.name,
adresse: h.adresse,
port: h.port,
tags: h.tags,
kunde: h.kunde?.name ?? null,
gruppen: h.gruppen.map((g) => g.hostgruppe).sort((a, b) => a.name.localeCompare(b.name, "de")),
letzterAbgleichAm: h.letzterAbgleichAm,
offeneAbweichungen: h._count.abweichungen,
status: hostStatus({
erreichbar: h.erreichbar,
driftZustand: h.driftZustand,
offeneAbweichungen: h._count.abweichungen,
}),
hostKey: hostKeyZusammenfassen(h.hostSchluessel.map((s) => s.status)),
}));
}
export type HostListenEintrag = Awaited<ReturnType<typeof hostListe>>[number];
export async function hostDetail(id: string) {
const host = await prisma.host.findUnique({
where: { id },
select: {
id: true,
name: true,
adresse: true,
port: true,
beschreibung: true,
tags: true,
anmeldeart: true,
bootstrapAm: true,
erreichbar: true,
zuletztGeprueftAm: true,
letzterAbgleichAm: true,
driftZustand: true,
sshdBefund: true,
archiviertAm: true,
angelegtAm: true,
kunde: { select: { id: true, name: true } },
gruppen: { select: { hostgruppe: { select: { id: true, name: true } } } },
hostSchluessel: {
orderBy: [{ status: "asc" }, { algorithmus: "asc" }],
select: {
id: true,
algorithmus: true,
fingerprint: true,
status: true,
erstmalsGesehen: true,
zuletztGesehen: true,
entschiedenAm: true,
},
},
zielkonten: {
where: { entferntAm: null },
orderBy: { benutzername: "asc" },
select: { id: true, benutzername: true, sudoModus: true, vonSshkvAngelegt: true },
},
_count: {
select: {
abweichungen: { where: OFFEN },
publicVerwendungen: { where: { zustand: { not: "ENTFERNT" } } },
privateVerwendungen: { where: { zustand: { not: "ENTFERNT" } } },
},
},
},
});
if (!host) return null;
const { _count, gruppen, ...rest } = host;
return {
...rest,
gruppen: gruppen.map((g) => g.hostgruppe).sort((a, b) => a.name.localeCompare(b.name, "de")),
offeneAbweichungen: _count.abweichungen,
publicVerwendungen: _count.publicVerwendungen,
privateVerwendungen: _count.privateVerwendungen,
status: hostStatus({
erreichbar: host.erreichbar,
driftZustand: host.driftZustand,
offeneAbweichungen: _count.abweichungen,
}),
hostKey: hostKeyZusammenfassen(host.hostSchluessel.map((s) => s.status)),
};
}
export type HostDetail = NonNullable<Awaited<ReturnType<typeof hostDetail>>>;
/** Fuer das Formular „Bearbeiten“: genau die Felder, die das Formular kennt. */
export async function hostFormularWerte(id: string) {
const host = await prisma.host.findUnique({
where: { id },
select: {
id: true,
name: true,
adresse: true,
port: true,
tags: true,
beschreibung: true,
anmeldeart: true,
archiviertAm: true,
kunde: { select: { name: true } },
gruppen: { select: { hostgruppeId: true } },
},
});
if (!host) return null;
return {
id: host.id,
name: host.name,
adresse: host.adresse,
port: host.port,
tags: host.tags,
beschreibung: host.beschreibung ?? "",
anmeldeart: host.anmeldeart,
kundeName: host.kunde?.name ?? "",
gruppenIds: host.gruppen.map((g) => g.hostgruppeId),
archiviert: host.archiviertAm !== null,
};
}
export type HostFormularWerte = NonNullable<Awaited<ReturnType<typeof hostFormularWerte>>>;
/** Auswahllisten fuer Formulare. */
export async function gruppenAuswahl() {
return prisma.hostgruppe.findMany({ orderBy: { name: "asc" }, select: { id: true, name: true } });
}
export async function hostAuswahl() {
return prisma.host.findMany({
where: { archiviertAm: null },
orderBy: { name: "asc" },
select: { id: true, name: true, adresse: true },
});
}
export async function kundenNamen() {
const kunden = await prisma.kunde.findMany({ orderBy: { name: "asc" }, select: { name: true } });
return kunden.map((k) => k.name);
}
// ---------------------------------------------------------------- Hostgruppen
export async function gruppenListe() {
const gruppen = await prisma.hostgruppe.findMany({
orderBy: { name: "asc" },
select: {
id: true,
name: true,
beschreibung: true,
_count: { select: { mitglieder: { where: { host: { archiviertAm: null } } } } },
},
});
return gruppen.map(({ _count, ...g }) => ({ ...g, mitglieder: _count.mitglieder }));
}
export async function gruppeDetail(id: string) {
const gruppe = await prisma.hostgruppe.findUnique({
where: { id },
select: {
id: true,
name: true,
beschreibung: true,
angelegtAm: true,
mitglieder: {
where: { host: { archiviertAm: null } },
orderBy: { host: { name: "asc" } },
select: {
host: {
select: {
id: true,
name: true,
adresse: true,
port: true,
erreichbar: true,
driftZustand: true,
_count: { select: { abweichungen: { where: OFFEN } } },
},
},
},
},
},
});
if (!gruppe) return null;
return {
id: gruppe.id,
name: gruppe.name,
beschreibung: gruppe.beschreibung,
angelegtAm: gruppe.angelegtAm,
mitglieder: gruppe.mitglieder.map(({ host: { _count, ...h } }) => ({
id: h.id,
name: h.name,
adresse: h.adresse,
port: h.port,
status: hostStatus({
erreichbar: h.erreichbar,
driftZustand: h.driftZustand,
offeneAbweichungen: _count.abweichungen,
}),
})),
};
}
+147
View File
@@ -0,0 +1,147 @@
import type { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import {
feldfehlerAus,
hostgruppeEingabeSchema,
type HostgruppeDaten,
type HostgruppeEingabe,
} from "@/lib/hosts";
import { HostFehler, istEindeutigkeitsFehler, type Akteur } from "@/server/services/hosts";
import { protokollieren } from "@/server/services/protokoll";
/**
* Hostgruppen anlegen und bearbeiten, Mitglieder setzen (#008).
*
* Loeschen gibt es bewusst noch nicht: an einer Gruppe haengen spaeter Public-Verwendungen
* (Cascade) – ein Loeschen wuerde Zuweisungen still mitnehmen. Das kommt mit #016, wenn
* klar ist, was mit den Verwendungen passieren soll.
*/
function pruefen(eingabe: HostgruppeEingabe): HostgruppeDaten {
const ergebnis = hostgruppeEingabeSchema.safeParse(eingabe);
if (!ergebnis.success) {
throw new HostFehler("Bitte die markierten Felder prüfen.", feldfehlerAus(ergebnis.error));
}
return ergebnis.data;
}
function nameVergeben(name: string): HostFehler {
return new HostFehler(`Eine Hostgruppe namens „${name}“ existiert bereits.`, {
name: "Dieser Name ist schon vergeben.",
});
}
/** Nur aktive Hosts duerfen Mitglied werden; liefert die Namen fuer das Protokoll. */
async function hostNamen(tx: Prisma.TransactionClient, ids: readonly string[]): Promise<Map<string, string>> {
if (ids.length === 0) return new Map();
const hosts = await tx.host.findMany({
where: { id: { in: [...ids] }, archiviertAm: null },
select: { id: true, name: true },
});
if (hosts.length !== ids.length) {
throw new HostFehler("Mindestens ein gewählter Host ist archiviert oder existiert nicht.", {
hostIds: "Bitte die Auswahl prüfen.",
});
}
return new Map(hosts.map((h) => [h.id, h.name]));
}
export async function hostgruppeAnlegen(eingabe: HostgruppeEingabe, akteur: Akteur) {
const daten = pruefen(eingabe);
if (await prisma.hostgruppe.findUnique({ where: { name: daten.name }, select: { id: true } })) {
throw nameVergeben(daten.name);
}
try {
return await prisma.$transaction(async (tx) => {
const namen = await hostNamen(tx, daten.hostIds);
const gruppe = await tx.hostgruppe.create({
data: {
name: daten.name,
beschreibung: daten.beschreibung,
mitglieder: { create: daten.hostIds.map((hostId) => ({ hostId })) },
},
});
await protokollieren(
{
akteur,
aktion: "hostgruppe.angelegt",
ergebnis: "ERFOLG",
details: {
name: gruppe.name,
beschreibung: gruppe.beschreibung,
mitglieder: [...namen.values()].sort(),
},
},
tx,
);
return gruppe;
});
} catch (fehler) {
if (istEindeutigkeitsFehler(fehler)) throw nameVergeben(daten.name);
throw fehler;
}
}
/**
* Name, Beschreibung und Mitglieder in einem Zug. Mitgliedschaften archivierter Hosts gibt
* es nicht (siehe hostArchivieren), deshalb darf die Auswahl die Liste komplett ersetzen.
*/
export async function hostgruppeBearbeiten(id: string, eingabe: HostgruppeEingabe, akteur: Akteur) {
const daten = pruefen(eingabe);
const alt = await prisma.hostgruppe.findUnique({
where: { id },
include: { mitglieder: { include: { host: { select: { id: true, name: true } } } } },
});
if (!alt) throw new HostFehler("Diese Hostgruppe gibt es nicht.");
try {
return await prisma.$transaction(async (tx) => {
const neu = await hostNamen(tx, daten.hostIds);
const vorher = new Map(alt.mitglieder.map((m) => [m.host.id, m.host.name]));
const hinzu = [...neu].filter(([hid]) => !vorher.has(hid));
const entfernt = [...vorher].filter(([hid]) => !neu.has(hid));
const geaendert: Record<string, unknown> = {};
if (alt.name !== daten.name) geaendert["name"] = { vorher: alt.name, nachher: daten.name };
if (alt.beschreibung !== daten.beschreibung) {
geaendert["beschreibung"] = { vorher: alt.beschreibung, nachher: daten.beschreibung };
}
if (hinzu.length > 0 || entfernt.length > 0) {
geaendert["mitglieder"] = {
hinzu: hinzu.map(([, n]) => n).sort(),
entfernt: entfernt.map(([, n]) => n).sort(),
};
}
const gruppe = await tx.hostgruppe.update({
where: { id },
data: { name: daten.name, beschreibung: daten.beschreibung },
});
if (entfernt.length > 0) {
await tx.hostgruppeMitglied.deleteMany({
where: { hostgruppeId: id, hostId: { in: entfernt.map(([hid]) => hid) } },
});
}
if (hinzu.length > 0) {
await tx.hostgruppeMitglied.createMany({
data: hinzu.map(([hostId]) => ({ hostId, hostgruppeId: id })),
});
}
if (Object.keys(geaendert).length > 0) {
await protokollieren(
{
akteur,
aktion: "hostgruppe.geaendert",
ergebnis: "ERFOLG",
details: { gruppe: gruppe.name, ...geaendert },
},
tx,
);
}
return gruppe;
});
} catch (fehler) {
if (istEindeutigkeitsFehler(fehler)) throw nameVergeben(daten.name);
throw fehler;
}
}
+217
View File
@@ -0,0 +1,217 @@
import { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { feldfehlerAus, hostEingabeSchema, type HostDaten, type HostEingabe } from "@/lib/hosts";
import { protokollieren } from "@/server/services/protokoll";
/**
* Hosts anlegen, bearbeiten, archivieren (#008).
*
* Archivieren statt Loeschen: am Host haengen Protokoll-Bezuege, Beobachtungen und spaeter
* Verwendungen. Ein geloeschter Host liesse Fragen wie „wer hatte bis Mai Zugang zu
* srv-pve01?“ unbeantwortet.
*/
export type Akteur = { readonly id: string; readonly name: string };
/** Fachlicher Fehler mit Meldung fuer die Oberflaeche und optional je Feld. */
export class HostFehler extends Error {
constructor(
nachricht: string,
readonly feldfehler: Record<string, string> = {},
) {
super(nachricht);
this.name = "HostFehler";
}
}
export function istEindeutigkeitsFehler(fehler: unknown): boolean {
return fehler instanceof Prisma.PrismaClientKnownRequestError && fehler.code === "P2002";
}
function pruefen(eingabe: HostEingabe): HostDaten {
const ergebnis = hostEingabeSchema.safeParse(eingabe);
if (!ergebnis.success) {
throw new HostFehler("Bitte die markierten Felder prüfen.", feldfehlerAus(ergebnis.error));
}
return ergebnis.data;
}
function nameVergeben(name: string): HostFehler {
return new HostFehler(`Ein Host namens „${name}“ existiert bereits (auch archivierte zählen).`, {
name: "Dieser Name ist schon vergeben.",
});
}
/** Kunde per Name finden oder anlegen; `null` heisst „kein Kunde“. */
async function kundeZuordnen(
tx: Prisma.TransactionClient,
kundeName: string | null,
akteur: Akteur,
): Promise<string | null> {
if (!kundeName) return null;
const vorhanden = await tx.kunde.findUnique({ where: { name: kundeName }, select: { id: true } });
if (vorhanden) return vorhanden.id;
const kunde = await tx.kunde.create({ data: { name: kundeName }, select: { id: true } });
await protokollieren(
{ akteur, aktion: "kunde.angelegt", ergebnis: "ERFOLG", details: { name: kundeName } },
tx,
);
return kunde.id;
}
/** Prueft, dass alle Gruppen existieren, und liefert ihre Namen fuer das Protokoll. */
async function gruppenNamen(tx: Prisma.TransactionClient, ids: readonly string[]): Promise<string[]> {
if (ids.length === 0) return [];
const gruppen = await tx.hostgruppe.findMany({ where: { id: { in: [...ids] } }, select: { name: true } });
if (gruppen.length !== ids.length) {
throw new HostFehler("Mindestens eine gewählte Hostgruppe gibt es nicht mehr.", {
gruppenIds: "Bitte die Auswahl prüfen.",
});
}
return gruppen.map((g) => g.name).sort();
}
export async function hostAnlegen(eingabe: HostEingabe, akteur: Akteur) {
const daten = pruefen(eingabe);
if (await prisma.host.findUnique({ where: { name: daten.name }, select: { id: true } })) {
throw nameVergeben(daten.name);
}
try {
return await prisma.$transaction(async (tx) => {
const gruppen = await gruppenNamen(tx, daten.gruppenIds);
const kundeId = await kundeZuordnen(tx, daten.kundeName, akteur);
const host = await tx.host.create({
data: {
name: daten.name,
adresse: daten.adresse,
port: daten.port,
tags: daten.tags,
beschreibung: daten.beschreibung,
anmeldeart: daten.anmeldeart,
kundeId,
gruppen: { create: daten.gruppenIds.map((hostgruppeId) => ({ hostgruppeId })) },
},
});
await protokollieren(
{
akteur,
aktion: "host.angelegt",
ergebnis: "ERFOLG",
hostName: host.name,
details: {
adresse: host.adresse,
port: host.port,
anmeldeart: host.anmeldeart,
tags: host.tags,
kunde: daten.kundeName,
gruppen,
},
},
tx,
);
return host;
});
} catch (fehler) {
// Zwischen Vorpruefung und Insert kann jemand denselben Namen angelegt haben.
if (istEindeutigkeitsFehler(fehler)) throw nameVergeben(daten.name);
throw fehler;
}
}
type Aenderung = { vorher: unknown; nachher: unknown };
function gleich(a: unknown, b: unknown): boolean {
return JSON.stringify(a) === JSON.stringify(b);
}
export async function hostBearbeiten(id: string, eingabe: HostEingabe, akteur: Akteur) {
const daten = pruefen(eingabe);
const alt = await prisma.host.findUnique({
where: { id },
include: { kunde: true, gruppen: { include: { hostgruppe: { select: { name: true } } } } },
});
if (!alt) throw new HostFehler("Diesen Host gibt es nicht.");
if (alt.archiviertAm) throw new HostFehler("Archivierte Hosts lassen sich nicht bearbeiten.");
try {
return await prisma.$transaction(async (tx) => {
const neueGruppen = await gruppenNamen(tx, daten.gruppenIds);
const kundeId = await kundeZuordnen(tx, daten.kundeName, akteur);
// Nur geaenderte Felder ins Protokoll – „wer hat den Port auf 2222 gestellt?“ soll
// ohne Vergleich zweier Vollstaende beantwortbar sein.
const vergleich: Record<string, Aenderung> = {
name: { vorher: alt.name, nachher: daten.name },
adresse: { vorher: alt.adresse, nachher: daten.adresse },
port: { vorher: alt.port, nachher: daten.port },
tags: { vorher: alt.tags, nachher: daten.tags },
beschreibung: { vorher: alt.beschreibung, nachher: daten.beschreibung },
anmeldeart: { vorher: alt.anmeldeart, nachher: daten.anmeldeart },
kunde: { vorher: alt.kunde?.name ?? null, nachher: daten.kundeName },
gruppen: { vorher: alt.gruppen.map((g) => g.hostgruppe.name).sort(), nachher: neueGruppen },
};
const geaendert = Object.fromEntries(
Object.entries(vergleich).filter(([, a]) => !gleich(a.vorher, a.nachher)),
);
const host = await tx.host.update({
where: { id },
data: {
name: daten.name,
adresse: daten.adresse,
port: daten.port,
tags: daten.tags,
beschreibung: daten.beschreibung,
anmeldeart: daten.anmeldeart,
kundeId,
},
});
if ("gruppen" in geaendert) {
await tx.hostgruppeMitglied.deleteMany({ where: { hostId: id } });
await tx.hostgruppeMitglied.createMany({
data: daten.gruppenIds.map((hostgruppeId) => ({ hostId: id, hostgruppeId })),
});
}
if (Object.keys(geaendert).length > 0) {
await protokollieren(
{ akteur, aktion: "host.geaendert", ergebnis: "ERFOLG", hostName: host.name, details: geaendert },
tx,
);
}
return host;
});
} catch (fehler) {
if (istEindeutigkeitsFehler(fehler)) throw nameVergeben(daten.name);
throw fehler;
}
}
/**
* Archiviert einen Host. Gruppenmitgliedschaften fallen dabei weg, damit spaetere
* Zuweisungen an eine Gruppe nie einen archivierten Host treffen; welche es waren, steht
* im Protokoll.
*/
export async function hostArchivieren(id: string, akteur: Akteur) {
const host = await prisma.host.findUnique({
where: { id },
include: { gruppen: { include: { hostgruppe: { select: { name: true } } } } },
});
if (!host) throw new HostFehler("Diesen Host gibt es nicht.");
if (host.archiviertAm) throw new HostFehler("Dieser Host ist bereits archiviert.");
return prisma.$transaction(async (tx) => {
await tx.hostgruppeMitglied.deleteMany({ where: { hostId: id } });
const archiviert = await tx.host.update({ where: { id }, data: { archiviertAm: new Date() } });
await protokollieren(
{
akteur,
aktion: "host.archiviert",
ergebnis: "ERFOLG",
hostName: host.name,
details: { gruppen: host.gruppen.map((g) => g.hostgruppe.name).sort() },
},
tx,
);
return archiviert;
});
}