L1 Stammdaten: Services, Dublettenprüfung, Server Actions und API v1

Kunden (Nummernkreis, Ansprechpartner, vorläufig bestätigen, Zusammenführen mit
Bestätigung), Objekte inkl. Historie, Teams mit Mitgliedschaften, Dublettenlogik
(lib + Service), API-Kontext/Antwortformat unter src/server/api und die Endpunkte
/api/v1/customers, /api/v1/sites, /api/v1/sites/[id]/history.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 12:26:07 +02:00
co-authored by Claude Opus 5
parent bf4456718e
commit 1f8e6413fe
23 changed files with 1836 additions and 61 deletions
+48
View File
@@ -0,0 +1,48 @@
"use server";
import { revalidatePath } from "next/cache";
import { moduleGuard } from "@/server/action-guard";
import { ctxFromGuard } from "@/server/services/context";
import { formObject, toActionError, type ActionState } from "@/server/api/action-state";
import { createContact, deleteContact, updateContact } from "@/server/services/customers/contacts";
const guard = moduleGuard("customers");
const CONTACT_KEYS = ["name", "role", "phone", "mobile", "email", "preferredChannel", "notes"] as const;
function contactValues(fd: FormData) {
const v = formObject(fd, CONTACT_KEYS);
return { ...v, name: v.name ?? "" };
}
export async function createContactAction(customerId: string, _prev: ActionState, fd: FormData): Promise<ActionState> {
try {
const ctx = ctxFromGuard(await guard("customer:write"));
await createContact(ctx, customerId, contactValues(fd));
} catch (err) {
return toActionError(err);
}
revalidatePath(`/customers/${customerId}`);
return { status: "ok" };
}
export async function updateContactAction(contactId: string, customerId: string, _prev: ActionState, fd: FormData): Promise<ActionState> {
try {
const ctx = ctxFromGuard(await guard("customer:write"));
await updateContact(ctx, contactId, contactValues(fd));
} catch (err) {
return toActionError(err);
}
revalidatePath(`/customers/${customerId}`);
return { status: "ok" };
}
export async function deleteContactAction(contactId: string, customerId: string): Promise<ActionState> {
try {
const ctx = ctxFromGuard(await guard("customer:write"));
await deleteContact(ctx, contactId);
} catch (err) {
return toActionError(err);
}
revalidatePath(`/customers/${customerId}`);
return { status: "ok" };
}
+123
View File
@@ -0,0 +1,123 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { moduleGuard } from "@/server/action-guard";
import { ctxFromGuard, ServiceError } from "@/server/services/context";
import { formObject, toActionError, type ActionState } from "@/server/api/action-state";
import {
confirmProvisionalCustomer,
createCustomer,
deleteCustomer,
updateCustomer,
} from "@/server/services/customers/customers";
import { mergeCustomers } from "@/server/services/customers/merge";
import type { DuplicateCandidate } from "@/server/services/customers/duplicates";
const guard = moduleGuard("customers");
const CUSTOMER_KEYS = [
"customerNumber",
"companyName",
"salutation",
"firstName",
"lastName",
"street",
"houseNumber",
"postalCode",
"city",
"country",
"phone",
"mobile",
"email",
"notes",
"billingNotes",
"status",
] as const;
export type CustomerFormState =
| ActionState
| { status: "duplicates"; candidates: DuplicateCandidate[]; values: Record<string, string | undefined> };
/** Create; on possible duplicates the form shows the candidates and may resubmit with acknowledgeDuplicates=1. */
export async function createCustomerAction(_prev: CustomerFormState, fd: FormData): Promise<CustomerFormState> {
const values = formObject(fd, CUSTOMER_KEYS);
let id: string;
try {
const ctx = ctxFromGuard(await guard("customer:write"));
const customer = await createCustomer(ctx, values, { acknowledgeDuplicates: fd.get("acknowledgeDuplicates") === "1" });
id = customer.id;
} catch (err) {
if (err instanceof ServiceError && (err.details as { reason?: string } | undefined)?.reason === "possible_duplicates") {
return { status: "duplicates", candidates: (err.details as { candidates: DuplicateCandidate[] }).candidates, values };
}
return toActionError(err);
}
revalidatePath("/customers");
redirect(`/customers/${id}`);
}
export async function updateCustomerAction(id: string, _prev: ActionState, fd: FormData): Promise<ActionState> {
try {
const ctx = ctxFromGuard(await guard("customer:write"));
const values = formObject(fd, CUSTOMER_KEYS);
// full form submit: empty inputs clear the field
const patch = Object.fromEntries(CUSTOMER_KEYS.map((k) => [k, values[k] ?? (k === "status" || k === "country" ? undefined : null)]));
await updateCustomer(ctx, id, patch);
} catch (err) {
return toActionError(err);
}
revalidatePath("/customers");
revalidatePath(`/customers/${id}`);
return { status: "ok" };
}
export async function deleteCustomerAction(id: string): Promise<ActionState> {
try {
const ctx = ctxFromGuard(await guard("customer:write"));
await deleteCustomer(ctx, id);
} catch (err) {
return toActionError(err);
}
revalidatePath("/customers");
redirect("/customers");
}
export async function confirmCustomerAction(id: string): Promise<ActionState> {
try {
const ctx = ctxFromGuard(await guard("customer:write"));
await confirmProvisionalCustomer(ctx, id);
} catch (err) {
return toActionError(err);
}
revalidatePath("/customers");
revalidatePath(`/customers/${id}`);
return { status: "ok" };
}
/** Merge `sourceId` into the selected target. Requires customer:merge and the confirmation checkbox. */
export async function mergeCustomerAction(sourceId: string, _prev: ActionState, fd: FormData): Promise<ActionState> {
let targetId: string;
try {
const g = await guard("customer:read", "customer:merge");
const ctx = ctxFromGuard(g);
const values = formObject(fd, ["targetId", "targetNumber"]);
let target = values.targetId;
if (!target && values.targetNumber) {
const byNumber = await ctx.db.customer.findFirst({
where: { customerNumber: values.targetNumber, deletedAt: null },
select: { id: true },
});
if (!byNumber) throw new ServiceError("invalid", "target not found", { field: "targetNumber", reason: "target_not_found" });
target = byNumber.id;
}
if (!target) throw new ServiceError("invalid", "target required", { field: "targetId", reason: "target_required" });
const confirmed = fd.get("confirm") === "on" || fd.get("confirm") === "1";
await mergeCustomers(ctx, { sourceId, targetId: target, confirm: confirmed as true });
targetId = target;
} catch (err) {
return toActionError(err);
}
revalidatePath("/customers");
redirect(`/customers/${targetId}?merged=1`);
}
+71
View File
@@ -0,0 +1,71 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { moduleGuard } from "@/server/action-guard";
import { ctxFromGuard } from "@/server/services/context";
import { formObject, toActionError, type ActionState } from "@/server/api/action-state";
import { createSite, deleteSite, updateSite } from "@/server/services/sites/sites";
const guard = moduleGuard("sites");
const SITE_KEYS = [
"customerId",
"name",
"street",
"houseNumber",
"postalCode",
"city",
"country",
"contactId",
"onSiteContact",
"phone",
"accessNotes",
"parkingNotes",
"safetyNotes",
"technicalNotes",
"status",
"latitude",
"longitude",
] as const;
export async function createSiteAction(_prev: ActionState, fd: FormData): Promise<ActionState> {
let id: string;
try {
const ctx = ctxFromGuard(await guard("site:write"));
const v = formObject(fd, SITE_KEYS);
const site = await createSite(ctx, { ...v, customerId: v.customerId ?? "", name: v.name ?? "" });
id = site.id;
} catch (err) {
return toActionError(err);
}
revalidatePath("/sites");
redirect(`/sites/${id}`);
}
export async function updateSiteAction(id: string, _prev: ActionState, fd: FormData): Promise<ActionState> {
try {
const ctx = ctxFromGuard(await guard("site:write"));
const v = formObject(fd, SITE_KEYS);
const patch = Object.fromEntries(
SITE_KEYS.map((k) => [k, v[k] ?? (k === "status" || k === "country" || k === "customerId" || k === "name" ? undefined : null)]),
);
await updateSite(ctx, id, patch);
} catch (err) {
return toActionError(err);
}
revalidatePath("/sites");
revalidatePath(`/sites/${id}`);
return { status: "ok" };
}
export async function deleteSiteAction(id: string): Promise<ActionState> {
try {
const ctx = ctxFromGuard(await guard("site:write"));
await deleteSite(ctx, id);
} catch (err) {
return toActionError(err);
}
revalidatePath("/sites");
redirect("/sites");
}
+46
View File
@@ -0,0 +1,46 @@
"use server";
import { revalidatePath } from "next/cache";
import { moduleGuard } from "@/server/action-guard";
import { ctxFromGuard } from "@/server/services/context";
import { formObject, toActionError, type ActionState } from "@/server/api/action-state";
import { createTeam, deleteTeam, updateTeam, type TeamInput } from "@/server/services/teams/teams";
const guard = moduleGuard("teams");
const TEAM_KEYS = ["name", "leaderUserId", "status", "phone", "vehicle", "area", "notes"] as const;
function teamValues(fd: FormData): TeamInput {
const v = formObject(fd, TEAM_KEYS);
const userIds = fd.getAll("memberUserId").map(String);
const froms = fd.getAll("memberValidFrom").map(String);
const tos = fd.getAll("memberValidTo").map(String);
const members = userIds
.map((userId, i) => ({ userId: userId.trim(), validFrom: froms[i] ?? "", validTo: tos[i] ?? "" }))
.filter((m) => m.userId);
return { ...v, name: v.name ?? "", leaderUserId: v.leaderUserId ?? null, members };
}
/** Create (id = null) or fully update a team incl. its member list. */
export async function saveTeamAction(id: string | null, _prev: ActionState, fd: FormData): Promise<ActionState> {
try {
const ctx = ctxFromGuard(await guard("team:manage"));
const input = teamValues(fd);
if (id) await updateTeam(ctx, id, input);
else await createTeam(ctx, input);
} catch (err) {
return toActionError(err);
}
revalidatePath("/teams");
return { status: "ok" };
}
export async function deleteTeamAction(id: string): Promise<ActionState> {
try {
const ctx = ctxFromGuard(await guard("team:manage"));
await deleteTeam(ctx, id);
} catch (err) {
return toActionError(err);
}
revalidatePath("/teams");
return { status: "ok" };
}