{
  "name": "Pre-call brief: Slack message 24h before customer meetings",
  "nodes": [
    {
      "id": "2f33fff9-ca09-5669-9114-402904719a94",
      "name": "Every hour (manual for the demo)",
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "position": [
        0,
        0
      ],
      "parameters": {}
    },
    {
      "id": "59592349-7292-50b5-b958-ef50c6e02604",
      "name": "Settings",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        220,
        0
      ],
      "parameters": {
        "jsCode": "\n// The demo runs on a fixed date so the numbers in the article stay the same.\n// In production: asOf = today, and the calendar window is the next 24 hours.\nreturn [{ json: { asOf: '2026-10-15', ownDomain: 'yourcompany.com' } }];\n"
      }
    },
    {
      "id": "da2dbb38-4bad-5c7c-8bac-7cb221140d26",
      "name": "Calendar: meetings in the next 24h",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        440,
        0
      ],
      "parameters": {
        "url": "http://127.0.0.1:8787/calendar/v3/calendars/primary/events?timeMin=2026-10-15T10:00:00Z&timeMax=2026-10-16T22:00:00Z&singleEvents=true",
        "options": {}
      }
    },
    {
      "id": "8e48b8a0-bb8c-5a64-9b6d-603daaeeec59",
      "name": "Meetings with outside guests",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        660,
        0
      ],
      "parameters": {
        "jsCode": "\nconst { asOf, ownDomain } = $('Settings').first().json;\nconst out = [];\nfor (const e of $input.first().json.items) {\n  const domains = [...new Set((e.attendees ?? [])\n    .map(a => a.email.split('@')[1].toLowerCase())\n    .filter(d => d !== ownDomain))];\n  if (domains.length === 0) continue;            // internal meeting: no brief\n  out.push({ json: { meetingId: e.id, title: e.summary, start: e.start.dateTime, domains, asOf }, pairedItem: { item: 0 } });\n}\nreturn out;\n"
      }
    },
    {
      "id": "500649a3-8b8d-5f71-b739-35ad31869a71",
      "name": "Which customer is it?",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.5,
      "position": [
        880,
        0
      ],
      "parameters": {
        "operation": "executeQuery",
        "query": "-- Only a single, known customer gets a brief. Guests from several customers: no guessing.\nSELECT $1::text AS meeting_id, $2::text AS title, $3::text AS start, $4::date AS as_of,\n       CASE WHEN count(DISTINCT d.customer_id) = 1 THEN min(d.customer_id) END AS customer_id,\n       count(DISTINCT d.customer_id)::int AS matches\nFROM customer_domains d\nWHERE d.domain = ANY (string_to_array($5, ','))\n  AND NOT EXISTS (SELECT 1 FROM brief_log b WHERE b.meeting_id = $1)\nHAVING count(DISTINCT d.customer_id) > 0;",
        "options": {
          "queryReplacement": "={{ [$json.meetingId, $json.title, $json.start, $json.asOf, $json.domains.join(',')] }}"
        }
      },
      "credentials": {
        "postgres": {
          "id": "pgBriefDb",
          "name": "Customer layer (Postgres)"
        }
      }
    },
    {
      "id": "0614ae86-8e05-585f-8d82-a05ad872ffe9",
      "name": "Collect the facts",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.5,
      "position": [
        1100,
        0
      ],
      "parameters": {
        "operation": "executeQuery",
        "query": "SELECT $1::text AS meeting_id, $2::text AS title, $3::text AS start, $6::int AS matches, b.*\nFROM (SELECT 1) one\nLEFT JOIN LATERAL pre_call_brief(nullif($4, ''), $5::date) b ON true;",
        "options": {
          "queryReplacement": "={{ [$json.meeting_id, $json.title, $json.start, $json.customer_id ?? '', $json.as_of, $json.matches] }}"
        }
      },
      "credentials": {
        "postgres": {
          "id": "pgBriefDb",
          "name": "Customer layer (Postgres)"
        }
      }
    },
    {
      "id": "9b16f029-c186-5984-b0d5-c2f585492690",
      "name": "Write the brief",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1320,
        0
      ],
      "parameters": {
        "jsCode": "\n// Turns the facts into five short lines. Rules, not AI: every line is a fact with a source.\nconst eur = n => '€' + Math.round(Number(n)).toLocaleString('en-US');\nconst days = n => n === 1 ? '1 day' : `${n} days`;\nconst unknown = src => `_unknown: ${src} not linked to this customer yet_`;\nconst end = s => /[.!?][\"”]?$/.test(s) ? s : s + '.';\nconst label = { stripe: 'billing', zendesk: 'support', product: 'product usage', hubspot: 'CRM' };\nconst out = [];\n\nfor (const [i, { json: f }] of $input.all().entries()) {\n  const missing = new Set(f.missing_sources ?? []);\n  const time = new Date(f.start).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Amsterdam' });\n  const lines = [];\n  const flags = [];\n\n  // Guests from more than one customer: say so instead of picking one.\n  if (f.matches > 1) {\n    const text = `*${f.title}* · tomorrow ${time}\\nGuests from ${f.matches} customers are invited, so no brief was written. Check which account this meeting is about.`;\n    out.push({ json: { meetingId: f.meeting_id, customer: null, text }, pairedItem: { item: i } });\n    continue;\n  }\n\n  // 1. Money\n  if (missing.has('stripe')) lines.push(`*Money:* ${unknown('billing')}`);\n  else {\n    let m = `${eur(f.billed_mrr)}/mo billed`;\n    if (f.contract_mrr && Number(f.contract_mrr) !== Number(f.billed_mrr)) {\n      // Show the difference; don't call it an error. It can be a discount, a seat change or a mistake.\n      const gap = Number(f.billed_mrr) - Number(f.contract_mrr);\n      m += `, ${eur(f.contract_mrr)}/mo in the contract (${eur(Math.abs(gap))}/mo ${gap < 0 ? 'less' : 'more'}: a discount, a seat change or a billing error)`;\n    }\n    if (f.overdue_count > 0) {\n      m += `. Invoice ${f.overdue_number} (${eur(f.overdue_eur)}) overdue ${days(f.overdue_days)}`;\n      flags.push('overdue');\n    } else m += '. No overdue invoices';\n    lines.push(`*Money:* ${m}. → Stripe, HubSpot`);\n  }\n\n  // 2. Support\n  if (missing.has('zendesk')) lines.push(`*Support:* ${unknown('support')}`);\n  else if (f.open_tickets === 0) lines.push('*Support:* no open tickets. → Zendesk');\n  else {\n    let s = `${f.open_tickets} open ticket${f.open_tickets === 1 ? '' : 's'}`;\n    if (f.urgent_tickets > 0) {\n      s += `, ${f.urgent_tickets} urgent: \"${f.urgent_subject}\", no reply for ${days(f.urgent_waiting_days)}`;\n      flags.push('urgent');\n    }\n    if (f.top_tag_count >= 3) s += `. ${f.top_tag_count} of ${f.open_tickets} are tagged \"${f.top_tag}\"`;\n    lines.push(`*Support:* ${s}. → Zendesk`);\n  }\n\n  // 3. Usage\n  if (missing.has('product')) lines.push(`*Usage:* ${unknown('product usage')}`);\n  else {\n    const dir = f.usage_change_pct < 0 ? `down ${-f.usage_change_pct}%` : `up ${f.usage_change_pct}%`;\n    lines.push(`*Usage:* ${f.avg_daily_active_users} daily active users on average over the last 30 days, ${dir} from the 30 days before. → product data`);\n    if (f.usage_change_pct <= -20) flags.push('usage');\n  }\n\n  // 4. Contract\n  if (missing.has('hubspot') || !f.renewal_date) lines.push(`*Contract:* ${unknown('contract dates')}`);\n  else {\n    const fmt = d => new Date(d).toLocaleDateString('en-GB', { day: 'numeric', month: 'short' });\n    let c = `renews ${fmt(f.renewal_date)}, in ${days(f.days_to_renewal)}`;\n    if (f.days_to_notice >= 0 && f.days_to_notice <= 30) {\n      c += `. Notice deadline (last day to cancel) ${fmt(f.notice_deadline)}, in ${days(f.days_to_notice)}`;\n      flags.push('notice');\n    }\n    lines.push(`*Contract:* ${c}. → HubSpot`);\n  }\n\n  // 5. Last contact\n  if (missing.has('hubspot')) lines.push(`*Last contact:* ${unknown('CRM')}`);\n  else {\n    let l = f.last_meeting_days != null ? `last meeting ${days(f.last_meeting_days)} ago (${f.last_meeting_by})` : 'no meeting logged';\n    if (f.latest_note_days != null && f.latest_note_days !== f.last_meeting_days)\n      l += `. ${f.latest_note_by}, ${days(f.latest_note_days)} ago: \"${f.latest_note}\"`;\n    lines.push(`*Last contact:* ${end(l)} → HubSpot`);\n  }\n\n  // One suggestion, from the flags only.\n  let tip;\n  if (missing.size > 0) tip = `Parts of this customer aren't linked yet (${[...missing].map(m => label[m]).join(', ')}). Ask RevOps to confirm the links, or check those systems yourself.`;\n  else if (flags.includes('notice')) tip = `They have ${days(f.days_to_notice)} left to give notice. Ask about their renewal plans`\n      + (flags.includes('urgent') ? ', and start with the urgent ticket.' : '.');\n  else if (flags.includes('urgent')) tip = 'Start with the urgent ticket.';\n  else if (flags.includes('overdue')) tip = 'Mention the overdue invoice, after the agenda.';\n  else tip = 'Nothing unusual. Use the time for their goals.';\n\n  const text = [`*${f.customer}* · ${f.title} · tomorrow ${time}`, ...lines.map(x => '• ' + x), `*Before you call:* ${tip}`].join('\\n');\n  out.push({ json: { meetingId: f.meeting_id, customer: f.customer, text }, pairedItem: { item: i } });\n}\nreturn out;\n"
      }
    },
    {
      "id": "a8c0e211-9c85-5dc5-a302-b9cd82b5c20e",
      "name": "Slack: send to the account owner",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1540,
        0
      ],
      "parameters": {
        "method": "POST",
        "url": "http://127.0.0.1:8787/slack/api/chat.postMessage",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ channel: 'U01SANNE', text: $json.text, unfurl_links: false }) }}",
        "options": {}
      }
    },
    {
      "id": "80217162-bc99-511b-acd1-8fa0f422bb22",
      "name": "Only briefs Slack accepted",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1760,
        0
      ],
      "parameters": {
        "jsCode": "\n// Slack answers {ok: true} for every message it accepted; keep the brief that went with it.\nconst briefs = $('Write the brief').all();\nreturn $input.all()\n  .map((res, i) => ({ i, ok: res.json.ok, brief: briefs[i].json }))\n  .filter(r => r.ok)\n  .map(r => ({ json: r.brief, pairedItem: { item: r.i } }));\n"
      }
    },
    {
      "id": "71a62b7b-af65-5b78-ab4a-d49c8f83fbb2",
      "name": "Log the brief",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.5,
      "position": [
        1980,
        0
      ],
      "parameters": {
        "operation": "executeQuery",
        "query": "INSERT INTO brief_log (meeting_id, customer_id, brief)\nVALUES ($1, (SELECT customer_id FROM customers WHERE name = nullif($2, '')), $3)\nON CONFLICT (meeting_id) DO NOTHING;",
        "options": {
          "queryReplacement": "={{ [$json.meetingId, $json.customer ?? '', $json.text] }}"
        }
      },
      "credentials": {
        "postgres": {
          "id": "pgBriefDb",
          "name": "Customer layer (Postgres)"
        }
      }
    },
    {
      "id": "2053aa66-3355-53fd-aef6-32cb52ff3da6",
      "name": "Read me",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        0,
        -420
      ],
      "parameters": {
        "content": "## Example workflow, not production-ready\n\nFrom the Seamline use case \"The pre-call brief\". It was tested against **simplified stand-ins** for the Stripe, Zendesk, HubSpot, Google Calendar and Slack APIs, at `http://127.0.0.1:8787`.\n\nBefore using it with real systems:\n1. Replace each HTTP Request node with n8n's built-in node for that tool, or point it at the real API and add credentials.\n2. Create the Postgres tables and the `pre_call_brief` function from the article, and set up the **Customer layer (Postgres)** credential.\n3. Replace the manual trigger with a Schedule trigger.\n4. Check field names: contract value, renewal date and notice period are custom HubSpot properties.\n\nAll sample data is fictional.\n\nThe **Settings** node fixes the date to 15 Oct 2026 for the demo; use today's date in production.",
        "width": 520,
        "height": 340
      }
    }
  ],
  "connections": {
    "Every hour (manual for the demo)": {
      "main": [
        [
          {
            "node": "Settings",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Settings": {
      "main": [
        [
          {
            "node": "Calendar: meetings in the next 24h",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Calendar: meetings in the next 24h": {
      "main": [
        [
          {
            "node": "Meetings with outside guests",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Meetings with outside guests": {
      "main": [
        [
          {
            "node": "Which customer is it?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Which customer is it?": {
      "main": [
        [
          {
            "node": "Collect the facts",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Collect the facts": {
      "main": [
        [
          {
            "node": "Write the brief",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Write the brief": {
      "main": [
        [
          {
            "node": "Slack: send to the account owner",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Slack: send to the account owner": {
      "main": [
        [
          {
            "node": "Only briefs Slack accepted",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Only briefs Slack accepted": {
      "main": [
        [
          {
            "node": "Log the brief",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  },
  "active": false,
  "pinData": {},
  "id": "briefDemo2"
}
