{
  "name": "Distru: set orders to DELIVERING on delivery day",
  "meta": {
    "description": "version 1.1 (2026-09-17). Every morning, fetch READY_TO_SHIP sales orders whose delivery_datetime falls today, set them to DELIVERING, post one summary line to Slack. Pure automation, no LLM. From Distru's No Bullshit AI Course (module 05, lesson 2). Leave DRY_RUN=true in the Config node for the first runs: the workflow then only reports what it would change. Endpoints, filter names and status values checked against the Distru public API order controller on 2026-09-17: GET /public/v1/orders?delivery_datetime=after,before&statuses[]=READY_TO_SHIP returns {data, next_page}; POST /public/v1/orders with {id, status} is a sparse update. DELIVERING needs every line item fulfilled and, for package-tracked items, a compliance transfer attached; orders that are not ready come back as a 400 and land in the failure branch. Credentials: an n8n Header Auth credential with name 'Authorization' and value 'Bearer YOUR_API_TOKEN' (token from Distru Settings > Distru API), plus a Slack credential. Set the workflow timezone in Settings before enabling the schedule. MIT.",
    "instanceId": "no-bullshit-ai-course"
  },
  "nodes": [
    {
      "parameters": {
        "rule": { "interval": [{ "field": "cronExpression", "expression": "0 6 * * *" }] }
      },
      "id": "n1",
      "name": "Every morning 06:00",
      "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": "DRY_RUN", "value": "true", "type": "string" },
            { "id": "a3", "name": "MAX_ORDERS_PER_RUN", "value": "50", "type": "string" },
            { "id": "a4", "name": "DAY_START", "value": "={{ $now.startOf('day').toISO() }}", "type": "string" },
            { "id": "a5", "name": "DAY_END", "value": "={{ $now.endOf('day').toISO() }}", "type": "string" },
            { "id": "a6", "name": "FROM_STATUS", "value": "READY_TO_SHIP", "type": "string" },
            { "id": "a7", "name": "TO_STATUS", "value": "DELIVERING", "type": "string" },
            { "id": "a8", "name": "SLACK_CHANNEL", "value": "#ops", "type": "string" }
          ]
        },
        "options": {}
      },
      "id": "n2",
      "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.DAY_START }},{{ $('Config').item.json.DAY_END }}" },
            { "name": "statuses[]", "value": "={{ $('Config').item.json.FROM_STATUS }}" }
          ]
        },
        "options": { "response": { "response": { "fullResponse": false } } }
      },
      "id": "n3",
      "name": "GET today's READY_TO_SHIP orders",
      "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": "// GET /orders returns { data: [...], next_page: url|null }. One n8n item per order.\n// If next_page is set there were more orders than one page; we stop and say so rather than guess.\nconst body = $input.first().json;\nconst orders = Array.isArray(body) ? body : (body.data ?? []);\nconst truncated = Boolean(body.next_page);\nreturn orders.map(o => ({ json: { ...o, _truncated: truncated } }));"
      },
      "id": "n4",
      "name": "One item per order",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [660, 0]
    },
    {
      "parameters": {
        "conditions": {
          "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict" },
          "conditions": [
            {
              "id": "c1",
              "leftValue": "={{ $json.status }}",
              "rightValue": "={{ $('Config').item.json.FROM_STATUS }}",
              "operator": { "type": "string", "operation": "equals" }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "n5",
      "name": "Still READY_TO_SHIP (belt and braces)",
      "type": "n8n-nodes-base.filter",
      "typeVersion": 2,
      "position": [880, 0]
    },
    {
      "parameters": { "maxItems": "={{ Number($('Config').item.json.MAX_ORDERS_PER_RUN) }}", "keep": "firstItems" },
      "id": "n6",
      "name": "Cap per run",
      "type": "n8n-nodes-base.limit",
      "typeVersion": 1,
      "position": [1100, 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": "n7",
      "name": "Dry run?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [1320, 0]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $('Config').item.json.BASE_URL }}/orders",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ id: $json.id, status: $('Config').item.json.TO_STATUS }) }}",
        "options": { "batching": { "batch": { "batchSize": 1, "batchInterval": 500 } } }
      },
      "id": "n8",
      "name": "POST status = DELIVERING",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [1540, 120],
      "credentials": { "httpHeaderAuth": { "id": "REPLACE_ME", "name": "Distru API (Authorization: Bearer ...)" } },
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "jsCode": "// One summary line. Works for both the dry-run and the live branch.\nconst items = $input.all();\nconst cfg = $('Config').first().json;\nconst dry = cfg.DRY_RUN === 'true';\nconst ids = items.map(i => i.json.order_number ?? i.json.id).join(', ');\nconst truncated = items.some(i => i.json._truncated);\nlet text = dry\n  ? `DRY RUN: would set ${items.length} order(s) to ${cfg.TO_STATUS} today: ${ids || 'none'}`\n  : `Set ${items.length} order(s) to ${cfg.TO_STATUS} today: ${ids || 'none'}`;\nif (truncated) text += `\\nNOTE: more than one page of orders matched; only the first page was handled. Run again or follow next_page.`;\nreturn [{ json: { text } }];"
      },
      "id": "n9",
      "name": "Summary line",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [1760, 0]
    },
    {
      "parameters": {
        "select": "channel",
        "channelId": { "__rl": true, "mode": "name", "value": "={{ $('Config').first().json.SLACK_CHANNEL }}" },
        "text": "={{ $json.text }}",
        "otherOptions": {}
      },
      "id": "n10",
      "name": "Slack #ops",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.2,
      "position": [1980, 0],
      "credentials": { "slackApi": { "id": "REPLACE_ME", "name": "Slack" } }
    },
    {
      "parameters": {
        "jsCode": "// Failed writes land here. A 400 usually means the order is not ready for DELIVERING\n// (unfulfilled line item, no customer, or no compliance transfer attached). Post them so a human sees them.\nconst failed = $input.all().map(i => {\n  const o = i.json;\n  const msg = o.error?.message ?? (Array.isArray(o.errors) ? o.errors.map(e => e.message).join('; ') : JSON.stringify(o.error ?? o));\n  return `${o.order_number ?? o.id ?? '?'}: ${msg}`;\n});\nreturn [{ json: { text: `FAILED to update ${failed.length} order(s):\\n` + failed.join('\\n') } }];"
      },
      "id": "n11",
      "name": "Failures → text",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [1760, 260]
    }
  ],
  "connections": {
    "Every morning 06:00": { "main": [[{ "node": "Config", "type": "main", "index": 0 }]] },
    "Config": { "main": [[{ "node": "GET today's READY_TO_SHIP orders", "type": "main", "index": 0 }]] },
    "GET today's READY_TO_SHIP orders": { "main": [[{ "node": "One item per order", "type": "main", "index": 0 }]] },
    "One item per order": { "main": [[{ "node": "Still READY_TO_SHIP (belt and braces)", "type": "main", "index": 0 }]] },
    "Still READY_TO_SHIP (belt and braces)": { "main": [[{ "node": "Cap per run", "type": "main", "index": 0 }]] },
    "Cap per run": { "main": [[{ "node": "Dry run?", "type": "main", "index": 0 }]] },
    "Dry run?": {
      "main": [
        [{ "node": "Summary line", "type": "main", "index": 0 }],
        [{ "node": "POST status = DELIVERING", "type": "main", "index": 0 }]
      ]
    },
    "POST status = DELIVERING": {
      "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" }]
}
