{
  "name": "Log new SAM.gov bids to Google Sheets and email a daily digest",
  "nodes": [
    {
      "parameters": {
        "content": "### How it works\nThis workflow helps small federal contractors and business-development teams watch SAM.gov without re-reading the same notices. Each morning it runs the Loomwise Federal Opportunities Actor with your NAICS, set-aside, keyword, agency, and place-of-performance filters. It compares each notice ID against your Google Sheet and appends only new opportunities. The digest flags notices whose response deadline falls within your urgent window (seven days by default) and can email or post the count plus top items. Failures after retries send an alert instead of creating duplicate bids.\n\n### Setup\n1. Install `@apify/n8n-nodes-apify` (v0.6+; https://docs.apify.com/integrations/n8n) and connect your Apify API credential. Never paste the API token into a node.\n2. Open https://apify.com/loomwise/federal-opportunities and confirm a small test run in Apify.\n3. Create a Google Sheet with header row: uniqueId, noticeId, solicitationNumber, postedDate, responseDeadline, title, agency, noticeType, naicsCode, setAsideCode, popState, link, isUrgent, daysToDeadline, retrievedAt.\n4. Connect Google Sheets OAuth. In Configure filters, paste the spreadsheet ID and set NAICS, set-asides, optional keywords, states, agencies, postedWithinDays, urgentDeadlineDays, and maxResults (50 for a first run).\n5. Connect Gmail (default) or Slack and replace the email or channel placeholders.\n6. Run once manually, then activate. The Actor is pay-per-result. More data tools: https://loomwise.app/data/\n\n### Customization\nAdd place-of-performance states, switch set-asides, or include description keywords. Edit the Apify input if you also want Grants.gov (off by default). Point the digest at a BD Slack channel if you prefer chat over email.\n",
        "height": 740,
        "width": 520,
        "color": 1
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -560,
        -100
      ],
      "id": "97d4dabc-0585-4c48-a78c-6fb779f95e51",
      "name": "Overview"
    },
    {
      "parameters": {
        "content": "## 1. Schedule and configuration\nSet filters, the Google Sheet ID, and notification choices here. Later nodes reuse these values.",
        "height": 200,
        "width": 480,
        "color": 7
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        20,
        -100
      ],
      "id": "935452bd-da60-4aed-b92e-2cdacde0746f",
      "name": "## 1. Schedule and configuration"
    },
    {
      "parameters": {
        "content": "## 2. Load existing rows and run Apify\nRead unique IDs already in the sheet, then run the Actor with your filters. The Apify node uses your API credential, not a pasted token.",
        "height": 200,
        "width": 720,
        "color": 7
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        540,
        -100
      ],
      "id": "fb07a023-ccb1-48fa-bc15-eed2ba2b5055",
      "name": "## 2. Load existing rows and run Apify"
    },
    {
      "parameters": {
        "content": "## 3. Filter and deduplicate\nDrop rows that fail your extra rules or already exist in the sheet, then build a short summary of what is new.",
        "height": 200,
        "width": 460,
        "color": 7
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1300,
        -100
      ],
      "id": "d92f35b7-aa52-482d-ac52-5fc09348e9ae",
      "name": "## 3. Filter and deduplicate"
    },
    {
      "parameters": {
        "content": "## 4. Save new rows and notify\nAppend only new rows. If notifications are on, send the count and top items by Gmail or Slack.",
        "height": 200,
        "width": 720,
        "color": 7
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1800,
        -100
      ],
      "id": "b2fdf250-dc24-4c43-97fc-e347c5585b85",
      "name": "## 4. Save new rows and notify"
    },
    {
      "parameters": {
        "content": "## 5. Handle failures\nIf the sheet read, Apify run, or append fails after retries, skip writing from that step and send a failure alert on your chosen channel.",
        "height": 160,
        "width": 720,
        "color": 7
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        540,
        520
      ],
      "id": "8b5cc089-e608-4af5-a4e7-90a2813ee630",
      "name": "## 5. Handle failures"
    },
    {
      "parameters": {
        "content": "Replace every REPLACE_WITH_\u2026 value in Configure filters, then connect Apify, Google Sheets, and Gmail or Slack credentials before you run or activate this workflow.",
        "height": 200,
        "width": 480,
        "color": 3
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        20,
        520
      ],
      "id": "fa00a525-d731-4085-9884-cbacc15c5ad6",
      "name": "\u26a0 Replace placeholders before you run"
    },
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "days",
              "daysInterval": 1,
              "triggerAtHour": 6,
              "triggerAtMinute": 0
            }
          ]
        }
      },
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        40,
        220
      ],
      "id": "b1b98ad3-0515-4247-a501-24ee8ce655f3",
      "name": "Every day 6am"
    },
    {
      "parameters": {
        "mode": "manual",
        "duplicateItem": false,
        "assignments": {
          "assignments": [
            {
              "id": "3d712953-4eec-4a3e-8a4a-75591c86ecb0",
              "name": "workflowTitle",
              "value": "Daily SAM.gov bid alerts",
              "type": "string"
            },
            {
              "id": "d0aae194-d34f-451c-89eb-b4762a38a30c",
              "name": "spreadsheetId",
              "value": "REPLACE_WITH_YOUR_GOOGLE_SHEET_ID",
              "type": "string"
            },
            {
              "id": "da38253d-f6ca-4614-898b-e8854fb21469",
              "name": "sheetName",
              "value": "Sheet1",
              "type": "string"
            },
            {
              "id": "ff4d97f8-39fd-4a5a-89f2-bac65f291347",
              "name": "uniqueIdField",
              "value": "id",
              "type": "string"
            },
            {
              "id": "086ae88d-2c7f-47c8-8cf2-8ba6ff9cd24a",
              "name": "naicsCodesCsv",
              "value": "541512",
              "type": "string"
            },
            {
              "id": "67005e72-66f2-42a2-aa2d-a474e8d81899",
              "name": "setAsideTypesCsv",
              "value": "SBA",
              "type": "string"
            },
            {
              "id": "af8ec85e-4611-4151-8c7a-b16d7c69050c",
              "name": "keywordsCsv",
              "value": "",
              "type": "string"
            },
            {
              "id": "7dc2930b-eab9-44f3-b3b9-5838647576f5",
              "name": "popStatesCsv",
              "value": "",
              "type": "string"
            },
            {
              "id": "c958b1c9-866b-4771-a48e-5f03c56a5990",
              "name": "agenciesCsv",
              "value": "",
              "type": "string"
            },
            {
              "id": "43805441-8309-4abf-8daa-37283a6e0f7d",
              "name": "postedWithinDays",
              "value": 1,
              "type": "number"
            },
            {
              "id": "10bdb2e3-1d69-4200-87f5-1f208963581e",
              "name": "urgentDeadlineDays",
              "value": 7,
              "type": "number"
            },
            {
              "id": "a7cd7373-b8b0-4ba6-bebb-b175198c9c98",
              "name": "maxResults",
              "value": 50,
              "type": "number"
            },
            {
              "id": "d5cd26d6-3db5-42c5-9246-7f2e1d99c050",
              "name": "sendSummaryNotification",
              "value": true,
              "type": "boolean"
            },
            {
              "id": "b76f61cf-fb38-48f5-80d3-2dacde4ad133",
              "name": "notificationChannel",
              "value": "gmail",
              "type": "string"
            },
            {
              "id": "16b01a18-3039-49fc-a096-88f48c096bc6",
              "name": "notifyEmail",
              "value": "REPLACE_WITH_YOUR_EMAIL@example.com",
              "type": "string"
            },
            {
              "id": "3ea5b6df-af8e-487b-9dce-7acd0c15d99f",
              "name": "slackChannelId",
              "value": "REPLACE_WITH_YOUR_SLACK_CHANNEL_ID",
              "type": "string"
            }
          ]
        },
        "includeOtherFields": false,
        "options": {}
      },
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        260,
        220
      ],
      "id": "c6f2172b-4cf7-42f9-80a5-4d03cf484048",
      "name": "Configure filters"
    },
    {
      "parameters": {
        "operation": "read",
        "documentId": {
          "__rl": true,
          "value": "={{ $('Configure filters').first().json.spreadsheetId }}",
          "mode": "id"
        },
        "sheetName": {
          "__rl": true,
          "value": "={{ $('Configure filters').first().json.sheetName }}",
          "mode": "name"
        },
        "options": {}
      },
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [
        540,
        220
      ],
      "id": "818f722f-ace8-43b5-9456-ed35387e2206",
      "name": "Read existing sheet rows",
      "credentials": {
        "googleSheetsOAuth2Api": {
          "id": "REPLACE_WITH_YOUR_GOOGLE_SHEETS_CREDENTIAL_ID",
          "name": "Google Sheets account"
        }
      },
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 5000,
      "alwaysOutputData": true,
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "jsCode": "const cfg = $('Configure filters').first().json;\nconst field = String(cfg.uniqueIdField || '').trim();\nconst existingIds = [];\nconst seen = new Set();\n\nfunction computeId(row) {\n  if (!row || typeof row !== 'object') return '';\n  const stored = String(row.uniqueId || '').trim();\n  if (stored && stored.toLowerCase() !== 'uniqueid') return stored;\n  if (field === 'cityId+permitNumber') {\n    return [row.cityId || row.city, row.permitNumber].map((v) => String(v || '').trim()).filter(Boolean).join('|');\n  }\n  if (field === 'taxpayerNumber+outletNumber') {\n    return [row.taxpayerNumber, row.outletNumber].map((v) => String(v || '').trim()).filter(Boolean).join('|');\n  }\n  return String(row[field] || '').trim();\n}\n\nfor (const item of $input.all()) {\n  const id = computeId(item.json || {});\n  if (id && !seen.has(id)) {\n    seen.add(id);\n    existingIds.push(id);\n  }\n}\n\nreturn [{\n  json: {\n    ...cfg,\n    existingIds,\n    existingCount: existingIds.length,\n  },\n}];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        780,
        220
      ],
      "id": "b78ec933-ae44-4a8f-8745-6ea8c0f2a859",
      "name": "Collect existing IDs"
    },
    {
      "parameters": {
        "operation": "Run actor and get dataset",
        "actorId": "loomwise~federal-opportunities",
        "customBody": "={{ JSON.stringify({ naicsCodes: String($json.naicsCodesCsv||\"\").split(\",\").map(s=>s.trim()).filter(Boolean), setAsideTypes: String($json.setAsideTypesCsv||\"\").split(\",\").map(s=>s.trim()).filter(Boolean), keywords: String($json.keywordsCsv||\"\").split(\",\").map(s=>s.trim()).filter(Boolean), placeOfPerformanceStates: String($json.popStatesCsv||\"\").split(\",\").map(s=>s.trim()).filter(Boolean), agencies: String($json.agenciesCsv||\"\").split(\",\").map(s=>s.trim()).filter(Boolean), postedWithinDays: $json.postedWithinDays, maxResults: $json.maxResults, excludeExpired: true, includeSam: true, includeGrants: false }) }}",
        "timeout": null,
        "memory": 1024,
        "resource": "Actors",
        "actorInputMode": "json",
        "authentication": "apifyApi"
      },
      "type": "@apify/n8n-nodes-apify.apify",
      "typeVersion": 1,
      "position": [
        1040,
        220
      ],
      "id": "f19b057e-cb54-411c-a383-906a72e94bf3",
      "name": "Fetch SAM.gov opportunities",
      "credentials": {
        "apifyApi": {
          "id": "REPLACE_WITH_YOUR_APIFY_CREDENTIAL_ID",
          "name": "Apify account"
        }
      },
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 10000,
      "alwaysOutputData": true,
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "jsCode": "const cfg = $('Collect existing IDs').first().json;\nconst existing = new Set((cfg.existingIds || []).map((id) => String(id)));\nconst field = String(cfg.uniqueIdField || '').trim();\nconst channel = String(cfg.notificationChannel || 'none').trim().toLowerCase();\nconst sendSummary = cfg.sendSummaryNotification === true || String(cfg.sendSummaryNotification).toLowerCase() === 'true';\n\nfunction computeId(row) {\n  if (!row || typeof row !== 'object') return '';\n  if (field === 'cityId+permitNumber') {\n    return [row.cityId || row.city, row.permitNumber].map((v) => String(v || '').trim()).filter(Boolean).join('|');\n  }\n  if (field === 'taxpayerNumber+outletNumber') {\n    return [row.taxpayerNumber, row.outletNumber].map((v) => String(v || '').trim()).filter(Boolean).join('|');\n  }\n  return String(row[field] || '').trim();\n}\n\nfunction hasActorFields(row) {\n  return Boolean(computeId(row));\n}\n\nfunction daysUntil(dateValue) {\n  if (!dateValue) return null;\n  const d = new Date(dateValue);\n  if (Number.isNaN(d.getTime())) return null;\n  const today = new Date();\n  today.setHours(0, 0, 0, 0);\n  return Math.round((d.getTime() - today.getTime()) / 86400000);\n}\nfunction passesExtraFilters(row, cfg) {\n  const agencies = String(cfg.agenciesCsv || '').split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);\n  if (agencies.length) {\n    const hay = `${row.agency || ''} ${row.subAgency || ''} ${row.office || ''}`.toLowerCase();\n    if (!agencies.some((a) => hay.includes(a))) return false;\n  }\n  return true;\n}\nfunction mapRow(row, uniqueId) {\n  const urgentDays = Number(cfg.urgentDeadlineDays);\n  const remaining = daysUntil(row.responseDeadline);\n  const isUrgent = Number.isFinite(urgentDays) && urgentDays >= 0 && remaining !== null && remaining >= 0 && remaining <= urgentDays;\n  return {\n    uniqueId,\n    noticeId: row.id || '',\n    solicitationNumber: row.solicitationNumber || '',\n    postedDate: row.postedDate || '',\n    responseDeadline: row.responseDeadline || '',\n    title: row.title || '',\n    agency: row.agency || '',\n    noticeType: row.noticeType || '',\n    naicsCode: row.naicsCode || '',\n    setAsideCode: row.setAsideCode || row.setAside || '',\n    popState: row.popState || '',\n    link: row.link || '',\n    isUrgent,\n    daysToDeadline: remaining === null ? '' : remaining,\n    retrievedAt: row.retrievedAt || new Date().toISOString(),\n  };\n}\n\nconst fetched = [];\nfor (const item of $input.all()) {\n  const row = item.json || {};\n  if (hasActorFields(row)) fetched.push(row);\n}\n\nlet filteredOut = 0;\nlet skippedDuplicates = 0;\nconst newRows = [];\nconst seenThisRun = new Set();\n\nfor (const row of fetched) {\n  if (!passesExtraFilters(row, cfg)) {\n    filteredOut += 1;\n    continue;\n  }\n  const uniqueId = computeId(row);\n  if (!uniqueId) {\n    filteredOut += 1;\n    continue;\n  }\n  if (existing.has(uniqueId) || seenThisRun.has(uniqueId)) {\n    skippedDuplicates += 1;\n    continue;\n  }\n  seenThisRun.add(uniqueId);\n  const mapped = mapRow(row, uniqueId);\n  newRows.push(mapped);\n}\n\nconst topItems = newRows.slice(0, 8);\nconst lines = topItems.map((row, idx) => `${idx + 1}. ${row.isUrgent ? '[DUE SOON] ' : ''}${row.title || '(no title)'} \u2014 ${row.agency || 'n/a'} | NAICS ${row.naicsCode || 'n/a'} | due ${row.responseDeadline || 'n/a'} ${row.link || ''}`);\nconst intro = `New SAM.gov opportunities appended today: ${newRows.length}. Due within your urgent window: ${newRows.filter(r => r.isUrgent).length}.`;\nconst body = newRows.length\n  ? [intro, '', ...lines, '', `Skipped ${skippedDuplicates} already in the sheet. Filtered out ${filteredOut} extra rows. Existing IDs loaded: ${cfg.existingCount || 0}.`].join('\\n')\n  : \"No new SAM.gov opportunities matched your filters (or they were already in the sheet).\";\nconst subject = `SAM.gov digest: ${newRows.length} new, ${newRows.filter(r => r.isUrgent).length} due soon`;\nconst slackText = body;\n\nreturn [{\n  json: {\n    fetchedCount: fetched.length,\n    newCount: newRows.length,\n    skippedDuplicates,\n    filteredOut,\n    existingCount: cfg.existingCount || 0,\n    newRows,\n    topItems,\n    sendSummaryNotification: sendSummary,\n    notificationChannel: channel,\n    notifyEmail: cfg.notifyEmail,\n    slackChannelId: cfg.slackChannelId,\n    subject,\n    body,\n    slackText,\n  },\n}];\n"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1320,
        220
      ],
      "id": "73596b51-2ea1-4341-a062-af9561373548",
      "name": "Keep only new matching leads"
    },
    {
      "parameters": {
        "fieldToSplitOut": "newRows",
        "options": {}
      },
      "type": "n8n-nodes-base.splitOut",
      "typeVersion": 1,
      "position": [
        1560,
        140
      ],
      "id": "cb2e57e5-a64f-4d71-80f0-0212b94b14a6",
      "name": "Split out new rows"
    },
    {
      "parameters": {
        "operation": "append",
        "documentId": {
          "__rl": true,
          "value": "={{ $('Configure filters').first().json.spreadsheetId }}",
          "mode": "id"
        },
        "sheetName": {
          "__rl": true,
          "value": "={{ $('Configure filters').first().json.sheetName }}",
          "mode": "name"
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "uniqueId": "={{ $json.uniqueId }}",
            "noticeId": "={{ $json.noticeId }}",
            "solicitationNumber": "={{ $json.solicitationNumber }}",
            "postedDate": "={{ $json.postedDate }}",
            "responseDeadline": "={{ $json.responseDeadline }}",
            "title": "={{ $json.title }}",
            "agency": "={{ $json.agency }}",
            "noticeType": "={{ $json.noticeType }}",
            "naicsCode": "={{ $json.naicsCode }}",
            "setAsideCode": "={{ $json.setAsideCode }}",
            "popState": "={{ $json.popState }}",
            "link": "={{ $json.link }}",
            "isUrgent": "={{ $json.isUrgent }}",
            "daysToDeadline": "={{ $json.daysToDeadline }}",
            "retrievedAt": "={{ $json.retrievedAt }}"
          },
          "matchingColumns": [],
          "schema": [
            {
              "id": "uniqueId",
              "displayName": "uniqueId",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "noticeId",
              "displayName": "noticeId",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "solicitationNumber",
              "displayName": "solicitationNumber",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "postedDate",
              "displayName": "postedDate",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "responseDeadline",
              "displayName": "responseDeadline",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "title",
              "displayName": "title",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "agency",
              "displayName": "agency",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "noticeType",
              "displayName": "noticeType",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "naicsCode",
              "displayName": "naicsCode",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "setAsideCode",
              "displayName": "setAsideCode",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "popState",
              "displayName": "popState",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "link",
              "displayName": "link",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "isUrgent",
              "displayName": "isUrgent",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "daysToDeadline",
              "displayName": "daysToDeadline",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "retrievedAt",
              "displayName": "retrievedAt",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            }
          ],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {}
      },
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [
        1840,
        140
      ],
      "id": "7a1cc683-7806-4e3e-9c26-b52f221ecb3c",
      "name": "Append new rows to Sheet",
      "credentials": {
        "googleSheetsOAuth2Api": {
          "id": "REPLACE_WITH_YOUR_GOOGLE_SHEETS_CREDENTIAL_ID",
          "name": "Google Sheets account"
        }
      },
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 5000,
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "ebdbf712-acf5-49cd-b3c8-664396588b71",
              "leftValue": "={{ $json.sendSummaryNotification }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1560,
        340
      ],
      "id": "e111868c-3645-41ce-bab0-9428be5e139f",
      "name": "Should send a summary?"
    },
    {
      "parameters": {
        "rules": {
          "values": [
            {
              "conditions": {
                "options": {
                  "caseSensitive": false,
                  "leftValue": "",
                  "typeValidation": "loose",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "0f948c25-4d43-4642-8e42-dc1e64d537cc",
                    "leftValue": "={{ $json.notificationChannel }}",
                    "rightValue": "gmail",
                    "operator": {
                      "type": "string",
                      "operation": "equals",
                      "name": "filter.operator.equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "gmail"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": false,
                  "leftValue": "",
                  "typeValidation": "loose",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "332fcd34-3e1f-4963-a4c2-37ebecf3a4b1",
                    "leftValue": "={{ $json.notificationChannel }}",
                    "rightValue": "slack",
                    "operator": {
                      "type": "string",
                      "operation": "equals",
                      "name": "filter.operator.equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "slack"
            }
          ]
        },
        "options": {
          "fallbackOutput": "none"
        }
      },
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3.2,
      "position": [
        1840,
        340
      ],
      "id": "25d66950-5a3b-4b48-af8e-ccc408b42346",
      "name": "Route the notification"
    },
    {
      "parameters": {
        "sendTo": "={{ $json.notifyEmail }}",
        "subject": "={{ $json.subject }}",
        "emailType": "text",
        "message": "={{ $json.body }}",
        "options": {}
      },
      "type": "n8n-nodes-base.gmail",
      "typeVersion": 2.1,
      "position": [
        2120,
        260
      ],
      "id": "b313dec4-f60c-47ee-8df5-53223e40db4b",
      "name": "Email the run summary",
      "credentials": {
        "gmailOAuth2": {
          "id": "REPLACE_WITH_YOUR_GMAIL_CREDENTIAL_ID",
          "name": "Gmail account"
        }
      },
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 5000
    },
    {
      "parameters": {
        "select": "channel",
        "channelId": {
          "__rl": true,
          "value": "={{ $json.slackChannelId }}",
          "mode": "id"
        },
        "text": "={{ $json.slackText }}",
        "otherOptions": {}
      },
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.2,
      "position": [
        2120,
        420
      ],
      "id": "55a024aa-37e1-4a16-b350-3e6447562fd0",
      "name": "Post the run summary to Slack",
      "credentials": {
        "slackApi": {
          "id": "REPLACE_WITH_YOUR_SLACK_CREDENTIAL_ID",
          "name": "Slack account"
        }
      },
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 5000
    },
    {
      "parameters": {
        "jsCode": "const cfg = $('Configure filters').first().json;\nconst item = $input.first() || {};\nconst payload = item.json || {};\nconst err = item.error || payload.error || {};\nconst message = err.message || payload.message || payload.errorMessage || 'Unknown error (open the failed execution in n8n for details).';\nconst channel = String(cfg.notificationChannel || 'none').trim().toLowerCase();\nconst title = cfg.workflowTitle || 'Loomwise lead workflow';\nconst subject = `Workflow failed: ${title}`;\nconst body = [\n  `The n8n workflow \"${title}\" hit an error and stopped.`,\n  'New rows were not appended from this failed step. Open the failed execution in n8n, fix the issue, then run it again.',\n  '',\n  `Error: ${message}`,\n].join('\\n');\n\nreturn [{\n  json: {\n    isFailure: true,\n    notificationChannel: channel,\n    notifyEmail: cfg.notifyEmail,\n    slackChannelId: cfg.slackChannelId,\n    subject,\n    body,\n    slackText: body,\n  },\n}];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1040,
        720
      ],
      "id": "4a1968d4-de4d-4272-b336-63eb0f6994b5",
      "name": "Format a run error"
    },
    {
      "parameters": {
        "rules": {
          "values": [
            {
              "conditions": {
                "options": {
                  "caseSensitive": false,
                  "leftValue": "",
                  "typeValidation": "loose",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "0438d5e9-530c-4728-8e7d-86c04efc7545",
                    "leftValue": "={{ $json.notificationChannel }}",
                    "rightValue": "gmail",
                    "operator": {
                      "type": "string",
                      "operation": "equals",
                      "name": "filter.operator.equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "gmail"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": false,
                  "leftValue": "",
                  "typeValidation": "loose",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "297e683d-27b9-4221-88cc-fe45e3d4fdc2",
                    "leftValue": "={{ $json.notificationChannel }}",
                    "rightValue": "slack",
                    "operator": {
                      "type": "string",
                      "operation": "equals",
                      "name": "filter.operator.equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "slack"
            }
          ]
        },
        "options": {
          "fallbackOutput": "none"
        }
      },
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3.2,
      "position": [
        1320,
        720
      ],
      "id": "c26ba0b7-6a52-4918-b062-97a55c9a8d6c",
      "name": "Route the failure alert"
    },
    {
      "parameters": {
        "sendTo": "={{ $json.notifyEmail }}",
        "subject": "={{ $json.subject }}",
        "emailType": "text",
        "message": "={{ $json.body }}",
        "options": {}
      },
      "type": "n8n-nodes-base.gmail",
      "typeVersion": 2.1,
      "position": [
        1600,
        640
      ],
      "id": "0a9d24d1-a77b-48fa-9079-c35ee1a0660e",
      "name": "Email a failure alert",
      "credentials": {
        "gmailOAuth2": {
          "id": "REPLACE_WITH_YOUR_GMAIL_CREDENTIAL_ID",
          "name": "Gmail account"
        }
      },
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 5000
    },
    {
      "parameters": {
        "select": "channel",
        "channelId": {
          "__rl": true,
          "value": "={{ $json.slackChannelId }}",
          "mode": "id"
        },
        "text": "={{ $json.slackText }}",
        "otherOptions": {}
      },
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.2,
      "position": [
        1600,
        820
      ],
      "id": "10363bbb-648f-43ac-b148-608e71a87dcb",
      "name": "Post a failure alert to Slack",
      "credentials": {
        "slackApi": {
          "id": "REPLACE_WITH_YOUR_SLACK_CREDENTIAL_ID",
          "name": "Slack account"
        }
      },
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 5000
    }
  ],
  "pinData": {},
  "connections": {
    "Every day 6am": {
      "main": [
        [
          {
            "node": "Configure filters",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Configure filters": {
      "main": [
        [
          {
            "node": "Read existing sheet rows",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read existing sheet rows": {
      "main": [
        [
          {
            "node": "Collect existing IDs",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Format a run error",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Collect existing IDs": {
      "main": [
        [
          {
            "node": "Fetch SAM.gov opportunities",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch SAM.gov opportunities": {
      "main": [
        [
          {
            "node": "Keep only new matching leads",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Format a run error",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Keep only new matching leads": {
      "main": [
        [
          {
            "node": "Split out new rows",
            "type": "main",
            "index": 0
          },
          {
            "node": "Should send a summary?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Split out new rows": {
      "main": [
        [
          {
            "node": "Append new rows to Sheet",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Append new rows to Sheet": {
      "main": [
        [],
        [
          {
            "node": "Format a run error",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Should send a summary?": {
      "main": [
        [
          {
            "node": "Route the notification",
            "type": "main",
            "index": 0
          }
        ],
        []
      ]
    },
    "Route the notification": {
      "main": [
        [
          {
            "node": "Email the run summary",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Post the run summary to Slack",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Format a run error": {
      "main": [
        [
          {
            "node": "Route the failure alert",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Route the failure alert": {
      "main": [
        [
          {
            "node": "Email a failure alert",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Post a failure alert to Slack",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "active": false,
  "settings": {
    "executionOrder": "v1",
    "callerPolicy": "workflowsFromSameOwner"
  },
  "versionId": "464597ce-f38b-41f3-a9a0-8474b7d83b85",
  "meta": {
    "templateCredsSetupCompleted": false,
    "instanceId": "loomwise-template-export"
  },
  "tags": []
}
