{
  "id": "KbbsNlzWnqBE3UMC",
  "meta": {
    "templateCredsSetupCompleted": true
  },
  "name": "Automated average House Rental & Sales info to slack",
  "tags": [],
  "nodes": [
    {
      "id": "e9c2c6e2-1cf7-436a-8998-f3d1a845a24a",
      "name": "Sticky Note",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1968,
        -1168
      ],
      "parameters": {
        "width": 480,
        "height": 896,
        "content": "## Automated average House Rental & Sales info to Slack\n\n### How it works\n\n1. Triggers on a set schedule and configures a target zipcode for real estate analysis.\n2. Queries a Supabase database for existing rental and sales market records to see if updates are needed.\n3. Sends a Slack notification with the current status if the existing database records are already up-to-date.\n4. Fetches the latest market statistics from the Rentcast API if new data is required.\n5. Upserts the freshly fetched rental and sales market data into the Supabase database.\n6. Formats the results and sends a final Slack notification summarizing the newly updated market information.\n\n### Setup steps\n\n- [ ] Connect your Supabase account and ensure the required tables for market rentals and sales exist.\n- [ ] Configure the Rentcast API credentials in the HTTP Request node.\n- [ ] Connect your Slack workspace and specify the target channels in all Slack notification nodes.\n- [ ] Adjust the Schedule Trigger to run at your desired reporting frequency.\n\n### Customization\n\nYou can easily modify the Edit Fields node to track a different zipcode, or expand the JavaScript nodes to customize the Slack message formatting."
      },
      "typeVersion": 1
    },
    {
      "id": "2513b2cc-8642-411e-95b6-73f10a7eed32",
      "name": "Sticky Note1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2528,
        -880
      ],
      "parameters": {
        "color": 7,
        "width": 416,
        "height": 304,
        "content": "## Trigger and Configure Zipcode\n\nSets up the scheduled execution and initializes the target zipcode for the workflow."
      },
      "typeVersion": 1
    },
    {
      "id": "18ce6b0c-d846-4769-9248-c47e0b504fe7",
      "name": "Sticky Note2",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2976,
        -976
      ],
      "parameters": {
        "color": 7,
        "width": 864,
        "height": 496,
        "content": "## Fetch and Evaluate Database Records\n\nRetrieves existing rental and sales records from Supabase, merges the results, and determines if an update is necessary."
      },
      "typeVersion": 1
    },
    {
      "id": "bd33b3c0-f953-4b72-9538-d5b714eb251d",
      "name": "Sticky Note3",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        3872,
        -1168
      ],
      "parameters": {
        "color": 7,
        "width": 640,
        "height": 496,
        "content": "## Existing Record Slack Notifications\n\nProcesses the existing database records and routes a corresponding status message to Slack."
      },
      "typeVersion": 1
    },
    {
      "id": "5f5ee003-d418-4329-bf7c-acbefd66417c",
      "name": "Sticky Note4",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        3872,
        -640
      ],
      "parameters": {
        "color": 7,
        "height": 384,
        "content": "## Fetch Rentcast Market Data\n\nSends a GET request to the Rentcast API to retrieve the latest real estate market statistics."
      },
      "typeVersion": 1
    },
    {
      "id": "9fa02f90-5b61-4cd2-b024-c7250b53577c",
      "name": "Sticky Note5",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        4144,
        -640
      ],
      "parameters": {
        "color": 7,
        "width": 640,
        "height": 496,
        "content": "## Upsert Market Statistics\n\nSaves the newly fetched rental and sales market data into Supabase, then aggregates the upsert results."
      },
      "typeVersion": 1
    },
    {
      "id": "e48707f3-7640-4d03-b35f-bcdf6811d6d1",
      "name": "Sticky Note6",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        4816,
        -720
      ],
      "parameters": {
        "color": 7,
        "width": 416,
        "height": 320,
        "content": "## Format New Market Data\n\nUses custom JavaScript to format the aggregated market data and evaluates routing conditions for notifications."
      },
      "typeVersion": 1
    },
    {
      "id": "557a548d-9c65-4e52-99ae-36f7dfde1177",
      "name": "Sticky Note7",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        5264,
        -864
      ],
      "parameters": {
        "color": 7,
        "height": 576,
        "content": "## Market Update Slack Notifications\n\nDispatches Slack messages to the team summarizing the newly inserted real estate market updates."
      },
      "typeVersion": 1
    },
    {
      "id": "87d15805-25be-4531-a535-c8553807b03f",
      "name": "Monthly Trigger",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [
        2576,
        -752
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "months",
              "triggerAtDayOfMonth": 5
            }
          ]
        }
      },
      "typeVersion": 1.3
    },
    {
      "id": "cb54ce84-5e21-4e12-a39d-38fddf10f5d7",
      "name": "Fetch Market Data",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        3920,
        -432
      ],
      "parameters": {
        "url": "https://api.rentcast.io/v1/markets",
        "options": {},
        "sendQuery": true,
        "sendHeaders": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "zipCode",
              "value": "={{ $('Set Zipcode Parameter').item.json.zipcode }}"
            },
            {
              "name": "dataType",
              "value": "All"
            },
            {
              "name": "historyRange",
              "value": "3"
            }
          ]
        },
        "headerParameters": {
          "parameters": [
            {
              "name": "Accept",
              "value": "application/json"
            },
            {
              "name": "X-Api-Key",
              "value": "86b21717132741519ed7b2b23049054b"
            }
          ]
        }
      },
      "typeVersion": 4.4
    },
    {
      "id": "300b1207-9eed-41a3-a645-e032ce667389",
      "name": "Set Zipcode Parameter",
      "type": "n8n-nodes-base.set",
      "position": [
        2800,
        -752
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "f0cf0b1a-df9b-4ba1-a6d3-4e1490250a8b",
              "name": "zipcode",
              "type": "number",
              "value": 78701
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "c4dc3fbf-29f2-4c19-93f1-98283ffa6baa",
      "name": "Fetch Rentals from Supabase",
      "type": "n8n-nodes-base.supabase",
      "position": [
        3024,
        -848
      ],
      "parameters": {
        "filters": {
          "conditions": [
            {
              "keyName": "zip_code",
              "keyValue": "={{ $json.zipcode }}",
              "condition": "eq"
            },
            {
              "keyName": "last_updated_date",
              "keyValue": "={{ $now.startOf('month').toISO() }}",
              "condition": "lt"
            }
          ]
        },
        "tableId": "market_rentals",
        "operation": "getAll"
      },
      "credentials": {
        "supabaseApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1,
      "alwaysOutputData": true
    },
    {
      "id": "ef22eb80-768b-41b3-b9a6-be2200dac04e",
      "name": "Fetch Sales from Supabase",
      "type": "n8n-nodes-base.supabase",
      "position": [
        3024,
        -656
      ],
      "parameters": {
        "filters": {
          "conditions": [
            {
              "keyName": "zip_code",
              "keyValue": "={{ $json.zipcode }}",
              "condition": "eq"
            },
            {
              "keyName": "last_updated_date",
              "keyValue": "={{ $now.startOf('month').toISO() }}",
              "condition": "lt"
            }
          ]
        },
        "tableId": "market_sales",
        "operation": "getAll"
      },
      "credentials": {
        "supabaseApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1,
      "alwaysOutputData": true
    },
    {
      "id": "d0787b0b-820b-417f-adba-eff35122da19",
      "name": "Merge Supabase Results",
      "type": "n8n-nodes-base.merge",
      "position": [
        3248,
        -752
      ],
      "parameters": {
        "mode": "combine",
        "options": {},
        "joinMode": "keepEverything",
        "fieldsToMatchString": "zip_code"
      },
      "typeVersion": 3.2,
      "alwaysOutputData": true
    },
    {
      "id": "e4ba9dad-ea73-4d6e-9ddf-aa6140df3d09",
      "name": "If Data Exists",
      "type": "n8n-nodes-base.if",
      "position": [
        3696,
        -752
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 3,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "6f64c529-1362-42b3-8df3-f2a040c698a0",
              "operator": {
                "type": "object",
                "operation": "notEmpty",
                "singleValue": true
              },
              "leftValue": "={{ $json.data[0] }}",
              "rightValue": ""
            }
          ]
        }
      },
      "typeVersion": 2.3
    },
    {
      "id": "d0b0200f-72e0-421d-984d-2db074d1f2bb",
      "name": "Aggregate Market Data",
      "type": "n8n-nodes-base.aggregate",
      "position": [
        3472,
        -752
      ],
      "parameters": {
        "options": {},
        "aggregate": "aggregateAllItemData"
      },
      "typeVersion": 1
    },
    {
      "id": "456d034d-32e7-4cb0-b5e1-a22e689abdb9",
      "name": "Merge Market Updates",
      "type": "n8n-nodes-base.merge",
      "position": [
        4416,
        -416
      ],
      "parameters": {
        "mode": "combine",
        "options": {},
        "joinMode": "keepEverything",
        "fieldsToMatchString": "zip_code"
      },
      "typeVersion": 3.2,
      "alwaysOutputData": true
    },
    {
      "id": "642e17c6-e792-4399-b6e7-a4ff5e7ad0ac",
      "name": "Upsert Rentals to Supabase",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        4192,
        -512
      ],
      "parameters": {
        "url": "https://voffipplerpdeedrexvf.supabase.co/rest/v1/market_rentals",
        "method": "POST",
        "options": {},
        "sendBody": true,
        "sendHeaders": true,
        "authentication": "predefinedCredentialType",
        "bodyParameters": {
          "parameters": [
            {
              "name": "zip_code",
              "value": "={{ $json.zipCode }}"
            },
            {
              "name": "last_updated_date",
              "value": "={{ $json.saleData.lastUpdatedDate }}"
            },
            {
              "name": "average_rent",
              "value": "={{ $json.rentalData.averageRent }}"
            },
            {
              "name": "median_rent",
              "value": "={{ $json.rentalData.medianRent }}"
            },
            {
              "name": "min_rent",
              "value": "={{ $json.rentalData.minRent }}"
            },
            {
              "name": "max_rent",
              "value": "={{ $json.rentalData.maxRent }}"
            },
            {
              "name": "average_rent_per_sqft",
              "value": "={{ $json.rentalData.averageRentPerSquareFoot }}"
            },
            {
              "name": "median_rent_per_sqft",
              "value": "={{ $json.rentalData.medianRentPerSquareFoot }}"
            },
            {
              "name": "min_rent_per_sqft",
              "value": "={{ $json.rentalData.minRentPerSquareFoot }}"
            },
            {
              "name": "max_rent_per_sqft",
              "value": "={{ $json.rentalData.maxRentPerSquareFoot }}"
            },
            {
              "name": "average_sqft",
              "value": "={{ $json.rentalData.averageSquareFootage }}"
            },
            {
              "name": "median_sqft",
              "value": "={{ $json.rentalData.medianSquareFootage }}"
            },
            {
              "name": "min_sqft",
              "value": "={{ $json.rentalData.minSquareFootage }}"
            },
            {
              "name": "max_sqft",
              "value": "={{ $json.rentalData.maxSquareFootage }}"
            },
            {
              "name": "average_dom",
              "value": "={{ $json.rentalData.averageDaysOnMarket }}"
            },
            {
              "name": "median_dom",
              "value": "={{ $json.rentalData.medianDaysOnMarket }}"
            },
            {
              "name": "min_dom",
              "value": "={{ $json.rentalData.minDaysOnMarket }}"
            },
            {
              "name": "max_dom",
              "value": "={{ $json.rentalData.maxDaysOnMarket }}"
            },
            {
              "name": "new_listings",
              "value": "={{ $json.rentalData.newListings }}"
            },
            {
              "name": "total_listings",
              "value": "={{ $json.rentalData.totalListings }}"
            },
            {
              "name": "data_by_property_type_rental",
              "value": "={{ $json.rentalData.dataByPropertyType }}"
            },
            {
              "name": "data_by_bedrooms_rental",
              "value": "={{ $json.rentalData.dataByBedrooms }}"
            },
            {
              "name": "created_at",
              "value": "={{ $now }}"
            },
            {
              "name": "updated_at",
              "value": "={{ $now }}"
            },
            {
              "name": "history_rentals",
              "value": "={{ $json.rentalData.history }}"
            }
          ]
        },
        "headerParameters": {
          "parameters": [
            {
              "name": "Prefer",
              "value": "resolution=merge-duplicates"
            }
          ]
        },
        "nodeCredentialType": "supabaseApi"
      },
      "credentials": {
        "supabaseApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.1
    },
    {
      "id": "9c44e53c-53d2-40cd-93af-b9cfa6316b1d",
      "name": "Upsert Sales to Supabase",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        4192,
        -320
      ],
      "parameters": {
        "url": "https://voffipplerpdeedrexvf.supabase.co/rest/v1/market_sales",
        "method": "POST",
        "options": {},
        "sendBody": true,
        "sendHeaders": true,
        "authentication": "predefinedCredentialType",
        "bodyParameters": {
          "parameters": [
            {
              "name": "zip_code",
              "value": "={{ $json.zipCode }}"
            },
            {
              "name": "last_updated_date",
              "value": "={{ $json.saleData.lastUpdatedDate }}"
            },
            {
              "name": "average_price",
              "value": "={{ $json.saleData.averagePrice }}"
            },
            {
              "name": "median_price",
              "value": "={{ $json.saleData.medianPrice }}"
            },
            {
              "name": "min_price",
              "value": "={{ $json.saleData.minPrice }}"
            },
            {
              "name": "max_price",
              "value": "={{ $json.saleData.maxPrice }}"
            },
            {
              "name": "average_price_per_sqft",
              "value": "={{ $json.saleData.averagePricePerSquareFoot }}"
            },
            {
              "name": "median_price_per_sqft",
              "value": "={{ $json.saleData.medianPricePerSquareFoot }}"
            },
            {
              "name": "min_price_per_sqft",
              "value": "={{ $json.saleData.minPricePerSquareFoot }}"
            },
            {
              "name": "max_price_per_sqft",
              "value": "={{ $json.saleData.maxPricePerSquareFoot }}"
            },
            {
              "name": "average_sqft",
              "value": "={{ $json.saleData.averageSquareFootage }}"
            },
            {
              "name": "median_sqft",
              "value": "={{ $json.saleData.medianSquareFootage }}"
            },
            {
              "name": "min_sqft",
              "value": "={{ $json.saleData.minSquareFootage }}"
            },
            {
              "name": "max_sqft",
              "value": "={{ $json.saleData.maxSquareFootage }}"
            },
            {
              "name": "average_dom",
              "value": "={{ $json.saleData.averageDaysOnMarket }}"
            },
            {
              "name": "median_dom",
              "value": "={{ $json.saleData.medianDaysOnMarket }}"
            },
            {
              "name": "min_dom",
              "value": "={{ $json.saleData.minDaysOnMarket }}"
            },
            {
              "name": "max_dom",
              "value": "={{ $json.saleData.maxDaysOnMarket }}"
            },
            {
              "name": "new_listings",
              "value": "={{ $json.saleData.newListings }}"
            },
            {
              "name": "total_listings",
              "value": "={{ $json.saleData.totalListings }}"
            },
            {
              "name": "data_by_property_type_sales",
              "value": "={{ $json.saleData.dataByPropertyType }}"
            },
            {
              "name": "data_by_bedrooms_sales",
              "value": "={{ $json.saleData.dataByBedrooms }}"
            },
            {
              "name": "created_at",
              "value": "={{ $now }}"
            },
            {
              "name": "updated_at",
              "value": "={{ $now }}"
            },
            {
              "name": "history_sales",
              "value": "={{ $json.saleData.history }}"
            }
          ]
        },
        "headerParameters": {
          "parameters": [
            {
              "name": "Prefer",
              "value": "resolution=merge-duplicates"
            }
          ]
        },
        "nodeCredentialType": "supabaseApi"
      },
      "credentials": {
        "supabaseApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.1
    },
    {
      "id": "ab62c698-966c-4fa8-9827-fcc74221e4b0",
      "name": "Aggregate Upsert Status",
      "type": "n8n-nodes-base.aggregate",
      "position": [
        4640,
        -416
      ],
      "parameters": {
        "options": {},
        "aggregate": "aggregateAllItemData"
      },
      "typeVersion": 1
    },
    {
      "id": "52c992a9-da0f-402d-8a02-31a99ec2c350",
      "name": "Check Upsert Success",
      "type": "n8n-nodes-base.code",
      "position": [
        4864,
        -560
      ],
      "parameters": {
        "jsCode": "const data = $('Fetch Market Data').first().json;\n\nif (!data) {\n  throw new Error('No data found from HTTP Request node \u2014 check the connection and pinned data.');\n}\n\nconst id = data.id;\nconst zipCode = data.zipCode;\n\nconst saleHistory = data.saleData?.history || {};\nconst rentHistory = data.rentalData?.history || {};\n\nconst saleMonths = Object.keys(saleHistory).sort();\nconst rentMonths = Object.keys(rentHistory).sort();\n\nconst last3Sale = saleMonths.slice(-3);\nconst last3Rent = rentMonths.slice(-3);\n\nfunction compare(historyObj, months, field) {\n  const series = months.map(m => ({\n    month: m,\n    value: historyObj[m]?.[field] ?? null\n  }));\n\n  while (series.length < 3) series.unshift({ month: null, value: null });\n\n  const [twoAgo, oneAgo, current] = series;\n\n  const diff = (current?.value != null && oneAgo?.value != null)\n    ? current.value - oneAgo.value\n    : null;\n\n  const pct = (diff != null && oneAgo.value !== 0)\n    ? (diff / oneAgo.value) * 100\n    : null;\n\n  return {\n    months: series,\n    previousMonth: oneAgo,\n    currentMonth: current,\n    change: diff !== null ? Number(diff.toFixed(2)) : null,\n    percentChange: pct !== null ? Number(pct.toFixed(2)) : null,\n    direction: diff > 0 ? 'increase' : diff < 0 ? 'decrease' : diff === 0 ? 'no change' : 'insufficient data'\n  };\n}\n\n// Plain-English sentence for one metric (price or rent)\nfunction buildSentence(comparison, label, currency = true) {\n  const { previousMonth, currentMonth, change, percentChange, direction } = comparison;\n\n  if (!previousMonth?.month || !currentMonth?.month || change === null) {\n    return `Not enough historical data to compare ${label.toLowerCase()} month-over-month.`;\n  }\n\n  const fmt = (v) => currency\n    ? `$${Math.abs(v).toLocaleString(undefined, { maximumFractionDigits: 0 })}`\n    : Math.abs(v).toLocaleString();\n\n  const curVal = fmt(currentMonth.value);\n  const prevVal = fmt(previousMonth.value);\n  const changeVal = fmt(change);\n  const pctVal = percentChange !== null ? `${Math.abs(percentChange)}%` : 'an unknown percentage';\n\n  if (direction === 'no change') {\n    return `${label} stayed flat at ${curVal} in ${currentMonth.month}, unchanged from ${previousMonth.month}.`;\n  }\n\n  return `${label} in ${currentMonth.month} is ${curVal}, ${direction === 'increase' ? 'up' : 'down'} ${changeVal} (${pctVal}) from ${prevVal} in ${previousMonth.month}.`;\n}\n\n// Slack mrkdwn version of the same metric \u2014 bold values, arrow + emoji for direction\nfunction buildSlackLine(comparison, label, currency = true) {\n  const { previousMonth, currentMonth, change, percentChange, direction } = comparison;\n\n  if (!previousMonth?.month || !currentMonth?.month || change === null) {\n    return `:warning: *${label}*: not enough historical data to compare month-over-month.`;\n  }\n\n  const fmt = (v) => currency\n    ? `$${Math.abs(v).toLocaleString(undefined, { maximumFractionDigits: 0 })}`\n    : Math.abs(v).toLocaleString();\n\n  const curVal = fmt(currentMonth.value);\n  const prevVal = fmt(previousMonth.value);\n  const changeVal = fmt(change);\n  const pctVal = percentChange !== null ? `${Math.abs(percentChange)}%` : 'n/a';\n\n  const arrow = direction === 'increase' ? ':arrow_upper_right:' : direction === 'decrease' ? ':arrow_lower_right:' : ':heavy_minus_sign:';\n  const verb = direction === 'increase' ? 'up' : direction === 'decrease' ? 'down' : 'flat';\n\n  return `${arrow} *${label}*: *${curVal}* (${currentMonth.month}) \u2014 ${verb} ${changeVal} (*${pctVal}*) vs ${prevVal} in ${previousMonth.month}`;\n}\n\nconst saleComparison = compare(saleHistory, last3Sale, 'averagePrice');\nconst rentComparison = compare(rentHistory, last3Rent, 'averageRent');\n\nconst saleSentence = buildSentence(saleComparison, 'Average sale price', true);\nconst rentSentence = buildSentence(rentComparison, 'Average rent', true);\n\nconst saleSlackLine = buildSlackLine(saleComparison, 'Avg Sale Price', true);\nconst rentSlackLine = buildSlackLine(rentComparison, 'Avg Rent', true);\n\n// Flag: true only if BOTH sale and rent have real month-over-month comparisons\nconst hasSufficientData =\n  saleComparison.change !== null &&\n  rentComparison.change !== null &&\n  saleComparison.previousMonth?.month != null &&\n  saleComparison.currentMonth?.month != null &&\n  rentComparison.previousMonth?.month != null &&\n  rentComparison.currentMonth?.month != null;\n\nconst slackSummary = `*Market Update \u2014 ZIP ${zipCode}*\\n${saleSlackLine}\\n${rentSlackLine}`;\n\nreturn [{\n  json: {\n    id,\n    zipCode,\n    sale: saleComparison,\n    rent: rentComparison,\n    summary: `${saleSentence} ${rentSentence}`,\n    slackSummary,\n    hasSufficientData\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "ca8824ee-1e72-4b81-a9c1-05593b7311f5",
      "name": "Post Success to Slack",
      "type": "n8n-nodes-base.slack",
      "position": [
        5312,
        -656
      ],
      "parameters": {
        "text": "={{ $json.slackSummary }}",
        "select": "channel",
        "channelId": {
          "__rl": true,
          "mode": "list",
          "value": "C0B8VH1M5PX",
          "cachedResultName": "general"
        },
        "otherOptions": {}
      },
      "credentials": {
        "slackApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2.5
    },
    {
      "id": "c04056a2-1e85-4306-9bfe-95185b42ab5f",
      "name": "If Upsert Successful",
      "type": "n8n-nodes-base.if",
      "position": [
        5088,
        -560
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 3,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "c86898ab-e3be-40dd-8c6a-3e4d7059c9b3",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              },
              "leftValue": "={{ $json.hasSufficientData }}",
              "rightValue": false
            }
          ]
        }
      },
      "typeVersion": 2.3
    },
    {
      "id": "ae9ff542-993a-4b38-984b-4f5731791c5a",
      "name": "Post Error to Slack",
      "type": "n8n-nodes-base.slack",
      "position": [
        5312,
        -464
      ],
      "parameters": {
        "text": "=There is an issue with the workflow, so please fix it ASAP.",
        "select": "channel",
        "channelId": {
          "__rl": true,
          "mode": "list",
          "value": "C0B8VH1M5PX",
          "cachedResultName": "general"
        },
        "otherOptions": {}
      },
      "credentials": {
        "slackApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2.5
    },
    {
      "id": "beeb77d8-0f85-4780-a39d-245fac6746a9",
      "name": "Analyze Empty Data Response",
      "type": "n8n-nodes-base.code",
      "position": [
        3920,
        -944
      ],
      "parameters": {
        "jsCode": "// The Aggregate node bundles everything into an array named 'data'. \n// We need to target the first item inside that array.\nconst payload = $input.first().json; \nconst data = payload.data ? payload.data[0] : payload;\n\nif (!data) {\n  throw new Error('No data found from the previous node \u2014 check the connection and pinned data.');\n}\n\n// Map to the correct JSON keys \nconst id = data.id || null; \nconst zipCode = data.zip_code; \n\n// Target the correct history objects\nconst saleHistory = data.history_sales || {};\nconst rentHistory = data.history_rentals || {};\n\nconst saleMonths = Object.keys(saleHistory).sort();\nconst rentMonths = Object.keys(rentHistory).sort();\n\nconst last3Sale = saleMonths.slice(-3);\nconst last3Rent = rentMonths.slice(-3);\n\nfunction compare(historyObj, months, field) {\n  const series = months.map(m => ({\n    month: m,\n    value: historyObj[m]?.[field] ?? null\n  }));\n\n  while (series.length < 3) series.unshift({ month: null, value: null });\n\n  const [twoAgo, oneAgo, current] = series;\n\n  const diff = (current?.value != null && oneAgo?.value != null)\n    ? current.value - oneAgo.value\n    : null;\n\n  const pct = (diff != null && oneAgo.value !== 0)\n    ? (diff / oneAgo.value) * 100\n    : null;\n\n  return {\n    months: series,\n    previousMonth: oneAgo,\n    currentMonth: current,\n    change: diff !== null ? Number(diff.toFixed(2)) : null,\n    percentChange: pct !== null ? Number(pct.toFixed(2)) : null,\n    direction: diff > 0 ? 'increase' : diff < 0 ? 'decrease' : diff === 0 ? 'no change' : 'insufficient data'\n  };\n}\n\n// Plain-English sentence for one metric (price or rent)\nfunction buildSentence(comparison, label, currency = true) {\n  const { previousMonth, currentMonth, change, percentChange, direction } = comparison;\n\n  if (!previousMonth?.month || !currentMonth?.month || change === null) {\n    return `Not enough historical data to compare ${label.toLowerCase()} month-over-month.`;\n  }\n\n  const fmt = (v) => currency\n    ? `$${Math.abs(v).toLocaleString(undefined, { maximumFractionDigits: 0 })}`\n    : Math.abs(v).toLocaleString();\n\n  const curVal = fmt(currentMonth.value);\n  const prevVal = fmt(previousMonth.value);\n  const changeVal = fmt(change);\n  const pctVal = percentChange !== null ? `${Math.abs(percentChange)}%` : 'an unknown percentage';\n\n  if (direction === 'no change') {\n    return `${label} stayed flat at ${curVal} in ${currentMonth.month}, unchanged from ${previousMonth.month}.`;\n  }\n\n  return `${label} in ${currentMonth.month} is ${curVal}, ${direction === 'increase' ? 'up' : 'down'} ${changeVal} (${pctVal}) from ${prevVal} in ${previousMonth.month}.`;\n}\n\n// Slack mrkdwn version of the same metric \u2014 bold values, arrow + emoji for direction\nfunction buildSlackLine(comparison, label, currency = true) {\n  const { previousMonth, currentMonth, change, percentChange, direction } = comparison;\n\n  if (!previousMonth?.month || !currentMonth?.month || change === null) {\n    return `:warning: *${label}*: not enough historical data to compare month-over-month.`;\n  }\n\n  const fmt = (v) => currency\n    ? `$${Math.abs(v).toLocaleString(undefined, { maximumFractionDigits: 0 })}`\n    : Math.abs(v).toLocaleString();\n\n  const curVal = fmt(currentMonth.value);\n  const prevVal = fmt(previousMonth.value);\n  const changeVal = fmt(change);\n  const pctVal = percentChange !== null ? `${Math.abs(percentChange)}%` : 'n/a';\n\n  const arrow = direction === 'increase' ? ':arrow_upper_right:' : direction === 'decrease' ? ':arrow_lower_right:' : ':heavy_minus_sign:';\n  const verb = direction === 'increase' ? 'up' : direction === 'decrease' ? 'down' : 'flat';\n\n  return `${arrow} *${label}*: *${curVal}* (${currentMonth.month}) \u2014 ${verb} ${changeVal} (*${pctVal}*) vs ${prevVal} in ${previousMonth.month}`;\n}\n\nconst saleComparison = compare(saleHistory, last3Sale, 'averagePrice');\nconst rentComparison = compare(rentHistory, last3Rent, 'averageRent');\n\nconst saleSentence = buildSentence(saleComparison, 'Average sale price', true);\nconst rentSentence = buildSentence(rentComparison, 'Average rent', true);\n\nconst saleSlackLine = buildSlackLine(saleComparison, 'Avg Sale Price', true);\nconst rentSlackLine = buildSlackLine(rentComparison, 'Avg Rent', true);\n\n// Flag: true only if BOTH sale and rent have real month-over-month comparisons\nconst hasSufficientData =\n  saleComparison.change !== null &&\n  rentComparison.change !== null &&\n  saleComparison.previousMonth?.month != null &&\n  saleComparison.currentMonth?.month != null &&\n  rentComparison.previousMonth?.month != null &&\n  rentComparison.currentMonth?.month != null;\n\nconst slackSummary = `*Market Update \u2014 ZIP ${zipCode}*\\n${saleSlackLine}\\n${rentSlackLine}`;\n\nreturn [{\n  json: {\n    id,\n    zipCode,\n    sale: saleComparison,\n    rent: rentComparison,\n    summary: `${saleSentence} ${rentSentence}`,\n    slackSummary,\n    hasSufficientData\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "cfa30766-8b38-4d57-aec7-59fcc09859bb",
      "name": "If Data Missing",
      "type": "n8n-nodes-base.if",
      "position": [
        4144,
        -944
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 3,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "cbd6cba0-4bd6-433f-a712-47757627a1a3",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              },
              "leftValue": "={{ $json.hasSufficientData }}",
              "rightValue": ""
            }
          ]
        }
      },
      "typeVersion": 2.3
    },
    {
      "id": "6f7b0f13-4c9d-4de2-a136-0278120ba4a8",
      "name": "Notify Missing Data to Slack",
      "type": "n8n-nodes-base.slack",
      "position": [
        4368,
        -1040
      ],
      "parameters": {
        "text": "={{ $json.slackSummary }}",
        "select": "channel",
        "channelId": {
          "__rl": true,
          "mode": "list",
          "value": "C0B8VH1M5PX",
          "cachedResultName": "general"
        },
        "otherOptions": {}
      },
      "credentials": {
        "slackApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2.5
    },
    {
      "id": "4ff88d7f-16ff-4354-9702-72ee4a111447",
      "name": "Notify Admin of Error",
      "type": "n8n-nodes-base.slack",
      "position": [
        4368,
        -848
      ],
      "parameters": {
        "text": "=There is an issue with the workflow, so please fix it ASAP.",
        "select": "channel",
        "channelId": {
          "__rl": true,
          "mode": "list",
          "value": "C0B8VH1M5PX",
          "cachedResultName": "general"
        },
        "otherOptions": {}
      },
      "credentials": {
        "slackApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2.5
    }
  ],
  "active": false,
  "settings": {
    "binaryMode": "separate",
    "availableInMCP": false,
    "executionOrder": "v1"
  },
  "versionId": "989d85e5-6d3f-4c20-af73-7e9a558b9b60",
  "nodeGroups": [],
  "connections": {
    "If Data Exists": {
      "main": [
        [
          {
            "node": "Analyze Empty Data Response",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Fetch Market Data",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "If Data Missing": {
      "main": [
        [
          {
            "node": "Notify Missing Data to Slack",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Notify Admin of Error",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Monthly Trigger": {
      "main": [
        [
          {
            "node": "Set Zipcode Parameter",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch Market Data": {
      "main": [
        [
          {
            "node": "Upsert Rentals to Supabase",
            "type": "main",
            "index": 0
          },
          {
            "node": "Upsert Sales to Supabase",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check Upsert Success": {
      "main": [
        [
          {
            "node": "If Upsert Successful",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "If Upsert Successful": {
      "main": [
        [
          {
            "node": "Post Success to Slack",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Post Error to Slack",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Merge Market Updates": {
      "main": [
        [
          {
            "node": "Aggregate Upsert Status",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Aggregate Market Data": {
      "main": [
        [
          {
            "node": "If Data Exists",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Set Zipcode Parameter": {
      "main": [
        [
          {
            "node": "Fetch Rentals from Supabase",
            "type": "main",
            "index": 0
          },
          {
            "node": "Fetch Sales from Supabase",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Merge Supabase Results": {
      "main": [
        [
          {
            "node": "Aggregate Market Data",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Aggregate Upsert Status": {
      "main": [
        [
          {
            "node": "Check Upsert Success",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Upsert Sales to Supabase": {
      "main": [
        [
          {
            "node": "Merge Market Updates",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "Fetch Sales from Supabase": {
      "main": [
        [
          {
            "node": "Merge Supabase Results",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "Upsert Rentals to Supabase": {
      "main": [
        [
          {
            "node": "Merge Market Updates",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Analyze Empty Data Response": {
      "main": [
        [
          {
            "node": "If Data Missing",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch Rentals from Supabase": {
      "main": [
        [
          {
            "node": "Merge Supabase Results",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}