Integrations
Intercom Integration
Audit Script

Audit Script

The audit script is a one-time utility for enriching existing Intercom contacts with member data, converting leads to users, cleaning up invalid UUIDs, and removing duplicate contacts.

Overview

Purpose: One-time enrichment and cleanup of existing Intercom contacts

Script: /sync/intercom/audit.js

Use Cases:

  • Initial setup and data migration
  • Cleanup of invalid UUIDs from old WordPress integration
  • Lead conversion to users
  • Duplicate contact resolution
  • Archive stale contacts with no member match

Log File: /tmp/intercom_audit.log (JSON format)

Key Features

1. Contact Enrichment

  • Lookup member data by external_id (member number)
  • Lookup member data by email
  • Lookup member data by legacy "Member Number" custom attribute
  • Update contacts with member information (region, facility, positions)

2. Lead Conversion

  • Convert Intercom leads to users when matched to member database
  • Preserves conversation history
  • Updates role from lead to user

3. UUID Cleanup

  • Identifies invalid UUID external_ids (from old WordPress integration)
  • Replaces UUIDs with actual member numbers
  • Validates member matches before updating

4. Duplicate Resolution

  • Finds all contacts with same email
  • Keeps newest contact (by created_at)
  • Archives older duplicates
  • Logs all duplicate actions

5. Stale Contact Archival

  • Archives contacts with no open conversations/tickets and no member match
  • Reduces contact count (Intercom pricing)
  • Preserves contacts with active support needs

Command Line Usage

Basic Usage

# Process all contacts
node sync/intercom/audit.js
 
# Preview changes without updating (recommended first run)
node sync/intercom/audit.js --dry-run
 
# Test with limited contacts
node sync/intercom/audit.js --limit=10
 
# Preview with limited contacts
node sync/intercom/audit.js --dry-run --limit=10

Advanced Options

# Process only leads (convert to users when matched)
node sync/intercom/audit.js --leads-only
 
# Preview lead cleanup
node sync/intercom/audit.js --leads-only --dry-run
 
# Re-enrich contacts that already have member_number
node sync/intercom/audit.js --force
 
# Process a single contact
node sync/intercom/audit.js --contact-id=67eb0a6e8f72a828ba21c342
 
# Custom batch size (default: 50)
node sync/intercom/audit.js --batch-size=100

Option Reference

OptionDescriptionDefault
--dry-runPreview changes without updating Intercomfalse
--limit=NProcess only N contactsnull (all)
--batch-size=NProcess N contacts per batch50
--forceRe-enrich contacts with existing external_idfalse
--leads-onlyProcess only contacts with role=leadfalse
--contact-id=IDProcess single contact by IDnull

Process Flow

1. Fetch Contacts from Intercom

// Fetch all contacts (or filter by role)
for await (const contact of intercomClient.getAllContacts()) {
  // Skip if leadsOnly and not a lead
  if (leadsOnly && contact.role !== 'lead') {
    continue;
  }
 
  contactsToProcess.push(contact);
 
  // Stop if we've hit the limit
  if (limit && contactCount >= limit) {
    break;
  }
 
  // Process in batches
  if (contactsToProcess.length >= batchSize) {
    await processBatch(connection, contactsToProcess);
    contactsToProcess.length = 0;
  }
}

2. Check for Duplicate Contacts

FIRST step for each contact (before any other checks):

if (contact.email) {
  const allContactsWithEmail = await intercomClient.searchContactsByEmail(contact.email);
 
  if (allContactsWithEmail.length > 1) {
    // Sort by created_at (newest first)
    allContactsWithEmail.sort((a, b) => b.created_at - a.created_at);
 
    const newestContact = allContactsWithEmail[0];
    const duplicates = allContactsWithEmail.slice(1);
 
    // If current contact is not the newest, archive it
    if (contact.id !== newestContact.id) {
      await intercomClient.archiveContact(contact.id);
      return { status: 'archived', reason: 'duplicate' };
    }
 
    // Current contact IS the newest - archive all older duplicates
    for (const duplicate of duplicates) {
      await intercomClient.archiveContact(duplicate.id);
    }
  }
}

