989 lines
40 KiB
TypeScript
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");
|
|
}
|