AutomationFlowsData & Sheets › Lcbp3 Migration Workflow V4.0.0

Lcbp3 Migration Workflow V4.0.0

LCBP3 Migration Workflow v4.0.0. Uses formTrigger, httpRequest, readWriteFile, spreadsheetFile. Event-driven trigger; 38 nodes.

Event trigger★★★★★ complexity38 nodesForm TriggerHTTP RequestRead Write FileSpreadsheet File
Data & Sheets Trigger: Event Nodes: 38 Complexity: ★★★★★ Added:
Lcbp3 Migration Workflow V4.0.0 — n8n workflow card showing Form Trigger, HTTP Request, Read Write File integration

This workflow follows the Form Trigger → HTTP Request recipe pattern — see all workflows that pair these two integrations.

The workflow JSON

Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →

Download .json
{
  "name": "LCBP3 Migration Workflow v4.0.0",
  "nodes": [
    {
      "parameters": {
        "formTitle": "LCBP3 Migration v4 - Safe Sequential Migration",
        "formDescription": "\u0e1b\u0e23\u0e30\u0e21\u0e27\u0e25\u0e1c\u0e25\u0e17\u0e35\u0e25\u0e30\u0e23\u0e32\u0e22\u0e01\u0e32\u0e23 \u0e1e\u0e23\u0e49\u0e2d\u0e21 resumable checkpoint \u2014 \u0e01\u0e23\u0e2d\u0e01 Migration Token \u0e17\u0e35\u0e48\u0e44\u0e14\u0e49\u0e08\u0e32\u0e01 Admin",
        "formFields": {
          "values": [
            {
              "fieldLabel": "Migration Run ID",
              "placeholder": "\u0e40\u0e0a\u0e48\u0e19 C22024-RUN-001",
              "requiredField": true
            },
            {
              "fieldLabel": "Excel File Path",
              "placeholder": "/home/node/.n8n-files/staging_ai/C22024.xlsx",
              "defaultValue": "/home/node/.n8n-files/staging_ai/C22024.xlsx",
              "requiredField": true
            },
            {
              "fieldLabel": "Project Code",
              "placeholder": "\u0e40\u0e0a\u0e48\u0e19 LCBP3 (\u0e23\u0e2b\u0e31\u0e2a\u0e42\u0e04\u0e23\u0e07\u0e01\u0e32\u0e23 \u2014 \u0e23\u0e30\u0e1a\u0e1a\u0e08\u0e30 resolve \u0e40\u0e1b\u0e47\u0e19 UUID \u0e2d\u0e31\u0e15\u0e42\u0e19\u0e21\u0e31\u0e15\u0e34)",
              "requiredField": true
            },
            {
              "fieldLabel": "Contract Code",
              "placeholder": "\u0e40\u0e0a\u0e48\u0e19 C22024 (\u0e40\u0e27\u0e49\u0e19\u0e27\u0e48\u0e32\u0e07\u0e44\u0e14\u0e49\u0e40\u0e21\u0e37\u0e48\u0e2d\u0e44\u0e21\u0e48\u0e21\u0e35 contract scope)"
            },
            {
              "fieldLabel": "Migration Token",
              "placeholder": "Bearer eyJhbGciOiJIUzI1NiIs...",
              "requiredField": true
            },
            {
              "fieldLabel": "Confidence Threshold High (optional)",
              "placeholder": "0.85 (default \u0e15\u0e32\u0e21 ADR-023A)",
              "requiredField": false
            },
            {
              "fieldLabel": "Confidence Threshold Mid (optional)",
              "placeholder": "0.60 (default \u0e15\u0e32\u0e21 ADR-023A)",
              "requiredField": false
            }
          ]
        },
        "options": {}
      },
      "id": "8cf0c7a9-7166-463f-8b8b-b622bf46c605",
      "name": "Form Trigger",
      "type": "n8n-nodes-base.formTrigger",
      "typeVersion": 2.2,
      "position": [
        -11376,
        6272
      ],
      "notes": "v4: Migration Token \u0e43\u0e2a\u0e48\u0e43\u0e19 form (\u0e44\u0e21\u0e48\u0e43\u0e0a\u0e49 env var \u0e2b\u0e23\u0e37\u0e2d credential) \u2014 token \u0e44\u0e21\u0e48\u0e16\u0e39\u0e01 persist \u0e43\u0e19 workflow definition"
    },
    {
      "parameters": {
        "jsCode": "// v4: configuration + validation \u2014 \u0e44\u0e21\u0e48\u0e43\u0e0a\u0e49 env vars (\u0e16\u0e39\u0e01\u0e1a\u0e25\u0e47\u0e2d\u0e01\u0e43\u0e19 n8n 2.21.7+)\n// MIGRATION_TOKEN \u0e43\u0e0a\u0e49\u0e1c\u0e48\u0e32\u0e19 HTTP Header Auth credential \u0e41\u0e17\u0e19\n// AI_THRESHOLD_HIGH/MID \u0e2d\u0e48\u0e32\u0e19\u0e08\u0e32\u0e01 form fields (optional, default \u0e15\u0e32\u0e21 ADR-023A)\nconst formData = $('Form Trigger').first()?.json || {};\n\nconst runId = String(formData['Migration Run ID'] || '').trim();\nconst excelFile = String(formData['Excel File Path'] || '').trim();\nconst projectCode = String(formData['Project Code'] || '').trim();\nconst contractCode = String(formData['Contract Code'] || '').trim();\n\n// \u2500\u2500 Validation \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nif (!/^[A-Za-z0-9][A-Za-z0-9._-]{2,79}$/.test(runId)) {\n  throw new Error('Migration Run ID must be 3-80 characters: letters, numbers, dot, underscore, hyphen');\n}\nif (!excelFile || excelFile.includes('..') || excelFile.includes('~')) {\n  throw new Error('Invalid Excel file path');\n}\nif (!projectCode) throw new Error('Project Code is required');\nif (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,49}$/.test(projectCode)) {\n  throw new Error('Project Code must be 1-50 characters: letters, numbers, dot, underscore, hyphen');\n}\nif (contractCode && !/^[A-Za-z0-9][A-Za-z0-9._-]{0,49}$/.test(contractCode)) {\n  throw new Error('Contract Code must be 1-50 characters or empty');\n}\n\n// \u2500\u2500 Token validation \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst migrationToken = String(formData['Migration Token'] || '').trim();\nif (!migrationToken) throw new Error('Migration Token is required');\n// \u0e16\u0e49\u0e32\u0e1c\u0e39\u0e49\u0e43\u0e0a\u0e49\u0e43\u0e2a\u0e48\u0e41\u0e04\u0e48 token (\u0e44\u0e21\u0e48\u0e21\u0e35 Bearer prefix) \u2192 \u0e40\u0e15\u0e34\u0e21\u0e43\u0e2b\u0e49\u0e2d\u0e31\u0e15\u0e42\u0e19\u0e21\u0e31\u0e15\u0e34\nconst authToken = migrationToken.startsWith('Bearer ') ? migrationToken : 'Bearer ' + migrationToken;\n\n// \u2500\u2500 Thresholds \u0e08\u0e32\u0e01 form (optional) \u0e2b\u0e23\u0e37\u0e2d default \u0e15\u0e32\u0e21 ADR-023A \u2500\u2500\u2500\u2500\nconst confidenceHigh = Number(formData['Confidence Threshold High (optional)'] || 0.85);\nconst confidenceMid = Number(formData['Confidence Threshold Mid (optional)'] || 0.60);\n\nif (isNaN(confidenceHigh) || confidenceHigh < 0 || confidenceHigh > 1) {\n  throw new Error('Confidence Threshold High must be a number between 0 and 1');\n}\nif (isNaN(confidenceMid) || confidenceMid < 0 || confidenceMid > 1) {\n  throw new Error('Confidence Threshold Mid must be a number between 0 and 1');\n}\nif (confidenceMid > confidenceHigh) {\n  throw new Error('Confidence Threshold Mid must not be greater than High');\n}\n\n// \u2500\u2500 Config output \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nreturn [{ json: { config: {\n  BACKEND_URL: 'http://backend:3000',\n  AUTH_TOKEN: authToken,\n  BATCH_ID: runId,\n  DELAY_MS: 2000,\n  AI_JOB_POLL_INTERVAL_MS: 5000,\n  AI_JOB_TIMEOUT_MS: 50000,\n  EXCEL_FILE: excelFile,\n  SOURCE_PDF_DIR: '/home/node/.n8n-files/staging_ai/pdfs',\n  LOG_PATH: '/home/node/.n8n-files/staging_ai/logs',\n  PROJECT_CODE: projectCode,\n  CONTRACT_CODE: contractCode || null,\n  CONFIDENCE_THRESHOLD_HIGH: confidenceHigh,\n  CONFIDENCE_THRESHOLD_MID: confidenceMid,\n  WORKFLOW_VERSION: '4.0.0',\n} } }];"
      },
      "id": "c785a235-4992-4396-884d-4c2137fb2304",
      "name": "Set Configuration",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -11184,
        6272
      ],
      "notes": "v4: Code node \u2014 validation + config build \u0e23\u0e27\u0e21\u0e43\u0e19 node \u0e40\u0e14\u0e35\u0e22\u0e27, \u0e44\u0e21\u0e48\u0e43\u0e0a\u0e49 $env (token \u0e43\u0e0a\u0e49 credential, thresholds \u0e08\u0e32\u0e01 form)"
    },
    {
      "parameters": {
        "url": "={{ $('Set Configuration').first().json.config.BACKEND_URL}}/health",
        "options": {
          "timeout": 5000
        }
      },
      "id": "0c23585c-33e3-4eaa-bc54-3cff5079745f",
      "name": "Check Backend Health",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.1,
      "position": [
        -11008,
        6272
      ],
      "notes": "\u0e15\u0e23\u0e27\u0e08\u0e2a\u0e2d\u0e1a Backend \u0e1e\u0e23\u0e49\u0e2d\u0e21\u0e43\u0e0a\u0e49\u0e07\u0e32\u0e19"
    },
    {
      "parameters": {
        "url": "={{ $('Set Configuration').first().json.config.BACKEND_URL}}/api/auth/profile",
        "options": {
          "timeout": 5000
        },
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "={{ 'Bearer ' + ($('Set Configuration').first().json.config.AUTH_TOKEN || '').replace(/^Bearer\\s+/i, '') }}"
            }
          ]
        }
      },
      "id": "69db8e96-2203-47fc-b8a8-c6573c53178e",
      "name": "Validate Token",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.1,
      "position": [
        -10832,
        6272
      ],
      "notes": "FR-010a: \u0e15\u0e23\u0e27\u0e08\u0e2a\u0e2d\u0e1a MIGRATION_TOKEN \u0e01\u0e48\u0e2d\u0e19\u0e40\u0e23\u0e34\u0e48\u0e21 Batch \u2014 M4: scope check \u0e17\u0e33\u0e17\u0e35\u0e48 node \u0e16\u0e31\u0e14\u0e44\u0e1b (Verify Migration Scope) \u0e15\u0e32\u0e21 ADR-016"
    },
    {
      "parameters": {
        "url": "={{ $('Set Configuration').first().json.config.BACKEND_URL}}/api/master/correspondence-types",
        "options": {
          "timeout": 10000
        },
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "={{ 'Bearer ' + ($('Set Configuration').first().json.config.AUTH_TOKEN || '').replace(/^Bearer\\s+/i, '') }}"
            }
          ]
        }
      },
      "id": "c9e3959b-4019-4ec6-b254-4a89156ca0d0",
      "name": "Fetch Categories",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.1,
      "position": [
        -11184,
        6464
      ],
      "notes": "\u0e14\u0e36\u0e07 Categories \u0e08\u0e32\u0e01 Backend"
    },
    {
      "parameters": {
        "url": "={{ $('Set Configuration').first().json.config.BACKEND_URL}}/api/master/tags",
        "options": {
          "timeout": 10000
        },
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "={{ 'Bearer ' + ($('Set Configuration').first().json.config.AUTH_TOKEN || '').replace(/^Bearer\\s+/i, '') }}"
            }
          ]
        }
      },
      "id": "e3ed851e-1ef2-42d4-84bd-df29cfe6e13e",
      "name": "Fetch Tags",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.1,
      "position": [
        -11008,
        6464
      ],
      "notes": "\u0e14\u0e36\u0e07 Tags \u0e17\u0e35\u0e48\u0e21\u0e35\u0e2d\u0e22\u0e39\u0e48\u0e41\u0e25\u0e49\u0e27\u0e08\u0e32\u0e01 Backend"
    },
    {
      "parameters": {
        "jsCode": "const fs = require('fs');\nconst config = $('Set Configuration').first().json.config;\n\ntry {\n  if (!fs.existsSync(config.EXCEL_FILE)) {\n    throw new Error(`Excel file not found at: ${config.EXCEL_FILE}`);\n  }\n  if (!fs.existsSync(config.SOURCE_PDF_DIR)) {\n    throw new Error(`PDF Source directory not found at: ${config.SOURCE_PDF_DIR}`);\n  }\n  \n  const files = fs.readdirSync(config.SOURCE_PDF_DIR).filter(f => f.toLowerCase().endsWith('.pdf'));\n  \n  // L4: \u0e15\u0e23\u0e27\u0e08 write permission \u0e42\u0e14\u0e22\u0e44\u0e21\u0e48\u0e17\u0e34\u0e49\u0e07 sentinel file\n  if (!fs.existsSync(config.LOG_PATH)) fs.mkdirSync(config.LOG_PATH, { recursive: true });\n  fs.accessSync(config.LOG_PATH, fs.constants.W_OK);\n  \n  // Grab categories\n  let categories = ['Correspondence','RFA','Drawing','Transmittal','Report','Other'];\n  try {\n    const upstreamData = $('Fetch Categories').first()?.json?.data;\n    if (upstreamData && Array.isArray(upstreamData)) {\n      categories = upstreamData.map(c => c.name || c.type || c);\n    }\n  } catch(e) {}\n  \n  // Grab existing tags\n  let existingTags = [];\n  try {\n    const tagData = $('Fetch Tags').first()?.json?.data || [];\n    existingTags = Array.isArray(tagData)\n      ? tagData.map(t => ({ publicId: t.publicId, tagName: t.tag_name || t.name || '' })).filter(t => t.tagName && t.publicId)\n      : [];\n  } catch(e) {}\n    \n  return [{ json: { \n    preflight_ok: true, \n    pdf_count_in_source: files.length,\n    excel_target: config.EXCEL_FILE,\n    system_categories: categories,\n    existing_tags: existingTags,\n    timestamp: new Date().toISOString()\n  }}];\n} catch (err) {\n  throw new Error(`Pre-flight check failed: ${err.message}`);\n}"
      },
      "id": "6568ed41-da80-4f00-9378-905cc95d86f0",
      "name": "File Mount Check",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -10832,
        6464
      ],
      "notes": "\u0e15\u0e23\u0e27\u0e08\u0e2a\u0e2d\u0e1a File System + \u0e23\u0e27\u0e1a\u0e23\u0e27\u0e21 master data \u0e2a\u0e33\u0e2b\u0e23\u0e31\u0e1a AI payload"
    },
    {
      "parameters": {
        "fileSelector": "={{ $json.excel_target }}",
        "options": {}
      },
      "id": "1802f33d-7fc6-42cb-897b-e312dc7c9626",
      "name": "Read Excel Binary",
      "type": "n8n-nodes-base.readWriteFile",
      "typeVersion": 1,
      "position": [
        -11184,
        6656
      ],
      "notes": "\u0e14\u0e36\u0e07\u0e44\u0e1f\u0e25\u0e4c Excel \u0e02\u0e36\u0e49\u0e19\u0e21\u0e32\u0e44\u0e27\u0e49\u0e43\u0e19\u0e2b\u0e19\u0e48\u0e27\u0e22\u0e04\u0e27\u0e32\u0e21\u0e08\u0e33"
    },
    {
      "parameters": {
        "options": {}
      },
      "id": "af6a69f5-7f04-40f6-9608-1cfe231ee85f",
      "name": "Read Excel",
      "type": "n8n-nodes-base.spreadsheetFile",
      "typeVersion": 2,
      "position": [
        -11008,
        6656
      ],
      "notes": "\u0e41\u0e1b\u0e25\u0e07\u0e02\u0e49\u0e2d\u0e21\u0e39\u0e25 Excel \u0e40\u0e1b\u0e47\u0e19 JSON Data"
    },
    {
      "parameters": {
        "url": "={{ $('Set Configuration').first().json.config.BACKEND_URL + '/api/ai/migration/checkpoint/' + $('Set Configuration').first().json.config.BATCH_ID }}",
        "options": {
          "timeout": 10000
        },
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "={{ 'Bearer ' + ($('Set Configuration').first().json.config.AUTH_TOKEN || '').replace(/^Bearer\\s+/i, '') }}"
            }
          ]
        }
      },
      "id": "ebddd4ee-0f4e-471b-9f01-179dbcb7f0ba",
      "name": "Read Checkpoint",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.1,
      "position": [
        -10832,
        6672
      ],
      "alwaysOutputData": true,
      "notes": "GET /api/ai/migration/checkpoint/:batchId \u2014 ADR-023A (\u0e44\u0e21\u0e48\u0e43\u0e0a\u0e49 MySQL \u0e42\u0e14\u0e22\u0e15\u0e23\u0e07) \u2014 H3: continueRegularOutput \u0e40\u0e1e\u0e37\u0e48\u0e2d\u0e43\u0e2b\u0e49 404 (batch \u0e43\u0e2b\u0e21\u0e48) \u0e44\u0e1b\u0e15\u0e48\u0e2d\u0e14\u0e49\u0e27\u0e22 startIndex=0",
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "jsCode": "const cpJson = $input.first()?.json || {};\nconst startIndex = cpJson.data?.lastProcessedIndex ?? cpJson.lastProcessedIndex ?? cpJson.data?.last_processed_index ?? cpJson.last_processed_index ?? 0;\nconst config = $('Set Configuration').first().json.config;\n\nconst allItems = $('Read Excel').all().map(i => i.json);\nconst remaining = allItems.slice(startIndex);\nconst currentBatch = remaining.slice(0, 1); // v4: exactly one row per loop\n\n// Exit condition: \u0e44\u0e21\u0e48\u0e21\u0e35 records \u0e40\u0e2b\u0e25\u0e37\u0e2d\nif (currentBatch.length === 0) {\n  return [{ json: { batch_complete: true, total_processed: startIndex } }];\n}\n\nconst normalize = (str) => {\n  if (!str) return '';\n  return String(str).normalize('NFC').trim();\n};\n\nreturn currentBatch.map((item, i) => {\n  const getVal = (possibleKeys) => {\n    const exactMatch = possibleKeys.find(k => item[k] !== undefined);\n    if (exactMatch) return item[exactMatch];\n    const lowerTrimmedKeys = Object.keys(item).map(k => ({ original: k, parsed: k.toLowerCase().trim() }));\n    for (const pk of possibleKeys) {\n      const found = lowerTrimmedKeys.find(k => k.parsed === pk.toLowerCase().trim());\n      if (found) return item[found.original];\n    }\n    return '';\n  };\n\n  const docNum = getVal(['document_number', 'correspondence_number', 'Document Number', 'Corr. No.']);\n  const excelFileName = getVal(['File name', 'file_name', 'File Name', 'filename']);\n  \n  if (!excelFileName) {\n    // Fix 1: \u0e44\u0e21\u0e48 throw \u2014 \u0e2a\u0e48\u0e07 error item \u0e44\u0e1b\u0e43\u0e19 flow \u0e1b\u0e01\u0e15\u0e34\u0e40\u0e1e\u0e37\u0e48\u0e2d\u0e43\u0e2b\u0e49 checkpoint advance\n    // \u0e16\u0e49\u0e32 throw \u0e08\u0e30\u0e17\u0e33\u0e43\u0e2b\u0e49 execution \u0e2b\u0e22\u0e38\u0e14\u0e41\u0e25\u0e30 checkpoint \u0e44\u0e21\u0e48 advance \u2192 infinite crash loop on resume\n    return {\n      json: {\n        batch_complete: false,\n        document_number: normalize(docNum),\n        title: normalize(getVal(['title', 'Title', 'Subject', 'subject'])),\n        legacy_number: normalize(getVal(['legacy_number', 'Legacy Number', 'Response Doc.'])),\n        excel_revision: revVal || 1,\n        revision_missing: !revVal,\n        original_index: startIndex + i,\n        batch_id: config.BATCH_ID,\n        file_name: '',\n        row_key: [config.BATCH_ID, startIndex + i, normalize(docNum), revVal || 1].join(':'),\n        file_valid: false,\n        error: `Missing 'File name' column for row ${startIndex + i + 1}, document: ${docNum}`,\n        error_type: 'MISSING_FILENAME',\n      }\n    };\n  }\n  \n  const revVal = getVal(['revision', 'Revision', 'rev']);\n  // L5: flag missing revision \u0e40\u0e1e\u0e37\u0e48\u0e2d route \u0e44\u0e1b Flagged (\u0e44\u0e21\u0e48 mask \u0e14\u0e49\u0e27\u0e22 default 1)\n  const revisionMissing = !revVal;\n  return {\n    json: {\n      batch_complete: false,\n      document_number: normalize(docNum),\n      title: normalize(getVal(['title', 'Title', 'Subject', 'subject'])),\n      legacy_number: normalize(getVal(['legacy_number', 'Legacy Number', 'Response Doc.'])),\n      excel_revision: revVal || 1, // \u0e43\u0e0a\u0e49 1 \u0e2a\u0e33\u0e2b\u0e23\u0e31\u0e1a row_key stability \u0e41\u0e15\u0e48 flag \u0e41\u0e22\u0e01\n      revision_missing: revisionMissing,\n      original_index: startIndex + i,\n      batch_id: config.BATCH_ID,\n      file_name: normalize(excelFileName),\n      row_key: [config.BATCH_ID, startIndex + i, normalize(docNum), revVal || 1].join(':'),\n    }\n  };\n});"
      },
      "id": "b488f4ac-4864-4a16-ac26-71bd850fa49b",
      "name": "Process Batch + Encoding",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -11184,
        6880
      ],
      "alwaysOutputData": true,
      "notes": "v4: \u0e1b\u0e23\u0e30\u0e21\u0e27\u0e25\u0e1c\u0e25\u0e17\u0e35\u0e25\u0e30\u0e41\u0e16\u0e27 (sequential) + Normalize UTF-8 + Exit condition \u0e40\u0e21\u0e37\u0e48\u0e2d records \u0e2b\u0e21\u0e14 \u2014 BATCH_SIZE \u0e16\u0e39\u0e01\u0e25\u0e1a\u0e2d\u0e2d\u0e01\u0e08\u0e32\u0e01 config (M2)"
    },
    {
      "parameters": {
        "jsCode": "const fs = require('fs');\nconst path = require('path');\nconst config = $('Set Configuration').first().json.config;\n\nconst items = $input.all();\nif (!items || items.length === 0) return [];\n\n// \u0e15\u0e23\u0e27\u0e08\u0e2a\u0e2d\u0e1a exit condition\nif (items[0]?.json?.batch_complete === true) {\n  return items;\n}\n\nconst validated = [];\nconst errors = [];\n\nfor (const item of items) {\n  // Fix 1: \u0e16\u0e49\u0e32 item \u0e21\u0e32\u0e08\u0e32\u0e01 Process Batch + Encoding \u0e17\u0e35\u0e48\u0e21\u0e35 file_valid: false \u0e2d\u0e22\u0e39\u0e48\u0e41\u0e25\u0e49\u0e27 \u2192 \u0e2a\u0e48\u0e07\u0e15\u0e23\u0e07\u0e44\u0e1b errors\n  if (item.json?.file_valid === false) {\n    errors.push(item);\n    continue;\n  }\n  const fileName = item.json?.file_name;\n  if (!fileName) {\n    errors.push({ ...item, json: { ...item.json, file_valid: false, error: 'file_name is missing', error_type: 'MISSING_FILENAME' } });\n    continue;\n  }\n  \n  let safeName = path.basename(String(fileName)).normalize('NFC');\n  if (!safeName.toLowerCase().endsWith('.pdf')) safeName += '.pdf';\n  const filePath = path.resolve(config.SOURCE_PDF_DIR, safeName);\n  \n  if (!filePath.startsWith(path.resolve(config.SOURCE_PDF_DIR))) {\n    errors.push({ ...item, json: { ...item.json, file_valid: false, error: 'Path traversal detected', error_type: 'SECURITY' } });\n    continue;\n  }\n  \n  try {\n    if (fs.existsSync(filePath)) {\n      const stats = fs.statSync(filePath);\n      validated.push({ ...item, json: { ...item.json, file_valid: true, file_path: filePath, file_size: stats.size } });\n    } else {\n      errors.push({ ...item, json: { ...item.json, file_valid: false, error: `File not found: ${safeName}`, error_type: 'FILE_NOT_FOUND' } });\n    }\n  } catch (err) {\n    errors.push({ ...item, json: { ...item.json, file_valid: false, error: err.message, error_type: 'FILE_ERROR' } });\n  }\n}\n\nreturn [...validated, ...errors];"
      },
      "id": "e831cd28-880c-4f93-99fc-901383d1cda3",
      "name": "File Validator",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -10640,
        6272
      ],
      "notes": "\u0e15\u0e23\u0e27\u0e08\u0e2a\u0e2d\u0e1a\u0e44\u0e1f\u0e25\u0e4c PDF \u0e43\u0e19 Directory"
    },
    {
      "parameters": {
        "fileSelector": "={{ $json.file_path }}",
        "options": {}
      },
      "id": "20625b33-17e8-4730-94f1-37c9b6c24df9",
      "name": "Read PDF File",
      "type": "n8n-nodes-base.readWriteFile",
      "typeVersion": 1,
      "position": [
        -10640,
        6464
      ],
      "onError": "continueErrorOutput",
      "notes": "\u0e2d\u0e48\u0e32\u0e19 PDF Binary \u0e40\u0e1e\u0e37\u0e48\u0e2d\u0e40\u0e15\u0e23\u0e35\u0e22\u0e21 Upload"
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $('Set Configuration').first().json.config.BACKEND_URL}}/api/files/upload",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "={{ 'Bearer ' + ($('Set Configuration').first().json.config.AUTH_TOKEN || '').replace(/^Bearer\\s+/i, '') }}"
            },
            {
              "name": "Idempotency-Key",
              "value": "={{ $json.row_key + ':upload' }}"
            }
          ]
        },
        "sendBody": true,
        "contentType": "multipart-form-data",
        "bodyParameters": {
          "parameters": [
            {
              "parameterType": "formBinaryData",
              "name": "file",
              "inputDataFieldName": "data"
            }
          ]
        },
        "options": {
          "timeout": 300000
        }
      },
      "id": "2677c8b3-223a-4952-b4e2-41b92bd7f059",
      "name": "Upload PDF to Backend",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        -10416,
        6272
      ],
      "onError": "continueErrorOutput",
      "notes": "ADR-023A: \u0e2d\u0e31\u0e1e\u0e42\u0e2b\u0e25\u0e14 PDF \u0e40\u0e02\u0e49\u0e32 Backend Temp Storage \u2192 \u0e23\u0e31\u0e1a temp_attachment_id \u2014 L8: Backend \u0e21\u0e35 FileCleanupService \u0e23\u0e31\u0e19 cron \u0e17\u0e38\u0e01\u0e40\u0e17\u0e35\u0e48\u0e22\u0e07\u0e04\u0e37\u0e19 \u0e25\u0e1a temp files \u0e17\u0e35\u0e48\u0e2b\u0e21\u0e14\u0e2d\u0e32\u0e22\u0e38 (TTL 24 \u0e0a\u0e21.) \u0e08\u0e36\u0e07\u0e44\u0e21\u0e48\u0e15\u0e49\u0e2d\u0e07 cleanup \u0e40\u0e2d\u0e07\u0e43\u0e19 workflow \u2014 \u0e14\u0e39 backend file-cleanup.service.ts"
    },
    {
      "parameters": {
        "jsCode": "// v3: Build AI Job Payload \u0e1e\u0e23\u0e49\u0e2d\u0e21 contextOverride \u0e2a\u0e33\u0e2b\u0e23\u0e31\u0e1a Master Data filter (ADR-030)\nconst config = $('Set Configuration').first().json.config;\nconst uploadResponse = $json || {};\nconst metaItem = $('File Validator').first()?.json || {};\nconst mountCheckData = $('File Mount Check').first()?.json || {};\n\n// Backend returns { data: { publicId, tempId, ... } } per ADR-019 (id is @Exclude'd)\nconst tempAttachmentPublicId = uploadResponse?.data?.publicId || uploadResponse?.publicId;\nif (!tempAttachmentPublicId) {\n  throw new Error(`Upload failed \u2014 no temp_attachment_public_id returned. Upload response: ${JSON.stringify(uploadResponse)}`);\n}\n\n// Validate required fields per DTO\nconst docNumber = String(metaItem.document_number || '').trim();\nconst docTitle = String(metaItem.title || '').trim();\nif (!docNumber) {\n  throw new Error(`document_number is empty for item: ${JSON.stringify(metaItem)}`);\n}\nif (!docTitle) {\n  throw new Error(`title is empty for document: ${docNumber}`);\n}\n\n// Normalize existingTags to match TagOptionDto (tagName is required)\n// L2: \u0e43\u0e0a\u0e49 null \u0e41\u0e17\u0e19 undefined \u0e40\u0e1e\u0e37\u0e48\u0e2d\u0e1b\u0e49\u0e2d\u0e07\u0e01\u0e31\u0e19 JSON.stringify \u0e15\u0e31\u0e14\u0e1f\u0e34\u0e25\u0e14\u0e4c\u0e17\u0e34\u0e49\u0e07\nconst existingTags = (mountCheckData.existing_tags || [])\n  .filter(t => t.tagName && t.tagName.trim())\n  .map(t => ({\n    publicId: t.publicId || null,\n    tagName: String(t.tagName).trim(),\n    colorCode: t.colorCode || null,\n  }));\n\n// v3: contextOverride \u2014 \u0e2a\u0e48\u0e07 public UUID \u0e43\u0e2b\u0e49 backend resolve master data context\n// \u0e15\u0e32\u0e21 ADR-030: filter master data \u0e15\u0e32\u0e21 project/contract scope \u0e40\u0e1e\u0e37\u0e48\u0e2d\u0e25\u0e14 context size\n// null = \u0e44\u0e21\u0e48 filter (\u0e43\u0e0a\u0e49\u0e04\u0e48\u0e32\u0e08\u0e32\u0e01 context_config \u0e02\u0e2d\u0e07 active prompt template)\n// \u0e2d\u0e48\u0e32\u0e19 UUID \u0e08\u0e32\u0e01 Set Resolved UUIDs (resolve \u0e08\u0e32\u0e01 Code \u0e1c\u0e48\u0e32\u0e19 backend API)\nconst resolved = $('Set Resolved UUIDs').first()?.json || {};\nconst contextOverride = {\n  projectPublicId: resolved.projectPublicId || null,\n  contractPublicId: resolved.contractPublicId || null,\n};\n\n// \u0e2a\u0e23\u0e49\u0e32\u0e07 SubmitAiJobDto \u0e15\u0e32\u0e21 Backend DTO (v3)\nconst submitPayload = {\n  type: 'migrate-document',\n  payload: {\n    tempAttachmentId: String(tempAttachmentPublicId),\n    documentNumber: docNumber,\n    title: docTitle,\n    batchId: String(config.BATCH_ID),\n    existingTags: existingTags,\n    systemCategories: mountCheckData.system_categories || [],\n    // v3 NEW: contextOverride \u0e2a\u0e33\u0e2b\u0e23\u0e31\u0e1a ADR-030 Master Data Context Filter\n    contextOverride: contextOverride,\n  }\n};\n\n// Idempotency-Key: deterministic format \u0e15\u0e32\u0e21 FR-001a\nconst idempotencyKey = String(metaItem.row_key || [config.BATCH_ID, metaItem.original_index, docNumber, metaItem.excel_revision || 1].join(':'));\n\nreturn [{\n  json: {\n    ...metaItem,\n    temp_attachment_public_id: tempAttachmentPublicId,\n    submit_payload: submitPayload,\n    idempotency_key: idempotencyKey,\n  }\n}];",
        "mode": "runOnceForEachItem"
      },
      "id": "fad3f363-a595-4a43-a48f-cefb258c2687",
      "name": "Build AI Job Payload",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -10400,
        6496
      ],
      "notes": "v4: \u0e40\u0e1e\u0e34\u0e48\u0e21 contextOverride (projectPublicId, contractPublicId) \u0e15\u0e32\u0e21 ADR-019/030 \u2014 Idempotency-Key \u0e15\u0e32\u0e21 FR-001a"
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $('Set Configuration').first().json.config.BACKEND_URL}}/api/ai/jobs",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "={{ 'Bearer ' + ($('Set Configuration').first().json.config.AUTH_TOKEN || '').replace(/^Bearer\\s+/i, '') }}"
            },
            {
              "name": "Idempotency-Key",
              "value": "={{ $json.idempotency_key }}"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify($json.submit_payload) }}",
        "options": {
          "timeout": 30000
        }
      },
      "id": "7fb24018-9739-4ae9-8650-d118d73e38d7",
      "name": "Submit AI Job",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.1,
      "position": [
        -10400,
        6672
      ],
      "onError": "continueErrorOutput",
      "notes": "ADR-023A: POST /api/ai/jobs \u2014 Backend \u0e08\u0e31\u0e14\u0e01\u0e32\u0e23 Ollama \u0e1c\u0e48\u0e32\u0e19 BullMQ"
    },
    {
      "parameters": {
        "jsCode": "// v3: Parse & Validate AI Response \u2014 \u0e23\u0e2d\u0e07\u0e23\u0e31\u0e1a 11-field schema (ADR-030)\n// recipients \u0e40\u0e1b\u0e47\u0e19 Array<{ organizationPublicId, recipientType }> \u0e41\u0e17\u0e19 parallel arrays\nconst config = $('Set Configuration').first().json.config;\nconst items = $input.all();\n\n// H1: UUIDv7 format validation (ADR-019) \u2014 format-invalid UUIDs treated as missing\nconst UUID_V7_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;\nconst isUuidV7 = (s) => typeof s === 'string' && s.trim().length > 0 && UUID_V7_RE.test(s.trim());\nconst results = [];\n\nfor (const item of items) {\n  const data = item.json;\n  \n  // \u0e16\u0e49\u0e32 poll \u0e2a\u0e48\u0e07 error \u0e01\u0e25\u0e31\u0e1a\u0e21\u0e32\u0e42\u0e14\u0e22\u0e15\u0e23\u0e07 (route_index = 3)\n  if (data.route_index === 3 || data.ai_status === 'failed') {\n    results.push({ json: { ...data, route_index: 3 } });\n    continue;\n  }\n  \n  const ai = data.ai_result || {};\n  \n  if (!ai || typeof ai !== 'object') {\n    results.push({ json: { ...data, parse_error: 'Empty or invalid ai_result', error_type: 'PARSE_ERROR', route_index: 3 } });\n    continue;\n  }\n  \n  // ================================================================\n  // v3: 11-field schema validation (ADR-030)\n  // ================================================================\n  \n  // 1. projectPublicId \u2014 UUID string \u0e2b\u0e23\u0e37\u0e2d null\n  const projectPublicId = isUuidV7(ai.projectPublicId) ? ai.projectPublicId.trim() : null;\n  \n  // 2. correspondenceTypeCode \u2014 \u0e23\u0e2b\u0e31\u0e2a\u0e1b\u0e23\u0e30\u0e40\u0e20\u0e17 \u0e40\u0e0a\u0e48\u0e19 RFA, LETTER, TRANSMITTAL\n  const correspondenceTypeCode = typeof ai.correspondenceTypeCode === 'string' && ai.correspondenceTypeCode.trim()\n    ? ai.correspondenceTypeCode.trim().toUpperCase()\n    : null;\n  \n  // 3. disciplineCode \u2014 \u0e23\u0e2b\u0e31\u0e2a\u0e2a\u0e32\u0e02\u0e32\u0e07\u0e32\u0e19 \u0e40\u0e0a\u0e48\u0e19 GEN, STR\n  const disciplineCode = typeof ai.disciplineCode === 'string' && ai.disciplineCode.trim()\n    ? ai.disciplineCode.trim().toUpperCase()\n    : null;\n  \n  // 4. originatorOrganizationPublicId \u2014 UUID string \u0e2b\u0e23\u0e37\u0e2d null\n  const originatorOrganizationPublicId = isUuidV7(ai.originatorOrganizationPublicId) ? ai.originatorOrganizationPublicId.trim() : null;\n  \n  // 5. recipients \u2014 Object Array \u0e15\u0e32\u0e21 spec ADR-030\n  // \u0e23\u0e39\u0e1b\u0e41\u0e1a\u0e1a: Array<{ organizationPublicId: string, recipientType: 'TO' | 'CC' }>\n  // \u0e2b\u0e49\u0e32\u0e21\u0e43\u0e0a\u0e49 parallel arrays (recipientOrganizationPublicIds + recipientTypes)\n  let recipients = [];\n  if (Array.isArray(ai.recipients)) {\n    recipients = ai.recipients\n      .filter(r => r && typeof r === 'object' && isUuidV7(r.organizationPublicId))\n      .map(r => ({\n        organizationPublicId: String(r.organizationPublicId).trim(),\n        recipientType: ['TO', 'CC'].includes(String(r.recipientType || '').trim().toUpperCase())\n          ? String(r.recipientType).trim().toUpperCase()\n          : 'TO',\n      }));\n  }\n  \n  // 6. subject \u2014 \u0e2b\u0e31\u0e27\u0e02\u0e49\u0e2d\u0e40\u0e2d\u0e01\u0e2a\u0e32\u0e23\n  const subject = typeof ai.subject === 'string' && ai.subject.trim()\n    ? ai.subject.trim()\n    : data.title || '';\n  \n  // 7. documentDate \u2014 YYYY-MM-DD\n  const documentDate = typeof ai.documentDate === 'string' && /^\\d{4}-\\d{2}-\\d{2}$/.test(ai.documentDate.trim())\n    ? ai.documentDate.trim()\n    : null;\n  \n  // 8. tags \u2014 string[]\n  const suggestedTags = Array.isArray(ai.tags)\n    ? ai.tags.map(t => {\n        if (typeof t === 'string') return { tagName: t.trim(), isNew: true, colorCode: 'default' };\n        if (typeof t === 'object' && t.tagName) return {\n          tagName: String(t.tagName || t.tag_name || '').trim(),\n          publicId: t.publicId || t.public_id || undefined,\n          isNew: Boolean(t.isNew ?? t.is_new ?? !t.publicId),\n          colorCode: t.colorCode || t.color_code || 'default',\n        };\n        return null;\n      }).filter(t => t && t.tagName)\n    : [];\n  \n  // 9. summary \u2014 \u0e20\u0e32\u0e29\u0e32\u0e44\u0e17\u0e22 4-5 \u0e1b\u0e23\u0e30\u0e42\u0e22\u0e04\n  const summary = typeof ai.summary === 'string' && ai.summary.trim() ? ai.summary.trim() : null;\n  \n  // 10. confidence \u2014 float 0-1\n  const confidence = typeof ai.confidence === 'number' ? Math.max(0, Math.min(1, ai.confidence)) : 0;\n  \n  // ================================================================\n  // Confidence-based routing (\u0e40\u0e2b\u0e21\u0e37\u0e2d\u0e19 v2)\n  // route_index: 0=Auto Ready, 1=Review Flagged, 2=Rejected, 3=Error Log\n  // L10: thresholds \u0e08\u0e32\u0e01 env (ADR-023A: AI_THRESHOLD_HIGH=0.85, AI_THRESHOLD_MID=0.60)\n  // ================================================================\n  const thresholdHigh = Number($('Set Configuration').first().json.config.CONFIDENCE_THRESHOLD_HIGH);\n  const thresholdMid = Number($('Set Configuration').first().json.config.CONFIDENCE_THRESHOLD_MID);\n  let route_index;\n  let review_reason = '';\n  let reject_reason = '';\n  \n  // \u0e15\u0e23\u0e27\u0e08\u0e2a\u0e2d\u0e1a UUID fields \u0e17\u0e35\u0e48\u0e08\u0e33\u0e40\u0e1b\u0e47\u0e19 \u2014 \u0e16\u0e49\u0e32\u0e44\u0e21\u0e48\u0e21\u0e35 \u2192 Flagged \u0e40\u0e1e\u0e37\u0e48\u0e2d human validation\n  const missingUuids = [];\n  if (!projectPublicId) missingUuids.push('projectPublicId');\n  if (!originatorOrganizationPublicId) missingUuids.push('originatorOrganizationPublicId');\n  if (recipients.length === 0) missingUuids.push('recipients');\n  // H1: \u0e16\u0e49\u0e32 AI \u0e04\u0e37\u0e19 UUID \u0e17\u0e35\u0e48\u0e23\u0e39\u0e1b\u0e41\u0e1a\u0e1a\u0e1c\u0e34\u0e14 \u2192 \u0e16\u0e37\u0e2d\u0e40\u0e2b\u0e21\u0e37\u0e2d\u0e19 missing (route \u0e44\u0e1b Flagged)\n  \n  // L5: missing revision \u2192 Flagged \u0e40\u0e1e\u0e37\u0e48\u0e2d human validation\n  const revisionMissing = Boolean(data.revision_missing);\n  \n  if (missingUuids.length > 0) {\n    route_index = 1;\n    review_reason = `UUID fields \u0e44\u0e21\u0e48\u0e04\u0e23\u0e1a: ${missingUuids.join(', ')} \u2014 \u0e15\u0e49\u0e2d\u0e07\u0e01\u0e32\u0e23 human validation (ADR-030)`;\n  } else if (revisionMissing) {\n    route_index = 1;\n    review_reason = `Excel revision \u0e02\u0e32\u0e14 \u2014 \u0e15\u0e49\u0e2d\u0e07\u0e01\u0e32\u0e23 human review (L5)`;\n  } else if (confidence >= thresholdHigh) {\n    route_index = 0; // Auto Ready\n  } else if (confidence >= thresholdMid) {\n    route_index = 1; // Review Flagged\n    review_reason = `confidence \u0e15\u0e48\u0e33 (${confidence.toFixed(2)}) \u2014 \u0e15\u0e49\u0e2d\u0e07\u0e01\u0e32\u0e23 human review`;\n  } else if (confidence > 0) {\n    route_index = 2; // Rejected\n    reject_reason = `confidence \u0e15\u0e48\u0e33\u0e40\u0e01\u0e34\u0e19 (${confidence.toFixed(2)}) \u2014 \u0e15\u0e48\u0e33\u0e01\u0e27\u0e48\u0e32 threshold ${thresholdMid}`;\n  } else {\n    route_index = 3; // Error\n  }\n  \n  const normalizedAi = {\n    // v3: 11-field schema (ADR-030)\n    projectPublicId,\n    correspondenceTypeCode,\n    disciplineCode,\n    originatorOrganizationPublicId,\n    recipients,\n    subject,\n    documentDate,\n    tags: suggestedTags,\n    summary,\n    confidence,\n  };\n  \n  results.push({\n    json: {\n      ...data,\n      ai_result: normalizedAi,\n      review_reason,\n      reject_reason,\n      route_index,\n    }\n  });\n}\n\nreturn results;"
      },
      "id": "419d8d1a-06c7-4cca-b1ed-53cf0a632c7e",
      "name": "Parse & Validate AI Response",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -10208,
        6496
      ],
      "notes": "v4: 11-field schema (ADR-030) \u2014 recipients \u0e40\u0e1b\u0e47\u0e19 Object Array + UUIDv7 validation routing (H1)"
    },
    {
      "parameters": {
        "rules": {
          "values": [
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "leftValue": "={{ $json.route_index }}",
                    "rightValue": 0,
                    "operator": {
                      "type": "number",
                      "operation": "equals",
                      "singleValue": true
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "Auto Ready"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "leftValue": "={{ $json.route_index }}",
                    "rightValue": 1,
                    "operator": {
                      "type": "number",
                      "operation": "equals",
                      "singleValue": true
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "Review Flagged"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "leftValue": "={{ $json.route_index }}",
                    "rightValue": 2,
                    "operator": {
                      "type": "number",
                      "operation": "equals",
                      "singleValue": true
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "Rejected"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "leftValue": "={{ $json.route_index }}",
                    "rightValue": 3,
                    "operator": {
                      "type": "number",
                      "operation": "equals",
                      "singleValue": true
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "Error Log"
            }
          ]
        },
        "options": {}
      },
      "id": "5abd2557-cb16-4102-aca3-db8e8d009286",
      "name": "Route by Confidence",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3.2,
      "position": [
        -10048,
        6560
      ]
    },
    {
      "parameters": {
        "jsCode": "const fs = require('fs');\nconst item = $input.first();\nconst config = $('Set Configuration').first().json.config;\n\nconst csvPath = `${config.LOG_PATH}/reject_log_${config.BATCH_ID}.csv`;\nconst header = 'timestamp,document_number,title,reject_reason,ai_confidence,job_id\\n';\nconst esc = (s) => `\"${String(s || '').replace(/\"/g, '\"\"')}\"`;\n\nif (!fs.existsSync(csvPath)) {\n  fs.writeFileSync(csvPath, header, 'utf8');\n}\n\nconst line = [\n  new Date().toISOString(),\n  esc(item.json.document_number),\n  esc(item.json.title),\n  esc(item.json.reject_reason),\n  item.json.ai_result?.confidence ?? 'N/A',\n  esc(item.json.job_id || '')\n].join(',') + '\\n';\n\nfs.appendFileSync(csvPath, line, 'utf8');\n\nreturn [$input.first()];"
      },
      "id": "62c48542-9b36-4db3-83ef-23bf1af39875",
      "name": "Log Reject to CSV",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -9808,
        6784
      ],
      "notes": "\u0e1a\u0e31\u0e19\u0e17\u0e36\u0e01\u0e23\u0e32\u0e22\u0e01\u0e32\u0e23\u0e17\u0e35\u0e48\u0e16\u0e39\u0e01\u0e1b\u0e0f\u0e34\u0e40\u0e2a\u0e18\u0e25\u0e07 CSV"
    },
    {
      "parameters": {
        "jsCode": "const fs = require('fs');\nconst items = $input.all();\nconst config = $('Set Configuration').first().json.config;\n\nconst ERROR_TYPE_MAP = {\n  AI_JOB_FAILED: 'API_ERROR',\n  PARSE_ERROR: 'AI_PARSE_ERROR',\n  TOKEN_EXPIRED: 'API_ERROR',\n};\nconst ALLOWED_ERROR_TYPES = new Set([\n  'FILE_NOT_FOUND',\n  'MISSING_FILENAME',\n  'FILE_ERROR',\n  'AI_PARSE_ERROR',\n  'API_ERROR',\n  'DB_ERROR',\n  'SECURITY',\n  'UNKNOWN',\n]);\nconst normalizeErrorType = (type, errorMsg) => {\n  // L7: \u0e15\u0e23\u0e27\u0e08 prefix \u0e43\u0e19 error message \u0e2a\u0e33\u0e2b\u0e23\u0e31\u0e1a errors \u0e17\u0e35\u0e48\u0e21\u0e32\u0e08\u0e32\u0e01 throw (\u0e44\u0e21\u0e48\u0e21\u0e35 error_type field)\n  if (!type && errorMsg) {\n    if (errorMsg.startsWith('AI_JOB_FAILED')) return 'API_ERROR';\n    if (errorMsg.startsWith('AI_JOB_POLL_ERROR')) return 'API_ERROR';\n    if (errorMsg.startsWith('TOKEN_EXPIRED')) return 'API_ERROR';\n  }\n  const mappedType = ERROR_TYPE_MAP[type] || type || 'UNKNOWN';\n  return ALLOWED_ERROR_TYPES.has(mappedType) ? mappedType : 'UNKNOWN';\n};\n\nconst csvPath = `${config.LOG_PATH}/error_log_${config.BATCH_ID}.csv`;\nconst header = 'timestamp,document_number,error_type,error_message,job_id\\n';\nconst esc = (s) => `\"${String(s || '').replace(/\"/g, '\"\"')}\"`;\nconst results = [];\n\nif (!fs.existsSync(csvPath)) {\n  fs.writeFileSync(csvPath, header, 'utf8');\n}\n\nfor (const item of items) {\n  // L6: \u0e44\u0e21\u0e48 mutate input item \u2014 \u0e2a\u0e23\u0e49\u0e32\u0e07 object \u0e43\u0e2b\u0e21\u0e48\n  // L7: \u0e1e\u0e22\u0e32\u0e22\u0e32\u0e21 extract document_number \u0e08\u0e32\u0e01 error message \u0e16\u0e49\u0e32\u0e44\u0e21\u0e48\u0e21\u0e35\u0e43\u0e19 item\n  let documentNumber = item.json.document_number || '';\n  if (!documentNumber && errorMsg) {\n    const m = errorMsg.match(/document=([^,]+)/);\n    if (m) documentNumber = m[1];\n  }\n  if (!documentNumber) documentNumber = 'WORKFLOW';\n  const errorType = normalizeErrorType(item.json.error_type, errorMsg);\n  const errorMsg = item.json.error || item.json.parse_error || item.json.message || '';\n  const jobId = item.json.job_id || '';\n\n  const line = [\n    new Date().toISOString(),\n    esc(documentNumber),\n    esc(errorType),\n    esc(errorMsg),\n    esc(jobId)\n  ].join(',') + '\\n';\n  fs.appendFileSync(csvPath, line, 'utf8');\n\n  // \u0e04\u0e37\u0e19 object \u0e43\u0e2b\u0e21\u0e48\u0e1e\u0e23\u0e49\u0e2d\u0e21\u0e04\u0e48\u0e32\u0e17\u0e35\u0e48 normalize \u0e41\u0e25\u0e49\u0e27\n  results.push({ json: { ...item.json, document_number: documentNumber, error_type: errorType, error: errorMsg, job_id: jobId } });\n}\n\nreturn results;"
      },
      "id": "724da26e-5c71-4146-8a4c-a16299dfae17",
      "name": "Log Error to CSV",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -10416,
        6864
      ],
      "notes": "\u0e1a\u0e31\u0e19\u0e17\u0e36\u0e01 Error \u0e25\u0e07 CSV"
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $('Set Configuration').first().json.config.BACKEND_URL}}/api/ai/migration/errors",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "={{ 'Bearer ' + ($('Set Configuration').first().json.config.AUTH_TOKEN || '').replace(/^Bearer\\s+/i, '') }}"
            },
            {
              "name": "Idempotency-Key",
              "value": "={{ ($json.batch_id || $('Set Configuration').first().json.config.BATCH_ID) + ':' + ($json.document_number || 'WORKFLOW') + ':error' }}"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ batchId: $('Set Configuration').first().json.config.BATCH_ID, documentNumber: $json.document_number, errorType: $json.error_type, errorMessage: $json.error, jobId: $json.job_id || '' }) }}",
        "options": {
          "timeout": 10000
        }
      },
      "id": "aa49b719-9133-4ab8-af24-27754c4b4cd2",
      "name": "Log Error to DB",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.1,
      "position": [
        -10208,
        6864
      ],
      "onError": "continueErrorOutput",
      "notes": "POST /api/ai/migration/errors \u2014 ADR-023A"
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $('Set Configuration').first().json.config.BACKEND_URL}}/api/ai/migration/checkpoint",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "={{ 'Bearer ' + ($('Set Configuration').first().json.config.AUTH_TOKEN || '').replace(/^Bearer\\s+/i, '') }}"
            },
            {
              "name": "Idempotency-Key",
              "value": "={{ $json.batch_id + ':checkpoint:' + $json.next_index }}"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ batchId: $json.batch_id, lastProcessedIndex: $json.next_index, status: 'RUNNING', outcome: $json.terminal_outcome, documentNumber: $json.document_number }) }}",
        "options": {
          "timeout": 10000
        }
      },
      "id": "702e0d86-786c-4731-89bb-a7b80aa4d425",
      "name": "Save Checkpoint",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.1,
      "position": [
        -9664,
        6384
      ],
      "onError": "continueErrorOutput",
      "notes": "POST /api/ai/migration/checkpoint \u2014 ADR-023A"
    },
    {
      "parameters": {
        "amount": "={{$('Set Configuration').first().json.config.DELAY_MS / 1000}}",
        "unit": "seconds"
      },
      "id": "f8eadb50-d241-423c-a62e-4c87fd96c3be",
      "name": "Delay",
      "type": "n8n-nodes-base.wait",
      "typeVersion": 1,
      "position": [
        -9536,
        6880
      ],
      "notes": "\u0e2b\u0e19\u0e48\u0e27\u0e07\u0e40\u0e27\u0e25\u0e32\u0e23\u0e30\u0e2b\u0e27\u0e48\u0e32\u0e07 Records"
    },
    {
      "parameters": {
        "rules": {
          "values": [
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "leftValue": "={{ $json.batch_complete }}",
                    "rightValue": true,
                    "operator": {
                      "type": "boolean",
                      "operation": "equals",
                      "singleValue": true
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "Batch Complete"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "leftValue": "={{ $json.batch_complete }}",
                    "rightValue": false,
                    "operator": {
                      "type": "boolean",
                      "operation": "equals",
                      "singleValue": true
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "Continue Loop"
            }
          ]
        },
        "options": {}
      },
      "id": "0ef559e7-8ffc-4bf5-b6ef-2d5e2d44d57a",
      "name": "Check Batch Complete",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3.2,
      "position": [
        -11008,
        6880
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $('Set Configuration').first().json.config.BACKEND_URL}}/api/ai/migration/checkpoint",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "={{ 'Bearer ' + ($('Set Configuration').first().json.config.AUTH_TOKEN || '').replace(/^Bearer\\s+/i, '') }}"
            },
            {
              "name": "Idempotency-Key",
              "value": "={{ $('Set Configuration').first().json.config.BATCH_ID + ':complete' }}"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ batchId: $('Set Configuration').first().json.config.BATCH_ID, lastProcessedIndex: $json.total_processed, status: 'COMPLETED', outcome: 'COMPLETED', documentNumber: '' }) }}",
        "options": {
          "timeout": 10000
        }
      },
      "id": "c059f49b-a184-43ff-aef4-a84077c10524",
      "name": "Mark Batch Complete",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.1,
      "position": [
        -10784,
        6864
      ],
      "notes": "Update checkpoint status to COMPLETED when batch finishes \u2014 L9: \u0e40\u0e1e\u0e34\u0e48\u0e21 outcome + documentNumber \u0e43\u0e2b\u0e49\u0e2a\u0e2d\u0e14\u0e04\u0e25\u0e49\u0e2d\u0e07\u0e01\u0e31\u0e1a Save Checkpoint schema"
    },
    {
      "parameters": {
        "rules": {
          "values": [
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "leftValue": "={{ $json.file_valid }}",
                    "rightValue": true,
                    "operator": {
                      "type": "boolean",
                      "operation": "equals",
                      "singleValue": true
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "Valid PDF"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "leftValue": "={{ $json.file_valid }}",
                    "rightValue": false,
                    "operator": {
                      "type": "boolean",
                      "operation": "equals",
                      "singleValue": true
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "Invalid PDF"
            }
          ]
        }
      },
      "id": "v4-route-file-validation",
      "name": "Route File Validation",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3.2,
      "position": [
        -10640,
        6380
      ]
    },
    {
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "// Fix 2: \u0e1b\u0e49\u0e2d\u0e07\u0e01\u0e31\u0e19 Loop History Pollution\n// $('Process Batch + Encoding') \u0e40\u0e1b\u0e47\u0e19 PRIMARY source \u0e40\u0e1e\u0e23\u0e32\u0e30:\n//   1. \u0e23\u0e31\u0e19\u0e17\u0e38\u0e01 iteration (\u0e40\u0e1b\u0e47\u0e19 loop entry point)\n//   2. \u0e21\u0e35 original_index \u0e40\u0e2a\u0e21\u0e2d\n// $('Parse & Validate AI Response') \u0e16\u0e39\u0e01\u0e16\u0e2d\u0e14\u0e2d\u0e2d\u0e01\u0e40\u0e1e\u0e23\u0e32\u0e30\u0e44\u0e21\u0e48\u0e23\u0e31\u0e19\u0e43\u0e19 error paths\n//   (\u0e40\u0e0a\u0e48\u0e19 file not found, upload failed) \u2192 \u0e08\u0e30\u0e04\u0e37\u0e19\u0e02\u0e49\u0e2d\u0e21\u0e39\u0e25\u0e08\u0e32\u0e01 iteration \u0e01\u0e48\u0e2d\u0e19\u0e2b\u0e19\u0e49\u0e32 (stale)\n// $input.first().json \u0e43\u0e0a\u0e49\u0e2a\u0e33\u0e2b\u0e23\u0e31\u0e1a terminal_outcome \u0e41\u0e25\u0e30 route_index \u0e40\u0e17\u0e48\u0e32\u0e19\u0e31\u0e49\u0e19\n//   \u0e40\u0e1e\u0e23\u0e32\u0e30 HTTP nodes \u0e41\u0e17\u0e19\u0e17\u0e35\u0e48 JSON \u0e17\u0e31\u0e49\u0e07\u0e2b\u0e21\u0e14\u0e14\u0e49\u0e27\u0e22 response\n\nconst currentItem = $json || {};\nconst config = $('Set Configuration').first().json.config;\n\n// PRIMARY: Process Batch + Encoding \u2014 \u0e23\u0e31\u0e19\u0e17\u0e38\u0e01 iteration, \u0e21\u0e35 metadata \u0e04\u0e23\u0e1a\nlet meta = {};\ntry {\n  meta = $('Process Batch + Encoding').first()?.json || {};\n} catch (_) {\n  // \u0e16\u0e49\u0e32\u0e40\u0e02\u0e49\u0e32\u0e16\u0e36\u0e07\u0e44\u0e21\u0e48\u0e44\u0e14\u0e49 \u0e43\u0e0a\u0e49 currentItem \u0e40\u0e1b\u0e47\u0e19 fallback\n  meta = currentItem;\n}\n\nconst originalIndex = Number(meta.original_index);\nif (!Number.isInteger(originalIndex) || originalIndex < 0) {\n  throw new Error(`CHECKPOINT_METADATA_MISSING: original_index unavailable \u2014 currentItem: ${JSON.stringify(currentItem).substring(0, 200)}`);\n}\n\n// terminal_outcome \u0e21\u0e32\u0e08\u0e32\u0e01 currentItem (\u0e0b\u0e36\u0e48\u0e07\u0e2d\u0e32\u0e08\u0e40\u0e1b\u0e47\u0e19 HTTP response \u0e2b\u0e23\u0e37\u0e2d Code node output)\n// \u0e16\u0e49\u0e32 currentItem \u0e21\u0e35 route_index \u0e43\u0e0a\u0e49\u0e2a\u0e34\u0e48\u0e07\u0e19\u0e31\u0e49\u0e19, \u0e16\u0e49\u0e32\u0e44\u0e21\u0e48\u0e21\u0e35\u0e15\u0e23\u0e27\u0e08\u0e08\u0e32\u0e01 meta\nconst routeIndex = currentItem.route_index ?? meta.route_index;\nconst hasError = currentItem.error || currentItem.parse_error || meta.error || meta.parse_error;\nconst terminalOutcome = routeIndex === 2 ? 'REJECTED' : (hasError ? 'FAILED' : 'QUEUED');\n\nreturn { json: {\n  batch_id: meta.batch_id || config.BATCH_ID,\n  original_index: originalIndex,\n  next_index: originalIndex + 1,\n  row_key: meta.row_key || '',\n  document_number: meta.document_number || currentItem.document_number || 'UNKNOWN',\n  terminal_outcome: terminalOutcome,\n} };"
      },
      "id": "v4-prepare-checkpoint",
      "name": "Prepare Terminal Checkpoint",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -9664,
        6600
      ],
      "notes": "Fix 2: \u0e43\u0e0a\u0e49 $('Process Batch + Encoding') \u0e40\u0e1b\u0e47\u0e19 PRIMARY source (\u0e23\u0e31\u0e19\u0e17\u0e38\u0e01 iteration) \u2014 \u0e16\u0e2d\u0e14 $('Parse & Validate AI Response') \u0e2d\u0e2d\u0e01\u0e08\u0e32\u0e01 candidates \u0e40\u0e1e\u0e23\u0e32\u0e30\u0e44\u0e21\u0e48\u0e23\u0e31\u0e19\u0e43\u0e19 error paths \u2192 \u0e01\u0e31\u0e19 Loop History Pollution \u0e08\u0e32\u0e01 iteration \u0e01\u0e48\u0e2d\u0e19\u0e2b\u0e19\u0e49\u0e32"
    },
    {
      "parameters": {
        "jsCode": "// M4: RBAC scope verification (ADR-016)\n// \u0e15\u0e23\u0e27\u0e08\u0e2a\u0e2d\u0e1a\u0e27\u0e48\u0e32 MIGRATION_TOKEN \u0e21\u0e35 permission \u0e17\u0e35\u0e48\u0e08\u0e33\u0e40\u0e1b\u0e47\u0e19\u0e2a\u0e33\u0e2b\u0e23\u0e31\u0e1a workflow \u0e19\u0e35\u0e49\n// Profile response shape: { assignments: [{ role: { roleName, permissions: [{ permissionName }] } }] }\nconst profile = $json || {};\nconst REQUIRED_PERMISSIONS = ['ai.suggest'];\nconst ADMIN_ROLES = new Set(['Superadmin', 'Org Admin']);\n\n// \u0e01\u0e23\u0e13\u0e35 backend \u0e04\u0e37\u0e19 { data: { ... } } \u0e2b\u0e23\u0e37\u0e2d { ... } \u0e42\u0e14\u0e22\u0e15\u0e23\u0e07\nconst user = profile.data || profile;\nconst assignments = Array.isArray(user.assignments) ? user.assignments : [];\n\n// \u0e01\u0e23\u0e2d\u0e07\u0e40\u0e09\u0e1e\u0e32\u0e30 assignments \u0e17\u0e35\u0e48\u0e21\u0e35 role\nconst roles = assignments\n  .map(a => a?.role?.roleName)\n  .filter(Boolean);\n\n// Superadmin \u0e1c\u0e48\u0e32\u0e19\u0e17\u0e38\u0e01 permission check\nconst isAdmin = roles.some(r => ADMIN_ROLES.has(r));\n\n// \u0e23\u0e27\u0e1a\u0e23\u0e27\u0e21 permission \u0e17\u0e31\u0e49\u0e07\u0e2b\u0e21\u0e14\u0e08\u0e32\u0e01\u0e17\u0e38\u0e01 role\nconst allPermissions = new Set();\nfor (const assignment of assignments) {\n  const perms = assignment?.role?.permissions;\n  if (Array.isArray(perms)) {\n    for (const p of perms) {\n      const name = p?.permissionName || p?.name;\n      if (name) allPermissions.add(name);\n    }\n  }\n}\n\n// \u0e15\u0e23\u0e27\u0e08\u0e2a\u0e2d\u0e1a required permissions\nconst missing = REQUIRED_PERMISSIONS.filter(p => !isAdmin && !allPermissions.has(p));\n\nif (missing.length > 0) {\n  const errorMsg = `UNAUTHORIZED_SCOPE: token \u0e02\u0e32\u0e14 permission \u0e17\u0e35\u0e48\u0e08\u0e33\u0e40\u0e1b\u0e47\u0e19 [${missing.join(', ')}] \u2014 roles: [${roles.join(', ') || 'none'}] (ADR-016)`;\n  throw new Error(errorMsg);\n}\n\nreturn [{\n  json: {\n    scope_verified: true,\n    roles: roles,\n    permissions: Array.from(allPermissions).sort(),\n    is_admin: isAdmin,\n    username: user.username || user.user_id || 'unknown',\n  }\n}];"
      },
      "id": "fb5121f7-8ea0-4972-b1e6-6a1e066d3ca8",
      "name": "Verify Migration Scope",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -10832,
        6272
      ],
      "notes": "M4: \u0e15\u0e23\u0e27\u0e08\u0e2a\u0e2d\u0e1a RBAC scope \u0e08\u0e32\u0e01 /api/auth/profile response \u2014 \u0e15\u0e49\u0e2d\u0e07\u0e21\u0e35 ai.suggest \u0e2b\u0e23\u0e37\u0e2d\u0e40\u0e1b\u0e47\u0e19 Superadmin/Org Admin (ADR-016)"
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $('Set Configuration').first().json.config.BACKEND_URL}}/api/ai/migration/queue/record",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "={{ 'Bearer ' + ($('Set Configuration').first().json.config.AUTH_TOKEN || '').replace(/^Bearer\\s+/i, '') }}"
            },
            {
              "name": "Idempotency-Key",
              "value": "={{ $json.row_key + ':queue' }}"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ batchId: $json.batch_id, documentNumber: $json.document_number, subject: ($json.ai_result?.subject || $json.title), originalSubject: $json.title, tempAttachmentId: $json.temp_attachment_public_id === undefined ? null : $json.temp_attachment_public_id, confidence: $json.ai_result?.confidence, reviewReason: $json.review_reason || '', status: 
Pro

For the full experience including quality scoring and batch install features for each workflow upgrade to Pro

About this workflow

LCBP3 Migration Workflow v4.0.0. Uses formTrigger, httpRequest, readWriteFile, spreadsheetFile. Event-driven trigger; 38 nodes.

Source: https://github.com/peancharoen/lcbp3/blob/b423d31f38d9231f17df73f55163a4862967ccd8/specs/03-Data-and-Storage/n8n.workflow.v4.json — original creator credit. Request a take-down →

More Data & Sheets workflows → · Browse all categories →

Related workflows

Workflows that share integrations, category, or trigger type with this one. All free to copy and import.

Data & Sheets

Shopify Csv Import. Uses formTrigger, moveBinaryData, spreadsheetFile, googleSheets. Event-driven trigger; 12 nodes.

Form Trigger, Move Binary Data, Spreadsheet File +2
Data & Sheets

cdp_router. Uses gmailTrigger, telegramTrigger, googleSheets, httpRequest. Event-driven trigger; 53 nodes.

Gmail Trigger, Telegram Trigger, Google Sheets +4
Data & Sheets

cdp_router. Uses gmailTrigger, telegramTrigger, googleSheets, httpRequest. Event-driven trigger; 53 nodes.

Gmail Trigger, Telegram Trigger, Google Sheets +4
Data & Sheets

Overview 🌐

Form Trigger, HTTP Request, Google Sheets
Data & Sheets

Splitout Code. Uses splitOut, httpRequest, googleSheets, stickyNote. Event-driven trigger; 36 nodes.

HTTP Request, Google Sheets, Form Trigger +1