{
  "name": "Renewal Health Storyteller",
  "nodes": [
    {
      "id": "2d52acb9-3436-4701-a607-e2f52733d3ec",
      "name": "Weekly Renewal Health Scan",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.4,
      "position": [0, 300],
      "parameters": { "rule": { "interval": [{ "field": "cronExpression", "expression": "0 8 * * 1" }] } }
    },
    {
      "id": "7dc555d3-16eb-45b9-af21-ecc7c58584bd",
      "name": "Fetch Closed Won Opportunities",
      "type": "n8n-nodes-base.salesforce",
      "typeVersion": 1.1,
      "position": [220, 300],
      "parameters": {
        "resource": "search",
        "operation": "query",
        "query": "SELECT Id, Name, AccountId, Account.Name, Account.Website, CloseDate, Amount, StageName, OwnerId, Owner.Name, Owner.Email FROM Opportunity WHERE StageName = 'Closed Won' ORDER BY CloseDate ASC LIMIT 300"
      },
      "credentials": { "salesforceOAuth2Api": { "id": "553Tae1kPe5Wpthq", "name": "Salesforce account" } }
    },
    {
      "id": "18b5c304-e229-4084-aa24-e0f95a12cde2",
      "name": "Compute Renewal Candidates",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [460, 300],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "const items = $input.all().map(i => i.json);\nconst now = new Date();\nconst msPerDay = 86400000;\n\nconst byAccount = {};\nfor (const opp of items) {\n  if (!opp.Account || !opp.AccountId) continue;\n  const accId = opp.AccountId;\n  if (!byAccount[accId]) {\n    byAccount[accId] = { accountId: accId, accountName: opp.Account.Name, website: opp.Account.Website || '', deals: [] };\n  }\n  byAccount[accId].deals.push({\n    name: opp.Name,\n    closeDate: opp.CloseDate,\n    amount: opp.Amount || 0,\n    ownerEmail: opp.Owner ? opp.Owner.Email : '',\n    ownerName: opp.Owner ? opp.Owner.Name : ''\n  });\n}\n\nconst candidates = [];\nfor (const accId in byAccount) {\n  const acc = byAccount[accId];\n  acc.deals.sort((a, b) => new Date(a.closeDate) - new Date(b.closeDate));\n  const original = acc.deals[0];\n  const anniversary = new Date(original.closeDate);\n  while (anniversary.getTime() < now.getTime()) {\n    anniversary.setFullYear(anniversary.getFullYear() + 1);\n  }\n  const daysToRenewal = Math.round((anniversary.getTime() - now.getTime()) / msPerDay);\n  if (daysToRenewal < 0 || daysToRenewal > 60) continue;\n\n  const totalContractValue = acc.deals.reduce((s, d) => s + d.amount, 0);\n  const dealHistoryText = acc.deals.map(d => '- ' + d.name + ': $' + d.amount.toLocaleString() + ' closed ' + d.closeDate).join('\\n');\n  const domain = (acc.website || '').replace(/^https?:\\/\\//, '').replace(/\\/$/, '');\n\n  candidates.push({\n    accountId: accId,\n    accountName: acc.accountName,\n    domain: domain,\n    renewalDate: anniversary.toISOString().slice(0, 10),\n    daysToRenewal: daysToRenewal,\n    contractAmount: totalContractValue,\n    originalCloseDate: original.closeDate,\n    dealHistoryText: dealHistoryText,\n    ownerEmail: original.ownerEmail,\n    ownerName: original.ownerName\n  });\n}\n\nreturn candidates.map(c => ({ json: c }));"
      }
    },
    {
      "id": "850ac76c-e416-4db1-b57b-de0342bb2cc5",
      "name": "Fetch HubSpot Contacts",
      "type": "n8n-nodes-base.hubspot",
      "typeVersion": 2.2,
      "position": [680, 300],
      "executeOnce": true,
      "parameters": {
        "authentication": "appToken",
        "resource": "contact",
        "operation": "getAll",
        "returnAll": true,
        "additionalFields": {
          "listMemberships": false,
          "propertiesCollection": {
            "propertiesValues": {
              "properties": ["email", "firstname", "lastname", "company", "hs_email_open", "hs_email_click", "hs_analytics_num_page_views", "notes_last_contacted", "hs_analytics_last_timestamp", "createdate", "lastmodifieddate"],
              "propertyMode": "valueAndHistory"
            }
          }
        }
      },
      "credentials": { "hubspotAppToken": { "id": "ynUSCUtMOnX1f4Jl", "name": "HubSpot Service Key account" } }
    },
    {
      "id": "8eeda43e-2dd2-48e4-9179-5f3931521569",
      "name": "Merge Real Engagement + Mock Support Signals",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [900, 300],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "const candidates = $('Compute Renewal Candidates').all().map(i => i.json);\nconst contacts = $('Fetch HubSpot Contacts').all().map(i => i.json);\n\nconst mockSignals = {\n  'Meridian Financial Group': {\n    supportSignalMock: 'MOCK (no support system connected): 1 ticket in the last 90 days, a minor SSO configuration question, resolved within 4 hours. No recurring issues on file.',\n    turnoverSignalMock: 'MOCK (no support system connected): No stakeholder turnover detected. The original champion (Director of IT Operations) is still listed as the primary contact.'\n  },\n  'Palisade Underwriting Group': {\n    supportSignalMock: 'MOCK (no support system connected): 4 tickets in the last 90 days, 2 still open, citing recurring platform stability complaints from the risk operations team.',\n    turnoverSignalMock: 'MOCK (no support system connected): The VP of Risk Operations who championed this deal left the company roughly 5 weeks ago. No successor has been assigned in the CRM.'\n  },\n  'Brambleworth Legal Partners': {\n    supportSignalMock: 'MOCK (no support system connected): 1 ticket in the last 90 days (SSO integration friction), resolved in 2 days. No open tickets on file.',\n    turnoverSignalMock: 'MOCK (no support system connected): The budget-holder contact recently moved to a new department internally. A formal handoff to a new stakeholder has not been confirmed.'\n  }\n};\nconst defaultMock = {\n  supportSignalMock: 'MOCK (no support system connected): No support ticket activity on file for this account in the last 90 days.',\n  turnoverSignalMock: 'MOCK (no support system connected): No stakeholder change detected for this account.'\n};\n\nfunction engagementForAccount(accountName) {\n  const matches = contacts.filter(c => {\n    const props = c.properties || {};\n    const company = props.company && props.company.value;\n    return company && company.toLowerCase() === accountName.toLowerCase();\n  });\n  if (matches.length === 0) {\n    return { engagementSummaryReal: 'REAL (HubSpot API): no contact on file at this account yet, so there is no engagement history to report.', hasAnyEngagement: false, contactCount: 0 };\n  }\n  let pageViewsTotal = 0;\n  let anyEmailOpen = false;\n  let anyEmailClick = false;\n  let mostRecentTouchMs = 0;\n  const names = [];\n  for (const c of matches) {\n    const props = c.properties || {};\n    names.push((((props.firstname && props.firstname.value) || '') + ' ' + ((props.lastname && props.lastname.value) || '')).trim());\n    const pv = props.hs_analytics_num_page_views ? parseInt(props.hs_analytics_num_page_views.value, 10) : NaN;\n    if (!isNaN(pv)) pageViewsTotal += pv;\n    if (props.hs_email_open && parseInt(props.hs_email_open.value, 10) > 0) anyEmailOpen = true;\n    if (props.hs_email_click && parseInt(props.hs_email_click.value, 10) > 0) anyEmailClick = true;\n    const touchCandidates = [props.notes_last_contacted, props.hs_analytics_last_timestamp].filter(Boolean);\n    for (const t of touchCandidates) {\n      const ms = parseInt(t.value, 10);\n      if (!isNaN(ms) && ms > mostRecentTouchMs) mostRecentTouchMs = ms;\n    }\n  }\n  const nowMs = Date.now();\n  const daysSinceLastTouch = mostRecentTouchMs > 0 ? Math.round((nowMs - mostRecentTouchMs) / 86400000) : null;\n  let summary = 'REAL (HubSpot API): ' + matches.length + ' contact(s) on file (' + names.join(', ') + '). ';\n  summary += 'Total tracked website page views: ' + pageViewsTotal + '. ';\n  summary += 'Marketing email opens recorded: ' + (anyEmailOpen ? 'yes' : 'none') + '; clicks recorded: ' + (anyEmailClick ? 'yes' : 'none') + '. ';\n  summary += daysSinceLastTouch !== null ? ('Last recorded CRM touch: ' + daysSinceLastTouch + ' days ago.') : 'No recorded CRM touch timestamp on file for this contact.';\n  return { engagementSummaryReal: summary, hasAnyEngagement: anyEmailOpen || anyEmailClick || pageViewsTotal > 0, contactCount: matches.length, daysSinceLastTouch: daysSinceLastTouch };\n}\n\nconst merged = candidates.map(c => {\n  const eng = engagementForAccount(c.accountName);\n  const mock = mockSignals[c.accountName] || defaultMock;\n  return Object.assign({}, c, eng, mock);\n});\n\nreturn merged.map(m => ({ json: m }));"
      }
    },
    {
      "id": "89e3e487-eca3-4a88-9f54-3aa7f427136e",
      "name": "Renewal Risk Storyteller",
      "type": "@n8n/n8n-nodes-langchain.agent",
      "typeVersion": 3.1,
      "position": [1140, 300],
      "parameters": {
        "promptType": "define",
        "text": "=Account: {{ $json.accountName }}\nRenewal date: {{ $json.renewalDate }} ({{ $json.daysToRenewal }} days from now)\nTotal contract value on file: ${{ $json.contractAmount }}\nDeal history:\n{{ $json.dealHistoryText }}\n\nEngagement signal (REAL, computed from the connected HubSpot account):\n{{ $json.engagementSummaryReal }}\n\nSupport signal (SIMULATED - no support ticket system is connected yet):\n{{ $json.supportSignalMock }}\n\nStakeholder signal (SIMULATED):\n{{ $json.turnoverSignalMock }}\n\nWrite the renewal health narrative for this account.",
        "hasOutputParser": true,
        "options": {
          "systemMessage": "You are a Renewal Health Storyteller for Fortavault, Inc., a fictional data protection and security platform (Fortavault Backup Cloud, Fortavault Shield, Fortavault Access, Fortavault Insights). For each renewal-nearing account, turn the signals given into a specific, evidence-grounded 'why this account is at risk' narrative instead of a bare health score - cite the actual facts given rather than vague language like 'engagement has dropped'. Explicitly distinguish which parts of your narrative rely on REAL data (marked REAL above) versus SIMULATED data (marked SIMULATED above) - never blur the two together as if both were equally certain. Respond with a JSON object with exactly five fields: riskLevel ('Low', 'Medium', or 'High'), headline (one sentence), narrative (4-6 sentences grounded in the specific facts given, explicitly noting which claims are real vs. simulated), emailSubject (a subject line an account owner could actually send), and emailBody (a 3-5 sentence email an account owner could forward or act on, written in first person as if the AE is flagging this to themselves or their team, ending with a suggested next action)."
        }
      }
    },
    {
      "id": "724fd535-4fe5-491f-a7cc-a9abff0fe433",
      "name": "Anthropic Chat Model",
      "type": "@n8n/n8n-nodes-langchain.lmChatAnthropic",
      "typeVersion": 1.5,
      "position": [1140, 500],
      "parameters": {
        "model": { "__rl": true, "mode": "list", "value": "claude-sonnet-4-6", "cachedResultName": "Claude Sonnet 4.6" },
        "options": { "temperature": 0.4 }
      },
      "credentials": { "anthropicApi": { "id": "3d3ZiTi7BwnkFwqU", "name": "Anthropic account" } }
    },
    {
      "id": "494fba89-876f-4592-820c-0c3a2429a7c8",
      "name": "Structured Output Parser",
      "type": "@n8n/n8n-nodes-langchain.outputParserStructured",
      "typeVersion": 1.3,
      "position": [1280, 500],
      "parameters": {
        "jsonSchemaExample": "{ \"riskLevel\": \"Low, Medium, or High\", \"headline\": \"one sentence\", \"narrative\": \"4-6 sentences grounded in the specific facts given, explicitly noting which claims are real vs simulated\", \"emailSubject\": \"a subject line an account owner could actually send\", \"emailBody\": \"3-5 sentence email an account owner could forward or act on, first person, ending with a suggested next action\" }"
      }
    },
    {
      "id": "883df512-1f35-4c1c-9f7b-eac38ce2e59c",
      "name": "Post Renewal Risk to Slack",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.7,
      "position": [1380, 200],
      "parameters": {
        "resource": "message",
        "operation": "post",
        "select": "channel",
        "channelId": { "__rl": true, "mode": "id", "value": "C0BQ3G96HL6", "cachedResultName": "general" },
        "text": "=🔔 *Renewal Health: {{ $('Merge Real Engagement + Mock Support Signals').item.json.accountName }}* — renews in {{ $('Merge Real Engagement + Mock Support Signals').item.json.daysToRenewal }} days ({{ $('Merge Real Engagement + Mock Support Signals').item.json.renewalDate }})\n\n*Risk: {{ $('Renewal Risk Storyteller').item.json.output.riskLevel }}* — {{ $('Renewal Risk Storyteller').item.json.output.headline }}\n\n{{ $('Renewal Risk Storyteller').item.json.output.narrative }}\n\nContract value on file: ${{ $('Merge Real Engagement + Mock Support Signals').item.json.contractAmount }}\nA Gmail draft with this narrative has been created for the account owner ({{ $('Merge Real Engagement + Mock Support Signals').item.json.ownerEmail }}).",
        "otherOptions": {}
      },
      "credentials": { "slackApi": { "id": "mv7TIi8kucFPqPg6", "name": "Slack account" } }
    },
    {
      "id": "e2588b81-de7f-4221-b8a9-619e01c0da96",
      "name": "Draft Renewal Risk Email",
      "type": "n8n-nodes-base.gmail",
      "typeVersion": 2.2,
      "position": [1380, 400],
      "parameters": {
        "resource": "draft",
        "operation": "create",
        "subject": "={{ $('Renewal Risk Storyteller').item.json.output.emailSubject }}",
        "emailType": "text",
        "message": "={{ $('Renewal Risk Storyteller').item.json.output.emailBody }}",
        "options": { "sendTo": "={{ $('Merge Real Engagement + Mock Support Signals').item.json.ownerEmail }}" }
      },
      "credentials": { "gmailOAuth2": { "id": "NHRZAq3WltWAuMWD", "name": "Gmail account" } }
    },
    {
      "id": "02eecfa8-dbda-47b1-9efa-3113dcdbf889",
      "name": "Log Renewal Health",
      "type": "n8n-nodes-base.dataTable",
      "typeVersion": 1.1,
      "position": [1620, 300],
      "parameters": {
        "resource": "row",
        "operation": "insert",
        "dataTableId": { "__rl": true, "mode": "id", "value": "nL6ZtdYzpN1LndTz", "cachedResultName": "Renewal Health Log" },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "accountName": "={{ $('Merge Real Engagement + Mock Support Signals').item.json.accountName }}",
            "accountId": "={{ $('Merge Real Engagement + Mock Support Signals').item.json.accountId }}",
            "renewalDate": "={{ $('Merge Real Engagement + Mock Support Signals').item.json.renewalDate }}",
            "daysToRenewal": "={{ $('Merge Real Engagement + Mock Support Signals').item.json.daysToRenewal }}",
            "contractAmount": "={{ $('Merge Real Engagement + Mock Support Signals').item.json.contractAmount }}",
            "riskLevel": "={{ $('Renewal Risk Storyteller').item.json.output.riskLevel }}",
            "engagementSummaryReal": "={{ $('Merge Real Engagement + Mock Support Signals').item.json.engagementSummaryReal }}",
            "supportSignalMock": "={{ $('Merge Real Engagement + Mock Support Signals').item.json.supportSignalMock }}",
            "turnoverSignalMock": "={{ $('Merge Real Engagement + Mock Support Signals').item.json.turnoverSignalMock }}",
            "narrative": "={{ $('Renewal Risk Storyteller').item.json.output.narrative }}",
            "slackPosted": true,
            "gmailDraftId": "={{ $('Draft Renewal Risk Email').item.json.id }}",
            "loggedAt": "={{ $now.toISO() }}"
          }
        },
        "options": {}
      }
    }
  ],
  "connections": {
    "Weekly Renewal Health Scan": { "main": [[{ "node": "Fetch Closed Won Opportunities", "type": "main", "index": 0 }]] },
    "Fetch Closed Won Opportunities": { "main": [[{ "node": "Compute Renewal Candidates", "type": "main", "index": 0 }]] },
    "Compute Renewal Candidates": { "main": [[{ "node": "Fetch HubSpot Contacts", "type": "main", "index": 0 }]] },
    "Fetch HubSpot Contacts": { "main": [[{ "node": "Merge Real Engagement + Mock Support Signals", "type": "main", "index": 0 }]] },
    "Merge Real Engagement + Mock Support Signals": { "main": [[{ "node": "Renewal Risk Storyteller", "type": "main", "index": 0 }]] },
    "Renewal Risk Storyteller": { "main": [[{ "node": "Post Renewal Risk to Slack", "type": "main", "index": 0 }]] },
    "Anthropic Chat Model": { "ai_languageModel": [[{ "node": "Renewal Risk Storyteller", "type": "ai_languageModel", "index": 0 }]] },
    "Structured Output Parser": { "ai_outputParser": [[{ "node": "Renewal Risk Storyteller", "type": "ai_outputParser", "index": 0 }]] },
    "Post Renewal Risk to Slack": { "main": [[{ "node": "Draft Renewal Risk Email", "type": "main", "index": 0 }]] },
    "Draft Renewal Risk Email": { "main": [[{ "node": "Log Renewal Health", "type": "main", "index": 0 }]] }
  },
  "settings": {
    "executionOrder": "v1"
  }
}
