{
  "name": "Gaplessly - booking demand to Deputy",
  "active": false,
  "nodes": [
    {
      "id": "setup-notes",
      "name": "Setup notes",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -80,
        -620
      ],
      "parameters": {
        "width": 560,
        "height": 520,
        "content": "## Booking demand into Deputy\nEvery hour this reads the next 14 days of bookings and sends Deputy one value per venue hour: booked covers for a restaurant, booked hours for appointments. Deputy shows them beside its own sales and forecasts when you build the roster.\n1. In Gaplessly Developer, create a key with organization:read plus reservations:read (restaurant) or appointments:read (appointments). Make an n8n Header Auth credential named Gaplessly: Name Authorization, Value Bearer YOUR_KEY. Choose it in Read organization and Read bookings.\n2. In Deputy, create a custom metric in Business Insights named BookedCovers (or BookedHours). Deputy refuses data for a metric it does not have.\n3. In Deputy, create an API access token for your install. Make a second Header Auth credential named Deputy: Name Authorization, Value is Bearer, a space, then your Deputy token. Choose it in Send to Deputy.\n4. Add n8n Variables: DEPUTY_INSTALL (for example https://yourvenue.au.deputy.com), DEPUTY_AREA_ID and DEPUTY_LOCATION_ID (the Deputy area and location ids), and optionally DEPUTY_METRIC if your metric has another name.\n5. Run it once by hand, check the values in Deputy, then activate it.\nOnly counts and hours leave Gaplessly. No guest names, contact details or notes are sent."
      }
    },
    {
      "id": "every-hour",
      "name": "Every hour",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        0,
        0
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "hours"
            }
          ]
        }
      }
    },
    {
      "id": "read-organization",
      "name": "Read organization",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        220,
        0
      ],
      "parameters": {
        "url": "https://gaplessly.com/api/v1/organization",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "options": {
          "timeout": 15000
        }
      }
    },
    {
      "id": "window",
      "name": "Window",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        440,
        0
      ],
      "parameters": {
        "jsCode": "\nfunction offsetAt(ms, tz) {\n  const p = Object.fromEntries(\n    new Intl.DateTimeFormat('en-US', { timeZone: tz, hourCycle: 'h23', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' })\n      .formatToParts(new Date(ms))\n      .map((x) => [x.type, x.value]),\n  );\n  return Date.UTC(+p.year, +p.month - 1, +p.day, +p.hour, +p.minute, +p.second) - ms;\n}\n// \"2026-10-24T19:00:00\" read as local time in tz, to milliseconds since the epoch.\nfunction zonedMs(naive, tz) {\n  const asUtc = Date.parse(naive.slice(0, 19) + 'Z');\n  const first = asUtc - offsetAt(asUtc, tz);\n  return asUtc - offsetAt(first, tz);\n}\nfunction venueToday(tz) {\n  const p = Object.fromEntries(\n    new Intl.DateTimeFormat('en-US', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit' })\n      .formatToParts(new Date())\n      .map((x) => [x.type, x.value]),\n  );\n  return p.year + '-' + p.month + '-' + p.day;\n}\nconst addDays = (d, n) => new Date(Date.parse(d + 'T00:00:00Z') + n * 86400000).toISOString().slice(0, 10);\n\nconst org = $input.first().json.data;\nif (!org || !org.timezone) throw new Error('Read organization returned no timezone. The key needs organization:read.');\nconst from = venueToday(org.timezone);\nconst to = addDays(from, 14);\nreturn [{ json: {\n  timezone: org.timezone,\n  resource: org.businessType === 'hospitality' ? 'reservations' : 'appointments',\n  // Venue wall clock, the clock from and to are compared on (see docs/api-keys.md).\n  from: from + 'T00:00:00',\n  to: to + 'T00:00:00',\n  firstDay: from,\n  days: 14,\n} }];"
      }
    },
    {
      "id": "read-bookings",
      "name": "Read bookings",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        660,
        0
      ],
      "parameters": {
        "url": "=https://gaplessly.com/api/v1/{{ $json.resource }}",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendQuery": true,
        "specifyQuery": "json",
        "jsonQuery": "={{ { from: $json.from, to: $json.to, limit: 200 } }}",
        "options": {
          "timeout": 15000,
          "pagination": {
            "pagination": {
              "paginationMode": "updateAParameterInEachRequest",
              "parameters": {
                "parameters": [
                  {
                    "type": "qs",
                    "name": "cursor",
                    "value": "={{ $response.body.nextCursor }}"
                  }
                ]
              },
              "paginationCompleteWhen": "other",
              "completeExpression": "={{ $response.body.nextCursor === null }}",
              "limitPagesFetched": true,
              "maxRequests": 50
            }
          }
        }
      }
    },
    {
      "id": "demand-by-hour",
      "name": "Demand by hour",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        880,
        0
      ],
      "parameters": {
        "jsCode": "\nfunction offsetAt(ms, tz) {\n  const p = Object.fromEntries(\n    new Intl.DateTimeFormat('en-US', { timeZone: tz, hourCycle: 'h23', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' })\n      .formatToParts(new Date(ms))\n      .map((x) => [x.type, x.value]),\n  );\n  return Date.UTC(+p.year, +p.month - 1, +p.day, +p.hour, +p.minute, +p.second) - ms;\n}\n// \"2026-10-24T19:00:00\" read as local time in tz, to milliseconds since the epoch.\nfunction zonedMs(naive, tz) {\n  const asUtc = Date.parse(naive.slice(0, 19) + 'Z');\n  const first = asUtc - offsetAt(asUtc, tz);\n  return asUtc - offsetAt(first, tz);\n}\nfunction venueToday(tz) {\n  const p = Object.fromEntries(\n    new Intl.DateTimeFormat('en-US', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit' })\n      .formatToParts(new Date())\n      .map((x) => [x.type, x.value]),\n  );\n  return p.year + '-' + p.month + '-' + p.day;\n}\nconst addDays = (d, n) => new Date(Date.parse(d + 'T00:00:00Z') + n * 86400000).toISOString().slice(0, 10);\n\nconst w = $('Window').first().json;\nconst area = Number($vars.DEPUTY_AREA_ID);\nconst location = Number($vars.DEPUTY_LOCATION_ID);\nif (!Number.isInteger(area) || !Number.isInteger(location)) {\n  throw new Error('Set the n8n Variables DEPUTY_AREA_ID and DEPUTY_LOCATION_ID to the Deputy area and location ids.');\n}\nconst hospitality = w.resource === 'reservations';\nconst type = String($vars.DEPUTY_METRIC || (hospitality ? 'BookedCovers' : 'BookedHours'));\nconst SKIP = new Set(['cancelled', 'no_show']);\n\n// One bucket per venue hour in the window, zeros included: a hour that loses its\n// last booking must overwrite the value Deputy already holds, through the same reference.\nconst buckets = new Map();\nfor (let d = 0; d < w.days; d++) {\n  const day = addDays(w.firstDay, d);\n  for (let h = 0; h < 24; h++) buckets.set(day + 'T' + String(h).padStart(2, '0'), 0);\n}\nfor (const page of $input.all()) {\n  for (const b of page.json.data || []) {\n    if (SKIP.has(b.status)) continue;\n    // startsAt is the venue wall clock wearing a +00 that is not true; the digits are what count.\n    const key = b.startsAt.slice(0, 13);\n    if (!buckets.has(key)) continue;\n    const value = hospitality\n      ? Number(b.partySize) || 0\n      : // The +00 the API returns has no minutes, which Date.parse refuses; the digits are what count.\n        Math.max(0, (Date.parse(b.endsAt.slice(0, 19) + 'Z') - Date.parse(b.startsAt.slice(0, 19) + 'Z')) / 3600000);\n    buckets.set(key, buckets.get(key) + value);\n  }\n}\nconst data = [...buckets].map(([hour, value]) => ({\n  timestamp: Math.floor(zonedMs(hour + ':00:00', w.timezone) / 1000),\n  area,\n  location,\n  type,\n  value: Math.round(value * 100) / 100,\n  // Stable per area, metric and hour, so every run overwrites rather than adds.\n  reference: 'gaplessly-' + type + '-' + area + '-' + hour,\n}));\nreturn [{ json: { data } }];"
      }
    },
    {
      "id": "send-to-deputy",
      "name": "Send to Deputy",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1100,
        0
      ],
      "parameters": {
        "method": "POST",
        "url": "={{ $vars.DEPUTY_INSTALL }}/api/v2/metrics:bulk",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify($json) }}",
        "options": {
          "timeout": 30000
        }
      }
    }
  ],
  "connections": {
    "Every hour": {
      "main": [
        [
          {
            "node": "Read organization",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read organization": {
      "main": [
        [
          {
            "node": "Window",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Window": {
      "main": [
        [
          {
            "node": "Read bookings",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read bookings": {
      "main": [
        [
          {
            "node": "Demand by hour",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Demand by hour": {
      "main": [
        [
          {
            "node": "Send to Deputy",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  }
}
