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:
@@ -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" };
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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" };
|
||||
}
|
||||
Reference in New Issue
Block a user