feat(kunden): Kundenverwaltung, Hostzuweisung und Token-Verwaltung (#006)
Kunden und Standorte pflegen, nicht zugeordnete Hosts in einem Schritt zuweisen, Enrollment- und Host-Tokens verwalten, Audit-Log. Dashboard, Kundenseiten und globale Suche laufen jetzt auf der Datenbank. 45 Tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { protokolliere } from "@/server/audit";
|
||||
import { hostsZuweisenService, ZuweisungsFehler } from "@/server/services/zuweisung";
|
||||
import type { AktionsErgebnis } from "./kunden";
|
||||
|
||||
/** Weist mehrere Hosts in einem Schritt einem Kunden zu. */
|
||||
export async function hostsZuweisen(_vorher: AktionsErgebnis, formular: FormData): Promise<AktionsErgebnis> {
|
||||
const hostIds = formular.getAll("hostId").map(String).filter(Boolean);
|
||||
const kundeId = formular.get("kundeId")?.toString() || null;
|
||||
const standortId = formular.get("standortId")?.toString() || null;
|
||||
|
||||
try {
|
||||
const { anzahl, kundenName } = await hostsZuweisenService(hostIds, kundeId, standortId);
|
||||
|
||||
revalidatePath("/hosts/nicht-zugeordnet");
|
||||
revalidatePath("/hosts");
|
||||
revalidatePath("/kunden");
|
||||
revalidatePath(`/kunden/${kundeId}`);
|
||||
revalidatePath("/");
|
||||
|
||||
return { erfolg: `${anzahl} Host${anzahl === 1 ? "" : "s"} ${kundenName} zugewiesen.` };
|
||||
} catch (fehler) {
|
||||
if (fehler instanceof ZuweisungsFehler) return { fehler: fehler.message };
|
||||
throw fehler;
|
||||
}
|
||||
}
|
||||
|
||||
/** Hebt die Zuordnung eines einzelnen Hosts auf. */
|
||||
export async function hostZuordnungAufheben(formular: FormData) {
|
||||
const hostId = formular.get("hostId")?.toString();
|
||||
if (!hostId) return;
|
||||
|
||||
await prisma.host.update({ where: { id: hostId }, data: { kundeId: null, standortId: null } });
|
||||
await protokolliere("host.zuordnung_aufgehoben", "Host", hostId, {});
|
||||
|
||||
revalidatePath("/hosts");
|
||||
revalidatePath("/hosts/nicht-zugeordnet");
|
||||
revalidatePath(`/hosts/${hostId}`);
|
||||
revalidatePath("/kunden");
|
||||
revalidatePath("/");
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { protokolliere } from "@/server/audit";
|
||||
|
||||
export interface AktionsErgebnis {
|
||||
fehler?: string;
|
||||
felder?: Record<string, string>;
|
||||
erfolg?: string;
|
||||
}
|
||||
|
||||
const leerZuNull = (wert: FormDataEntryValue | null) => {
|
||||
const text = typeof wert === "string" ? wert.trim() : "";
|
||||
return text.length > 0 ? text : null;
|
||||
};
|
||||
|
||||
const kundeSchema = z.object({
|
||||
name: z.string().min(2, "Bitte einen Namen angeben."),
|
||||
kundennummer: z.string().min(1, "Bitte eine Kundennummer angeben."),
|
||||
ansprechpartner: z.string().nullable(),
|
||||
email: z.union([z.string().email("Keine gültige E-Mail-Adresse."), z.null()]),
|
||||
telefon: z.string().nullable(),
|
||||
notizen: z.string().nullable(),
|
||||
});
|
||||
|
||||
/** Legt einen Kunden an oder ändert ihn; `id` im Formular entscheidet darüber. */
|
||||
export async function kundeSpeichern(_vorher: AktionsErgebnis, formular: FormData): Promise<AktionsErgebnis> {
|
||||
const id = leerZuNull(formular.get("id"));
|
||||
const eingabe = kundeSchema.safeParse({
|
||||
name: formular.get("name")?.toString().trim() ?? "",
|
||||
kundennummer: formular.get("kundennummer")?.toString().trim() ?? "",
|
||||
ansprechpartner: leerZuNull(formular.get("ansprechpartner")),
|
||||
email: leerZuNull(formular.get("email")),
|
||||
telefon: leerZuNull(formular.get("telefon")),
|
||||
notizen: leerZuNull(formular.get("notizen")),
|
||||
});
|
||||
|
||||
if (!eingabe.success) {
|
||||
const felder: Record<string, string> = {};
|
||||
for (const problem of eingabe.error.issues) felder[String(problem.path[0])] = problem.message;
|
||||
return { fehler: "Bitte die markierten Felder prüfen.", felder };
|
||||
}
|
||||
|
||||
const vorhandeneNummer = await prisma.kunde.findUnique({
|
||||
where: { kundennummer: eingabe.data.kundennummer },
|
||||
});
|
||||
if (vorhandeneNummer && vorhandeneNummer.id !== id) {
|
||||
return {
|
||||
fehler: "Diese Kundennummer ist bereits vergeben.",
|
||||
felder: { kundennummer: "Bereits vergeben." },
|
||||
};
|
||||
}
|
||||
|
||||
const kunde = id
|
||||
? await prisma.kunde.update({ where: { id }, data: eingabe.data })
|
||||
: await prisma.kunde.create({ data: eingabe.data });
|
||||
|
||||
await protokolliere(id ? "kunde.geaendert" : "kunde.angelegt", "Kunde", kunde.id, eingabe.data);
|
||||
|
||||
revalidatePath("/kunden");
|
||||
revalidatePath(`/kunden/${kunde.id}`);
|
||||
redirect(`/kunden/${kunde.id}`);
|
||||
}
|
||||
|
||||
export async function kundeLoeschen(formular: FormData) {
|
||||
const id = formular.get("id")?.toString();
|
||||
if (!id) return;
|
||||
|
||||
const kunde = await prisma.kunde.findUnique({ where: { id }, select: { name: true } });
|
||||
// Hosts bleiben erhalten und gelten danach als nicht zugeordnet (onDelete: SetNull).
|
||||
await prisma.kunde.delete({ where: { id } });
|
||||
await protokolliere("kunde.geloescht", "Kunde", id, { name: kunde?.name });
|
||||
|
||||
revalidatePath("/kunden");
|
||||
revalidatePath("/hosts/nicht-zugeordnet");
|
||||
redirect("/kunden");
|
||||
}
|
||||
|
||||
const standortSchema = z.object({
|
||||
kundeId: z.string().min(1),
|
||||
name: z.string().min(2, "Bitte einen Namen angeben."),
|
||||
strasse: z.string().nullable(),
|
||||
plz: z.string().nullable(),
|
||||
ort: z.string().nullable(),
|
||||
});
|
||||
|
||||
export async function standortSpeichern(
|
||||
_vorher: AktionsErgebnis,
|
||||
formular: FormData,
|
||||
): Promise<AktionsErgebnis> {
|
||||
const id = leerZuNull(formular.get("id"));
|
||||
const eingabe = standortSchema.safeParse({
|
||||
kundeId: formular.get("kundeId")?.toString() ?? "",
|
||||
name: formular.get("name")?.toString().trim() ?? "",
|
||||
strasse: leerZuNull(formular.get("strasse")),
|
||||
plz: leerZuNull(formular.get("plz")),
|
||||
ort: leerZuNull(formular.get("ort")),
|
||||
});
|
||||
|
||||
if (!eingabe.success) {
|
||||
const felder: Record<string, string> = {};
|
||||
for (const problem of eingabe.error.issues) felder[String(problem.path[0])] = problem.message;
|
||||
return { fehler: "Bitte die markierten Felder prüfen.", felder };
|
||||
}
|
||||
|
||||
const standort = id
|
||||
? await prisma.standort.update({ where: { id }, data: eingabe.data })
|
||||
: await prisma.standort.create({ data: eingabe.data });
|
||||
|
||||
await protokolliere(id ? "standort.geaendert" : "standort.angelegt", "Standort", standort.id, eingabe.data);
|
||||
revalidatePath(`/kunden/${eingabe.data.kundeId}`);
|
||||
return { erfolg: "Standort gespeichert." };
|
||||
}
|
||||
|
||||
export async function standortLoeschen(formular: FormData) {
|
||||
const id = formular.get("id")?.toString();
|
||||
const kundeId = formular.get("kundeId")?.toString();
|
||||
if (!id) return;
|
||||
|
||||
await prisma.standort.delete({ where: { id } });
|
||||
await protokolliere("standort.geloescht", "Standort", id, {});
|
||||
if (kundeId) revalidatePath(`/kunden/${kundeId}`);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { erzeugeToken } from "@/lib/tokens";
|
||||
import { protokolliere } from "@/server/audit";
|
||||
import type { AktionsErgebnis } from "./kunden";
|
||||
|
||||
export interface TokenErgebnis extends AktionsErgebnis {
|
||||
/** Nur direkt nach dem Erzeugen gesetzt – danach ist der Token nicht mehr lesbar. */
|
||||
klartext?: string;
|
||||
}
|
||||
|
||||
/** Erzeugt einen Enrollment-Token; der Klartext wird genau einmal zurückgegeben. */
|
||||
export async function enrollmentTokenErzeugen(
|
||||
_vorher: TokenErgebnis,
|
||||
formular: FormData,
|
||||
): Promise<TokenErgebnis> {
|
||||
const bezeichnung = formular.get("bezeichnung")?.toString().trim() || null;
|
||||
const kundeId = formular.get("kundeId")?.toString() || null;
|
||||
const standortId = formular.get("standortId")?.toString() || null;
|
||||
const gueltigTage = Number(formular.get("gueltigTage") ?? 30);
|
||||
const maxNutzungenRoh = formular.get("maxNutzungen")?.toString().trim();
|
||||
const maxNutzungen = maxNutzungenRoh ? Number(maxNutzungenRoh) : null;
|
||||
|
||||
if (!Number.isFinite(gueltigTage) || gueltigTage < 1 || gueltigTage > 365) {
|
||||
return { fehler: "Gültigkeit muss zwischen 1 und 365 Tagen liegen." };
|
||||
}
|
||||
if (maxNutzungen !== null && (!Number.isInteger(maxNutzungen) || maxNutzungen < 1)) {
|
||||
return { fehler: "Die Anzahl der Nutzungen muss mindestens 1 sein." };
|
||||
}
|
||||
|
||||
const token = erzeugeToken("enrollment");
|
||||
const eintrag = await prisma.enrollmentToken.create({
|
||||
data: {
|
||||
tokenHash: token.hash,
|
||||
praefix: token.praefix,
|
||||
bezeichnung,
|
||||
kundeId,
|
||||
standortId,
|
||||
maxNutzungen,
|
||||
gueltigBis: new Date(Date.now() + gueltigTage * 86_400_000),
|
||||
},
|
||||
});
|
||||
|
||||
await protokolliere("enrollment_token.angelegt", "EnrollmentToken", eintrag.id, {
|
||||
bezeichnung,
|
||||
kundeId,
|
||||
gueltigTage,
|
||||
maxNutzungen,
|
||||
});
|
||||
|
||||
revalidatePath("/einstellungen/tokens");
|
||||
return {
|
||||
klartext: token.klartext,
|
||||
erfolg: "Token erzeugt. Er wird nur dieses eine Mal angezeigt.",
|
||||
};
|
||||
}
|
||||
|
||||
export async function enrollmentTokenWiderrufen(formular: FormData) {
|
||||
const id = formular.get("id")?.toString();
|
||||
if (!id) return;
|
||||
|
||||
await prisma.enrollmentToken.update({ where: { id }, data: { widerrufenAm: new Date() } });
|
||||
await protokolliere("enrollment_token.widerrufen", "EnrollmentToken", id, {});
|
||||
revalidatePath("/einstellungen/tokens");
|
||||
}
|
||||
|
||||
export async function hostTokenWiderrufen(formular: FormData) {
|
||||
const id = formular.get("id")?.toString();
|
||||
const hostId = formular.get("hostId")?.toString();
|
||||
if (!id) return;
|
||||
|
||||
await prisma.agentToken.update({ where: { id }, data: { widerrufenAm: new Date() } });
|
||||
await protokolliere("agent_token.widerrufen", "AgentToken", id, { hostId });
|
||||
|
||||
revalidatePath("/einstellungen/tokens");
|
||||
if (hostId) revalidatePath(`/hosts/${hostId}`);
|
||||
}
|
||||
Reference in New Issue
Block a user