3. Check If Already Enriched

// Skip unless --force flag is used
// BUT: Don't skip if external_id is a UUID (those need cleanup)
if (contact.external_id && !force && !isUUID(contact.external_id)) {
  logger.info('Skipping contact - already has external_id');
  stats.skipped++;
  return { status: 'skipped', reason: 'has_external_id' };
}
 
// If external_id is a UUID, treat it as needing cleanup
if (isUUID(contact.external_id)) {
  logger.info('Contact has UUID external_id - will attempt cleanup');
}

4. Member Lookup (Priority Order)

A. Lookup by external_id (Validated)

// 1. Try to lookup by external_id first, but VALIDATE before trusting
// Skip if external_id is a UUID (won't match any member)
if (contact.external_id && !isUUID(contact.external_id)) {
  const memberByExternalId = await lookupMemberByNumber(connection, contact.external_id);
 
  if (memberByExternalId) {
    // Validate that this member actually matches the contact data
    const validation = await validateMemberMatch(connection, contact, memberByExternalId, true);
 
    if (validation.isValid) {
      member = memberByExternalId;
      lookupMethod = 'external_id_validated';
    } else {
      logger.warn('external_id found member but validation failed - trying other methods');
      // Don't use this member, try other lookup methods
    }
  }
}

Validation Rules for external_id:

  • If name matches AND 1+ other fields match → Accept
  • If email domain matches (alternate email) → Accept
  • If contact has ONLY external_id (no email/name) → Accept (authenticated source)
  • Otherwise → Reject, try other lookup methods

B. Lookup by Email

// 2. If external_id didn't validate or not found, try email
if (!member && contact.email) {
  member = await lookupMemberByEmail(connection, contact.email);
  if (member) {
    lookupMethod = 'email';
 
    // If we found by email but contact has wrong external_id, mark for correction
    if (contact.external_id && contact.external_id !== member.membernumber.toString()) {
      correctedExternalId = true;
      logger.warn(`Correcting external_id: ${contact.external_id} -> ${member.membernumber}`);
    }
  }
}

C. Lookup by Legacy "Member Number"

// 3. If not found by email, try legacy "Member Number" custom attribute
if (!member && contact.custom_attributes?.['Member Number']) {
  const legacyMemberNumber = contact.custom_attributes['Member Number'];
  member = await lookupMemberByNumber(connection, legacyMemberNumber);
  if (member) {
    lookupMethod = 'legacy_member_number';
 
    // If we found by legacy but contact has wrong external_id, mark for correction
    if (contact.external_id && contact.external_id !== member.membernumber.toString()) {
      correctedExternalId = true;
    }
  }
}

5. Handle No Member Match

if (!member) {
  // Clear external_id if present to prevent future bad matches
  if (contact.external_id) {
    await intercomClient.updateContact(contact.id, {}, { external_id: '' });
    logger.info('Cleared invalid external_id');
  }
 
  // Check if contact has any OPEN conversations or tickets
  const [openConversationCount, openTicketCount] = await Promise.all([
    intercomClient.searchContactOpenConversations(contact.id),
    intercomClient.searchContactOpenTickets(contact.id)
  ]);
 
  if (openConversationCount === 0 && openTicketCount === 0) {
    // Archive contact with no open conversations, no open tickets, and no member match
    await intercomClient.archiveContact(contact.id);
    logger.info('Archived contact - no open conversations/tickets and no member match');
    return { status: 'archived', reason: 'no_open_conversations' };
  }
 
  logger.info('Skipping contact - no match but has open conversations/tickets');
  return { status: 'skipped', reason: 'no_match' };
}

6. Update Contact with Member Data

// Prepare built-in fields
const builtInFields = {
  name: `${member.firstname} ${member.lastname}`,
  external_id: member.membernumber.toString(),
  role: 'user'  // Convert leads to users when enriching
};
 
// Set email if contact doesn't have one
if (!contact.email && member.email) {
  builtInFields.email = member.email;
}
 
