{
  "name": "LGM 10 — Own-Infra Growth Stack (SIMULATED)",
  "nodes": [
    {
      "parameters": {},
      "id": "f10-start",
      "name": "Start",
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "position": [
        120,
        420
      ]
    },
    {
      "parameters": {
        "jsCode": "// Manual-run fixture. Edit branch_hint here to exercise different\n// downstream paths when you click Execute workflow:\n//   'booked' | 'human-handoff' | 'after-hours' | 'not-a-fit' | 'auto'\nconst branchHint = 'booked';\nconst fixture = {\n  enquiry_id: 'ENQ-' + Math.floor(Math.random() * 90000 + 10000),\n  received_at: new Date().toISOString(),\n  contact: {\n    name: 'Mick O\\'Donnell',\n    phone: '+61400000444',\n    email: 'mick.odonnell@example.invalid',\n    marketing_consent: true\n  },\n  channel: 'whatsapp',\n  intent: 'book-a-quote',\n  payload: {\n    service_type: 'fence build',\n    suburb: 'Preston',\n    selected_slot: { startsAt: 'Wed 12:30 PM', durationMin: 30 },\n    after_hours: false\n  },\n  branch_hint: branchHint,\n  source: 'manual-test'\n};\nreturn [{ json: fixture }];"
      },
      "id": "f10-manual-fixture",
      "name": "Build Manual Fixture",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        340,
        420
      ]
    },
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "lgm-inbox-enquiry",
        "responseMode": "lastNode",
        "options": {}
      },
      "id": "f10-webhook",
      "name": "Inbox Webhook",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 1,
      "position": [
        120,
        240
      ],
      "webhookId": "lgm-inbox-enquiry"
    },
    {
      "parameters": {
        "jsCode": "// Classify + Gate — normalise the inbound inbox enquiry and apply the gates.\n// Responsibilities:\n//   1. Blank-input guard: no enquiry_id and no contact is acked + dropped.\n//   2. Opt-out gate: contacts with marketing_consent=false are acknowledged\n//      but never dispatched to booking or sequences.\n//   3. After-hours stub: real business-hours checks belong in\n//      docs/automation-runtime.md; the fixture carries an explicit flag.\n//      After-hours enquiries are captured + acknowledged, with a morning\n//      follow-up queued — never booked automatically.\n//   4. Intent → branch mapping (branch_hint overrides for manual runs).\n//   5. CRM contract: exactly one contact write per enquiry, keyed by email\n//      or phone, idempotent on enquiry_id.\nconst items = $input.all();\nconst raw = items[0].json || {};\nconst contact = raw.contact || {};\nconst payload = raw.payload || {};\n\nconst intentMap = {\n  'book-a-quote': 'booked',\n  'ask-a-question': 'human-handoff',\n  'speak-to-a-human': 'human-handoff',\n  'out-of-area': 'not-a-fit'\n};\nlet branch = raw.branch_hint && raw.branch_hint !== 'auto'\n  ? raw.branch_hint\n  : (intentMap[raw.intent] || 'booked');\n\nconst afterHours = payload.after_hours === true;\nif (afterHours && branch === 'booked') branch = 'after-hours';\n\nconst blankInput = !raw.enquiry_id || (!contact.email && !contact.phone);\nconst consentBlocked = contact.marketing_consent === false;\nconst checks = {\n  blank_input: blankInput ? 'BLOCKED' : 'ok',\n  opt_out: consentBlocked ? 'BLOCKED' : 'ok',\n  business_hours: afterHours ? 'after-hours' : 'open',\n  crm_write: 'pending'\n};\n\nif (blankInput) {\n  return [{\n    json: {\n      ...raw, contact, payload, checks,\n      branch: 'unhandled',\n      dispatch_status: 'blocked-blank-input',\n      simulatedOutputs: [\n        { type: 'heading', label: 'Stack — blank input' },\n        { type: 'note', label: 'Result',\n          text: 'Enquiry missing enquiry_id and contact. Acked and dropped — nothing was dispatched.' },\n        { type: 'code', label: 'Raw event', language: 'json', body: JSON.stringify(raw, null, 2) }\n      ]\n    }\n  }];\n}\n\nif (consentBlocked) {\n  return [{\n    json: {\n      ...raw, contact, payload, checks,\n      branch: 'unhandled',\n      dispatch_status: 'blocked-opt-out',\n      simulatedOutputs: [\n        { type: 'heading', label: 'Stack — opt-out gate' },\n        { type: 'note', label: 'Result',\n          text: 'Contact ' + (contact.name || '(unknown)') + ' has marketing_consent=false. Enquiry acknowledged but not dispatched to booking or sequences. Consent flag written to the CRM before any future send.' },\n        { type: 'code', label: 'Audit log', language: 'json', body: JSON.stringify(raw, null, 2) }\n      ]\n    }\n  }];\n}\n\nreturn [{\n  json: {\n    ...raw, contact, payload, checks,\n    crm_contract: {\n      upsert_key: contact.email || contact.phone,\n      idempotency_key: raw.enquiry_id,\n      stage: 'New',\n      source_channel: raw.channel\n    },\n    branch,\n    dispatch_status: 'ok'\n  }\n}];"
      },
      "id": "f10-gate",
      "name": "Classify + Gate",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        560,
        340
      ]
    },
    {
      "parameters": {
        "rules": {
          "values": [
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "loose"
                },
                "conditions": [
                  {
                    "leftValue": "={{ $json.branch }}",
                    "rightValue": "booked",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "outputKey": "booked",
              "renameOutput": true
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "loose"
                },
                "conditions": [
                  {
                    "leftValue": "={{ $json.branch }}",
                    "rightValue": "human-handoff",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "outputKey": "human-handoff",
              "renameOutput": true
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "loose"
                },
                "conditions": [
                  {
                    "leftValue": "={{ $json.branch }}",
                    "rightValue": "after-hours",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "outputKey": "after-hours",
              "renameOutput": true
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "loose"
                },
                "conditions": [
                  {
                    "leftValue": "={{ $json.branch }}",
                    "rightValue": "not-a-fit",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "outputKey": "not-a-fit",
              "renameOutput": true
            }
          ]
        },
        "options": {
          "fallbackOutput": "extra"
        }
      },
      "id": "f10-switch",
      "name": "Route by Branch",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3.2,
      "position": [
        780,
        420
      ]
    },
    {
      "parameters": {
        "jsCode": "// Build Booked Output — qualified enquiry with a customer-selected slot.\n// The slot round-trips from payload.selected_slot; the workflow never\n// invents times. Confirmation email goes from the business's own domain.\nconst items = $input.all();\nconst $json = items[0].json;\nconst contact = $json.contact || {};\nconst payload = $json.payload || {};\nconst slot = payload.selected_slot || null;\nconst service = payload.service_type || 'the job';\n\nconst simulatedOutputs = [\n  { type: 'heading', label: '1. Qualified & booked' },\n  { type: 'crm', rows: [\n    { label: 'Contact', value: contact.name || '(unknown)' },\n    { label: 'Source channel', value: $json.channel || 'unknown' },\n    { label: 'Service', value: service },\n    { label: 'Stage', value: 'Booked' }\n  ] }\n];\n\nif (slot) {\n  simulatedOutputs.push(\n    { type: 'message', channel: 'whatsapp',\n      body: 'Hi ' + (contact.name || 'there') + ', thanks for the enquiry — you\\'re booked in for ' + slot.startsAt + '. A confirmation email is on its way. — The team' },\n    { type: 'calendar',\n      summary: 'Quote visit — ' + service + ' — ' + (contact.name || 'customer'),\n      startsAt: slot.startsAt,\n      durationMin: slot.durationMin,\n      attendees: [contact.name || 'customer', 'Owner'] },\n    { type: 'message', channel: 'email',\n      subject: 'Booking confirmed — ' + slot.startsAt,\n      body: 'Hi ' + (contact.name || 'there') + ',\\n\\nYour quote visit is locked in for ' + slot.startsAt + \". You'll get a short reminder the day before.\\n\\n— The team\" },\n    { type: 'code', label: 'Simulated email dispatch (Listmonk)', language: 'json',\n      body: JSON.stringify({\n        method: 'POST',\n        url: 'https://mail.example.invalid/api/tx',\n        body: { subscriber_email: contact.email, template: 'booking-confirmation', slot },\n        idempotency_key: $json.enquiry_id\n      }, null, 2) }\n  );\n} else {\n  simulatedOutputs.push(\n    { type: 'message', channel: 'whatsapp',\n      body: 'Hi ' + (contact.name || 'there') + ', thanks for getting in touch. We can come and take a look — reply with a slot number and we\\'ll lock it in. — The team' },\n    { type: 'note', label: 'Awaiting customer slot selection',\n      text: 'No slot picked yet — the thread stays open in the shared inbox and the cadence pauses until the customer replies. No calendar entry is created.' }\n  );\n}\n\nreturn [{ json: { ...$json, branch: 'booked', simulatedOutputs } }];"
      },
      "id": "f10-booked",
      "name": "Build Booked Output",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1040,
        80
      ]
    },
    {
      "parameters": {
        "jsCode": "// Build Handoff Output — the customer asked for a human. The conversation\n// is assigned to a person inside the SAME shared-inbox thread — history and\n// context intact, nobody asks the customer to repeat themselves.\nconst items = $input.all();\nconst $json = items[0].json;\nconst contact = $json.contact || {};\nconst simulatedOutputs = [\n  { type: 'heading', label: '1. Hands to a human' },\n  { type: 'message', channel: 'chat',\n    body: 'Hi ' + (contact.name || 'there') + ', no problem — I\\'ll get one of the team to pick this up. They can see our whole conversation, so you won\\'t need to repeat anything.' },\n  { type: 'message', channel: 'internal',\n    body: 'Conversation assigned to the team in the shared inbox — thread, history and context intact. Customer: ' + (contact.name || '(unknown)') + '. Channel: ' + ($json.channel || 'unknown') + '.' },\n  { type: 'task', title: 'Reply in the shared inbox thread', owner: 'Team', dueIn: 'Within 15 minutes during business hours' },\n  { type: 'code', label: 'Simulated inbox assignment', language: 'json',\n    body: JSON.stringify({\n      method: 'POST',\n      url: 'https://inbox.example.invalid/api/v1/conversations/' + ($json.enquiry_id || 'unknown') + '/assign',\n      body: { assignee: 'team-round-robin', preserve_thread: true },\n      idempotency_key: $json.enquiry_id\n    }, null, 2) }\n];\nreturn [{ json: { ...$json, branch: 'human-handoff', simulatedOutputs } }];"
      },
      "id": "f10-handoff",
      "name": "Build Handoff Output",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1040,
        240
      ]
    },
    {
      "parameters": {
        "jsCode": "// Build After-Hours Output — office closed: acknowledge now, queue the\n// follow-up for the morning. Never books automatically after hours.\nconst items = $input.all();\nconst $json = items[0].json;\nconst contact = $json.contact || {};\nconst channel = $json.channel === 'whatsapp' ? 'whatsapp' : 'sms';\nconst simulatedOutputs = [\n  { type: 'heading', label: '1. After-hours capture' },\n  { type: 'message', channel,\n    body: 'Hi ' + (contact.name || 'there') + ', thanks for the message — we\\'ve received it and someone from the team will come back to you first thing in the morning. If it\\'s urgent, call our after-hours line. — The team' },\n  { type: 'task', title: 'Morning follow-up on overnight enquiry', owner: 'Team', dueIn: 'Start of next business day',\n    notes: (contact.name || 'Customer') + ' via ' + ($json.channel || 'unknown') + ': ' + ($json.intent || 'unknown intent') + '.' }\n];\nreturn [{ json: { ...$json, branch: 'after-hours', simulatedOutputs } }];"
      },
      "id": "f10-afterhours",
      "name": "Build After-Hours Output",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1040,
        400
      ]
    },
    {
      "parameters": {
        "jsCode": "// Build Not-A-Fit Output — out of area or out of scope: one courteous\n// reply, a CRM tag, no sequence. Don't promise what the business can't\n// deliver well.\nconst items = $input.all();\nconst $json = items[0].json;\nconst contact = $json.contact || {};\nconst simulatedOutputs = [\n  { type: 'heading', label: '1. Not a fit — graceful referral' },\n  { type: 'message', channel: 'whatsapp',\n    body: 'Hi ' + (contact.name || 'there') + ', thanks for reaching out. This one\\'s a bit outside our area — reply here and we\\'ll share a couple of options we trust. — The team' },\n  { type: 'note', label: 'Tagged not-a-fit',\n    text: 'CRM tag written with the reason. One courtesy follow-up only — no sequence fires for this contact.' },\n  { type: 'code', label: 'Simulated CRM write', language: 'json',\n    body: JSON.stringify({\n      method: 'PATCH',\n      url: 'https://crm.example.invalid/contacts/' + ($json.crm_contract?.upsert_key || 'unknown'),\n      body: { tag: 'not-a-fit', reason: $json.intent, source_channel: $json.channel },\n      idempotency_key: $json.enquiry_id\n    }, null, 2) }\n];\nreturn [{ json: { ...$json, branch: 'not-a-fit', simulatedOutputs } }];"
      },
      "id": "f10-notafit",
      "name": "Build Not-A-Fit Output",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1040,
        560
      ]
    },
    {
      "parameters": {
        "jsCode": "// Catch-all for unhandled branches (including the blank-input and opt-out\n// acks from Classify + Gate, which arrive with branch 'unhandled').\nconst items = $input.all();\nconst $json = items[0].json;\nconst simulatedOutputs = [\n  { type: 'heading', label: '1. Stack — unhandled branch' },\n  { type: 'note', label: 'Ack',\n    text: 'Unhandled branch \"' + $json.branch + '\"; logged for human review. Returning 200 to prevent retry storms.' },\n  { type: 'code', label: 'Raw event', language: 'json', body: JSON.stringify($json, null, 2) }\n];\nreturn [{ json: { ...$json, branch: 'unhandled', simulatedOutputs } }];"
      },
      "id": "f10-unhandled",
      "name": "Build Unhandled Output",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1040,
        720
      ]
    },
    {
      "parameters": {},
      "id": "f10-sink",
      "name": "Sink",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        1300,
        420
      ]
    }
  ],
  "connections": {
    "Start": {
      "main": [
        [
          {
            "node": "Build Manual Fixture",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Manual Fixture": {
      "main": [
        [
          {
            "node": "Classify + Gate",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Inbox Webhook": {
      "main": [
        [
          {
            "node": "Classify + Gate",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Classify + Gate": {
      "main": [
        [
          {
            "node": "Route by Branch",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Route by Branch": {
      "main": [
        [
          {
            "node": "Build Booked Output",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Build Handoff Output",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Build After-Hours Output",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Build Not-A-Fit Output",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Build Unhandled Output",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Booked Output": {
      "main": [
        [
          {
            "node": "Sink",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Handoff Output": {
      "main": [
        [
          {
            "node": "Sink",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build After-Hours Output": {
      "main": [
        [
          {
            "node": "Sink",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Not-A-Fit Output": {
      "main": [
        [
          {
            "node": "Sink",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Unhandled Output": {
      "main": [
        [
          {
            "node": "Sink",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "active": false,
  "settings": {
    "executionOrder": "v1",
    "callerPolicy": "workflowsFromSameOwner"
  },
  "versionId": "lgm-10-own-infra-stack",
  "id": "lgm-10-own-infra-stack",
  "meta": {
    "templateCredsSetupCompleted": false,
    "description": "Own-infra growth stack — one enquiry, every tool. Manual + Webhook entrypoints → blank/opt-out/after-hours gates → branch by intent: booked (real slot round-trips, own-domain email), inbox human-handoff (same thread), after-hours capture, not-a-fit referral. Every dispatch block is SIMULATED — no live inbox, CRM, booking or email wired."
  },
  "tags": [
    {
      "name": "lgm-10-own-infra-stack"
    }
  ]
}
