Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/utils/integrations/crm/crmService.server.ts
T

989 lines
40 KiB
TypeScript

// R-P6 (P6 CRM core, Variante A) — domain service for the CRM module.
//
// Pure-ish server logic for the typed CRM entities (Kontakt + address/email/phone
// channels, relations, list/filter/dup-check/bulk). Always tenant-scoped, enforces
// owner visibility (crm.read_own → 404, not 403). NO React/UI imports. Routes call
// these from their loaders/actions and build the View-Model from the returned rows.
// Audit + Activity are NOT written here — the routes wire them via crmAudit.
//
// RLS-ready (mirrors taskService / fileService): every tenant-scoped access runs
// inside withTenantRLS() so the tenant GUC is set before the query. Internal
// composition goes through *WithTx(tx, ...) helpers that reuse the active
// transaction client — functions never nest withTenantRLS(). This is a functional
// no-op until the CRM RLS policies are activated (prisma/rls/crm-isolation.sql, P5
// cutover). The only access NOT wrapped is the global User lookup
// (resolveUserLabels): User is not tenant-scoped, so it stays on the db singleton.
import { publishOutboxEvent, type OutboxPublisherTransaction } from "@hivegrid/db/outbox";
import { CatalogError, foundationErrors, massActionErrors } from "@hivegrid/foundation";
import { buildWorkflowTriggerOutboxEvent } from "@hivegrid/queue";
import { crmContactsIndex } from "@hivegrid/search";
import { TenantUserStatus } from "~/application/enums/tenants/TenantUserStatus";
import { publishKontaktSearchUpsert, publishSearchProjectionDelete } from "~/services/search/searchIndexProjection.server";
import { db } from "~/utils/db.server";
import { withTenantRLS, type TenantRlsClient } from "~/utils/db/.server/tenantRls.server";
/**
* Domain error carrying an i18n message key (M002 Klartext). Routes map
* `messageKey` to a catalog error / VM error branch — never a bare HTTP code.
*/
export class CrmServiceError extends Error {
readonly messageKey: string;
constructor(messageKey: string) {
super(messageKey);
this.name = "CrmServiceError";
this.messageKey = messageKey;
}
}
// --- Domain type aliases (mirror the crm.prisma enums 1:1) -----------------
export type KontaktTyp = "privatperson" | "organisation";
export type KontaktStatus = "kontakt" | "kunde" | "bestandskunde";
export type OrgMerkmal = "gmbh" | "kg" | "oeffentliche_einrichtung" | "sonstige";
export type KontaktArt = "hauptkontakt" | "sekundaerkontakt" | "mitarbeiter" | "sonstige";
const MAX_LIST_LIMIT = 200;
const DEFAULT_LIST_LIMIT = 50;
/** Owner-visibility context (the user's permissions for this tenant). */
export type CrmVisibilityContext = {
userId: string;
/** The user's permission names for this tenant (from getPermissionsByUser). */
permissions: readonly string[];
};
/** True when the user may see every contact in the tenant (crm.read_all). */
export function canReadAllKontakte(ctx: CrmVisibilityContext): boolean {
return ctx.permissions.includes("crm.read_all");
}
/**
* Owner-scope `where` fragment for the crm.read_own filter (DoD 8). read_all users
* get an empty fragment (no restriction); everyone else only ever sees contacts
* assigned to them (`zustaendigerUserId`).
*/
export function ownerScopeWhere(ctx: CrmVisibilityContext): { zustaendigerUserId?: string } {
return canReadAllKontakte(ctx) ? {} : { zustaendigerUserId: ctx.userId };
}
async function requireActiveTenantUserWithTx(tx: TenantRlsClient, tenantId: string, userId: string): Promise<void> {
const membership = await tx.tenantUser.findFirst({
where: { tenantId, userId, status: TenantUserStatus.ACTIVE, user: { active: true } },
select: { id: true },
});
if (!membership) {
throw new CatalogError(foundationErrors.notFound, { userId });
}
}
// --- Row types (returned to routes; relation fields excluded) --------------
export type KontaktRow = {
id: string;
tenantId: string;
createdByUserId: string;
kontaktTyp: KontaktTyp;
kontaktStatus: KontaktStatus;
vorname: string | null;
nachname: string | null;
orgName: string | null;
orgMerkmal: OrgMerkmal | null;
primaerEmail: string | null;
primaerTelefon: string | null;
adresseId: string | null;
zustaendigerUserId: string | null;
isDemoData: boolean;
createdAt: Date;
updatedAt: Date;
archivedAt: Date | null;
};
export type KontaktEmailRow = {
id: string;
tenantId: string;
kontaktId: string;
email: string;
primaer: boolean;
createdAt: Date;
};
export type KontaktTelefonRow = {
id: string;
tenantId: string;
kontaktId: string;
telefon: string;
primaer: boolean;
createdAt: Date;
};
export type KontaktRelationRow = {
id: string;
tenantId: string;
vonKontaktId: string;
zuKontaktId: string;
rolle: KontaktArt;
kontext: string | null;
createdAt: Date;
};
export type AdresseRow = {
id: string;
tenantId: string;
strasse: string | null;
hausnummer: string | null;
plz: string | null;
ort: string | null;
land: string;
createdAt: Date;
updatedAt: Date;
};
export type AdresseInput = {
strasse?: string | null;
hausnummer?: string | null;
plz?: string | null;
ort?: string | null;
land?: string | null;
};
// --- Primary-channel helper (pure) -----------------------------------------
/**
* Pick the primary channel from an input list (DoD: exactly one primary). The
* first item flagged `primaer` wins; if none is flagged the first item is the
* primary; an empty list has no primary. Returns the denormalised primary value
* plus the per-item primary flags to persist. Pure (no DB) — used at create time.
*/
function pickPrimary<T extends { value: string; primaer?: boolean }>(
items: readonly T[]
): {
primaryValue: string | null;
flags: boolean[];
} {
if (items.length === 0) {
return { primaryValue: null, flags: [] };
}
let primaryIndex = items.findIndex((item) => item.primaer === true);
if (primaryIndex < 0) {
primaryIndex = 0;
}
return {
primaryValue: items[primaryIndex].value,
flags: items.map((_, index) => index === primaryIndex),
};
}
/**
* Re-derive the single primary email for a contact and sync `Kontakt.primaerEmail`.
* Runs on the active RLS tx (no own transaction). `preferredEmailId` forces that
* row to become primary (e.g. just-added / explicit set); otherwise the existing
* primary is kept, falling back to the oldest row.
*/
async function syncPrimaryEmailWithTx(tx: TenantRlsClient, tenantId: string, kontaktId: string, preferredEmailId?: string): Promise<void> {
const emails = await tx.kontaktEmail.findMany({
where: { tenantId, kontaktId },
select: { id: true, email: true, primaer: true },
orderBy: { createdAt: "asc" },
});
if (emails.length === 0) {
await tx.kontakt.updateMany({ where: { id: kontaktId, tenantId }, data: { primaerEmail: null } });
return;
}
const preferred = preferredEmailId ? emails.find((row) => row.id === preferredEmailId) : undefined;
const primary = preferred ?? emails.find((row) => row.primaer) ?? emails[0];
for (const row of emails) {
const shouldBePrimary = row.id === primary.id;
if (row.primaer !== shouldBePrimary) {
await tx.kontaktEmail.updateMany({ where: { id: row.id, tenantId }, data: { primaer: shouldBePrimary } });
}
}
await tx.kontakt.updateMany({ where: { id: kontaktId, tenantId }, data: { primaerEmail: primary.email } });
}
/** Telephone counterpart of {@link syncPrimaryEmailWithTx}. */
async function syncPrimaryTelefonWithTx(tx: TenantRlsClient, tenantId: string, kontaktId: string, preferredTelefonId?: string): Promise<void> {
const telefone = await tx.kontaktTelefon.findMany({
where: { tenantId, kontaktId },
select: { id: true, telefon: true, primaer: true },
orderBy: { createdAt: "asc" },
});
if (telefone.length === 0) {
await tx.kontakt.updateMany({ where: { id: kontaktId, tenantId }, data: { primaerTelefon: null } });
return;
}
const preferred = preferredTelefonId ? telefone.find((row) => row.id === preferredTelefonId) : undefined;
const primary = preferred ?? telefone.find((row) => row.primaer) ?? telefone[0];
for (const row of telefone) {
const shouldBePrimary = row.id === primary.id;
if (row.primaer !== shouldBePrimary) {
await tx.kontaktTelefon.updateMany({ where: { id: row.id, tenantId }, data: { primaer: shouldBePrimary } });
}
}
await tx.kontakt.updateMany({ where: { id: kontaktId, tenantId }, data: { primaerTelefon: primary.telefon } });
}
// --- Create / update / lifecycle -------------------------------------------
export type CreateKontaktInput = {
tenantId: string;
userId: string;
kontaktTyp: KontaktTyp;
kontaktStatus?: KontaktStatus;
vorname?: string | null;
nachname?: string | null;
orgName?: string | null;
orgMerkmal?: OrgMerkmal | null;
emails?: { email: string; primaer?: boolean }[];
telefone?: { telefon: string; primaer?: boolean }[];
adresse?: AdresseInput | null;
zustaendigerUserId?: string | null;
isDemoData?: boolean;
};
function validateKontaktName(input: { kontaktTyp: KontaktTyp; vorname?: string | null; nachname?: string | null; orgName?: string | null }): void {
if (input.kontaktTyp === "privatperson") {
if (!input.vorname?.trim() && !input.nachname?.trim()) {
throw new CrmServiceError("crm.error.nameRequired");
}
} else if (!input.orgName?.trim()) {
throw new CrmServiceError("crm.error.orgNameRequired");
}
}
function hasAddressData(adresse?: AdresseInput | null): boolean {
if (!adresse) {
return false;
}
return Boolean(adresse.strasse?.trim() || adresse.hausnummer?.trim() || adresse.plz?.trim() || adresse.ort?.trim() || adresse.land?.trim());
}
/**
* R-P12c: kontakt domain events feed the generic automation trigger bus via
* the durable workflow-trigger outbox (same delivery as lead/project events).
* Published inside the mutation transaction so event and write commit together.
*/
async function publishKontaktWorkflowEvent(
tx: TenantRlsClient,
input: {
tenantId: string;
userId: string | null;
event: "kontakt.created" | "kontakt.updated";
kontakt: { id: string; kontaktTyp: string; kontaktStatus: string; updatedAt?: Date };
changedFields?: readonly string[];
}
): Promise<void> {
const suffix = input.event === "kontakt.updated" ? `:${input.kontakt.updatedAt?.getTime() ?? Date.now()}` : "";
await publishOutboxEvent(
tx as unknown as OutboxPublisherTransaction,
buildWorkflowTriggerOutboxEvent({
tenantId: input.tenantId,
event: input.event,
data: {
kontaktId: input.kontakt.id,
kontaktTyp: input.kontakt.kontaktTyp,
kontaktStatus: input.kontakt.kontaktStatus,
...(input.changedFields ? { changedFields: [...input.changedFields] } : {}),
},
userId: input.userId,
sourceId: `kontakt:${input.kontakt.id}:${input.event}${suffix}`,
})
);
}
export async function createKontakt(input: CreateKontaktInput): Promise<KontaktRow> {
validateKontaktName(input);
const emailItems = (input.emails ?? []).map((entry) => ({ value: entry.email.trim(), primaer: entry.primaer })).filter((entry) => entry.value);
const telefonItems = (input.telefone ?? []).map((entry) => ({ value: entry.telefon.trim(), primaer: entry.primaer })).filter((entry) => entry.value);
const primaryEmail = pickPrimary(emailItems);
const primaryTelefon = pickPrimary(telefonItems);
const kontakt = await withTenantRLS(input.tenantId, async (tx) => {
const responsibleUserId = input.zustaendigerUserId ?? input.userId;
await requireActiveTenantUserWithTx(tx, input.tenantId, responsibleUserId);
let adresseId: string | null = null;
if (hasAddressData(input.adresse)) {
const adresse = await tx.adresse.create({
data: {
tenantId: input.tenantId,
strasse: input.adresse?.strasse ?? null,
hausnummer: input.adresse?.hausnummer ?? null,
plz: input.adresse?.plz ?? null,
ort: input.adresse?.ort ?? null,
land: input.adresse?.land?.trim() || "DE",
},
select: { id: true },
});
adresseId = adresse.id;
}
const created = await tx.kontakt.create({
data: {
tenantId: input.tenantId,
createdByUserId: input.userId,
kontaktTyp: input.kontaktTyp,
kontaktStatus: input.kontaktStatus ?? "kontakt",
vorname: input.vorname ?? null,
nachname: input.nachname ?? null,
orgName: input.orgName ?? null,
orgMerkmal: input.orgMerkmal ?? null,
primaerEmail: primaryEmail.primaryValue,
primaerTelefon: primaryTelefon.primaryValue,
adresseId,
zustaendigerUserId: responsibleUserId,
isDemoData: input.isDemoData ?? false,
},
});
for (let i = 0; i < emailItems.length; i += 1) {
await tx.kontaktEmail.create({ data: { tenantId: input.tenantId, kontaktId: created.id, email: emailItems[i].value, primaer: primaryEmail.flags[i] } });
}
for (let i = 0; i < telefonItems.length; i += 1) {
await tx.kontaktTelefon.create({
data: { tenantId: input.tenantId, kontaktId: created.id, telefon: telefonItems[i].value, primaer: primaryTelefon.flags[i] },
});
}
await publishKontaktSearchUpsert(tx, created);
await publishKontaktWorkflowEvent(tx, {
tenantId: input.tenantId,
userId: input.userId,
event: "kontakt.created",
kontakt: created,
});
return created;
});
return kontakt as KontaktRow;
}
export type UpdateKontaktInput = {
tenantId: string;
kontaktId: string;
kontaktStatus?: KontaktStatus;
vorname?: string | null;
nachname?: string | null;
orgName?: string | null;
orgMerkmal?: OrgMerkmal | null;
};
export async function updateKontakt(input: UpdateKontaktInput): Promise<void> {
await withTenantRLS(input.tenantId, async (tx) => {
const existing = await tx.kontakt.findFirst({ where: { id: input.kontaktId, tenantId: input.tenantId }, select: { id: true } });
if (!existing) {
throw new CatalogError(foundationErrors.notFound, { kontaktId: input.kontaktId });
}
const updated = await tx.kontakt.update({
where: { id: existing.id },
data: {
...(input.kontaktStatus !== undefined ? { kontaktStatus: input.kontaktStatus } : {}),
...(input.vorname !== undefined ? { vorname: input.vorname } : {}),
...(input.nachname !== undefined ? { nachname: input.nachname } : {}),
...(input.orgName !== undefined ? { orgName: input.orgName } : {}),
...(input.orgMerkmal !== undefined ? { orgMerkmal: input.orgMerkmal } : {}),
},
});
await publishKontaktSearchUpsert(tx, updated);
const changedFields = (["kontaktStatus", "vorname", "nachname", "orgName", "orgMerkmal"] as const).filter(
(field) => input[field] !== undefined
);
await publishKontaktWorkflowEvent(tx, {
tenantId: input.tenantId,
userId: null,
event: "kontakt.updated",
kontakt: updated,
changedFields,
});
});
}
export async function archiveKontakt(input: { tenantId: string; kontaktId: string }): Promise<void> {
await withTenantRLS(input.tenantId, async (tx) => {
const kontakt = await tx.kontakt.findFirst({ where: { id: input.kontaktId, tenantId: input.tenantId }, select: { id: true, kontaktTyp: true } });
if (!kontakt) {
throw new CatalogError(foundationErrors.notFound, { kontaktId: input.kontaktId });
}
await tx.kontakt.update({ where: { id: kontakt.id }, data: { archivedAt: new Date() } });
await publishSearchProjectionDelete(tx, {
schema: crmContactsIndex,
tenantId: input.tenantId,
entityType: kontakt.kontaktTyp === "organisation" ? "company" : "contact",
entityId: kontakt.id,
});
});
}
export async function reactivateKontakt(input: { tenantId: string; kontaktId: string }): Promise<void> {
await withTenantRLS(input.tenantId, async (tx) => {
const existing = await tx.kontakt.findFirst({ where: { id: input.kontaktId, tenantId: input.tenantId }, select: { id: true } });
if (!existing) {
throw new CatalogError(foundationErrors.notFound, { kontaktId: input.kontaktId });
}
const updated = await tx.kontakt.update({ where: { id: existing.id }, data: { archivedAt: null } });
await publishKontaktSearchUpsert(tx, updated);
});
}
export async function hardDeleteKontakt(input: { tenantId: string; kontaktId: string }): Promise<void> {
await withTenantRLS(input.tenantId, async (tx) => {
const kontakt = await tx.kontakt.findFirst({ where: { id: input.kontaktId, tenantId: input.tenantId }, select: { id: true, kontaktTyp: true } });
if (!kontakt) {
throw new CatalogError(foundationErrors.notFound, { kontaktId: input.kontaktId });
}
// KontaktEmail / KontaktTelefon / KontaktRelation cascade on Kontakt delete
// (schema onDelete: Cascade). The reusable Adresse stays (it may be shared).
await tx.kontakt.delete({ where: { id: kontakt.id } });
await publishSearchProjectionDelete(tx, {
schema: crmContactsIndex,
tenantId: input.tenantId,
entityType: kontakt.kontaktTyp === "organisation" ? "company" : "contact",
entityId: kontakt.id,
});
});
}
export async function reassignKontakt(input: { tenantId: string; kontaktId: string; zustaendigerUserId: string }): Promise<void> {
await withTenantRLS(input.tenantId, async (tx) => {
await requireActiveTenantUserWithTx(tx, input.tenantId, input.zustaendigerUserId);
const existing = await tx.kontakt.findFirst({ where: { id: input.kontaktId, tenantId: input.tenantId }, select: { id: true } });
if (!existing) {
throw new CatalogError(foundationErrors.notFound, { kontaktId: input.kontaktId });
}
const updated = await tx.kontakt.update({ where: { id: existing.id }, data: { zustaendigerUserId: input.zustaendigerUserId } });
await publishKontaktSearchUpsert(tx, updated);
});
}
// --- Email channels --------------------------------------------------------
type KontaktScopedMutationInput = {
tenantId: string;
kontaktId: string;
ctx: CrmVisibilityContext;
};
export async function addKontaktEmail(input: KontaktScopedMutationInput & { email: string; primaer?: boolean }): Promise<KontaktEmailRow> {
const email = input.email.trim();
if (!email) {
throw new CrmServiceError("crm.error.emailRequired");
}
return withTenantRLS(input.tenantId, async (tx) => {
const kontakt = await getVisibleKontaktOrThrowWithTx(tx, input.tenantId, input.kontaktId, input.ctx);
const created = await tx.kontaktEmail.create({ data: { tenantId: input.tenantId, kontaktId: kontakt.id, email, primaer: false } });
await syncPrimaryEmailWithTx(tx, input.tenantId, kontakt.id, input.primaer ? created.id : undefined);
const updated = await tx.kontakt.findFirst({ where: { id: kontakt.id, tenantId: input.tenantId } });
if (updated) await publishKontaktSearchUpsert(tx, updated);
return created as KontaktEmailRow;
});
}
export async function setPrimaryEmail(input: KontaktScopedMutationInput & { emailId: string }): Promise<void> {
await withTenantRLS(input.tenantId, async (tx) => {
const kontakt = await getVisibleKontaktOrThrowWithTx(tx, input.tenantId, input.kontaktId, input.ctx);
const email = await tx.kontaktEmail.findFirst({
where: { id: input.emailId, tenantId: input.tenantId, kontaktId: kontakt.id },
select: { id: true, kontaktId: true },
});
if (!email) {
throw new CatalogError(foundationErrors.notFound, { emailId: input.emailId });
}
await syncPrimaryEmailWithTx(tx, input.tenantId, kontakt.id, email.id);
const updated = await tx.kontakt.findFirst({ where: { id: kontakt.id, tenantId: input.tenantId } });
if (updated) await publishKontaktSearchUpsert(tx, updated);
});
}
export async function removeKontaktEmail(input: KontaktScopedMutationInput & { emailId: string }): Promise<void> {
await withTenantRLS(input.tenantId, async (tx) => {
const kontakt = await getVisibleKontaktOrThrowWithTx(tx, input.tenantId, input.kontaktId, input.ctx);
const email = await tx.kontaktEmail.findFirst({
where: { id: input.emailId, tenantId: input.tenantId, kontaktId: kontakt.id },
select: { id: true, kontaktId: true },
});
if (!email) {
throw new CatalogError(foundationErrors.notFound, { emailId: input.emailId });
}
await tx.kontaktEmail.deleteMany({ where: { id: email.id, tenantId: input.tenantId, kontaktId: kontakt.id } });
await syncPrimaryEmailWithTx(tx, input.tenantId, kontakt.id);
const updated = await tx.kontakt.findFirst({ where: { id: kontakt.id, tenantId: input.tenantId } });
if (updated) await publishKontaktSearchUpsert(tx, updated);
});
}
// --- Telephone channels ----------------------------------------------------
export async function addKontaktTelefon(input: KontaktScopedMutationInput & { telefon: string; primaer?: boolean }): Promise<KontaktTelefonRow> {
const telefon = input.telefon.trim();
if (!telefon) {
throw new CrmServiceError("crm.error.telefonRequired");
}
return withTenantRLS(input.tenantId, async (tx) => {
const kontakt = await getVisibleKontaktOrThrowWithTx(tx, input.tenantId, input.kontaktId, input.ctx);
const created = await tx.kontaktTelefon.create({ data: { tenantId: input.tenantId, kontaktId: kontakt.id, telefon, primaer: false } });
await syncPrimaryTelefonWithTx(tx, input.tenantId, kontakt.id, input.primaer ? created.id : undefined);
const updated = await tx.kontakt.findFirst({ where: { id: kontakt.id, tenantId: input.tenantId } });
if (updated) await publishKontaktSearchUpsert(tx, updated);
return created as KontaktTelefonRow;
});
}
export async function setPrimaryTelefon(input: KontaktScopedMutationInput & { telefonId: string }): Promise<void> {
await withTenantRLS(input.tenantId, async (tx) => {
const kontakt = await getVisibleKontaktOrThrowWithTx(tx, input.tenantId, input.kontaktId, input.ctx);
const telefon = await tx.kontaktTelefon.findFirst({
where: { id: input.telefonId, tenantId: input.tenantId, kontaktId: kontakt.id },
select: { id: true, kontaktId: true },
});
if (!telefon) {
throw new CatalogError(foundationErrors.notFound, { telefonId: input.telefonId });
}
await syncPrimaryTelefonWithTx(tx, input.tenantId, kontakt.id, telefon.id);
const updated = await tx.kontakt.findFirst({ where: { id: kontakt.id, tenantId: input.tenantId } });
if (updated) await publishKontaktSearchUpsert(tx, updated);
});
}
export async function removeKontaktTelefon(input: KontaktScopedMutationInput & { telefonId: string }): Promise<void> {
await withTenantRLS(input.tenantId, async (tx) => {
const kontakt = await getVisibleKontaktOrThrowWithTx(tx, input.tenantId, input.kontaktId, input.ctx);
const telefon = await tx.kontaktTelefon.findFirst({
where: { id: input.telefonId, tenantId: input.tenantId, kontaktId: kontakt.id },
select: { id: true, kontaktId: true },
});
if (!telefon) {
throw new CatalogError(foundationErrors.notFound, { telefonId: input.telefonId });
}
await tx.kontaktTelefon.deleteMany({ where: { id: telefon.id, tenantId: input.tenantId, kontaktId: kontakt.id } });
await syncPrimaryTelefonWithTx(tx, input.tenantId, kontakt.id);
const updated = await tx.kontakt.findFirst({ where: { id: kontakt.id, tenantId: input.tenantId } });
if (updated) await publishKontaktSearchUpsert(tx, updated);
});
}
// --- Relations -------------------------------------------------------------
export async function addKontaktRelation(
input: KontaktScopedMutationInput & { zuKontaktId: string; rolle: KontaktArt; kontext?: string | null }
): Promise<KontaktRelationRow> {
if (input.kontaktId === input.zuKontaktId) {
throw new CrmServiceError("crm.error.selfRelation");
}
return withTenantRLS(input.tenantId, async (tx) => {
const source = await getVisibleKontaktOrThrowWithTx(tx, input.tenantId, input.kontaktId, input.ctx);
const target = await getVisibleKontaktOrThrowWithTx(tx, input.tenantId, input.zuKontaktId, input.ctx);
try {
const created = await tx.kontaktRelation.create({
data: { tenantId: input.tenantId, vonKontaktId: source.id, zuKontaktId: target.id, rolle: input.rolle, kontext: input.kontext ?? null },
});
return created as KontaktRelationRow;
} catch (error) {
if (isUniqueConstraintError(error)) {
throw new CrmServiceError("crm.error.relationExists");
}
throw error;
}
});
}
export async function removeKontaktRelation(input: KontaktScopedMutationInput & { relationId: string }): Promise<void> {
await withTenantRLS(input.tenantId, async (tx) => {
const kontakt = await getVisibleKontaktOrThrowWithTx(tx, input.tenantId, input.kontaktId, input.ctx);
const relation = await tx.kontaktRelation.findFirst({
where: {
id: input.relationId,
tenantId: input.tenantId,
OR: [{ vonKontaktId: kontakt.id }, { zuKontaktId: kontakt.id }],
},
select: { id: true, vonKontaktId: true, zuKontaktId: true },
});
if (!relation) {
throw new CatalogError(foundationErrors.notFound, { relationId: input.relationId });
}
const otherKontaktId = relation.vonKontaktId === kontakt.id ? relation.zuKontaktId : relation.vonKontaktId;
await getVisibleKontaktOrThrowWithTx(tx, input.tenantId, otherKontaktId, input.ctx);
await tx.kontaktRelation.deleteMany({
where: {
id: relation.id,
tenantId: input.tenantId,
OR: [{ vonKontaktId: kontakt.id }, { zuKontaktId: kontakt.id }],
},
});
});
}
// --- Visibility / read -----------------------------------------------------
async function getVisibleKontaktOrThrowWithTx(tx: TenantRlsClient, tenantId: string, kontaktId: string, ctx: CrmVisibilityContext): Promise<KontaktRow> {
const kontakt = await tx.kontakt.findFirst({ where: { id: kontaktId, tenantId, ...ownerScopeWhere(ctx) } });
if (!kontakt) {
throw new CatalogError(foundationErrors.notFound, { kontaktId });
}
return kontakt as KontaktRow;
}
/** Load a single contact (tenant-scoped) and enforce owner visibility, else 404. */
export async function getVisibleKontaktOrThrow(tenantId: string, kontaktId: string, ctx: CrmVisibilityContext): Promise<KontaktRow> {
return withTenantRLS(tenantId, (tx) => getVisibleKontaktOrThrowWithTx(tx, tenantId, kontaktId, ctx));
}
async function listVisibleRelationsForKontaktWithTx(
tx: TenantRlsClient,
tenantId: string,
kontaktId: string,
ctx: CrmVisibilityContext
): Promise<KontaktRelationRow[]> {
const rows = await tx.kontaktRelation.findMany({
where: { tenantId, OR: [{ vonKontaktId: kontaktId }, { zuKontaktId: kontaktId }] },
orderBy: { createdAt: "asc" },
});
if (rows.length === 0) return [];
const otherKontaktIds = [...new Set(rows.map((row) => (row.vonKontaktId === kontaktId ? row.zuKontaktId : row.vonKontaktId)))];
const visibleContacts = await tx.kontakt.findMany({
where: { id: { in: otherKontaktIds }, tenantId, ...ownerScopeWhere(ctx) },
select: { id: true },
});
const visibleIds = new Set(visibleContacts.map(({ id }) => id));
return rows.filter((row) => visibleIds.has(row.vonKontaktId === kontaktId ? row.zuKontaktId : row.vonKontaktId)) as KontaktRelationRow[];
}
/** All relations whose source and target contacts are visible to the caller. */
export async function listRelationsForKontakt(tenantId: string, kontaktId: string, ctx: CrmVisibilityContext): Promise<KontaktRelationRow[]> {
return withTenantRLS(tenantId, async (tx) => {
await getVisibleKontaktOrThrowWithTx(tx, tenantId, kontaktId, ctx);
return listVisibleRelationsForKontaktWithTx(tx, tenantId, kontaktId, ctx);
});
}
export type KontaktDetail = {
kontakt: KontaktRow;
emails: KontaktEmailRow[];
telefone: KontaktTelefonRow[];
relationen: KontaktRelationRow[];
adresse: AdresseRow | null;
};
/** Load a contact with its channels, relations and address (visibility-checked). */
export async function getKontaktDetail(tenantId: string, kontaktId: string, ctx: CrmVisibilityContext): Promise<KontaktDetail> {
return withTenantRLS(tenantId, async (tx) => {
const kontakt = await getVisibleKontaktOrThrowWithTx(tx, tenantId, kontaktId, ctx);
const [emails, telefone, relationen, adresse] = await Promise.all([
tx.kontaktEmail.findMany({ where: { tenantId, kontaktId }, orderBy: { createdAt: "asc" } }),
tx.kontaktTelefon.findMany({ where: { tenantId, kontaktId }, orderBy: { createdAt: "asc" } }),
listVisibleRelationsForKontaktWithTx(tx, tenantId, kontaktId, ctx),
kontakt.adresseId ? tx.adresse.findFirst({ where: { id: kontakt.adresseId, tenantId } }) : Promise.resolve(null),
]);
return {
kontakt,
emails: emails as KontaktEmailRow[],
telefone: telefone as KontaktTelefonRow[],
relationen: relationen as KontaktRelationRow[],
adresse: (adresse as AdresseRow | null) ?? null,
};
});
}
// --- List / filter / sort / paginate (server-side, DoD 2) ------------------
export type KontaktSortField = "name" | "createdAt" | "kontaktStatus";
export type SortDir = "asc" | "desc";
export type ListKontakteFilter = {
kontaktTyp?: KontaktTyp | null;
kontaktStatus?: KontaktStatus | null;
zustaendigerUserId?: string | null;
search?: string | null;
tagIds?: string[] | null;
tagMatch?: "and" | "or";
includeArchived?: boolean;
includeDemoData?: boolean;
};
export type ListKontakteSort = { field: KontaktSortField; dir: SortDir };
// Tags on Kontakte are stored with TagAssignment.entityType === "kontakt"
// (the canonical string for this entity, mirroring CustomFieldEntityType).
const KONTAKT_TAG_ENTITY_TYPE = "kontakt";
function buildOrderBy(sort?: ListKontakteSort): Record<string, SortDir> | Array<Record<string, SortDir>> {
if (!sort) {
return { createdAt: "desc" };
}
if (sort.field === "name") {
return [{ nachname: sort.dir }, { vorname: sort.dir }, { orgName: sort.dir }];
}
if (sort.field === "kontaktStatus") {
return { kontaktStatus: sort.dir };
}
return { createdAt: sort.dir };
}
/**
* Build the tenant-scoped, owner-filtered, tag/search-filtered `where` for the
* contact browser. Returns `{ where, empty }`; `empty` short-circuits to a 0-row
* result when a tag filter matched no contacts (avoids an `id IN ()` query).
*/
async function resolveKontaktBrowserWhereWithTx(
tx: TenantRlsClient,
tenantId: string,
ctx: CrmVisibilityContext,
filter: ListKontakteFilter
): Promise<{ where: Record<string, unknown>; empty: boolean }> {
const search = filter.search?.trim();
let tagScopedIds: string[] | undefined;
if (filter.tagIds && filter.tagIds.length > 0) {
const assignments = await tx.tagAssignment.findMany({
where: { tenantId, entityType: KONTAKT_TAG_ENTITY_TYPE, tagId: { in: filter.tagIds } },
select: { entityId: true, tagId: true },
});
if (filter.tagMatch === "and") {
const byEntity = new Map<string, Set<string>>();
for (const row of assignments) {
const set = byEntity.get(row.entityId) ?? new Set<string>();
set.add(row.tagId);
byEntity.set(row.entityId, set);
}
const required = new Set(filter.tagIds);
tagScopedIds = [...byEntity.entries()].filter(([, tags]) => [...required].every((tag) => tags.has(tag))).map(([entityId]) => entityId);
} else {
tagScopedIds = [...new Set(assignments.map((row) => row.entityId))];
}
if (tagScopedIds.length === 0) {
return { where: {}, empty: true };
}
}
const where = {
tenantId,
...ownerScopeWhere(ctx),
...(filter.includeArchived ? {} : { archivedAt: null }),
...(filter.includeDemoData ? {} : { isDemoData: false }),
...(filter.kontaktTyp ? { kontaktTyp: filter.kontaktTyp } : {}),
...(filter.kontaktStatus ? { kontaktStatus: filter.kontaktStatus } : {}),
...(filter.zustaendigerUserId ? { zustaendigerUserId: filter.zustaendigerUserId } : {}),
...(tagScopedIds ? { id: { in: tagScopedIds } } : {}),
...(search
? {
OR: [
{ vorname: { contains: search, mode: "insensitive" as const } },
{ nachname: { contains: search, mode: "insensitive" as const } },
{ orgName: { contains: search, mode: "insensitive" as const } },
{ primaerEmail: { contains: search, mode: "insensitive" as const } },
],
}
: {}),
};
return { where, empty: false };
}
export async function listKontakteForBrowser(input: {
tenantId: string;
ctx: CrmVisibilityContext;
filter?: ListKontakteFilter;
sort?: ListKontakteSort;
page?: number;
limit?: number;
}): Promise<{ rows: KontaktRow[]; total: number; page: number; pageCount: number }> {
const limit = Math.min(Math.max(input.limit ?? DEFAULT_LIST_LIMIT, 1), MAX_LIST_LIMIT);
const page = Math.max(input.page ?? 1, 1);
const filter = input.filter ?? {};
return withTenantRLS(input.tenantId, async (tx) => {
const { where, empty } = await resolveKontaktBrowserWhereWithTx(tx, input.tenantId, input.ctx, filter);
if (empty) {
return { rows: [], total: 0, page, pageCount: 0 };
}
const [rows, total] = await Promise.all([
tx.kontakt.findMany({ where, orderBy: buildOrderBy(input.sort), skip: (page - 1) * limit, take: limit }),
tx.kontakt.count({ where }),
]);
return { rows: rows as KontaktRow[], total, page, pageCount: Math.ceil(total / limit) };
});
}
/** All contacts matching the same browser filter, unpaginated, for CSV export
* (DoD 2). The CSV string itself is built in the route / a pure util. */
export async function listKontakteForCsv(input: {
tenantId: string;
ctx: CrmVisibilityContext;
filter?: ListKontakteFilter;
sort?: ListKontakteSort;
}): Promise<KontaktRow[]> {
const filter = input.filter ?? {};
return withTenantRLS(input.tenantId, async (tx) => {
const { where, empty } = await resolveKontaktBrowserWhereWithTx(tx, input.tenantId, input.ctx, filter);
if (empty) {
return [];
}
const rows = await tx.kontakt.findMany({ where, orderBy: buildOrderBy(input.sort) });
return rows as KontaktRow[];
});
}
// --- Duplicate detection (DoD 4) -------------------------------------------
export type DuplicateCheckInput = {
tenantId: string;
email?: string | null;
telefon?: string | null;
vorname?: string | null;
nachname?: string | null;
orgName?: string | null;
excludeKontaktId?: string | null;
};
/**
* Find potential duplicate contacts (tenant-scoped, non-archived). `hard` = exact
* channel match (primary or any email/phone row); `soft` = name match only
* (excluding hard hits). The contact identified by `excludeKontaktId` is omitted.
*/
export async function findDuplicateKontakte(input: DuplicateCheckInput): Promise<{ hard: KontaktRow[]; soft: KontaktRow[] }> {
const email = input.email?.trim();
const telefon = input.telefon?.trim();
const vorname = input.vorname?.trim();
const nachname = input.nachname?.trim();
const orgName = input.orgName?.trim();
const hardConds: Record<string, unknown>[] = [];
if (email) {
hardConds.push(
{ primaerEmail: { equals: email, mode: "insensitive" as const } },
{ emails: { some: { email: { equals: email, mode: "insensitive" as const } } } }
);
}
if (telefon) {
hardConds.push({ primaerTelefon: telefon }, { telefone: { some: { telefon } } });
}
const softConds: Record<string, unknown>[] = [];
if (vorname && nachname) {
softConds.push({ AND: [{ vorname: { equals: vorname, mode: "insensitive" as const } }, { nachname: { equals: nachname, mode: "insensitive" as const } }] });
}
if (orgName) {
softConds.push({ orgName: { equals: orgName, mode: "insensitive" as const } });
}
if (hardConds.length === 0 && softConds.length === 0) {
return { hard: [], soft: [] };
}
const excludeId = input.excludeKontaktId ?? undefined;
return withTenantRLS(input.tenantId, async (tx) => {
const base = { tenantId: input.tenantId, archivedAt: null, ...(excludeId ? { id: { not: excludeId } } : {}) };
const hard = hardConds.length > 0 ? await tx.kontakt.findMany({ where: { ...base, OR: hardConds }, orderBy: { createdAt: "desc" } }) : [];
const hardIds = new Set(hard.map((row) => row.id));
const softRaw = softConds.length > 0 ? await tx.kontakt.findMany({ where: { ...base, OR: softConds }, orderBy: { createdAt: "desc" } }) : [];
const soft = softRaw.filter((row) => !hardIds.has(row.id));
return { hard: hard as KontaktRow[], soft: soft as KontaktRow[] };
});
}
// --- Bulk actions (DoD 2) --------------------------------------------------
export const CRM_BULK_MAX_CONTACTS = 100;
export function normalizeBulkKontaktIds(kontaktIds: readonly string[]): string[] {
if (kontaktIds.length > CRM_BULK_MAX_CONTACTS) {
throw new CatalogError(massActionErrors.exceedsLimit, {
affected: kontaktIds.length,
hardLimit: CRM_BULK_MAX_CONTACTS,
});
}
return [...new Set(kontaktIds.map((id) => id.trim()).filter(Boolean))];
}
export async function listVisibleKontakteByIds(input: {
tenantId: string;
ctx: CrmVisibilityContext;
kontaktIds: readonly string[];
/** Archived contacts are excluded by default; set for the un-archive path. */
includeArchived?: boolean;
}): Promise<KontaktRow[]> {
const kontaktIds = normalizeBulkKontaktIds(input.kontaktIds);
if (kontaktIds.length === 0) return [];
const rows = await withTenantRLS(input.tenantId, (tx) =>
tx.kontakt.findMany({
where: {
id: { in: kontaktIds },
tenantId: input.tenantId,
...(input.includeArchived ? {} : { archivedAt: null }),
...ownerScopeWhere(input.ctx),
},
})
);
const rowById = new Map(rows.map((row) => [row.id, row as KontaktRow]));
return kontaktIds.flatMap((id) => {
const row = rowById.get(id);
return row ? [row] : [];
});
}
/** Archive many contacts; only contacts inside the caller's owner-scope are
* affected (read_own may not touch others'). Returns the number affected. */
export async function bulkArchiveKontakte(input: { tenantId: string; ctx: CrmVisibilityContext; kontaktIds: string[] }): Promise<{ affected: number }> {
const kontaktIds = normalizeBulkKontaktIds(input.kontaktIds);
if (kontaktIds.length === 0) {
return { affected: 0 };
}
const result = await withTenantRLS(input.tenantId, (tx) =>
tx.kontakt.updateMany({ where: { id: { in: kontaktIds }, tenantId: input.tenantId, ...ownerScopeWhere(input.ctx) }, data: { archivedAt: new Date() } })
);
return { affected: result.count };
}
/** Reassign many contacts; owner-scoped like {@link bulkArchiveKontakte}. */
export async function bulkReassignKontakte(input: {
tenantId: string;
ctx: CrmVisibilityContext;
kontaktIds: string[];
zustaendigerUserId: string;
}): Promise<{ affected: number }> {
const kontaktIds = normalizeBulkKontaktIds(input.kontaktIds);
if (kontaktIds.length === 0) {
return { affected: 0 };
}
const result = await withTenantRLS(input.tenantId, async (tx) => {
await requireActiveTenantUserWithTx(tx, input.tenantId, input.zustaendigerUserId);
return tx.kontakt.updateMany({
where: { id: { in: kontaktIds }, tenantId: input.tenantId, ...ownerScopeWhere(input.ctx) },
data: { zustaendigerUserId: input.zustaendigerUserId },
});
});
return { affected: result.count };
}
// --- Directory helpers (zustaendigerUser display + picker) -----------------
export type LabeledOption = { id: string; label: string };
function userLabel(user: { firstName: string; lastName: string; email: string }): string {
const name = `${user.firstName} ${user.lastName}`.trim();
return name || user.email;
}
/** Resolve display labels for a set of user ids (global user table). */
export async function resolveUserLabels(userIds: readonly string[]): Promise<Map<string, string>> {
const ids = [...new Set(userIds)].filter(Boolean);
if (ids.length === 0) {
return new Map();
}
const users = await db.user.findMany({ where: { id: { in: ids } }, select: { id: true, firstName: true, lastName: true, email: true } });
return new Map(users.map((user) => [user.id, userLabel(user)]));
}
/** Tenant members as zustaendigerUser picker options (id + display label). */
export async function listAssignableUsers(tenantId: string): Promise<LabeledOption[]> {
const members = await withTenantRLS(tenantId, (tx) =>
tx.tenantUser.findMany({
where: { tenantId, status: TenantUserStatus.ACTIVE, user: { active: true } },
select: { user: { select: { id: true, firstName: true, lastName: true, email: true } } },
})
);
return members.filter((member) => member.user).map((member) => ({ id: member.user.id, label: userLabel(member.user) }));
}
// --- internal --------------------------------------------------------------
function isUniqueConstraintError(error: unknown): boolean {
return Boolean(error && typeof error === "object" && "code" in error && (error as { code?: unknown }).code === "P2002");
}