{
  "meta": {
    "templateCredsSetupCompleted": true
  },
  "nodes": [
    {
      "id": "2b43c48f-48fd-449d-89d8-4350be2d3f4b",
      "name": "\ud83d\udccb Overview",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -384,
        -1248
      ],
      "parameters": {
        "width": 640,
        "height": 1600,
        "content": "# \ud83d\uddc2\ufe0f Legal & Compliance Document Archiving\n\n**The problem this solves:**\nHR managers receive signed contracts via email, WhatsApp, or phone scans \u2014 then manually rename files, drag them into the right Drive folder, and tick a checkbox in their HR system. Files get lost, folders stay inconsistent, and audit trails are weak. This workflow automates the entire chain.\n\n---\n\n## \u2699\ufe0f How it works\n1. **Webhook** receives a document upload (binary scan or remote URL) + employee metadata\n2. **Validator** sanitises all inputs \u2014 normalises employee name, generates a structured filename with date stamp, resolves the correct Drive folder path\n3. **Upload to URL** instantly hosts the document and returns a clean public CDN link\n4. **Google Drive** uploads the file into a structured folder: `HR / Contracts / {Year} / {EmployeeName}/`  \u2014 creates the folder if it doesn't exist\n5. **Airtable** looks up the employee record, ticks `Contract Received`, logs the Drive URL, file name, and timestamp\n6. **Duplicate Check** flags if a contract for this employee already exists in Airtable\n7. **Notifier** sends a confirmation email to HR + the employee\n8. **Webhook Response** returns the full archiving summary\n\n---\n\n## \ud83d\udd10 Required Credentials\n| Credential | Node |\n|---|---|\n| `UploadToURL API` | Upload to URL nodes |\n| `Google Drive OAuth2` | Drive folder + file upload |\n| `Airtable API` | Record lookup + update |\n| `Gmail / SMTP` | Confirmation email |\n\n---\n\n## \ud83d\udd27 Setup Checklist\n- [ ] Install **Upload to URL** community node via npm\n- [ ] Set `UploadToURL API` credentials\n- [ ] Connect `Google Drive OAuth2` credentials\n- [ ] Set n8n variable `GDRIVE_ROOT_FOLDER_ID` to your HR root folder ID in Drive\n- [ ] Connect `Airtable API` token\n- [ ] Set n8n variable `AIRTABLE_BASE_ID` and `AIRTABLE_TABLE_NAME`\n- [ ] Connect `Gmail` or `SMTP` credentials for confirmation emails\n- [ ] Activate workflow and copy the webhook URL\n\n---\n\n## \ud83d\udcec Example Webhook Payload\n```json\n{\n  \"fileUrl\": \"https://cdn.example.com/contract-signed.pdf\",\n  \"filename\": \"contract-signed.pdf\",\n  \"employeeName\": \"Sarah Johnson\",\n  \"employeeId\": \"EMP-0042\",\n  \"employeeEmail\": \"sarah.johnson@company.com\",\n  \"contractType\": \"Full-Time Employment\",\n  \"department\": \"Engineering\",\n  \"effectiveDate\": \"2025-03-01\",\n  \"notifyEmployee\": true\n}\n```\n\n> \ud83d\udca1 **Tip:** Send binary PDF via `multipart/form-data` with field name `file` to skip the `fileUrl` requirement."
      },
      "typeVersion": 1
    },
    {
      "id": "56ea1858-57e2-40f4-8d80-de36f8fdd700",
      "name": "Entry & Duplicate Check",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        608,
        -128
      ],
      "parameters": {
        "color": 7,
        "width": 500,
        "height": 502,
        "content": "## \ud83d\udeaa Entry, Validation & Duplicate Check\n**Nodes:** Webhook \u2192 Validate & Enrich \u2192 Airtable Duplicate Check \u2192 IF Duplicate?\n\n- Accepts `POST` with employee metadata + either `fileUrl` or binary PDF/image\n- Normalises employee name (title-case, strips special chars), generates a structured filename: `{EmployeeID}_{LastName}_{ContractType}_{YYYY-MM-DD}.pdf`\n- Builds the full Drive folder path: `HR/Contracts/{Year}/{Department}/{EmployeeName}/`\n- **Duplicate check runs first** \u2014 searches Airtable for an existing record with `Contract Received = true` for this employee before uploading anything, saving API quota and preventing double-filing\n- IF branch halts the workflow and returns a `409 Conflict` if a contract already exists"
      },
      "typeVersion": 1
    },
    {
      "id": "7adcde7a-ad34-4f2f-b26c-3e6deed5d9b6",
      "name": "Upload Chain",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1504,
        -208
      ],
      "parameters": {
        "color": 7,
        "width": 500,
        "height": 780,
        "content": "## \u2601\ufe0f Upload to URL \u2192 Google Drive\n**Nodes:** Has Remote URL? \u2192 Upload to URL (\u00d72) \u2192 Extract Doc URL \u2192 Get/Create Drive Folder \u2192 Upload to Drive\n\n- Native **Upload to URL** node hosts the file first \u2014 this gives us a stable CDN URL to store in Airtable regardless of Drive sharing settings\n- `Get/Create Drive Folder` checks if the employee's subfolder already exists under the year/department path; creates it if not \u2014 no manual folder management needed\n- Drive upload uses the structured filename and sets `mimeType` correctly for PDF vs image scans\n- Both the CDN URL (UploadToURL) and the Drive file ID are passed downstream for full redundancy"
      },
      "typeVersion": 1
    },
    {
      "id": "c4495c99-6e3b-4753-a4ec-b8498d447661",
      "name": "Airtable Update",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        3936,
        -192
      ],
      "parameters": {
        "color": 7,
        "width": 500,
        "height": 770,
        "content": "## \ud83d\uddc3\ufe0f Airtable Update\n**Nodes:** Airtable - Find Employee \u2192 Airtable - Update Record\n\n- `Find Employee` searches by `Employee ID` field first, falls back to name match\n- `Update Record` ticks `Contract Received` checkbox, writes `Contract URL` (Drive link), `CDN Backup URL` (UploadToURL link), `Contract Type`, `Effective Date`, `Filed By`, and `Filed At` timestamp\n- If no Airtable record is found, a **new record is created** automatically so no employee needs to be pre-entered\n- Both Drive URL and the raw UploadToURL CDN link are stored \u2014 providing a redundant access path for auditors"
      },
      "typeVersion": 1
    },
    {
      "id": "fd814fd6-732a-407d-896f-c5953038a326",
      "name": "Notification & Response",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        4480,
        -160
      ],
      "parameters": {
        "color": 7,
        "width": 500,
        "height": 728,
        "content": "## \ud83d\udce7 Notification & Response\n**Nodes:** Send Confirmation Email \u2192 Build Final Response \u2192 Respond to Webhook\n\n- Sends a confirmation email to the HR manager (always) and optionally to the employee if `notifyEmployee: true`\n- Email includes: Drive folder link, CDN backup URL, structured filename, contract type, effective date\n- `Build Final Response` normalises the full archiving summary: Drive file ID, folder path, Airtable record ID, both URLs, and a `auditTrail` object\n- Returns `201 Created` with the complete record \u2014 ready to pipe into a broader HRIS or compliance dashboard"
      },
      "typeVersion": 1
    },
    {
      "id": "01cd1834-06a3-4431-bcac-56a665891e3a",
      "name": "Error & Conflict Handling",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1184,
        656
      ],
      "parameters": {
        "color": 7,
        "width": 460,
        "height": 415,
        "content": "## \u26a0\ufe0f Error & Conflict Handling\n**Nodes:** Duplicate Response (409) \u2192 Error Handler \u2192 Error Response (400)\n\n- `409 Conflict` is returned immediately if a contract already exists for the employee \u2014 prevents accidental overwrites during re-submissions\n- General `Error Handler` catches all other upstream failures (Drive API errors, Airtable timeouts, upload failures) and returns a structured `400` with the error message\n- Connect any node's **error output** to the Error Handler node"
      },
      "typeVersion": 1
    },
    {
      "id": "5891f4a4-2d75-4783-af17-f312874287ed",
      "name": "Webhook - Receive Contract",
      "type": "n8n-nodes-base.webhook",
      "position": [
        688,
        240
      ],
      "parameters": {
        "path": "hr-contract-archive",
        "options": {
          "allowedOrigins": "*"
        },
        "httpMethod": "POST",
        "responseMode": "responseNode"
      },
      "typeVersion": 2
    },
    {
      "id": "05264ff6-76ae-4def-aba1-e88cf91e62c8",
      "name": "Validate & Enrich Payload",
      "type": "n8n-nodes-base.code",
      "position": [
        912,
        240
      ],
      "parameters": {
        "jsCode": "const body = $input.first().json.body || $input.first().json;\n\n// \u2500\u2500 Required field guards \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 (!body.employeeName || body.employeeName.trim() === '') {\n  throw new Error('Missing required field: employeeName');\n}\nif (!body.fileUrl && !body.filename) {\n  throw new Error('Provide either fileUrl (remote document) or filename (binary upload).');\n}\n\n// \u2500\u2500 Name normalisation (Title Case, strip special chars) \u2500\u2500\u2500\u2500\u2500\u2500\nconst normaliseName = (raw) => {\n  return raw\n    .trim()\n    .replace(/[^a-zA-Z\\s\\-']/g, '')\n    .replace(/\\s+/g, ' ')\n    .split(' ')\n    .map(w => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())\n    .join(' ');\n};\nconst employeeName = normaliseName(body.employeeName);\nconst nameParts = employeeName.split(' ');\nconst lastName = nameParts[nameParts.length - 1];\nconst firstName = nameParts[0];\n\n// \u2500\u2500 Employee ID \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\u2500\nconst employeeId = (body.employeeId || '').toUpperCase().replace(/[^A-Z0-9\\-]/g, '') || `EMP-${Date.now()}`;\n\n// \u2500\u2500 Contract type slug \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 contractType = body.contractType || 'Employment Contract';\nconst contractTypeSlug = contractType.replace(/\\s+/g, '-').replace(/[^a-zA-Z0-9\\-]/g, '').toUpperCase();\n\n// \u2500\u2500 Date handling \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\nconst effectiveDate = body.effectiveDate || new Date().toISOString().split('T')[0];\nconst filedAt = new Date().toISOString();\nconst yearFolder = effectiveDate.split('-')[0];\n\n// \u2500\u2500 Filename \u2014 structured for audit trail \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst originalFilename = body.filename || body.fileUrl?.split('?')[0].split('/').pop() || 'document.pdf';\nconst ext = originalFilename.split('.').pop()?.toLowerCase() || 'pdf';\nconst allowedExts = ['pdf', 'jpg', 'jpeg', 'png', 'docx', 'tiff'];\nif (!allowedExts.includes(ext)) {\n  throw new Error(`File type .${ext} not allowed. Accepted: ${allowedExts.join(', ')}`);\n}\nconst structuredFilename = `${employeeId}_${lastName.toUpperCase()}_${contractTypeSlug}_${effectiveDate}.${ext}`;\n\n// \u2500\u2500 MIME type map \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\nconst mimeMap = {\n  pdf: 'application/pdf',\n  jpg: 'image/jpeg', jpeg: 'image/jpeg',\n  png: 'image/png',\n  docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n  tiff: 'image/tiff'\n};\nconst mimeType = mimeMap[ext] || 'application/octet-stream';\n\n// \u2500\u2500 Drive folder path \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 department = body.department || 'General';\nconst driveFolderPath = `HR/Contracts/${yearFolder}/${department}/${employeeName}`;\n\nreturn [{\n  json: {\n    // Source\n    fileUrl: body.fileUrl || null,\n    originalFilename,\n    structuredFilename,\n    mimeType,\n    ext,\n    // Employee\n    employeeName,\n    firstName,\n    lastName,\n    employeeId,\n    employeeEmail: body.employeeEmail || '',\n    department,\n    // Contract\n    contractType,\n    contractTypeSlug,\n    effectiveDate,\n    // Drive\n    driveFolderPath,\n    yearFolder,\n    rootFolderId: $vars.GDRIVE_ROOT_FOLDER_ID || 'root',\n    // Flags\n    notifyEmployee: body.notifyEmployee !== false,\n    filedBy: body.filedBy || 'HR System',\n    filedAt\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "aa8b3b42-5bc3-4a02-ba8f-5e2d85642f3c",
      "name": "Airtable - Duplicate Check",
      "type": "n8n-nodes-base.airtable",
      "notes": "Searches for existing contract record before uploading. Prevents duplicate filings and saves Drive quota.",
      "position": [
        1120,
        240
      ],
      "parameters": {
        "base": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "table": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "options": {
          "fields": [
            "Employee ID",
            "Employee Name",
            "Contract Received",
            "Contract URL",
            "Filed At"
          ]
        },
        "operation": "search",
        "filterByFormula": "=AND({Employee ID} = '{{ $json.employeeId }}', {Contract Received} = TRUE())"
      },
      "credentials": {
        "airtableTokenApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2.1
    },
    {
      "id": "51a5a374-b4d9-45ab-8166-55dea3242ee4",
      "name": "IF Duplicate Exists?",
      "type": "n8n-nodes-base.if",
      "position": [
        1344,
        240
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "caseSensitive": false,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "cond-duplicate",
              "operator": {
                "type": "number",
                "operation": "gt"
              },
              "leftValue": "={{ $json.records?.length ?? 0 }}",
              "rightValue": 0
            }
          ]
        }
      },
      "typeVersion": 2
    },
    {
      "id": "ec542ad4-017f-4e4a-a5c5-093374be5b1f",
      "name": "Respond - 409 Duplicate",
      "type": "n8n-nodes-base.respondToWebhook",
      "position": [
        1552,
        128
      ],
      "parameters": {
        "options": {
          "responseCode": 409,
          "responseHeaders": {
            "entries": [
              {
                "name": "Content-Type",
                "value": "application/json"
              }
            ]
          }
        },
        "respondWith": "json",
        "responseBody": "={\n  \"success\": false,\n  \"conflict\": true,\n  \"message\": \"A contract for employee {{ $('Validate & Enrich Payload').first().json.employeeName }} ({{ $('Validate & Enrich Payload').first().json.employeeId }}) is already filed. Existing record: {{ $json.records[0].fields['Contract URL'] }}\",\n  \"existingContractUrl\": \"{{ $json.records[0].fields['Contract URL'] }}\",\n  \"filedAt\": \"{{ $json.records[0].fields['Filed At'] }}\"\n}"
      },
      "typeVersion": 1.1
    },
    {
      "id": "adbf5a1e-f466-412e-92a5-f55ded178cf8",
      "name": "Has Remote URL?",
      "type": "n8n-nodes-base.if",
      "position": [
        1552,
        352
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "caseSensitive": false,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "cond-fileurl",
              "operator": {
                "type": "string",
                "operation": "notEmpty"
              },
              "leftValue": "={{ $('Validate & Enrich Payload').first().json.fileUrl }}",
              "rightValue": ""
            }
          ]
        }
      },
      "typeVersion": 2
    },
    {
      "id": "2c1a9f2b-46a4-4491-8149-e8ff23b21881",
      "name": "Upload to URL - Remote",
      "type": "n8n-nodes-uploadtourl.uploadToUrl",
      "position": [
        1760,
        224
      ],
      "parameters": {
        "operation": "uploadFile"
      },
      "credentials": {
        "uploadToUrlApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1
    },
    {
      "id": "7fbca76a-eda4-48b8-9c44-400214202fe4",
      "name": "Upload to URL - Binary",
      "type": "n8n-nodes-uploadtourl.uploadToUrl",
      "position": [
        1760,
        464
      ],
      "parameters": {
        "operation": "uploadFile"
      },
      "credentials": {
        "uploadToUrlApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1
    },
    {
      "id": "334da8a8-b8b9-4c45-9a33-ab93f64a6ced",
      "name": "Extract CDN URL",
      "type": "n8n-nodes-base.code",
      "position": [
        1984,
        352
      ],
      "parameters": {
        "jsCode": "const uploadResp = $input.first().json;\nconst meta = $('Validate & Enrich Payload').first().json;\n\nconst cdnUrl =\n  uploadResp.url ||\n  uploadResp.link ||\n  uploadResp.data?.url ||\n  uploadResp.file?.url ||\n  uploadResp.shortUrl;\n\nif (!cdnUrl) {\n  throw new Error('Upload to URL returned no public URL. Response: ' + JSON.stringify(uploadResp));\n}\n\nreturn [{\n  json: {\n    ...meta,\n    cdnUrl: cdnUrl.replace(/^http:\\/\\//, 'https://'),\n    uploadId: uploadResp.id || uploadResp.data?.id || null,\n    fileSizeBytes: uploadResp.size || uploadResp.data?.size || null\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "e82aac5b-6551-4415-9e87-288205dd1e68",
      "name": "Drive - Find Employee Folder",
      "type": "n8n-nodes-base.googleDrive",
      "notes": "Searches for an existing employee subfolder. Result passed to the Create Folder or Use Existing branch.",
      "position": [
        2208,
        240
      ],
      "parameters": {
        "operation": "search"
      },
      "credentials": {
        "googleDriveOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 3
    },
    {
      "id": "0ca63735-0c28-4ae3-a54c-dd0fe1dba37d",
      "name": "Resolve Folder ID",
      "type": "n8n-nodes-base.code",
      "position": [
        2432,
        240
      ],
      "parameters": {
        "jsCode": "// Decide whether to create a new folder or use the existing one\nconst searchResult = $input.first().json;\nconst meta = $('Extract CDN URL').first().json;\n\n// Google Drive search returns items array\nconst existingFolder = searchResult.files?.[0] || searchResult.items?.[0] || null;\nconst folderId = existingFolder?.id || null;\n\nreturn [{\n  json: {\n    ...meta,\n    existingFolderId: folderId,\n    needsNewFolder: !folderId\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "61329861-d0c7-415b-99b4-4b7a1cca5b41",
      "name": "Needs New Folder?",
      "type": "n8n-nodes-base.if",
      "position": [
        2640,
        240
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "caseSensitive": false,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "cond-newfolder",
              "operator": {
                "type": "boolean",
                "operation": "true"
              },
              "leftValue": "={{ $json.needsNewFolder }}",
              "rightValue": true
            }
          ]
        }
      },
      "typeVersion": 2
    },
    {
      "id": "f82d1330-66de-4517-ac66-925df674384e",
      "name": "Drive - Create Employee Folder",
      "type": "n8n-nodes-base.googleDrive",
      "notes": "Creates the employee subfolder under HR/Contracts/{Year}/{Department}/. Only runs if folder doesn't already exist.",
      "position": [
        2864,
        128
      ],
      "parameters": {
        "operation": "createFolder"
      },
      "credentials": {
        "googleDriveOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 3
    },
    {
      "id": "cae0e67c-2fd0-4c8a-a3b8-0ddc5a455d7f",
      "name": "Merge Folder ID",
      "type": "n8n-nodes-base.code",
      "position": [
        3088,
        240
      ],
      "parameters": {
        "jsCode": "// Merge the folder ID from either path (new or existing)\nconst input = $input.first().json;\nconst meta = $('Resolve Folder ID').first().json;\n\n// If we just created a folder, the response has the new folder's id\nconst targetFolderId =\n  input.id ||               // newly created folder\n  meta.existingFolderId;    // pre-existing folder\n\nif (!targetFolderId) {\n  throw new Error('Could not resolve a Google Drive folder ID for upload.');\n}\n\nreturn [{\n  json: {\n    ...meta,\n    targetFolderId\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "cf175356-ab80-4d3e-a24e-5ca30310f6c2",
      "name": "Drive - Upload Document",
      "type": "n8n-nodes-base.googleDrive",
      "notes": "Uploads the document to the resolved employee folder using the structured filename. Sets correct MIME type for PDF vs image.",
      "position": [
        3312,
        240
      ],
      "parameters": {
        "name": "={{ $json.structuredFilename }}",
        "driveId": {
          "__rl": true,
          "mode": "list",
          "value": "MyDrive"
        },
        "options": {
          "ocrLanguage": ""
        },
        "folderId": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $json.targetFolderId }}"
        }
      },
      "credentials": {
        "googleDriveOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 3
    },
    {
      "id": "ce49884a-6f07-41a5-b1ff-4ff323160002",
      "name": "Airtable - Find Employee Record",
      "type": "n8n-nodes-base.airtable",
      "position": [
        3520,
        240
      ],
      "parameters": {
        "base": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "table": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "options": {
          "fields": [
            "Employee ID",
            "Employee Name",
            "Department",
            "Contract Received"
          ]
        },
        "operation": "search",
        "filterByFormula": "={Employee ID} = '{{ $('Merge Folder ID').first().json.employeeId }}'"
      },
      "credentials": {
        "airtableTokenApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2.1
    },
    {
      "id": "9cca6163-dc9d-46ca-9531-fdfe6fc031fe",
      "name": "Prepare Airtable Update",
      "type": "n8n-nodes-base.code",
      "position": [
        3744,
        240
      ],
      "parameters": {
        "jsCode": "const airtableResp = $input.first().json;\nconst meta = $('Merge Folder ID').first().json;\nconst driveResp = $('Drive - Upload Document').first().json;\n\n// Build Drive shareable URL from file ID\nconst driveFileId = driveResp.id;\nconst driveUrl = driveFileId\n  ? `https://drive.google.com/file/d/${driveFileId}/view`\n  : null;\n\nconst existingRecord = airtableResp.records?.[0] || null;\n\nreturn [{\n  json: {\n    ...meta,\n    driveFileId,\n    driveUrl,\n    airtableRecordId: existingRecord?.id || null,\n    airtableRecordExists: !!existingRecord\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "90abc179-346a-4a82-97e7-6c2bc7c9fdbc",
      "name": "Airtable Record Exists?",
      "type": "n8n-nodes-base.if",
      "position": [
        3968,
        240
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "caseSensitive": false,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "cond-record-exists",
              "operator": {
                "type": "boolean",
                "operation": "true"
              },
              "leftValue": "={{ $json.airtableRecordExists }}",
              "rightValue": true
            }
          ]
        }
      },
      "typeVersion": 2
    },
    {
      "id": "57d9299a-20e1-4075-b2d2-2d31f3102ca7",
      "name": "Airtable - Update Existing Record",
      "type": "n8n-nodes-base.airtable",
      "notes": "Updates the existing employee record: ticks Contract Received, writes Drive URL, CDN backup URL, filename, effective date, and filing metadata.",
      "position": [
        4192,
        128
      ],
      "parameters": {
        "base": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "table": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "columns": {
          "value": {
            "Filed At": "={{ $json.filedAt }}",
            "Filed By": "={{ $json.filedBy }}",
            "Contract URL": "={{ $json.driveUrl }}",
            "Contract Type": "={{ $json.contractType }}",
            "CDN Backup URL": "={{ $json.cdnUrl }}",
            "Effective Date": "={{ $json.effectiveDate }}",
            "Contract Received": true,
            "Drive Folder Path": "={{ $json.driveFolderPath }}",
            "Structured Filename": "={{ $json.structuredFilename }}"
          },
          "mappingMode": "defineBelow"
        },
        "options": {},
        "operation": "update"
      },
      "credentials": {
        "airtableTokenApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2.1
    },
    {
      "id": "30b1753c-763c-4b50-9905-2756f6e85bba",
      "name": "Airtable - Create New Record",
      "type": "n8n-nodes-base.airtable",
      "notes": "Creates a brand new Airtable record if the employee wasn't pre-entered. No manual Airtable setup needed before first use.",
      "position": [
        4192,
        368
      ],
      "parameters": {
        "base": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "table": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "columns": {
          "value": {
            "Filed At": "={{ $json.filedAt }}",
            "Filed By": "={{ $json.filedBy }}",
            "Department": "={{ $json.department }}",
            "Employee ID": "={{ $json.employeeId }}",
            "Contract URL": "={{ $json.driveUrl }}",
            "Contract Type": "={{ $json.contractType }}",
            "Employee Name": "={{ $json.employeeName }}",
            "CDN Backup URL": "={{ $json.cdnUrl }}",
            "Effective Date": "={{ $json.effectiveDate }}",
            "Contract Received": true,
            "Drive Folder Path": "={{ $json.driveFolderPath }}",
            "Structured Filename": "={{ $json.structuredFilename }}"
          },
          "mappingMode": "defineBelow"
        },
        "options": {},
        "operation": "create"
      },
      "credentials": {
        "airtableTokenApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2.1
    },
    {
      "id": "c92fc87d-1dc2-423e-971e-3b3d6b548759",
      "name": "Merge Airtable Result",
      "type": "n8n-nodes-base.code",
      "position": [
        4400,
        240
      ],
      "parameters": {
        "jsCode": "// Normalise output from both Airtable branches\nconst airtableResp = $input.first().json;\nconst meta = $('Prepare Airtable Update').first().json;\n\nconst airtableRecordId = airtableResp.id || meta.airtableRecordId;\n\nreturn [{\n  json: {\n    ...meta,\n    airtableRecordId,\n    airtableUpdated: true\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "c69bd238-b672-4de2-80e7-64548b2f1fba",
      "name": "Send Confirmation Email",
      "type": "n8n-nodes-base.gmail",
      "notes": "Sends a formatted HTML confirmation to HR (always) and optionally to the employee. Includes Drive link, CDN backup, structured filename, and filing metadata.",
      "position": [
        4624,
        240
      ],
      "parameters": {
        "operation": "sendEmail"
      },
      "credentials": {
        "gmailOAuth2": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2.1
    },
    {
      "id": "44c9e268-f8f9-4e76-93c1-f3e92324145f",
      "name": "Build Final Response",
      "type": "n8n-nodes-base.code",
      "position": [
        4848,
        240
      ],
      "parameters": {
        "jsCode": "const emailResp = $input.first().json;\nconst data = $('Merge Airtable Result').first().json;\n\nreturn [{\n  json: {\n    success: true,\n    message: `Contract for ${data.employeeName} successfully archived.`,\n    // Core identifiers\n    employeeId: data.employeeId,\n    employeeName: data.employeeName,\n    department: data.department,\n    // Document\n    structuredFilename: data.structuredFilename,\n    contractType: data.contractType,\n    effectiveDate: data.effectiveDate,\n    // Storage\n    driveFileId: data.driveFileId,\n    driveUrl: data.driveUrl,\n    driveFolderPath: data.driveFolderPath,\n    cdnBackupUrl: data.cdnUrl,\n    // Airtable\n    airtableRecordId: data.airtableRecordId,\n    // Audit trail\n    auditTrail: {\n      filedBy: data.filedBy,\n      filedAt: data.filedAt,\n      uploadId: data.uploadId,\n      fileSizeBytes: data.fileSizeBytes\n    }\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "42c588d7-d455-4357-9fc1-9d16ac39a101",
      "name": "Respond to Webhook",
      "type": "n8n-nodes-base.respondToWebhook",
      "position": [
        5072,
        240
      ],
      "parameters": {
        "options": {
          "responseCode": 201,
          "responseHeaders": {
            "entries": [
              {
                "name": "Content-Type",
                "value": "application/json"
              }
            ]
          }
        },
        "respondWith": "json",
        "responseBody": "={{ $json }}"
      },
      "typeVersion": 1.1
    },
    {
      "id": "5de2d9b8-930f-4837-8d83-34c75eabf316",
      "name": "Error Handler",
      "type": "n8n-nodes-base.code",
      "position": [
        1248,
        928
      ],
      "parameters": {
        "jsCode": "const err = $input.first();\nconst msg = err.json?.message || err.error?.message || err.json?.error || 'Unexpected error in document archiving workflow';\nconsole.error('[HR Archive] Error:', msg);\nreturn [{ json: { success: false, error: msg, timestamp: new Date().toISOString() } }];"
      },
      "typeVersion": 2,
      "alwaysOutputData": true
    },
    {
      "id": "6dddfd8d-2de7-4a97-8806-e8a16c5e262b",
      "name": "Respond with Error",
      "type": "n8n-nodes-base.respondToWebhook",
      "position": [
        1456,
        928
      ],
      "parameters": {
        "options": {
          "responseCode": 400,
          "responseHeaders": {
            "entries": [
              {
                "name": "Content-Type",
                "value": "application/json"
              }
            ]
          }
        },
        "respondWith": "json",
        "responseBody": "={{ $json }}"
      },
      "typeVersion": 1.1
    }
  ],
  "connections": {
    "Error Handler": {
      "main": [
        [
          {
            "node": "Respond with Error",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Extract CDN URL": {
      "main": [
        [
          {
            "node": "Drive - Find Employee Folder",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Has Remote URL?": {
      "main": [
        [
          {
            "node": "Upload to URL - Remote",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Upload to URL - Binary",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Merge Folder ID": {
      "main": [
        [
          {
            "node": "Drive - Upload Document",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Needs New Folder?": {
      "main": [
        [
          {
            "node": "Drive - Create Employee Folder",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Merge Folder ID",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Resolve Folder ID": {
      "main": [
        [
          {
            "node": "Needs New Folder?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Final Response": {
      "main": [
        [
          {
            "node": "Respond to Webhook",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "IF Duplicate Exists?": {
      "main": [
        [
          {
            "node": "Respond - 409 Duplicate",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Has Remote URL?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Merge Airtable Result": {
      "main": [
        [
          {
            "node": "Send Confirmation Email",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Upload to URL - Binary": {
      "main": [
        [
          {
            "node": "Extract CDN URL",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Upload to URL - Remote": {
      "main": [
        [
          {
            "node": "Extract CDN URL",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Airtable Record Exists?": {
      "main": [
        [
          {
            "node": "Airtable - Update Existing Record",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Airtable - Create New Record",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Drive - Upload Document": {
      "main": [
        [
          {
            "node": "Airtable - Find Employee Record",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare Airtable Update": {
      "main": [
        [
          {
            "node": "Airtable Record Exists?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Send Confirmation Email": {
      "main": [
        [
          {
            "node": "Build Final Response",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Validate & Enrich Payload": {
      "main": [
        [
          {
            "node": "Airtable - Duplicate Check",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Airtable - Duplicate Check": {
      "main": [
        [
          {
            "node": "IF Duplicate Exists?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Webhook - Receive Contract": {
      "main": [
        [
          {
            "node": "Validate & Enrich Payload",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Airtable - Create New Record": {
      "main": [
        [
          {
            "node": "Merge Airtable Result",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Drive - Find Employee Folder": {
      "main": [
        [
          {
            "node": "Resolve Folder ID",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Drive - Create Employee Folder": {
      "main": [
        [
          {
            "node": "Merge Folder ID",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Airtable - Find Employee Record": {
      "main": [
        [
          {
            "node": "Prepare Airtable Update",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Airtable - Update Existing Record": {
      "main": [
        [
          {
            "node": "Merge Airtable Result",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}