{
  "name": "REVOPS-LI-005 | 02 | HubSpot duplicate detector",
  "nodes": [
    {
      "parameters": {
        "inputSource": "passthrough"
      },
      "id": "9cdbc2d2-6397-45a0-b01d-9634a14231df",
      "name": "Intake Event",
      "type": "n8n-nodes-base.executeWorkflowTrigger",
      "typeVersion": 1.1,
      "position": [
        0,
        0
      ]
    },
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "minutes",
              "minutesInterval": 5
            }
          ]
        }
      },
      "id": "e3ccde47-ef50-4cb7-83a4-e6fcbcd7d690",
      "name": "Drain Queue",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        270,
        0
      ]
    },
    {
      "parameters": {},
      "id": "e3810366-c115-41d8-a328-ad68ddb86e74",
      "name": "Run Detector Manually",
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "position": [
        540,
        0
      ]
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "return [{json:{config:{tenant:'REVOPS-LI-005',score_floor:40,task_score_floor:60,create_review_tasks:true,review_owner_id:'REPLACE_HUBSPOT_OWNER_ID',portal_id:'REPLACE_HUBSPOT_PORTAL_ID',approval_base_url:'REPLACE_N8N_PUBLIC_URL/webhook/revops-li-005-decision'}}}];"
      },
      "id": "9456a855-b0d3-460e-9249-6c77b3f122ac",
      "name": "Detection Configuration",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        810,
        0
      ]
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "SELECT duplicate_cleanup.claim_event($1::text) AS job;",
        "options": {
          "queryReplacement": "={{ [String($execution.id)] }}"
        }
      },
      "id": "25569bf8-5167-4867-9273-a4329cdc576b",
      "name": "Claim One Scan",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.6,
      "position": [
        1080,
        0
      ],
      "credentials": {
        "postgres": {
          "id": "REPLACE_POSTGRES_CREDENTIAL",
          "name": "ATTRIBUTION Postgres"
        }
      }
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "3f978b81-d5f7-4168-9da1-76066f9a36f1",
              "leftValue": "={{ $json.job !== null }}",
              "rightValue": true,
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "9801e793-f2a9-494a-b3a2-88b018c5f108",
      "name": "Job Found?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1350,
        0
      ]
    },
    {
      "parameters": {
        "method": "GET",
        "url": "={{ 'https://api.hubapi.com/crm/v3/objects/'+$('Claim One Scan').first().json.job.object_type+'/'+$('Claim One Scan').first().json.job.record_id+'?properties='+encodeURIComponent($('Build Source Read').first().json.properties.join(',')) }}",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "options": {
          "timeout": 30000,
          "response": {
            "response": {
              "fullResponse": true,
              "neverError": true,
              "responseFormat": "json"
            }
          }
        }
      },
      "id": "fd39269e-0fdd-416a-903c-b6f20a2aee40",
      "name": "Read Authoritative HubSpot Record",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1620,
        0
      ],
      "credentials": {
        "httpHeaderAuth": {
          "id": "REPLACE_HUBSPOT_CREDENTIAL",
          "name": "ATTRIBUTION HubSpot"
        }
      }
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "const D = (function duplicateLibrary(){\n  const text=v=>String(v??'').trim();\n  const lower=v=>text(v).toLowerCase();\n  const digits=v=>text(v).replace(/\\D/g,'');\n  const host=v=>{try{return new URL(/^https?:\\/\\//i.test(text(v))?text(v):'https://'+text(v)).hostname.toLowerCase().replace(/^www\\./,'')}catch{return lower(v).replace(/^www\\./,'').split('/')[0]}};\n  const normName=v=>lower(v).normalize('NFKD').replace(/[\\u0300-\\u036f]/g,'').replace(/[^a-z0-9]+/g,' ').trim();\n  const tokens=v=>new Set(normName(v).split(' ').filter(x=>x.length>1));\n  const similarity=(a,b)=>{const A=tokens(a),B=tokens(b);if(!A.size||!B.size)return 0;let hit=0;for(const x of A)if(B.has(x))hit++;return hit/Math.max(A.size,B.size)};\n  const truthy=v=>['true','1','yes','on'].includes(lower(v));\n  const prop=(r,k)=>r?.properties?.[k]??'';\n  function score(source,candidate,type){\n    const s=[],p=source.properties||{},q=candidate.properties||{};let score=0;\n    const add=(points,signal)=>{score=Math.max(score,points);s.push({signal,points})};\n    if(type==='contacts'){\n      if(lower(p.email)&&lower(p.email)===lower(q.email))add(100,'EXACT_EMAIL');\n      if(lower(p.revops_linkedin_profile_url)&&lower(p.revops_linkedin_profile_url)===lower(q.revops_linkedin_profile_url))add(95,'EXACT_LINKEDIN');\n      const sameName=normName(p.firstname+' '+p.lastname)===normName(q.firstname+' '+q.lastname)&&normName(p.firstname+' '+p.lastname);\n      const samePhone=digits(p.phone||p.mobilephone).length>=7&&digits(p.phone||p.mobilephone)===digits(q.phone||q.mobilephone);\n      if(samePhone&&sameName)add(90,'PHONE_AND_NAME');\n      const sd=host(p.website),qd=host(q.website);if(sameName&&sd&&sd===qd)add(85,'NAME_AND_COMPANY_DOMAIN');\n      if(sd&&sd===qd&&similarity(p.firstname+' '+p.lastname,q.firstname+' '+q.lastname)>=.6)add(80,'DOMAIN_AND_SIMILAR_NAME');\n      if(similarity(p.firstname+' '+p.lastname,q.firstname+' '+q.lastname)>=.8&&similarity(p.company,q.company)>=.7)add(60,'SIMILAR_PERSON_AND_COMPANY');\n      const su=lower(p.email).split('@')[0],qu=lower(q.email).split('@')[0];if(su&&su===qu)add(40,'EMAIL_USERNAME_ONLY');\n      if(lower(p.firstname)&&lower(p.firstname)===lower(q.firstname))add(10,'FIRST_NAME_ONLY');\n    }else{\n      if(lower(p.revops_company_linkedin_url)&&lower(p.revops_company_linkedin_url)===lower(q.revops_company_linkedin_url))add(80,'EXACT_COMPANY_LINKEDIN');\n      const sd=host(p.domain||p.website),qd=host(q.domain||q.website);if(sd&&sd===qd)add(75,'EXACT_COMPANY_DOMAIN');\n      if(similarity(p.name,q.name)>=.8&&lower(p.country)&&lower(p.country)===lower(q.country))add(65,'SIMILAR_COMPANY_AND_COUNTRY');\n    }\n    return {score,signals:s.sort((a,b)=>b.points-a.points)};\n  }\n  const completeness=(r,keys)=>keys.reduce((n,k)=>n+(text(prop(r,k))?1:0),0);\n  function rank(r,type){const p=r.properties||{};const life={customer:50,opportunity:40,salesqualifiedlead:30,marketingqualifiedlead:20,lead:10};return (life[lower(p.lifecyclestage)]||0)+(Number(p.num_associated_deals||0)>0?30:0)+(text(p.hubspot_owner_id)?10:0)+completeness(r,type==='contacts'?['email','phone','mobilephone','jobtitle','firstname','lastname','company','website']:['name','domain','phone','country','website'])*2;}\n  function choosePrimary(a,b,type){const ar=rank(a,type),br=rank(b,type);if(ar!==br)return ar>br?a:b;const ad=Date.parse(a.createdAt||prop(a,'createdate')||'9999-12-31'),bd=Date.parse(b.createdAt||prop(b,'createdate')||'9999-12-31');return ad<=bd?a:b;}\n  function classify(n){return n>=90?'DUPLICATE_CONFIRMED':n>=60?'DUPLICATE_REVIEW_REQUIRED':n>=40?'POSSIBLE_DUPLICATE':n>0?'DUPLICATE_WEAK_MATCH':'NO_DUPLICATE_FOUND'}\n  function buildSearches(source,type){const p=source.properties||{},out=[];const exact=(propertyName,value)=>{if(text(value))out.push({kind:'exact',propertyName,value:text(value),body:{filterGroups:[{filters:[{propertyName,operator:'EQ',value:text(value)}]}],properties:type==='contacts'?CONTACT_PROPS:COMPANY_PROPS,limit:100}})};\n    if(type==='contacts'){exact('email',lower(p.email));exact('phone',p.phone);exact('mobilephone',p.mobilephone);if(text(p.firstname+' '+p.lastname))out.push({kind:'fuzzy',propertyName:'name',value:text(p.firstname+' '+p.lastname),body:{query:text(p.firstname+' '+p.lastname),properties:CONTACT_PROPS,limit:100}})}\n    else{exact('domain',host(p.domain));exact('website',p.website);if(text(p.name))out.push({kind:'fuzzy',propertyName:'name',value:text(p.name),body:{query:text(p.name),properties:COMPANY_PROPS,limit:100}})}\n    return out;\n  }\n  function best(source,responses,type){const map=new Map();for(const response of responses){const body=response.body||response;for(const r of body.results||[])if(String(r.id)!==String(source.id))map.set(String(r.id),r)}let winner=null;for(const r of map.values()){const scored=score(source,r,type);if(!winner||scored.score>winner.score)winner={record:r,...scored}}if(!winner)return {found:false,status:'NO_DUPLICATE_FOUND'};const primary=choosePrimary(source,winner.record,type),secondary=String(primary.id)===String(source.id)?winner.record:source;return {found:winner.score>=40,status:classify(winner.score),score:winner.score,signals:winner.signals,primary,secondary,source_snapshot:source,candidate_snapshot:winner.record};}\n  function mergePlan(candidate,records,config){const bySide=Object.fromEntries(records.map(x=>[x.side,x.record]));const primary=bySide.primary,secondary=bySide.secondary,blockers=[];if(!primary||!secondary)blockers.push('RECORD_FETCH_FAILED');if(!primary||!secondary)return {safe:false,blockers};\n    if(String(primary.id)===String(secondary.id))blockers.push('RECORDS_ALREADY_RESOLVE_TO_SAME_ID');\n    const expected=candidate.snapshots||{};for(const [side,r] of Object.entries({primary,secondary})){const old=[expected.primary,expected.secondary].find(x=>String(x?.id||'')===String(r.id));if(old?.updatedAt&&r.updatedAt&&old.updatedAt!==r.updatedAt)blockers.push(side.toUpperCase()+'_CHANGED_AFTER_REVIEW');}\n    const pp=primary.properties||{},sp=secondary.properties||{};if(text(pp.hubspot_owner_id)&&text(sp.hubspot_owner_id)&&pp.hubspot_owner_id!==sp.hubspot_owner_id&&!candidate.manager_override)blockers.push('DIFFERENT_OWNERS_REQUIRE_MANAGER_OVERRIDE');\n    if(candidate.object_type==='contacts'){\n      if(truthy(pp.revops_do_not_contact)||truthy(sp.revops_do_not_contact))blockers.push('DO_NOT_CONTACT_PRESENT');\n      if(text(pp.hs_email_optout)!==text(sp.hs_email_optout)&&text(pp.hs_email_optout)&&text(sp.hs_email_optout))blockers.push('CONSENT_CONFLICT');\n      const companies=r=>(r.associations?.companies?.results||[]).map(x=>String(x.id)).sort();const a=companies(primary),b=companies(secondary);if(a.length&&b.length&&JSON.stringify(a)!==JSON.stringify(b))blockers.push('DIFFERENT_COMPANY_ASSOCIATIONS');\n      if(truthy(pp.revops_sequence_enrolled)||truthy(sp.revops_sequence_enrolled))blockers.push('ACTIVE_SEQUENCE_REVIEW_REQUIRED');\n    }\n    const deals=r=>(r.associations?.deals?.results||[]).map(x=>String(x.id)).sort();const da=deals(primary),db=deals(secondary);if(da.length&&db.length&&JSON.stringify(da)!==JSON.stringify(db))blockers.push('DIFFERENT_DEAL_ASSOCIATIONS');\n    const safeKeys=candidate.object_type==='contacts'?['phone','mobilephone','jobtitle','revops_linkedin_profile_url','website','company']:['phone','website','country','city','state','zip','revops_company_linkedin_url'];const patch={};for(const k of safeKeys)if(!text(pp[k])&&text(sp[k]))patch[k]=sp[k];\n    return {safe:blockers.length===0,blockers,patch,primary,secondary,merge_body:{primaryObjectId:String(primary.id),objectIdToMerge:String(secondary.id)},execute:config.execute_merges===true};\n  }\n  const CONTACT_PROPS=['email','firstname','lastname','phone','mobilephone','jobtitle','company','website','lifecyclestage','hubspot_owner_id','num_associated_deals','hs_email_optout','hs_email_bad_address','revops_do_not_contact','revops_sequence_enrolled','revops_linkedin_profile_url','revops_duplicate_status','createdate','lastmodifieddate'];\n  const COMPANY_PROPS=['name','domain','website','phone','country','city','state','zip','lifecyclestage','hubspot_owner_id','num_associated_deals','revops_company_linkedin_url','revops_duplicate_status','createdate','lastmodifieddate'];\n  return {text,lower,digits,host,normName,similarity,score,choosePrimary,classify,buildSearches,best,mergePlan,CONTACT_PROPS,COMPANY_PROPS};\n})();\nconst job=$('Claim One Scan').first().json.job;return [{json:{job,properties:job.object_type==='contacts'?D.CONTACT_PROPS:D.COMPANY_PROPS}}];"
      },
      "id": "1ec5a789-39db-471a-ac4a-f55fee7fca08",
      "name": "Build Source Read",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1890,
        0
      ]
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "const D = (function duplicateLibrary(){\n  const text=v=>String(v??'').trim();\n  const lower=v=>text(v).toLowerCase();\n  const digits=v=>text(v).replace(/\\D/g,'');\n  const host=v=>{try{return new URL(/^https?:\\/\\//i.test(text(v))?text(v):'https://'+text(v)).hostname.toLowerCase().replace(/^www\\./,'')}catch{return lower(v).replace(/^www\\./,'').split('/')[0]}};\n  const normName=v=>lower(v).normalize('NFKD').replace(/[\\u0300-\\u036f]/g,'').replace(/[^a-z0-9]+/g,' ').trim();\n  const tokens=v=>new Set(normName(v).split(' ').filter(x=>x.length>1));\n  const similarity=(a,b)=>{const A=tokens(a),B=tokens(b);if(!A.size||!B.size)return 0;let hit=0;for(const x of A)if(B.has(x))hit++;return hit/Math.max(A.size,B.size)};\n  const truthy=v=>['true','1','yes','on'].includes(lower(v));\n  const prop=(r,k)=>r?.properties?.[k]??'';\n  function score(source,candidate,type){\n    const s=[],p=source.properties||{},q=candidate.properties||{};let score=0;\n    const add=(points,signal)=>{score=Math.max(score,points);s.push({signal,points})};\n    if(type==='contacts'){\n      if(lower(p.email)&&lower(p.email)===lower(q.email))add(100,'EXACT_EMAIL');\n      if(lower(p.revops_linkedin_profile_url)&&lower(p.revops_linkedin_profile_url)===lower(q.revops_linkedin_profile_url))add(95,'EXACT_LINKEDIN');\n      const sameName=normName(p.firstname+' '+p.lastname)===normName(q.firstname+' '+q.lastname)&&normName(p.firstname+' '+p.lastname);\n      const samePhone=digits(p.phone||p.mobilephone).length>=7&&digits(p.phone||p.mobilephone)===digits(q.phone||q.mobilephone);\n      if(samePhone&&sameName)add(90,'PHONE_AND_NAME');\n      const sd=host(p.website),qd=host(q.website);if(sameName&&sd&&sd===qd)add(85,'NAME_AND_COMPANY_DOMAIN');\n      if(sd&&sd===qd&&similarity(p.firstname+' '+p.lastname,q.firstname+' '+q.lastname)>=.6)add(80,'DOMAIN_AND_SIMILAR_NAME');\n      if(similarity(p.firstname+' '+p.lastname,q.firstname+' '+q.lastname)>=.8&&similarity(p.company,q.company)>=.7)add(60,'SIMILAR_PERSON_AND_COMPANY');\n      const su=lower(p.email).split('@')[0],qu=lower(q.email).split('@')[0];if(su&&su===qu)add(40,'EMAIL_USERNAME_ONLY');\n      if(lower(p.firstname)&&lower(p.firstname)===lower(q.firstname))add(10,'FIRST_NAME_ONLY');\n    }else{\n      if(lower(p.revops_company_linkedin_url)&&lower(p.revops_company_linkedin_url)===lower(q.revops_company_linkedin_url))add(80,'EXACT_COMPANY_LINKEDIN');\n      const sd=host(p.domain||p.website),qd=host(q.domain||q.website);if(sd&&sd===qd)add(75,'EXACT_COMPANY_DOMAIN');\n      if(similarity(p.name,q.name)>=.8&&lower(p.country)&&lower(p.country)===lower(q.country))add(65,'SIMILAR_COMPANY_AND_COUNTRY');\n    }\n    return {score,signals:s.sort((a,b)=>b.points-a.points)};\n  }\n  const completeness=(r,keys)=>keys.reduce((n,k)=>n+(text(prop(r,k))?1:0),0);\n  function rank(r,type){const p=r.properties||{};const life={customer:50,opportunity:40,salesqualifiedlead:30,marketingqualifiedlead:20,lead:10};return (life[lower(p.lifecyclestage)]||0)+(Number(p.num_associated_deals||0)>0?30:0)+(text(p.hubspot_owner_id)?10:0)+completeness(r,type==='contacts'?['email','phone','mobilephone','jobtitle','firstname','lastname','company','website']:['name','domain','phone','country','website'])*2;}\n  function choosePrimary(a,b,type){const ar=rank(a,type),br=rank(b,type);if(ar!==br)return ar>br?a:b;const ad=Date.parse(a.createdAt||prop(a,'createdate')||'9999-12-31'),bd=Date.parse(b.createdAt||prop(b,'createdate')||'9999-12-31');return ad<=bd?a:b;}\n  function classify(n){return n>=90?'DUPLICATE_CONFIRMED':n>=60?'DUPLICATE_REVIEW_REQUIRED':n>=40?'POSSIBLE_DUPLICATE':n>0?'DUPLICATE_WEAK_MATCH':'NO_DUPLICATE_FOUND'}\n  function buildSearches(source,type){const p=source.properties||{},out=[];const exact=(propertyName,value)=>{if(text(value))out.push({kind:'exact',propertyName,value:text(value),body:{filterGroups:[{filters:[{propertyName,operator:'EQ',value:text(value)}]}],properties:type==='contacts'?CONTACT_PROPS:COMPANY_PROPS,limit:100}})};\n    if(type==='contacts'){exact('email',lower(p.email));exact('phone',p.phone);exact('mobilephone',p.mobilephone);if(text(p.firstname+' '+p.lastname))out.push({kind:'fuzzy',propertyName:'name',value:text(p.firstname+' '+p.lastname),body:{query:text(p.firstname+' '+p.lastname),properties:CONTACT_PROPS,limit:100}})}\n    else{exact('domain',host(p.domain));exact('website',p.website);if(text(p.name))out.push({kind:'fuzzy',propertyName:'name',value:text(p.name),body:{query:text(p.name),properties:COMPANY_PROPS,limit:100}})}\n    return out;\n  }\n  function best(source,responses,type){const map=new Map();for(const response of responses){const body=response.body||response;for(const r of body.results||[])if(String(r.id)!==String(source.id))map.set(String(r.id),r)}let winner=null;for(const r of map.values()){const scored=score(source,r,type);if(!winner||scored.score>winner.score)winner={record:r,...scored}}if(!winner)return {found:false,status:'NO_DUPLICATE_FOUND'};const primary=choosePrimary(source,winner.record,type),secondary=String(primary.id)===String(source.id)?winner.record:source;return {found:winner.score>=40,status:classify(winner.score),score:winner.score,signals:winner.signals,primary,secondary,source_snapshot:source,candidate_snapshot:winner.record};}\n  function mergePlan(candidate,records,config){const bySide=Object.fromEntries(records.map(x=>[x.side,x.record]));const primary=bySide.primary,secondary=bySide.secondary,blockers=[];if(!primary||!secondary)blockers.push('RECORD_FETCH_FAILED');if(!primary||!secondary)return {safe:false,blockers};\n    if(String(primary.id)===String(secondary.id))blockers.push('RECORDS_ALREADY_RESOLVE_TO_SAME_ID');\n    const expected=candidate.snapshots||{};for(const [side,r] of Object.entries({primary,secondary})){const old=[expected.primary,expected.secondary].find(x=>String(x?.id||'')===String(r.id));if(old?.updatedAt&&r.updatedAt&&old.updatedAt!==r.updatedAt)blockers.push(side.toUpperCase()+'_CHANGED_AFTER_REVIEW');}\n    const pp=primary.properties||{},sp=secondary.properties||{};if(text(pp.hubspot_owner_id)&&text(sp.hubspot_owner_id)&&pp.hubspot_owner_id!==sp.hubspot_owner_id&&!candidate.manager_override)blockers.push('DIFFERENT_OWNERS_REQUIRE_MANAGER_OVERRIDE');\n    if(candidate.object_type==='contacts'){\n      if(truthy(pp.revops_do_not_contact)||truthy(sp.revops_do_not_contact))blockers.push('DO_NOT_CONTACT_PRESENT');\n      if(text(pp.hs_email_optout)!==text(sp.hs_email_optout)&&text(pp.hs_email_optout)&&text(sp.hs_email_optout))blockers.push('CONSENT_CONFLICT');\n      const companies=r=>(r.associations?.companies?.results||[]).map(x=>String(x.id)).sort();const a=companies(primary),b=companies(secondary);if(a.length&&b.length&&JSON.stringify(a)!==JSON.stringify(b))blockers.push('DIFFERENT_COMPANY_ASSOCIATIONS');\n      if(truthy(pp.revops_sequence_enrolled)||truthy(sp.revops_sequence_enrolled))blockers.push('ACTIVE_SEQUENCE_REVIEW_REQUIRED');\n    }\n    const deals=r=>(r.associations?.deals?.results||[]).map(x=>String(x.id)).sort();const da=deals(primary),db=deals(secondary);if(da.length&&db.length&&JSON.stringify(da)!==JSON.stringify(db))blockers.push('DIFFERENT_DEAL_ASSOCIATIONS');\n    const safeKeys=candidate.object_type==='contacts'?['phone','mobilephone','jobtitle','revops_linkedin_profile_url','website','company']:['phone','website','country','city','state','zip','revops_company_linkedin_url'];const patch={};for(const k of safeKeys)if(!text(pp[k])&&text(sp[k]))patch[k]=sp[k];\n    return {safe:blockers.length===0,blockers,patch,primary,secondary,merge_body:{primaryObjectId:String(primary.id),objectIdToMerge:String(secondary.id)},execute:config.execute_merges===true};\n  }\n  const CONTACT_PROPS=['email','firstname','lastname','phone','mobilephone','jobtitle','company','website','lifecyclestage','hubspot_owner_id','num_associated_deals','hs_email_optout','hs_email_bad_address','revops_do_not_contact','revops_sequence_enrolled','revops_linkedin_profile_url','revops_duplicate_status','createdate','lastmodifieddate'];\n  const COMPANY_PROPS=['name','domain','website','phone','country','city','state','zip','lifecyclestage','hubspot_owner_id','num_associated_deals','revops_company_linkedin_url','revops_duplicate_status','createdate','lastmodifieddate'];\n  return {text,lower,digits,host,normName,similarity,score,choosePrimary,classify,buildSearches,best,mergePlan,CONTACT_PROPS,COMPANY_PROPS};\n})();\nconst response=$input.first().json;if(response.statusCode!==200)throw new Error('HubSpot source read failed with '+response.statusCode);const source=response.body;const job=$('Claim One Scan').first().json.job;const searches=D.buildSearches(source,job.object_type);if(!searches.length)return [{json:{job,source,empty:true,body:{filterGroups:[{filters:[{propertyName:'hs_object_id',operator:'EQ',value:'0'}]}],properties:job.object_type==='contacts'?D.CONTACT_PROPS:D.COMPANY_PROPS,limit:1}}}];return searches.map(search=>({json:{job,source,search,body:search.body}}));"
      },
      "id": "5b4d0cc9-da14-439d-b8db-ef6962f3b4af",
      "name": "Prepare Candidate Searches",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        0,
        300
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ 'https://api.hubapi.com/crm/v3/objects/'+$json.job.object_type+'/search' }}",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendBody": true,
        "contentType": "json",
        "specifyBody": "json",
        "jsonBody": "={{ $json.body }}",
        "options": {
          "timeout": 30000,
          "response": {
            "response": {
              "fullResponse": true,
              "neverError": true,
              "responseFormat": "json"
            }
          }
        }
      },
      "id": "6d4ac526-f6f2-4beb-90d4-cba967b6ba6f",
      "name": "Search HubSpot Candidates",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        270,
        300
      ],
      "credentials": {
        "httpHeaderAuth": {
          "id": "REPLACE_HUBSPOT_CREDENTIAL",
          "name": "ATTRIBUTION HubSpot"
        }
      }
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "const D = (function duplicateLibrary(){\n  const text=v=>String(v??'').trim();\n  const lower=v=>text(v).toLowerCase();\n  const digits=v=>text(v).replace(/\\D/g,'');\n  const host=v=>{try{return new URL(/^https?:\\/\\//i.test(text(v))?text(v):'https://'+text(v)).hostname.toLowerCase().replace(/^www\\./,'')}catch{return lower(v).replace(/^www\\./,'').split('/')[0]}};\n  const normName=v=>lower(v).normalize('NFKD').replace(/[\\u0300-\\u036f]/g,'').replace(/[^a-z0-9]+/g,' ').trim();\n  const tokens=v=>new Set(normName(v).split(' ').filter(x=>x.length>1));\n  const similarity=(a,b)=>{const A=tokens(a),B=tokens(b);if(!A.size||!B.size)return 0;let hit=0;for(const x of A)if(B.has(x))hit++;return hit/Math.max(A.size,B.size)};\n  const truthy=v=>['true','1','yes','on'].includes(lower(v));\n  const prop=(r,k)=>r?.properties?.[k]??'';\n  function score(source,candidate,type){\n    const s=[],p=source.properties||{},q=candidate.properties||{};let score=0;\n    const add=(points,signal)=>{score=Math.max(score,points);s.push({signal,points})};\n    if(type==='contacts'){\n      if(lower(p.email)&&lower(p.email)===lower(q.email))add(100,'EXACT_EMAIL');\n      if(lower(p.revops_linkedin_profile_url)&&lower(p.revops_linkedin_profile_url)===lower(q.revops_linkedin_profile_url))add(95,'EXACT_LINKEDIN');\n      const sameName=normName(p.firstname+' '+p.lastname)===normName(q.firstname+' '+q.lastname)&&normName(p.firstname+' '+p.lastname);\n      const samePhone=digits(p.phone||p.mobilephone).length>=7&&digits(p.phone||p.mobilephone)===digits(q.phone||q.mobilephone);\n      if(samePhone&&sameName)add(90,'PHONE_AND_NAME');\n      const sd=host(p.website),qd=host(q.website);if(sameName&&sd&&sd===qd)add(85,'NAME_AND_COMPANY_DOMAIN');\n      if(sd&&sd===qd&&similarity(p.firstname+' '+p.lastname,q.firstname+' '+q.lastname)>=.6)add(80,'DOMAIN_AND_SIMILAR_NAME');\n      if(similarity(p.firstname+' '+p.lastname,q.firstname+' '+q.lastname)>=.8&&similarity(p.company,q.company)>=.7)add(60,'SIMILAR_PERSON_AND_COMPANY');\n      const su=lower(p.email).split('@')[0],qu=lower(q.email).split('@')[0];if(su&&su===qu)add(40,'EMAIL_USERNAME_ONLY');\n      if(lower(p.firstname)&&lower(p.firstname)===lower(q.firstname))add(10,'FIRST_NAME_ONLY');\n    }else{\n      if(lower(p.revops_company_linkedin_url)&&lower(p.revops_company_linkedin_url)===lower(q.revops_company_linkedin_url))add(80,'EXACT_COMPANY_LINKEDIN');\n      const sd=host(p.domain||p.website),qd=host(q.domain||q.website);if(sd&&sd===qd)add(75,'EXACT_COMPANY_DOMAIN');\n      if(similarity(p.name,q.name)>=.8&&lower(p.country)&&lower(p.country)===lower(q.country))add(65,'SIMILAR_COMPANY_AND_COUNTRY');\n    }\n    return {score,signals:s.sort((a,b)=>b.points-a.points)};\n  }\n  const completeness=(r,keys)=>keys.reduce((n,k)=>n+(text(prop(r,k))?1:0),0);\n  function rank(r,type){const p=r.properties||{};const life={customer:50,opportunity:40,salesqualifiedlead:30,marketingqualifiedlead:20,lead:10};return (life[lower(p.lifecyclestage)]||0)+(Number(p.num_associated_deals||0)>0?30:0)+(text(p.hubspot_owner_id)?10:0)+completeness(r,type==='contacts'?['email','phone','mobilephone','jobtitle','firstname','lastname','company','website']:['name','domain','phone','country','website'])*2;}\n  function choosePrimary(a,b,type){const ar=rank(a,type),br=rank(b,type);if(ar!==br)return ar>br?a:b;const ad=Date.parse(a.createdAt||prop(a,'createdate')||'9999-12-31'),bd=Date.parse(b.createdAt||prop(b,'createdate')||'9999-12-31');return ad<=bd?a:b;}\n  function classify(n){return n>=90?'DUPLICATE_CONFIRMED':n>=60?'DUPLICATE_REVIEW_REQUIRED':n>=40?'POSSIBLE_DUPLICATE':n>0?'DUPLICATE_WEAK_MATCH':'NO_DUPLICATE_FOUND'}\n  function buildSearches(source,type){const p=source.properties||{},out=[];const exact=(propertyName,value)=>{if(text(value))out.push({kind:'exact',propertyName,value:text(value),body:{filterGroups:[{filters:[{propertyName,operator:'EQ',value:text(value)}]}],properties:type==='contacts'?CONTACT_PROPS:COMPANY_PROPS,limit:100}})};\n    if(type==='contacts'){exact('email',lower(p.email));exact('phone',p.phone);exact('mobilephone',p.mobilephone);if(text(p.firstname+' '+p.lastname))out.push({kind:'fuzzy',propertyName:'name',value:text(p.firstname+' '+p.lastname),body:{query:text(p.firstname+' '+p.lastname),properties:CONTACT_PROPS,limit:100}})}\n    else{exact('domain',host(p.domain));exact('website',p.website);if(text(p.name))out.push({kind:'fuzzy',propertyName:'name',value:text(p.name),body:{query:text(p.name),properties:COMPANY_PROPS,limit:100}})}\n    return out;\n  }\n  function best(source,responses,type){const map=new Map();for(const response of responses){const body=response.body||response;for(const r of body.results||[])if(String(r.id)!==String(source.id))map.set(String(r.id),r)}let winner=null;for(const r of map.values()){const scored=score(source,r,type);if(!winner||scored.score>winner.score)winner={record:r,...scored}}if(!winner)return {found:false,status:'NO_DUPLICATE_FOUND'};const primary=choosePrimary(source,winner.record,type),secondary=String(primary.id)===String(source.id)?winner.record:source;return {found:winner.score>=40,status:classify(winner.score),score:winner.score,signals:winner.signals,primary,secondary,source_snapshot:source,candidate_snapshot:winner.record};}\n  function mergePlan(candidate,records,config){const bySide=Object.fromEntries(records.map(x=>[x.side,x.record]));const primary=bySide.primary,secondary=bySide.secondary,blockers=[];if(!primary||!secondary)blockers.push('RECORD_FETCH_FAILED');if(!primary||!secondary)return {safe:false,blockers};\n    if(String(primary.id)===String(secondary.id))blockers.push('RECORDS_ALREADY_RESOLVE_TO_SAME_ID');\n    const expected=candidate.snapshots||{};for(const [side,r] of Object.entries({primary,secondary})){const old=[expected.primary,expected.secondary].find(x=>String(x?.id||'')===String(r.id));if(old?.updatedAt&&r.updatedAt&&old.updatedAt!==r.updatedAt)blockers.push(side.toUpperCase()+'_CHANGED_AFTER_REVIEW');}\n    const pp=primary.properties||{},sp=secondary.properties||{};if(text(pp.hubspot_owner_id)&&text(sp.hubspot_owner_id)&&pp.hubspot_owner_id!==sp.hubspot_owner_id&&!candidate.manager_override)blockers.push('DIFFERENT_OWNERS_REQUIRE_MANAGER_OVERRIDE');\n    if(candidate.object_type==='contacts'){\n      if(truthy(pp.revops_do_not_contact)||truthy(sp.revops_do_not_contact))blockers.push('DO_NOT_CONTACT_PRESENT');\n      if(text(pp.hs_email_optout)!==text(sp.hs_email_optout)&&text(pp.hs_email_optout)&&text(sp.hs_email_optout))blockers.push('CONSENT_CONFLICT');\n      const companies=r=>(r.associations?.companies?.results||[]).map(x=>String(x.id)).sort();const a=companies(primary),b=companies(secondary);if(a.length&&b.length&&JSON.stringify(a)!==JSON.stringify(b))blockers.push('DIFFERENT_COMPANY_ASSOCIATIONS');\n      if(truthy(pp.revops_sequence_enrolled)||truthy(sp.revops_sequence_enrolled))blockers.push('ACTIVE_SEQUENCE_REVIEW_REQUIRED');\n    }\n    const deals=r=>(r.associations?.deals?.results||[]).map(x=>String(x.id)).sort();const da=deals(primary),db=deals(secondary);if(da.length&&db.length&&JSON.stringify(da)!==JSON.stringify(db))blockers.push('DIFFERENT_DEAL_ASSOCIATIONS');\n    const safeKeys=candidate.object_type==='contacts'?['phone','mobilephone','jobtitle','revops_linkedin_profile_url','website','company']:['phone','website','country','city','state','zip','revops_company_linkedin_url'];const patch={};for(const k of safeKeys)if(!text(pp[k])&&text(sp[k]))patch[k]=sp[k];\n    return {safe:blockers.length===0,blockers,patch,primary,secondary,merge_body:{primaryObjectId:String(primary.id),objectIdToMerge:String(secondary.id)},execute:config.execute_merges===true};\n  }\n  const CONTACT_PROPS=['email','firstname','lastname','phone','mobilephone','jobtitle','company','website','lifecyclestage','hubspot_owner_id','num_associated_deals','hs_email_optout','hs_email_bad_address','revops_do_not_contact','revops_sequence_enrolled','revops_linkedin_profile_url','revops_duplicate_status','createdate','lastmodifieddate'];\n  const COMPANY_PROPS=['name','domain','website','phone','country','city','state','zip','lifecyclestage','hubspot_owner_id','num_associated_deals','revops_company_linkedin_url','revops_duplicate_status','createdate','lastmodifieddate'];\n  return {text,lower,digits,host,normName,similarity,score,choosePrimary,classify,buildSearches,best,mergePlan,CONTACT_PROPS,COMPANY_PROPS};\n})();\nconst requests=$('Prepare Candidate Searches').all();const first=requests[0].json;const responses=$input.all().map(x=>x.json);const result=D.best(first.source,responses,first.job.object_type);return [{json:{job:first.job,...result}}];"
      },
      "id": "a65704f9-188a-414f-85c2-58d81958f950",
      "name": "Score and Recommend Master",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        540,
        300
      ]
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "SELECT duplicate_cleanup.record_detection($1::uuid,$2::jsonb) AS result;",
        "options": {
          "queryReplacement": "={{ [$('Claim One Scan').first().json.job.event_id,JSON.stringify($json)] }}"
        }
      },
      "id": "c049219f-89d5-47b4-acbc-e9536962fb94",
      "name": "Persist Candidate and Audit",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.6,
      "position": [
        810,
        300
      ],
      "credentials": {
        "postgres": {
          "id": "REPLACE_POSTGRES_CREDENTIAL",
          "name": "ATTRIBUTION Postgres"
        }
      }
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "4edfc9a8-3034-4fc1-91cb-d956c8f6a353",
              "leftValue": "={{ $('Score and Recommend Master').first().json.found === true && $('Score and Recommend Master').first().json.score >= $('Detection Configuration').first().json.config.task_score_floor && $('Detection Configuration').first().json.config.create_review_tasks === true }}",
              "rightValue": true,
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "0ee01eb0-5b79-4e9a-b01b-69b3217b8b8a",
      "name": "Create Review Task?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1080,
        300
      ]
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "const c=$('Persist Candidate and Audit').first().json.result;const cfg=$('Detection Configuration').first().json.config;const url=id=>'https://app.hubspot.com/contacts/'+cfg.portal_id+'/'+(c.object_type==='contacts'?'contact':'company')+'/'+id;const body=['Duplicate review '+c.candidate_id,'Confidence: '+c.score,'Recommended primary: '+c.recommended_primary_id,'Record A: '+url(c.record_a_id),'Record B: '+url(c.record_b_id),'Signals: '+(c.signals||[]).map(x=>x.signal).join(', '),'Use the protected approval endpoint; do not merge directly.'].join('\\n');return [{json:{candidate_id:c.candidate_id,task:{properties:{hs_task_subject:'Review possible duplicate ('+c.score+')',hs_task_body:body,hs_timestamp:new Date(Date.now()+3600000).toISOString(),hs_task_status:'NOT_STARTED',hs_task_priority:c.score>=90?'HIGH':'MEDIUM',hs_task_type:'TODO',...(cfg.review_owner_id&&!cfg.review_owner_id.includes('REPLACE_')?{hubspot_owner_id:cfg.review_owner_id}:{})}}}}];"
      },
      "id": "220b49a4-a77e-407a-ac94-a3ee9c05765b",
      "name": "Build HubSpot Review Task",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1350,
        300
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.hubapi.com/crm/v3/objects/tasks",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendBody": true,
        "contentType": "json",
        "specifyBody": "json",
        "jsonBody": "={{ $json.task }}",
        "options": {
          "timeout": 30000
        }
      },
      "id": "e7b84ec6-6835-41ad-8dcd-a006cbde845a",
      "name": "Create HubSpot Review Task",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1620,
        300
      ],
      "credentials": {
        "httpHeaderAuth": {
          "id": "REPLACE_HUBSPOT_CREDENTIAL",
          "name": "ATTRIBUTION HubSpot"
        }
      },
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 2000
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "SELECT duplicate_cleanup.attach_task($1::uuid,$2::text);",
        "options": {
          "queryReplacement": "={{ [$('Build HubSpot Review Task').first().json.candidate_id,$json.id] }}"
        }
      },
      "id": "3e785211-6422-4b3c-9899-100f32ff76d6",
      "name": "Save Task ID",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.6,
      "position": [
        1890,
        300
      ],
      "credentials": {
        "postgres": {
          "id": "REPLACE_POSTGRES_CREDENTIAL",
          "name": "ATTRIBUTION Postgres"
        }
      }
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "SELECT duplicate_cleanup.finish_event($1::uuid);",
        "options": {
          "queryReplacement": "={{ [$('Claim One Scan').first().json.job.event_id] }}"
        }
      },
      "id": "f8fbc8cf-80b2-4b70-a46d-154e9cef016a",
      "name": "Finish Without Task",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.6,
      "position": [
        0,
        600
      ],
      "credentials": {
        "postgres": {
          "id": "REPLACE_POSTGRES_CREDENTIAL",
          "name": "ATTRIBUTION Postgres"
        }
      }
    },
    {
      "parameters": {
        "content": "## Candidate detection and master recommendation\nReads authoritative HubSpot data, normalizes only in memory, searches exact and fuzzy candidates, then scores locally.\nNo CRM fields are overwritten and no merge happens here. Score 40+ creates a candidate; score 60+ can create a HubSpot task.\nSet portal_id and optional review_owner_id. Bind workflow 01 and select workflow 06 as Error Workflow.",
        "width": 700,
        "height": 220
      },
      "id": "b02e64d1-1865-4be6-831d-0744766b2bbb",
      "name": "Workflow Guide",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        0,
        -270
      ]
    }
  ],
  "connections": {
    "Intake Event": {
      "main": [
        [
          {
            "node": "Detection Configuration",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Drain Queue": {
      "main": [
        [
          {
            "node": "Detection Configuration",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Run Detector Manually": {
      "main": [
        [
          {
            "node": "Detection Configuration",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Detection Configuration": {
      "main": [
        [
          {
            "node": "Claim One Scan",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Claim One Scan": {
      "main": [
        [
          {
            "node": "Job Found?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Job Found?": {
      "main": [
        [
          {
            "node": "Build Source Read",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Source Read": {
      "main": [
        [
          {
            "node": "Read Authoritative HubSpot Record",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read Authoritative HubSpot Record": {
      "main": [
        [
          {
            "node": "Prepare Candidate Searches",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare Candidate Searches": {
      "main": [
        [
          {
            "node": "Search HubSpot Candidates",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Search HubSpot Candidates": {
      "main": [
        [
          {
            "node": "Score and Recommend Master",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Score and Recommend Master": {
      "main": [
        [
          {
            "node": "Persist Candidate and Audit",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Persist Candidate and Audit": {
      "main": [
        [
          {
            "node": "Create Review Task?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Create Review Task?": {
      "main": [
        [
          {
            "node": "Build HubSpot Review Task",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Finish Without Task",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build HubSpot Review Task": {
      "main": [
        [
          {
            "node": "Create HubSpot Review Task",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Create HubSpot Review Task": {
      "main": [
        [
          {
            "node": "Save Task ID",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1",
    "timezone": "Asia/Tbilisi",
    "saveExecutionProgress": true
  },
  "active": false,
  "pinData": {},
  "tags": []
}
