Merge lane/stammdaten in feature/craftvia-mvp

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 12:38:34 +02:00
co-authored by Claude Opus 5
64 changed files with 7249 additions and 73 deletions
+48
View File
@@ -0,0 +1,48 @@
"use server";
import { revalidatePath } from "next/cache";
import { moduleGuard } from "@/server/action-guard";
import { ctxFromGuard } from "@/server/services/context";
import { formObject, toActionError, type ActionState } from "@/server/api/action-state";
import { createContact, deleteContact, updateContact } from "@/server/services/customers/contacts";
const guard = moduleGuard("customers");
const CONTACT_KEYS = ["name", "role", "phone", "mobile", "email", "preferredChannel", "notes"] as const;
function contactValues(fd: FormData) {
const v = formObject(fd, CONTACT_KEYS);
return { ...v, name: v.name ?? "" };
}
export async function createContactAction(customerId: string, _prev: ActionState, fd: FormData): Promise<ActionState> {
try {
const ctx = ctxFromGuard(await guard("customer:write"));
await createContact(ctx, customerId, contactValues(fd));
} catch (err) {
return toActionError(err);
}
revalidatePath(`/customers/${customerId}`);
return { status: "ok" };
}
export async function updateContactAction(contactId: string, customerId: string, _prev: ActionState, fd: FormData): Promise<ActionState> {
try {
const ctx = ctxFromGuard(await guard("customer:write"));
await updateContact(ctx, contactId, contactValues(fd));
} catch (err) {
return toActionError(err);
}
revalidatePath(`/customers/${customerId}`);
return { status: "ok" };
}
export async function deleteContactAction(contactId: string, customerId: string): Promise<ActionState> {
try {
const ctx = ctxFromGuard(await guard("customer:write"));
await deleteContact(ctx, contactId);
} catch (err) {
return toActionError(err);
}
revalidatePath(`/customers/${customerId}`);
return { status: "ok" };
}
+123
View File
@@ -0,0 +1,123 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { moduleGuard } from "@/server/action-guard";
import { ctxFromGuard, ServiceError } from "@/server/services/context";
import { formObject, toActionError, type ActionState } from "@/server/api/action-state";
import {
confirmProvisionalCustomer,
createCustomer,
deleteCustomer,
updateCustomer,
} from "@/server/services/customers/customers";
import { mergeCustomers } from "@/server/services/customers/merge";
import type { DuplicateCandidate } from "@/server/services/customers/duplicates";
const guard = moduleGuard("customers");
const CUSTOMER_KEYS = [
"customerNumber",
"companyName",
"salutation",
"firstName",
"lastName",
"street",
"houseNumber",
"postalCode",
"city",
"country",
"phone",
"mobile",
"email",
"notes",
"billingNotes",
"status",
] as const;
export type CustomerFormState =
| ActionState
| { status: "duplicates"; candidates: DuplicateCandidate[]; values: Record<string, string | undefined> };
/** Create; on possible duplicates the form shows the candidates and may resubmit with acknowledgeDuplicates=1. */
export async function createCustomerAction(_prev: CustomerFormState, fd: FormData): Promise<CustomerFormState> {
const values = formObject(fd, CUSTOMER_KEYS);
let id: string;
try {
const ctx = ctxFromGuard(await guard("customer:write"));
const customer = await createCustomer(ctx, values, { acknowledgeDuplicates: fd.get("acknowledgeDuplicates") === "1" });
id = customer.id;
} catch (err) {
if (err instanceof ServiceError && (err.details as { reason?: string } | undefined)?.reason === "possible_duplicates") {
return { status: "duplicates", candidates: (err.details as { candidates: DuplicateCandidate[] }).candidates, values };
}
return toActionError(err);
}
revalidatePath("/customers");
redirect(`/customers/${id}`);
}
export async function updateCustomerAction(id: string, _prev: ActionState, fd: FormData): Promise<ActionState> {
try {
const ctx = ctxFromGuard(await guard("customer:write"));
const values = formObject(fd, CUSTOMER_KEYS);
// full form submit: empty inputs clear the field
const patch = Object.fromEntries(CUSTOMER_KEYS.map((k) => [k, values[k] ?? (k === "status" || k === "country" ? undefined : null)]));
await updateCustomer(ctx, id, patch);
} catch (err) {
return toActionError(err);
}
revalidatePath("/customers");
revalidatePath(`/customers/${id}`);
return { status: "ok" };
}
export async function deleteCustomerAction(id: string): Promise<ActionState> {
try {
const ctx = ctxFromGuard(await guard("customer:write"));
await deleteCustomer(ctx, id);
} catch (err) {
return toActionError(err);
}
revalidatePath("/customers");
redirect("/customers");
}
export async function confirmCustomerAction(id: string): Promise<ActionState> {
try {
const ctx = ctxFromGuard(await guard("customer:write"));
await confirmProvisionalCustomer(ctx, id);
} catch (err) {
return toActionError(err);
}
revalidatePath("/customers");
revalidatePath(`/customers/${id}`);
return { status: "ok" };
}
/** Merge `sourceId` into the selected target. Requires customer:merge and the confirmation checkbox. */
export async function mergeCustomerAction(sourceId: string, _prev: ActionState, fd: FormData): Promise<ActionState> {
let targetId: string;
try {
const g = await guard("customer:read", "customer:merge");
const ctx = ctxFromGuard(g);
const values = formObject(fd, ["targetId", "targetNumber"]);
let target = values.targetId;
if (!target && values.targetNumber) {
const byNumber = await ctx.db.customer.findFirst({
where: { customerNumber: values.targetNumber, deletedAt: null },
select: { id: true },
});
if (!byNumber) throw new ServiceError("invalid", "target not found", { field: "targetNumber", reason: "target_not_found" });
target = byNumber.id;
}
if (!target) throw new ServiceError("invalid", "target required", { field: "targetId", reason: "target_required" });
const confirmed = fd.get("confirm") === "on" || fd.get("confirm") === "1";
await mergeCustomers(ctx, { sourceId, targetId: target, confirm: confirmed as true });
targetId = target;
} catch (err) {
return toActionError(err);
}
revalidatePath("/customers");
redirect(`/customers/${targetId}?merged=1`);
}
+44
View File
@@ -0,0 +1,44 @@
"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 { deleteDocument, updateDocumentMeta } from "@/server/services/documents/access";
import type { DocumentCategory, DocumentVisibility } from "@prisma/client";
const guard = moduleGuard("documents");
/** Only revalidate same-app paths passed by our own pages. */
function safePath(path: string): string {
return path.startsWith("/") && !path.startsWith("//") ? path.split("?")[0] : "/documents";
}
export async function updateDocumentAction(documentId: string, returnPath: string, _prev: ActionState, fd: FormData): Promise<ActionState> {
try {
const ctx = ctxFromGuard(await guard("document:write"));
const v = formObject(fd, ["title", "category", "visibility"]);
await updateDocumentMeta(ctx, documentId, {
title: v.title ?? null,
category: v.category as DocumentCategory | undefined,
visibility: v.visibility as DocumentVisibility | undefined,
});
} catch (err) {
return toActionError(err);
}
revalidatePath(safePath(returnPath));
revalidatePath("/documents");
return { status: "ok" };
}
export async function deleteDocumentAction(documentId: string, returnPath: string): Promise<ActionState> {
try {
const ctx = ctxFromGuard(await guard("document:write"));
await deleteDocument(ctx, documentId);
} catch (err) {
return toActionError(err);
}
revalidatePath(safePath(returnPath));
revalidatePath("/documents");
return { status: "ok" };
}
+71
View File
@@ -0,0 +1,71 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { moduleGuard } from "@/server/action-guard";
import { ctxFromGuard } from "@/server/services/context";
import { formObject, toActionError, type ActionState } from "@/server/api/action-state";
import { createSite, deleteSite, updateSite } from "@/server/services/sites/sites";
const guard = moduleGuard("sites");
const SITE_KEYS = [
"customerId",
"name",
"street",
"houseNumber",
"postalCode",
"city",
"country",
"contactId",
"onSiteContact",
"phone",
"accessNotes",
"parkingNotes",
"safetyNotes",
"technicalNotes",
"status",
"latitude",
"longitude",
] as const;
export async function createSiteAction(_prev: ActionState, fd: FormData): Promise<ActionState> {
let id: string;
try {
const ctx = ctxFromGuard(await guard("site:write"));
const v = formObject(fd, SITE_KEYS);
const site = await createSite(ctx, { ...v, customerId: v.customerId ?? "", name: v.name ?? "" });
id = site.id;
} catch (err) {
return toActionError(err);
}
revalidatePath("/sites");
redirect(`/sites/${id}`);
}
export async function updateSiteAction(id: string, _prev: ActionState, fd: FormData): Promise<ActionState> {
try {
const ctx = ctxFromGuard(await guard("site:write"));
const v = formObject(fd, SITE_KEYS);
const patch = Object.fromEntries(
SITE_KEYS.map((k) => [k, v[k] ?? (k === "status" || k === "country" || k === "customerId" || k === "name" ? undefined : null)]),
);
await updateSite(ctx, id, patch);
} catch (err) {
return toActionError(err);
}
revalidatePath("/sites");
revalidatePath(`/sites/${id}`);
return { status: "ok" };
}
export async function deleteSiteAction(id: string): Promise<ActionState> {
try {
const ctx = ctxFromGuard(await guard("site:write"));
await deleteSite(ctx, id);
} catch (err) {
return toActionError(err);
}
revalidatePath("/sites");
redirect("/sites");
}
+46
View File
@@ -0,0 +1,46 @@
"use server";
import { revalidatePath } from "next/cache";
import { moduleGuard } from "@/server/action-guard";
import { ctxFromGuard } from "@/server/services/context";
import { formObject, toActionError, type ActionState } from "@/server/api/action-state";
import { createTeam, deleteTeam, updateTeam, type TeamInput } from "@/server/services/teams/teams";
const guard = moduleGuard("teams");
const TEAM_KEYS = ["name", "leaderUserId", "status", "phone", "vehicle", "area", "notes"] as const;
function teamValues(fd: FormData): TeamInput {
const v = formObject(fd, TEAM_KEYS);
const userIds = fd.getAll("memberUserId").map(String);
const froms = fd.getAll("memberValidFrom").map(String);
const tos = fd.getAll("memberValidTo").map(String);
const members = userIds
.map((userId, i) => ({ userId: userId.trim(), validFrom: froms[i] ?? "", validTo: tos[i] ?? "" }))
.filter((m) => m.userId);
return { ...v, name: v.name ?? "", leaderUserId: v.leaderUserId ?? null, members };
}
/** Create (id = null) or fully update a team incl. its member list. */
export async function saveTeamAction(id: string | null, _prev: ActionState, fd: FormData): Promise<ActionState> {
try {
const ctx = ctxFromGuard(await guard("team:manage"));
const input = teamValues(fd);
if (id) await updateTeam(ctx, id, input);
else await createTeam(ctx, input);
} catch (err) {
return toActionError(err);
}
revalidatePath("/teams");
return { status: "ok" };
}
export async function deleteTeamAction(id: string): Promise<ActionState> {
try {
const ctx = ctxFromGuard(await guard("team:manage"));
await deleteTeam(ctx, id);
} catch (err) {
return toActionError(err);
}
revalidatePath("/teams");
return { status: "ok" };
}
+53
View File
@@ -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;
}
+97
View File
@@ -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 ?? []),
};
}
+104
View File
@@ -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");
}
}
+47
View File
@@ -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;
}
+239
View File
@@ -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 } },
},
});
}
+108
View File
@@ -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);
}
+29
View File
@@ -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(", ");
}
+57
View File
@@ -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 };
}
+72
View File
@@ -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>;
+183
View File
@@ -0,0 +1,183 @@
import { z } from "zod";
import { DocumentCategory, DocumentVisibility, type Prisma } from "@prisma/client";
import { writeAuditLog } from "@/server/audit";
import { storage, type StoredContent } from "@/server/storage/adapter";
import { assertCan, can, ServiceError, type ServiceCtx } from "@/server/services/context";
import { allowedDocumentVisibility, customerScope, siteScope, workOrderScope } from "@/server/services/work-orders/visibility";
/**
* Read side of the document service: visibility filter, download authorization, listing,
* metadata changes and soft delete (spec §24.3, ARCHITEKTUR §2 "Dokument-Sichtbarkeit").
*/
/**
* Documents the user may read:
* - `document:read` required;
* - visibility ∈ allowedDocumentVisibility(ctx) (backoffice_only needs document:read_internal);
* - users without `work_order:read_all`: linked work order in `workOrderScope`, or — for documents
* without an order — site in `siteScope` / customer in `customerScope`. Unlinked documents
* (e.g. import originals) are backoffice-only.
*/
export async function documentReadWhere(ctx: ServiceCtx): Promise<Prisma.DocumentWhereInput> {
if (!can(ctx, "document:read")) return { id: "__none__" };
const base: Prisma.DocumentWhereInput = {
deletedAt: null,
uploadStatus: "uploaded",
visibility: { in: allowedDocumentVisibility(ctx) },
};
if (can(ctx, "work_order:read_all")) return base;
const [wo, site, customer] = await Promise.all([workOrderScope(ctx), siteScope(ctx), customerScope(ctx)]);
return {
AND: [
base,
{
OR: [
{ workOrderId: { not: null }, workOrder: { is: wo } },
{ workOrderId: null, siteId: { not: null }, site: { is: site } },
{ workOrderId: null, siteId: null, customerId: { not: null }, customer: { is: customer } },
],
},
],
};
}
/** Load a document the user may read, or throw `not_found` (never reveals existence). */
export async function authorizeDocumentAccess(ctx: ServiceCtx, documentId: string) {
const document = await ctx.db.document.findFirst({ where: { AND: [{ id: documentId }, await documentReadWhere(ctx)] } });
if (!document) throw new ServiceError("not_found", "document not found");
return document;
}
/** Internal download link — authorization happens again on every request to the route. */
export async function getDownloadUrl(ctx: ServiceCtx, documentId: string): Promise<string> {
const document = await authorizeDocumentAccess(ctx, documentId);
return documentHref(document.id);
}
export function documentHref(documentId: string): string {
return `/files/${encodeURIComponent(documentId)}`;
}
/** Authorize and open the stored bytes (used by /files/[documentId]). */
export async function openDocumentContent(ctx: ServiceCtx, documentId: string): Promise<{ document: Awaited<ReturnType<typeof authorizeDocumentAccess>>; content: StoredContent }> {
const document = await authorizeDocumentAccess(ctx, documentId);
// defense in depth: the key must carry the tenant prefix
if (!document.storageKey.startsWith(`${ctx.tenantId}/`)) throw new ServiceError("not_found", "document content not available");
const content = await storage.get(document.storageKey);
if (!content) throw new ServiceError("not_found", "document content not available");
return { document, content };
}
export type DocumentListFilter = {
category?: DocumentCategory;
customerId?: string;
siteId?: string;
workOrderId?: string;
q?: string;
/** Only the newest version of each lineage. */
latestOnly?: boolean;
page?: number;
pageSize?: number;
};
export async function listDocuments(ctx: ServiceCtx, filter: DocumentListFilter = {}) {
const page = Math.max(1, filter.page ?? 1);
const pageSize = Math.min(500, Math.max(1, filter.pageSize ?? 25));
const and: Prisma.DocumentWhereInput[] = [await documentReadWhere(ctx)];
if (filter.category) and.push({ category: filter.category });
if (filter.workOrderId) and.push({ workOrderId: filter.workOrderId });
if (filter.siteId) and.push({ OR: [{ siteId: filter.siteId }, { workOrder: { is: { siteId: filter.siteId } } }] });
if (filter.customerId) {
and.push({
OR: [
{ customerId: filter.customerId },
{ site: { is: { customerId: filter.customerId } } },
{ workOrder: { is: { customerId: filter.customerId } } },
],
});
}
if (filter.q?.trim()) {
const q = filter.q.trim();
and.push({ OR: [{ fileName: { contains: q, mode: "insensitive" } }, { title: { contains: q, mode: "insensitive" } }] });
}
let where: Prisma.DocumentWhereInput = { AND: and };
if (filter.latestOnly) {
const groups = await ctx.db.document.groupBy({ by: ["lineageId"], where, _max: { version: true } });
where = { AND: [where, { OR: groups.map((g) => ({ lineageId: g.lineageId, version: g._max.version ?? 1 })) }] };
if (groups.length === 0) return { items: [], total: 0, page, pageSize };
}
const [total, items] = await Promise.all([
ctx.db.document.count({ where }),
ctx.db.document.findMany({
where,
orderBy: [{ createdAt: "desc" }, { version: "desc" }],
skip: (page - 1) * pageSize,
take: pageSize,
select: {
id: true,
title: true,
fileName: true,
category: true,
visibility: true,
mimeType: true,
fileSize: true,
checksum: true,
version: true,
lineageId: true,
approvalStatus: true,
uploadedById: true,
createdAt: true,
updatedAt: true,
customer: { select: { id: true, customerNumber: true, companyName: true, firstName: true, lastName: true } },
site: { select: { id: true, name: true } },
workOrder: { select: { id: true, number: true, title: true } },
},
}),
]);
return { items, total, page, pageSize };
}
const metaPatchSchema = z.object({
title: z.preprocess((v) => (typeof v === "string" && v.trim() === "" ? null : v), z.string().trim().max(300).nullable().optional()),
category: z.enum(DocumentCategory).optional(),
visibility: z.enum(DocumentVisibility).optional(),
});
export async function updateDocumentMeta(ctx: ServiceCtx, documentId: string, input: z.input<typeof metaPatchSchema>) {
assertCan(ctx, "document:write");
const data = metaPatchSchema.parse(input);
const before = await authorizeDocumentAccess(ctx, documentId);
if (data.visibility && !allowedDocumentVisibility(ctx).includes(data.visibility)) {
throw new ServiceError("invalid", "visibility not allowed", { field: "visibility", reason: "visibility_not_allowed" });
}
const after = await ctx.db.document.update({ where: { id: documentId }, data });
await writeAuditLog({
tenantId: ctx.tenantId,
actorId: ctx.userId,
action: "update",
entity: "document",
entityId: documentId,
before: { title: before.title, category: before.category, visibility: before.visibility },
after: { title: after.title, category: after.category, visibility: after.visibility },
});
return after;
}
/** Soft delete of one document version. */
export async function deleteDocument(ctx: ServiceCtx, documentId: string) {
assertCan(ctx, "document:write");
const before = await authorizeDocumentAccess(ctx, documentId);
const after = await ctx.db.document.update({ where: { id: documentId }, data: { deletedAt: new Date() } });
await writeAuditLog({
tenantId: ctx.tenantId,
actorId: ctx.userId,
action: "delete",
entity: "document",
entityId: documentId,
before: { fileName: before.fileName, version: before.version, lineageId: before.lineageId },
after: { deletedAt: after.deletedAt },
});
return after;
}
+158
View File
@@ -0,0 +1,158 @@
import { connect } from "node:net";
/**
* File scanning (ARCHITEKTUR §4.3, spec §27.4). MVP: magic-byte/type verification against an
* allowlist. If CLAMAV_HOST is set, the bytes are additionally streamed to clamd (INSTREAM).
* Scanners never throw for bad content — they return a structured verdict.
*/
export type DetectedKind = "pdf" | "image" | "audio";
export type ScanVerdict =
| { ok: true; detectedMime: string; kind: DetectedKind }
| { ok: false; reason: "unsupported_type" | "type_mismatch" | "malware" | "scanner_unavailable"; detail?: string };
export interface FileScanner {
name: string;
scan(input: { bytes: Uint8Array; declaredMime: string; fileName: string }): Promise<ScanVerdict>;
}
/** Allowlisted MIME types → kind. */
export const ALLOWED_MIME: Record<string, DetectedKind> = {
"application/pdf": "pdf",
"image/jpeg": "image",
"image/png": "image",
"image/webp": "image",
"image/heic": "image",
"audio/webm": "audio",
"audio/ogg": "audio",
"audio/mp4": "audio",
"audio/mpeg": "audio",
"audio/wav": "audio",
};
const MIME_ALIASES: Record<string, string> = {
"image/jpg": "image/jpeg",
"image/pjpeg": "image/jpeg",
"image/heif": "image/heic",
"audio/x-wav": "audio/wav",
"audio/wave": "audio/wav",
"audio/x-m4a": "audio/mp4",
"audio/m4a": "audio/mp4",
"audio/mp3": "audio/mpeg",
"video/webm": "audio/webm", // MediaRecorder often labels audio-only recordings as video/webm
};
export function canonicalMime(mime: string): string {
const base = mime.split(";")[0].trim().toLowerCase();
return MIME_ALIASES[base] ?? base;
}
function startsWith(bytes: Uint8Array, sig: number[], offset = 0): boolean {
if (bytes.length < offset + sig.length) return false;
return sig.every((b, i) => bytes[offset + i] === b);
}
function ascii(bytes: Uint8Array, start: number, end: number): string {
return String.fromCharCode(...bytes.slice(start, end));
}
/** Detect the real MIME type from the leading bytes; null if not on the allowlist. */
export function detectMime(bytes: Uint8Array): string | null {
if (startsWith(bytes, [0x25, 0x50, 0x44, 0x46, 0x2d])) return "application/pdf"; // %PDF-
if (startsWith(bytes, [0xff, 0xd8, 0xff])) return "image/jpeg";
if (startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return "image/png";
if (ascii(bytes, 0, 4) === "RIFF" && ascii(bytes, 8, 12) === "WEBP") return "image/webp";
if (ascii(bytes, 0, 4) === "RIFF" && ascii(bytes, 8, 12) === "WAVE") return "audio/wav";
if (startsWith(bytes, [0x1a, 0x45, 0xdf, 0xa3])) return "audio/webm"; // EBML (WebM/Matroska)
if (ascii(bytes, 0, 4) === "OggS") return "audio/ogg";
if (ascii(bytes, 0, 3) === "ID3" || startsWith(bytes, [0xff, 0xfb]) || startsWith(bytes, [0xff, 0xf3])) return "audio/mpeg";
if (ascii(bytes, 4, 8) === "ftyp") {
const brand = ascii(bytes, 8, 12);
if (["heic", "heix", "mif1", "msf1", "heim", "heis"].includes(brand)) return "image/heic";
if (["M4A ", "mp42", "isom", "dash", "iso5", "iso6"].includes(brand)) return "audio/mp4";
}
return null;
}
export class MagicByteScanner implements FileScanner {
name = "magic-bytes";
async scan({ bytes, declaredMime }: { bytes: Uint8Array; declaredMime: string; fileName: string }): Promise<ScanVerdict> {
const declared = canonicalMime(declaredMime);
if (!ALLOWED_MIME[declared]) return { ok: false, reason: "unsupported_type", detail: declared };
const detected = detectMime(bytes);
if (!detected) return { ok: false, reason: "type_mismatch", detail: "unknown signature" };
if (detected !== declared) return { ok: false, reason: "type_mismatch", detail: `${declared} ≠ ${detected}` };
return { ok: true, detectedMime: detected, kind: ALLOWED_MIME[detected] };
}
}
/** clamd INSTREAM client (only active if CLAMAV_HOST is configured). */
export class ClamAvScanner implements FileScanner {
name = "clamav";
constructor(
private host: string,
private port: number,
private timeoutMs = 15_000,
) {}
scan({ bytes }: { bytes: Uint8Array; declaredMime: string; fileName: string }): Promise<ScanVerdict> {
return new Promise((resolve) => {
const socket = connect({ host: this.host, port: this.port });
let response = "";
const done = (v: ScanVerdict) => {
socket.destroy();
resolve(v);
};
socket.setTimeout(this.timeoutMs, () => done({ ok: false, reason: "scanner_unavailable", detail: "timeout" }));
socket.on("error", (err) => done({ ok: false, reason: "scanner_unavailable", detail: err.message }));
socket.on("data", (chunk) => (response += chunk.toString("utf8")));
socket.on("end", () => {
if (/OK\s*\0?$/.test(response.trim())) done({ ok: true, detectedMime: "", kind: "pdf" });
else if (/FOUND/.test(response)) done({ ok: false, reason: "malware", detail: response.trim() });
else done({ ok: false, reason: "scanner_unavailable", detail: response.trim() });
});
socket.on("connect", () => {
socket.write("zINSTREAM\0");
const chunkSize = 64 * 1024;
for (let i = 0; i < bytes.length; i += chunkSize) {
const chunk = bytes.subarray(i, i + chunkSize);
const len = Buffer.alloc(4);
len.writeUInt32BE(chunk.length, 0);
socket.write(len);
socket.write(chunk);
}
socket.write(Buffer.alloc(4)); // zero-length chunk terminates the stream
});
});
}
}
/** Magic bytes first (cheap, authoritative for the stored MIME), then optional ClamAV. */
export class CompositeScanner implements FileScanner {
name: string;
constructor(private primary: FileScanner, private extra: FileScanner[]) {
this.name = [primary.name, ...extra.map((s) => s.name)].join("+");
}
async scan(input: { bytes: Uint8Array; declaredMime: string; fileName: string }): Promise<ScanVerdict> {
const first = await this.primary.scan(input);
if (!first.ok) return first;
for (const s of this.extra) {
const v = await s.scan(input);
if (!v.ok) return v;
}
return first;
}
}
let scanner: FileScanner | null = null;
export function getFileScanner(): FileScanner {
if (scanner) return scanner;
const host = process.env.CLAMAV_HOST?.trim();
const magic = new MagicByteScanner();
scanner = host ? new CompositeScanner(magic, [new ClamAvScanner(host, Number(process.env.CLAMAV_PORT ?? 3310))]) : magic;
return scanner;
}
+201
View File
@@ -0,0 +1,201 @@
import { createHash, randomUUID } from "node:crypto";
import { z } from "zod";
import { DocumentCategory, DocumentVisibility, type Document } from "@prisma/client";
import { writeAuditLog } from "@/server/audit";
import { storage } from "@/server/storage/adapter";
import { assertCan, can, ServiceError, type ServiceCtx } from "@/server/services/context";
import { allowedDocumentVisibility, requireVisibleWorkOrder } from "@/server/services/work-orders/visibility";
import { getFileScanner, type DetectedKind, type FileScanner } from "@/server/services/documents/scanner";
import { documentReadWhere } from "@/server/services/documents/access";
/**
* Document storage service (ARCHITEKTUR §4.3, spec §24, §27.4). Owned by lane "stammdaten";
* every lane stores files ONLY through `storeFile` and links downloads via `getDownloadUrl`
* (see ./access.ts).
*/
const MB = 1024 * 1024;
/** Size limits per detected kind (ARCHITEKTUR §4.3). */
export const SIZE_LIMITS: Record<DetectedKind, number> = { image: 15 * MB, pdf: 25 * MB, audio: 20 * MB };
export const MAX_UPLOAD_BYTES = Math.max(...Object.values(SIZE_LIMITS));
export const DOCUMENT_CATEGORIES = Object.values(DocumentCategory);
export const DOCUMENT_VISIBILITIES = Object.values(DocumentVisibility);
export type StoreFileInput = {
bytes: Uint8Array;
fileName: string;
declaredMime: string;
category: DocumentCategory;
visibility: DocumentVisibility;
title?: string | null;
links?: { customerId?: string | null; siteId?: string | null; workOrderId?: string | null };
/** Existing lineage → stored as the next version of that document. */
lineageId?: string | null;
approvalStatus?: "draft" | "approved" | null;
};
const metaSchema = z.object({
fileName: z.string().min(1).max(500),
declaredMime: z.string().min(1).max(200),
category: z.enum(DocumentCategory),
visibility: z.enum(DocumentVisibility),
title: z.string().trim().max(300).nullable().optional(),
lineageId: z.string().min(1).max(64).nullable().optional(),
approvalStatus: z.enum(["draft", "approved"]).nullable().optional(),
links: z
.object({
customerId: z.string().min(1).nullable().optional(),
siteId: z.string().min(1).nullable().optional(),
workOrderId: z.string().min(1).nullable().optional(),
})
.optional(),
});
/**
* Normalize a user-supplied file name: strip any path, control and reserved characters, unify
* Unicode (NFC), collapse whitespace, keep the extension, limit length. Never empty.
*/
export function normalizeFileName(name: string): string {
const base = name.split(/[\\/]/).pop() ?? "";
const cleaned = base
.normalize("NFC")
.replace(/[\x00-\x1f\x7f]/g, "")
.replace(/[<>:"|?*]/g, "_")
.replace(/\s+/g, " ")
.replace(/^[.\s]+/, "")
.trim();
if (!cleaned) return "datei";
const MAX = 180;
if (cleaned.length <= MAX) return cleaned;
const dot = cleaned.lastIndexOf(".");
const ext = dot > 0 && cleaned.length - dot <= 10 ? cleaned.slice(dot) : "";
return cleaned.slice(0, MAX - ext.length) + ext;
}
export function sha256Hex(bytes: Uint8Array): string {
return createHash("sha256").update(bytes).digest("hex");
}
/** Who may attach a file where (the caller's own action guard stays in place in addition). */
async function assertMayAttach(ctx: ServiceCtx, links: NonNullable<StoreFileInput["links"]>) {
if (links.workOrderId) {
// field roles attach photos/voice notes/signatures to orders in their scope
if (!["document:write", "field:execute", "report:write", "emergency:create"].some((p) => can(ctx, p))) {
throw new ServiceError("forbidden", "missing permission to attach documents");
}
await requireVisibleWorkOrder(ctx, links.workOrderId, { id: true });
} else if (links.siteId || links.customerId) {
assertCan(ctx, "document:write");
} else if (!can(ctx, "document:write") && !can(ctx, "import:write")) {
// unlinked originals (e.g. PDF imports) are backoffice material
throw new ServiceError("forbidden", "missing permission document:write");
}
if (links.siteId) {
const site = await ctx.db.site.findFirst({ where: { id: links.siteId, deletedAt: null }, select: { id: true } });
if (!site) throw new ServiceError("invalid", "site not found", { field: "siteId", reason: "site_not_found" });
}
if (links.customerId) {
const customer = await ctx.db.customer.findFirst({ where: { id: links.customerId, deletedAt: null }, select: { id: true } });
if (!customer) throw new ServiceError("invalid", "customer not found", { field: "customerId", reason: "customer_not_found" });
}
}
/**
* Validate and store a file, creating a `Document` row.
* Order: metadata → size → magic bytes/scanner → permission/links → visibility → storage → DB → audit.
* Rejections are `ServiceError("invalid", …, { reason })` with reason
* `empty_file | too_large | unsupported_type | type_mismatch | malware | scanner_unavailable |
* visibility_not_allowed | lineage_not_found`.
*/
export async function storeFile(ctx: ServiceCtx, input: StoreFileInput, deps: { scanner?: FileScanner } = {}): Promise<Document> {
const meta = metaSchema.parse({ ...input, bytes: undefined });
const bytes = input.bytes;
if (!bytes || bytes.byteLength === 0) throw new ServiceError("invalid", "empty file", { field: "file", reason: "empty_file" });
if (bytes.byteLength > MAX_UPLOAD_BYTES) throw new ServiceError("invalid", "file too large", { field: "file", reason: "too_large" });
const fileName = normalizeFileName(meta.fileName);
const verdict = await (deps.scanner ?? getFileScanner()).scan({ bytes, declaredMime: meta.declaredMime, fileName });
if (!verdict.ok) throw new ServiceError("invalid", `file rejected: ${verdict.reason}`, { field: "file", reason: verdict.reason });
if (bytes.byteLength > SIZE_LIMITS[verdict.kind]) {
throw new ServiceError("invalid", "file too large", { field: "file", reason: "too_large", limit: SIZE_LIMITS[verdict.kind] });
}
let links = { customerId: meta.links?.customerId ?? null, siteId: meta.links?.siteId ?? null, workOrderId: meta.links?.workOrderId ?? null };
let lineageId: string = randomUUID();
let version = 1;
if (meta.lineageId) {
// a new version is only possible for a document the user may read
const previous = await ctx.db.document.findFirst({
where: { AND: [{ lineageId: meta.lineageId }, await documentReadWhere(ctx)] },
orderBy: { version: "desc" },
});
if (!previous) throw new ServiceError("invalid", "lineage not found", { field: "lineageId", reason: "lineage_not_found" });
lineageId = previous.lineageId;
version = previous.version + 1;
if (!links.customerId && !links.siteId && !links.workOrderId) {
links = { customerId: previous.customerId, siteId: previous.siteId, workOrderId: previous.workOrderId };
}
}
await assertMayAttach(ctx, links);
if (!allowedDocumentVisibility(ctx).includes(meta.visibility)) {
throw new ServiceError("invalid", "visibility not allowed", { field: "visibility", reason: "visibility_not_allowed" });
}
const checksum = sha256Hex(bytes);
const stored = await storage.put({ tenantId: ctx.tenantId, filename: fileName, contentType: verdict.detectedMime, bytes });
let document: Document | null = null;
for (let attempt = 0; attempt < 2 && !document; attempt++) {
try {
document = await ctx.db.document.create({
data: {
tenantId: ctx.tenantId,
...links,
category: meta.category,
title: meta.title ?? null,
fileName,
storageKey: stored.storageKey,
mimeType: verdict.detectedMime,
fileSize: bytes.byteLength,
checksum,
version,
lineageId,
visibility: meta.visibility,
approvalStatus: meta.approvalStatus ?? null,
uploadStatus: "uploaded",
uploadedById: ctx.userId,
},
});
} catch (err) {
// concurrent new version of the same lineage → take the next number once
if ((err as { code?: string }).code !== "P2002" || attempt > 0 || !meta.lineageId) throw err;
const latest = await ctx.db.document.findFirst({ where: { lineageId }, orderBy: { version: "desc" }, select: { version: true } });
version = (latest?.version ?? version) + 1;
}
}
if (!document) throw new ServiceError("conflict", "could not store document version");
await writeAuditLog({
tenantId: ctx.tenantId,
actorId: ctx.userId,
action: "create",
entity: "document",
entityId: document.id,
after: {
fileName,
category: document.category,
visibility: document.visibility,
mimeType: document.mimeType,
fileSize: document.fileSize,
checksum,
version,
lineageId,
links,
scanner: (deps.scanner ?? getFileScanner()).name,
},
});
return document;
}
+127
View File
@@ -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 };
}
+22
View File
@@ -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)}`;
}
+149
View File
@@ -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;
}
+129
View File
@@ -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;
}