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:
@@ -1,61 +0,0 @@
|
|||||||
import { requireSession } from "@/server/auth";
|
|
||||||
import { storage } from "@/server/storage/adapter";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Download-Route für Dateien im Objektspeicher (Garage/S3) anhand ihres Storage-Keys.
|
|
||||||
*
|
|
||||||
* Mandanten-Isolation:
|
|
||||||
* Der Key ist mandantenpräfixiert (`<tenantId>/…`). Er MUSS mit dem Tenant der
|
|
||||||
* aktuellen Session beginnen — ein Fremd-Tenant-Key wird mit 404 abgewiesen
|
|
||||||
* (keine Existenz-Preisgabe).
|
|
||||||
*
|
|
||||||
* TODO(documents): Defense in Depth wiederherstellen — sobald das Craftvia-Document-Modell
|
|
||||||
* existiert, zusätzlich prüfen, dass der Key in einer mandantengebundenen Referenz
|
|
||||||
* (Document.storageKey) vorkommt und der Nutzer das Dokument sehen darf
|
|
||||||
* (document:read bzw. document:read_internal für interne Dokumente).
|
|
||||||
*
|
|
||||||
* Auslieferung mit `Content-Disposition: attachment` und `X-Content-Type-Options:
|
|
||||||
* nosniff` (F-07) — kein Inline-Rendering, kein MIME-Sniffing.
|
|
||||||
*
|
|
||||||
* Route-Handler laufen NICHT durch das Layout-Gate; die Auth wird hier eigenständig
|
|
||||||
* über `requireSession` erzwungen.
|
|
||||||
*/
|
|
||||||
export async function GET(
|
|
||||||
_req: Request,
|
|
||||||
{ params }: { params: Promise<{ key: string[] }> },
|
|
||||||
) {
|
|
||||||
const session = await requireSession();
|
|
||||||
const tenantId = session.user.tenantId;
|
|
||||||
|
|
||||||
const { key: segments } = await params;
|
|
||||||
// Catch-all-Segmente sind bereits URL-dekodiert; zum Objekt-Key zusammenfügen.
|
|
||||||
const key = (segments ?? []).join("/");
|
|
||||||
|
|
||||||
// Pfad-Traversal ausschließen und Mandantenpräfix erzwingen.
|
|
||||||
if (
|
|
||||||
!tenantId ||
|
|
||||||
!key ||
|
|
||||||
key.includes("..") ||
|
|
||||||
key.includes("\0") ||
|
|
||||||
!key.startsWith(`${tenantId}/`)
|
|
||||||
) {
|
|
||||||
return new Response("Nicht gefunden.", { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const content = await storage.get(key);
|
|
||||||
if (!content) {
|
|
||||||
// Kein Byte-Backend (Stub) oder Objekt fehlt → 404.
|
|
||||||
return new Response("Datei nicht verfügbar.", { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const filename = content.filename.replace(/["\\]/g, "_");
|
|
||||||
const headers = new Headers({
|
|
||||||
"Content-Type": content.contentType ?? "application/octet-stream",
|
|
||||||
"Content-Disposition": `attachment; filename="${filename}"`,
|
|
||||||
"X-Content-Type-Options": "nosniff",
|
|
||||||
"Cache-Control": "private, no-store",
|
|
||||||
});
|
|
||||||
if (content.size != null) headers.set("Content-Length", String(content.size));
|
|
||||||
|
|
||||||
return new Response(content.stream, { headers });
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { assertSameOrigin, requireApiContext } from "@/server/api/context";
|
||||||
|
import { json, readJson, withApi } from "@/server/api/respond";
|
||||||
|
import { getCustomer, updateCustomer } from "@/server/services/customers/customers";
|
||||||
|
import type { CustomerPatchInput } from "@/server/services/customers/schemas";
|
||||||
|
|
||||||
|
type Ctx = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
/** GET /api/v1/customers/:id — customer incl. contacts (scope applies, otherwise 404). */
|
||||||
|
export const GET = withApi(async (_req: Request, { params }: Ctx) => {
|
||||||
|
const ctx = await requireApiContext("customers", "customer:read");
|
||||||
|
const { id } = await params;
|
||||||
|
return json({ data: await getCustomer(ctx, id) });
|
||||||
|
});
|
||||||
|
|
||||||
|
/** PATCH /api/v1/customers/:id — absent fields unchanged, null clears. */
|
||||||
|
export const PATCH = withApi(async (req: Request, { params }: Ctx) => {
|
||||||
|
assertSameOrigin(req);
|
||||||
|
const ctx = await requireApiContext("customers", "customer:write");
|
||||||
|
const { id } = await params;
|
||||||
|
const body = (await readJson(req)) as CustomerPatchInput | null;
|
||||||
|
return json({ data: await updateCustomer(ctx, id, body ?? {}) });
|
||||||
|
});
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { assertSameOrigin, requireApiContext } from "@/server/api/context";
|
||||||
|
import { json, paginated, parsePagination, readJson, withApi } from "@/server/api/respond";
|
||||||
|
import { createCustomer, CUSTOMER_LIST_STATUSES, listCustomers } from "@/server/services/customers/customers";
|
||||||
|
import type { CustomerCreateInput } from "@/server/services/customers/schemas";
|
||||||
|
|
||||||
|
const statusParam = z.enum([...CUSTOMER_LIST_STATUSES, "all"]).optional();
|
||||||
|
|
||||||
|
/** GET /api/v1/customers?q&status&page&pageSize */
|
||||||
|
export const GET = withApi(async (req: Request) => {
|
||||||
|
const ctx = await requireApiContext("customers", "customer:read");
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const { page, pageSize } = parsePagination(url);
|
||||||
|
const status = statusParam.parse(url.searchParams.get("status") ?? undefined);
|
||||||
|
const result = await listCustomers(ctx, { q: url.searchParams.get("q") ?? undefined, status, page, pageSize });
|
||||||
|
return paginated(result.items, result.total, result.page, result.pageSize);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v1/customers — body: customer fields + optional `acknowledgeDuplicates: true`.
|
||||||
|
* Possible duplicates without acknowledgement → 409 `{ error: { code: "conflict", details: { reason: "possible_duplicates", candidates } } }`.
|
||||||
|
*/
|
||||||
|
export const POST = withApi(async (req: Request) => {
|
||||||
|
assertSameOrigin(req);
|
||||||
|
const ctx = await requireApiContext("customers", "customer:write");
|
||||||
|
const body = (await readJson(req)) as Record<string, unknown> | null;
|
||||||
|
const customer = await createCustomer(ctx, (body ?? {}) as CustomerCreateInput, {
|
||||||
|
acknowledgeDuplicates: body?.acknowledgeDuplicates === true,
|
||||||
|
});
|
||||||
|
return json({ data: customer }, { status: 201 });
|
||||||
|
});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { requireApiContext } from "@/server/api/context";
|
||||||
|
import { json, parsePagination, withApi } from "@/server/api/respond";
|
||||||
|
import { getSiteHistory } from "@/server/services/sites/history";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/sites/:id/history?onlyApproved=true&page&pageSize
|
||||||
|
* Field roles always receive released deployments only (see getSiteHistory).
|
||||||
|
*/
|
||||||
|
export const GET = withApi(async (req: Request, { params }: { params: Promise<{ id: string }> }) => {
|
||||||
|
const ctx = await requireApiContext("sites", "site:read");
|
||||||
|
const { id } = await params;
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const { page, pageSize } = parsePagination(url, { pageSize: 50 });
|
||||||
|
const flag = url.searchParams.get("onlyApproved");
|
||||||
|
const result = await getSiteHistory(ctx, id, { onlyApproved: flag === "true" || flag === "1", page, pageSize });
|
||||||
|
return json({
|
||||||
|
data: result.items,
|
||||||
|
pagination: { page: result.page, pageSize: result.pageSize, total: result.total },
|
||||||
|
meta: { onlyApproved: result.onlyApproved },
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { assertSameOrigin, requireApiContext } from "@/server/api/context";
|
||||||
|
import { json, paginated, parsePagination, readJson, withApi } from "@/server/api/respond";
|
||||||
|
import { createSite, listSites, SITE_STATUSES, type SiteCreateInput } from "@/server/services/sites/sites";
|
||||||
|
|
||||||
|
const statusParam = z.enum([...SITE_STATUSES, "all"]).optional();
|
||||||
|
|
||||||
|
/** GET /api/v1/sites?q&customerId&status&page&pageSize */
|
||||||
|
export const GET = withApi(async (req: Request) => {
|
||||||
|
const ctx = await requireApiContext("sites", "site:read");
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const { page, pageSize } = parsePagination(url);
|
||||||
|
const result = await listSites(ctx, {
|
||||||
|
q: url.searchParams.get("q") ?? undefined,
|
||||||
|
customerId: url.searchParams.get("customerId") ?? undefined,
|
||||||
|
status: statusParam.parse(url.searchParams.get("status") ?? undefined),
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
});
|
||||||
|
return paginated(result.items, result.total, result.page, result.pageSize);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** POST /api/v1/sites */
|
||||||
|
export const POST = withApi(async (req: Request) => {
|
||||||
|
assertSameOrigin(req);
|
||||||
|
const ctx = await requireApiContext("sites", "site:write");
|
||||||
|
const body = (await readJson(req)) as SiteCreateInput | null;
|
||||||
|
return json({ data: await createSite(ctx, body ?? ({} as SiteCreateInput)) }, { status: 201 });
|
||||||
|
});
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
// Customer duplicate detection — pure, client-safe normalization and scoring (spec §7.3, US-003).
|
||||||
|
// The DB lookup lives in src/server/services/customers/duplicates.ts#findDuplicateCustomers.
|
||||||
|
// Used by lane "imports" (review mask) and the manual create flow. NEVER merges automatically.
|
||||||
|
|
||||||
|
export type DuplicateReason = "customer_number" | "company_name" | "address" | "email" | "phone";
|
||||||
|
|
||||||
|
/** Fields of a new/imported customer that are compared against existing customers. */
|
||||||
|
export type DuplicateCandidateInput = {
|
||||||
|
customerNumber?: string | null;
|
||||||
|
companyName?: string | null;
|
||||||
|
firstName?: string | null;
|
||||||
|
lastName?: string | null;
|
||||||
|
street?: string | null;
|
||||||
|
houseNumber?: string | null;
|
||||||
|
postalCode?: string | null;
|
||||||
|
city?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
phone?: string | null;
|
||||||
|
mobile?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DuplicateMatch = { score: number; reasons: DuplicateReason[] };
|
||||||
|
|
||||||
|
/** Minimum score for a record to be reported as "possible duplicate". */
|
||||||
|
export const DUPLICATE_THRESHOLD = 0.4;
|
||||||
|
|
||||||
|
/** Signal weights; combined as probabilistic OR: 1 - Π(1 - w). */
|
||||||
|
export const DUPLICATE_WEIGHTS = {
|
||||||
|
customerNumber: 1,
|
||||||
|
email: 0.6,
|
||||||
|
phone: 0.5,
|
||||||
|
companyExact: 0.6,
|
||||||
|
companySimilar: 0.45,
|
||||||
|
addressExact: 0.4,
|
||||||
|
addressStreetOnly: 0.25,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Legal forms, longest first so "gmbh & co kg" is removed before "gmbh"/"kg".
|
||||||
|
const LEGAL_FORMS = [
|
||||||
|
"gmbh & co. kgaa",
|
||||||
|
"gmbh & co. kg",
|
||||||
|
"gmbh & co kg",
|
||||||
|
"gmbh und co kg",
|
||||||
|
"ug (haftungsbeschraenkt)",
|
||||||
|
"ug haftungsbeschraenkt",
|
||||||
|
"e. k.",
|
||||||
|
"e.k.",
|
||||||
|
"e. v.",
|
||||||
|
"e.v.",
|
||||||
|
"kgaa",
|
||||||
|
"gmbh",
|
||||||
|
"mbh",
|
||||||
|
"ohg",
|
||||||
|
"gbr",
|
||||||
|
"partg",
|
||||||
|
"ltd.",
|
||||||
|
"ltd",
|
||||||
|
"inc.",
|
||||||
|
"inc",
|
||||||
|
"ag",
|
||||||
|
"kg",
|
||||||
|
"ug",
|
||||||
|
"se",
|
||||||
|
"ek",
|
||||||
|
"ev",
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Lowercase, transliterate German umlauts, strip remaining diacritics, collapse whitespace. */
|
||||||
|
export function normalizeText(value: string | null | undefined): string {
|
||||||
|
if (!value) return "";
|
||||||
|
return value
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/ä/g, "ae")
|
||||||
|
.replace(/ö/g, "oe")
|
||||||
|
.replace(/ü/g, "ue")
|
||||||
|
.replace(/ß/g, "ss")
|
||||||
|
.normalize("NFKD")
|
||||||
|
.replace(/\p{M}+/gu, "")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeRegExp(s: string): string {
|
||||||
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Company name without legal form and punctuation, e.g. "Müller GmbH & Co. KG" → "mueller". */
|
||||||
|
export function normalizeCompanyName(value: string | null | undefined): string {
|
||||||
|
let s = normalizeText(value);
|
||||||
|
if (!s) return "";
|
||||||
|
for (const form of LEGAL_FORMS) {
|
||||||
|
s = s.replace(new RegExp(`(^|[\\s,])${escapeRegExp(form)}(?=$|[\\s,])`, "g"), " ");
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
.replace(/&/g, " ")
|
||||||
|
.replace(/\bund\b/g, " ")
|
||||||
|
.replace(/[^a-z0-9]+/g, " ")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Street with unified abbreviation: "Hafenstraße" / "Hafen-Str." / "Hafen Strasse" → "hafenstr". */
|
||||||
|
export function normalizeStreet(value: string | null | undefined): string {
|
||||||
|
return normalizeText(value)
|
||||||
|
.replace(/strasse\b|str\.?(?=\s|$)/g, "str")
|
||||||
|
.replace(/[^a-z0-9]+/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeHouseNumber(value: string | null | undefined): string {
|
||||||
|
return normalizeText(value).replace(/[^a-z0-9]+/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizePostalCode(value: string | null | undefined): string {
|
||||||
|
return (value ?? "").replace(/\s+/g, "").toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeEmail(value: string | null | undefined): string {
|
||||||
|
return (value ?? "").trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phone digits only; international German prefix unified to national form
|
||||||
|
* (+49 40 … / 0049 40 … / 040 … → "040…"). Numbers with < 6 digits are ignored.
|
||||||
|
*/
|
||||||
|
export function normalizePhone(value: string | null | undefined): string {
|
||||||
|
let digits = (value ?? "").replace(/\D+/g, "");
|
||||||
|
if (digits.startsWith("0049")) digits = "0" + digits.slice(4);
|
||||||
|
else if (digits.startsWith("49") && (value ?? "").trim().startsWith("+")) digits = "0" + digits.slice(2);
|
||||||
|
return digits.length >= 6 ? digits : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Display/compare name: company without legal form, otherwise "first last". */
|
||||||
|
export function normalizedPartyName(c: Pick<DuplicateCandidateInput, "companyName" | "firstName" | "lastName">): string {
|
||||||
|
const company = normalizeCompanyName(c.companyName);
|
||||||
|
if (company) return company;
|
||||||
|
return normalizeText([c.firstName, c.lastName].filter(Boolean).join(" ")).replace(/[^a-z0-9 ]+/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function bigrams(s: string): Map<string, number> {
|
||||||
|
const compact = s.replace(/\s+/g, " ");
|
||||||
|
const map = new Map<string, number>();
|
||||||
|
for (let i = 0; i < compact.length - 1; i++) {
|
||||||
|
const g = compact.slice(i, i + 2);
|
||||||
|
map.set(g, (map.get(g) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sørensen–Dice coefficient over character bigrams (0..1). */
|
||||||
|
export function nameSimilarity(a: string, b: string): number {
|
||||||
|
if (!a || !b) return 0;
|
||||||
|
if (a === b) return 1;
|
||||||
|
if (a.length < 2 || b.length < 2) return 0;
|
||||||
|
const ba = bigrams(a);
|
||||||
|
const bb = bigrams(b);
|
||||||
|
let overlap = 0;
|
||||||
|
for (const [g, n] of ba) overlap += Math.min(n, bb.get(g) ?? 0);
|
||||||
|
const total = a.length - 1 + (b.length - 1);
|
||||||
|
return (2 * overlap) / total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Score one existing customer against a candidate. Pure — safe for client and tests. */
|
||||||
|
export function scoreDuplicate(candidate: DuplicateCandidateInput, existing: DuplicateCandidateInput): DuplicateMatch {
|
||||||
|
const reasons: DuplicateReason[] = [];
|
||||||
|
const weights: number[] = [];
|
||||||
|
|
||||||
|
const numA = normalizeText(candidate.customerNumber).replace(/\s+/g, "");
|
||||||
|
const numB = normalizeText(existing.customerNumber).replace(/\s+/g, "");
|
||||||
|
if (numA && numA === numB) {
|
||||||
|
reasons.push("customer_number");
|
||||||
|
weights.push(DUPLICATE_WEIGHTS.customerNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nameA = normalizedPartyName(candidate);
|
||||||
|
const nameB = normalizedPartyName(existing);
|
||||||
|
if (nameA && nameB) {
|
||||||
|
if (nameA === nameB) {
|
||||||
|
reasons.push("company_name");
|
||||||
|
weights.push(DUPLICATE_WEIGHTS.companyExact);
|
||||||
|
} else if (nameSimilarity(nameA, nameB) >= 0.8) {
|
||||||
|
reasons.push("company_name");
|
||||||
|
weights.push(DUPLICATE_WEIGHTS.companySimilar);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const streetA = normalizeStreet(candidate.street);
|
||||||
|
const streetB = normalizeStreet(existing.street);
|
||||||
|
const plzA = normalizePostalCode(candidate.postalCode);
|
||||||
|
const plzB = normalizePostalCode(existing.postalCode);
|
||||||
|
if (streetA && streetA === streetB && plzA && plzA === plzB) {
|
||||||
|
const hnA = normalizeHouseNumber(candidate.houseNumber);
|
||||||
|
const hnB = normalizeHouseNumber(existing.houseNumber);
|
||||||
|
reasons.push("address");
|
||||||
|
weights.push(hnA && hnA === hnB ? DUPLICATE_WEIGHTS.addressExact : DUPLICATE_WEIGHTS.addressStreetOnly);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mailA = normalizeEmail(candidate.email);
|
||||||
|
if (mailA && mailA === normalizeEmail(existing.email)) {
|
||||||
|
reasons.push("email");
|
||||||
|
weights.push(DUPLICATE_WEIGHTS.email);
|
||||||
|
}
|
||||||
|
|
||||||
|
const phonesA = [normalizePhone(candidate.phone), normalizePhone(candidate.mobile)].filter(Boolean);
|
||||||
|
const phonesB = new Set([normalizePhone(existing.phone), normalizePhone(existing.mobile)].filter(Boolean));
|
||||||
|
if (phonesA.some((p) => phonesB.has(p))) {
|
||||||
|
reasons.push("phone");
|
||||||
|
weights.push(DUPLICATE_WEIGHTS.phone);
|
||||||
|
}
|
||||||
|
|
||||||
|
const score = 1 - weights.reduce((acc, w) => acc * (1 - w), 1);
|
||||||
|
return { score: Math.round(Math.min(1, score) * 1000) / 1000, reasons };
|
||||||
|
}
|
||||||
@@ -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" };
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { ZodError } from "zod";
|
||||||
|
import { ServiceError } from "@/server/services/context";
|
||||||
|
import { ForbiddenError } from "@/server/rbac";
|
||||||
|
import { ModuleDisabledError } from "@/server/modules";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uniform result shape for form-based server actions of the master-data lanes.
|
||||||
|
* Errors are CODES (translated in the client via messages/<ns>.json → errors.<code>),
|
||||||
|
* never raw server messages (CWE-209). Field errors map field name → issue code.
|
||||||
|
*/
|
||||||
|
export type ActionErrorCode = "invalid" | "not_found" | "forbidden" | "conflict" | "blocked" | "generic";
|
||||||
|
|
||||||
|
export type ActionState<T = undefined> =
|
||||||
|
| { status: "idle" }
|
||||||
|
| { status: "ok"; data?: T }
|
||||||
|
| { status: "error"; code: ActionErrorCode; reason?: string; fieldErrors?: Record<string, string> };
|
||||||
|
|
||||||
|
export const IDLE: ActionState = { status: "idle" };
|
||||||
|
|
||||||
|
export function toActionError(err: unknown): Extract<ActionState, { status: "error" }> {
|
||||||
|
if (err instanceof ZodError) {
|
||||||
|
const fieldErrors: Record<string, string> = {};
|
||||||
|
for (const issue of err.issues) {
|
||||||
|
const key = issue.path.join(".") || "_";
|
||||||
|
if (!fieldErrors[key]) fieldErrors[key] = issue.code === "too_small" && issue.minimum === 1 ? "required" : "invalid";
|
||||||
|
}
|
||||||
|
return { status: "error", code: "invalid", fieldErrors };
|
||||||
|
}
|
||||||
|
if (err instanceof ServiceError) {
|
||||||
|
const details = err.details as { field?: string; reason?: string } | undefined;
|
||||||
|
return {
|
||||||
|
status: "error",
|
||||||
|
code: err.code,
|
||||||
|
reason: details?.reason ?? err.message,
|
||||||
|
...(details?.field ? { fieldErrors: { [details.field]: details.reason ?? err.code } } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (err instanceof ForbiddenError || err instanceof ModuleDisabledError) return { status: "error", code: "forbidden" };
|
||||||
|
if (err instanceof Error && /Tenant isolation violation/.test(err.message)) return { status: "error", code: "not_found" };
|
||||||
|
console.error("[action] unexpected error", err);
|
||||||
|
return { status: "error", code: "generic" };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** FormData → plain object of trimmed strings; empty strings become undefined. */
|
||||||
|
export function formObject(fd: FormData, keys: readonly string[]): Record<string, string | undefined> {
|
||||||
|
const out: Record<string, string | undefined> = {};
|
||||||
|
for (const k of keys) {
|
||||||
|
const v = fd.get(k);
|
||||||
|
const s = typeof v === "string" ? v.trim() : "";
|
||||||
|
out[k] = s === "" ? undefined : s;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { requireSession } from "@/server/auth";
|
||||||
|
import { dbForTenant, prisma } from "@/server/db";
|
||||||
|
import { writeAuditLog } from "@/server/audit";
|
||||||
|
import { isTokenStillValid } from "@/server/sessions";
|
||||||
|
import { assertModuleEnabled, requireModule } from "@/server/modules";
|
||||||
|
import type { Permission } from "@/server/rbac";
|
||||||
|
import type { ModuleKey } from "@/lib/modules";
|
||||||
|
import type { ServiceCtx } from "@/server/services/context";
|
||||||
|
import { ApiError } from "@/server/api/respond";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service context for /api/v1 route handlers and other route handlers (e.g. /files/<id>).
|
||||||
|
*
|
||||||
|
* Same authority model as `moduleGuard` (src/server/action-guard.ts, F-06): session cookie
|
||||||
|
* (Auth.js), then membership status, identity status, session kill switch, forced password
|
||||||
|
* change and the EFFECTIVE permissions are read from the database — never from the JWT.
|
||||||
|
* Differences: failures are thrown as `ApiError` (401/403) so handlers can answer with JSON,
|
||||||
|
* and `moduleKey` may be `null` for cross-module endpoints (document downloads are needed
|
||||||
|
* by field, reports and documents alike).
|
||||||
|
*/
|
||||||
|
export async function requireApiContext(moduleKey: ModuleKey | null, ...permissions: Permission[]): Promise<ServiceCtx> {
|
||||||
|
let session;
|
||||||
|
try {
|
||||||
|
session = await requireSession();
|
||||||
|
} catch {
|
||||||
|
throw new ApiError("unauthorized", "authentication required");
|
||||||
|
}
|
||||||
|
const tenantId = session.user.tenantId;
|
||||||
|
const db = dbForTenant(tenantId);
|
||||||
|
|
||||||
|
const account = await db.user.findFirst({
|
||||||
|
where: { id: session.user.id, status: "ACTIVE" },
|
||||||
|
select: {
|
||||||
|
userRoles: { select: { role: { select: { rolePermissions: { select: { permission: { select: { key: true } } } } } } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const identity = session.user.identityId
|
||||||
|
? await prisma.identity.findUnique({
|
||||||
|
where: { id: session.user.identityId },
|
||||||
|
select: { status: true, mustChangePassword: true, sessionsValidAfter: true },
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
if (!account || !identity || identity.status !== "ACTIVE") {
|
||||||
|
await writeAuditLog({ tenantId, actorId: session.user.id, action: "denied", entity: "account_inactive", entityId: session.user.id });
|
||||||
|
throw new ApiError("unauthorized", "account inactive");
|
||||||
|
}
|
||||||
|
if (!isTokenStillValid(session.user.tokenIssuedAt, identity.sessionsValidAfter)) {
|
||||||
|
throw new ApiError("unauthorized", "session invalidated");
|
||||||
|
}
|
||||||
|
if (identity.mustChangePassword) throw new ApiError("forbidden", "password change required");
|
||||||
|
|
||||||
|
const effective = new Set(account.userRoles.flatMap((ur) => ur.role.rolePermissions.map((rp) => rp.permission.key)));
|
||||||
|
for (const p of permissions) {
|
||||||
|
if (!effective.has(p)) {
|
||||||
|
await writeAuditLog({ tenantId, actorId: session.user.id, action: "denied", entity: "api", entityId: p });
|
||||||
|
throw new ApiError("forbidden", "forbidden");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (moduleKey) await assertModuleEnabled(session, moduleKey); // throws ModuleDisabledError → 403
|
||||||
|
|
||||||
|
return { db, tenantId, userId: session.user.id, permissions: effective };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CSRF defense for cookie-authenticated, state-changing route handlers: reject requests whose
|
||||||
|
* Origin (or Sec-Fetch-Site) shows a foreign site. Server actions have this built in.
|
||||||
|
*/
|
||||||
|
export function assertSameOrigin(req: Request): void {
|
||||||
|
const site = req.headers.get("sec-fetch-site");
|
||||||
|
if (site && site !== "same-origin" && site !== "none") throw new ApiError("forbidden", "cross-site request");
|
||||||
|
const origin = req.headers.get("origin");
|
||||||
|
if (origin) {
|
||||||
|
const host = req.headers.get("x-forwarded-host") ?? req.headers.get("host");
|
||||||
|
let originHost: string | null = null;
|
||||||
|
try {
|
||||||
|
originHost = new URL(origin).host;
|
||||||
|
} catch {
|
||||||
|
originHost = null;
|
||||||
|
}
|
||||||
|
if (!host || originHost !== host) throw new ApiError("forbidden", "cross-site request");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read context for server components (pages). Uses the session's permission set (JWT), which
|
||||||
|
* is the documented behaviour for read paths (AGENTS.md "Rollen"); mutations go through
|
||||||
|
* moduleGuard / requireApiContext with DB-authoritative permissions. Also enforces the module gate.
|
||||||
|
*/
|
||||||
|
export async function requirePageContext(moduleKey: ModuleKey): Promise<ServiceCtx> {
|
||||||
|
const session = await requireModule(moduleKey);
|
||||||
|
return {
|
||||||
|
db: dbForTenant(session.user.tenantId),
|
||||||
|
tenantId: session.user.tenantId,
|
||||||
|
userId: session.user.id,
|
||||||
|
permissions: new Set(session.user.permissions ?? []),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { ZodError } from "zod";
|
||||||
|
import { ServiceError } from "@/server/services/context";
|
||||||
|
import { ForbiddenError } from "@/server/rbac";
|
||||||
|
import { ModuleDisabledError } from "@/server/modules";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON response helpers for /api/v1 route handlers (spec §29.2).
|
||||||
|
* Error format: `{ error: { code, message, details? } }`; list format:
|
||||||
|
* `{ data: [...], pagination: { page, pageSize, total } }`.
|
||||||
|
* Internal error details never leave the server (CWE-209).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ApiErrorCode =
|
||||||
|
| "unauthorized"
|
||||||
|
| "forbidden"
|
||||||
|
| "not_found"
|
||||||
|
| "invalid"
|
||||||
|
| "conflict"
|
||||||
|
| "blocked"
|
||||||
|
| "payload_too_large"
|
||||||
|
| "internal";
|
||||||
|
|
||||||
|
const STATUS: Record<ApiErrorCode, number> = {
|
||||||
|
unauthorized: 401,
|
||||||
|
forbidden: 403,
|
||||||
|
not_found: 404,
|
||||||
|
invalid: 422,
|
||||||
|
conflict: 409,
|
||||||
|
blocked: 409,
|
||||||
|
payload_too_large: 413,
|
||||||
|
internal: 500,
|
||||||
|
};
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
public code: ApiErrorCode,
|
||||||
|
message: string,
|
||||||
|
public details?: unknown,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ApiError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function errorResponse(code: ApiErrorCode, message: string, details?: unknown): Response {
|
||||||
|
return Response.json(
|
||||||
|
{ error: { code, message, ...(details !== undefined ? { details } : {}) } },
|
||||||
|
{ status: STATUS[code], headers: { "Cache-Control": "no-store" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map any thrown error to a JSON error response. */
|
||||||
|
export function toErrorResponse(err: unknown): Response {
|
||||||
|
if (err instanceof ApiError) return errorResponse(err.code, err.message, err.details);
|
||||||
|
if (err instanceof ServiceError) return errorResponse(err.code, err.message, err.details);
|
||||||
|
if (err instanceof ZodError) {
|
||||||
|
return errorResponse(
|
||||||
|
"invalid",
|
||||||
|
"validation failed",
|
||||||
|
err.issues.map((i) => ({ path: i.path.join("."), code: i.code })),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (err instanceof ForbiddenError) return errorResponse("forbidden", "forbidden");
|
||||||
|
if (err instanceof ModuleDisabledError) return errorResponse("forbidden", "module disabled");
|
||||||
|
if (err instanceof Error && /Tenant isolation violation/.test(err.message)) return errorResponse("not_found", "not found");
|
||||||
|
console.error("[api] unhandled error", err);
|
||||||
|
return errorResponse("internal", "internal error");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function json(data: unknown, init?: { status?: number }): Response {
|
||||||
|
return Response.json(data, { status: init?.status ?? 200, headers: { "Cache-Control": "no-store" } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function paginated<T>(items: T[], total: number, page: number, pageSize: number): Response {
|
||||||
|
return json({ data: items, pagination: { page, pageSize, total } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `?page&pageSize` with sane bounds (pageSize 1..100, default 25). */
|
||||||
|
export function parsePagination(url: URL | string, defaults = { pageSize: 25 }): { page: number; pageSize: number } {
|
||||||
|
const u = typeof url === "string" ? new URL(url) : url;
|
||||||
|
const page = Math.max(1, Math.floor(Number(u.searchParams.get("page")) || 1));
|
||||||
|
const pageSize = Math.min(100, Math.max(1, Math.floor(Number(u.searchParams.get("pageSize")) || defaults.pageSize)));
|
||||||
|
return { page, pageSize };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wrap a handler so every thrown error becomes a JSON error response. */
|
||||||
|
export function withApi<A extends unknown[]>(handler: (...args: A) => Promise<Response>) {
|
||||||
|
return async (...args: A): Promise<Response> => {
|
||||||
|
try {
|
||||||
|
return await handler(...args);
|
||||||
|
} catch (err) {
|
||||||
|
return toErrorResponse(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read a JSON body; malformed JSON → 422. */
|
||||||
|
export async function readJson(req: Request): Promise<unknown> {
|
||||||
|
try {
|
||||||
|
return await req.json();
|
||||||
|
} catch {
|
||||||
|
throw new ApiError("invalid", "malformed JSON body");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { writeAuditLog } from "@/server/audit";
|
||||||
|
import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||||
|
import { customerScope } from "@/server/services/work-orders/visibility";
|
||||||
|
import { contactSchema, type ContactInput } from "@/server/services/customers/schemas";
|
||||||
|
|
||||||
|
async function requireWritableCustomer(ctx: ServiceCtx, customerId: string) {
|
||||||
|
const customer = await ctx.db.customer.findFirst({
|
||||||
|
where: { AND: [{ id: customerId }, await customerScope(ctx), { status: { not: "merged" } }] },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!customer) throw new ServiceError("not_found", "customer not found");
|
||||||
|
return customer;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requireContact(ctx: ServiceCtx, contactId: string) {
|
||||||
|
const contact = await ctx.db.contact.findFirst({
|
||||||
|
where: { id: contactId, deletedAt: null, customer: { AND: [await customerScope(ctx), { status: { not: "merged" } }] } },
|
||||||
|
});
|
||||||
|
if (!contact) throw new ServiceError("not_found", "contact not found");
|
||||||
|
return contact;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createContact(ctx: ServiceCtx, customerId: string, input: ContactInput) {
|
||||||
|
assertCan(ctx, "customer:write");
|
||||||
|
const data = contactSchema.parse(input);
|
||||||
|
await requireWritableCustomer(ctx, customerId);
|
||||||
|
const contact = await ctx.db.contact.create({ data: { ...data, tenantId: ctx.tenantId, customerId } });
|
||||||
|
await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action: "create", entity: "contact", entityId: contact.id, after: contact });
|
||||||
|
return contact;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateContact(ctx: ServiceCtx, contactId: string, input: ContactInput) {
|
||||||
|
assertCan(ctx, "customer:write");
|
||||||
|
const data = contactSchema.parse(input);
|
||||||
|
const before = await requireContact(ctx, contactId);
|
||||||
|
const after = await ctx.db.contact.update({ where: { id: contactId }, data });
|
||||||
|
await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action: "update", entity: "contact", entityId: contactId, before, after });
|
||||||
|
return after;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteContact(ctx: ServiceCtx, contactId: string) {
|
||||||
|
assertCan(ctx, "customer:write");
|
||||||
|
const before = await requireContact(ctx, contactId);
|
||||||
|
const after = await ctx.db.contact.update({ where: { id: contactId }, data: { deletedAt: new Date() } });
|
||||||
|
await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action: "delete", entity: "contact", entityId: contactId, before, after });
|
||||||
|
return after;
|
||||||
|
}
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
import type { Prisma } from "@prisma/client";
|
||||||
|
import { writeAuditLog } from "@/server/audit";
|
||||||
|
import { nextNumber } from "@/server/services/numbering";
|
||||||
|
import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||||
|
import { customerScope, workOrderScope } from "@/server/services/work-orders/visibility";
|
||||||
|
import { findDuplicateCustomers } from "@/server/services/customers/duplicates";
|
||||||
|
import {
|
||||||
|
customerCreateSchema,
|
||||||
|
customerPatchSchema,
|
||||||
|
type CustomerCreateInput,
|
||||||
|
type CustomerPatchInput,
|
||||||
|
} from "@/server/services/customers/schemas";
|
||||||
|
|
||||||
|
export const CUSTOMER_LIST_STATUSES = ["active", "inactive", "provisional", "merged"] as const;
|
||||||
|
export type CustomerListStatus = (typeof CUSTOMER_LIST_STATUSES)[number];
|
||||||
|
|
||||||
|
const CLOSED_ORDER_STATUSES = ["billed", "cancelled"] as const;
|
||||||
|
|
||||||
|
function isUniqueViolation(err: unknown): boolean {
|
||||||
|
return (err as { code?: string })?.code === "P2002";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Customer ids are only visible within the user's scope; everything else is "not found". */
|
||||||
|
async function findVisibleCustomer(ctx: ServiceCtx, id: string, extra: Prisma.CustomerWhereInput = {}) {
|
||||||
|
return ctx.db.customer.findFirst({ where: { AND: [{ id }, await customerScope(ctx), extra] } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listCustomers(
|
||||||
|
ctx: ServiceCtx,
|
||||||
|
opts: { q?: string; status?: CustomerListStatus | "all"; page?: number; pageSize?: number } = {},
|
||||||
|
) {
|
||||||
|
assertCan(ctx, "customer:read");
|
||||||
|
const page = Math.max(1, opts.page ?? 1);
|
||||||
|
const pageSize = Math.min(100, Math.max(1, opts.pageSize ?? 25));
|
||||||
|
const q = opts.q?.trim();
|
||||||
|
const statusFilter: Prisma.CustomerWhereInput =
|
||||||
|
!opts.status || opts.status === "all" ? { status: { not: "merged" } } : { status: opts.status };
|
||||||
|
const where: Prisma.CustomerWhereInput = {
|
||||||
|
AND: [
|
||||||
|
await customerScope(ctx),
|
||||||
|
statusFilter,
|
||||||
|
q
|
||||||
|
? {
|
||||||
|
OR: [
|
||||||
|
{ customerNumber: { contains: q, mode: "insensitive" } },
|
||||||
|
{ companyName: { contains: q, mode: "insensitive" } },
|
||||||
|
{ firstName: { contains: q, mode: "insensitive" } },
|
||||||
|
{ lastName: { contains: q, mode: "insensitive" } },
|
||||||
|
{ city: { contains: q, mode: "insensitive" } },
|
||||||
|
{ email: { contains: q, mode: "insensitive" } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const [total, items] = await Promise.all([
|
||||||
|
ctx.db.customer.count({ where }),
|
||||||
|
ctx.db.customer.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: [{ companyName: "asc" }, { lastName: "asc" }, { createdAt: "asc" }],
|
||||||
|
skip: (page - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
customerNumber: true,
|
||||||
|
companyName: true,
|
||||||
|
salutation: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
postalCode: true,
|
||||||
|
city: true,
|
||||||
|
phone: true,
|
||||||
|
email: true,
|
||||||
|
status: true,
|
||||||
|
updatedAt: true,
|
||||||
|
_count: { select: { sites: { where: { deletedAt: null } } } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
return { items, total, page, pageSize };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCustomer(ctx: ServiceCtx, id: string) {
|
||||||
|
assertCan(ctx, "customer:read");
|
||||||
|
const customer = await ctx.db.customer.findFirst({
|
||||||
|
where: { AND: [{ id }, await customerScope(ctx)] },
|
||||||
|
include: { contacts: { where: { deletedAt: null }, orderBy: { name: "asc" } } },
|
||||||
|
});
|
||||||
|
if (!customer) throw new ServiceError("not_found", "customer not found");
|
||||||
|
return customer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lightweight options for selects (sites form, merge target). */
|
||||||
|
export async function customerOptions(ctx: ServiceCtx, opts: { take?: number } = {}) {
|
||||||
|
assertCan(ctx, "customer:read");
|
||||||
|
return ctx.db.customer.findMany({
|
||||||
|
where: { AND: [await customerScope(ctx), { status: { in: ["active", "provisional"] } }] },
|
||||||
|
select: { id: true, customerNumber: true, companyName: true, firstName: true, lastName: true, city: true },
|
||||||
|
orderBy: [{ companyName: "asc" }, { lastName: "asc" }],
|
||||||
|
take: opts.take ?? 500,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertNumberFree(ctx: ServiceCtx, customerNumber: string, exceptId?: string) {
|
||||||
|
const clash = await ctx.db.customer.findFirst({
|
||||||
|
where: { customerNumber, ...(exceptId ? { id: { not: exceptId } } : {}) },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (clash) throw new ServiceError("conflict", "customer number taken", { field: "customerNumber", reason: "number_taken" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Next free sequence number; skips values already taken by manually entered numbers. */
|
||||||
|
async function allocateCustomerNumber(ctx: ServiceCtx): Promise<string> {
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
const candidate = await nextNumber(ctx.db, ctx.tenantId, "customer");
|
||||||
|
const taken = await ctx.db.customer.findFirst({ where: { customerNumber: candidate }, select: { id: true } });
|
||||||
|
if (!taken) return candidate;
|
||||||
|
}
|
||||||
|
throw new ServiceError("conflict", "could not allocate customer number", { reason: "number_allocation" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a customer. Runs the duplicate check first; if possible duplicates exist and the caller
|
||||||
|
* has not acknowledged them, throws `conflict` with `details.reason = "possible_duplicates"` and
|
||||||
|
* `details.candidates` — the UI shows "Mögliche Dublette" and lets the user decide.
|
||||||
|
*/
|
||||||
|
export async function createCustomer(ctx: ServiceCtx, input: CustomerCreateInput, opts: { acknowledgeDuplicates?: boolean } = {}) {
|
||||||
|
assertCan(ctx, "customer:write");
|
||||||
|
const data = customerCreateSchema.parse(input);
|
||||||
|
|
||||||
|
// a taken number is a hard conflict — acknowledging a duplicate hint could not resolve it
|
||||||
|
if (data.customerNumber) await assertNumberFree(ctx, data.customerNumber);
|
||||||
|
|
||||||
|
if (!opts.acknowledgeDuplicates) {
|
||||||
|
const candidates = await findDuplicateCustomers(ctx, data);
|
||||||
|
if (candidates.length > 0) {
|
||||||
|
throw new ServiceError("conflict", "possible duplicates", { reason: "possible_duplicates", candidates });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const customerNumber = data.customerNumber ?? (await allocateCustomerNumber(ctx));
|
||||||
|
const status = data.status ?? "active";
|
||||||
|
|
||||||
|
let customer;
|
||||||
|
try {
|
||||||
|
customer = await ctx.db.customer.create({
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
customerNumber,
|
||||||
|
country: data.country ?? "DE",
|
||||||
|
status,
|
||||||
|
isProvisional: status === "provisional",
|
||||||
|
createdById: ctx.userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (isUniqueViolation(err)) throw new ServiceError("conflict", "customer number taken", { field: "customerNumber", reason: "number_taken" });
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action: "create", entity: "customer", entityId: customer.id, after: customer });
|
||||||
|
return customer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateCustomer(ctx: ServiceCtx, id: string, patch: CustomerPatchInput) {
|
||||||
|
assertCan(ctx, "customer:write");
|
||||||
|
const data = customerPatchSchema.parse(patch);
|
||||||
|
const before = await findVisibleCustomer(ctx, id, { status: { not: "merged" } });
|
||||||
|
if (!before) throw new ServiceError("not_found", "customer not found");
|
||||||
|
|
||||||
|
const merged = { companyName: before.companyName, lastName: before.lastName, ...data };
|
||||||
|
if (!merged.companyName && !merged.lastName) {
|
||||||
|
throw new ServiceError("invalid", "name required", { field: "companyName", reason: "name_required" });
|
||||||
|
}
|
||||||
|
if (data.customerNumber === null) delete data.customerNumber; // the number can be changed, not removed
|
||||||
|
if (data.customerNumber && data.customerNumber !== before.customerNumber) await assertNumberFree(ctx, data.customerNumber, id);
|
||||||
|
|
||||||
|
let after;
|
||||||
|
try {
|
||||||
|
after = await ctx.db.customer.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
...(data.status ? { isProvisional: data.status === "provisional" } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (isUniqueViolation(err)) throw new ServiceError("conflict", "customer number taken", { field: "customerNumber", reason: "number_taken" });
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action: "update", entity: "customer", entityId: id, before, after });
|
||||||
|
return after;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Soft delete (spec §27.5). Blocked while open work orders reference the customer. */
|
||||||
|
export async function deleteCustomer(ctx: ServiceCtx, id: string) {
|
||||||
|
assertCan(ctx, "customer:write");
|
||||||
|
const before = await findVisibleCustomer(ctx, id);
|
||||||
|
if (!before) throw new ServiceError("not_found", "customer not found");
|
||||||
|
const open = await ctx.db.workOrder.count({
|
||||||
|
where: { customerId: id, deletedAt: null, status: { notIn: [...CLOSED_ORDER_STATUSES] } },
|
||||||
|
});
|
||||||
|
if (open > 0) throw new ServiceError("blocked", "customer has open work orders", { reason: "open_work_orders", count: open });
|
||||||
|
const after = await ctx.db.customer.update({ where: { id }, data: { deletedAt: new Date(), status: "inactive" } });
|
||||||
|
await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action: "delete", entity: "customer", entityId: id, before, after });
|
||||||
|
return after;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** provisional → active (used by the emergency lane's backoffice review). */
|
||||||
|
export async function confirmProvisionalCustomer(ctx: ServiceCtx, id: string) {
|
||||||
|
assertCan(ctx, "customer:write");
|
||||||
|
const before = await findVisibleCustomer(ctx, id);
|
||||||
|
if (!before) throw new ServiceError("not_found", "customer not found");
|
||||||
|
if (before.status !== "provisional") throw new ServiceError("conflict", "customer is not provisional", { reason: "not_provisional" });
|
||||||
|
const after = await ctx.db.customer.update({ where: { id }, data: { status: "active", isProvisional: false } });
|
||||||
|
await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action: "update", entity: "customer", entityId: id, before, after });
|
||||||
|
return after;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read-only order list for the customer detail tab (work order scope applies). */
|
||||||
|
export async function listCustomerWorkOrders(ctx: ServiceCtx, customerId: string, opts: { take?: number } = {}) {
|
||||||
|
await getCustomer(ctx, customerId);
|
||||||
|
return ctx.db.workOrder.findMany({
|
||||||
|
where: { AND: [{ customerId }, await workOrderScope(ctx)] },
|
||||||
|
orderBy: [{ plannedStart: "desc" }, { createdAt: "desc" }],
|
||||||
|
take: opts.take ?? 100,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
number: true,
|
||||||
|
title: true,
|
||||||
|
status: true,
|
||||||
|
plannedStart: true,
|
||||||
|
createdAt: true,
|
||||||
|
site: { select: { id: true, name: true } },
|
||||||
|
team: { select: { name: true } },
|
||||||
|
orderType: { select: { name: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import type { Prisma } from "@prisma/client";
|
||||||
|
import { assertCan, type ServiceCtx } from "@/server/services/context";
|
||||||
|
import { customerScope } from "@/server/services/work-orders/visibility";
|
||||||
|
import {
|
||||||
|
DUPLICATE_THRESHOLD,
|
||||||
|
normalizeCompanyName,
|
||||||
|
normalizePhone,
|
||||||
|
normalizeText,
|
||||||
|
scoreDuplicate,
|
||||||
|
type DuplicateCandidateInput,
|
||||||
|
type DuplicateReason,
|
||||||
|
} from "@/lib/customers/duplicates";
|
||||||
|
import { customerDisplayName } from "@/server/services/customers/format";
|
||||||
|
|
||||||
|
export type DuplicateCandidate = {
|
||||||
|
customerId: string;
|
||||||
|
score: number;
|
||||||
|
reasons: DuplicateReason[];
|
||||||
|
customerNumber: string | null;
|
||||||
|
displayName: string;
|
||||||
|
city: string | null;
|
||||||
|
status: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PREFILTER_LIMIT = 200;
|
||||||
|
|
||||||
|
/** A raw (non-transliterated) significant word of the company name for a DB `contains` prefilter. */
|
||||||
|
function rawNameTokens(companyName: string | null | undefined): string[] {
|
||||||
|
const normalized = normalizeCompanyName(companyName);
|
||||||
|
const raw = (companyName ?? "").toLowerCase().split(/[^\p{L}\p{N}]+/u).filter((w) => w.length >= 3);
|
||||||
|
const norm = normalized.split(" ").filter((w) => w.length >= 3);
|
||||||
|
const legal = new Set(["gmbh", "mbh", "kgaa", "ohg", "gbr", "und", "co"]);
|
||||||
|
return [...new Set([...raw, ...norm])].filter((w) => !legal.has(normalizeText(w))).slice(0, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Possible duplicates of `candidate` among the tenant's customers (spec §7.3, US-003).
|
||||||
|
* Returns candidates with score ≥ DUPLICATE_THRESHOLD, best first. Never merges.
|
||||||
|
* Contract (ARCHITEKTUR §6, used by lane imports): `findDuplicateCustomers(ctx, candidate) → Candidate[]`.
|
||||||
|
*/
|
||||||
|
export async function findDuplicateCustomers(
|
||||||
|
ctx: ServiceCtx,
|
||||||
|
candidate: DuplicateCandidateInput,
|
||||||
|
opts: { excludeId?: string; limit?: number } = {},
|
||||||
|
): Promise<DuplicateCandidate[]> {
|
||||||
|
assertCan(ctx, "customer:read");
|
||||||
|
|
||||||
|
const or: Prisma.CustomerWhereInput[] = [];
|
||||||
|
const insensitive = "insensitive" as const;
|
||||||
|
if (candidate.customerNumber?.trim()) or.push({ customerNumber: { equals: candidate.customerNumber.trim(), mode: insensitive } });
|
||||||
|
if (candidate.email?.trim()) or.push({ email: { equals: candidate.email.trim(), mode: insensitive } });
|
||||||
|
if (candidate.postalCode?.trim()) or.push({ postalCode: candidate.postalCode.replace(/\s+/g, "") });
|
||||||
|
for (const token of rawNameTokens(candidate.companyName)) or.push({ companyName: { contains: token, mode: insensitive } });
|
||||||
|
if (candidate.lastName?.trim()) or.push({ lastName: { equals: candidate.lastName.trim(), mode: insensitive } });
|
||||||
|
const scope = await customerScope(ctx);
|
||||||
|
const baseFilter: Prisma.CustomerWhereInput[] = [scope, { status: { not: "merged" } }, opts.excludeId ? { id: { not: opts.excludeId } } : {}];
|
||||||
|
|
||||||
|
// Stored phone numbers carry arbitrary formatting ("+49 40 123456-0"), so a SQL `contains` is
|
||||||
|
// unreliable: compare normalized digits over the (narrow) phone columns in memory instead.
|
||||||
|
const wantedPhones = new Set([normalizePhone(candidate.phone), normalizePhone(candidate.mobile)].filter(Boolean));
|
||||||
|
if (wantedPhones.size) {
|
||||||
|
const phoneRows = await ctx.db.customer.findMany({
|
||||||
|
where: { AND: [...baseFilter, { OR: [{ phone: { not: null } }, { mobile: { not: null } }] }] },
|
||||||
|
select: { id: true, phone: true, mobile: true },
|
||||||
|
take: 10_000,
|
||||||
|
});
|
||||||
|
const ids = phoneRows.filter((r) => wantedPhones.has(normalizePhone(r.phone)) || wantedPhones.has(normalizePhone(r.mobile))).map((r) => r.id);
|
||||||
|
if (ids.length) or.push({ id: { in: ids } });
|
||||||
|
}
|
||||||
|
if (or.length === 0) return [];
|
||||||
|
|
||||||
|
const rows = await ctx.db.customer.findMany({
|
||||||
|
where: { AND: [...baseFilter, { OR: or }] },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
customerNumber: true,
|
||||||
|
companyName: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
street: true,
|
||||||
|
houseNumber: true,
|
||||||
|
postalCode: true,
|
||||||
|
city: true,
|
||||||
|
email: true,
|
||||||
|
phone: true,
|
||||||
|
mobile: true,
|
||||||
|
status: true,
|
||||||
|
},
|
||||||
|
take: PREFILTER_LIMIT,
|
||||||
|
});
|
||||||
|
|
||||||
|
return rows
|
||||||
|
.map((r) => {
|
||||||
|
const m = scoreDuplicate(candidate, r);
|
||||||
|
return {
|
||||||
|
customerId: r.id,
|
||||||
|
score: m.score,
|
||||||
|
reasons: m.reasons,
|
||||||
|
customerNumber: r.customerNumber,
|
||||||
|
displayName: customerDisplayName(r),
|
||||||
|
city: r.city,
|
||||||
|
status: r.status,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((c) => c.score >= DUPLICATE_THRESHOLD)
|
||||||
|
.sort((a, b) => b.score - a.score)
|
||||||
|
.slice(0, opts.limit ?? 10);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
// Pure display helpers (no server imports) — usable from server and client components.
|
||||||
|
|
||||||
|
export type CustomerNameFields = {
|
||||||
|
companyName?: string | null;
|
||||||
|
salutation?: string | null;
|
||||||
|
firstName?: string | null;
|
||||||
|
lastName?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function customerDisplayName(c: CustomerNameFields): string {
|
||||||
|
if (c.companyName?.trim()) return c.companyName.trim();
|
||||||
|
return [c.firstName, c.lastName].filter((s) => s && s.trim()).join(" ").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AddressFields = {
|
||||||
|
street?: string | null;
|
||||||
|
houseNumber?: string | null;
|
||||||
|
postalCode?: string | null;
|
||||||
|
city?: string | null;
|
||||||
|
country?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function formatAddress(a: AddressFields, opts: { withCountry?: boolean } = {}): string {
|
||||||
|
const line1 = [a.street, a.houseNumber].filter(Boolean).join(" ");
|
||||||
|
const line2 = [a.postalCode, a.city].filter(Boolean).join(" ");
|
||||||
|
const parts = [line1, line2];
|
||||||
|
if (opts.withCountry && a.country && a.country !== "DE") parts.push(a.country);
|
||||||
|
return parts.filter(Boolean).join(", ");
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { writeAuditLog } from "@/server/audit";
|
||||||
|
import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||||
|
import { mergeSchema, type MergeInput } from "@/server/services/customers/schemas";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge two customers (spec §7.3). Only with `customer:merge` and an explicit `confirm: true`.
|
||||||
|
* Contacts, sites, work orders and documents of the source are moved to the target; the source
|
||||||
|
* becomes status `merged` with `mergedIntoId`. Both records must belong to the caller's tenant
|
||||||
|
* (dbForTenant) — ids of another tenant are "not found". Never triggered automatically.
|
||||||
|
*/
|
||||||
|
export async function mergeCustomers(ctx: ServiceCtx, input: MergeInput) {
|
||||||
|
assertCan(ctx, "customer:merge");
|
||||||
|
const { sourceId, targetId } = mergeSchema.parse(input);
|
||||||
|
|
||||||
|
const [source, target] = await Promise.all([
|
||||||
|
ctx.db.customer.findFirst({ where: { id: sourceId, deletedAt: null } }),
|
||||||
|
ctx.db.customer.findFirst({ where: { id: targetId, deletedAt: null } }),
|
||||||
|
]);
|
||||||
|
if (!source) throw new ServiceError("not_found", "source customer not found", { field: "sourceId", reason: "not_found" });
|
||||||
|
if (!target) throw new ServiceError("not_found", "target customer not found", { field: "targetId", reason: "not_found" });
|
||||||
|
if (source.status === "merged" || target.status === "merged") {
|
||||||
|
throw new ServiceError("conflict", "customer already merged", { reason: "already_merged" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [contacts, sites, workOrders, documents, mergedSource] = await ctx.db.$transaction([
|
||||||
|
ctx.db.contact.updateMany({ where: { customerId: sourceId }, data: { customerId: targetId } }),
|
||||||
|
ctx.db.site.updateMany({ where: { customerId: sourceId }, data: { customerId: targetId } }),
|
||||||
|
// version bump: offline clients must not overwrite the re-parented order with stale data
|
||||||
|
ctx.db.workOrder.updateMany({ where: { customerId: sourceId }, data: { customerId: targetId, version: { increment: 1 } } }),
|
||||||
|
ctx.db.document.updateMany({ where: { customerId: sourceId }, data: { customerId: targetId } }),
|
||||||
|
ctx.db.customer.update({
|
||||||
|
where: { id: sourceId },
|
||||||
|
data: { status: "merged", mergedIntoId: targetId, isProvisional: false },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const moved = { contacts: contacts.count, sites: sites.count, workOrders: workOrders.count, documents: documents.count };
|
||||||
|
await writeAuditLog({
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
actorId: ctx.userId,
|
||||||
|
action: "update",
|
||||||
|
entity: "customer",
|
||||||
|
entityId: sourceId,
|
||||||
|
before: source,
|
||||||
|
after: { ...mergedSource, merge: { role: "source", targetId, moved } },
|
||||||
|
});
|
||||||
|
await writeAuditLog({
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
actorId: ctx.userId,
|
||||||
|
action: "update",
|
||||||
|
entity: "customer",
|
||||||
|
entityId: targetId,
|
||||||
|
before: target,
|
||||||
|
after: { merge: { role: "target", sourceId, moved } },
|
||||||
|
});
|
||||||
|
return { sourceId, targetId, moved };
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
/** Empty strings become null; strings are trimmed and length-limited. */
|
||||||
|
export const optStr = (max: number) =>
|
||||||
|
z.preprocess((v) => (typeof v === "string" && v.trim() === "" ? null : v), z.string().trim().max(max).nullable().optional());
|
||||||
|
|
||||||
|
export const optEmail = () =>
|
||||||
|
z.preprocess(
|
||||||
|
(v) => (typeof v === "string" && v.trim() === "" ? null : typeof v === "string" ? v.trim().toLowerCase() : v),
|
||||||
|
z.string().max(200).email().nullable().optional(),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const CUSTOMER_EDITABLE_STATUSES = ["active", "inactive", "provisional"] as const;
|
||||||
|
|
||||||
|
const customerFields = {
|
||||||
|
customerNumber: optStr(40),
|
||||||
|
companyName: optStr(200),
|
||||||
|
salutation: optStr(40),
|
||||||
|
firstName: optStr(100),
|
||||||
|
lastName: optStr(100),
|
||||||
|
street: optStr(200),
|
||||||
|
houseNumber: optStr(20),
|
||||||
|
postalCode: optStr(12),
|
||||||
|
city: optStr(100),
|
||||||
|
country: z.preprocess(
|
||||||
|
(v) => (typeof v === "string" && v.trim() === "" ? undefined : typeof v === "string" ? v.trim().toUpperCase() : v),
|
||||||
|
z.string().regex(/^[A-Z]{2}$/).optional(),
|
||||||
|
),
|
||||||
|
phone: optStr(50),
|
||||||
|
mobile: optStr(50),
|
||||||
|
email: optEmail(),
|
||||||
|
notes: optStr(5000),
|
||||||
|
billingNotes: optStr(5000),
|
||||||
|
status: z.enum(CUSTOMER_EDITABLE_STATUSES).optional(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const nameRequired = (v: { companyName?: string | null; lastName?: string | null }) => Boolean(v.companyName || v.lastName);
|
||||||
|
|
||||||
|
export const customerCreateSchema = z
|
||||||
|
.object(customerFields)
|
||||||
|
.refine(nameRequired, { message: "name_required", path: ["companyName"] });
|
||||||
|
|
||||||
|
/** PATCH semantics: absent = unchanged, null = cleared. */
|
||||||
|
export const customerPatchSchema = z.object(customerFields).partial();
|
||||||
|
|
||||||
|
export type CustomerCreateInput = z.input<typeof customerCreateSchema>;
|
||||||
|
export type CustomerPatchInput = z.input<typeof customerPatchSchema>;
|
||||||
|
|
||||||
|
export const CONTACT_CHANNELS = ["phone", "mobile", "email"] as const;
|
||||||
|
|
||||||
|
export const contactSchema = z.object({
|
||||||
|
name: z.string().trim().min(1).max(200),
|
||||||
|
role: optStr(100),
|
||||||
|
phone: optStr(50),
|
||||||
|
mobile: optStr(50),
|
||||||
|
email: optEmail(),
|
||||||
|
preferredChannel: z.preprocess((v) => (v === "" ? null : v), z.enum(CONTACT_CHANNELS).nullable().optional()),
|
||||||
|
notes: optStr(2000),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ContactInput = z.input<typeof contactSchema>;
|
||||||
|
|
||||||
|
export const mergeSchema = z
|
||||||
|
.object({
|
||||||
|
sourceId: z.string().min(1),
|
||||||
|
targetId: z.string().min(1),
|
||||||
|
// explicit confirmation is mandatory (spec §7.3: never merge without confirmation)
|
||||||
|
confirm: z.literal(true),
|
||||||
|
})
|
||||||
|
.refine((v) => v.sourceId !== v.targetId, { message: "same_customer", path: ["targetId"] });
|
||||||
|
|
||||||
|
export type MergeInput = z.input<typeof mergeSchema>;
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import type { Prisma, WorkOrderStatus } from "@prisma/client";
|
||||||
|
import { assertCan, can, ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||||
|
import { siteScope } from "@/server/services/work-orders/visibility";
|
||||||
|
|
||||||
|
export type SiteHistoryEntry = {
|
||||||
|
workOrderId: string;
|
||||||
|
number: string;
|
||||||
|
title: string;
|
||||||
|
date: Date;
|
||||||
|
status: WorkOrderStatus;
|
||||||
|
isEmergency: boolean;
|
||||||
|
orderType: string | null;
|
||||||
|
team: string | null;
|
||||||
|
/** Texts of ActivityNote kind work_done, oldest first. */
|
||||||
|
workDone: string[];
|
||||||
|
/** Short summary for list views (≤ 280 chars). */
|
||||||
|
summary: string;
|
||||||
|
materials: { name: string; unit: string; quantity: number }[];
|
||||||
|
photoCount: number;
|
||||||
|
approvedReports: { id: string; type: "daily" | "completion"; reportDate: Date; version: number }[];
|
||||||
|
signed: boolean;
|
||||||
|
followUps: string[];
|
||||||
|
hasOpenFollowUp: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SUMMARY_MAX = 280;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chronological deployment history of a site (spec §8.3, US-005, US-011) — newest first.
|
||||||
|
*
|
||||||
|
* Access: the site itself must be visible (`siteScope`: backoffice all, field roles only via a
|
||||||
|
* visible work order at the site — otherwise `not_found`).
|
||||||
|
* Field roles (no `work_order:read_all`) ALWAYS get only released deployments — work orders with an
|
||||||
|
* approved report — regardless of `onlyApproved` (US-011 "Liste aller freigegebenen Einsätze");
|
||||||
|
* this is the history of the site, so released deployments of other teams are included (US-005).
|
||||||
|
* Internal notes are never part of the result. Backoffice may pass `onlyApproved=false`.
|
||||||
|
*/
|
||||||
|
export async function getSiteHistory(
|
||||||
|
ctx: ServiceCtx,
|
||||||
|
siteId: string,
|
||||||
|
opts: { onlyApproved?: boolean; page?: number; pageSize?: number } = {},
|
||||||
|
): Promise<{ items: SiteHistoryEntry[]; total: number; page: number; pageSize: number; onlyApproved: boolean }> {
|
||||||
|
assertCan(ctx, "site:read");
|
||||||
|
const site = await ctx.db.site.findFirst({ where: { AND: [{ id: siteId }, await siteScope(ctx)] }, select: { id: true } });
|
||||||
|
if (!site) throw new ServiceError("not_found", "site not found");
|
||||||
|
|
||||||
|
const onlyApproved = !can(ctx, "work_order:read_all") || opts.onlyApproved === true;
|
||||||
|
const page = Math.max(1, opts.page ?? 1);
|
||||||
|
const pageSize = Math.min(100, Math.max(1, opts.pageSize ?? 50));
|
||||||
|
|
||||||
|
const where: Prisma.WorkOrderWhereInput = {
|
||||||
|
siteId,
|
||||||
|
deletedAt: null,
|
||||||
|
...(onlyApproved ? { reports: { some: { status: "approved" } } } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const orders = await ctx.db.workOrder.findMany({
|
||||||
|
where,
|
||||||
|
take: 1000,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
number: true,
|
||||||
|
title: true,
|
||||||
|
status: true,
|
||||||
|
isEmergency: true,
|
||||||
|
plannedStart: true,
|
||||||
|
createdAt: true,
|
||||||
|
followUpWork: true,
|
||||||
|
orderType: { select: { name: true } },
|
||||||
|
team: { select: { name: true } },
|
||||||
|
workSessions: { select: { startedAt: true }, orderBy: { startedAt: "asc" }, take: 1 },
|
||||||
|
notes: {
|
||||||
|
where: { deletedAt: null, kind: { in: ["work_done", "follow_up"] } },
|
||||||
|
select: { kind: true, text: true },
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
},
|
||||||
|
materialUsages: { where: { usageStatus: { not: "not_used" } }, select: { name: true, unit: true, actualQuantity: true } },
|
||||||
|
_count: { select: { photos: true } },
|
||||||
|
reports: {
|
||||||
|
where: { status: { not: "superseded" } },
|
||||||
|
select: { id: true, type: true, reportDate: true, version: true, status: true, signature: { select: { outcome: true } } },
|
||||||
|
orderBy: { reportDate: "asc" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const entries: SiteHistoryEntry[] = orders.map((o) => {
|
||||||
|
const workDone = o.notes.filter((n) => n.kind === "work_done").map((n) => n.text);
|
||||||
|
const followUps = [
|
||||||
|
...(o.followUpWork?.trim() ? [o.followUpWork.trim()] : []),
|
||||||
|
...o.notes.filter((n) => n.kind === "follow_up").map((n) => n.text),
|
||||||
|
];
|
||||||
|
const materialMap = new Map<string, { name: string; unit: string; quantity: number }>();
|
||||||
|
for (const m of o.materialUsages) {
|
||||||
|
const key = `${m.name.trim().toLowerCase()}|${m.unit.trim().toLowerCase()}`;
|
||||||
|
const entry = materialMap.get(key) ?? { name: m.name.trim(), unit: m.unit.trim(), quantity: 0 };
|
||||||
|
entry.quantity = Math.round((entry.quantity + Number(m.actualQuantity.toString())) * 1000) / 1000;
|
||||||
|
materialMap.set(key, entry);
|
||||||
|
}
|
||||||
|
const joined = workDone.join(" · ");
|
||||||
|
const reports = onlyApproved ? o.reports.filter((r) => r.status === "approved") : o.reports;
|
||||||
|
return {
|
||||||
|
workOrderId: o.id,
|
||||||
|
number: o.number,
|
||||||
|
title: o.title,
|
||||||
|
date: o.workSessions[0]?.startedAt ?? o.plannedStart ?? o.createdAt,
|
||||||
|
status: o.status,
|
||||||
|
isEmergency: o.isEmergency,
|
||||||
|
orderType: o.orderType?.name ?? null,
|
||||||
|
team: o.team?.name ?? null,
|
||||||
|
workDone,
|
||||||
|
summary: joined.length > SUMMARY_MAX ? `${joined.slice(0, SUMMARY_MAX - 1)}…` : joined,
|
||||||
|
materials: [...materialMap.values()].sort((a, b) => a.name.localeCompare(b.name, "de")),
|
||||||
|
photoCount: o._count.photos,
|
||||||
|
approvedReports: o.reports
|
||||||
|
.filter((r) => r.status === "approved")
|
||||||
|
.map((r) => ({ id: r.id, type: r.type, reportDate: r.reportDate, version: r.version })),
|
||||||
|
signed: reports.some((r) => r.signature?.outcome === "signed"),
|
||||||
|
followUps,
|
||||||
|
hasOpenFollowUp: followUps.length > 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
entries.sort((a, b) => b.date.getTime() - a.date.getTime());
|
||||||
|
const total = entries.length;
|
||||||
|
return { items: entries.slice((page - 1) * pageSize, page * pageSize), total, page, pageSize, onlyApproved };
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
// OpenStreetMap link for a site (spec §8.1) — plain URL, no embed (no third-party requests from the app).
|
||||||
|
|
||||||
|
export function siteMapUrl(site: {
|
||||||
|
street?: string | null;
|
||||||
|
houseNumber?: string | null;
|
||||||
|
postalCode?: string | null;
|
||||||
|
city?: string | null;
|
||||||
|
country?: string | null;
|
||||||
|
latitude?: number | null;
|
||||||
|
longitude?: number | null;
|
||||||
|
}): string | null {
|
||||||
|
if (typeof site.latitude === "number" && typeof site.longitude === "number") {
|
||||||
|
const lat = site.latitude.toFixed(6);
|
||||||
|
const lon = site.longitude.toFixed(6);
|
||||||
|
return `https://www.openstreetmap.org/?mlat=${lat}&mlon=${lon}#map=18/${lat}/${lon}`;
|
||||||
|
}
|
||||||
|
const line = [[site.street, site.houseNumber].filter(Boolean).join(" "), [site.postalCode, site.city].filter(Boolean).join(" "), site.country]
|
||||||
|
.filter((s) => s && String(s).trim())
|
||||||
|
.join(", ");
|
||||||
|
if (!site.city && !site.postalCode) return null;
|
||||||
|
return `https://www.openstreetmap.org/search?query=${encodeURIComponent(line)}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import type { Prisma } from "@prisma/client";
|
||||||
|
import { writeAuditLog } from "@/server/audit";
|
||||||
|
import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||||
|
import { siteScope } from "@/server/services/work-orders/visibility";
|
||||||
|
import { optStr } from "@/server/services/customers/schemas";
|
||||||
|
|
||||||
|
export const SITE_STATUSES = ["active", "inactive", "provisional"] as const;
|
||||||
|
|
||||||
|
const optCoord = (min: number, max: number) =>
|
||||||
|
z.preprocess((v) => (v === "" || v === undefined ? undefined : v === null ? null : Number(String(v).replace(",", "."))), z.number().min(min).max(max).nullable().optional());
|
||||||
|
|
||||||
|
const siteFields = {
|
||||||
|
customerId: z.string().min(1),
|
||||||
|
name: z.string().trim().min(1).max(200),
|
||||||
|
street: optStr(200),
|
||||||
|
houseNumber: optStr(20),
|
||||||
|
postalCode: optStr(12),
|
||||||
|
city: optStr(100),
|
||||||
|
country: z.preprocess(
|
||||||
|
(v) => (typeof v === "string" && v.trim() === "" ? undefined : typeof v === "string" ? v.trim().toUpperCase() : v),
|
||||||
|
z.string().regex(/^[A-Z]{2}$/).optional(),
|
||||||
|
),
|
||||||
|
contactId: optStr(64),
|
||||||
|
onSiteContact: optStr(200),
|
||||||
|
phone: optStr(50),
|
||||||
|
accessNotes: optStr(5000),
|
||||||
|
parkingNotes: optStr(5000),
|
||||||
|
safetyNotes: optStr(5000),
|
||||||
|
technicalNotes: optStr(5000),
|
||||||
|
status: z.enum(SITE_STATUSES).optional(),
|
||||||
|
latitude: optCoord(-90, 90),
|
||||||
|
longitude: optCoord(-180, 180),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const siteCreateSchema = z.object(siteFields);
|
||||||
|
export const sitePatchSchema = z.object(siteFields).partial();
|
||||||
|
export type SiteCreateInput = z.input<typeof siteCreateSchema>;
|
||||||
|
export type SitePatchInput = z.input<typeof sitePatchSchema>;
|
||||||
|
|
||||||
|
const CLOSED_ORDER_STATUSES = ["billed", "cancelled"] as const;
|
||||||
|
|
||||||
|
async function assertCustomerAndContact(ctx: ServiceCtx, customerId: string, contactId: string | null | undefined) {
|
||||||
|
const customer = await ctx.db.customer.findFirst({
|
||||||
|
where: { id: customerId, deletedAt: null, status: { not: "merged" } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!customer) throw new ServiceError("invalid", "customer not found", { field: "customerId", reason: "customer_not_found" });
|
||||||
|
if (contactId) {
|
||||||
|
const contact = await ctx.db.contact.findFirst({ where: { id: contactId, customerId, deletedAt: null }, select: { id: true } });
|
||||||
|
if (!contact) throw new ServiceError("invalid", "contact does not belong to customer", { field: "contactId", reason: "contact_mismatch" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listSites(
|
||||||
|
ctx: ServiceCtx,
|
||||||
|
opts: { q?: string; customerId?: string; status?: (typeof SITE_STATUSES)[number] | "all"; page?: number; pageSize?: number } = {},
|
||||||
|
) {
|
||||||
|
assertCan(ctx, "site:read");
|
||||||
|
const page = Math.max(1, opts.page ?? 1);
|
||||||
|
const pageSize = Math.min(100, Math.max(1, opts.pageSize ?? 25));
|
||||||
|
const q = opts.q?.trim();
|
||||||
|
const where: Prisma.SiteWhereInput = {
|
||||||
|
AND: [
|
||||||
|
await siteScope(ctx),
|
||||||
|
opts.status && opts.status !== "all" ? { status: opts.status } : {},
|
||||||
|
opts.customerId ? { customerId: opts.customerId } : {},
|
||||||
|
q
|
||||||
|
? {
|
||||||
|
OR: [
|
||||||
|
{ name: { contains: q, mode: "insensitive" } },
|
||||||
|
{ street: { contains: q, mode: "insensitive" } },
|
||||||
|
{ city: { contains: q, mode: "insensitive" } },
|
||||||
|
{ postalCode: { contains: q } },
|
||||||
|
{ customer: { companyName: { contains: q, mode: "insensitive" } } },
|
||||||
|
{ customer: { lastName: { contains: q, mode: "insensitive" } } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const [total, items] = await Promise.all([
|
||||||
|
ctx.db.site.count({ where }),
|
||||||
|
ctx.db.site.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: [{ name: "asc" }, { createdAt: "asc" }],
|
||||||
|
skip: (page - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
street: true,
|
||||||
|
houseNumber: true,
|
||||||
|
postalCode: true,
|
||||||
|
city: true,
|
||||||
|
status: true,
|
||||||
|
customer: { select: { id: true, customerNumber: true, companyName: true, firstName: true, lastName: true } },
|
||||||
|
_count: { select: { workOrders: { where: { deletedAt: null } } } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
return { items, total, page, pageSize };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSite(ctx: ServiceCtx, id: string) {
|
||||||
|
assertCan(ctx, "site:read");
|
||||||
|
const site = await ctx.db.site.findFirst({
|
||||||
|
where: { AND: [{ id }, await siteScope(ctx)] },
|
||||||
|
include: {
|
||||||
|
customer: { select: { id: true, customerNumber: true, companyName: true, firstName: true, lastName: true, status: true } },
|
||||||
|
contact: { select: { id: true, name: true, phone: true, mobile: true, email: true, preferredChannel: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!site) throw new ServiceError("not_found", "site not found");
|
||||||
|
return site;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSite(ctx: ServiceCtx, input: SiteCreateInput) {
|
||||||
|
assertCan(ctx, "site:write");
|
||||||
|
const data = siteCreateSchema.parse(input);
|
||||||
|
await assertCustomerAndContact(ctx, data.customerId, data.contactId);
|
||||||
|
const site = await ctx.db.site.create({ data: { ...data, tenantId: ctx.tenantId, country: data.country ?? "DE" } });
|
||||||
|
await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action: "create", entity: "site", entityId: site.id, after: site });
|
||||||
|
return site;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSite(ctx: ServiceCtx, id: string, patch: SitePatchInput) {
|
||||||
|
assertCan(ctx, "site:write");
|
||||||
|
const data = sitePatchSchema.parse(patch);
|
||||||
|
const before = await ctx.db.site.findFirst({ where: { AND: [{ id }, await siteScope(ctx)] } });
|
||||||
|
if (!before) throw new ServiceError("not_found", "site not found");
|
||||||
|
const customerId = data.customerId ?? before.customerId;
|
||||||
|
const contactId = data.contactId === undefined ? (data.customerId ? null : before.contactId) : data.contactId;
|
||||||
|
await assertCustomerAndContact(ctx, customerId, contactId);
|
||||||
|
const after = await ctx.db.site.update({ where: { id }, data: { ...data, contactId } });
|
||||||
|
await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action: "update", entity: "site", entityId: id, before, after });
|
||||||
|
return after;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteSite(ctx: ServiceCtx, id: string) {
|
||||||
|
assertCan(ctx, "site:write");
|
||||||
|
const before = await ctx.db.site.findFirst({ where: { AND: [{ id }, await siteScope(ctx)] } });
|
||||||
|
if (!before) throw new ServiceError("not_found", "site not found");
|
||||||
|
const open = await ctx.db.workOrder.count({ where: { siteId: id, deletedAt: null, status: { notIn: [...CLOSED_ORDER_STATUSES] } } });
|
||||||
|
if (open > 0) throw new ServiceError("blocked", "site has open work orders", { reason: "open_work_orders", count: open });
|
||||||
|
const after = await ctx.db.site.update({ where: { id }, data: { deletedAt: new Date(), status: "inactive" } });
|
||||||
|
await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action: "delete", entity: "site", entityId: id, before, after });
|
||||||
|
return after;
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { writeAuditLog } from "@/server/audit";
|
||||||
|
import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||||
|
import { optStr } from "@/server/services/customers/schemas";
|
||||||
|
|
||||||
|
export const TEAM_STATUSES = ["active", "inactive"] as const;
|
||||||
|
|
||||||
|
const dateInput = z.preprocess((v) => (v === "" || v === null || v === undefined ? undefined : v), z.coerce.date().optional());
|
||||||
|
const optDate = z.preprocess((v) => (v === "" || v === undefined ? undefined : v), z.coerce.date().nullable().optional());
|
||||||
|
|
||||||
|
export const teamMemberSchema = z
|
||||||
|
.object({
|
||||||
|
userId: z.string().min(1),
|
||||||
|
validFrom: dateInput,
|
||||||
|
validTo: optDate,
|
||||||
|
})
|
||||||
|
.refine((m) => !m.validTo || !m.validFrom || m.validTo >= m.validFrom, { message: "valid_to_before_from", path: ["validTo"] });
|
||||||
|
|
||||||
|
export const teamSchema = z.object({
|
||||||
|
name: z.string().trim().min(1).max(120),
|
||||||
|
leaderUserId: optStr(64),
|
||||||
|
status: z.enum(TEAM_STATUSES).optional(),
|
||||||
|
phone: optStr(50),
|
||||||
|
vehicle: optStr(120),
|
||||||
|
area: optStr(200),
|
||||||
|
notes: optStr(5000),
|
||||||
|
members: z.array(teamMemberSchema).max(100).default([]),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TeamInput = z.input<typeof teamSchema>;
|
||||||
|
|
||||||
|
const teamInclude = {
|
||||||
|
leader: { select: { id: true, name: true } },
|
||||||
|
members: { include: { user: { select: { id: true, name: true, email: true, status: true } } }, orderBy: { validFrom: "asc" as const } },
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function listTeams(ctx: ServiceCtx, opts: { includeInactive?: boolean } = {}) {
|
||||||
|
assertCan(ctx, "team:read");
|
||||||
|
return ctx.db.team.findMany({
|
||||||
|
where: { deletedAt: null, ...(opts.includeInactive ? {} : { status: "active" }) },
|
||||||
|
include: teamInclude,
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTeam(ctx: ServiceCtx, id: string) {
|
||||||
|
assertCan(ctx, "team:read");
|
||||||
|
const team = await ctx.db.team.findFirst({ where: { id, deletedAt: null }, include: teamInclude });
|
||||||
|
if (!team) throw new ServiceError("not_found", "team not found");
|
||||||
|
return team;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Active members of the tenant for leader/member selects. */
|
||||||
|
export async function teamUserOptions(ctx: ServiceCtx) {
|
||||||
|
assertCan(ctx, "team:manage");
|
||||||
|
return ctx.db.user.findMany({ where: { status: "ACTIVE" }, select: { id: true, name: true, email: true }, orderBy: { name: "asc" } });
|
||||||
|
}
|
||||||
|
|
||||||
|
type ParsedTeam = z.output<typeof teamSchema>;
|
||||||
|
|
||||||
|
async function validateTeam(ctx: ServiceCtx, data: ParsedTeam, exceptId?: string) {
|
||||||
|
const memberIds = data.members.map((m) => m.userId);
|
||||||
|
if (new Set(memberIds).size !== memberIds.length) {
|
||||||
|
throw new ServiceError("invalid", "duplicate member", { field: "members", reason: "duplicate_member" });
|
||||||
|
}
|
||||||
|
const ids = [...new Set([...memberIds, ...(data.leaderUserId ? [data.leaderUserId] : [])])];
|
||||||
|
if (ids.length) {
|
||||||
|
// dbForTenant restricts to the tenant: users of other tenants are simply not found.
|
||||||
|
const found = await ctx.db.user.count({ where: { id: { in: ids }, status: "ACTIVE" } });
|
||||||
|
if (found !== ids.length) throw new ServiceError("invalid", "unknown or inactive user", { field: "members", reason: "inactive_user" });
|
||||||
|
}
|
||||||
|
const clash = await ctx.db.team.findFirst({ where: { name: data.name, ...(exceptId ? { id: { not: exceptId } } : {}) }, select: { id: true } });
|
||||||
|
if (clash) throw new ServiceError("conflict", "team name taken", { field: "name", reason: "name_taken" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function memberRows(ctx: ServiceCtx, teamId: string, data: ParsedTeam) {
|
||||||
|
const now = new Date();
|
||||||
|
return data.members.map((m) => ({ tenantId: ctx.tenantId, teamId, userId: m.userId, validFrom: m.validFrom ?? now, validTo: m.validTo ?? null }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshot(team: { members: { userId: string; validFrom: Date; validTo: Date | null }[] } & Record<string, unknown>) {
|
||||||
|
const { members, ...rest } = team;
|
||||||
|
return { ...rest, members: members.map((m) => ({ userId: m.userId, validFrom: m.validFrom, validTo: m.validTo })) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createTeam(ctx: ServiceCtx, input: TeamInput) {
|
||||||
|
assertCan(ctx, "team:manage");
|
||||||
|
const data = teamSchema.parse(input);
|
||||||
|
await validateTeam(ctx, data);
|
||||||
|
const { members, ...fields } = data;
|
||||||
|
void members;
|
||||||
|
const team = await ctx.db.team.create({ data: { ...fields, tenantId: ctx.tenantId } });
|
||||||
|
if (data.members.length) await ctx.db.teamMember.createMany({ data: memberRows(ctx, team.id, data) });
|
||||||
|
const after = await getTeam(ctx, team.id);
|
||||||
|
await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action: "create", entity: "team", entityId: team.id, after: snapshot(after) });
|
||||||
|
return after;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Full replace of team data and membership list (validity periods included). */
|
||||||
|
export async function updateTeam(ctx: ServiceCtx, id: string, input: TeamInput) {
|
||||||
|
assertCan(ctx, "team:manage");
|
||||||
|
const data = teamSchema.parse(input);
|
||||||
|
const before = await getTeam(ctx, id);
|
||||||
|
await validateTeam(ctx, data, id);
|
||||||
|
const { members, ...fields } = data;
|
||||||
|
void members;
|
||||||
|
await ctx.db.$transaction([
|
||||||
|
ctx.db.team.update({ where: { id }, data: fields }),
|
||||||
|
ctx.db.teamMember.deleteMany({ where: { teamId: id } }),
|
||||||
|
ctx.db.teamMember.createMany({ data: memberRows(ctx, id, data) }),
|
||||||
|
]);
|
||||||
|
const after = await getTeam(ctx, id);
|
||||||
|
await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action: "update", entity: "team", entityId: id, before: snapshot(before), after: snapshot(after) });
|
||||||
|
return after;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Soft delete; the unique name is released by suffixing it. Blocked while open orders are assigned. */
|
||||||
|
export async function deleteTeam(ctx: ServiceCtx, id: string) {
|
||||||
|
assertCan(ctx, "team:manage");
|
||||||
|
const before = await getTeam(ctx, id);
|
||||||
|
const open = await ctx.db.workOrder.count({ where: { assignedTeamId: id, deletedAt: null, status: { notIn: ["billed", "cancelled"] } } });
|
||||||
|
if (open > 0) throw new ServiceError("blocked", "team has open work orders", { reason: "open_work_orders", count: open });
|
||||||
|
const after = await ctx.db.team.update({
|
||||||
|
where: { id },
|
||||||
|
data: { deletedAt: new Date(), status: "inactive", name: `${before.name} · ${id.slice(-6)}` },
|
||||||
|
});
|
||||||
|
await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action: "delete", entity: "team", entityId: id, before: snapshot(before), after });
|
||||||
|
return after;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user