{
  "id": "5XbBCTfQqteczaNfkok_t",
  "name": "Plan delivery routes from Notion orders and email the schedule",
  "tags": [],
  "nodes": [
    {
      "id": "fab3ff69-4e04-4b97-8d2c-6dc467082007",
      "name": "Overview and setup",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        0,
        -32
      ],
      "parameters": {
        "width": 1360,
        "height": 884,
        "content": "## Plan delivery routes from Notion orders and email the schedule\n\nBatch delivery orders into a Notion database, then on a schedule email an optimized route plan grouped by delivery day, with nearest-neighbor stop ordering and per-stop payment tags.\n\n## Who's it for\n\nSmall delivery operations such as lunch box services, home bakeries, and meal prep rounds that collect orders over time and plan the driving route by hand.\n\n## How it works\n\nA form saves each order to Notion. On a schedule (Friday 6pm by default) the workflow reads pending orders, geocodes addresses through OpenStreetMap Nominatim (free, no API key), sorts each delivery day's stops by nearest-neighbor distance, and emails a plan with [PAID] or [COLLECT ON DELIVERY] tagged on every stop.\n\n## How to set up\n\n1. Create a Notion database with these properties (names and select options are case-sensitive): Customer Name (Title), Order ID (Text), Phone (Phone), Address (Text), Items (Text), Total (Number), Day (Select: Saturday, Sunday), Paid (Checkbox), Notes (Text), Status (Select: pending, done), Received At (Date).\n2. Create a Notion integration (Internal) at notion.so/profile/integrations, copy the secret, and connect it to the database via the database ... menu, Connections.\n3. In n8n add a Notion API credential and an SMTP credential.\n4. Paste your Notion database ID into both Notion nodes (Notion: Create Order and Notion: Get Pending).\n5. Open Email Plan, assign the SMTP credential, and set From and To to real addresses.\n6. Open Order Intake Form, copy the Production URL (bookmark it as your order form), then activate the workflow.\n\n## Requirements\n\n- An n8n instance (cloud or self-hosted)\n- A Notion workspace and an Internal integration\n- Any SMTP account (Gmail app password, Sendgrid, or your own server)\n\n## How to customize\n\n- Change the schedule or the Delivery Day options for non-weekend rounds.\n- Swap Email Plan for a Telegram or Slack node to receive the plan on your phone.\n- Switch to a paid geocoder (Google, Mapbox) for large order volumes."
      },
      "typeVersion": 1
    },
    {
      "id": "f9662cd6-3a4d-4684-aae9-f7766ebcbd15",
      "name": "Intake section",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        16,
        896
      ],
      "parameters": {
        "color": 5,
        "width": 312,
        "height": 180,
        "content": "### Intake\n\nForm \u2192 Save Order \u2192 Notion. Each submission creates one page in the Notion database.\n\nPayment Status set here is reflected on the planned route."
      },
      "typeVersion": 1
    },
    {
      "id": "b812b3e3-9908-4def-952f-43e22a667a48",
      "name": "Planning section",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        16,
        1152
      ],
      "parameters": {
        "color": 4,
        "width": 296,
        "height": 204,
        "content": "### Planning\n\nNotion (filter status=pending) \u2192 Load Pending Orders \u2192 geocode \u2192 optimize \u2192 email.\n\nGeocode rate-limited to 1 req/1.1s for Nominatim. First entry per day anchors the nearest-neighbor sort."
      },
      "typeVersion": 1
    },
    {
      "id": "761e2b2d-ab5d-440f-9332-1603dcf4d3cf",
      "name": "Order Intake Form",
      "type": "n8n-nodes-base.formTrigger",
      "position": [
        368,
        928
      ],
      "parameters": {
        "path": "bd511d74-2d79-47b9-8cf8-93802737c7f0",
        "options": {},
        "formTitle": "New Delivery Order",
        "formFields": {
          "values": [
            {
              "fieldLabel": "Customer Name",
              "requiredField": true
            },
            {
              "fieldLabel": "Phone",
              "requiredField": true
            },
            {
              "fieldType": "textarea",
              "fieldLabel": "Delivery Address",
              "placeholder": "Street, city, state, ZIP",
              "requiredField": true
            },
            {
              "fieldType": "textarea",
              "fieldLabel": "Order Details",
              "placeholder": "What they ordered, e.g. 2 biryani, 1 naan",
              "requiredField": true
            },
            {
              "fieldType": "number",
              "fieldLabel": "Total",
              "placeholder": "Dollar amount",
              "requiredField": true
            },
            {
              "fieldType": "dropdown",
              "fieldLabel": "Delivery Day",
              "fieldOptions": {
                "values": [
                  {
                    "option": "Saturday"
                  },
                  {
                    "option": "Sunday"
                  }
                ]
              },
              "requiredField": true
            },
            {
              "fieldType": "dropdown",
              "fieldLabel": "Payment Status",
              "fieldOptions": {
                "values": [
                  {
                    "option": "Paid"
                  },
                  {
                    "option": "Collect on delivery"
                  }
                ]
              },
              "requiredField": true
            },
            {
              "fieldType": "textarea",
              "fieldLabel": "Notes"
            }
          ]
        },
        "responseMode": "lastNode",
        "formDescription": "Add a new order to the delivery batch."
      },
      "typeVersion": 2.1
    },
    {
      "id": "84364596-334c-4a8e-870f-64265a550b31",
      "name": "Save Order",
      "type": "n8n-nodes-base.code",
      "position": [
        640,
        928
      ],
      "parameters": {
        "jsCode": "const form = $input.first().json;\nconst orderId = 'ORD-' + Date.now().toString(36).toUpperCase().slice(-6);\n\nreturn [{\n  json: {\n    orderId,\n    customer: form['Customer Name'],\n    phone: form['Phone'],\n    address: form['Delivery Address'],\n    items: form['Order Details'],\n    total: Number(form['Total']) || 0,\n    day: form['Delivery Day'],\n    paid: form['Payment Status'] === 'Paid',\n    notes: form['Notes'] || '',\n    status: 'pending',\n    receivedAt: new Date().toISOString(),\n    message: `Order ${orderId} received. ${form['Customer Name']} for ${form['Delivery Day']}.`\n  }\n}];\n"
      },
      "typeVersion": 2,
      "alwaysOutputData": true
    },
    {
      "id": "91dbf8ee-b2a0-4e65-8bf0-64ed2d1d1751",
      "name": "Notion: Create Order",
      "type": "n8n-nodes-base.notion",
      "position": [
        912,
        928
      ],
      "parameters": {
        "title": "={{ $json.customer }}",
        "options": {},
        "resource": "databasePage",
        "databaseId": {
          "__rl": true,
          "mode": "id",
          "value": "YOUR_NOTION_DATABASE_ID_HERE"
        },
        "propertiesUi": {
          "propertyValues": [
            {
              "key": "Order ID|rich_text",
              "textContent": "={{ $json.orderId }}"
            },
            {
              "key": "Phone|phone_number",
              "phoneValue": "={{ $json.phone }}"
            },
            {
              "key": "Address|rich_text",
              "textContent": "={{ $json.address }}"
            },
            {
              "key": "Items|rich_text",
              "textContent": "={{ $json.items }}"
            },
            {
              "key": "Total|number",
              "numberValue": "={{ $json.total }}"
            },
            {
              "key": "Day|select",
              "selectValue": "={{ $json.day }}"
            },
            {
              "key": "Paid|checkbox",
              "checkboxValue": "={{ $json.paid }}"
            },
            {
              "key": "Notes|rich_text",
              "textContent": "={{ $json.notes }}"
            },
            {
              "key": "Status|select",
              "selectValue": "pending"
            },
            {
              "key": "Received At|date",
              "date": "={{ $json.receivedAt }}"
            }
          ]
        }
      },
      "typeVersion": 2
    },
    {
      "id": "90dd78d7-07dd-403f-8ed3-24b9d8ba55ac",
      "name": "Friday 6pm Plan",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [
        368,
        1136
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "cronExpression",
              "expression": "0 18 * * 5"
            }
          ]
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "8ebdb8ae-f834-46d4-b234-95ac51e53b40",
      "name": "Test Run",
      "type": "n8n-nodes-base.manualTrigger",
      "position": [
        368,
        1280
      ],
      "parameters": {},
      "typeVersion": 1
    },
    {
      "id": "a12d139f-787d-4259-a8ca-23587869b799",
      "name": "Notion: Get Pending",
      "type": "n8n-nodes-base.notion",
      "position": [
        592,
        1216
      ],
      "parameters": {
        "simple": false,
        "filters": {
          "conditions": [
            {
              "key": "Status|select",
              "condition": "equals",
              "selectValue": "pending"
            }
          ]
        },
        "options": {},
        "resource": "databasePage",
        "operation": "getAll",
        "returnAll": true,
        "databaseId": {
          "__rl": true,
          "mode": "id",
          "value": "YOUR_NOTION_DATABASE_ID_HERE"
        },
        "filterType": "manual"
      },
      "typeVersion": 2,
      "alwaysOutputData": true
    },
    {
      "id": "4c1b0db2-d6df-4976-8090-2805e70ec693",
      "name": "Load Pending Orders",
      "type": "n8n-nodes-base.code",
      "position": [
        784,
        1216
      ],
      "parameters": {
        "jsCode": "const items = $input.all();\nconst realPages = items.filter(item => item.json && item.json.id && item.json.properties);\n\nif (realPages.length === 0) {\n  return [{ json: { _empty: true } }];\n}\n\nconst text = (prop) => {\n  if (!prop) return '';\n  if (Array.isArray(prop.title)) return prop.title.map(t => t.plain_text).join('');\n  if (Array.isArray(prop.rich_text)) return prop.rich_text.map(t => t.plain_text).join('');\n  return '';\n};\n\nconst allOrders = realPages.map(item => {\n  const props = item.json.properties || {};\n  return {\n    id: text(props['Order ID']),\n    customer: text(props['Customer Name']),\n    phone: (props['Phone'] && props['Phone'].phone_number) || '',\n    address: text(props['Address']),\n    items: text(props['Items']),\n    total: (props['Total'] && props['Total'].number) || 0,\n    day: (props['Day'] && props['Day'].select && props['Day'].select.name) || '',\n    paid: (props['Paid'] && props['Paid'].checkbox) || false,\n    notes: text(props['Notes']),\n    status: (props['Status'] && props['Status'].select && props['Status'].select.name) || 'pending',\n    receivedAt: (props['Received At'] && props['Received At'].date && props['Received At'].date.start) || ''\n  };\n});\n\nconst pending = allOrders.filter(o => o.status === 'pending');\n\nif (pending.length === 0) {\n  return [{ json: { _empty: true } }];\n}\n\nreturn pending.map(order => ({ json: order }));\n"
      },
      "typeVersion": 2
    },
    {
      "id": "e5228cf1-c320-43a8-9bdf-d65450ea9495",
      "name": "Geocode (Nominatim)",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        992,
        1216
      ],
      "parameters": {
        "url": "https://nominatim.openstreetmap.org/search",
        "options": {
          "batching": {
            "batch": {
              "batchSize": 1,
              "batchInterval": 1100
            }
          },
          "response": {
            "response": {
              "neverError": true
            }
          }
        },
        "sendQuery": true,
        "sendHeaders": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "q",
              "value": "={{ $json.address || '' }}"
            },
            {
              "name": "format",
              "value": "json"
            },
            {
              "name": "limit",
              "value": "1"
            }
          ]
        },
        "headerParameters": {
          "parameters": [
            {
              "name": "User-Agent",
              "value": "local-delivery-route-planner (n8n template)"
            }
          ]
        }
      },
      "typeVersion": 4.2,
      "alwaysOutputData": true
    },
    {
      "id": "20fb5cc3-8e37-4778-9c2a-655ce31ade76",
      "name": "Optimize Routes & Format",
      "type": "n8n-nodes-base.code",
      "position": [
        1184,
        1216
      ],
      "parameters": {
        "jsCode": "const geoResults = $input.all();\nconst originals = $('Load Pending Orders').all();\n\nif (originals.length === 1 && originals[0].json._empty) {\n  return [{\n    json: {\n      empty: true,\n      markdown: '# Delivery Plan\\n\\nNo pending orders right now.'\n    }\n  }];\n}\n\nconst enriched = originals.map((item, i) => {\n  const order = item.json;\n  const geoRaw = geoResults[i] ? geoResults[i].json : null;\n  let lat = null;\n  let lon = null;\n  const result = Array.isArray(geoRaw) ? geoRaw[0] : geoRaw;\n  if (result && result.lat) {\n    lat = parseFloat(result.lat);\n    lon = parseFloat(result.lon);\n  }\n  return { ...order, lat, lon, geocodeFailed: lat === null };\n});\n\nfunction haversine(a, b) {\n  if (a.lat == null || b.lat == null) return Infinity;\n  const R = 6371;\n  const toRad = (d) => d * Math.PI / 180;\n  const dLat = toRad(b.lat - a.lat);\n  const dLon = toRad(b.lon - a.lon);\n  const lat1 = toRad(a.lat);\n  const lat2 = toRad(b.lat);\n  const h = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2;\n  return R * 2 * Math.atan2(Math.sqrt(h), Math.sqrt(1 - h));\n}\n\nfunction optimize(stops) {\n  if (stops.length <= 1) return stops;\n  const valid = stops.filter(s => s.lat != null);\n  const invalid = stops.filter(s => s.lat == null);\n  if (valid.length === 0) return stops;\n  const route = [valid[0]];\n  const remaining = valid.slice(1);\n  while (remaining.length > 0) {\n    const current = route[route.length - 1];\n    let bestIdx = 0;\n    let bestDist = Infinity;\n    for (let i = 0; i < remaining.length; i++) {\n      const d = haversine(current, remaining[i]);\n      if (d < bestDist) {\n        bestDist = d;\n        bestIdx = i;\n      }\n    }\n    route.push(remaining.splice(bestIdx, 1)[0]);\n  }\n  return [...route, ...invalid];\n}\n\nconst saturday = optimize(enriched.filter(o => o.day === 'Saturday'));\nconst sunday = optimize(enriched.filter(o => o.day === 'Sunday'));\n\nfunction formatDay(label, stops) {\n  if (stops.length === 0) return `## ${label}\\n\\nNo deliveries planned.\\n\\n`;\n  let total = 0;\n  let unpaid = 0;\n  let md = `## ${label} (${stops.length} ${stops.length === 1 ? 'stop' : 'stops'})\\n\\n`;\n  stops.forEach((s, i) => {\n    total += s.total;\n    if (!s.paid) unpaid += s.total;\n    md += `### Stop ${i + 1}: ${s.customer}\\n`;\n    md += `- Order ID: ${s.id}\\n`;\n    md += `- Phone: ${s.phone}\\n`;\n    md += `- Address: ${s.address}\\n`;\n    md += `- Items: ${s.items}\\n`;\n    md += `- Total: $${s.total.toFixed(2)} ${s.paid ? '[PAID]' : '[COLLECT ON DELIVERY]'}\\n`;\n    if (s.notes) md += `- Notes: ${s.notes}\\n`;\n    if (s.geocodeFailed) md += `- [WARN] Address could not be geocoded, placed at end of route\\n`;\n    md += '\\n';\n  });\n  md += `**${label} totals:** $${total.toFixed(2)} revenue`;\n  if (unpaid > 0) md += `, $${unpaid.toFixed(2)} to collect on route`;\n  md += '\\n\\n';\n  return md;\n}\n\nconst planDate = new Date().toISOString().slice(0, 10);\nconst markdown =\n  `# Delivery Plan (${planDate})\\n\\n` +\n  `${enriched.length} total orders, ${saturday.length} Saturday, ${sunday.length} Sunday.\\n\\n` +\n  formatDay('Saturday', saturday) +\n  formatDay('Sunday', sunday) +\n  `---\\n\\nRoutes optimized by nearest-neighbor on geocoded coordinates from Nominatim/OpenStreetMap.\\n\\nMark orders fulfilled in workflow static data once delivered.\\n`;\n\nreturn [{\n  json: {\n    planDate,\n    markdown,\n    saturday: {\n      stops: saturday.length,\n      revenue: saturday.reduce((s, o) => s + o.total, 0),\n      toCollect: saturday.filter(o => !o.paid).reduce((s, o) => s + o.total, 0),\n      orders: saturday\n    },\n    sunday: {\n      stops: sunday.length,\n      revenue: sunday.reduce((s, o) => s + o.total, 0),\n      toCollect: sunday.filter(o => !o.paid).reduce((s, o) => s + o.total, 0),\n      orders: sunday\n    }\n  }\n}];\n"
      },
      "typeVersion": 2
    },
    {
      "id": "00f566ac-790a-4fad-a220-08e450d7dbc9",
      "name": "Email Plan",
      "type": "n8n-nodes-base.emailSend",
      "position": [
        1392,
        1216
      ],
      "parameters": {
        "text": "={{ $json.markdown }}",
        "options": {},
        "subject": "={{ \"Delivery Plan \" + $json.planDate }}",
        "toEmail": "friend@example.com",
        "fromEmail": "you@example.com",
        "emailFormat": "text"
      },
      "typeVersion": 2.1
    },
    {
      "id": "784b7eba-397e-432c-8acd-52abfe47f8a0",
      "name": "Sticky Note",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        0,
        880
      ],
      "parameters": {
        "color": 5,
        "width": 1104,
        "height": 208,
        "content": ""
      },
      "typeVersion": 1
    },
    {
      "id": "045f5007-b8ca-4387-9e01-408da53f1153",
      "name": "Sticky Note1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        0,
        1120
      ],
      "parameters": {
        "color": 4,
        "width": 1584,
        "height": 304,
        "content": ""
      },
      "typeVersion": 1
    }
  ],
  "active": false,
  "settings": {
    "availableInMCP": false,
    "executionOrder": "v1"
  },
  "versionId": "4665b81d-78ce-46c5-bf73-d901db973b47",
  "connections": {
    "Test Run": {
      "main": [
        [
          {
            "node": "Notion: Get Pending",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Save Order": {
      "main": [
        [
          {
            "node": "Notion: Create Order",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Friday 6pm Plan": {
      "main": [
        [
          {
            "node": "Notion: Get Pending",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Order Intake Form": {
      "main": [
        [
          {
            "node": "Save Order",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Geocode (Nominatim)": {
      "main": [
        [
          {
            "node": "Optimize Routes & Format",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Load Pending Orders": {
      "main": [
        [
          {
            "node": "Geocode (Nominatim)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Notion: Get Pending": {
      "main": [
        [
          {
            "node": "Load Pending Orders",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Optimize Routes & Format": {
      "main": [
        [
          {
            "node": "Email Plan",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}