diff --git a/src/app/(app)/files/[...key]/route.ts b/src/app/(app)/files/[...key]/route.ts deleted file mode 100644 index 5632cb3..0000000 --- a/src/app/(app)/files/[...key]/route.ts +++ /dev/null @@ -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 (`/…`). 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 }); -} diff --git a/src/app/api/v1/customers/[id]/route.ts b/src/app/api/v1/customers/[id]/route.ts new file mode 100644 index 0000000..3f13787 --- /dev/null +++ b/src/app/api/v1/customers/[id]/route.ts @@ -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 ?? {}) }); +}); diff --git a/src/app/api/v1/customers/route.ts b/src/app/api/v1/customers/route.ts new file mode 100644 index 0000000..ccf5f00 --- /dev/null +++ b/src/app/api/v1/customers/route.ts @@ -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 | null; + const customer = await createCustomer(ctx, (body ?? {}) as CustomerCreateInput, { + acknowledgeDuplicates: body?.acknowledgeDuplicates === true, + }); + return json({ data: customer }, { status: 201 }); +}); diff --git a/src/app/api/v1/sites/[id]/history/route.ts b/src/app/api/v1/sites/[id]/history/route.ts new file mode 100644 index 0000000..445a982 --- /dev/null +++ b/src/app/api/v1/sites/[id]/history/route.ts @@ -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 }, + }); +}); diff --git a/src/app/api/v1/sites/route.ts b/src/app/api/v1/sites/route.ts new file mode 100644 index 0000000..354b5ce --- /dev/null +++ b/src/app/api/v1/sites/route.ts @@ -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 }); +}); diff --git a/src/lib/customers/duplicates.ts b/src/lib/customers/duplicates.ts new file mode 100644 index 0000000..78dacbc --- /dev/null +++ b/src/lib/customers/duplicates.ts @@ -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): 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 { + const compact = s.replace(/\s+/g, " "); + const map = new Map(); + 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 }; +} diff --git a/src/server/actions/customers/contacts.ts b/src/server/actions/customers/contacts.ts new file mode 100644 index 0000000..30b2b47 --- /dev/null +++ b/src/server/actions/customers/contacts.ts @@ -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 { + 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 { + 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 { + try { + const ctx = ctxFromGuard(await guard("customer:write")); + await deleteContact(ctx, contactId); + } catch (err) { + return toActionError(err); + } + revalidatePath(`/customers/${customerId}`); + return { status: "ok" }; +} diff --git a/src/server/actions/customers/customers.ts b/src/server/actions/customers/customers.ts new file mode 100644 index 0000000..6702933 --- /dev/null +++ b/src/server/actions/customers/customers.ts @@ -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 }; + +/** Create; on possible duplicates the form shows the candidates and may resubmit with acknowledgeDuplicates=1. */ +export async function createCustomerAction(_prev: CustomerFormState, fd: FormData): Promise { + 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 { + 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 { + 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 { + 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 { + 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`); +} diff --git a/src/server/actions/sites/sites.ts b/src/server/actions/sites/sites.ts new file mode 100644 index 0000000..7922b7e --- /dev/null +++ b/src/server/actions/sites/sites.ts @@ -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 { + 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 { + 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 { + try { + const ctx = ctxFromGuard(await guard("site:write")); + await deleteSite(ctx, id); + } catch (err) { + return toActionError(err); + } + revalidatePath("/sites"); + redirect("/sites"); +} diff --git a/src/server/actions/teams/teams.ts b/src/server/actions/teams/teams.ts new file mode 100644 index 0000000..e162fa4 --- /dev/null +++ b/src/server/actions/teams/teams.ts @@ -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 { + 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 { + try { + const ctx = ctxFromGuard(await guard("team:manage")); + await deleteTeam(ctx, id); + } catch (err) { + return toActionError(err); + } + revalidatePath("/teams"); + return { status: "ok" }; +} diff --git a/src/server/api/action-state.ts b/src/server/api/action-state.ts new file mode 100644 index 0000000..373ff20 --- /dev/null +++ b/src/server/api/action-state.ts @@ -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/.json → errors.), + * 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 = + | { status: "idle" } + | { status: "ok"; data?: T } + | { status: "error"; code: ActionErrorCode; reason?: string; fieldErrors?: Record }; + +export const IDLE: ActionState = { status: "idle" }; + +export function toActionError(err: unknown): Extract { + if (err instanceof ZodError) { + const fieldErrors: Record = {}; + 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 { + const out: Record = {}; + for (const k of keys) { + const v = fd.get(k); + const s = typeof v === "string" ? v.trim() : ""; + out[k] = s === "" ? undefined : s; + } + return out; +} diff --git a/src/server/api/context.ts b/src/server/api/context.ts new file mode 100644 index 0000000..4fbcce9 --- /dev/null +++ b/src/server/api/context.ts @@ -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/). + * + * 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 { + 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 { + 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 ?? []), + }; +} diff --git a/src/server/api/respond.ts b/src/server/api/respond.ts new file mode 100644 index 0000000..a4ae630 --- /dev/null +++ b/src/server/api/respond.ts @@ -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 = { + 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(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(handler: (...args: A) => Promise) { + return async (...args: A): Promise => { + try { + return await handler(...args); + } catch (err) { + return toErrorResponse(err); + } + }; +} + +/** Read a JSON body; malformed JSON → 422. */ +export async function readJson(req: Request): Promise { + try { + return await req.json(); + } catch { + throw new ApiError("invalid", "malformed JSON body"); + } +} diff --git a/src/server/services/customers/contacts.ts b/src/server/services/customers/contacts.ts new file mode 100644 index 0000000..41bbe33 --- /dev/null +++ b/src/server/services/customers/contacts.ts @@ -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; +} diff --git a/src/server/services/customers/customers.ts b/src/server/services/customers/customers.ts new file mode 100644 index 0000000..bbb0208 --- /dev/null +++ b/src/server/services/customers/customers.ts @@ -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 { + 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 } }, + }, + }); +} diff --git a/src/server/services/customers/duplicates.ts b/src/server/services/customers/duplicates.ts new file mode 100644 index 0000000..53bb5b1 --- /dev/null +++ b/src/server/services/customers/duplicates.ts @@ -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 { + 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); +} diff --git a/src/server/services/customers/format.ts b/src/server/services/customers/format.ts new file mode 100644 index 0000000..9c2f552 --- /dev/null +++ b/src/server/services/customers/format.ts @@ -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(", "); +} diff --git a/src/server/services/customers/merge.ts b/src/server/services/customers/merge.ts new file mode 100644 index 0000000..206018e --- /dev/null +++ b/src/server/services/customers/merge.ts @@ -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 }; +} diff --git a/src/server/services/customers/schemas.ts b/src/server/services/customers/schemas.ts new file mode 100644 index 0000000..2ac2a72 --- /dev/null +++ b/src/server/services/customers/schemas.ts @@ -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; +export type CustomerPatchInput = z.input; + +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; + +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; diff --git a/src/server/services/sites/history.ts b/src/server/services/sites/history.ts new file mode 100644 index 0000000..1aaed76 --- /dev/null +++ b/src/server/services/sites/history.ts @@ -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(); + 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 }; +} diff --git a/src/server/services/sites/map-link.ts b/src/server/services/sites/map-link.ts new file mode 100644 index 0000000..502a7e9 --- /dev/null +++ b/src/server/services/sites/map-link.ts @@ -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)}`; +} diff --git a/src/server/services/sites/sites.ts b/src/server/services/sites/sites.ts new file mode 100644 index 0000000..c0380d4 --- /dev/null +++ b/src/server/services/sites/sites.ts @@ -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; +export type SitePatchInput = z.input; + +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; +} diff --git a/src/server/services/teams/teams.ts b/src/server/services/teams/teams.ts new file mode 100644 index 0000000..2bf89b3 --- /dev/null +++ b/src/server/services/teams/teams.ts @@ -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; + +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; + +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) { + 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; +}