{
  "name": "Distru: deliveries onto Google Calendar (idempotent)",
  "meta": {
    "description": "version 1.0 (2026-09-17). Every 15 minutes, fetch sales orders with a delivery_datetime in the next N days and mirror each one as a Google Calendar event. One event per order, forever: the calendar event id is derived from the Distru order id ('distru' + the id without dashes, which fits Google's a-v/0-9 id alphabet), so the workflow looks the event up by id before it creates, updates when the date or total moved, and deletes when the order is CANCELED. Re-running never duplicates. Pure automation, no LLM. From Distru's No Bullshit AI Course (module 05, lesson 3). Credentials: n8n Header Auth for Distru (name 'Authorization', value 'Bearer YOUR_API_TOKEN'), a Google Calendar OAuth2 credential, Slack. Set TZ in the Config node to the calendar's time zone; Distru returns delivery_datetime in UTC. Leave DRY_RUN=true until the Slack plan looks right. Endpoint facts checked against the Distru order controller and Google Calendar API reference on 2026-09-17; n8n Google Calendar node parameters checked against the node source (typeVersion 1.3). MIT.",
    "instanceId": "no-bullshit-ai-course"
  },
  "nodes": [
    {
      "parameters": { "rule": { "interval": [{ "field": "minutes", "minutesInterval": 15 }] } },
      "id": "c1",
      "name": "Every 15 minutes",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [0, 0]
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            { "id": "a1", "name": "BASE_URL", "value": "https://app.distru.com/public/v1", "type": "string" },
            { "id": "a2", "name": "CALENDAR_ID", "value": "primary", "type": "string" },
            { "id": "a3", "name": "TZ", "value": "America/Los_Angeles", "type": "string" },
            { "id": "a4", "name": "DAYS_AHEAD", "value": "14", "type": "string" },
            { "id": "a5", "name": "EVENT_MINUTES", "value": "60", "type": "string" },
            { "id": "a6", "name": "DRY_RUN", "value": "true", "type": "string" },
            { "id": "a7", "name": "SLACK_CHANNEL", "value": "#ops", "type": "string" },
            { "id": "a8", "name": "WINDOW_START", "value": "={{ $now.startOf('day').toISO() }}", "type": "string" },
            { "id": "a9", "name": "WINDOW_END", "value": "={{ $now.plus({ days: 14 }).endOf('day').toISO() }}", "type": "string" }
          ]
        },
        "options": {}
      },
      "id": "c2",
      "name": "Config",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [220, 0]
    },
    {
      "parameters": {
        "method": "GET",
        "url": "={{ $('Config').item.json.BASE_URL }}/orders",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            { "name": "delivery_datetime", "value": "={{ $('Config').item.json.WINDOW_START }},{{ $('Config').item.json.WINDOW_END }}" }
          ]
        },
        "options": {}
      },
      "id": "c3",
      "name": "GET orders delivering in window",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [440, 0],
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 5000,
      "credentials": { "httpHeaderAuth": { "id": "REPLACE_ME", "name": "Distru API (Authorization: Bearer ...)" } }
    },
    {
      "parameters": {
        "jsCode": "// One item per order, with everything the calendar needs already computed.\n// Google event ids may only use a-v and 0-9 (5..1024 chars). A UUID without dashes is 0-9a-f, so it fits.\nconst cfg = $('Config').first().json;\nconst body = $input.first().json;\nconst orders = Array.isArray(body) ? body : (body.data ?? []);\nconst truncated = Boolean(body.next_page);\nconst minutes = Number(cfg.EVENT_MINUTES) || 60;\n\nreturn orders\n  .filter(o => o.delivery_datetime)\n  .map(o => {\n    const start = DateTime.fromISO(o.delivery_datetime, { zone: 'utc' }).setZone(cfg.TZ);\n    const end = start.plus({ minutes });\n    const company = o.company?.name ?? 'Unknown customer';\n    const eventId = 'distru' + String(o.id).replace(/-/g, '').toLowerCase();\n    return { json: {\n      orderId: o.id,\n      orderNumber: o.order_number,\n      status: o.status,\n      eventId,\n      start: start.toISO(),\n      end: end.toISO(),\n      summary: `Delivery ${o.order_number} · ${company}`,\n      description: [\n        `Distru order ${o.order_number}`,\n        `Customer: ${company}`,\n        `Status: ${o.status}`,\n        `Total: ${o.total}`,\n        `Payment terms: ${o.payment_term_name ?? 'n/a'}`,\n        `Synced by n8n; do not edit here, edit the order in Distru.`\n      ].join('\\n'),\n      location: o.shipping_location?.address ?? '',\n      wantDelete: o.status === 'CANCELED',\n      _truncated: truncated\n    } };\n  });"
      },
      "id": "c4",
      "name": "Prepare events",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [660, 0]
    },
    {
      "parameters": {
        "resource": "event",
        "operation": "get",
        "calendar": { "__rl": true, "mode": "id", "value": "={{ $('Config').item.json.CALENDAR_ID }}" },
        "eventId": "={{ $json.eventId }}",
        "options": {}
      },
      "id": "c5",
      "name": "Find existing event by id",
      "type": "n8n-nodes-base.googleCalendar",
      "typeVersion": 1.3,
      "position": [880, 0],
      "onError": "continueErrorOutput",
      "credentials": { "googleCalendarOAuth2Api": { "id": "REPLACE_ME", "name": "Google Calendar" } }
    },
    {
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "// Decide what to do with this order. Runs once per item, on both outputs of the lookup:\n// output 0 = the event exists (json is the Google event), output 1 = lookup failed (404 = not there yet).\nconst order = $('Prepare events').item.json;\nconst found = !$json.error && Boolean($json.id);\nconst notFound = Boolean($json.error) && /404|not\\s*found/i.test(JSON.stringify($json.error));\nif ($json.error && !notFound) {\n  // Auth or quota problem, not a missing event. Do not create anything on top of it.\n  return { json: { ...order, action: 'error', reason: JSON.stringify($json.error).slice(0, 300) } };\n}\nlet action = 'skip';\nif (found && order.wantDelete) action = 'delete';\nelse if (found && $json.status === 'cancelled') {\n  // Someone deleted the event by hand. The id is still taken, so a create would 409. Say so.\n  return { json: { ...order, action: 'error', reason: 'calendar event was deleted by hand; restore it from the calendar trash or wait 30 days for the id to free up' } };\n}\nelse if (found) {\n  const sameStart = ($json.start?.dateTime ?? '') !== '' && DateTime.fromISO($json.start.dateTime).toMillis() === DateTime.fromISO(order.start).toMillis();\n  const sameText = $json.summary === order.summary && ($json.description ?? '') === order.description;\n  action = (sameStart && sameText) ? 'skip' : 'update';\n}\nelse if (!found && !order.wantDelete) action = 'create';\nreturn { json: { ...order, action } };"
      },
      "id": "c6",
      "name": "Plan: create / update / delete / skip",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [1100, 0]
    },
    {
      "parameters": {
        "conditions": {
          "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict" },
          "conditions": [
            { "id": "f1", "leftValue": "={{ $json.action }}", "rightValue": "skip", "operator": { "type": "string", "operation": "notEquals" } }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "c7",
      "name": "Something to do?",
      "type": "n8n-nodes-base.filter",
      "typeVersion": 2,
      "position": [1320, 0]
    },
    {
      "parameters": {
        "conditions": {
          "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict" },
          "conditions": [
            { "id": "d1", "leftValue": "={{ $('Config').item.json.DRY_RUN }}", "rightValue": "true", "operator": { "type": "string", "operation": "equals" } }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "c8",
      "name": "Dry run?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [1540, 0]
    },
    {
      "parameters": {
        "rules": {
          "values": [
            {
              "conditions": {
                "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict" },
                "conditions": [{ "id": "s1", "leftValue": "={{ $json.action }}", "rightValue": "create", "operator": { "type": "string", "operation": "equals" } }],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "create"
            },
            {
              "conditions": {
                "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict" },
                "conditions": [{ "id": "s2", "leftValue": "={{ $json.action }}", "rightValue": "update", "operator": { "type": "string", "operation": "equals" } }],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "update"
            },
            {
              "conditions": {
                "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict" },
                "conditions": [{ "id": "s3", "leftValue": "={{ $json.action }}", "rightValue": "delete", "operator": { "type": "string", "operation": "equals" } }],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "delete"
            }
          ]
        },
        "options": { "fallbackOutput": "extra" }
      },
      "id": "c9",
      "name": "Which write?",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3.2,
      "position": [1760, 160]
    },
    {
      "parameters": {
        "resource": "event",
        "operation": "create",
        "calendar": { "__rl": true, "mode": "id", "value": "={{ $('Config').item.json.CALENDAR_ID }}" },
        "start": "={{ $json.start }}",
        "end": "={{ $json.end }}",
        "useDefaultReminders": true,
        "additionalFields": {
          "id": "={{ $json.eventId }}",
          "summary": "={{ $json.summary }}",
          "description": "={{ $json.description }}",
          "location": "={{ $json.location }}"
        }
      },
      "id": "c10",
      "name": "Create event (id = order)",
      "type": "n8n-nodes-base.googleCalendar",
      "typeVersion": 1.3,
      "position": [1980, 40],
      "onError": "continueErrorOutput",
      "credentials": { "googleCalendarOAuth2Api": { "id": "REPLACE_ME", "name": "Google Calendar" } }
    },
    {
      "parameters": {
        "resource": "event",
        "operation": "update",
        "calendar": { "__rl": true, "mode": "id", "value": "={{ $('Config').item.json.CALENDAR_ID }}" },
        "eventId": "={{ $json.eventId }}",
        "modifyTarget": "event",
        "useDefaultReminders": true,
        "updateFields": {
          "start": "={{ $json.start }}",
          "end": "={{ $json.end }}",
          "summary": "={{ $json.summary }}",
          "description": "={{ $json.description }}",
          "location": "={{ $json.location }}"
        }
      },
      "id": "c11",
      "name": "Update event",
      "type": "n8n-nodes-base.googleCalendar",
      "typeVersion": 1.3,
      "position": [1980, 200],
      "onError": "continueErrorOutput",
      "credentials": { "googleCalendarOAuth2Api": { "id": "REPLACE_ME", "name": "Google Calendar" } }
    },
    {
      "parameters": {
        "resource": "event",
        "operation": "delete",
        "calendar": { "__rl": true, "mode": "id", "value": "={{ $('Config').item.json.CALENDAR_ID }}" },
        "eventId": "={{ $json.eventId }}",
        "options": {}
      },
      "id": "c12",
      "name": "Delete event (order canceled)",
      "type": "n8n-nodes-base.googleCalendar",
      "typeVersion": 1.3,
      "position": [1980, 360],
      "onError": "continueErrorOutput",
      "credentials": { "googleCalendarOAuth2Api": { "id": "REPLACE_ME", "name": "Google Calendar" } }
    },
    {
      "parameters": {
        "jsCode": "// One line per run so a human can read what happened. Empty runs post nothing.\nconst items = $input.all();\nif (!items.length) return [];\nconst cfg = $('Config').first().json;\nconst dry = cfg.DRY_RUN === 'true';\nconst by = { create: [], update: [], delete: [], error: [] };\nfor (const i of items) (by[i.json.action] ?? by.error).push(i.json.orderNumber ?? i.json.orderId);\nconst line = (k) => by[k].length ? `${k}: ${by[k].length} (${by[k].join(', ')})` : null;\nconst parts = ['create', 'update', 'delete', 'error'].map(line).filter(Boolean);\nlet text = `${dry ? 'DRY RUN calendar plan' : 'Calendar sync'}: ` + parts.join(' · ');\nif (items.some(i => i.json._truncated)) text += '\\nNOTE: more than one page of orders matched; only the first page was handled.';\nreturn [{ json: { text } }];"
      },
      "id": "c13",
      "name": "Summary line",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [2200, 0]
    },
    {
      "parameters": {
        "select": "channel",
        "channelId": { "__rl": true, "mode": "name", "value": "={{ $('Config').first().json.SLACK_CHANNEL }}" },
        "text": "={{ $json.text }}",
        "otherOptions": {}
      },
      "id": "c14",
      "name": "Slack #ops",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.2,
      "position": [2420, 0],
      "credentials": { "slackApi": { "id": "REPLACE_ME", "name": "Slack" } }
    },
    {
      "parameters": {
        "jsCode": "// Google refused a write. Most common: 409 on create (id already exists: a previous run made it and\n// the lookup failed), 403 rate limit / quota, 404 on update or delete (someone deleted it by hand).\nconst failed = $input.all().map(i => `${i.json.orderNumber ?? i.json.eventId ?? '?'}: ${i.json.error?.message ?? JSON.stringify(i.json.error ?? i.json).slice(0, 200)}`);\nreturn [{ json: { text: `Calendar sync FAILED on ${failed.length} event(s):\\n` + failed.join('\\n') } }];"
      },
      "id": "c15",
      "name": "Failures → text",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [2200, 300]
    }
  ],
  "connections": {
    "Every 15 minutes": { "main": [[{ "node": "Config", "type": "main", "index": 0 }]] },
    "Config": { "main": [[{ "node": "GET orders delivering in window", "type": "main", "index": 0 }]] },
    "GET orders delivering in window": { "main": [[{ "node": "Prepare events", "type": "main", "index": 0 }]] },
    "Prepare events": { "main": [[{ "node": "Find existing event by id", "type": "main", "index": 0 }]] },
    "Find existing event by id": {
      "main": [
        [{ "node": "Plan: create / update / delete / skip", "type": "main", "index": 0 }],
        [{ "node": "Plan: create / update / delete / skip", "type": "main", "index": 0 }]
      ]
    },
    "Plan: create / update / delete / skip": { "main": [[{ "node": "Something to do?", "type": "main", "index": 0 }]] },
    "Something to do?": { "main": [[{ "node": "Dry run?", "type": "main", "index": 0 }]] },
    "Dry run?": {
      "main": [
        [{ "node": "Summary line", "type": "main", "index": 0 }],
        [{ "node": "Which write?", "type": "main", "index": 0 }]
      ]
    },
    "Which write?": {
      "main": [
        [{ "node": "Create event (id = order)", "type": "main", "index": 0 }],
        [{ "node": "Update event", "type": "main", "index": 0 }],
        [{ "node": "Delete event (order canceled)", "type": "main", "index": 0 }],
        [{ "node": "Summary line", "type": "main", "index": 0 }]
      ]
    },
    "Create event (id = order)": {
      "main": [
        [{ "node": "Summary line", "type": "main", "index": 0 }],
        [{ "node": "Failures → text", "type": "main", "index": 0 }]
      ]
    },
    "Update event": {
      "main": [
        [{ "node": "Summary line", "type": "main", "index": 0 }],
        [{ "node": "Failures → text", "type": "main", "index": 0 }]
      ]
    },
    "Delete event (order canceled)": {
      "main": [
        [{ "node": "Summary line", "type": "main", "index": 0 }],
        [{ "node": "Failures → text", "type": "main", "index": 0 }]
      ]
    },
    "Summary line": { "main": [[{ "node": "Slack #ops", "type": "main", "index": 0 }]] },
    "Failures → text": { "main": [[{ "node": "Slack #ops", "type": "main", "index": 0 }]] }
  },
  "settings": { "executionOrder": "v1", "timezone": "America/Los_Angeles" },
  "pinData": {},
  "tags": [{ "name": "distru" }, { "name": "no-bullshit-ai-course" }]
}