// Format phone to E.164
const formattedPhone = formatPhoneForIntercom(member.phone);
if (formattedPhone) {
  builtInFields.phone = formattedPhone;
}
 
// Determine region/facility (use "RNAV" for Retired members)
const region = member.status === 'Retired' ? 'RNAV' : (member.region || '');
const facility = member.status === 'Retired' ? 'RNAV' : (member.facility || '');
 
// Determine member type
const memberType = getMemberTypeForIntercom(member.status, member.membertypeid);
 
// Track if this is a lead conversion
const isLeadConversion = contact.role === 'lead';
 
// Update contact
await intercomClient.updateContact(
  contact.id,
  {
    member_type: memberType,
    region: region,
    facility: facility,
    positions: member.positions.join(', ')
  },
  builtInFields
);
 
if (isLeadConversion) {
  stats.convertedLeads++;
}

7. Handle Update Errors

Unique Constraint Error (external_id already exists)

try {
  await intercomClient.updateContact(id, attrs, { external_id: memberNumber });
} catch (error) {
  if (error.message.includes('unique_user_constraint')) {
    // Find the contact that already has this external_id
    const existingContact = await intercomClient.searchContactByExternalId(memberNumber);
 
    if (existingContact && existingContact.id !== contact.id) {
      // Determine which contact to keep based on created_at (keep newer)
      const currentContactCreated = contact.created_at || 0;
      const existingContactCreated = existingContact.created_at || 0;
 
      if (currentContactCreated > existingContactCreated) {
        // Current contact is newer - archive the existing one
        await intercomClient.archiveContact(existingContact.id);
        // Retry the update
        await intercomClient.updateContact(contact.id, customAttrs, builtInFields);
      } else {
        // Existing contact is newer - archive current contact
        await intercomClient.archiveContact(contact.id);
        return { status: 'archived', reason: 'duplicate_external_id' };
      }
    }
  }
}

Validation Logic

Member Match Validation

The audit script validates member matches to prevent incorrect associations:

async function validateMemberMatch(connection, contact, member, isExternalIdValidation = false) {
  let matchCount = 0;
  const matchDetails = {};
 
  // 1. Email match (exact, case-insensitive)
  // For external_id validation, check ALL member emails (not just primary)
  if (contact.email && member.email) {
    let emailMatch = contact.email.toLowerCase() === member.email.toLowerCase();
 
    // If primary doesn't match but this is external_id validation, check alternate emails
    if (!emailMatch && isExternalIdValidation) {
      emailMatch = await checkMemberHasEmail(connection, member.membernumber, contact.email);
    }
 
    matchDetails.email = emailMatch;
    if (emailMatch) matchCount++;
  }
 
  // 2. Facility match (exact, case-insensitive)
  if (contact.custom_attributes?.facility && member.facility) {
    const facilityMatch = contact.custom_attributes.facility.toLowerCase() === member.facility.toLowerCase();
    matchDetails.facility = facilityMatch;
    if (facilityMatch) matchCount++;
  }
 
  // 3. Region match (exact, case-insensitive)
  if (contact.custom_attributes?.region && member.region) {
    const regionMatch = contact.custom_attributes.region.toLowerCase() === member.region.toLowerCase();
    matchDetails.region = regionMatch;
    if (regionMatch) matchCount++;
  }
 
  // 4. Name fuzzy match (60%+ similarity)
  if (contact.name && member.firstname && member.lastname) {
    const contactName = contact.name.toLowerCase().replace(/[^a-z]/g, '');
    const memberName = `${member.firstname}${member.lastname}`.toLowerCase().replace(/[^a-z]/g, '');
 
    // Simple similarity: check if one contains most of the other
    const longer = contactName.length > memberName.length ? contactName : memberName;
    const shorter = contactName.length > memberName.length ? memberName : contactName;
 
    let matches = 0;
    for (let i = 0; i < shorter.length; i++) {
      if (longer.includes(shorter[i])) matches++;
    }
 
    const similarity = matches / longer.length;
    const nameMatch = similarity >= 0.6; // 60% threshold
    matchDetails.name = nameMatch;
    if (nameMatch) matchCount++;
  }
 
  // 5. Phone match (E.164 format)
  if (contact.phone && member.phone) {
    const formattedMemberPhone = formatPhoneForIntercom(member.phone);
    const phoneMatch = formattedMemberPhone && contact.phone === formattedMemberPhone;
    matchDetails.phone = phoneMatch;
    if (phoneMatch) matchCount++;
  }
 
  // Special validation for external_id: be more lenient
  // If validating external_id AND name matches, accept with just 1 match total
  if (isExternalIdValidation && matchDetails.name && matchCount >= 1) {
    matchDetails.validation_note = 'Accepted: external_id with name match';
    return { isValid: true, matchCount, matches: matchDetails };
  }
 
  // If validating external_id and contact has ONLY external_id (no email, no name),
  // trust it anyway since external_id comes from authenticated WordPress/Auth0 sessions
  if (isExternalIdValidation && !contact.email && !contact.name) {
    matchDetails.validation_note = 'Accepted: external_id only (authenticated source)';
    return { isValid: true, matchCount: 0, matches: matchDetails };
  }
 
  // Standard validation: require 2+ matches
  return { isValid: matchCount >= 2, matchCount, matches: matchDetails };
}

