← back to Rentv Adintel

lib/entity-resolution.js

267 lines

'use strict';
/**
 * Entity resolution — deterministic + scored candidate matching (spec §12).
 *
 * Rules (in order of priority):
 *  1. Exact normalized domain
 *  2. Exact public email domain
 *  3. Exact normalized name + city + state
 *  4. Exact LinkedIn company URL (from permitted source only)
 *
 * Candidate scoring: 0-1 float.
 * Thresholds: >=0.97 = auto; 0.82-0.9699 = review; <0.82 = no.
 * Merges are REVERSIBLE via merge_audit snapshot.
 */

const { normalizeName } = require('./types');

/**
 * Run deterministic matching rules against an existing org record.
 *
 * @param {{ domain?: string, emailDomain?: string, normalizedName?: string, city?: string, state?: string, linkedinUrl?: string }} candidate
 * @param {{ domain?: string, emailDomain?: string, normalized_name?: string, headquarters_city?: string, headquarters_state?: string, linkedin_url?: string }} existing  - one row from organizations table
 * @returns {{ matched: boolean, rule: string | null, confidence: number }}
 */
function deterministicMatch(candidate, existing) {
  // Rule 1: exact normalized domain (both non-null)
  if (candidate.domain && existing.domain) {
    const cd = String(candidate.domain).toLowerCase().replace(/^www\./, '');
    const ed = String(existing.domain).toLowerCase().replace(/^www\./, '');
    if (cd === ed) {
      return { matched: true, rule: 'EXACT_DOMAIN', confidence: 1.0 };
    }
  }

  // Rule 2: exact public email domain
  if (candidate.emailDomain && existing.emailDomain) {
    const ce = String(candidate.emailDomain).toLowerCase();
    const ee = String(existing.emailDomain).toLowerCase();
    if (ce === ee) {
      return { matched: true, rule: 'EXACT_EMAIL_DOMAIN', confidence: 0.98 };
    }
  }

  // Rule 3: exact normalized name + city + state
  if (candidate.normalizedName && existing.normalized_name) {
    const cn = normalizeName(candidate.normalizedName);
    const en = String(existing.normalized_name).trim();
    const cityMatch =
      !candidate.city ||
      !existing.headquarters_city ||
      String(candidate.city).toLowerCase() === String(existing.headquarters_city).toLowerCase();
    const stateMatch =
      !candidate.state ||
      !existing.headquarters_state ||
      String(candidate.state).toUpperCase() === String(existing.headquarters_state).toUpperCase();
    if (cn === en && cityMatch && stateMatch) {
      return { matched: true, rule: 'EXACT_NAME_CITY_STATE', confidence: 0.97 };
    }
  }

  // Rule 4: exact LinkedIn company URL (must come from a permitted discovery source)
  if (candidate.linkedinUrl && existing.linkedin_url) {
    const normalize = (u) =>
      String(u)
        .toLowerCase()
        .replace(/\/$/, '')
        .replace(/^https?:\/\/(www\.)?/, '');
    if (normalize(candidate.linkedinUrl) === normalize(existing.linkedin_url)) {
      return { matched: true, rule: 'EXACT_LINKEDIN_URL', confidence: 0.99 };
    }
  }

  return { matched: false, rule: null, confidence: 0 };
}

/**
 * Score a candidate pair (a, b) on a 0-1 scale using overlapping signals.
 * Intended for fuzzy / probabilistic candidate generation (not deterministic).
 *
 * @param {Object} a
 * @param {Object} b
 * @returns {number} 0-1 float
 */
function scoreCandidatePair(a, b) {
  let score = 0;
  let signals = 0;

  // Name similarity (Jaccard on trigrams, simplified)
  const na = normalizeName(a.display_name || a.legal_name || '');
  const nb = normalizeName(b.display_name || b.legal_name || '');
  if (na && nb) {
    const nameSim = jaccardTrigram(na, nb);
    score += nameSim * 0.40;
    signals++;
  }

  // Domain match
  if (a.domain && b.domain) {
    const da = String(a.domain).toLowerCase().replace(/^www\./, '');
    const db = String(b.domain).toLowerCase().replace(/^www\./, '');
    score += (da === db ? 1.0 : 0) * 0.30;
    signals++;
  }

  // State match
  if (a.headquarters_state && b.headquarters_state) {
    const sa = String(a.headquarters_state).toUpperCase();
    const sb = String(b.headquarters_state).toUpperCase();
    score += (sa === sb ? 1.0 : 0) * 0.10;
    signals++;
  }

  // City match
  if (a.headquarters_city && b.headquarters_city) {
    const ca = String(a.headquarters_city).toLowerCase().trim();
    const cb = String(b.headquarters_city).toLowerCase().trim();
    score += (ca === cb ? 1.0 : 0) * 0.10;
    signals++;
  }

  // Category overlap
  if (Array.isArray(a.advertiser_categories) && Array.isArray(b.advertiser_categories)) {
    const setA = new Set(a.advertiser_categories);
    const intersection = (b.advertiser_categories || []).filter((c) => setA.has(c));
    const union = new Set([...a.advertiser_categories, ...(b.advertiser_categories || [])]);
    const catSim = union.size > 0 ? intersection.length / union.size : 0;
    score += catSim * 0.10;
    signals++;
  }

  return signals > 0 ? Math.min(1, score) : 0;
}

