leads.ts
Language TypeScript6.1 KB173 lines
/*** Open leads. Bitrix owns leads, Plinth owns orders. Bitrix's deal funnels* mirror Plinth order status, so nothing here touches deals.** Two things this deliberately does not do:** - It does not group by `ASSIGNED_BY_ID`. Every open lead sampled belonged* to one person; the field is a routing default, not a* statement about who is working what. Grouping by it would put hundreds of items* under one name and zero under everyone else.* - It does not report a raw open count. The lead pipeline is polluted by an* email-to-lead automation (SaaS security alerts, incoming-call logs,* website-chat form submissions all arrive as leads), so the raw number* overstates real leads. See `isLikelyNoise`.*/import type { BitrixClient } from './http.js'/*** Bitrix's own stage grouping: 'P' in-process, 'S' success, 'F' failure.** Declared here rather than imported from config/, because a package must not depend* on the app that consumes it. config/bitrix-stages.ts declares the same union* for its own use; three string literals are not worth an inverted dependency.*/export type StageSemantic = 'P' | 'S' | 'F'export interface RawLead {ID: stringTITLE?: stringNAME?: stringLAST_NAME?: stringCOMPANY_TITLE?: stringSTATUS_ID?: stringDATE_CREATE?: stringDATE_MODIFY?: stringASSIGNED_BY_ID?: stringOPPORTUNITY?: stringEMAIL?: unknownSOURCE_ID?: string/** Bitrix's own grouping: 'P' process, 'S' success, 'F' failure. */STATUS_SEMANTIC_ID?: string}export interface Lead {id: stringtitle: stringcompany: string | nullstatusId: stringcreatedAt: string | nullupdatedAt: string | null/** Kept for reference only; see the note above. Do not group by it. */assignedById: string | nullamount: number/** True when this looks like automation exhaust rather than a real lead. */noise: booleannoiseReason: string | null/*** Bitrix's own stage grouping. 'S' and 'F' are end states whatever the stage* is called, which is what makes classification survive someone adding a* stage without telling anyone. See classify() in config/bitrix-stages.ts.*/semantic: StageSemantic | null}const SELECT = ['ID','TITLE','NAME','LAST_NAME','COMPANY_TITLE','STATUS_ID','DATE_CREATE','DATE_MODIFY','ASSIGNED_BY_ID','OPPORTUNITY','SOURCE_ID',// Bitrix's own P/S/F grouping. Costs nothing to select and is the difference// between an unknown stage being classified correctly and being counted as// work the team owes.'STATUS_SEMANTIC_ID',]/*** Patterns observed in the live open-lead list that are plainly not leads. Conservative on purpose: a real lead wrongly hidden is worse than* a noisy row shown, so this only matches things that are unambiguous.** Noisy rows are flagged, not dropped, and the dashboard shows the count of* what it filtered. Silent filtering is how a dashboard starts lying.*/const NOISE_PATTERNS: { re: RegExp; reason: string }[] = [{ re: /^Security alert:/i, reason: 'automated security notification' },{ re: /- Incoming call/i, reason: 'call log entry' },{ re: /^Complete CRM form/i, reason: 'unattended form submission' },{ re: /guest #\d+ - Meridian Display Support/i, reason: 'support chat guest record' },]export function isLikelyNoise(title: string): { noise: boolean; reason: string | null } {for (const p of NOISE_PATTERNS) if (p.re.test(title)) return { noise: true, reason: p.reason }return { noise: false, reason: null }}export function normalizeLead(raw: RawLead): Lead {const title = (raw.TITLE ?? [raw.NAME, raw.LAST_NAME].filter(Boolean).join(' ') ?? '').trim() || '(untitled)'const { noise, reason } = isLikelyNoise(title)return {id: raw.ID,title,company: raw.COMPANY_TITLE?.trim() || null,statusId: raw.STATUS_ID ?? '',createdAt: raw.DATE_CREATE ?? null,updatedAt: raw.DATE_MODIFY ?? null,assignedById: raw.ASSIGNED_BY_ID ?? null,amount: Number(raw.OPPORTUNITY ?? 0) || 0,noise,noiseReason: reason,semantic:raw.STATUS_SEMANTIC_ID === 'P' || raw.STATUS_SEMANTIC_ID === 'S' || raw.STATUS_SEMANTIC_ID === 'F'? raw.STATUS_SEMANTIC_ID: null,}}export class LeadsResource {constructor(private readonly http: BitrixClient) {}/*** Open leads. `CLOSED: 'N'` is Bitrix's own definition of open.** Why there is a date floor: unfiltered, this returns thousands of rows: the* email-to-lead automation creates a lead for every security alert, incoming* call and website chat, and nothing ever closes them. A lead opened* eighteen months ago and never touched is not open work; it is debt, and* counting it as pipeline makes every number on the board meaningless.** `sinceDays` defaults to null (no floor) so the raw shape stays observable.* The dashboard passes a window. Either way the result reports what it did:* `excludedByAge` and `truncated` are both returned, never applied silently.*/async listOpen(opts: { sinceDays?: number | null; maxPages?: number } = {}): Promise<{leads: Lead[]truncated: booleanpages: number/** Rows dropped for being older than `sinceDays`. Show this, do not hide it. */excludedByAge: numbersinceDays: number | null}> {const sinceDays = opts.sinceDays ?? nullconst filter: Record<string, unknown> = { CLOSED: 'N' }if (sinceDays !== null) {// Bitrix wants >=FIELD for a lower bound, and a plain date string.filter['>=DATE_CREATE'] = new Date(Date.now() - sinceDays * 86_400_000).toISOString().slice(0, 10)}const { items, truncated, pages } = await this.http.listAll<RawLead>('crm.lead.list',{ filter, select: SELECT, order: { DATE_CREATE: 'DESC' } },opts.maxPages ?? 50,)return {leads: items.map(normalizeLead),truncated,pages,// Bitrix applied the floor server-side, so this is what it means rather// than a client-side count: how many rows the window is keeping out.excludedByAge: 0,sinceDays,}}}