UUID Detection

function isUUID(str) {
  if (!str) return false;
  const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
  return uuidRegex.test(str);
}

Logging

Dual Logging (Console + File)

All audit actions are logged to both console and /tmp/intercom_audit.log:

log(level, message, data = {}) {
  const timestamp = new Date().toISOString();
  const logEntry = {
    timestamp,
    level,
    message,
    ...data
  };
 
  // Log to console
  logger[level](message, data);
 
  // Log to file as JSON
  this.logStream.write(JSON.stringify(logEntry) + '\n');
}

Log File Format

Each line is a JSON object:

{"timestamp":"2025-10-04T12:00:00.000Z","level":"info","message":"Enriched contact 67eb0a6e8f72a828ba21c342","lookup_method":"email","member_number":"12345"}
{"timestamp":"2025-10-04T12:00:01.000Z","level":"warn","message":"Correcting external_id: a1b2c3d4-e5f6-7890-abcd-ef1234567890 -> 12345","contact_id":"67eb0a6e8f72a828ba21c342"}
{"timestamp":"2025-10-04T12:00:02.000Z","level":"info","message":"Archived older duplicate contact 66a13ba93686d4c0e4fdb04e","email":"john.doe@example.com"}

Accessing Logs

# Tail the audit log
tail -f /tmp/intercom_audit.log
 
# Pretty print JSON logs
cat /tmp/intercom_audit.log | jq '.'
 
# Filter by level
cat /tmp/intercom_audit.log | jq 'select(.level == "error")'
 
# Find all UUID cleanups
cat /tmp/intercom_audit.log | grep "UUID external_id"

Statistics & Summary

Tracked Metrics

this.stats = {
  startTime: null,
  endTime: null,
  totalContacts: 0,
  processed: 0,
  enriched: 0,
  skipped: 0,
  archived: 0,
  archivedNoConversations: 0,
  convertedLeads: 0,
  failed: 0,
  errors: []
};

Example Summary

📊 Intercom Audit Summary:
⏱️  Duration: 1847.52s
📈 Total Contacts: 5000
✅ Enriched: 3245
🔄 Converted Leads → Users: 87
📦 Archived (duplicates): 156
📦 Archived (no conversations): 423
⏭️  Skipped: 1089
❌ Failed: 0

Use Cases & Examples

Initial Setup (First Time)

# 1. Preview with limited contacts to understand what will happen
node sync/intercom/audit.js --dry-run --limit=100
 
# 2. Review logs and ensure behavior is expected
cat /tmp/intercom_audit.log | jq '.'
 
# 3. Run full audit (no dry-run)
node sync/intercom/audit.js
 