/** Jaccard similarity on character trigrams of two strings. */
function jaccardTrigram(a, b) {
  const trigrams = (s) => {
    const set = new Set();
    for (let i = 0; i < s.length - 2; i++) set.add(s.slice(i, i + 3));
    return set;
  };
  const ta = trigrams(a);
  const tb = trigrams(b);
  if (ta.size === 0 && tb.size === 0) return 1;
  if (ta.size === 0 || tb.size === 0) return 0;
  let inter = 0;
  for (const t of ta) if (tb.has(t)) inter++;
  return inter / (ta.size + tb.size - inter);
}

/**
 * Classify a candidate pair score into a merge decision.
 *
 * @param {number} score 0-1
 * @returns {'auto' | 'review' | 'no'}
 */
function classifyMerge(score) {
  if (score >= 0.97) return 'auto';
  if (score >= 0.82) return 'review';
  return 'no';
}

/**
 * Perform a reversible merge of mergedId into keptId.
 *
 * Steps:
 *  1. Snapshot the merged org row into merge_audit.
 *  2. Update FK references (organization_id) in child tables.
 *  3. Delete the merged org row.
 *
 * The merge is fully reversible by reading merge_audit.snapshot.
 *
 * @param {Object} pool  - pg Pool instance
 * @param {string} keptId    - UUID of the org to keep
 * @param {string} mergedId  - UUID of the org to merge (will be deleted)
 * @returns {Promise<{merge_audit_id: string}>}
 */
async function reversibleMerge(pool, keptId, mergedId) {
  if (keptId === mergedId) throw new Error('Cannot merge an org into itself.');

  const client = await pool.connect();
  try {
    await client.query('BEGIN');

    // 1. Snapshot the merged row
    const snap = await client.query(
      `SELECT row_to_json(o) AS snapshot FROM organizations o WHERE id=$1`,
      [mergedId]
    );
    if (snap.rows.length === 0) {
      throw new Error(`Organization to merge (id=${mergedId}) not found.`);
    }
    const snapshot = snap.rows[0].snapshot;

    const auditRes = await client.query(
      `INSERT INTO merge_audit (kept_id, merged_id, entity_table, snapshot)
       VALUES ($1,$2,'organizations',$3::jsonb)
       RETURNING id`,
      [keptId, mergedId, JSON.stringify(snapshot)]
    );
    const merge_audit_id = auditRes.rows[0].id;

    // 2. Re-parent child rows — tables with organization_id FK
    const CHILD_TABLES = [
      'ad_sightings',
      'campaigns',
      'contact_points',
      'creative_assets',
      'event_relationships',
      'notes',
      'opportunities',
      'opportunity_scores',
      'organization_markets',
      'organization_tags',
      'suppression_requests',
      'tasks',
    ];

    for (const tbl of CHILD_TABLES) {
      // organization_tags and organization_markets have composite PKs — handle ON CONFLICT
      if (tbl === 'organization_tags') {
        await client.query(
          `UPDATE organization_tags SET organization_id=$1 WHERE organization_id=$2`,
          [keptId, mergedId]
        );
        // Remove any duplicates that arose from the re-parent
        await client.query(
          `DELETE FROM organization_tags a USING organization_tags b
           WHERE a.ctid > b.ctid AND a.organization_id=b.organization_id AND a.tag_id=b.tag_id`
        );
      } else if (tbl === 'organization_markets') {
        await client.query(
          `UPDATE organization_markets SET organization_id=$1 WHERE organization_id=$2`,
          [keptId, mergedId]
        );
        await client.query(
          `DELETE FROM organization_markets a USING organization_markets b
           WHERE a.ctid > b.ctid AND a.organization_id=b.organization_id
             AND a.market_id=b.market_id AND a.relationship_type=b.relationship_type`
        );
      } else {
        await client.query(
          `UPDATE ${tbl} SET organization_id=$1 WHERE organization_id=$2`,
          [keptId, mergedId]
        );
      }
    }

    // 3. Delete the merged org (all remaining FKs should be cleared now)
    await client.query(`DELETE FROM organizations WHERE id=$1`, [mergedId]);

    await client.query('COMMIT');
    return { merge_audit_id };
  } catch (e) {
    await client.query('ROLLBACK');
    throw e;
  } finally {
    client.release();
  }
}

module.exports = {
  deterministicMatch,
  scoreCandidatePair,
  classifyMerge,
  reversibleMerge,
};