{
  "name": "Multi-Touch Attribution Tracker",
  "nodes": [
    {
      "id": "144fa6d5-408a-49bc-be1d-a79f8cf6e901",
      "name": "Weekly Attribution Run",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        0,
        112
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "cronExpression",
              "expression": "25 8 * * 1"
            }
          ]
        }
      }
    },
    {
      "id": "bfd5e60b-0c4a-464a-b2db-13b5adf94f75",
      "name": "Get Closed Won Deals",
      "type": "n8n-nodes-base.salesforce",
      "typeVersion": 1.1,
      "position": [
        224,
        112
      ],
      "parameters": {
        "resource": "search",
        "operation": "query",
        "query": "SELECT Id, Name, Amount, CloseDate, Account.Name FROM Opportunity WHERE IsWon = true"
      },
      "executeOnce": true,
      "credentials": {
        "salesforceOAuth2Api": {
          "id": "553Tae1kPe5Wpthq",
          "name": "Salesforce account"
        }
      }
    },
    {
      "id": "94534deb-72bc-495f-bda2-b83a97744981",
      "name": "Get Touch Timeline",
      "type": "n8n-nodes-base.dataTable",
      "typeVersion": 1.1,
      "position": [
        448,
        112
      ],
      "parameters": {
        "resource": "row",
        "operation": "get",
        "dataTableId": {
          "__rl": true,
          "mode": "id",
          "value": "IXWgX5Ogb1BL4V0z",
          "cachedResultName": "Seeded Touch Timeline"
        },
        "returnAll": true
      },
      "executeOnce": true
    },
    {
      "id": "89247450-424a-4fc1-8bef-15eef4af01ca",
      "name": "Get HubSpot Touch Coverage",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        672,
        112
      ],
      "parameters": {
        "method": "GET",
        "url": "https://api.hubapi.com/crm/v3/objects/contacts?limit=100&properties=email,hs_analytics_source,hs_analytics_num_page_views,num_conversion_events,hs_email_click,hs_email_open",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "hubspotAppToken",
        "options": {}
      },
      "executeOnce": true,
      "credentials": {
        "hubspotAppToken": {
          "id": "ynUSCUtMOnX1f4Jl",
          "name": "HubSpot Service Key account"
        }
      }
    },
    {
      "id": "3aad92ec-4974-47f3-82a7-246060331a3a",
      "name": "Get Previous Runs",
      "type": "n8n-nodes-base.dataTable",
      "typeVersion": 1.1,
      "position": [
        896,
        112
      ],
      "parameters": {
        "resource": "row",
        "operation": "get",
        "dataTableId": {
          "__rl": true,
          "mode": "id",
          "value": "giyVpQub5Utp4dL9",
          "cachedResultName": "Attribution Snapshots"
        },
        "returnAll": true
      },
      "executeOnce": true,
      "alwaysOutputData": true
    },
    {
      "id": "660e4934-b0f5-450b-84ad-5c1445fb23b5",
      "name": "Compute Attribution Models",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1120,
        112
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// Three attribution models over the same touch sequences, computed deterministically. Claude only describes the result.\nconst runDate = $today.toFormat('yyyy-MM-dd');\nconst periodEnd = runDate, periodStart = $today.minus({ days: 365 }).toFormat('yyyy-MM-dd');\nconst won = $('Get Closed Won Deals').all().map(i => i.json).filter(d => d.Id && d.CloseDate >= periodStart && d.CloseDate <= periodEnd);\nconst touches = $('Get Touch Timeline').all().map(i => i.json).filter(t => t.opportunityId);\nconst hs = ($('Get HubSpot Touch Coverage').first().json.results || []).map(c => c.properties || {});\nconst prevRows = $('Get Previous Runs').all().map(i => i.json).filter(r => r.runDate && r.channel === '(all)')\n  .sort((a, b) => String(b.loggedAt).localeCompare(String(a.loggedAt)));\n\nconst byDeal = {}; for (const t of touches) (byDeal[t.opportunityId] = byDeal[t.opportunityId] || []).push(t);\nconst ch = {}; const C = name => (ch[name] = ch[name] || { channel: name, first: 0, last: 0, linear: 0, deals: new Set(), touches: 0, campaigns: {} });\nlet attributed = 0, noTouch = [], totalTouches = 0, day7Touches = 0;\nfor (const d of won) {\n  const amt = Number(d.Amount) || 0;\n  const seq = (byDeal[d.Id] || []).filter(t => t.touchDate <= d.CloseDate).sort((a, b) => a.touchDate.localeCompare(b.touchDate) || a.touchOrder - b.touchOrder);\n  if (!seq.length) { noTouch.push(d.Name); continue; }\n  attributed += amt; totalTouches += seq.length;\n  C(seq[0].channel).first += amt;\n  C(seq[seq.length - 1].channel).last += amt;\n  for (const t of seq) {\n    const c = C(t.channel); c.linear += amt / seq.length; c.deals.add(d.Id); c.touches++;\n    c.campaigns[t.campaign] = (c.campaigns[t.campaign] || 0) + amt / seq.length;\n    if (t.source !== 'seeded') day7Touches++;\n  }\n}\nconst pct = v => attributed ? Math.round(1000 * v / attributed) / 10 : 0;\nconst channels = Object.values(ch).map(c => {\n  const s = { first: pct(c.first), last: pct(c.last), linear: pct(c.linear) };\n  const vals = [s.first, s.last, s.linear];\n  const topCampaign = Object.entries(c.campaigns).sort((a, b) => b[1] - a[1])[0];\n  return { channel: c.channel, firstTouchRevenue: Math.round(c.first), lastTouchRevenue: Math.round(c.last), linearRevenue: Math.round(c.linear),\n    firstShare: s.first, lastShare: s.last, linearShare: s.linear, spreadPts: Math.round(10 * (Math.max(...vals) - Math.min(...vals))) / 10,\n    dealsTouched: c.deals.size, touches: c.touches, topCampaign: topCampaign ? topCampaign[0] : '',\n    pattern: s.last - s.first >= 10 ? 'closer' : s.first - s.last >= 10 ? 'opener' : 'steady' };\n}).sort((a, b) => b.linearRevenue - a.linearRevenue);\nconst leader = k => channels.slice().sort((a, b) => b[k] - a[k])[0].channel;\nconst tracked = hs.filter(p => Number(p.hs_analytics_num_page_views) > 0 || Number(p.num_conversion_events) > 0 || Number(p.hs_email_click) > 0 || Number(p.hs_email_open) > 0).length;\nreturn [{ json: {\n  runDate, periodStart, periodEnd, wonDeals: won.length, dealsAttributed: won.length - noTouch.length, dealsWithoutTouches: noTouch,\n  attributedRevenue: Math.round(attributed), avgTouchesPerDeal: won.length - noTouch.length ? Math.round(10 * totalTouches / (won.length - noTouch.length)) / 10 : 0,\n  day7EventTouches: day7Touches, leaders: { first: leader('firstTouchRevenue'), last: leader('lastTouchRevenue'), linear: leader('linearRevenue') },\n  hubspot: { contacts: hs.length, contactsWithTrackedTouches: tracked },\n  previous: prevRows[0] ? { runDate: prevRows[0].runDate, narrative: prevRows[0].narrative } : null,\n  channels\n} }];\n"
      },
      "executeOnce": true
    },
    {
      "id": "f6571892-6cec-43df-b780-f69f4c6d996a",
      "name": "Attribution Narrator",
      "type": "@n8n/n8n-nodes-langchain.agent",
      "typeVersion": 3.1,
      "position": [
        1344,
        112
      ],
      "parameters": {
        "promptType": "define",
        "text": "={{ JSON.stringify({ period: $json.periodStart + ' to ' + $json.periodEnd, wonDeals: $json.wonDeals, attributedRevenue: $json.attributedRevenue, avgTouchesPerDeal: $json.avgTouchesPerDeal, leaders: $json.leaders, day7EventTouches: $json.day7EventTouches, touchesSeeded: true, hubspot: $json.hubspot, previousRun: $json.previous, channels: $json.channels.map(c => ({ channel: c.channel, firstShare: c.firstShare, lastShare: c.lastShare, linearShare: c.linearShare, spreadPts: c.spreadPts, pattern: c.pattern, dealsTouched: c.dealsTouched, topCampaign: c.topCampaign })) }, null, 1) }}",
        "hasOutputParser": true,
        "options": {
          "systemMessage": "You write the weekly multi-touch attribution note for Fortavault, Inc. (a fictional data protection company) marketing leadership. Three attribution models (first-touch, last-touch, linear) were ALREADY computed in code over the same touch sequences; you never compute, re-round or change any number. Quote shares exactly as given, with a % sign.\n\nWrite:\n- summary: 3 to 4 sentences, under 120 words. Do NOT simply restate each model's winner. Focus on where the models AGREE and where they DISAGREE, and what the disagreement means: a channel whose last-touch share is far above its first-touch share is closing deals other channels opened; a channel strong at first touch but near zero at last touch opens deals but doesn't close them; a channel steady across all three is a consistent contributor. Name the channels with the largest spread and say what that implies for budget.\n- caveat: one sentence. If touchesSeeded is true, say plainly that the touch timeline is seeded (except the Day 7 event touches), so the pattern reflects how the seed was built and is a demonstration of the method, not a finding. Also say HubSpot currently tracks no digital touches for these contacts, using the hubspot counts given.\n\nRules: use only the numbers given. Describe channels only as opening deals, closing deals or contributing consistently, as the pattern field says; never assign other roles or funnel positions (such as mid-funnel) that the data does not show. Never invent channels, campaigns, deals or causes. Never quote field names. Do not use the ~ character; write \"about\" or \"≈\" instead."
        }
      },
      "executeOnce": true
    },
    {
      "id": "4dc227ad-1dc8-4963-abee-4d6c70757c9c",
      "name": "Claude Sonnet",
      "type": "@n8n/n8n-nodes-langchain.lmChatAnthropic",
      "typeVersion": 1.3,
      "position": [
        1360,
        336
      ],
      "parameters": {
        "model": {
          "__rl": true,
          "mode": "list",
          "value": "claude-sonnet-4-6",
          "cachedResultName": "Claude Sonnet 4.6"
        },
        "options": {
          "temperature": 0.3
        }
      },
      "credentials": {
        "anthropicApi": {
          "id": "3d3ZiTi7BwnkFwqU",
          "name": "Anthropic account"
        }
      }
    },
    {
      "id": "dbd4d798-d8be-48c5-9bae-04f6997099a3",
      "name": "Summary Parser",
      "type": "@n8n/n8n-nodes-langchain.outputParserStructured",
      "typeVersion": 1.3,
      "position": [
        1488,
        336
      ],
      "parameters": {
        "schemaType": "fromJson",
        "jsonSchemaExample": "{ \"summary\": \"x\", \"caveat\": \"x\" }"
      }
    },
    {
      "id": "5689d9c2-af7e-4103-8a76-119727261556",
      "name": "Build Attribution Post",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1696,
        112
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "const f = $('Compute Attribution Models').first().json;\nconst n = $('Attribution Narrator').first().json.output || {};\nconst safe = t => String(t || '').replace(/~/g, '≈'); // Slack renders ~text~ as strikethrough\nconst K = v => v >= 1e6 ? '$' + (v / 1e6).toFixed(2) + 'M' : '$' + Math.round(v / 1000) + 'K';\nconst pad = (s, w) => (String(s) + ' '.repeat(w)).slice(0, w), lpad = (s, w) => (' '.repeat(w) + String(s)).slice(-w);\nconst tag = { closer: 'closes deals', opener: 'opens deals', steady: 'consistent' };\nconst rows = f.channels.map(c => pad(c.channel, 17) + lpad(c.firstShare + '%', 7) + lpad(c.lastShare + '%', 7) + lpad(c.linearShare + '%', 8) + lpad(c.spreadPts, 7) + '  ' + tag[c.pattern]);\nconst table = '```' + pad('Channel', 17) + lpad('First', 7) + lpad('Last', 7) + lpad('Linear', 8) + lpad('Spread', 7) + '\\n' + rows.join('\\n') + '```';\nconst text = ':compass: *Multi-Touch Attribution — Fortavault* (' + f.periodStart + ' → ' + f.periodEnd + ')\\n' +\n  f.wonDeals + ' Closed Won deals · ' + K(f.attributedRevenue) + ' attributed · ' + f.avgTouchesPerDeal + ' touches per deal on average\\n' +\n  'Leaders: first-touch *' + f.leaders.first + '* · last-touch *' + f.leaders.last + '* · linear *' + f.leaders.linear + '*\\n\\n' +\n  table + '\\n' + safe(n.summary) + '\\n\\n_' + safe(n.caveat) + '_\\n' +\n  '_Share of attributed revenue per model; spread = largest gap between models in points. ' + f.day7EventTouches + ' event touches come from the Day 7 Event ROI Log; HubSpot contacts with tracked digital touches: ' + f.hubspot.contactsWithTrackedTouches + ' of ' + f.hubspot.contacts + '._';\nreturn [{ json: { slackText: text.slice(0, 3000) } }];\n"
      }
    },
    {
      "id": "8c855c3f-dcb2-4071-9c1b-73aaef5a4c5b",
      "name": "Post Attribution to Slack",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.7,
      "position": [
        1920,
        16
      ],
      "parameters": {
        "resource": "message",
        "operation": "post",
        "select": "channel",
        "channelId": {
          "__rl": true,
          "mode": "id",
          "value": "C0BQ3G96HL6",
          "cachedResultName": "general"
        },
        "text": "={{ $json.slackText }}",
        "otherOptions": {
          "includeLinkToWorkflow": false
        }
      },
      "webhookId": "a4c19b78-abb2-4ba0-bb32-c4e954f4c474",
      "disabled": false,
      "credentials": {
        "slackApi": {
          "id": "mv7TIi8kucFPqPg6",
          "name": "Slack account"
        }
      }
    },
    {
      "id": "0bca58d8-2db9-4a2e-83ed-f6995100f208",
      "name": "Build Snapshot Rows",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1920,
        208
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "const f = $('Compute Attribution Models').first().json;\nconst n = $('Attribution Narrator').first().json.output || {};\nconst now = $now.toISO(), base = { runDate: f.runDate, periodStart: f.periodStart, periodEnd: f.periodEnd, loggedAt: now };\nconst rows = f.channels.map(c => Object.assign({}, base, { channel: c.channel, firstTouchRevenue: c.firstTouchRevenue, lastTouchRevenue: c.lastTouchRevenue,\n  linearRevenue: c.linearRevenue, firstShare: c.firstShare, lastShare: c.lastShare, linearShare: c.linearShare, spreadPts: c.spreadPts, dealsTouched: c.dealsTouched, narrative: '' }));\nrows.push(Object.assign({}, base, { channel: '(all)', firstTouchRevenue: f.attributedRevenue, lastTouchRevenue: f.attributedRevenue, linearRevenue: f.attributedRevenue,\n  firstShare: 100, lastShare: 100, linearShare: 100, spreadPts: 0, dealsTouched: f.dealsAttributed, narrative: String(n.summary || '') + ' ' + String(n.caveat || '') }));\nreturn rows.map(json => ({ json }));\n"
      }
    },
    {
      "id": "9bdacdee-6087-4d34-a7c7-dc63290d6751",
      "name": "Log Attribution Snapshot",
      "type": "n8n-nodes-base.dataTable",
      "typeVersion": 1.1,
      "position": [
        2144,
        208
      ],
      "parameters": {
        "resource": "row",
        "operation": "insert",
        "dataTableId": {
          "__rl": true,
          "mode": "id",
          "value": "giyVpQub5Utp4dL9",
          "cachedResultName": "Attribution Snapshots"
        },
        "columns": {
          "mappingMode": "autoMapInputData",
          "value": {}
        }
      },
      "disabled": false
    }
  ],
  "connections": {
    "Weekly Attribution Run": {
      "main": [
        [
          {
            "node": "Get Closed Won Deals",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get Closed Won Deals": {
      "main": [
        [
          {
            "node": "Get Touch Timeline",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get Touch Timeline": {
      "main": [
        [
          {
            "node": "Get HubSpot Touch Coverage",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get HubSpot Touch Coverage": {
      "main": [
        [
          {
            "node": "Get Previous Runs",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get Previous Runs": {
      "main": [
        [
          {
            "node": "Compute Attribution Models",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Compute Attribution Models": {
      "main": [
        [
          {
            "node": "Attribution Narrator",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Attribution Narrator": {
      "main": [
        [
          {
            "node": "Build Attribution Post",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Claude Sonnet": {
      "ai_languageModel": [
        [
          {
            "node": "Attribution Narrator",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    },
    "Summary Parser": {
      "ai_outputParser": [
        [
          {
            "node": "Attribution Narrator",
            "type": "ai_outputParser",
            "index": 0
          }
        ]
      ]
    },
    "Build Attribution Post": {
      "main": [
        [
          {
            "node": "Post Attribution to Slack",
            "type": "main",
            "index": 0
          },
          {
            "node": "Build Snapshot Rows",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Snapshot Rows": {
      "main": [
        [
          {
            "node": "Log Attribution Snapshot",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  },
  "pinData": {},
  "meta": {
    "templateCredsSetupCompleted": false
  }
}