import type { CallClickPayload, LeadPayload } from '../../shared/lead.ts' import { AmoClient, type AmoContact, type AmoFieldEntry } from './amocrm.ts' import { buildCallClickName, buildLeadName, mapCallClick, mapLead, resolveLeadFieldMap, type LeadFieldMap, } from './lead-mapper.ts' const digitsOnly = (value: string) => value.replace(/\D/g, '') /** * Last ten digits — the part that is stable across "+7 (999) 123-45-67", * "8 999 1234567" and "9991234567". Used to decide whether amoCRM already * knows a phone number. */ const phoneKey = (value: string) => digitsOnly(value).slice(-10) /** * amoCRM's full-text search matches digit substrings, but a contact stored as * "+7 999…" is not found by a query starting with "8 999…". Try every spelling * a Russian visitor might type. */ function phoneQueries(phone: string): string[] { const digits = digitsOnly(phone) const queries = new Set([phone.trim(), digits]) if (digits.length >= 10) { const national = digits.slice(-10) queries.add(national) queries.add(`7${national}`) queries.add(`8${national}`) } return [...queries].filter(Boolean) } /** Reads the values already stored in a contact's standard multitext field. */ function existingValues(contact: AmoContact, code: 'PHONE' | 'EMAIL'): string[] { const entry = contact.custom_fields_values?.find((f) => f.field_code === code) return (entry?.values ?? []).map((v) => String(v.value ?? '')).filter(Boolean) } function contactFieldEntries(payload: LeadPayload): AmoFieldEntry[] { const entries: AmoFieldEntry[] = [ { field_code: 'PHONE', values: [{ value: payload.phone, enum_code: 'WORK' }] }, ] if (payload.email) { entries.push({ field_code: 'EMAIL', values: [{ value: payload.email, enum_code: 'WORK' }] }) } return entries } export interface CreatedLead { leadId: number contactId: number contactCreated: boolean } export class LeadService { private fieldMap?: Promise constructor(private readonly amo: AmoClient) {} private getFieldMap(): Promise { this.fieldMap ??= this.amo.getLeadFields().then(resolveLeadFieldMap) return this.fieldMap } async submit(payload: LeadPayload): Promise { const fieldMap = await this.getFieldMap() // 1. Reuse the existing contact when the phone or email is already known. const existing = await this.amo.findContact([...phoneQueries(payload.phone), payload.email ?? '']) let contactId: number let contactCreated = false if (existing) { contactId = existing.id // Add whichever channel amoCRM does not have on file yet. const missing: AmoFieldEntry[] = [] const knownPhones = existingValues(existing, 'PHONE').map(phoneKey) if (!knownPhones.includes(phoneKey(payload.phone))) { missing.push({ field_code: 'PHONE', values: [ ...existingValues(existing, 'PHONE').map((value) => ({ value })), { value: payload.phone, enum_code: 'WORK' }, ], }) } if (payload.email) { const knownEmails = existingValues(existing, 'EMAIL').map((e) => e.toLowerCase()) if (!knownEmails.includes(payload.email.toLowerCase())) { missing.push({ field_code: 'EMAIL', values: [ ...existingValues(existing, 'EMAIL').map((value) => ({ value })), { value: payload.email, enum_code: 'WORK' }, ], }) } } await this.amo.appendContactFields(contactId, missing) } else { const created = await this.amo.createContact({ name: payload.name, fields: contactFieldEntries(payload) }) contactId = created.id contactCreated = true } // 2. Lead in the configured pipeline, first stage. const { fields, note } = mapLead(payload, fieldMap) const lead = await this.amo.createLead({ name: buildLeadName(payload), contactId, fields, tags: [payload.form === 'callback' ? 'обратный звонок' : 'заявка с сайта'], }) // 3. Everything the account has no field for lands in a readable note. // A failed note must not fail the lead — the lead is the valuable part. if (note) { await this.amo.addLeadNote(lead.id, note).catch((error: unknown) => { console.warn('[amo] lead %d created but the note failed:', lead.id, error) }) } return { leadId: lead.id, contactId, contactCreated } } /** * A visitor tapped the phone button. There is no contact to reuse or create — * only the click itself — so the lead stands alone, and its tag and note are * what tell a manager to expect an incoming call from this landing. */ async submitCallClick(payload: CallClickPayload): Promise<{ leadId: number }> { const fieldMap = await this.getFieldMap() const { fields, note } = mapCallClick(payload, fieldMap) const lead = await this.amo.createLead({ name: buildCallClickName(), fields, tags: ['клик по телефону'], }) // As above: a failed note must not fail the lead. if (note) { await this.amo.addLeadNote(lead.id, note).catch((error: unknown) => { console.warn('[amo] lead %d created but the note failed:', lead.id, error) }) } return { leadId: lead.id } } }