{
  "name": "REVOPS-LI-006 | 04 | GA4 traffic attribution sync",
  "nodes": [
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "cronExpression",
              "expression": "0 8 * * *"
            }
          ]
        }
      },
      "id": "63045faa-b9ad-4635-8cfa-e7fecf1a4c4f",
      "name": "Daily Traffic Refresh",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        0,
        0
      ]
    },
    {
      "parameters": {},
      "id": "5e40ba7d-abcd-4e81-98dc-eed80bbab37d",
      "name": "Refresh Traffic Manually",
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "position": [
        270,
        0
      ]
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "const config={tenant:'REVOPS-LI-006',property_id:'REPLACE_GA4_NUMERIC_PROPERTY_ID',property_timezone:'REPLACE_GA4_PROPERTY_TIMEZONE',lookback_days:7,page_size:10000,max_rows:250000};\nif(!/^\\d+$/.test(config.property_id))throw new Error('Set numeric GA4 property ID (not G- measurement ID)');\nif(config.lookback_days<1||config.lookback_days>90||!Number.isInteger(config.lookback_days))throw new Error('lookback_days must be 1..90');\nif(config.page_size<1||config.page_size>250000||config.max_rows<config.page_size)throw new Error('Invalid page limits');\nconst today=new Intl.DateTimeFormat('en-CA',{timeZone:config.property_timezone,year:'numeric',month:'2-digit',day:'2-digit'}).format(new Date());\nconst day=new Date(today+'T12:00:00Z');const dateShift=n=>new Date(day.getTime()+n*86400000).toISOString().slice(0,10);\nconfig.start_date=dateShift(-config.lookback_days);config.end_date=dateShift(-1);\nreturn [{json:{config,run_id:String($execution.id),offset:0}}];"
      },
      "id": "09fa9256-d9ea-4be2-9d87-f48dddfc92e8",
      "name": "GA4 Configuration",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        540,
        0
      ]
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "INSERT INTO attribution.ga4_runs(run_id,tenant,property_id,start_date,end_date) VALUES($1::text,$2::text,$3::text,$4::date,$5::date) RETURNING $6::jsonb AS context;",
        "options": {
          "queryReplacement": "={{ [$json.run_id,$json.config.tenant,$json.config.property_id,$json.config.start_date,$json.config.end_date,JSON.stringify($json)] }}"
        }
      },
      "id": "4cdf85de-23e9-4265-9c00-eda5755a3c39",
      "name": "Start Isolated Snapshot",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.6,
      "position": [
        810,
        0
      ],
      "credentials": {
        "postgres": {
          "id": "REPLACE_POSTGRES_CREDENTIAL",
          "name": "ATTRIBUTION Postgres"
        }
      }
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "const c=$input.first().json.context||$input.first().json;const dimensions=['date','sessionSource','sessionMedium','sessionCampaignName','sessionDefaultChannelGroup'];const metrics=['sessions','engagedSessions','screenPageViews','keyEvents'];\nreturn [{json:{...c,report_request:{dateRanges:[{startDate:c.config.start_date,endDate:c.config.end_date}],dimensions:dimensions.map(name=>({name})),metrics:metrics.map(name=>({name})),offset:String(c.offset),limit:String(c.config.page_size),orderBys:dimensions.map(dimensionName=>({dimension:{dimensionName},desc:false})),returnPropertyQuota:true,keepEmptyRows:false}}}];"
      },
      "id": "c474134f-057a-44dd-ac98-d8fb027eba62",
      "name": "Prepare GA4 Page",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1080,
        0
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ 'https://analyticsdata.googleapis.com/v1beta/properties/'+$json.config.property_id+':runReport' }}",
        "authentication": "genericCredentialType",
        "genericAuthType": "oAuth2Api",
        "sendBody": true,
        "contentType": "json",
        "specifyBody": "json",
        "jsonBody": "={{ $json.report_request }}",
        "options": {
          "timeout": 60000
        }
      },
      "id": "55eed347-1ce1-4fc8-8642-f051bad60701",
      "name": "Fetch GA4 Traffic Page",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1350,
        0
      ],
      "credentials": {
        "oAuth2Api": {
          "id": "REPLACE_GA4_CREDENTIAL",
          "name": "ATTRIBUTION GA4"
        }
      },
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 3000
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "const A = (function attributionLibrary() {\n const utmKeys=['utm_source','utm_medium','utm_campaign','utm_content','utm_term','utm_id'];\n const clickKeys=['gclid','fbclid','msclkid','ttclid','li_fat_id'];\n const sourceAliases={li:'linkedin',linkedin_ads:'linkedin','linkedin.com':'linkedin',fb:'facebook',meta:'facebook','facebook.com':'facebook',google_ads:'google','google.com':'google','bing.com':'bing',twitter:'x','twitter.com':'x','x.com':'x'};\n const mediumAliases={ppc:'cpc',paid_social:'paid_social',paidsocial:'paid_social',cpc_social:'paid_social',organic_social:'organic_social',organicsocial:'organic_social',email_newsletter:'email',newsletter:'email',e_mail:'email'};\n const social=new Set(['linkedin','facebook','instagram','tiktok','x','pinterest','reddit','snapchat']);\n const search=new Set(['google','bing','yahoo','duckduckgo','baidu','yandex']);\n const clean=v=>typeof v==='string'?v.normalize('NFKC').trim().toLowerCase().replace(/[\\s-]+/g,'_').replace(/_+/g,'_').replace(/^_|_$/g,''):null;\n function source(v){const s=clean(v);return sourceAliases[s]||s||null}\n function medium(v,s){let m=clean(v);m=mediumAliases[m]||m;if(m==='cpc')m=social.has(s)?'paid_social':search.has(s)?'paid_search':'paid_unknown';return m||null}\n // Portable inside n8n Code sandboxes: no Node require(), URL or external modules.\n function parseUrl(v){\n  if(typeof v!=='string'||/[\\s\\u0000-\\u001f]/.test(v))return null;\n  const m=v.match(/^(https?):\\/\\/([^/?#]+)([^?#]*)(?:\\?([^#]*))?(?:#.*)?$/i);if(!m)return null;\n  const host=m[2].match(/^([a-z0-9.-]+)(?::(\\d{1,5}))?$/i);if(!host)return null;\n  const decode=x=>{try{return decodeURIComponent(x.replace(/\\+/g,' '))}catch{return x}};\n  const pairs=(m[4]||'').split('&').filter(Boolean).map(raw=>{const index=raw.indexOf('=');return {raw,key:decode(index<0?raw:raw.slice(0,index)),value:decode(index<0?'':raw.slice(index+1))}});\n  return {hostname:host[1].toLowerCase(),base:m[1].toLowerCase()+'://'+m[2].toLowerCase()+(m[3]||'/'),pairs,get:key=>pairs.find(p=>p.key===key)?.value||null};\n }\n function url(v){const u=parseUrl(v);if(!u)return null;const kept=u.pairs.filter(p=>!/^utm_/i.test(p.key)&&!clickKeys.includes(p.key)&&!['_ga','_gl','mc_cid','mc_eid'].includes(p.key));return u.base+(kept.length?'?'+kept.map(p=>p.raw).join('&'):'')}\n function touch(raw,receivedAt,config){\n  const issues=[];const warnings=[];const values={};\n  const landing=parseUrl(raw.landing_page_url||raw.page_url||raw.conversion_page_url||'');\n  for(const k of utmKeys){const form=raw[k];const fromUrl=landing?.get(k);const canonical=v=>k==='utm_source'?source(v):k==='utm_medium'?medium(v,source(raw.utm_source||landing?.get('utm_source'))):clean(v);if(form&&fromUrl&&canonical(form)!==canonical(fromUrl))issues.push('FIELD_URL_DISAGREEMENT:'+k);if(new Set((landing?.pairs||[]).filter(p=>p.key===k).map(p=>canonical(p.value))).size>1)issues.push('DUPLICATE_URL_UTM:'+k);values[k]=typeof form==='string'&&form.trim()?form:fromUrl||null}\n  values.utm_source=source(values.utm_source);values.utm_medium=medium(values.utm_medium,values.utm_source);\n  for(const k of utmKeys.filter(k=>!['utm_source','utm_medium'].includes(k)))values[k]=clean(values[k])||null;\n  const clicks={};for(const k of clickKeys){const v=raw[k]||landing?.get(k);if(typeof v==='string'&&v.trim())clicks[k]=v.trim()}\n  let category='UNKNOWN';let evidence='unknown';let src=values.utm_source;let med=values.utm_medium;\n  if(src&&med){evidence='utm';category=({paid_search:'PAID_SEARCH',paid_social:'PAID_SOCIAL',organic:'ORGANIC_SEARCH',organic_search:'ORGANIC_SEARCH',organic_social:'ORGANIC_SOCIAL',email:'EMAIL',referral:'REFERRAL',partner:'PARTNER',event:'EVENT',webinar:'WEBINAR',outbound:'OUTBOUND',display:'DISPLAY',paid_video:'PAID_VIDEO',video:'VIDEO',affiliate:'AFFILIATE',affiliates:'AFFILIATE',cross_network:'CROSS_NETWORK'})[med]||'UNKNOWN';if(src==='(direct)'&&['(none)','none'].includes(med)){category='DIRECT_TRAFFIC';evidence='explicit_direct'}}\n  if(category==='UNKNOWN'&&Object.keys(clicks).length){evidence='click_id';if(!src)src=clicks.gclid?'google':clicks.msclkid?'bing':clicks.fbclid?'facebook':clicks.ttclid?'tiktok':clicks.li_fat_id?'linkedin':null;warnings.push('CLICK_ID_CHANNEL_UNRESOLVED');}\n  if(category==='UNKNOWN'&&src&&med==='social')warnings.push('SOCIAL_PAID_OR_ORGANIC_UNRESOLVED');\n  if(category==='UNKNOWN'&&!Object.keys(clicks).length&&!values.utm_source&&!values.utm_medium){\n   const r=parseUrl(raw.referrer_url);\n   if(r&&!config.own_domains.some(d=>r.hostname===d||r.hostname.endsWith('.'+d))){src=source(r.hostname.replace(/^www\\./,''));med='referral';category='REFERRAL';evidence='external_referrer';warnings.push('REFERRER_CLASSIFICATION_HEURISTIC')}\n   else if(raw.direct_evidence===true&&raw.tracking_complete===true){src='(direct)';med='(none)';category='DIRECT_TRAFFIC';evidence='observed_direct'}\n  }\n  if(!src&&category==='UNKNOWN')src='unknown';\n  values.utm_source=src;values.utm_medium=med;\n  const stamp=raw.conversion_timestamp||raw.timestamp||raw.submission_timestamp;\n  let timestamp=receivedAt;let inferred=!stamp;\n  if(stamp){if(typeof stamp!=='string'||!/(Z|[+-]\\d\\d:\\d\\d)$/.test(stamp)||!Number.isFinite(Date.parse(stamp)))issues.push('INVALID_TIMESTAMP');else timestamp=new Date(stamp).toISOString()}\n  if(Date.parse(timestamp)>Date.parse(receivedAt)+300000)issues.push('FUTURE_TIMESTAMP');\n  const full=!!values.utm_source&&!!values.utm_medium&&!!values.utm_campaign&&category!=='UNKNOWN';\n  const status=issues.length?'ATTRIBUTION_CONFLICT':full?'ATTRIBUTION_COMPLETE':category!=='UNKNOWN'||Object.keys(clicks).length||values.utm_source!=='unknown'?'ATTRIBUTION_PARTIAL':raw.form_type==='embedded'?'ATTRIBUTION_INCOMPLETE':'ATTRIBUTION_UNKNOWN';\n  return {...values,click_ids:clicks,category,evidence,status,timestamp,timestamp_inferred:inferred,issues,warnings,landing_page_url:url(raw.landing_page_url||raw.page_url),conversion_page_url:url(raw.conversion_page_url||raw.form_page_url||raw.page_url),referrer_url:url(raw.referrer_url),form_id:typeof raw.form_id==='string'?raw.form_id.trim():null,form_name:typeof raw.form_name==='string'?raw.form_name.trim():null,event_name:typeof raw.conversion_event_name==='string'?raw.conversion_event_name:'unknown_conversion'};\n }\n function resolve(job,related,config){\n  const raw=job.raw;const matching=related.filter(e=>e.event_id!==job.event_id&&raw.submission_id&&e.raw.submission_id===raw.submission_id);\n  const candidates=[job,...matching];\n  const emailSet=new Set(candidates.map(e=>typeof e.raw.email==='string'?e.raw.email.trim().toLowerCase():'').filter(Boolean));\n  const idSet=new Set(candidates.map(e=>String(e.raw.contact_id||'')).filter(Boolean));\n  const issues=[];\n  if(emailSet.size>1||idSet.size>1)issues.push('IDENTITY_CONFLICT');\n  const email=[...emailSet][0]||null;const contact_id=[...idSet][0]||null;\n  if(email&&!/^[^\\s<>@]+@[^\\s<>@]+\\.[^\\s<>@]+$/.test(email))issues.push('INVALID_EMAIL_IDENTITY');\n  if(contact_id&&!/^\\d+$/.test(contact_id))issues.push('INVALID_CONTACT_ID');\n  const normalized=candidates.map(e=>({event:e,touch:touch(e.raw,e.received_at,config)}));\n  for(const k of ['utm_source','utm_medium','utm_campaign']){\n   const set=new Set(normalized.filter(x=>x.touch.evidence==='utm').map(x=>x.touch[k]).filter(Boolean));\n   if(set.size>1)issues.push('SUBMISSION_SOURCE_CONFLICT:'+k);\n  }\n  const rank=x=>(x.touch.status==='ATTRIBUTION_COMPLETE'?100:0)+({form:40,website:30,crm:20,analytics:10}[x.event.raw.producer]||0)+(x.touch.evidence==='utm'?20:0);\n  normalized.sort((a,b)=>rank(b)-rank(a)||Date.parse(a.event.received_at)-Date.parse(b.event.received_at));\n  const selected=normalized[0];\n  const firsts=normalized.flatMap(x=>{\n   const arr=[x.touch];if(x.event.raw.first_touch&&typeof x.event.raw.first_touch==='object'){const f=touch(x.event.raw.first_touch,x.event.received_at,config);arr.push(f);issues.push(...f.issues.map(i=>'FIRST_TOUCH:'+i))}return arr;\n  }).filter(t=>t.category!=='UNKNOWN'&&!t.issues.length).sort((a,b)=>Date.parse(a.timestamp)-Date.parse(b.timestamp));\n  issues.push(...selected.touch.issues);\n  return {job,email,contact_id,existing_review_task_id:candidates.find(e=>e.review_task_id)?.review_task_id||null,match_method:raw.email||raw.contact_id?'direct_identity':matching.length&&(email||contact_id)?'shared_submission_id':'unmatched',related_event_ids:matching.map(e=>e.event_id),suggested_event_ids:related.filter(e=>!matching.includes(e)).map(e=>e.event_id),current:selected.touch,earliest:firsts[0]||null,conflicts:[...new Set(issues)],config,lookup_allowed:!!(email||contact_id)&&!issues.some(s=>s.includes('IDENTITY')||s==='INVALID_CONTACT_ID')};\n }\n function decide(ctx,response){\n  const conflict=[...ctx.conflicts];const warnings=[...ctx.current.warnings];let contact=null;\n  if(response){if(response.statusCode===200)contact=response.body;else if(response.statusCode!==404)throw new Error('HubSpot lookup failed: HTTP '+response.statusCode)}\n  const p=contact?.properties||{};const fields={};const preserved=[];\n  if(contact&&ctx.email&&String(p.email||'').toLowerCase()!==ctx.email)conflict.push('CONTACT_EMAIL_ID_MISMATCH');\n  const truth=v=>v===true||v==='true';const locked=truth(p.revops_attribution_locked);\n  if(truth(p.revops_source_conflict_flag))conflict.push('UNRESOLVED_CRM_SOURCE_CONFLICT');\n  if(locked)preserved.push('ALL_SOURCE_FIELDS_USER_LOCKED');\n  const current=ctx.current;const earliest=ctx.earliest;\n  if(p.revops_first_touch_at&&(!Number.isFinite(Date.parse(p.revops_first_touch_at))))conflict.push('EXISTING_FIRST_TIMESTAMP_INVALID');\n  if(p.revops_latest_touch_at&&(!Number.isFinite(Date.parse(p.revops_latest_touch_at))))conflict.push('EXISTING_LATEST_TIMESTAMP_INVALID');\n  if(earliest&&p.revops_original_source&&p.revops_first_touch_at&&Date.parse(earliest.timestamp)<Date.parse(p.revops_first_touch_at)-1000)conflict.push('OLDER_FIRST_TOUCH_DISCOVERED');\n  if(p.revops_original_source==='UNKNOWN'&&earliest)conflict.push('UNKNOWN_ORIGINAL_REQUIRES_REVIEW');\n  if(p.revops_latest_touch_at&&Date.parse(current.timestamp)===Date.parse(p.revops_latest_touch_at)&&p.revops_latest_source&&p.revops_latest_source!==current.category)conflict.push('EQUAL_TIMESTAMP_DIFFERENT_SOURCE');\n  if(p.revops_latest_touch_at&&!locked&&!conflict.length&&Date.parse(current.timestamp)<Date.parse(p.revops_latest_touch_at))preserved.push('LATEST_SOURCE_NEWER_THAN_EVENT');\n  const status=conflict.length?'ATTRIBUTION_CONFLICT':current.status;\n  const reliable=current.category!=='UNKNOWN'&&!current.issues.length;\n  const latestNewer=!p.revops_latest_touch_at||Date.parse(current.timestamp)>Date.parse(p.revops_latest_touch_at);\n  if(contact&&!locked&&!conflict.length){\n   if(!p.revops_original_source&&earliest){fields.revops_original_source=earliest.category;fields.revops_original_source_detail=[earliest.utm_source,earliest.utm_medium,earliest.utm_campaign].filter(Boolean).join(' / ');fields.revops_first_touch_at=earliest.timestamp;}\n   else preserved.push('ORIGINAL_SOURCE_AND_TIMESTAMP');\n   if(reliable&&latestNewer){\n    Object.assign(fields,{revops_latest_source:current.category,revops_latest_source_detail:[current.utm_source,current.utm_medium,current.utm_campaign].filter(Boolean).join(' / '),revops_latest_touch_at:current.timestamp});\n    for(const k of utmKeys)fields['revops_'+k]=current[k]||'';\n    for(const k of ['landing_page_url','conversion_page_url','referrer_url','form_id','form_name'])fields['revops_'+k]=current[k]||'';\n    fields.revops_conversion_event_name=current.event_name;\n    fields.revops_last_submission_id=ctx.job.raw.submission_id||ctx.job.event_id;\n    fields.revops_attribution_status=status;fields.revops_source_cleanup_status=current.timestamp_inferred?'TIMESTAMP_INFERRED':'NORMALIZED';\n    fields.revops_source_conflict_flag='false';fields.revops_source_last_updated_at=new Date().toISOString();\n   } else if(!p.revops_latest_touch_at){fields.revops_attribution_status=status;fields.revops_source_cleanup_status=current.timestamp_inferred?'TIMESTAMP_INFERRED':'HISTORY_ONLY';}\n  }\n  if(contact&&!locked&&conflict.length){fields.revops_source_conflict_flag='true';fields.revops_attribution_status='ATTRIBUTION_CONFLICT';fields.revops_source_cleanup_status='REVIEW_REQUIRED';}\n  let action=locked?'history_only':conflict.length?'review':!contact?'awaiting_contact':Object.keys(fields).length?'completed':'history_only';\n  if(!ctx.lookup_allowed&&!conflict.length)action='awaiting_match';\n  const reviewRequired=!!conflict.length||current.category==='UNKNOWN'||['ATTRIBUTION_UNKNOWN','ATTRIBUTION_INCOMPLETE'].includes(status)||(Object.keys(current.click_ids).length>0&&!current.utm_campaign);\n  if(reviewRequired&&contact&&!locked)action='review';\n  const crmPatch=ctx.config.write_to_hubspot===true&&contact&&!locked&&Object.keys(fields).length>0;\n  const createTask=ctx.config.write_to_hubspot===true&&ctx.config.create_review_tasks===true&&reviewRequired&&!!contact&&!locked&&!ctx.existing_review_task_id;\n  const taskProperties={hs_timestamp:new Date(Date.now()+86400000).toISOString(),hs_task_subject:'Review lead attribution: '+ctx.job.event_id,hs_task_body:'Workflow REVOPS-LI-006\\nEvent '+ctx.job.event_id+'\\nReasons: '+[...conflict,...warnings,status].join('; ')+'\\nRaw/normalized payloads and proposed changes are in the attribution audit vault.',hs_task_status:'NOT_STARTED',hs_task_type:'TODO',hs_task_priority:'MEDIUM'};\n  const owner=ctx.config.review_owner_id||p.hubspot_owner_id;if(owner)taskProperties.hubspot_owner_id=String(owner);\n  return {...ctx,contact_id:contact?.id||null,company_ids:(contact?.associations?.companies?.results||[]).map(x=>x.id),previous_properties:p,proposed_properties:fields,fields_preserved:preserved,conflicts:[...new Set(conflict)],warnings,status,action,review_required:reviewRequired,crm_patch:!!crmPatch,create_task:!!createTask,task:{properties:taskProperties,associations:contact?[{to:{id:contact.id},types:[{associationCategory:'HUBSPOT_DEFINED',associationTypeId:204}]}]:[]}};\n }\n return {clean,source,medium,url,touch,resolve,decide};\n})();\nconst response=$input.first().json;const c=$('Prepare GA4 Page').item.json;\nconst expectedDimensions=['date','sessionSource','sessionMedium','sessionCampaignName','sessionDefaultChannelGroup'];const expectedMetrics=['sessions','engagedSessions','screenPageViews','keyEvents'];\nif(JSON.stringify((response.dimensionHeaders||[]).map(x=>x.name))!==JSON.stringify(expectedDimensions)||JSON.stringify((response.metricHeaders||[]).map(x=>x.name))!==JSON.stringify(expectedMetrics))throw new Error('Unexpected GA4 response schema');\nif(response.metadata?.timeZone&&response.metadata.timeZone!==c.config.property_timezone)throw new Error('Configured timezone differs from GA4 property timezone');\nconst rows=response.rows||[];const count=Number(response.rowCount||0);\nif(!Number.isInteger(count)||count<0||count>c.config.max_rows)throw new Error('GA4 row cap exceeded or invalid count; snapshot not published');\nif(!rows.length&&c.offset<count)throw new Error('Empty GA4 page before rowCount reached');\nconst parsed=rows.map(row=>{const d=row.dimensionValues.map(x=>x.value);const m=row.metricValues.map(x=>Number(x.value));if(d.length!==5||m.length!==4||!/^\\d{8}$/.test(d[0])||m.some(x=>!Number.isFinite(x)||x<0))throw new Error('Invalid GA4 row');const channelMedium={'Paid Search':'paid_search','Paid Social':'paid_social','Cross-network':'cross_network','Display':'display','Organic Search':'organic_search','Organic Social':'organic_social','Email':'email'};return {date:d[0].slice(0,4)+'-'+d[0].slice(4,6)+'-'+d[0].slice(6),raw_source:d[1],raw_medium:d[2],raw_campaign:d[3],ga4_channel:d[4],source:A.source(d[1]),medium:channelMedium[d[4]]||A.clean(d[2]),campaign:A.clean(d[3]),sessions:m[0],engaged_sessions:m[1],page_views:m[2],key_events:m[3]}});\nreturn [{json:{config:c.config,run_id:c.run_id,offset:c.offset,next_offset:c.offset+rows.length,has_more:c.offset+rows.length<count,rows:parsed,metadata:response.metadata||{},property_quota:response.propertyQuota||{},row_count:count}}];"
      },
      "id": "e7bdf9ac-f2ba-4012-8dc5-a4f30d0470f4",
      "name": "Validate GA4 Page",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1620,
        0
      ]
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "SELECT attribution.stage_ga4_page($1::text,$2::jsonb,$3::jsonb) AS staged,$4::jsonb AS context;",
        "options": {
          "queryReplacement": "={{ [$json.run_id,JSON.stringify($json.rows),JSON.stringify({metadata:$json.metadata,row_count:$json.row_count,quota:$json.property_quota}),JSON.stringify({...$json,rows:undefined})] }}"
        }
      },
      "id": "403198f7-371d-40b6-9c1f-3bfd63ee0192",
      "name": "Stage GA4 Page",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.6,
      "position": [
        1890,
        0
      ],
      "credentials": {
        "postgres": {
          "id": "REPLACE_POSTGRES_CREDENTIAL",
          "name": "ATTRIBUTION Postgres"
        }
      }
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "1b5d6784-c786-4050-a537-33d969e46e10",
              "leftValue": "={{ $json.context.has_more === true }}",
              "rightValue": true,
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "14d9beb1-8a81-4b1d-b24f-e067f92170a1",
      "name": "More GA4 Pages?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        0,
        300
      ]
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "const c=$input.first().json.context;return [{json:{...c,offset:c.next_offset}}];"
      },
      "id": "f37ea80d-67f8-4313-ab12-d4e150736483",
      "name": "Advance Page",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        270,
        300
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ 'https://analyticsdata.googleapis.com/v1beta/properties/'+$('GA4 Configuration').first().json.config.property_id+':runReport' }}",
        "authentication": "genericCredentialType",
        "genericAuthType": "oAuth2Api",
        "sendBody": true,
        "contentType": "json",
        "specifyBody": "json",
        "jsonBody": "={{ {dateRanges:[{startDate:$('GA4 Configuration').first().json.config.start_date,endDate:$('GA4 Configuration').first().json.config.end_date}],metrics:['sessions','totalUsers','engagedSessions','screenPageViews','keyEvents'].map(name=>({name})),returnPropertyQuota:true} }}",
        "options": {
          "timeout": 60000
        }
      },
      "id": "39ad21d5-a418-48c0-b702-09fac00b47bf",
      "name": "Fetch Overall Totals",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        540,
        300
      ],
      "credentials": {
        "oAuth2Api": {
          "id": "REPLACE_GA4_CREDENTIAL",
          "name": "ATTRIBUTION GA4"
        }
      },
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 3000
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "const r=$input.first().json;const expected=['sessions','totalUsers','engagedSessions','screenPageViews','keyEvents'];if(JSON.stringify((r.metricHeaders||[]).map(x=>x.name))!==JSON.stringify(expected))throw new Error('Unexpected totals schema');const values=(r.rows?.[0]?.metricValues||expected.map(()=>({value:'0'}))).map(x=>Number(x.value));if(values.length!==5||values.some(v=>!Number.isFinite(v)||v<0))throw new Error('Invalid totals');return [{json:{totals:Object.fromEntries(expected.map((k,i)=>[k,values[i]])),metadata:r.metadata||{}}}];"
      },
      "id": "b6c1d3a0-7c9e-4022-bd38-be9d61ea60db",
      "name": "Validate Overall Totals",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        810,
        300
      ]
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "SELECT attribution.publish_ga4($1::text,$2::jsonb,$3::jsonb) AS result;",
        "options": {
          "queryReplacement": "={{ [$('GA4 Configuration').first().json.run_id,JSON.stringify($json.totals),JSON.stringify($json.metadata)] }}"
        }
      },
      "id": "56e4e98b-55dc-4605-a40a-d60319d32eba",
      "name": "Publish Complete Traffic Snapshot",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.6,
      "position": [
        1080,
        300
      ],
      "credentials": {
        "postgres": {
          "id": "REPLACE_POSTGRES_CREDENTIAL",
          "name": "ATTRIBUTION Postgres"
        }
      }
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "SELECT attribution.report($1::text,$2::text) AS report;",
        "options": {
          "queryReplacement": "={{ [$('GA4 Configuration').first().json.config.tenant,$('GA4 Configuration').first().json.config.property_id] }}"
        }
      },
      "id": "d7289567-6cf8-4fd1-8e41-6cefde62f9c7",
      "name": "Return Attribution Report",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.6,
      "position": [
        1350,
        300
      ],
      "credentials": {
        "postgres": {
          "id": "REPLACE_POSTGRES_CREDENTIAL",
          "name": "ATTRIBUTION Postgres"
        }
      }
    },
    {
      "parameters": {
        "content": "## GA4 overall traffic\nSet property ID, exact GA4 property timezone and Google OAuth credential.\nFetches session-level source/medium/campaign/channel for seven complete days, with pagination.\nA separate unsegmented totals request supplies distinct users.\nOnly a fully loaded snapshot is published. No email/contact data is sent to GA4.",
        "width": 700,
        "height": 220
      },
      "id": "b3de8a08-a8ea-442c-88a4-657c66f48ef3",
      "name": "Workflow Guide",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        0,
        -270
      ]
    }
  ],
  "connections": {
    "Daily Traffic Refresh": {
      "main": [
        [
          {
            "node": "GA4 Configuration",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "GA4 Configuration": {
      "main": [
        [
          {
            "node": "Start Isolated Snapshot",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Start Isolated Snapshot": {
      "main": [
        [
          {
            "node": "Prepare GA4 Page",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare GA4 Page": {
      "main": [
        [
          {
            "node": "Fetch GA4 Traffic Page",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch GA4 Traffic Page": {
      "main": [
        [
          {
            "node": "Validate GA4 Page",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Validate GA4 Page": {
      "main": [
        [
          {
            "node": "Stage GA4 Page",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Stage GA4 Page": {
      "main": [
        [
          {
            "node": "More GA4 Pages?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Refresh Traffic Manually": {
      "main": [
        [
          {
            "node": "GA4 Configuration",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "More GA4 Pages?": {
      "main": [
        [
          {
            "node": "Advance Page",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Fetch Overall Totals",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Advance Page": {
      "main": [
        [
          {
            "node": "Prepare GA4 Page",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch Overall Totals": {
      "main": [
        [
          {
            "node": "Validate Overall Totals",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Validate Overall Totals": {
      "main": [
        [
          {
            "node": "Publish Complete Traffic Snapshot",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Publish Complete Traffic Snapshot": {
      "main": [
        [
          {
            "node": "Return Attribution Report",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1",
    "timezone": "Asia/Tbilisi",
    "saveExecutionProgress": true
  },
  "active": false,
  "pinData": {},
  "tags": []
}

