{
  "id": "HJMDTOdY8tK6LICd",
  "meta": {
    "templateCredsSetupCompleted": true
  },
  "name": "Match Lost and Found Items with OpenAI, SendGrid and Google Sheets",
  "tags": [],
  "nodes": [
    {
      "id": "85d1b6e2-e3e6-4a89-b812-9c17c5c8d2f6",
      "name": "Sticky Note",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2944,
        -1120
      ],
      "parameters": {
        "width": 1000,
        "height": 1144,
        "content": "## Lost & Found Item Matcher\n\nThis workflow logs lost and found item reports from airports, hotels, or transit desks, uses OpenAI to validate and normalize each freeform report into structured data, searches existing reports on file for the best possible match, and emails both parties via SendGrid once a confident match is found \u2014 with every case tracked in Google Sheets.\n\n### Who's it for\n- Airport lost & found desks handling dozens of reports a day\n- Hotel concierge teams reconciling guest lost-item reports\n- Transit authorities (rail, bus, rideshare) matching lost items to claims\n- Any front desk that wants to stop manually cross-checking spreadsheets\n\n### How it works\n1. A desk agent or online form submits a POST request with report type (lost/found), item description, location, date, and contact info\n2. OpenAI normalizes the freeform description into structured fields \u2014 category, color, brand, distinguishing features \u2014 and flags whether the report is valid/complete\n3. Existing opposite-type reports (found reports for a lost item, or vice versa) are pulled from the Google Sheets log\n4. A Python scoring engine compares the new report against every candidate on file, weighing category, color, brand, location, and date proximity\n5. The best match is selected; if its confidence score clears the threshold, AI drafts a warm, clear match notification\n6. SendGrid emails both the original reporter and the matched reporter with the match details and a claim reference\n7. Every report \u2014 matched or still pending \u2014 is logged to Google Sheets for audit and future rematching\n8. The webhook returns the full outcome (matched, pending, or invalid) as JSON\n\n### How to set up\n1. Import this workflow into n8n\n2. Add OpenAI credentials to both OpenAI Chat Model nodes\n3. Add a SendGrid API key as an HTTP Header Auth credential (Authorization: Bearer YOUR_SENDGRID_API_KEY) and attach it to both Send Email nodes\n4. Replace YOUR_VERIFIED_SENDER with a SendGrid-verified sender address\n5. Add Google Sheets OAuth2 credentials and replace YOUR_SHEET_ID in all three Sheets nodes\n6. Activate the workflow and POST a test lost report, then a matching found report\n\n### Requirements\n- OpenAI API key (GPT-4.1-mini or above)\n- SendGrid account with a verified sender identity\n- Google Sheets with OAuth2 credentials\n\n### How to customize\n- Adjust the match confidence threshold in JS - Select Best Match (default: 75/100)\n- Adjust scoring weights in Python - Score Candidate Matches (default: category 40%, color 20%, brand 20%, location 10%, date 10%)\n- Add photo-similarity scoring by extending the AI normalization step with an image URL field\n- Swap SendGrid for another ESP by changing the HTTP request URL/body in the Send Email nodes\n- Add SMS fallback (Twilio) alongside email for high-value item matches\n\n### Google Sheets Column Layout\nSet up your ReportsLog tab with these headers in row 1:\nReport ID | Date | Timestamp | Report Type | Category | Color | Brand | Location | Date Lost/Found | Description | Contact Name | Contact Email | Matched Report ID | Match Score | Status"
      },
      "typeVersion": 1
    },
    {
      "id": "84cf15bb-7857-4d04-a76e-e4abf49c95a1",
      "name": "Sticky Note1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        4048,
        -944
      ],
      "parameters": {
        "color": 3,
        "width": 780,
        "height": 836,
        "content": "## 1. Trigger & Report Intake\n\nAccepts new lost/found reports via webhook POST in real time, or runs a periodic sweep every 15 minutes (useful for retrying rematches against reports that arrived after an earlier unmatched report).\n\nRequired POST fields: reportType (lost/found), itemDescription, location, dateLostFound, contactName, contactEmail.\n\nThe Set node normalizes all fields and generates a unique requestId. This raw report is then handed to OpenAI for validation and structuring."
      },
      "typeVersion": 1
    },
    {
      "id": "1ee8783c-f123-48de-a5d7-c80b738e6592",
      "name": "Sticky Note2",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        4864,
        -896
      ],
      "parameters": {
        "color": 3,
        "width": 820,
        "height": 992,
        "content": "## 2. AI Normalization & Candidate Fetch\n\nThe AI - Normalize & Validate Report agent reads the freeform description and location text and extracts structured attributes: category, color, brand, distinguishing features, a cleaned description, and an isValid flag with any missing fields.\n\nJS - Parse AI Normalization safely parses that structured output and merges it with the original report. Filter - Valid Report routes incomplete or unparseable reports to an error handler that immediately returns a clear error to the caller.\n\nValid reports move on to Fetch Existing Opposite Reports, which pulls every pending report of the opposite type (lost vs found) from the Google Sheets log so they can be compared against the new report."
      },
      "typeVersion": 1
    },
    {
      "id": "aa577ac3-4500-4fca-a0db-f07922caa303",
      "name": "Sticky Note3",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        5728,
        -800
      ],
      "parameters": {
        "color": 3,
        "width": 876,
        "height": 640,
        "content": "## 3. Scoring & Match Selection\n\nThe Python scoring engine compares the new report against every candidate pulled from the sheet, computing a weighted composite score from category match, color match, brand match, location match, and how close the dates are.\n\nJS - Select Best Match sorts all scored candidates and picks the top result, flagging isConfidentMatch when the score clears the threshold (default 75/100). Below that threshold, the report is logged as still pending rather than force-matched."
      },
      "typeVersion": 1
    },
    {
      "id": "feb03c44-7762-46a6-ab33-5046d49ff26c",
      "name": "Sticky Note4",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        6640,
        -928
      ],
      "parameters": {
        "color": 3,
        "width": 1176,
        "height": 912,
        "content": "## 4. Notify & Log\n\nIf a confident match was found: AI - Generate Match Notification Message drafts a calm, clear email explaining the match and a claim reference number, without oversharing sensitive contact details. JS - Format Match Emails builds two SendGrid payloads \u2014 one for the original reporter, one for the matched reporter \u2014 and both are sent via SendGrid, each with continueOnFail so an email outage never blocks the webhook response.\n\nIf no confident match yet: the report is simply logged as Pending so future incoming reports can be matched against it.\n\nEither way, the case is appended to the Google Sheets log, and the webhook returns the full outcome as JSON."
      },
      "typeVersion": 1
    },
    {
      "id": "66ed45c6-2f8b-4ba9-89b9-c4729f73ca71",
      "name": "Webhook - New Item Report",
      "type": "n8n-nodes-base.webhook",
      "position": [
        4208,
        -560
      ],
      "parameters": {
        "path": "lost-found-report",
        "options": {},
        "httpMethod": "POST",
        "responseMode": "responseNode"
      },
      "typeVersion": 1.1
    },
    {
      "id": "07e3f8a1-89f9-4c4a-9ba5-516e107438d5",
      "name": "Poll - Periodic Rematch Sweep",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [
        4208,
        -368
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "cronExpression",
              "expression": "*/15 * * * *"
            }
          ]
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "8f373b45-7315-4074-bb72-2d2daac39742",
      "name": "Prepare Report Context",
      "type": "n8n-nodes-base.set",
      "position": [
        4432,
        -464
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "name": "reportType",
              "type": "string",
              "value": "={{ ($json.reportType || $json.body?.reportType || 'lost').toLowerCase() }}"
            },
            {
              "name": "itemDescription",
              "type": "string",
              "value": "={{ $json.itemDescription || $json.body?.itemDescription || '' }}"
            },
            {
              "name": "location",
              "type": "string",
              "value": "={{ $json.location || $json.body?.location || '' }}"
            },
            {
              "name": "dateLostFound",
              "type": "string",
              "value": "={{ $json.dateLostFound || $json.body?.dateLostFound || '' }}"
            },
            {
              "name": "contactName",
              "type": "string",
              "value": "={{ $json.contactName || $json.body?.contactName || '' }}"
            },
            {
              "name": "contactEmail",
              "type": "string",
              "value": "={{ $json.contactEmail || $json.body?.contactEmail || '' }}"
            },
            {
              "name": "requestId",
              "type": "string",
              "value": "={{ $json.requestId || 'LF-' + Date.now().toString() }}"
            },
            {
              "name": "requestTimestamp",
              "type": "string",
              "value": "={{ new Date().toISOString() }}"
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "de5cf941-58ed-4c1b-9ea3-4b6e8fab5e53",
      "name": "AI - Normalize & Validate Report",
      "type": "@n8n/n8n-nodes-langchain.agent",
      "position": [
        4576,
        -464
      ],
      "parameters": {
        "text": "=You are an intake assistant for a lost & found matching system. Read the raw report below and return ONLY a JSON object (no markdown, no commentary) with these fields:\n\n- category (string, e.g. \"backpack\", \"phone\", \"jewelry\", \"document\", \"other\")\n- color (string, primary color, empty string if unknown)\n- brand (string, empty string if unknown)\n- distinguishingFeatures (short string, key identifying details)\n- locationNormalized (short string, cleaned-up location name)\n- dateNormalized (string, ISO date if determinable, else empty string)\n- cleanDescription (string, a clear one-sentence description)\n- isValid (boolean, true only if reportType, a usable description, location, and a contact email are all present)\n- missingFields (array of strings, names of any required fields that are missing or unusable)\n\nRaw report:\nReport Type: {{ $json.reportType }}\nDescription: {{ $json.itemDescription }}\nLocation: {{ $json.location }}\nDate: {{ $json.dateLostFound }}\nContact Name: {{ $json.contactName }}\nContact Email: {{ $json.contactEmail }}",
        "options": {},
        "promptType": "define"
      },
      "typeVersion": 1.6
    },
    {
      "id": "c51a9f7f-e205-4f62-8ee8-a3b63c8eb237",
      "name": "OpenAI Chat Model",
      "type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
      "position": [
        4656,
        -256
      ],
      "parameters": {
        "model": {
          "__rl": true,
          "mode": "list",
          "value": "gpt-4.1-mini"
        },
        "options": {},
        "builtInTools": {}
      },
      "credentials": {
        "openAiApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1.3
    },
    {
      "id": "bda8064c-62e1-4802-b572-ddd3b03d03d2",
      "name": "JS - Parse AI Normalization",
      "type": "n8n-nodes-base.code",
      "position": [
        4880,
        -464
      ],
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "// Safely parse the AI's structured JSON output and merge with the original report\nconst item = $input.item.json;\nconst raw = item.output || item.text || item.response || '{}';\n\nlet parsed = {};\ntry {\n  const cleaned = String(raw).replace(/```json|```/g, '').trim();\n  parsed = JSON.parse(cleaned);\n} catch (e) {\n  parsed = { isValid: false, missingFields: ['unparseable_ai_response'] };\n}\n\nconst original = $('Prepare Report Context').item.json;\n\nreturn {\n  json: {\n    ...original,\n    category: parsed.category || '',\n    color: parsed.color || '',\n    brand: parsed.brand || '',\n    distinguishingFeatures: parsed.distinguishingFeatures || '',\n    locationNormalized: parsed.locationNormalized || original.location,\n    dateNormalized: parsed.dateNormalized || original.dateLostFound,\n    cleanDescription: parsed.cleanDescription || original.itemDescription,\n    isValid: !!parsed.isValid && !!original.contactEmail,\n    missingFields: parsed.missingFields || []\n  }\n};"
      },
      "typeVersion": 2
    },
    {
      "id": "ad089e86-fd53-4c0b-ae31-bee942c3317a",
      "name": "Filter - Valid Report",
      "type": "n8n-nodes-base.filter",
      "position": [
        5104,
        -464
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "conditions": [
            {
              "operator": {
                "type": "boolean",
                "operation": "true"
              },
              "leftValue": "={{ $json.isValid }}"
            }
          ]
        }
      },
      "typeVersion": 2.2
    },
    {
      "id": "1d7ff8e8-898b-4079-9c00-768b014bd23d",
      "name": "Fetch Existing Opposite Reports",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        5328,
        -464
      ],
      "parameters": {
        "url": "https://sheets.googleapis.com/v4/spreadsheets/YOUR_SHEET_ID/values/ReportsLog!A2:O5000",
        "options": {},
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "googleSheetsOAuth2Api"
      },
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.2,
      "continueOnFail": true
    },
    {
      "id": "a7fc08d9-8030-4cec-9aef-2b6339b2aaae",
      "name": "JS - Parse Existing Reports",
      "type": "n8n-nodes-base.code",
      "position": [
        5552,
        -464
      ],
      "parameters": {
        "jsCode": "// Convert raw sheet rows into candidate report objects,\n// keeping only pending reports of the opposite type\nconst newReport = $('JS - Parse AI Normalization').first().json;\nconst oppositeType = newReport.reportType === 'lost' ? 'found' : 'lost';\n\nconst rows = ($input.first().json.values) || [];\n\nconst candidates = rows.map(row => ({\n  requestId: row[0] || '',\n  reportType: (row[3] || '').toLowerCase(),\n  category: row[4] || '',\n  color: row[5] || '',\n  brand: row[6] || '',\n  location: row[7] || '',\n  dateLostFound: row[8] || '',\n  description: row[9] || '',\n  contactName: row[10] || '',\n  contactEmail: row[11] || '',\n  status: (row[14] || '').toLowerCase()\n})).filter(c => c.reportType === oppositeType && c.status === 'pending' && c.contactEmail);\n\nif (candidates.length === 0) {\n  return [{ json: { ...newReport, noCandidates: true } }];\n}\n\nreturn candidates.map(c => ({ json: { ...newReport, candidate: c } }));"
      },
      "typeVersion": 2
    },
    {
      "id": "af902914-049f-4a12-a9bd-1a5d0dbbf7c9",
      "name": "Python - Score Candidate Matches",
      "type": "n8n-nodes-base.code",
      "position": [
        5776,
        -464
      ],
      "parameters": {
        "mode": "runOnceForEachItem",
        "language": "python",
        "pythonCode": "# Composite match scoring algorithm\n# Score = category(40) + color(20) + brand(20) + location(10) + date proximity(10)\nfrom datetime import datetime\n\nitem = _input.item['json']\n\nif item.get('noCandidates'):\n    return {**item, 'compositeScore': 0, 'candidate': None}\n\ncandidate = item.get('candidate', {})\n\ndef norm(s):\n    return (s or '').strip().lower()\n\nscore = 0.0\n\n# Category match (exact or substring)\nnewCat, candCat = norm(item.get('category')), norm(candidate.get('category'))\nif newCat and candCat and (newCat == candCat or newCat in candCat or candCat in newCat):\n    score += 40\n\n# Color match\nnewColor, candColor = norm(item.get('color')), norm(candidate.get('color'))\nif newColor and candColor and newColor == candColor:\n    score += 20\n\n# Brand match\nnewBrand, candBrand = norm(item.get('brand')), norm(candidate.get('brand'))\nif newBrand and candBrand and newBrand == candBrand:\n    score += 20\nelif not newBrand and not candBrand:\n    score += 5  # neither report specifies a brand; slight neutral credit\n\n# Location match (exact or substring)\nnewLoc, candLoc = norm(item.get('locationNormalized')), norm(candidate.get('location'))\nif newLoc and candLoc and (newLoc == candLoc or newLoc in candLoc or candLoc in newLoc):\n    score += 10\n\n# Date proximity (within 3 days = full credit, within 7 = half credit)\ndateScore = 0\ntry:\n    d1 = datetime.fromisoformat(str(item.get('dateNormalized'))[:10])\n    d2 = datetime.fromisoformat(str(candidate.get('dateLostFound'))[:10])\n    daysApart = abs((d1 - d2).days)\n    if daysApart <= 3:\n        dateScore = 10\n    elif daysApart <= 7:\n        dateScore = 5\nexcept Exception:\n    dateScore = 0\nscore += dateScore\n\nreturn {\n    **item,\n    'compositeScore': round(score, 2)\n}"
      },
      "typeVersion": 2
    },
    {
      "id": "539a9e17-11fe-47a3-842e-0f4ad0731097",
      "name": "JS - Select Best Match",
      "type": "n8n-nodes-base.code",
      "position": [
        6000,
        -464
      ],
      "parameters": {
        "jsCode": "// Pick the highest-scoring candidate and decide if it's confident enough\nconst MATCH_THRESHOLD = 75;\nconst items = $input.all().map(i => i.json);\n\nitems.sort((a, b) => (b.compositeScore || 0) - (a.compositeScore || 0));\n\nconst top = items[0] || {};\nconst bestMatch = top.candidate || null;\nconst bestScore = top.compositeScore || 0;\n\nreturn [{\n  json: {\n    ...top,\n    bestMatch,\n    bestScore,\n    isConfidentMatch: !!bestMatch && bestScore >= MATCH_THRESHOLD\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "0e09dc33-caf9-4156-b0e6-295be1c69d46",
      "name": "IF - Confident Match Found",
      "type": "n8n-nodes-base.if",
      "position": [
        6224,
        -464
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "operator": {
                "type": "boolean",
                "operation": "true"
              },
              "leftValue": "={{ $json.isConfidentMatch }}"
            }
          ]
        }
      },
      "typeVersion": 2.2
    },
    {
      "id": "1fc4319c-924e-40fd-9f42-b0fddf52f84b",
      "name": "AI - Generate Match Notification Message",
      "type": "@n8n/n8n-nodes-langchain.agent",
      "position": [
        6368,
        -560
      ],
      "parameters": {
        "text": "=You are a lost & found matching assistant. A confident match has been found between two reports.\n\nClaim Reference: {{ $json.requestId }}\nMatch Score: {{ $json.bestScore }}/100\n\nNew Report ({{ $json.reportType }}):\n- Item: {{ $json.cleanDescription }}\n- Category: {{ $json.category }}, Color: {{ $json.color }}, Brand: {{ $json.brand }}\n- Location: {{ $json.locationNormalized }}\n- Date: {{ $json.dateNormalized }}\n\nMatched Report ({{ $json.bestMatch.reportType }}):\n- Item: {{ $json.bestMatch.description }}\n- Category: {{ $json.bestMatch.category }}, Color: {{ $json.bestMatch.color }}, Brand: {{ $json.bestMatch.brand }}\n- Location: {{ $json.bestMatch.location }}\n- Date: {{ $json.bestMatch.dateLostFound }}\n\nWrite a short, warm, reassuring email body (under 150 words) telling the recipient a likely match has been found for their item. Include the claim reference number, a brief description of the matched item, and a next step to verify ownership at the desk before pickup or return. Do NOT include any disclaimers about being an AI, and do NOT invent contact details.",
        "options": {},
        "promptType": "define"
      },
      "typeVersion": 1.6
    },
    {
      "id": "79b39ef8-aba3-42fc-bae0-2fa178aaa80d",
      "name": "JS - Format Match Emails",
      "type": "n8n-nodes-base.code",
      "position": [
        6992,
        -576
      ],
      "parameters": {
        "jsCode": "// Build two SendGrid email payloads: one for each matched party\nconst item = $input.first().json;\nconst messageBody = item.output || item.response || item.text ||\n  `Good news \u2014 we believe we found a match for your item (Claim Reference: ${item.requestId}). Please visit the desk to verify and complete the handoff.`;\n\nconst subject = `Lost & Found Match Found \u2014 Claim #${item.requestId}`;\n\nreturn [{\n  json: {\n    ...item,\n    emailSubject: subject,\n    emailBody: messageBody,\n    toOriginalReporter: item.contactEmail,\n    toMatchedReporter: item.bestMatch ? item.bestMatch.contactEmail : '',\n    logDate: new Date().toISOString().split('T')[0],\n    logTimestamp: new Date().toISOString()\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "7dd1b3be-6e7c-4386-9027-0d8eb4baaa4f",
      "name": "Send Email to Original Reporter",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        7344,
        -656
      ],
      "parameters": {
        "url": "https://api.sendgrid.com/v3/mail/send",
        "method": "POST",
        "options": {},
        "sendBody": true,
        "authentication": "predefinedCredentialType",
        "bodyParameters": {
          "parameters": [
            {}
          ]
        },
        "nodeCredentialType": "httpHeaderAuth"
      },
      "credentials": {
        "httpHeaderAuth": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.2,
      "continueOnFail": true
    },
    {
      "id": "d316fde9-ab71-45ca-9e9e-1f24d520d97b",
      "name": "Send Email to Matched Reporter",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        7344,
        -448
      ],
      "parameters": {
        "url": "https://api.sendgrid.com/v3/mail/send",
        "method": "POST",
        "options": {},
        "sendBody": true,
        "authentication": "predefinedCredentialType",
        "bodyParameters": {
          "parameters": [
            {}
          ]
        },
        "nodeCredentialType": "httpHeaderAuth"
      },
      "credentials": {
        "httpHeaderAuth": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.2,
      "continueOnFail": true
    },
    {
      "id": "e3ab35aa-d340-4c4a-b6dd-426d4b86a16b",
      "name": "Log Matched Report",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        7360,
        -256
      ],
      "parameters": {
        "url": "https://sheets.googleapis.com/v4/spreadsheets/YOUR_SHEET_ID/values/ReportsLog!A1:append?valueInputOption=USER_ENTERED",
        "method": "POST",
        "options": {},
        "jsonBody": "={\n  \"values\": [[\n    \"{{ $json.requestId }}\",\n    \"{{ $json.logDate }}\",\n    \"{{ $json.logTimestamp }}\",\n    \"{{ $json.reportType }}\",\n    \"{{ $json.category }}\",\n    \"{{ $json.color }}\",\n    \"{{ $json.brand }}\",\n    \"{{ $json.locationNormalized }}\",\n    \"{{ $json.dateNormalized }}\",\n    \"{{ $json.cleanDescription }}\",\n    \"{{ $json.contactName }}\",\n    \"{{ $json.contactEmail }}\",\n    \"{{ $json.bestMatch ? $json.bestMatch.requestId : '' }}\",\n    \"{{ $json.bestScore }}\",\n    \"Matched\"\n  ]]\n}",
        "sendBody": true,
        "specifyBody": "json",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "googleSheetsOAuth2Api"
      },
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.2,
      "continueOnFail": true
    },
    {
      "id": "41687905-525f-4e3a-9e3d-bd501699bd57",
      "name": "Webhook Response - Match Found",
      "type": "n8n-nodes-base.respondToWebhook",
      "position": [
        7568,
        -496
      ],
      "parameters": {
        "options": {},
        "respondWith": "json",
        "responseBody": "={{ JSON.stringify({ success: true, status: 'matched', requestId: $json.requestId, matchScore: $json.bestScore, matchedReportId: $json.bestMatch ? $json.bestMatch.requestId : null, notificationMessage: $json.emailBody }) }}"
      },
      "typeVersion": 1.1
    },
    {
      "id": "57e950d2-32c6-43f9-a790-22019e6b2595",
      "name": "JS - Format Pending Record",
      "type": "n8n-nodes-base.code",
      "position": [
        6432,
        -320
      ],
      "parameters": {
        "jsCode": "// No confident match yet \u2014 prepare this report to be logged as pending\nconst item = $input.first().json;\n\nreturn [{\n  json: {\n    ...item,\n    logDate: new Date().toISOString().split('T')[0],\n    logTimestamp: new Date().toISOString()\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "e63cad78-42c2-41d7-b5c1-01466a3e6b81",
      "name": "Log Pending Report",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        6672,
        -304
      ],
      "parameters": {
        "url": "https://sheets.googleapis.com/v4/spreadsheets/YOUR_SHEET_ID/values/ReportsLog!A1:append?valueInputOption=USER_ENTERED",
        "method": "POST",
        "options": {},
        "jsonBody": "={\n  \"values\": [[\n    \"{{ $json.requestId }}\",\n    \"{{ $json.logDate }}\",\n    \"{{ $json.logTimestamp }}\",\n    \"{{ $json.reportType }}\",\n    \"{{ $json.category }}\",\n    \"{{ $json.color }}\",\n    \"{{ $json.brand }}\",\n    \"{{ $json.locationNormalized }}\",\n    \"{{ $json.dateNormalized }}\",\n    \"{{ $json.cleanDescription }}\",\n    \"{{ $json.contactName }}\",\n    \"{{ $json.contactEmail }}\",\n    \"\",\n    \"{{ $json.bestScore || 0 }}\",\n    \"Pending\"\n  ]]\n}",
        "sendBody": true,
        "specifyBody": "json",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "googleSheetsOAuth2Api"
      },
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.2,
      "continueOnFail": true
    },
    {
      "id": "80e36106-9ddd-4504-b2a3-1ac68be0fab6",
      "name": "Webhook Response - No Match Yet",
      "type": "n8n-nodes-base.respondToWebhook",
      "position": [
        6864,
        -304
      ],
      "parameters": {
        "options": {},
        "respondWith": "json",
        "responseBody": "={{ JSON.stringify({ success: true, status: 'pending', requestId: $json.requestId, bestScoreSoFar: $json.bestScore || 0, message: 'No confident match yet. Report has been logged and will be checked against future submissions.' }) }}"
      },
      "typeVersion": 1.1
    },
    {
      "id": "8c43f401-9376-406a-8c88-d6901ce55459",
      "name": "Wait For Result",
      "type": "n8n-nodes-base.wait",
      "position": [
        6720,
        -560
      ],
      "parameters": {},
      "typeVersion": 1.1
    }
  ],
  "active": false,
  "settings": {
    "executionOrder": "v1"
  },
  "versionId": "11d6fade-d930-41de-a118-7ff4861cc658",
  "connections": {
    "Wait For Result": {
      "main": [
        [
          {
            "node": "JS - Format Match Emails",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "OpenAI Chat Model": {
      "ai_languageModel": [
        [
          {
            "node": "AI - Normalize & Validate Report",
            "type": "ai_languageModel",
            "index": 0
          },
          {
            "node": "AI - Generate Match Notification Message",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    },
    "Log Matched Report": {
      "main": [
        [
          {
            "node": "Webhook Response - Match Found",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Log Pending Report": {
      "main": [
        [
          {
            "node": "Webhook Response - No Match Yet",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Filter - Valid Report": {
      "main": [
        [
          {
            "node": "Fetch Existing Opposite Reports",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "JS - Select Best Match": {
      "main": [
        [
          {
            "node": "IF - Confident Match Found",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare Report Context": {
      "main": [
        [
          {
            "node": "AI - Normalize & Validate Report",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "JS - Format Match Emails": {
      "main": [
        [
          {
            "node": "Send Email to Original Reporter",
            "type": "main",
            "index": 0
          },
          {
            "node": "Send Email to Matched Reporter",
            "type": "main",
            "index": 0
          },
          {
            "node": "Log Matched Report",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Webhook - New Item Report": {
      "main": [
        [
          {
            "node": "Prepare Report Context",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "IF - Confident Match Found": {
      "main": [
        [
          {
            "node": "AI - Generate Match Notification Message",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "JS - Format Pending Record",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "JS - Format Pending Record": {
      "main": [
        [
          {
            "node": "Log Pending Report",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "JS - Parse AI Normalization": {
      "main": [
        [
          {
            "node": "Filter - Valid Report",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "JS - Parse Existing Reports": {
      "main": [
        [
          {
            "node": "Python - Score Candidate Matches",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Poll - Periodic Rematch Sweep": {
      "main": [
        [
          {
            "node": "Prepare Report Context",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Send Email to Matched Reporter": {
      "main": [
        [
          {
            "node": "Webhook Response - Match Found",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch Existing Opposite Reports": {
      "main": [
        [
          {
            "node": "JS - Parse Existing Reports",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Send Email to Original Reporter": {
      "main": [
        [
          {
            "node": "Webhook Response - Match Found",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "AI - Normalize & Validate Report": {
      "main": [
        [
          {
            "node": "JS - Parse AI Normalization",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Python - Score Candidate Matches": {
      "main": [
        [
          {
            "node": "JS - Select Best Match",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "AI - Generate Match Notification Message": {
      "main": [
        [
          {
            "node": "Wait For Result",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}