{
  "name": "Appointment Reminder & No-Show Rescue Engine",
  "active": false,
  "settings": {
    "executionOrder": "v1",
    "saveDataErrorExecution": "all",
    "saveDataSuccessExecution": "all",
    "saveManualExecutions": true
  },
  "nodes": [
    {
      "name": "Run Sweep Now",
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "position": [
        -1180,
        280
      ],
      "parameters": {},
      "id": "52dc5659-47ee-4a31-9d7e-f5ea14cb705e"
    },
    {
      "name": "Every 15 Minutes",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        -1180,
        460
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "minutes",
              "minutesInterval": 15
            }
          ]
        }
      },
      "id": "400de2ea-f0d8-4228-888f-e5d480235321"
    },
    {
      "name": "Init Reminder Run",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -960,
        370
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "/**\n * NODE: \"Init Reminder Run\"  (Code node, Run Once for All Items)\n * -------------------------------------------------------------\n * Configuration for one 15-minute sweep. Everything the engine decides is driven\n * from here, so the logic nodes have no per-client settings buried in them.\n */\n\nconst CONFIG = {\n  mockMode: true,               // run with fixtures, no credentials\n  simulateDelivery: true,       // never actually send while this is true\n  locationId: 'REPLACE_WITH_LOCATION_ID',\n\n  // The fixture calendar in \"Mock Appointments\" is written against a fixed\n  // instant: apt_001 is 23.5 hours out so its 24-hour touch is due, apt_002 is\n  // three hours out, and the quiet-hours cases depend on what o'clock it is\n  // where the customer lives. Evaluated against the wall clock those\n  // relationships survive for about an hour, after which the demo plans nothing\n  // and looks broken. While mockMode is on, the sweep is therefore evaluated at\n  // this instant instead of \"now\" \u2014 mock calendar, mock clock \u2014 so the demo\n  // shows the same sweep on any day, and matches samples/expected-output.json.\n  // Ignored the moment mockMode is false: a live sweep always uses the real\n  // clock. Set to null to run the fixtures against real time.\n  mockClockIso: '2026-08-15T10:00:00.000Z',\n\n  // The business's own timezone. Used only as a last resort \u2014 a reminder should\n  // be timed to the customer's clock, not the office's.\n  businessTimeZone: 'America/Los_Angeles',\n\n  // Local-time window in which the customer may be contacted. 21:00-08:00 is\n  // quiet. A send that lands inside it is deferred, not dropped, unless the\n  // deferral would push it past the appointment itself.\n  quietHours: { startHour: 21, endHour: 8 },\n\n  // The sweep runs every 15 minutes; this is how far back it will look for a\n  // touch it should have sent. Generous enough to survive a paused workflow,\n  // tight enough that a 24-hour reminder never goes out 6 hours late.\n  dueWindowMinutes: 90,\n\n  // Reminder ladder, relative to appointment start. Negative = before.\n  reminderTouches: [\n    { key: 't-24h', offsetMinutes: -1440, channels: ['email', 'sms'], template: 'reminder_24h' },\n    { key: 't-3h', offsetMinutes: -180, channels: ['sms'], template: 'reminder_3h' },\n    { key: 't-30m', offsetMinutes: -30, channels: ['sms'], template: 'reminder_30m' },\n  ],\n\n  // No-show rescue, relative to appointment end. Cancels itself the moment the\n  // customer rebooks.\n  noShowTouches: [\n    { key: 'noshow-15m', offsetMinutes: 15, channels: ['sms'], template: 'noshow_immediate' },\n    { key: 'noshow-1d', offsetMinutes: 1440, channels: ['email'], template: 'noshow_day1' },\n    { key: 'noshow-3d', offsetMinutes: 4320, channels: ['email', 'sms'], template: 'noshow_day3' },\n  ],\n\n  // A touch whose send time had already passed when the customer booked is\n  // pointless and looks broken. Someone who books 20 minutes out should not get\n  // a \"your appointment is tomorrow\" text.\n  suppressTouchesEarlierThanBooking: true,\n\n  // Statuses that stop every remaining touch immediately.\n  terminalStatuses: ['cancelled', 'canceled', 'completed', 'rescheduled'],\n};\n\n/**\n * Idempotency ledger. In production this comes from wherever you record sends \u2014\n * a contact custom field, a Data Store, a Postgres table. The shape is a flat\n * list of keys: \"<appointmentId>:<touchKey>:<channel>\".\n *\n * It is passed in rather than looked up inside the planner so the planner stays\n * a pure function and can be tested.\n */\nconst ALREADY_SENT = [\n  'apt_009:t-24h:email',\n  'apt_009:t-24h:sms',\n];\n\n/** The instant this sweep is evaluated at. Real time, unless the fixtures are\n *  driving and a mock clock has been pinned (see CONFIG.mockClockIso). */\nfunction resolveNowMs(config) {\n  if (config.mockMode === true && config.mockClockIso) {\n    const pinned = Date.parse(config.mockClockIso);\n    if (!Number.isNaN(pinned)) return pinned;\n  }\n  return Date.now();\n}\n\nfunction processAll(items) {\n  const nowMs = resolveNowMs(CONFIG);\n  const nowIso = new Date(nowMs).toISOString();\n  const startedAtIso = new Date().toISOString();\n  return [{\n    json: {\n      config: CONFIG,\n      alreadySent: ALREADY_SENT,\n      run: {\n        runId: 'rem_' + nowIso.replace(/[-:.TZ]/g, '').slice(0, 14),\n        startedAt: nowIso,\n        // Wall-clock time the sweep actually ran. Differs from startedAt only\n        // when the mock clock is pinned; useful when reading an execution back.\n        executedAt: startedAtIso,\n        // Every downstream node times itself off this, so one sweep cannot\n        // straddle two instants.\n        nowMs,\n        clock: CONFIG.mockMode === true && CONFIG.mockClockIso ? 'pinned' : 'live',\n      },\n      useLive: CONFIG.mockMode !== true,\n      retry: { attempt: 0, maxAttempts: 5, waitSeconds: 0, history: [] },\n    },\n    pairedItem: items.length ? 0 : undefined,\n  }];\n}\n\n// --- exported for the local test harness; n8n ignores this block -----------\nif (typeof module !== 'undefined' && module.exports) {\n  module.exports = { CONFIG, ALREADY_SENT, resolveNowMs, processAll };\n}\n\n// --- n8n entry point -------------------------------------------------------\nif (typeof $input !== 'undefined') {\n  return processAll($input.all());\n}"
      },
      "id": "48831c1a-1afc-4c92-8e38-21dfdea1a515"
    },
    {
      "name": "Use Live Calendar?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        -740,
        370
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "use-live",
              "leftValue": "={{ $json.useLive }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "looseTypeValidation": true,
        "options": {}
      },
      "id": "8312e218-99d0-41c3-a649-44af6a1b70bc"
    },
    {
      "name": "CRM: Fetch Appointments",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        -500,
        240
      ],
      "parameters": {
        "method": "GET",
        "url": "https://services.leadconnectorhq.com/calendars/events",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendQuery": true,
        "specifyQuery": "keypair",
        "queryParameters": {
          "parameters": [
            {
              "name": "locationId",
              "value": "={{ $('Init Reminder Run').item.json.config.locationId }}"
            },
            {
              "name": "startTime",
              "value": "={{ new Date(Date.now() - 5 * 86400000).toISOString() }}"
            },
            {
              "name": "endTime",
              "value": "={{ new Date(Date.now() + 3 * 86400000).toISOString() }}"
            }
          ]
        },
        "sendHeaders": true,
        "specifyHeaders": "keypair",
        "headerParameters": {
          "parameters": [
            {
              "name": "Version",
              "value": "2021-04-15"
            },
            {
              "name": "Accept",
              "value": "application/json"
            }
          ]
        },
        "options": {
          "timeout": 20000,
          "response": {
            "response": {
              "fullResponse": true,
              "neverError": true
            }
          }
        }
      },
      "id": "3a7c9421-7e37-4ebc-8f0d-98a7be265660"
    },
    {
      "name": "Mock Appointments",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -500,
        500
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "/**\n * NODE: \"Mock Appointments\"  (Code node, Run Once for All Items)\n * -------------------------------------------------------------\n * Fixture calendar, in the same shape the live endpoint returns. Every record\n * exists to exercise one decision the planner has to get right:\n *\n *   apt_001  quiet hours \u2014 3am for the customer, must be deferred not sent\n *   apt_002  ordinary 3-hour reminder in a sensible local time\n *   apt_003  30-minute reminder, different timezone again\n *   apt_004  no-show, first rescue touch due\n *   apt_005  no-show but already rebooked \u2014 the whole sequence must cancel\n *   apt_006  cancelled \u2014 every remaining touch must stop\n *   apt_007  booked 10 minutes ago; the 24-hour reminder is in the past\n *   apt_008  do-not-disturb contact\n *   apt_009  24-hour touch already recorded as sent \u2014 must not repeat\n *   apt_010  evening local time, allowed\n *   apt_011  no phone number, must fall back to email\n *   apt_012  emoji in the service name \u2014 turns the SMS into UCS-2\n *   apt_013  quiet hours where deferring would miss the appointment \u2014 must drop\n *   apt_014  no contact timezone at all; must be inferred from the phone\n *\n * Times are absolute, and they only mean anything relative to one instant:\n * 2026-08-15T10:00:00Z. apt_001 is 23.5 hours out so its 24-hour touch is due;\n * apt_002 is three hours out; the quiet-hours cases (apt_001, apt_013) depend\n * on what o'clock it is where that customer lives, which no amount of shifting\n * the calendar forward can preserve. So the clock is pinned to that instant\n * instead \u2014 see CONFIG.mockClockIso in \"Init Reminder Run\". test/run.js freezes\n * Date to the same instant, which is why the two agree.\n */\n\nconst MOCK_APPOINTMENTS = [\n  { id: 'apt_001', status: 'confirmed', startAt: '2026-08-16T09:30:00Z', endAt: '2026-08-16T10:30:00Z',\n    createdAt: '2026-08-01T12:00:00Z', service: 'Annual check-up', staff: 'Dr. Alvarez',\n    contact: { id: 'ct_001', firstName: 'Robert', lastName: 'Chen', phone: '+14155550143',\n      email: 'rob.chen@northsidehvac.example.com', timeZone: 'America/Los_Angeles',\n      smsConsent: true, emailConsent: true, dnd: false } },\n\n  { id: 'apt_002', status: 'confirmed', startAt: '2026-08-15T13:00:00Z', endAt: '2026-08-15T13:45:00Z',\n    createdAt: '2026-08-10T09:00:00Z', service: 'Boiler service', staff: 'Marek',\n    contact: { id: 'ct_002', firstName: 'Sarah', lastName: 'Okonkwo', phone: '+447700900461',\n      email: 'sarah.okonkwo@gmail.com', timeZone: 'Europe/London',\n      smsConsent: true, emailConsent: true, dnd: false } },\n\n  { id: 'apt_003', status: 'confirmed', startAt: '2026-08-15T10:25:00Z', endAt: '2026-08-15T11:00:00Z',\n    createdAt: '2026-08-12T06:00:00Z', service: 'Dental cleaning', staff: 'Dr. Menon',\n    contact: { id: 'ct_003', firstName: 'Priya', lastName: 'Venkatesan', phone: '+919845012345',\n      email: 'priya.v@brightpathdental.example.com', timeZone: 'Asia/Kolkata',\n      smsConsent: true, emailConsent: true, dnd: false } },\n\n  { id: 'apt_004', status: 'no-show', startAt: '2026-08-15T09:00:00Z', endAt: '2026-08-15T09:30:00Z',\n    createdAt: '2026-08-09T04:00:00Z', service: 'Consultation', staff: 'Ibrahim',\n    contact: { id: 'ct_004', firstName: 'Aminath', lastName: 'Rasheed', phone: '+9607712345',\n      email: 'aminath.r@example.com', timeZone: 'Indian/Maldives',\n      smsConsent: true, emailConsent: true, dnd: false } },\n\n  { id: 'apt_005', status: 'no-show', startAt: '2026-08-14T09:00:00Z', endAt: '2026-08-14T09:30:00Z',\n    createdAt: '2026-08-01T04:00:00Z', service: 'Consultation', staff: 'Ibrahim',\n    rebookedAppointmentId: 'apt_020',\n    contact: { id: 'ct_005', firstName: 'Daniel', lastName: 'Okafor', phone: '+2348012345678',\n      email: 'daniel.okafor@example.com', timeZone: 'Africa/Lagos',\n      smsConsent: true, emailConsent: true, dnd: false } },\n\n  { id: 'apt_006', status: 'cancelled', startAt: '2026-08-16T09:30:00Z', endAt: '2026-08-16T10:00:00Z',\n    createdAt: '2026-08-02T12:00:00Z', service: 'Follow-up', staff: 'Dr. Alvarez',\n    contact: { id: 'ct_006', firstName: 'Karen', lastName: 'Whitfield', phone: '+14155550233',\n      email: 'karen.whitfield@example.com', timeZone: 'America/Los_Angeles',\n      smsConsent: true, emailConsent: true, dnd: false } },\n\n  { id: 'apt_007', status: 'confirmed', startAt: '2026-08-16T06:00:00Z', endAt: '2026-08-16T06:30:00Z',\n    createdAt: '2026-08-15T09:50:00Z', service: 'Same-day quote', staff: 'Marek',\n    contact: { id: 'ct_007', firstName: 'Dana', lastName: 'Reyes', phone: '+14155550177',\n      email: '', timeZone: 'America/New_York',\n      smsConsent: true, emailConsent: false, dnd: false } },\n\n  { id: 'apt_008', status: 'confirmed', startAt: '2026-08-15T13:00:00Z', endAt: '2026-08-15T13:30:00Z',\n    createdAt: '2026-08-05T10:00:00Z', service: 'Physio', staff: 'Lena',\n    contact: { id: 'ct_008', firstName: 'Harold', lastName: 'Beckett', phone: '+14155550244',\n      email: 'h.beckett@example.org', timeZone: 'America/Los_Angeles',\n      smsConsent: true, emailConsent: true, dnd: true } },\n\n  { id: 'apt_009', status: 'confirmed', startAt: '2026-08-16T09:30:00Z', endAt: '2026-08-16T10:00:00Z',\n    createdAt: '2026-08-03T12:00:00Z', service: 'Skin consult', staff: 'Dr. Alvarez',\n    contact: { id: 'ct_009', firstName: 'Jennifer', lastName: 'Nakamura', phone: '+61412345678',\n      email: 'jen.nakamura@brightpath.example.com', timeZone: 'Australia/Sydney',\n      smsConsent: true, emailConsent: true, dnd: false } },\n\n  { id: 'apt_010', status: 'confirmed', startAt: '2026-08-16T09:00:00Z', endAt: '2026-08-16T09:30:00Z',\n    createdAt: '2026-08-04T12:00:00Z', service: 'Massage', staff: 'Lena',\n    contact: { id: 'ct_010', firstName: 'Lucas', lastName: 'van der Berg', phone: '+61412345699',\n      email: 'lucas@vanderberg-bouw.example.com', timeZone: 'Australia/Sydney',\n      smsConsent: true, emailConsent: true, dnd: false } },\n\n  { id: 'apt_011', status: 'confirmed', startAt: '2026-08-16T09:30:00Z', endAt: '2026-08-16T10:00:00Z',\n    createdAt: '2026-08-06T12:00:00Z', service: 'Consultation', staff: 'Dr. Menon',\n    contact: { id: 'ct_011', firstName: '', lastName: 'Petrova', phone: '',\n      email: 'anna.petrova@example.net', timeZone: 'Europe/London',\n      smsConsent: false, emailConsent: true, dnd: false } },\n\n  { id: 'apt_012', status: 'confirmed', startAt: '2026-08-15T13:00:00Z', endAt: '2026-08-15T14:00:00Z',\n    createdAt: '2026-08-07T12:00:00Z', service: 'Spa day \ud83d\udc86 \u2014 full package',\n    staff: 'Lena',\n    contact: { id: 'ct_012', firstName: 'Michael', lastName: 'Johnson', phone: '+14155550188',\n      email: 'mike.johnson@example.com', timeZone: 'Europe/London',\n      smsConsent: true, emailConsent: true, dnd: false } },\n\n  { id: 'apt_013', status: 'confirmed', startAt: '2026-08-15T12:30:00Z', endAt: '2026-08-15T13:00:00Z',\n    createdAt: '2026-08-08T12:00:00Z', service: 'Early appointment', staff: 'Dr. Alvarez',\n    contact: { id: 'ct_013', firstName: 'Igor', lastName: 'Petrov', phone: '+14155550211',\n      email: 'igor.petrov@example.net', timeZone: 'America/Los_Angeles',\n      smsConsent: true, emailConsent: false, dnd: false } },\n\n  { id: 'apt_014', status: 'confirmed', startAt: '2026-08-16T09:30:00Z', endAt: '2026-08-16T10:00:00Z',\n    createdAt: '2026-08-09T12:00:00Z', service: 'Site visit', staff: 'Marek',\n    contact: { id: 'ct_014', firstName: 'Tomas', lastName: 'Kowalski', phone: '+447700900123',\n      email: 'tomas.k@example.com', timeZone: '',\n      smsConsent: true, emailConsent: true, dnd: false } },\n];\n\nfunction processAll(items) {\n  return items.map((item, index) => ({\n    json: {\n      ...item.json,\n      response: {\n        appointments: MOCK_APPOINTMENTS,\n        meta: { total: MOCK_APPOINTMENTS.length, source: 'MOCK_MODE' },\n      },\n    },\n    pairedItem: index,\n  }));\n}\n\n// --- exported for the local test harness; n8n ignores this block -----------\nif (typeof module !== 'undefined' && module.exports) {\n  module.exports = { MOCK_APPOINTMENTS, processAll };\n}\n\n// --- n8n entry point -------------------------------------------------------\nif (typeof $input !== 'undefined') {\n  return processAll($input.all());\n}"
      },
      "id": "a481c6cd-3e16-419b-b3ca-b04e6f1748b6"
    },
    {
      "name": "Classify Fetch Error",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -260,
        40
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "/**\n * NODE: \"Classify API Error\"  (Code node, Run Once for All Items)\n * --------------------------------------------------------------\n * Wired to the non-2xx branch of the calendar fetch. Identical to the file in\n * demos 01 and 02 apart from this header \u2014 the same three-way decision applies to any API.\n * Turns a raw failure into a decision: retry after N seconds, alert a human, or\n * give up and queue the sweep for manual handling \u2014 never \"fail silently\" and\n * never \"retry forever\".\n *\n * Why this is a Code node and not the HTTP node's built-in retry:\n *\n *   n8n's node-level \"Retry On Fail\" is ignored when \"On Error\" is set to either\n *   Continue option \u2014 the node continues down the error branch on the first\n *   failure instead of retrying (n8n issue #10763). So if you want retries *and*\n *   a graceful error path, you have to own the retry loop yourself. This node\n *   plus a Wait node is that loop.\n *\n * It also does the thing most retry code skips: it reads Retry-After. A 429 that\n * says \"wait 30 seconds\" and gets retried in 2 seconds is not a retry, it is a\n * second offence, and providers extend the block for it.\n */\n\nconst RETRY = {\n  maxAttempts: 5,\n  baseDelaySeconds: 2,     // 2, 4, 8, 16, 32 before jitter\n  maxDelaySeconds: 120,\n  jitterRatio: 0.3,        // \u00b130%, so parallel executions do not retry in lockstep\n};\n\n// Transport-level failures worth retrying \u2014 the request never reached the app.\nconst RETRYABLE_NETWORK_CODES = [\n  'ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'ENOTFOUND', 'EAI_AGAIN',\n  'EPIPE', 'ESOCKETTIMEDOUT', 'ERR_SOCKET_CONNECTION_TIMEOUT',\n];\n\nconst RETRYABLE_STATUSES = [408, 425, 429, 500, 502, 503, 504, 522, 524];\n\n/**\n * n8n wraps HTTP failures differently depending on node version, whether the\n * response was JSON, and whether the failure was transport or application level.\n * Dig the useful bits out of all of them rather than assuming one shape.\n */\nfunction extractError(json) {\n  // Preferred shape: the HTTP node is configured Full Response + Never Error, so\n  // a 429 arrives as an ordinary item with statusCode, headers and body intact.\n  // That is the only configuration in which Retry-After is readable at all \u2014 on\n  // the error output, n8n hands you the error, not the response envelope.\n  const fullResponse = typeof json.statusCode === 'number' && json.headers !== undefined;\n  const container = fullResponse ? json : (json.error || json);\n\n  const status = Number(\n    container.statusCode ?? container.status ?? container.httpCode ??\n    (container.response && (container.response.status ?? container.response.statusCode)) ??\n    (container.context && container.context.statusCode) ??\n    (typeof container.code === 'number' ? container.code : undefined) ?? 0\n  ) || 0;\n\n  const headers =\n    container.headers ||\n    (container.response && container.response.headers) ||\n    (container.context && container.context.headers) || {};\n\n  const body =\n    (fullResponse ? container.body : undefined) ??\n    (container.response && (container.response.body ?? container.response.data)) ??\n    container.body ?? container.data ?? null;\n\n  const networkCode = typeof container.code === 'string' ? container.code\n    : typeof container.errno === 'string' ? container.errno : '';\n\n  const message = String(\n    container.message || container.description ||\n    (body && (body.message || body.error || body.msg)) ||\n    'Unknown error'\n  ).slice(0, 500);\n\n  return { status, headers, body, networkCode, message };\n}\n\n/** Header lookup that does not care about casing, because proxies do not either. */\nfunction header(headers, name) {\n  if (!headers) return undefined;\n  const target = name.toLowerCase();\n  for (const [key, value] of Object.entries(headers)) {\n    if (String(key).toLowerCase() === target) return value;\n  }\n  return undefined;\n}\n\n/**\n * Retry-After is legal in two forms: delay-seconds (\"120\") and an HTTP-date\n * (\"Wed, 21 Oct 2026 07:28:00 GMT\"). Both appear in the wild. Also handles the\n * x-ratelimit-reset variants, which some APIs give as a unix timestamp.\n */\nfunction retryAfterSeconds(headers, nowMs) {\n  const raw = header(headers, 'retry-after');\n  if (raw !== undefined && raw !== null && String(raw).trim() !== '') {\n    const text = String(raw).trim();\n    if (/^\\d+$/.test(text)) return Number(text);\n    const date = new Date(text);\n    if (!isNaN(date.getTime())) {\n      return Math.max(0, Math.ceil((date.getTime() - nowMs) / 1000));\n    }\n  }\n\n  const reset = header(headers, 'x-ratelimit-reset') ?? header(headers, 'ratelimit-reset');\n  if (reset !== undefined && /^\\d+$/.test(String(reset))) {\n    const value = Number(reset);\n    // Ten digits or more is an absolute unix timestamp; anything less is a delta.\n    const seconds = String(value).length >= 10\n      ? Math.ceil((value * 1000 - nowMs) / 1000)\n      : value;\n    if (seconds > 0) return seconds;\n  }\n\n  return null;\n}\n\nfunction backoffSeconds(attempt) {\n  const exponential = RETRY.baseDelaySeconds * Math.pow(2, Math.max(0, attempt - 1));\n  const capped = Math.min(exponential, RETRY.maxDelaySeconds);\n  const jitter = capped * RETRY.jitterRatio * (Math.random() * 2 - 1);\n  return Math.max(1, Math.round(capped + jitter));\n}\n\n/**\n * Returns a decision object. `disposition` is the only field the IF nodes read:\n *   'retry'      -> Wait node, then back to the HTTP node\n *   'alert'      -> notify a human immediately; the whole integration is down\n *   'quarantine' -> this one lead is bad; park it, keep processing the rest\n */\nfunction classify(json, nowMs, runIndex) {\n  const state = json.retry || { attempt: 0, maxAttempts: RETRY.maxAttempts, history: [] };\n\n  // Attempt counting has to survive the item losing its state: when an HTTP node\n  // fails, what arrives on the error output is the error, not necessarily the\n  // item that went in. n8n's $runIndex counts how many times this node has run\n  // inside the current loop, so it is the reliable floor for \"which attempt is\n  // this\". Without it, a retry loop that loses state retries forever.\n  const floor = Number.isFinite(Number(runIndex)) ? Number(runIndex) : 0;\n  const attempt = Math.max(Number(state.attempt || 0), floor) + 1;\n  const maxAttempts = Number(state.maxAttempts || RETRY.maxAttempts);\n\n  const error = extractError(json);\n  const { status, networkCode } = error;\n\n  let category;\n  let disposition;\n  let operatorMessage;\n  let retryable = false;\n\n  if (status === 401 || status === 403) {\n    category = 'auth';\n    disposition = 'alert';\n    operatorMessage = status === 401\n      ? 'CRM rejected the credentials (401). The token has expired or been revoked \u2014 reconnect the credential in n8n. Nothing will sync until this is fixed.'\n      : 'CRM accepted the credentials but refused the action (403). The connected user is missing a scope or the location ID is wrong.';\n  } else if (status === 429) {\n    category = 'rate_limit';\n    retryable = true;\n    operatorMessage = 'Rate limited by the CRM. Backing off and retrying.';\n  } else if (status === 422 || status === 400) {\n    category = 'validation';\n    disposition = 'quarantine';\n    operatorMessage = 'The CRM rejected this payload (' + status + '). This is a data problem with one lead, not an outage \u2014 parked for review. Detail: ' + error.message;\n  } else if (status === 404) {\n    category = 'not_found';\n    disposition = 'quarantine';\n    operatorMessage = 'Endpoint or record not found (404). Check the URL and the location/subaccount ID.';\n  } else if (status === 409) {\n    category = 'conflict';\n    disposition = 'quarantine';\n    operatorMessage = 'Conflict (409) \u2014 the CRM believes this contact already exists. Re-run the search rather than retrying the write.';\n  } else if (RETRYABLE_STATUSES.includes(status)) {\n    category = 'server';\n    retryable = true;\n    operatorMessage = 'CRM returned ' + status + '. Transient server-side failure, retrying.';\n  } else if (RETRYABLE_NETWORK_CODES.includes(networkCode)) {\n    category = 'network';\n    retryable = true;\n    operatorMessage = 'Network failure (' + networkCode + ') before the CRM answered. Retrying.';\n  } else if (status >= 400 && status < 500) {\n    category = 'client';\n    disposition = 'quarantine';\n    operatorMessage = 'CRM returned ' + status + '. Not retryable \u2014 the request itself is wrong.';\n  } else {\n    category = 'unknown';\n    retryable = true;\n    operatorMessage = 'Unclassified failure: ' + error.message + '. Treating as transient.';\n  }\n\n  let waitSeconds = 0;\n  if (retryable) {\n    if (attempt >= maxAttempts) {\n      disposition = 'quarantine';\n      operatorMessage = 'Gave up after ' + maxAttempts + ' attempts. Last failure: ' + operatorMessage;\n    } else {\n      disposition = 'retry';\n      // Honour the server's own instruction when it gives one; it knows better\n      // than our exponential curve does.\n      const serverHint = retryAfterSeconds(error.headers, nowMs);\n      waitSeconds = serverHint !== null\n        ? Math.min(serverHint, RETRY.maxDelaySeconds)\n        : backoffSeconds(attempt);\n      if (serverHint !== null) {\n        operatorMessage += ' Server asked for ' + serverHint + 's, honouring it.';\n      }\n    }\n  }\n\n  const history = Array.isArray(state.history) ? state.history.slice(-9) : [];\n  history.push({\n    attempt,\n    at: new Date(nowMs).toISOString(),\n    status,\n    category,\n    waitSeconds,\n  });\n\n  return {\n    ...json,\n    retry: { attempt, maxAttempts, waitSeconds, history },\n    failure: {\n      category,\n      disposition,\n      status,\n      networkCode,\n      message: error.message,\n      operatorMessage,\n      // Body is capped: a 500 from a CRM sometimes returns an entire HTML error\n      // page, and stuffing that into every execution log is how you fill a disk.\n      responseSnippet: error.body\n        ? String(typeof error.body === 'string' ? error.body : JSON.stringify(error.body)).slice(0, 600)\n        : null,\n    },\n    shouldRetry: disposition === 'retry',\n    shouldAlert: disposition === 'alert',\n    isQuarantined: disposition === 'quarantine',\n  };\n}\n\nfunction processAll(items, runIndex) {\n  const nowMs = Date.now();\n  return items.map((item, index) => ({\n    json: classify(item.json || {}, nowMs, runIndex || 0),\n    pairedItem: index,\n  }));\n}\n\n// --- exported for the local test harness; n8n ignores this block -----------\nif (typeof module !== 'undefined' && module.exports) {\n  module.exports = {\n    RETRY, extractError, header, retryAfterSeconds, backoffSeconds, classify, processAll,\n  };\n}\n\n// --- n8n entry point -------------------------------------------------------\nif (typeof $input !== 'undefined') {\n  return processAll($input.all(), typeof $runIndex !== 'undefined' ? $runIndex : 0);\n}"
      },
      "id": "98333b0c-71ab-4e30-bfaf-cad5fc083b8b"
    },
    {
      "name": "Retry Fetch?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        -40,
        40
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "should-retry",
              "leftValue": "={{ $json.shouldRetry }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "looseTypeValidation": true,
        "options": {}
      },
      "id": "b966eb6b-96eb-4919-8713-da40ce38647f"
    },
    {
      "name": "Backoff Wait",
      "type": "n8n-nodes-base.wait",
      "typeVersion": 1.1,
      "position": [
        -40,
        -160
      ],
      "parameters": {
        "amount": "={{ $json.retry.waitSeconds }}",
        "unit": "seconds"
      },
      "id": "1153832e-10cb-49be-a729-86ffcf7a8f55"
    },
    {
      "name": "Stop: Calendar Unavailable",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        200,
        -80
      ],
      "parameters": {},
      "id": "01e7831e-32d9-47b1-8456-38291413969a"
    },
    {
      "name": "Plan Sends",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -40,
        370
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "/**\n * NODE: \"Plan Sends\"  (Code node, Run Once for All Items)\n * ------------------------------------------------------\n * Decides which reminders are due right now, for whom, on which channel, and at\n * what local time \u2014 then emits one item per send, plus a decision log for every\n * touch it deliberately did not send.\n *\n * The decision log is not decoration. When a client asks \"why didn't Mrs Chen get\n * her reminder\", the answer needs to be one line with a reason code, not an hour\n * of reading execution history.\n *\n * The hard parts, in order of how often they are got wrong:\n *\n *   TIMEZONES. \"Send at 9am\" means 9am where the customer is. Doing this with\n *   fixed offsets breaks twice a year; doing it with the server's timezone means\n *   your Sydney customers get texted at 3am. This uses Intl with a real IANA\n *   zone, which handles DST because the tz database does.\n *\n *   QUIET HOURS. A send that lands at 2am is deferred to the start of the next\n *   allowed window, not dropped \u2014 unless deferring would push it past the\n *   appointment, in which case it is dropped with that exact reason.\n *\n *   IDEMPOTENCY. The sweep runs every 15 minutes and the due window is 90\n *   minutes wide, so every touch is seen six times. A deterministic key per\n *   (appointment, touch, channel) is what stops the customer getting six texts.\n *\n *   CANCELLATION. A no-show rescue sequence that keeps running after the\n *   customer rebooks is worse than no sequence at all.\n *\n * Requires a Node build with full ICU (n8n's official Docker images have it) \u2014\n * without it, Intl silently falls back to UTC for every zone. The planner checks\n * for this rather than quietly mistiming everything.\n */\n\n// ---------------------------------------------------------------------------\n// Timezone primitives\n// ---------------------------------------------------------------------------\n\n/** Wall-clock parts for an instant in a given IANA zone. */\nfunction localParts(date, timeZone) {\n  const formatter = new Intl.DateTimeFormat('en-US', {\n    timeZone,\n    hour12: false,\n    year: 'numeric', month: '2-digit', day: '2-digit',\n    hour: '2-digit', minute: '2-digit', second: '2-digit',\n    weekday: 'short',\n  });\n  const parts = {};\n  for (const part of formatter.formatToParts(date)) parts[part.type] = part.value;\n  return {\n    year: Number(parts.year),\n    month: Number(parts.month),\n    day: Number(parts.day),\n    // Some ICU builds render midnight as hour \"24\" under hour12:false.\n    hour: Number(parts.hour) % 24,\n    minute: Number(parts.minute),\n    second: Number(parts.second),\n    weekday: parts.weekday,\n  };\n}\n\n/** Minutes that `timeZone` is ahead of UTC at that instant. DST-correct,\n *  because the answer is derived from the zone's own rendering of the time. */\nfunction offsetMinutes(date, timeZone) {\n  const parts = localParts(date, timeZone);\n  const asIfUtc = Date.UTC(parts.year, parts.month - 1, parts.day,\n    parts.hour, parts.minute, parts.second);\n  return Math.round((asIfUtc - date.getTime()) / 60000);\n}\n\n/**\n * Convert a local wall-clock time in `timeZone` back to a UTC instant.\n *\n * Two passes, because the offset you need depends on the instant you are trying\n * to find. The first guess uses the offset at the naive instant; the second\n * corrects it if that guess landed on the other side of a DST transition. This\n * is the step that hand-rolled timezone code almost always misses.\n */\nfunction localToUtc(parts, timeZone) {\n  const naive = Date.UTC(parts.year, parts.month - 1, parts.day,\n    parts.hour, parts.minute || 0, 0);\n  let guess = new Date(naive - offsetMinutes(new Date(naive), timeZone) * 60000);\n  const corrected = new Date(naive - offsetMinutes(guess, timeZone) * 60000);\n  if (corrected.getTime() !== guess.getTime()) guess = corrected;\n  return guess;\n}\n\n/** Cheap sanity check that the runtime actually has timezone data. */\nfunction hasFullIcu() {\n  try {\n    const reference = new Date('2026-01-15T12:00:00Z');\n    return offsetMinutes(reference, 'Asia/Kolkata') === 330;\n  } catch (error) {\n    return false;\n  }\n}\n\n/** Country dialling code -> a representative IANA zone. A crude fallback, used\n *  only when the CRM has no timezone on the contact, and always reported as\n *  inferred so nobody mistakes it for a fact. */\nconst DIAL_CODE_ZONES = [\n  { cc: '960', zone: 'Indian/Maldives' }, { cc: '971', zone: 'Asia/Dubai' },\n  { cc: '966', zone: 'Asia/Riyadh' }, { cc: '353', zone: 'Europe/Dublin' },\n  { cc: '234', zone: 'Africa/Lagos' }, { cc: '91', zone: 'Asia/Kolkata' },\n  { cc: '61', zone: 'Australia/Sydney' }, { cc: '64', zone: 'Pacific/Auckland' },\n  { cc: '65', zone: 'Asia/Singapore' }, { cc: '44', zone: 'Europe/London' },\n  { cc: '49', zone: 'Europe/Berlin' }, { cc: '31', zone: 'Europe/Amsterdam' },\n  { cc: '27', zone: 'Africa/Johannesburg' }, { cc: '63', zone: 'Asia/Manila' },\n];\n\nfunction resolveTimeZone(contact, config) {\n  if (contact.timeZone && contact.timeZone.trim()) {\n    return { zone: contact.timeZone.trim(), source: 'contact_record' };\n  }\n  const digits = String(contact.phone ?? '').replace(/\\D/g, '');\n  if (digits) {\n    const match = DIAL_CODE_ZONES\n      .slice()\n      .sort((a, b) => b.cc.length - a.cc.length)\n      .find((entry) => digits.startsWith(entry.cc));\n    if (match) return { zone: match.zone, source: 'inferred_from_phone_country_code' };\n  }\n  return { zone: config.businessTimeZone, source: 'business_default' };\n}\n\n// ---------------------------------------------------------------------------\n// Quiet hours\n// ---------------------------------------------------------------------------\n\nfunction isQuiet(parts, quietHours) {\n  const { startHour, endHour } = quietHours;\n  if (startHour === endHour) return false;\n  return startHour > endHour\n    ? (parts.hour >= startHour || parts.hour < endHour)  // window crosses midnight\n    : (parts.hour >= startHour && parts.hour < endHour);\n}\n\n/** The next instant at or after `date` that is not inside quiet hours. */\nfunction nextAllowedInstant(date, timeZone, quietHours) {\n  const parts = localParts(date, timeZone);\n  if (!isQuiet(parts, quietHours)) return date;\n\n  const target = { ...parts, hour: quietHours.endHour, minute: 0, second: 0 };\n  // If it is already past the end of the window today, the next opening is\n  // tomorrow. Date.UTC normalises day overflow (32 August becomes 1 September).\n  if (parts.hour >= quietHours.endHour) target.day += 1;\n\n  return localToUtc(target, timeZone);\n}\n\n// ---------------------------------------------------------------------------\n// Planning\n// ---------------------------------------------------------------------------\n\nfunction channelIsUsable(channel, contact) {\n  if (contact.dnd === true) return { usable: false, reason: 'CONTACT_DND' };\n  if (channel === 'sms') {\n    if (!contact.phone) return { usable: false, reason: 'NO_PHONE_NUMBER' };\n    if (contact.smsConsent !== true) return { usable: false, reason: 'NO_SMS_CONSENT' };\n    return { usable: true };\n  }\n  if (channel === 'email') {\n    if (!contact.email) return { usable: false, reason: 'NO_EMAIL_ADDRESS' };\n    if (contact.emailConsent !== true) return { usable: false, reason: 'NO_EMAIL_CONSENT' };\n    return { usable: true };\n  }\n  return { usable: false, reason: 'UNKNOWN_CHANNEL' };\n}\n\nfunction planAppointment(appointment, config, sentKeys, nowMs) {\n  const decisions = [];\n  const sends = [];\n  const contact = appointment.contact || {};\n\n  const push = (touchKey, channel, outcome, reason, extra) => {\n    decisions.push({\n      appointmentId: appointment.id,\n      contactId: contact.id || null,\n      touch: touchKey,\n      channel: channel || null,\n      outcome,\n      reason,\n      ...(extra || {}),\n    });\n  };\n\n  const status = String(appointment.status ?? '').toLowerCase();\n\n  if (config.terminalStatuses.includes(status)) {\n    push('*', null, 'suppressed', 'APPOINTMENT_' + status.toUpperCase());\n    return { sends, decisions };\n  }\n  if (appointment.rebookedAppointmentId) {\n    // The single most important rule in a no-show sequence.\n    push('*', null, 'suppressed', 'ALREADY_REBOOKED',\n      { rebookedAs: appointment.rebookedAppointmentId });\n    return { sends, decisions };\n  }\n  if (contact.dnd === true) {\n    push('*', null, 'suppressed', 'CONTACT_DND');\n    return { sends, decisions };\n  }\n\n  const startMs = Date.parse(appointment.startAt);\n  const endMs = Date.parse(appointment.endAt) || startMs;\n  const createdMs = Date.parse(appointment.createdAt) || 0;\n  if (!startMs) {\n    push('*', null, 'error', 'UNPARSEABLE_START_TIME');\n    return { sends, decisions };\n  }\n\n  const { zone, source: zoneSource } = resolveTimeZone(contact, config);\n\n  const isNoShow = status === 'no-show' || status === 'noshow' || status === 'no_show';\n  const touches = isNoShow ? config.noShowTouches : config.reminderTouches;\n  const anchorMs = isNoShow ? endMs : startMs;\n\n  for (const touch of touches) {\n    const idealMs = anchorMs + touch.offsetMinutes * 60000;\n\n    // Due window: this sweep is responsible for touches that came due within\n    // the last dueWindowMinutes. Anything older was missed while the workflow\n    // was off, and firing it now would be worse than not firing it.\n    if (nowMs < idealMs) {\n      push(touch.key, null, 'not_yet', 'SCHEDULED_FOR_' + new Date(idealMs).toISOString());\n      continue;\n    }\n    // Checked before the due window, because \"this touch was already in the past\n    // when they booked\" is permanent and is the useful answer. \"Outside the due\n    // window\" is transient and would hide it.\n    if (config.suppressTouchesEarlierThanBooking && idealMs < createdMs) {\n      push(touch.key, null, 'skipped', 'BOOKED_AFTER_THIS_TOUCH_WAS_DUE',\n        { bookedAt: appointment.createdAt, touchDueAt: new Date(idealMs).toISOString() });\n      continue;\n    }\n    if (nowMs - idealMs > config.dueWindowMinutes * 60000) {\n      push(touch.key, null, 'skipped', 'OUTSIDE_DUE_WINDOW',\n        { wasDueAt: new Date(idealMs).toISOString(),\n          minutesLate: Math.round((nowMs - idealMs) / 60000) });\n      continue;\n    }\n\n    // --- quiet hours ---\n    const idealDate = new Date(idealMs);\n    const idealLocal = localParts(idealDate, zone);\n    let sendDate = idealDate;\n    let deferred = false;\n\n    if (isQuiet(idealLocal, config.quietHours)) {\n      sendDate = nextAllowedInstant(idealDate, zone, config.quietHours);\n      deferred = true;\n\n      if (!isNoShow && sendDate.getTime() >= startMs) {\n        push(touch.key, null, 'dropped', 'QUIET_HOURS_DEFERRAL_WOULD_MISS_APPOINTMENT', {\n          idealSendAt: idealDate.toISOString(),\n          idealLocalTime: idealLocal.hour + ':' + String(idealLocal.minute).padStart(2, '0'),\n          wouldSendAt: sendDate.toISOString(),\n          appointmentStartsAt: appointment.startAt,\n          timeZone: zone,\n        });\n        continue;\n      }\n    }\n\n    // --- channels ---\n    for (const channel of touch.channels) {\n      const key = appointment.id + ':' + touch.key + ':' + channel;\n\n      if (sentKeys.has(key)) {\n        push(touch.key, channel, 'skipped', 'ALREADY_SENT', { idempotencyKey: key });\n        continue;\n      }\n\n      const usable = channelIsUsable(channel, contact);\n      if (!usable.usable) {\n        push(touch.key, channel, 'skipped', usable.reason);\n        continue;\n      }\n\n      const sendLocal = localParts(sendDate, zone);\n      const startLocal = localParts(new Date(startMs), zone);\n\n      sends.push({\n        idempotencyKey: key,\n        appointmentId: appointment.id,\n        contactId: contact.id,\n        touch: touch.key,\n        template: touch.template,\n        channel,\n        destination: channel === 'sms' ? contact.phone : contact.email,\n        sendAt: sendDate.toISOString(),\n        deferredForQuietHours: deferred,\n        idealSendAt: idealDate.toISOString(),\n        timeZone: zone,\n        timeZoneSource: zoneSource,\n        localSendTime: String(sendLocal.hour).padStart(2, '0') + ':'\n          + String(sendLocal.minute).padStart(2, '0'),\n        // Everything a template can reference. Built here so the renderer stays\n        // a pure string function with no knowledge of appointments.\n        context: {\n          first_name: contact.firstName || '',\n          last_name: contact.lastName || '',\n          service: appointment.service || '',\n          staff: appointment.staff || '',\n          appointment_date: startLocal.year + '-' + String(startLocal.month).padStart(2, '0')\n            + '-' + String(startLocal.day).padStart(2, '0'),\n          appointment_time: String(startLocal.hour).padStart(2, '0') + ':'\n            + String(startLocal.minute).padStart(2, '0'),\n          appointment_weekday: startLocal.weekday,\n          time_zone: zone,\n        },\n      });\n\n      push(touch.key, channel, 'queued', deferred ? 'DEFERRED_OUT_OF_QUIET_HOURS' : 'DUE',\n        { sendAt: sendDate.toISOString(), localSendTime: sendLocal.hour + ':'\n          + String(sendLocal.minute).padStart(2, '0') });\n    }\n  }\n\n  return { sends, decisions };\n}\n\nfunction plan(appointments, config, alreadySent, nowMs) {\n  const sentKeys = new Set(alreadySent || []);\n  const sends = [];\n  const decisions = [];\n\n  for (const appointment of appointments) {\n    const result = planAppointment(appointment, config, sentKeys, nowMs);\n    sends.push(...result.sends);\n    decisions.push(...result.decisions);\n    // Guard against the same key being queued twice inside one sweep, which can\n    // happen if the calendar returns an appointment more than once.\n    for (const send of result.sends) sentKeys.add(send.idempotencyKey);\n  }\n\n  sends.sort((a, b) => a.sendAt.localeCompare(b.sendAt) ||\n    a.idempotencyKey.localeCompare(b.idempotencyKey));\n\n  return {\n    sends,\n    decisions,\n    summary: {\n      appointmentsSeen: appointments.length,\n      sendsQueued: sends.length,\n      deferredForQuietHours: sends.filter((send) => send.deferredForQuietHours).length,\n      suppressed: decisions.filter((entry) => entry.outcome === 'suppressed').length,\n      skipped: decisions.filter((entry) => entry.outcome === 'skipped').length,\n      dropped: decisions.filter((entry) => entry.outcome === 'dropped').length,\n      bySms: sends.filter((send) => send.channel === 'sms').length,\n      byEmail: sends.filter((send) => send.channel === 'email').length,\n      timeZonesInferred: sends.filter((send) =>\n        send.timeZoneSource !== 'contact_record').length,\n    },\n  };\n}\n\n// --- exported for the local test harness; n8n ignores this block -----------\nif (typeof module !== 'undefined' && module.exports) {\n  module.exports = {\n    localParts, offsetMinutes, localToUtc, hasFullIcu, resolveTimeZone,\n    isQuiet, nextAllowedInstant, channelIsUsable, planAppointment, plan,\n  };\n}\n\n// --- n8n entry point -------------------------------------------------------\nif (typeof $input !== 'undefined') {\n  const first = $input.all()[0].json;\n\n  // Mock path keeps config on the item; live path replaced it with the API\n  // response, so config comes back from the initialiser.\n  const state = first.config ? first : $('Init Reminder Run').all()[0].json;\n  // Mock: { response }. Live with Full Response on: { statusCode, headers, body }.\n  const response = first.response !== undefined ? first.response\n    : first.body !== undefined ? first.body : first;\n  const appointments = (response && (response.appointments || response.events ||\n    response.data || [])) || [];\n\n  if (!hasFullIcu()) {\n    throw new Error('This n8n runtime has no timezone database (small-icu build). '\n      + 'Every reminder would be timed in UTC, which is worse than not sending them. '\n      + 'Use the official n8n Docker image, or start Node with --icu-data-dir.');\n  }\n\n  // \"Now\" comes from the initialiser so the whole sweep is evaluated at one\n  // instant \u2014 and so the fixture calendar can be run against a pinned clock.\n  const nowMs = state.run && typeof state.run.nowMs === 'number'\n    ? state.run.nowMs : Date.now();\n\n  const result = plan(appointments, state.config, state.alreadySent || [], nowMs);\n\n  return result.sends.length\n    // The decision log goes on the first item only. Copying it onto all of them\n    // multiplies the execution data by the number of sends for no benefit; read\n    // it from $('Plan Sends').first() if you need it downstream.\n    ? result.sends.map((send, index) => ({ json: {\n        ...send,\n        config: state.config,\n        runId: state.run.runId,\n        planSummary: result.summary,\n        hasSends: true,\n        ...(index === 0 ? { decisions: result.decisions } : {}),\n      } }))\n    : [{ json: {\n        runId: state.run.runId,\n        hasSends: false,\n        planSummary: result.summary,\n        decisions: result.decisions,\n      } }];\n}"
      },
      "id": "09712961-b783-49d3-a621-843bd8a45c4b"
    },
    {
      "name": "Anything Due?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        180,
        370
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "has-sends",
              "leftValue": "={{ $json.hasSends }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "looseTypeValidation": true,
        "options": {}
      },
      "id": "bca5fc42-1f17-43c2-ba91-76ceee38806f"
    },
    {
      "name": "Nothing Due This Sweep",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        420,
        520
      ],
      "parameters": {},
      "id": "3fc437f7-22b5-4b5b-a474-39214209aaf1"
    },
    {
      "name": "Render Messages",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        420,
        280
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "/**\n * NODE: \"Render Messages\"  (Code node, Run Once for All Items)\n * -----------------------------------------------------------\n * Turns a planned send into the exact text that goes out, and works out what it\n * will cost.\n *\n * Two things here that a no-code template field cannot do:\n *\n *   FALLBACKS. `{{first_name|there}}` renders \"there\" when the CRM has no first\n *   name. Nothing else in this file is allowed to emit a raw `{{...}}` \u2014 if a\n *   placeholder has no value and no fallback, the send is blocked rather than\n *   texting a customer \"Hi {{first_name}}\". That message has gone out from every\n *   agency at least once and it is always embarrassing.\n *\n *   SEGMENT COUNTING. SMS is billed per segment, not per message, and the rules\n *   change depending on the characters used. One emoji in a service name flips\n *   the whole message from GSM-7 to UCS-2 and cuts the segment size from 160\n *   characters to 70 \u2014 so a 150-character message silently becomes 3 billable\n *   segments instead of 1. Multiply that by 4,000 reminders a month and it is a\n *   real line on the client's invoice. This counts it, and flags anything over\n *   the configured limit before it is sent rather than after it is billed.\n */\n\n// ---------------------------------------------------------------------------\n// Templates. In production these belong in the CRM or a Data Store; inline here\n// so the demo runs standalone.\n// ---------------------------------------------------------------------------\nconst TEMPLATES = {\n  reminder_24h: {\n    sms: 'Hi {{first_name|there}}, reminder: {{service}} with {{staff|our team}} tomorrow at {{appointment_time}}. Reply C to cancel.',\n    email: {\n      subject: 'Your {{service}} appointment tomorrow at {{appointment_time}}',\n      body: 'Hi {{first_name|there}},\\n\\nThis is a reminder of your {{service}} appointment with {{staff|our team}} on {{appointment_weekday}} {{appointment_date}} at {{appointment_time}} ({{time_zone}}).\\n\\nIf you need to change it, just reply to this email.',\n    },\n  },\n  reminder_3h: {\n    sms: 'Hi {{first_name|there}}, see you at {{appointment_time}} today for your {{service}}. Reply C to cancel.',\n  },\n  reminder_30m: {\n    sms: '{{first_name|Hi}} - your {{service}} appointment is in about 30 minutes. See you shortly.',\n  },\n  noshow_immediate: {\n    sms: 'Hi {{first_name|there}}, sorry we missed you today. Want to rebook? Reply YES and we will sort a new time.',\n  },\n  noshow_day1: {\n    email: {\n      subject: 'Sorry we missed you - want to rebook?',\n      body: 'Hi {{first_name|there}},\\n\\nWe had you down for {{service}} with {{staff|our team}} on {{appointment_date}} and did not manage to see you.\\n\\nNo problem at all - reply to this email and we will find a time that works better.',\n    },\n  },\n  noshow_day3: {\n    sms: 'Hi {{first_name|there}}, still happy to get you booked in for {{service}} whenever suits. Reply YES.',\n    email: {\n      subject: 'Still happy to book you in',\n      body: 'Hi {{first_name|there}},\\n\\nThe offer stands - reply and we will get {{service}} in the diary.',\n    },\n  },\n};\n\nconst LIMITS = {\n  maxSmsSegments: 2,     // above this, the send is flagged rather than sent\n};\n\n// ---------------------------------------------------------------------------\n// Template rendering\n// ---------------------------------------------------------------------------\n\n/**\n * Renders {{key}} and {{key|fallback}}. Returns the text plus the list of\n * placeholders that resolved to nothing at all, so the caller can refuse to\n * send rather than leaking template syntax to a customer.\n */\nfunction render(template, context) {\n  const missing = [];\n  const used = [];\n\n  const text = String(template ?? '').replace(/\\{\\{\\s*([a-z0-9_]+)\\s*(?:\\|([^}]*))?\\}\\}/gi,\n    (match, key, fallback) => {\n      const value = context[key];\n      const resolved = value === null || value === undefined || String(value).trim() === ''\n        ? undefined\n        : String(value).trim();\n\n      if (resolved !== undefined) {\n        used.push(key);\n        return resolved;\n      }\n      if (fallback !== undefined) {\n        used.push(key + '(fallback)');\n        return fallback.trim();\n      }\n      missing.push(key);\n      return '';\n    });\n\n  return {\n    // Collapse the double spaces left behind by an empty substitution.\n    text: text.replace(/[ \\t]{2,}/g, ' ').replace(/ +([.,!?])/g, '$1').trim(),\n    missing,\n    used,\n  };\n}\n\n// ---------------------------------------------------------------------------\n// SMS encoding and segment counting\n// ---------------------------------------------------------------------------\n\n// GSM 03.38 basic set. Anything outside it forces the whole message to UCS-2.\nconst GSM7_BASIC = '@\u00a3$\u00a5\u00e8\u00e9\u00f9\u00ec\u00f2\u00c7\\n\u00d8\u00f8\\r\u00c5\u00e5\u0394_\u03a6\u0393\u039b\u03a9\u03a0\u03a8\u03a3\u0398\u039e\u00c6\u00e6\u00df\u00c9 !\"#\u00a4%&\\'()*+,-./0123456789:;<=>?'\n  + '\u00a1ABCDEFGHIJKLMNOPQRSTUVWXYZ\u00c4\u00d6\u00d1\u00dc\u00a7\u00bfabcdefghijklmnopqrstuvwxyz\u00e4\u00f6\u00f1\u00fc\u00e0';\n\n// These cost two GSM-7 characters each because they are escape sequences.\nconst GSM7_EXTENDED = '^{}\\\\[~]|\u20ac';\n\n/**\n * Returns { encoding, characters, segments, perSegment }.\n *\n * Segment sizes are the GSM 03.40 concatenation rules: a single message gets the\n * full payload, but multi-part messages lose room to the UDH header, which is\n * why the second segment is 153 characters and not 160.\n */\nfunction measureSms(text) {\n  const characters = Array.from(String(text ?? ''));   // surrogate-pair safe\n\n  let gsmLength = 0;\n  let isGsm = true;\n  for (const character of characters) {\n    if (GSM7_BASIC.includes(character)) gsmLength += 1;\n    else if (GSM7_EXTENDED.includes(character)) gsmLength += 2;\n    else { isGsm = false; break; }\n  }\n\n  if (isGsm) {\n    const perSegment = gsmLength <= 160 ? 160 : 153;\n    return {\n      encoding: 'GSM-7',\n      characters: gsmLength,\n      perSegment,\n      segments: gsmLength === 0 ? 0 : Math.ceil(gsmLength / perSegment),\n    };\n  }\n\n  // UCS-2 is billed in 16-bit code units, so an emoji outside the BMP counts as\n  // two. String#length already counts code units, which is what we want here.\n  const units = String(text ?? '').length;\n  const perSegment = units <= 70 ? 70 : 67;\n  return {\n    encoding: 'UCS-2',\n    characters: units,\n    perSegment,\n    segments: units === 0 ? 0 : Math.ceil(units / perSegment),\n  };\n}\n\n// ---------------------------------------------------------------------------\nfunction renderSend(send) {\n  const template = TEMPLATES[send.template];\n  if (!template) {\n    return { ...send, renderError: 'NO_TEMPLATE_NAMED_' + send.template, blocked: true };\n  }\n\n  if (send.channel === 'sms') {\n    const source = template.sms;\n    if (!source) {\n      return { ...send, renderError: 'TEMPLATE_HAS_NO_SMS_VARIANT', blocked: true };\n    }\n    const rendered = render(source, send.context);\n    const sms = measureSms(rendered.text);\n\n    const blocked = rendered.missing.length > 0 || sms.segments > LIMITS.maxSmsSegments;\n\n    return {\n      ...send,\n      body: rendered.text,\n      missingPlaceholders: rendered.missing,\n      sms,\n      costNote: sms.encoding === 'UCS-2'\n        ? 'Non-GSM characters (emoji or smart quotes) forced UCS-2, cutting the segment '\n          + 'size from 160 characters to 70. This message bills as ' + sms.segments + ' segments.'\n        : sms.segments + ' segment(s).',\n      blocked,\n      blockedReason: rendered.missing.length\n        ? 'UNRESOLVED_PLACEHOLDERS:' + rendered.missing.join(',')\n        : sms.segments > LIMITS.maxSmsSegments\n          ? 'OVER_SEGMENT_LIMIT:' + sms.segments\n          : null,\n    };\n  }\n\n  const source = template.email;\n  if (!source) {\n    return { ...send, renderError: 'TEMPLATE_HAS_NO_EMAIL_VARIANT', blocked: true };\n  }\n  const subject = render(source.subject, send.context);\n  const body = render(source.body, send.context);\n  const missing = Array.from(new Set([...subject.missing, ...body.missing]));\n\n  return {\n    ...send,\n    subject: subject.text,\n    body: body.text,\n    missingPlaceholders: missing,\n    blocked: missing.length > 0,\n    blockedReason: missing.length ? 'UNRESOLVED_PLACEHOLDERS:' + missing.join(',') : null,\n  };\n}\n\n// --- exported for the local test harness; n8n ignores this block -----------\nif (typeof module !== 'undefined' && module.exports) {\n  module.exports = { TEMPLATES, LIMITS, render, measureSms, renderSend };\n}\n\n// --- n8n entry point -------------------------------------------------------\nif (typeof $input !== 'undefined') {\n  return $input.all()\n    .filter((item) => item.json && item.json.idempotencyKey)\n    .map((item, index) => ({ json: renderSend(item.json), pairedItem: index }));\n}"
      },
      "id": "ff3d30cf-ea52-4444-b22e-d5aab7739301"
    },
    {
      "name": "Send For Real?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        640,
        280
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "really-send",
              "leftValue": "={{ $json.config.simulateDelivery !== true && $json.blocked !== true }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "looseTypeValidation": true,
        "options": {}
      },
      "id": "9630afce-fc23-49a8-8e64-83a9583aba0b"
    },
    {
      "name": "Simulate Delivery",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        880,
        460
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "/**\n * NODE: \"Simulate Delivery\"  (Code node, Run Once for All Items)\n * -------------------------------------------------------------\n * Runs instead of the send nodes while `simulateDelivery` is on. Produces the\n * same result shape the real providers do, so **Record Sends** downstream cannot\n * tell the difference and the whole engine can be demonstrated, reviewed and\n * regression-tested without texting anybody.\n *\n * It refuses anything the renderer blocked, exactly as the live path does \u2014 a\n * simulation that is more permissive than production teaches you nothing.\n */\n\nfunction processAll(items, nowIso) {\n  return items.map((item, index) => {\n    const send = item.json || {};\n\n    if (send.blocked) {\n      return {\n        json: {\n          ...send,\n          delivery: {\n            simulated: true,\n            status: 'blocked',\n            reason: send.blockedReason || send.renderError || 'BLOCKED',\n            providerId: null,\n            at: nowIso,\n          },\n        },\n        pairedItem: index,\n      };\n    }\n\n    return {\n      json: {\n        ...send,\n        delivery: {\n          simulated: true,\n          status: 'sent',\n          // Deterministic fake id, so expected-output.json does not churn.\n          providerId: 'sim_' + send.idempotencyKey.replace(/[^a-z0-9]/gi, '_'),\n          billedSegments: send.channel === 'sms' && send.sms ? send.sms.segments : 0,\n          at: nowIso,\n        },\n      },\n      pairedItem: index,\n    };\n  });\n}\n\n// --- exported for the local test harness; n8n ignores this block -----------\nif (typeof module !== 'undefined' && module.exports) {\n  module.exports = { processAll };\n}\n\n// --- n8n entry point -------------------------------------------------------\nif (typeof $input !== 'undefined') {\n  // Same instant the sweep was planned against (see \"Init Reminder Run\"), so a\n  // simulated ledger entry cannot be stamped later than the send that made it.\n  let stampedAt;\n  try {\n    stampedAt = $('Init Reminder Run').first().json.run.startedAt;\n  } catch (error) {\n    stampedAt = new Date().toISOString();\n  }\n  return processAll($input.all(), stampedAt || new Date().toISOString());\n}"
      },
      "id": "9f3bd0d3-23e4-4897-9a9a-26fbab82c143"
    },
    {
      "name": "SMS or Email?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        880,
        160
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "is-sms",
              "leftValue": "={{ $json.channel === 'sms' }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "looseTypeValidation": true,
        "options": {}
      },
      "id": "d79c3850-2c65-4cbe-baa0-ad0b71c7d96b"
    },
    {
      "name": "Send SMS",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1120,
        60
      ],
      "parameters": {
        "method": "POST",
        "url": "https://services.leadconnectorhq.com/conversations/messages",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendHeaders": true,
        "specifyHeaders": "keypair",
        "headerParameters": {
          "parameters": [
            {
              "name": "Version",
              "value": "2021-04-15"
            },
            {
              "name": "Accept",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "contentType": "json",
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ type: 'SMS', contactId: $json.contactId, message: $json.body }) }}",
        "options": {
          "timeout": 20000,
          "batching": {
            "batch": {
              "batchSize": 10,
              "batchInterval": 1000
            }
          },
          "response": {
            "response": {
              "fullResponse": true,
              "neverError": true
            }
          }
        }
      },
      "id": "6c2e04a2-d5c7-426f-9a90-5406564d80bc"
    },
    {
      "name": "Send Email",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1120,
        260
      ],
      "parameters": {
        "method": "POST",
        "url": "https://services.leadconnectorhq.com/conversations/messages",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendHeaders": true,
        "specifyHeaders": "keypair",
        "headerParameters": {
          "parameters": [
            {
              "name": "Version",
              "value": "2021-04-15"
            },
            {
              "name": "Accept",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "contentType": "json",
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ type: 'Email', contactId: $json.contactId, subject: $json.subject, message: $json.body }) }}",
        "options": {
          "timeout": 20000,
          "batching": {
            "batch": {
              "batchSize": 10,
              "batchInterval": 1000
            }
          },
          "response": {
            "response": {
              "fullResponse": true,
              "neverError": true
            }
          }
        }
      },
      "id": "1f829e31-55ab-4111-b7ff-a795ad10d600"
    },
    {
      "name": "Record Sends",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1360,
        260
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "/**\n * NODE: \"Record Sends\"  (Code node, Run Once for All Items)\n * --------------------------------------------------------\n * Closes the loop. Produces the list of idempotency keys to append to the\n * ledger, a per-send outcome, the SMS segment bill for the sweep, and the\n * decision log for everything that was deliberately not sent.\n *\n * The ledger append is the important output. Until a key is recorded, the next\n * sweep will consider that touch unsent and send it again \u2014 so this must be\n * written before the next run, and it must record only what actually went out.\n * Recording a key for a failed send loses the message permanently; recording\n * nothing for a successful send sends it twice. Both are worse than the failure\n * they came from.\n */\n\nfunction outcomeOf(json) {\n  if (json.blocked || (json.delivery && json.delivery.status === 'blocked')) {\n    return { status: 'blocked', detail: json.blockedReason || json.renderError || 'blocked' };\n  }\n  if (json.error || (json.delivery && json.delivery.status === 'failed')) {\n    const message = (json.error && (json.error.message || json.error)) ||\n      (json.delivery && json.delivery.reason) || 'unknown error';\n    return { status: 'failed', detail: String(message).slice(0, 300) };\n  }\n  if (json.delivery && json.delivery.status === 'sent') {\n    return { status: 'sent', detail: json.delivery.providerId || '' };\n  }\n  // A live provider that answered 2xx without our simulated envelope.\n  if (json.messageId || json.id || json.sid) {\n    return { status: 'sent', detail: String(json.messageId || json.id || json.sid) };\n  }\n  return { status: 'unknown', detail: 'No recognisable delivery result on this item.' };\n}\n\nfunction processAll(items, nowIso) {\n  const results = items.map((item) => {\n    const json = item.json || {};\n    const outcome = outcomeOf(json);\n    return {\n      idempotencyKey: json.idempotencyKey || null,\n      appointmentId: json.appointmentId || null,\n      contactId: json.contactId || null,\n      touch: json.touch || null,\n      channel: json.channel || null,\n      destination: json.destination || null,\n      localSendTime: json.localSendTime || null,\n      timeZone: json.timeZone || null,\n      deferredForQuietHours: json.deferredForQuietHours === true,\n      segments: json.sms ? json.sms.segments : 0,\n      encoding: json.sms ? json.sms.encoding : null,\n      simulated: Boolean(json.delivery && json.delivery.simulated),\n      status: outcome.status,\n      detail: outcome.detail,\n    };\n  });\n\n  const sent = results.filter((result) => result.status === 'sent');\n\n  return [{\n    json: {\n      completedAt: nowIso,\n      attempted: results.length,\n      sent: sent.length,\n      failed: results.filter((result) => result.status === 'failed').length,\n      blocked: results.filter((result) => result.status === 'blocked').length,\n      // Append these, and only these, to the ledger.\n      ledgerAppend: sent.map((result) => result.idempotencyKey).filter(Boolean),\n      // Retry these on the next sweep \u2014 they are still unsent and still unrecorded.\n      unsentKeys: results.filter((result) => result.status !== 'sent')\n        .map((result) => result.idempotencyKey).filter(Boolean),\n      smsBill: {\n        messages: sent.filter((result) => result.channel === 'sms').length,\n        billedSegments: sent.reduce((total, result) => total + (result.segments || 0), 0),\n        ucs2Messages: sent.filter((result) => result.encoding === 'UCS-2').length,\n      },\n      deferredForQuietHours: results.filter((result) => result.deferredForQuietHours).length,\n      results,\n    },\n  }];\n}\n\n// --- exported for the local test harness; n8n ignores this block -----------\nif (typeof module !== 'undefined' && module.exports) {\n  module.exports = { outcomeOf, processAll };\n}\n\n// --- n8n entry point -------------------------------------------------------\nif (typeof $input !== 'undefined') {\n  // On the live path the send nodes replaced each item with the provider's\n  // response, which carries no idempotency key. Without pairing it back, nothing\n  // is ever appended to the ledger and every sweep re-sends the whole window \u2014\n  // the exact failure this node exists to prevent.\n  // One timestamp for the whole sweep, taken from the initialiser (see\n  // \"Init Reminder Run\" \u2014 pinned while the fixtures are driving, real otherwise).\n  let sweepIso;\n  try {\n    sweepIso = $('Init Reminder Run').first().json.run.startedAt;\n  } catch (error) {\n    sweepIso = null;\n  }\n  if (!sweepIso) sweepIso = new Date().toISOString();\n\n  const incoming = $input.all().map((item, index) => {\n    const json = item.json || {};\n    if (json.idempotencyKey) return item;\n    try {\n      const planned = $('Render Messages').itemMatching(index).json;\n      const status = json.statusCode === undefined || Number(json.statusCode) < 400\n        ? 'sent' : 'failed';\n      return { json: { ...planned, delivery: {\n        simulated: false,\n        status,\n        providerId: (json.body && (json.body.messageId || json.body.id)) || null,\n        reason: status === 'failed' ? 'HTTP ' + json.statusCode : null,\n        at: sweepIso,\n      } } };\n    } catch (error) {\n      // Unpairable: report it rather than recording a key we cannot trust.\n      return { json: { ...json, delivery: { status: 'unknown',\n        reason: 'Could not pair the provider response back to a planned send: ' + error.message } } };\n    }\n  });\n  return processAll(incoming, sweepIso);\n}"
      },
      "id": "78701fc7-a951-44f9-9246-a56dd1e5ab33"
    },
    {
      "name": "README",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -1200,
        -60
      ],
      "parameters": {
        "width": 460,
        "height": 280,
        "color": 4,
        "content": "## Appointment reminders that respect the customer's clock\n\nRuns every 15 minutes. Works out which reminder touches are due, in the customer's own timezone, defers anything landing in quiet hours, and cancels a no-show sequence the moment the customer rebooks.\n\n**Sends nothing by default.** `simulateDelivery: true` and `mockMode: true` in **Init Reminder Run**. Hit **Test workflow** and read **Record Sends**.\n\nWhile `mockMode` is on the sweep is evaluated at `mockClockIso`, not at *now* \u2014 the fixture calendar only means anything relative to one instant, so pinning the clock is what makes this demo show the same sweep on any day. The live path always uses the real clock.\n\nThe node to open is **Plan Sends**."
      },
      "id": "eeba3196-cbef-4f46-955f-98dba001a300"
    },
    {
      "name": "Why this is code",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -280,
        -300
      ],
      "parameters": {
        "width": 480,
        "height": 240,
        "color": 5,
        "content": "## The three things a reminder builder gets wrong\n\n**Timezones.** \"Send at 9am\" means 9am where the customer is. Fixed offsets break twice a year; the server's clock texts Sydney at 3am. This uses Intl with real IANA zones, so DST is the tz database's problem, not ours.\n\n**Idempotency.** A 15-minute sweep with a 90-minute due window sees every touch six times. A deterministic key per (appointment, touch, channel) is what stops six texts.\n\n**Cancellation.** A rescue sequence that keeps running after the customer rebooks is worse than no sequence."
      },
      "id": "b193be28-37cf-4f98-8b87-f96ea3e6fde2"
    },
    {
      "name": "SMS billing",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        180,
        -80
      ],
      "parameters": {
        "width": 400,
        "height": 200,
        "color": 3,
        "content": "## Segment counting\n\nSMS is billed per segment. One emoji in a service name flips the message from GSM-7 to UCS-2 and drops the segment size from 160 characters to 70 \u2014 so a 150-character message silently bills as 3 segments instead of 1.\n\n**Render Messages** counts this before sending and blocks anything over the configured limit. It also refuses to send a message with an unresolved `{{placeholder}}`."
      },
      "id": "1336f941-2bd2-42fb-9c15-d6f4382a4ee8"
    },
    {
      "name": "Calendar Fetched OK?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        -260,
        240
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "calendar-fetched-ok",
              "leftValue": "={{ $json.statusCode >= 200 && $json.statusCode < 300 }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "looseTypeValidation": true,
        "options": {}
      },
      "id": "d390557b-9578-4ee3-86d6-099cbcbeffe6"
    }
  ],
  "connections": {
    "Run Sweep Now": {
      "main": [
        [
          {
            "node": "Init Reminder Run",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Every 15 Minutes": {
      "main": [
        [
          {
            "node": "Init Reminder Run",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Init Reminder Run": {
      "main": [
        [
          {
            "node": "Use Live Calendar?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Use Live Calendar?": {
      "main": [
        [
          {
            "node": "CRM: Fetch Appointments",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Mock Appointments",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "CRM: Fetch Appointments": {
      "main": [
        [
          {
            "node": "Calendar Fetched OK?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Mock Appointments": {
      "main": [
        [
          {
            "node": "Plan Sends",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Classify Fetch Error": {
      "main": [
        [
          {
            "node": "Retry Fetch?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Retry Fetch?": {
      "main": [
        [
          {
            "node": "Backoff Wait",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Stop: Calendar Unavailable",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Backoff Wait": {
      "main": [
        [
          {
            "node": "CRM: Fetch Appointments",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Plan Sends": {
      "main": [
        [
          {
            "node": "Anything Due?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Anything Due?": {
      "main": [
        [
          {
            "node": "Render Messages",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Nothing Due This Sweep",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Render Messages": {
      "main": [
        [
          {
            "node": "Send For Real?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Send For Real?": {
      "main": [
        [
          {
            "node": "SMS or Email?",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Simulate Delivery",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "SMS or Email?": {
      "main": [
        [
          {
            "node": "Send SMS",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Send Email",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Send SMS": {
      "main": [
        [
          {
            "node": "Record Sends",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Send Email": {
      "main": [
        [
          {
            "node": "Record Sends",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Simulate Delivery": {
      "main": [
        [
          {
            "node": "Record Sends",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Calendar Fetched OK?": {
      "main": [
        [
          {
            "node": "Plan Sends",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Classify Fetch Error",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}