926 lines
36 KiB
TypeScript
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 })),
|
|
};
|
|
});
|
|
}
|