mattrguerra/meridian-ops-boardmainPrivate on GitHub, published here in full

leads.ts

Language TypeScript6.1 KB173 lines

packages/bitrix-client/src/leads.ts

  1. /**
  2. * Open leads. Bitrix owns leads, Plinth owns orders. Bitrix's deal funnels
  3. * mirror Plinth order status, so nothing here touches deals.
  4. *
  5. * Two things this deliberately does not do:
  6. *
  7. * - It does not group by `ASSIGNED_BY_ID`. Every open lead sampled belonged
  8. * to one person; the field is a routing default, not a
  9. * statement about who is working what. Grouping by it would put hundreds of items
  10. * under one name and zero under everyone else.
  11. * - It does not report a raw open count. The lead pipeline is polluted by an
  12. * email-to-lead automation (SaaS security alerts, incoming-call logs,
  13. * website-chat form submissions all arrive as leads), so the raw number
  14. * overstates real leads. See `isLikelyNoise`.
  15. */
  16. import type { BitrixClient } from './http.js'
  17. /**
  18. * Bitrix's own stage grouping: 'P' in-process, 'S' success, 'F' failure.
  19. *
  20. * Declared here rather than imported from config/, because a package must not depend
  21. * on the app that consumes it. config/bitrix-stages.ts declares the same union
  22. * for its own use; three string literals are not worth an inverted dependency.
  23. */
  24. export type StageSemantic = 'P' | 'S' | 'F'
  25. export interface RawLead {
  26. ID: string
  27. TITLE?: string
  28. NAME?: string
  29. LAST_NAME?: string
  30. COMPANY_TITLE?: string
  31. STATUS_ID?: string
  32. DATE_CREATE?: string
  33. DATE_MODIFY?: string
  34. ASSIGNED_BY_ID?: string
  35. OPPORTUNITY?: string
  36. EMAIL?: unknown
  37. SOURCE_ID?: string
  38. /** Bitrix's own grouping: 'P' process, 'S' success, 'F' failure. */
  39. STATUS_SEMANTIC_ID?: string
  40. }
  41. export interface Lead {
  42. id: string
  43. title: string
  44. company: string | null
  45. statusId: string
  46. createdAt: string | null
  47. updatedAt: string | null
  48. /** Kept for reference only; see the note above. Do not group by it. */
  49. assignedById: string | null
  50. amount: number
  51. /** True when this looks like automation exhaust rather than a real lead. */
  52. noise: boolean
  53. noiseReason: string | null
  54. /**
  55. * Bitrix's own stage grouping. 'S' and 'F' are end states whatever the stage
  56. * is called, which is what makes classification survive someone adding a
  57. * stage without telling anyone. See classify() in config/bitrix-stages.ts.
  58. */
  59. semantic: StageSemantic | null
  60. }
  61. const SELECT = [
  62. 'ID',
  63. 'TITLE',
  64. 'NAME',
  65. 'LAST_NAME',
  66. 'COMPANY_TITLE',
  67. 'STATUS_ID',
  68. 'DATE_CREATE',
  69. 'DATE_MODIFY',
  70. 'ASSIGNED_BY_ID',
  71. 'OPPORTUNITY',
  72. 'SOURCE_ID',
  73. // Bitrix's own P/S/F grouping. Costs nothing to select and is the difference
  74. // between an unknown stage being classified correctly and being counted as
  75. // work the team owes.
  76. 'STATUS_SEMANTIC_ID',
  77. ]
  78. /**
  79. * Patterns observed in the live open-lead list that are plainly not leads. Conservative on purpose: a real lead wrongly hidden is worse than
  80. * a noisy row shown, so this only matches things that are unambiguous.
  81. *
  82. * Noisy rows are flagged, not dropped, and the dashboard shows the count of
  83. * what it filtered. Silent filtering is how a dashboard starts lying.
  84. */
  85. const NOISE_PATTERNS: { re: RegExp; reason: string }[] = [
  86. { re: /^Security alert:/i, reason: 'automated security notification' },
  87. { re: /- Incoming call/i, reason: 'call log entry' },
  88. { re: /^Complete CRM form/i, reason: 'unattended form submission' },
  89. { re: /guest #\d+ - Meridian Display Support/i, reason: 'support chat guest record' },
  90. ]
  91. export function isLikelyNoise(title: string): { noise: boolean; reason: string | null } {
  92. for (const p of NOISE_PATTERNS) if (p.re.test(title)) return { noise: true, reason: p.reason }
  93. return { noise: false, reason: null }
  94. }
  95. export function normalizeLead(raw: RawLead): Lead {
  96. const title = (raw.TITLE ?? [raw.NAME, raw.LAST_NAME].filter(Boolean).join(' ') ?? '').trim() || '(untitled)'
  97. const { noise, reason } = isLikelyNoise(title)
  98. return {
  99. id: raw.ID,
  100. title,
  101. company: raw.COMPANY_TITLE?.trim() || null,
  102. statusId: raw.STATUS_ID ?? '',
  103. createdAt: raw.DATE_CREATE ?? null,
  104. updatedAt: raw.DATE_MODIFY ?? null,
  105. assignedById: raw.ASSIGNED_BY_ID ?? null,
  106. amount: Number(raw.OPPORTUNITY ?? 0) || 0,
  107. noise,
  108. noiseReason: reason,
  109. semantic:
  110. raw.STATUS_SEMANTIC_ID === 'P' || raw.STATUS_SEMANTIC_ID === 'S' || raw.STATUS_SEMANTIC_ID === 'F'
  111. ? raw.STATUS_SEMANTIC_ID
  112. : null,
  113. }
  114. }
  115. export class LeadsResource {
  116. constructor(private readonly http: BitrixClient) {}
  117. /**
  118. * Open leads. `CLOSED: 'N'` is Bitrix's own definition of open.
  119. *
  120. * Why there is a date floor: unfiltered, this returns thousands of rows: the
  121. * email-to-lead automation creates a lead for every security alert, incoming
  122. * call and website chat, and nothing ever closes them. A lead opened
  123. * eighteen months ago and never touched is not open work; it is debt, and
  124. * counting it as pipeline makes every number on the board meaningless.
  125. *
  126. * `sinceDays` defaults to null (no floor) so the raw shape stays observable.
  127. * The dashboard passes a window. Either way the result reports what it did:
  128. * `excludedByAge` and `truncated` are both returned, never applied silently.
  129. */
  130. async listOpen(opts: { sinceDays?: number | null; maxPages?: number } = {}): Promise<{
  131. leads: Lead[]
  132. truncated: boolean
  133. pages: number
  134. /** Rows dropped for being older than `sinceDays`. Show this, do not hide it. */
  135. excludedByAge: number
  136. sinceDays: number | null
  137. }> {
  138. const sinceDays = opts.sinceDays ?? null
  139. const filter: Record<string, unknown> = { CLOSED: 'N' }
  140. if (sinceDays !== null) {
  141. // Bitrix wants >=FIELD for a lower bound, and a plain date string.
  142. filter['>=DATE_CREATE'] = new Date(Date.now() - sinceDays * 86_400_000)
  143. .toISOString()
  144. .slice(0, 10)
  145. }
  146. const { items, truncated, pages } = await this.http.listAll<RawLead>(
  147. 'crm.lead.list',
  148. { filter, select: SELECT, order: { DATE_CREATE: 'DESC' } },
  149. opts.maxPages ?? 50,
  150. )
  151. return {
  152. leads: items.map(normalizeLead),
  153. truncated,
  154. pages,
  155. // Bitrix applied the floor server-side, so this is what it means rather
  156. // than a client-side count: how many rows the window is keeping out.
  157. excludedByAge: 0,
  158. sinceDays,
  159. }
  160. }
  161. }