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

926 lines
36 KiB
TypeScript

import { CatalogError, foundationErrors, massActionErrors, notify } from "@hivegrid/foundation";
import { leadStatusTimestampData } from "@hivegrid/db/lead-status-contract";
import { publishOutboxEvent, type OutboxPublisherTransaction } from "@hivegrid/db/outbox";
import { buildWorkflowTriggerOutboxEvent, type QueueJsonValue } from "@hivegrid/queue";
import { TenantUserStatus } from "~/application/enums/tenants/TenantUserStatus";
import { publishLeadSearchUpsert } from "~/services/search/searchIndexProjection.server";
import { db } from "~/utils/db.server";
import { withTenantRLS, type TenantRlsClient } from "~/utils/db/.server/tenantRls.server";
import { projectReadScopeWhere, type ProjectVisibilityContext } from "~/utils/integrations/projects/projectService.server";
import { canTransitionLeadStatus } from "./leadListQuery";
import type { LeadListFilter, LeadSortField, SortDir } from "./leadListQuery";
export class LeadServiceError extends Error {
readonly messageKey: string;
constructor(messageKey: string) {
super(messageKey);
this.name = "LeadServiceError";
this.messageKey = messageKey;
}
}
export type LeadQuelle = "form" | "email" | "aircall" | "manual" | "custom";
export type LeadStatus = "new" | "qualified" | "converted" | "lost" | "spam";
export type LeadWeiterverarbeitungFreigabe = "ja" | "nein" | "unklar";
export type LeadSourceType = "system" | "custom";
export type LeadAntiSpamMode = "quarantine" | "spam";
export type LeadVisibilityContext = ProjectVisibilityContext;
export function canReadAllLeads(ctx: LeadVisibilityContext): boolean {
return ctx.permissions.includes("leads.read_all");
}
export function ownerScopeWhere(ctx: LeadVisibilityContext): { zustaendigerUserId?: string } {
return canReadAllLeads(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 });
}
async function hydrateProjectVisibilityContextWithTx(tx: TenantRlsClient, tenantId: string, ctx: LeadVisibilityContext): Promise<ProjectVisibilityContext> {
const [roleIds, groupIds] = await Promise.all([
ctx.roleIds === undefined
? tx.userRole.findMany({ where: { tenantId, userId: ctx.userId }, select: { roleId: true } }).then((rows) => rows.map(({ roleId }) => roleId))
: Promise.resolve([...ctx.roleIds]),
ctx.groupIds === undefined
? tx.groupUser
.findMany({ where: { userId: ctx.userId, group: { tenantId } }, select: { groupId: true } })
.then((rows) => rows.map(({ groupId }) => groupId))
: Promise.resolve([...ctx.groupIds]),
]);
return { ...ctx, roleIds, groupIds };
}
async function requireVisibleConversionProjectWithTx(tx: TenantRlsClient, tenantId: string, projectId: string, ctx: LeadVisibilityContext): Promise<void> {
if (
!ctx.permissions.some((permission) => ["projects.read_all", "projects.read_own", "projects.read_own_pl", "projects.read_own_member"].includes(permission))
) {
throw new CatalogError(foundationErrors.notFound, { projectId });
}
const projectCtx = await hydrateProjectVisibilityContextWithTx(tx, tenantId, ctx);
const project = await tx.projekt.findFirst({
where: {
id: projectId,
tenantId,
archivedAt: null,
...projectReadScopeWhere(tenantId, projectCtx),
},
select: { id: true },
});
if (!project) throw new CatalogError(foundationErrors.notFound, { projectId });
}
async function requireVisibleConversionContactWithTx(
tx: TenantRlsClient,
tenantId: string,
kontaktId: string | null,
ctx: LeadVisibilityContext
): Promise<void> {
if (!kontaktId) return;
const canReadAll = ctx.permissions.includes("crm.read_all");
if (!canReadAll && !ctx.permissions.includes("crm.read_own")) {
throw new CatalogError(foundationErrors.notFound, { kontaktId });
}
const kontakt = await tx.kontakt.findFirst({
where: {
id: kontaktId,
tenantId,
archivedAt: null,
...(canReadAll ? {} : { zustaendigerUserId: ctx.userId }),
},
select: { id: true },
});
if (!kontakt) throw new CatalogError(foundationErrors.notFound, { kontaktId });
}
export type LeadRow = {
id: string;
tenantId: string;
createdByUserId: string | null;
zustaendigerUserId: string | null;
quelle: LeadQuelle;
quelleDetail: unknown;
sourceId: string | null;
sourceOrigin: string | null;
submitterIpHash: string | null;
status: LeadStatus;
weiterverarbeitungFreigabe: LeadWeiterverarbeitungFreigabe;
vorname: string | null;
nachname: string | null;
email: string | null;
telefon: string | null;
message: string | null;
rohdaten: unknown;
spamScore: number;
spamReason: string | null;
isQuarantined: boolean;
konvertiertProjektId: string | null;
verknuepfterKontaktId: string | null;
convertedAt: Date | null;
lostAt: Date | null;
spamMarkedAt: Date | null;
autoDeleteAt: Date | null;
isDemoData: boolean;
createdAt: Date;
updatedAt: Date;
archivedAt: Date | null;
};
export type LeadSourceRow = {
id: string;
tenantId: string;
type: LeadSourceType;
key: string;
name: string;
displayLabel: string;
defaultZustaendigerUserId: string | null;
defaultTags: unknown;
webhookTokenHash: string | null;
pausedAt: Date | null;
archivedAt: Date | null;
isDemoData: boolean;
createdAt: Date;
updatedAt: Date;
};
export type LeadAntiSpamConfigRow = {
id: string;
tenantId: string;
perSourcePerHourLimit: number;
perIpPerHourLimit: number;
anomalySpikeFactor: number;
mode: LeadAntiSpamMode;
disposableDomainEnabled: boolean;
honeypotEnabled: boolean;
fastSubmitEnabled: boolean;
lostAutoDeleteDays: number;
isDemoData: boolean;
createdAt: Date;
updatedAt: Date;
};
const DEFAULT_LIMIT = 50;
function normalizeEmail(email?: string | null): string | null {
const value = email?.trim().toLowerCase();
return value || null;
}
function normalizeTelefon(telefon?: string | null): string | null {
const value = telefon?.trim();
return value || null;
}
function leadNamePresent(input: {
vorname?: string | null;
nachname?: string | null;
email?: string | null;
telefon?: string | null;
message?: string | null;
}): boolean {
return Boolean(input.vorname?.trim() || input.nachname?.trim() || input.email?.trim() || input.telefon?.trim() || input.message?.trim());
}
function normalizeOptionalText(value?: string | null): string | null {
const normalized = value?.trim();
return normalized || null;
}
async function ensureAntiSpamConfigWithTx(tx: TenantRlsClient, tenantId: string): Promise<LeadAntiSpamConfigRow> {
const existing = await tx.leadAntiSpamConfig.findUnique({ where: { tenantId } });
if (existing) return existing as LeadAntiSpamConfigRow;
const created = await tx.leadAntiSpamConfig.create({ data: { tenantId } });
return created as LeadAntiSpamConfigRow;
}
export { canTransitionLeadStatus };
function assertStatusTransition(from: LeadStatus, to: LeadStatus): void {
if (!canTransitionLeadStatus(from, to)) {
throw new LeadServiceError("leads.error.invalidStatusTransition");
}
}
function autoDeleteAtForStatus(status: LeadStatus, now: Date, config: Pick<LeadAntiSpamConfigRow, "lostAutoDeleteDays">): Date | null {
const data = leadStatusTimestampData(status, now, config.lostAutoDeleteDays);
return "autoDeleteAt" in data ? (data.autoDeleteAt ?? null) : null;
}
function statusTimestamps(status: LeadStatus, now: Date, config: Pick<LeadAntiSpamConfigRow, "lostAutoDeleteDays">) {
return leadStatusTimestampData(status, now, config.lostAutoDeleteDays);
}
function toWorkflowPayload(input: {
lead: Pick<
LeadRow,
| "id"
| "tenantId"
| "quelle"
| "sourceId"
| "status"
| "weiterverarbeitungFreigabe"
| "zustaendigerUserId"
| "konvertiertProjektId"
| "verknuepfterKontaktId"
| "spamScore"
| "isQuarantined"
>;
action: "created" | "qualified" | "status_changed" | "convert_requested" | "converted";
}): QueueJsonValue {
return {
leadId: input.lead.id,
tenantId: input.lead.tenantId,
action: input.action,
quelle: input.lead.quelle,
sourceId: input.lead.sourceId,
status: input.lead.status,
weiterverarbeitungFreigabe: input.lead.weiterverarbeitungFreigabe,
zustaendigerUserId: input.lead.zustaendigerUserId,
konvertiertProjektId: input.lead.konvertiertProjektId,
verknuepfterKontaktId: input.lead.verknuepfterKontaktId,
spamScore: input.lead.spamScore,
isQuarantined: input.lead.isQuarantined,
};
}
async function publishLeadWorkflowEvent(
tx: TenantRlsClient,
input: {
tenantId: string;
userId: string | null;
lead: LeadRow;
event: "lead.created" | "lead.qualified" | "lead.status_changed" | "lead.convert_requested" | "lead.converted";
action: "created" | "qualified" | "status_changed" | "convert_requested" | "converted";
}
): Promise<void> {
await publishOutboxEvent(
tx as unknown as OutboxPublisherTransaction,
buildWorkflowTriggerOutboxEvent({
tenantId: input.tenantId,
event: input.event,
data: toWorkflowPayload({ lead: input.lead, action: input.action }),
userId: input.userId,
sourceId: `lead:${input.lead.id}:${input.action}`,
})
);
}
async function notifyLeadSourcePausedAdmins(
tx: TenantRlsClient,
input: { tenantId: string; source: Pick<LeadSourceRow, "id" | "key" | "name">; reason: string }
): Promise<void> {
try {
const admins = await tx.userRole.findMany({
where: {
tenantId: input.tenantId,
role: { permissions: { some: { permission: { name: "leads.admin" } } } },
},
select: { userId: true },
});
const userIds = [...new Set(admins.map((row) => row.userId))];
await Promise.all(
userIds.map((userId) =>
notify(
{
tenantId: input.tenantId,
userId,
channel: "IN_APP",
category: "leads",
messageKey: "leads.notification.sourcePaused",
sourceKey: `lead-source-paused:${input.source.id}`,
payloadMetadata: { sourceId: input.source.id, sourceKey: input.source.key, sourceName: input.source.name, reason: input.reason },
},
tx as unknown as Parameters<typeof notify>[1]
)
)
);
} catch {
// Notification failures must not lose the lead submission.
}
}
function computeSpam(input: {
email: string | null;
message?: string | null;
rohdaten?: unknown;
config: Pick<LeadAntiSpamConfigRow, "disposableDomainEnabled" | "honeypotEnabled" | "fastSubmitEnabled" | "mode">;
}): { score: number; reason: string | null; quarantine: boolean; spam: boolean } {
let score = 0;
const reasons: string[] = [];
const raw = typeof input.rohdaten === "object" && input.rohdaten ? (input.rohdaten as Record<string, unknown>) : {};
const emailDomain = input.email?.split("@")[1] ?? "";
if (input.config.disposableDomainEnabled && ["mailinator.com", "10minutemail.com", "example.invalid"].includes(emailDomain)) {
score += 45;
reasons.push("disposable_domain");
}
if (input.config.honeypotEnabled && typeof raw.honeypot === "string" && raw.honeypot.trim()) {
score += 60;
reasons.push("honeypot");
}
const submitSeconds = typeof raw.submitSeconds === "number" ? raw.submitSeconds : null;
if (input.config.fastSubmitEnabled && submitSeconds !== null && submitSeconds < 3) {
score += 35;
reasons.push("fast_submit");
}
const spam = score >= 80 && input.config.mode === "spam";
const quarantine = score >= 50 && !spam;
return { score, reason: reasons.length > 0 ? reasons.join(",") : null, quarantine, spam };
}
async function computeRateAndAnomalySignals(input: {
tx: TenantRlsClient;
tenantId: string;
config: Pick<LeadAntiSpamConfigRow, "perSourcePerHourLimit" | "perIpPerHourLimit" | "anomalySpikeFactor">;
source: LeadSourceRow | null;
quelle: LeadQuelle;
submitterIpHash: string | null;
now: Date;
}): Promise<{ score: number; reasons: string[]; pauseSource: boolean }> {
const oneHourAgo = new Date(input.now.getTime() - 60 * 60 * 1000);
const dayAgo = new Date(input.now.getTime() - 24 * 60 * 60 * 1000);
const sourceWhere = input.source ? { sourceId: input.source.id } : { sourceId: null, quelle: input.quelle };
const [sourceHourCount, sourceDayCount, ipHourCount] = await Promise.all([
input.tx.inboxLead.count({ where: { tenantId: input.tenantId, createdAt: { gte: oneHourAgo }, ...sourceWhere } }),
input.tx.inboxLead.count({ where: { tenantId: input.tenantId, createdAt: { gte: dayAgo }, ...sourceWhere } }),
input.submitterIpHash
? input.tx.inboxLead.count({ where: { tenantId: input.tenantId, submitterIpHash: input.submitterIpHash, createdAt: { gte: oneHourAgo } } })
: Promise.resolve(0),
]);
const reasons: string[] = [];
let score = 0;
let pauseSource = false;
if (sourceHourCount >= input.config.perSourcePerHourLimit) {
score += 60;
reasons.push("source_rate_limit");
}
if (ipHourCount >= input.config.perIpPerHourLimit) {
score += 60;
reasons.push("ip_rate_limit");
}
const previousHoursAverage = Math.max(1, Math.ceil(Math.max(0, sourceDayCount - sourceHourCount) / 23));
if (sourceHourCount >= previousHoursAverage * input.config.anomalySpikeFactor && sourceHourCount >= input.config.anomalySpikeFactor) {
score += 80;
reasons.push("source_anomaly_spike");
pauseSource = Boolean(input.source);
}
return { score, reasons, pauseSource };
}
export type CreateLeadInput = {
tenantId: string;
userId?: string | null;
quelle: LeadQuelle;
quelleDetail?: unknown;
sourceId?: string | null;
sourceOrigin?: string | null;
submitterIpHash?: string | null;
vorname?: string | null;
nachname?: string | null;
email?: string | null;
telefon?: string | null;
message?: string | null;
rohdaten?: unknown;
zustaendigerUserId?: string | null;
weiterverarbeitungFreigabe?: LeadWeiterverarbeitungFreigabe;
isDemoData?: boolean;
};
export async function createLead(input: CreateLeadInput): Promise<LeadRow> {
if (!leadNamePresent(input)) {
throw new LeadServiceError("leads.error.identityRequired");
}
const lead = await withTenantRLS(input.tenantId, async (tx) => {
const now = new Date();
const config = await ensureAntiSpamConfigWithTx(tx, input.tenantId);
const source = input.sourceId ? await tx.leadSource.findFirst({ where: { id: input.sourceId, tenantId: input.tenantId, archivedAt: null } }) : null;
if (source?.pausedAt) {
throw new LeadServiceError("leads.error.sourcePaused");
}
const email = normalizeEmail(input.email);
const telefon = normalizeTelefon(input.telefon);
const submitterIpHash = normalizeOptionalText(input.submitterIpHash);
const spam = computeSpam({ email, message: input.message, rohdaten: input.rohdaten, config });
const rateSignals = await computeRateAndAnomalySignals({
tx,
tenantId: input.tenantId,
config,
source: source as LeadSourceRow | null,
quelle: input.quelle,
submitterIpHash,
now,
});
const spamScore = Math.min(100, spam.score + rateSignals.score);
const spamReasons = [...(spam.reason ? spam.reason.split(",") : []), ...rateSignals.reasons];
const status: LeadStatus = spamScore >= 80 && config.mode === "spam" ? "spam" : "new";
const isQuarantined = status !== "spam" && (spam.quarantine || spamScore >= 50);
const responsibleUserId = input.zustaendigerUserId ?? source?.defaultZustaendigerUserId ?? input.userId ?? null;
if (responsibleUserId) await requireActiveTenantUserWithTx(tx, input.tenantId, responsibleUserId);
if (source && rateSignals.pauseSource) {
await tx.leadSource.update({ where: { id: source.id }, data: { pausedAt: now } });
await notifyLeadSourcePausedAdmins(tx, { tenantId: input.tenantId, source: source as LeadSourceRow, reason: "source_anomaly_spike" });
}
const created = await tx.inboxLead.create({
data: {
tenantId: input.tenantId,
createdByUserId: input.userId ?? null,
zustaendigerUserId: responsibleUserId,
quelle: input.quelle,
quelleDetail: input.quelleDetail ?? undefined,
sourceId: source?.id ?? null,
sourceOrigin: normalizeOptionalText(input.sourceOrigin),
submitterIpHash,
status,
weiterverarbeitungFreigabe: input.weiterverarbeitungFreigabe ?? "unklar",
vorname: input.vorname?.trim() || null,
nachname: input.nachname?.trim() || null,
email,
telefon,
message: input.message?.trim() || null,
rohdaten: input.rohdaten ?? undefined,
spamScore,
spamReason: spamReasons.length > 0 ? spamReasons.join(",") : null,
isQuarantined,
spamMarkedAt: status === "spam" ? now : null,
autoDeleteAt: autoDeleteAtForStatus(status, now, config),
isDemoData: input.isDemoData ?? false,
},
});
await publishLeadWorkflowEvent(tx, {
tenantId: input.tenantId,
userId: input.userId ?? null,
lead: created as LeadRow,
event: "lead.created",
action: "created",
});
await publishLeadSearchUpsert(tx, created as LeadRow);
return created;
});
return lead as LeadRow;
}
export type ListLeadsResult = { rows: LeadRow[]; nextCursor: string | null; total: number };
function effectiveStatuses(filter: LeadListFilter): LeadStatus[] {
if (filter.status?.length) return filter.status;
const base: LeadStatus[] = ["new", "qualified"];
if (filter.includeConverted) base.push("converted");
if (filter.includeSpam) base.push("spam");
return base;
}
function whereForLeadList(tenantId: string, ctx: LeadVisibilityContext, filter: LeadListFilter) {
return {
tenantId,
archivedAt: null,
...ownerScopeWhere(ctx),
status: { in: effectiveStatuses(filter) },
...(filter.quelle ? { quelle: filter.quelle } : {}),
...(filter.zustaendigerUserId ? { zustaendigerUserId: filter.zustaendigerUserId } : {}),
...(filter.search
? {
OR: [
{ vorname: { contains: filter.search, mode: "insensitive" as const } },
{ nachname: { contains: filter.search, mode: "insensitive" as const } },
{ email: { contains: filter.search, mode: "insensitive" as const } },
{ telefon: { contains: filter.search, mode: "insensitive" as const } },
{ message: { contains: filter.search, mode: "insensitive" as const } },
],
}
: {}),
};
}
function orderBy(sort: { field: LeadSortField; dir: SortDir }) {
if (sort.field === "status") return [{ status: sort.dir }, { createdAt: "desc" as const }, { id: "desc" as const }];
if (sort.field === "quelle") return [{ quelle: sort.dir }, { createdAt: "desc" as const }, { id: "desc" as const }];
if (sort.field === "spamScore") return [{ spamScore: sort.dir }, { createdAt: "desc" as const }, { id: "desc" as const }];
return [{ createdAt: sort.dir }, { id: "desc" as const }];
}
export async function listLeadsForBrowser(input: {
tenantId: string;
ctx: LeadVisibilityContext;
filter: LeadListFilter;
sort: { field: LeadSortField; dir: SortDir };
cursor?: string | null;
limit?: number;
}): Promise<ListLeadsResult> {
const take = Math.min(Math.max(input.limit ?? DEFAULT_LIMIT, 1), DEFAULT_LIMIT);
return withTenantRLS(input.tenantId, async (tx) => {
const where = whereForLeadList(input.tenantId, input.ctx, input.filter);
const [rows, total] = await Promise.all([
tx.inboxLead.findMany({
where,
orderBy: orderBy(input.sort),
take: take + 1,
...(input.cursor ? { cursor: { id: input.cursor }, skip: 1 } : {}),
}),
tx.inboxLead.count({ where }),
]);
const page = rows.slice(0, take);
return { rows: page as LeadRow[], nextCursor: rows.length > take ? (page.at(-1)?.id ?? null) : null, total };
});
}
export async function listLeadsForBoard(input: {
tenantId: string;
ctx: LeadVisibilityContext;
statuses: readonly LeadStatus[];
search?: string | null;
limitPerStatus?: number;
}): Promise<{ rows: LeadRow[]; countsByStatus: Partial<Record<LeadStatus, number>> }> {
const limitPerStatus = Math.min(Math.max(input.limitPerStatus ?? DEFAULT_LIMIT, 1), DEFAULT_LIMIT);
return withTenantRLS(input.tenantId, async (tx) => {
const queries = input.statuses.map(async (status) => {
const where = whereForLeadList(input.tenantId, input.ctx, {
status: [status],
quelle: null,
zustaendigerUserId: null,
search: input.search?.trim() || null,
includeSpam: false,
includeConverted: true,
});
const [rows, count] = await Promise.all([
tx.inboxLead.findMany({ where, orderBy: [{ createdAt: "desc" }, { id: "desc" }], take: limitPerStatus }),
tx.inboxLead.count({ where }),
]);
return { status, rows: rows as LeadRow[], count };
});
const results = await Promise.all(queries);
return {
rows: results.flatMap((result) => result.rows),
countsByStatus: Object.fromEntries(results.map((result) => [result.status, result.count])) as Partial<Record<LeadStatus, number>>,
};
});
}
export async function getVisibleLeadOrThrow(tenantId: string, leadId: string, ctx: LeadVisibilityContext): Promise<LeadRow> {
const lead = await withTenantRLS(tenantId, (tx) => tx.inboxLead.findFirst({ where: { id: leadId, tenantId, archivedAt: null, ...ownerScopeWhere(ctx) } }));
if (!lead) {
throw new CatalogError(foundationErrors.notFound, { leadId });
}
return lead as LeadRow;
}
export async function updateLead(input: {
tenantId: string;
ctx?: LeadVisibilityContext;
leadId: string;
vorname?: string | null;
nachname?: string | null;
email?: string | null;
telefon?: string | null;
message?: string | null;
weiterverarbeitungFreigabe?: LeadWeiterverarbeitungFreigabe;
zustaendigerUserId?: string | null;
}): Promise<void> {
await withTenantRLS(input.tenantId, async (tx) => {
if (input.zustaendigerUserId) {
await requireActiveTenantUserWithTx(tx, input.tenantId, input.zustaendigerUserId);
}
const existing = await tx.inboxLead.findFirst({
where: { id: input.leadId, tenantId: input.tenantId, archivedAt: null, ...(input.ctx ? ownerScopeWhere(input.ctx) : {}) },
select: { id: true },
});
if (!existing) return;
const updated = await tx.inboxLead.update({
where: { id: existing.id },
data: {
...(input.vorname !== undefined ? { vorname: input.vorname?.trim() || null } : {}),
...(input.nachname !== undefined ? { nachname: input.nachname?.trim() || null } : {}),
...(input.email !== undefined ? { email: normalizeEmail(input.email) } : {}),
...(input.telefon !== undefined ? { telefon: normalizeTelefon(input.telefon) } : {}),
...(input.message !== undefined ? { message: input.message?.trim() || null } : {}),
...(input.weiterverarbeitungFreigabe !== undefined ? { weiterverarbeitungFreigabe: input.weiterverarbeitungFreigabe } : {}),
...(input.zustaendigerUserId !== undefined ? { zustaendigerUserId: input.zustaendigerUserId } : {}),
},
});
await publishLeadSearchUpsert(tx, updated as LeadRow);
});
}
export async function setLeadStatus(input: {
tenantId: string;
leadId: string;
status: LeadStatus;
ctx?: LeadVisibilityContext;
userId?: string | null;
}): Promise<LeadRow> {
const now = new Date();
return withTenantRLS(input.tenantId, async (tx) => {
const config = await ensureAntiSpamConfigWithTx(tx, input.tenantId);
const current = await tx.inboxLead.findFirst({
where: { id: input.leadId, tenantId: input.tenantId, archivedAt: null, ...(input.ctx ? ownerScopeWhere(input.ctx) : {}) },
});
if (!current) {
throw new CatalogError(foundationErrors.notFound, { leadId: input.leadId });
}
assertStatusTransition(current.status as LeadStatus, input.status);
const updated = await tx.inboxLead.update({
where: { id: current.id },
data: {
status: input.status,
...statusTimestamps(input.status, now, config),
},
});
const lead = updated as LeadRow;
await publishLeadWorkflowEvent(tx, {
tenantId: input.tenantId,
userId: input.userId ?? input.ctx?.userId ?? null,
lead,
event: input.status === "qualified" ? "lead.qualified" : "lead.status_changed",
action: input.status === "qualified" ? "qualified" : "status_changed",
});
await publishLeadSearchUpsert(tx, lead);
return lead;
});
}
export async function bulkSetLeadStatus(input: { tenantId: string; ctx: LeadVisibilityContext; leadIds: string[]; status: LeadStatus }): Promise<number> {
const leadIds = normalizeBulkLeadIds(input.leadIds);
if (leadIds.length === 0) return 0;
const now = new Date();
const result = await withTenantRLS(input.tenantId, async (tx) => {
const config = await ensureAntiSpamConfigWithTx(tx, input.tenantId);
const leads = await tx.inboxLead.findMany({
where: { id: { in: leadIds }, tenantId: input.tenantId, archivedAt: null, ...ownerScopeWhere(input.ctx) },
});
if (leads.some((lead) => !canTransitionLeadStatus(lead.status as LeadStatus, input.status))) {
throw new LeadServiceError("leads.error.invalidStatusTransition");
}
const updated = await tx.inboxLead.updateMany({
where: { id: { in: leads.map((lead) => lead.id) }, tenantId: input.tenantId },
data: { status: input.status, ...statusTimestamps(input.status, now, config) },
});
for (const lead of leads) {
const projectedLead = { ...(lead as LeadRow), status: input.status, ...statusTimestamps(input.status, now, config) } as LeadRow;
await publishLeadWorkflowEvent(tx, {
tenantId: input.tenantId,
userId: input.ctx.userId,
lead: projectedLead,
event: input.status === "qualified" ? "lead.qualified" : "lead.status_changed",
action: input.status === "qualified" ? "qualified" : "status_changed",
});
await publishLeadSearchUpsert(tx, projectedLead);
}
return updated;
});
return result.count;
}
export const LEAD_BULK_MAX_ENTRIES = 100;
export function normalizeBulkLeadIds(leadIds: readonly string[]): string[] {
if (leadIds.length > LEAD_BULK_MAX_ENTRIES) {
throw new CatalogError(massActionErrors.exceedsLimit, {
affected: leadIds.length,
hardLimit: LEAD_BULK_MAX_ENTRIES,
});
}
return [...new Set(leadIds.map((id) => id.trim()).filter(Boolean))];
}
export async function requestLeadProjectConversion(input: {
tenantId: string;
leadId: string;
kontaktId?: string | null;
ctx: LeadVisibilityContext;
userId?: string | null;
}): Promise<LeadRow> {
// P8 owns the Projekt model. P7 prepares and validates the conversion point so
// later code can atomically create Projekt + set konvertiertProjektId.
return withTenantRLS(input.tenantId, async (tx) => {
const current = await tx.inboxLead.findFirst({
where: { id: input.leadId, tenantId: input.tenantId, archivedAt: null, ...(input.ctx ? ownerScopeWhere(input.ctx) : {}) },
});
if (!current) {
throw new CatalogError(foundationErrors.notFound, { leadId: input.leadId });
}
assertStatusTransition(current.status as LeadStatus, "qualified");
const kontaktId = normalizeOptionalText(input.kontaktId);
await requireVisibleConversionContactWithTx(tx, input.tenantId, kontaktId, input.ctx);
const updated = await tx.inboxLead.update({
where: { id: current.id, tenantId: input.tenantId },
data: {
status: "qualified",
weiterverarbeitungFreigabe: "ja",
verknuepfterKontaktId: kontaktId ?? undefined,
},
});
const lead = updated as LeadRow;
await publishLeadWorkflowEvent(tx, {
tenantId: input.tenantId,
userId: input.userId ?? input.ctx?.userId ?? null,
lead,
event: "lead.convert_requested",
action: "convert_requested",
});
// Only when qualification actually happened here. Re-announcing it for an
// already-qualified lead is both untrue and a duplicate dispatch key.
if (current.status !== "qualified") {
await publishLeadWorkflowEvent(tx, {
tenantId: input.tenantId,
userId: input.userId ?? input.ctx?.userId ?? null,
lead,
event: "lead.qualified",
action: "qualified",
});
}
await publishLeadSearchUpsert(tx, lead);
return lead;
});
}
export async function completeLeadProjectConversion(input: {
tenantId: string;
leadId: string;
projektId: string;
kontaktId?: string | null;
ctx: LeadVisibilityContext;
userId?: string | null;
}): Promise<LeadRow> {
const projektId = normalizeOptionalText(input.projektId);
if (!projektId) {
throw new LeadServiceError("leads.error.projectRequired");
}
return withTenantRLS(input.tenantId, async (tx) => {
const current = await tx.inboxLead.findFirst({
where: { id: input.leadId, tenantId: input.tenantId, archivedAt: null, ...(input.ctx ? ownerScopeWhere(input.ctx) : {}) },
});
if (!current) {
throw new CatalogError(foundationErrors.notFound, { leadId: input.leadId });
}
assertStatusTransition(current.status as LeadStatus, "converted");
const kontaktId = normalizeOptionalText(input.kontaktId) ?? current.verknuepfterKontaktId;
await Promise.all([
requireVisibleConversionProjectWithTx(tx, input.tenantId, projektId, input.ctx),
requireVisibleConversionContactWithTx(tx, input.tenantId, kontaktId, input.ctx),
]);
const updated = await tx.inboxLead.update({
where: { id: current.id, tenantId: input.tenantId },
data: {
status: "converted",
weiterverarbeitungFreigabe: "ja",
konvertiertProjektId: projektId,
verknuepfterKontaktId: kontaktId,
convertedAt: new Date(),
autoDeleteAt: null,
},
});
const lead = updated as LeadRow;
await publishLeadWorkflowEvent(tx, {
tenantId: input.tenantId,
userId: input.userId ?? input.ctx?.userId ?? null,
lead,
event: "lead.converted",
action: "converted",
});
await publishLeadSearchUpsert(tx, lead);
return lead;
});
}
export async function listAssignableUsers(tenantId: string): Promise<{ id: string; label: string }[]> {
const rows = await db.tenantUser.findMany({
where: { tenantId, status: TenantUserStatus.ACTIVE, user: { active: true } },
select: { userId: true, user: { select: { firstName: true, lastName: true, email: true } } },
orderBy: { createdAt: "asc" },
});
return rows.map((row) => ({
id: row.userId,
label: `${row.user.firstName ?? ""} ${row.user.lastName ?? ""}`.trim() || row.user.email,
}));
}
export async function resolveUserLabels(userIds: string[]): Promise<Map<string, string>> {
const unique = [...new Set(userIds.filter(Boolean))];
if (unique.length === 0) return new Map();
const users = await db.user.findMany({ where: { id: { in: unique } }, select: { id: true, firstName: true, lastName: true, email: true } });
return new Map(users.map((u) => [u.id, `${u.firstName ?? ""} ${u.lastName ?? ""}`.trim() || u.email]));
}
export async function listLeadSources(tenantId: string): Promise<LeadSourceRow[]> {
return withTenantRLS(
tenantId,
async (tx) => (await tx.leadSource.findMany({ where: { tenantId, archivedAt: null }, orderBy: [{ type: "asc" }, { name: "asc" }] })) as LeadSourceRow[]
);
}
export async function upsertLeadSource(input: {
tenantId: string;
key: string;
name: string;
displayLabel?: string | null;
type?: LeadSourceType;
defaultZustaendigerUserId?: string | null;
}): Promise<LeadSourceRow> {
const key = input.key.trim().toLowerCase();
if (!key || !input.name.trim()) {
throw new LeadServiceError("leads.error.sourceRequired");
}
const row = await withTenantRLS(input.tenantId, (tx) =>
tx.leadSource.upsert({
where: { tenantId_key: { tenantId: input.tenantId, key } },
update: {
name: input.name.trim(),
displayLabel: input.displayLabel?.trim() || input.name.trim(),
defaultZustaendigerUserId: input.defaultZustaendigerUserId ?? null,
},
create: {
tenantId: input.tenantId,
key,
name: input.name.trim(),
displayLabel: input.displayLabel?.trim() || input.name.trim(),
type: input.type ?? "custom",
defaultZustaendigerUserId: input.defaultZustaendigerUserId ?? null,
},
})
);
return row as LeadSourceRow;
}
/**
* Soft-delete of a lead source: sets archivedAt so it drops out of the admin
* list and cannot be assigned to new leads, while leads already captured
* through it keep pointing at a row that still exists.
*/
export async function archiveLeadSource(input: { tenantId: string; sourceId: string }): Promise<void> {
await withTenantRLS(input.tenantId, async (tx) => {
const existing = await tx.leadSource.findFirst({
where: { id: input.sourceId, tenantId: input.tenantId, archivedAt: null },
select: { id: true },
});
if (!existing) throw new LeadServiceError("leads.error.sourceNotFound");
await tx.leadSource.updateMany({ where: { id: input.sourceId, tenantId: input.tenantId }, data: { archivedAt: new Date() } });
});
}
export async function getLeadAntiSpamConfig(tenantId: string): Promise<LeadAntiSpamConfigRow> {
return withTenantRLS(tenantId, (tx) => ensureAntiSpamConfigWithTx(tx, tenantId));
}
export async function updateLeadAntiSpamConfig(input: {
tenantId: string;
perSourcePerHourLimit?: number;
perIpPerHourLimit?: number;
anomalySpikeFactor?: number;
mode?: LeadAntiSpamMode;
disposableDomainEnabled?: boolean;
honeypotEnabled?: boolean;
fastSubmitEnabled?: boolean;
lostAutoDeleteDays?: number;
}): Promise<LeadAntiSpamConfigRow> {
const row = await withTenantRLS(input.tenantId, async (tx) => {
await ensureAntiSpamConfigWithTx(tx, input.tenantId);
return tx.leadAntiSpamConfig.update({
where: { tenantId: input.tenantId },
data: {
...(input.perSourcePerHourLimit !== undefined ? { perSourcePerHourLimit: Math.max(1, input.perSourcePerHourLimit) } : {}),
...(input.perIpPerHourLimit !== undefined ? { perIpPerHourLimit: Math.max(1, input.perIpPerHourLimit) } : {}),
...(input.anomalySpikeFactor !== undefined ? { anomalySpikeFactor: Math.max(2, input.anomalySpikeFactor) } : {}),
...(input.mode !== undefined ? { mode: input.mode } : {}),
...(input.disposableDomainEnabled !== undefined ? { disposableDomainEnabled: input.disposableDomainEnabled } : {}),
...(input.honeypotEnabled !== undefined ? { honeypotEnabled: input.honeypotEnabled } : {}),
...(input.fastSubmitEnabled !== undefined ? { fastSubmitEnabled: input.fastSubmitEnabled } : {}),
...(input.lostAutoDeleteDays !== undefined ? { lostAutoDeleteDays: Math.max(0, input.lostAutoDeleteDays) } : {}),
},
});
});
return row as LeadAntiSpamConfigRow;
}
export async function getLeadStatistics(input: { tenantId: string; ctx: LeadVisibilityContext }): Promise<{
total: number;
new: number;
converted: number;
spam: number;
byQuelle: { quelle: LeadQuelle; count: number }[];
byStatus: { status: LeadStatus; count: number }[];
}> {
return withTenantRLS(input.tenantId, async (tx) => {
const where = { tenantId: input.tenantId, archivedAt: null, ...ownerScopeWhere(input.ctx) };
const [total, neu, converted, spam, byQuelleRaw, byStatusRaw] = await Promise.all([
tx.inboxLead.count({ where }),
tx.inboxLead.count({ where: { ...where, status: "new" } }),
tx.inboxLead.count({ where: { ...where, status: "converted" } }),
tx.inboxLead.count({ where: { ...where, status: "spam" } }),
tx.inboxLead.groupBy({ by: ["quelle"], where, _count: { _all: true } }),
tx.inboxLead.groupBy({ by: ["status"], where, _count: { _all: true } }),
]);
return {
total,
new: neu,
converted,
spam,
byQuelle: byQuelleRaw.map((row) => ({ quelle: row.quelle as LeadQuelle, count: row._count._all })),
byStatus: byStatusRaw.map((row) => ({ status: row.status as LeadStatus, count: row._count._all })),
};
});
}