# 4. Monitor progress
tail -f /tmp/intercom_audit.log

Lead Conversion

# Preview lead conversion
node sync/intercom/audit.js --leads-only --dry-run
 
# Convert all leads to users (when matched)
node sync/intercom/audit.js --leads-only

UUID Cleanup

UUIDs are automatically detected and cleaned up during normal audit:

# Audit will find and replace UUIDs with member numbers
node sync/intercom/audit.js
 
# Check audit log for UUID cleanups
cat /tmp/intercom_audit.log | grep "UUID external_id"

Re-enrich Contacts

# Re-enrich all contacts (even those with existing external_id)
node sync/intercom/audit.js --force
 
# Re-enrich with limit (test first)
node sync/intercom/audit.js --force --limit=100 --dry-run

Single Contact Debug

# Process and debug single contact
node sync/intercom/audit.js --contact-id=67eb0a6e8f72a828ba21c342
 
# Check audit log for this contact
cat /tmp/intercom_audit.log | grep "67eb0a6e8f72a828ba21c342"

Data Sources

MySQL (Primary for Audit)

Member lookups use MySQL for audit script:

-- Lookup by member number
SELECT
  m.membernumber, m.firstname, m.lastname, m.status, m.membertypeid,
  r.code as region, f.code as facility,
  me.email, mp.number as phone
FROM member m
LEFT JOIN region r ON m.regionid = r.id
LEFT JOIN facility f ON m.facilityid = f.id
LEFT JOIN (SELECT memberid, email FROM emailinformation WHERE isprimary = 1) me ON m.id = me.memberid
LEFT JOIN (SELECT memberid, number FROM phoneinformation WHERE isprimary = 1) mp ON m.id = mp.memberid
WHERE m.membernumber = ? AND m.status IN ('Active', 'Retired')
 
-- Lookup by email (checks all emails, not just primary)
SELECT ... WHERE LOWER(me.email) = LOWER(?) AND m.status IN ('Active', 'Retired')

Supabase (Positions Only)

Positions are fetched from Supabase (already synced):

const { data } = await supabase
  .from('positions')
  .select('positiontype')
  .eq('membernumber', memberNumber);
 
// Reverse map codes to full names
return data.map(p => POSITION_CODE_TO_NAME[p.positiontype]);

Performance

Expected Runtime

ContactsDurationRequests/secNotes
100~5min~0.3Includes MySQL lookups
1,000~50min~0.3Batch size: 50
5,000~250min~0.3~4 hours
10,000~500min~0.3~8 hours

Optimization Tips

  1. Use --limit for testing

    node sync/intercom/audit.js --dry-run --limit=100
  2. Increase batch size (default: 50)

    node sync/intercom/audit.js --batch-size=100
  3. Run during off-hours (less Intercom API traffic)

  4. Monitor rate limits (audit script respects Intercom rate limits automatically)

Troubleshooting

Audit Script Not Finding Members

Problem: Contacts not being enriched, high skip rate

Solutions:

  1. Check MySQL connection and credentials
  2. Verify member data exists for contact emails
  3. Review validation logic (may need adjustment)
  4. Check audit log for validation failures

UUID Cleanup Not Working

Problem: UUIDs still present after audit

Solutions:

  1. Verify UUID detection regex matches your UUID format
  2. Check audit log for UUID cleanup attempts
  3. Ensure validation logic isn't rejecting valid matches
  4. Run with --force to re-process contacts

Duplicate Resolution Issues

Problem: Duplicates not being archived

Solutions:

  1. Check that duplicates are being detected (log shows "Found X contacts")
  2. Verify archive permissions in Intercom API token
  3. Review created_at timestamps (keeping newest)
  4. Run with --dry-run to see planned actions

High Failure Rate

Problem: Many contacts failing to enrich

Solutions:

  1. Review error logs: cat /tmp/intercom_audit.log | jq 'select(.level == "error")'
  2. Check MySQL connection stability
  3. Verify Intercom API token permissions
  4. Test with single contact: --contact-id=<id>

Related Documentation