{
  "name": "Gaplessly - create a booking",
  "active": false,
  "nodes": [
    {
      "id": "setup-notes",
      "name": "Setup notes",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -80,
        -420
      ],
      "parameters": {
        "content": "## Create a table booking or appointment\n1. Self-hosted n8n: start n8n with NODE_FUNCTION_ALLOW_BUILTIN=crypto (n8n Cloud allows it).\n2. Create a Header Auth credential: Name Authorization, Value Bearer followed by a Gaplessly key with organization:read plus reservations:write or appointments:write. Start with a Test (sandbox) key. Select it on both HTTP Request nodes.\n3. Replace Run manually with your real trigger and map Inputs from it. sourceEventId must be a stable id from that source event: the same on a retry, new for every intended booking.\n4. startsAt is the business local time, YYYY-MM-DDTHH:MM, with no offset.\n5. Only a 201 counts. A deposit or card requirement (402 payment_required) and a conflict (409) fail the run on purpose; nothing is booked.",
        "height": 320,
        "width": 520
      }
    },
    {
      "id": "start",
      "name": "Run manually",
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "position": [
        0,
        0
      ],
      "parameters": {}
    },
    {
      "id": "inputs",
      "name": "Inputs",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        220,
        0
      ],
      "parameters": {
        "mode": "raw",
        "jsonOutput": "{\"sourceEventId\":\"\",\"startsAt\":\"\",\"partySize\":2,\"serviceId\":\"\",\"providerId\":\"\",\"name\":\"\",\"email\":\"\",\"phone\":\"\",\"notes\":\"\"}"
      },
      "notesInFlow": true,
      "notes": "Restaurants: partySize. Appointments: serviceId and providerId. Phone may be required by the business. No secrets here."
    },
    {
      "id": "organization",
      "name": "Read organization",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        440,
        0
      ],
      "parameters": {
        "url": "https://gaplessly.com/api/v1/organization",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "options": {
          "timeout": 15000
        }
      },
      "notesInFlow": true,
      "notes": "Header Auth credential: Authorization = Bearer followed by your key. Tells the workflow which booking engine this organization runs."
    },
    {
      "id": "build",
      "name": "Build booking request",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        660,
        0
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// Builds one create request for whichever engine the organization runs.\n// The Idempotency-Key is derived from sourceEventId, so a retry of the same\n// source event replays the first answer instead of booking twice.\nconst crypto = require('crypto');\nconst input = $('Inputs').first().json;\nconst org = ($('Read organization').first().json || {}).data || {};\n\nconst sourceId = String(input.sourceEventId || '').trim();\nif (!sourceId) throw new Error('Set sourceEventId to a stable id from the source event. Reuse it on a retry; use a new one for each intended booking.');\nconst resource = org.businessType === 'hospitality' ? 'reservations' : org.businessType === 'appointments' ? 'appointments' : null;\nif (!resource) throw new Error('Unknown Gaplessly booking engine: ' + org.businessType);\n\nconst startsAt = String(input.startsAt || '').trim();\n// Local wall clock only. The API drops an offset rather than applying it, so\n// one is refused here instead of silently booking the wrong hour.\nif (!/^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}(:\\d{2})?$/.test(startsAt)) {\n  throw new Error('startsAt must be the business local time, YYYY-MM-DDTHH:MM, with no Z or UTC offset');\n}\n\nconst customer = { name: String(input.name || '').trim(), email: String(input.email || '').trim() };\nif (!customer.name || !customer.email) throw new Error('name and email are required');\nif (input.phone) customer.phone = String(input.phone).trim();\n\nconst body = { startsAt, customer };\nif (input.notes) body.notes = String(input.notes);\nif (resource === 'reservations') {\n  const partySize = Number(input.partySize);\n  if (!Number.isInteger(partySize) || partySize < 1) throw new Error('partySize must be a whole number for a table booking');\n  body.partySize = partySize;\n} else {\n  if (!input.serviceId || !input.providerId) throw new Error('serviceId and providerId are required for an appointment');\n  body.serviceId = String(input.serviceId);\n  body.providerId = String(input.providerId);\n}\n\nconst hex = crypto.createHash('sha256').update('gaplessly:n8n:create:' + resource + ':' + sourceId).digest('hex');\nconst idempotencyKey = hex.slice(0, 8) + '-' + hex.slice(8, 12) + '-5' + hex.slice(13, 16) + '-a' + hex.slice(17, 20) + '-' + hex.slice(20, 32);\nreturn [{ json: { resource, idempotencyKey, body } }];\n"
      }
    },
    {
      "id": "create",
      "name": "Create booking",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        880,
        0
      ],
      "parameters": {
        "method": "POST",
        "url": "=https://gaplessly.com/api/v1/{{ $json.resource }}",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Idempotency-Key",
              "value": "={{ $json.idempotencyKey }}"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify($json.body) }}",
        "options": {
          "response": {
            "response": {
              "fullResponse": true,
              "neverError": true
            }
          },
          "timeout": 15000
        }
      },
      "notesInFlow": true,
      "notes": "Same Header Auth credential. Never Error is on so the next node can tell a refusal from a booking."
    },
    {
      "id": "check",
      "name": "Check result",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1100,
        0
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// Only a 201 is a booking. Everything else fails this execution on purpose,\n// so an Error Workflow or a retry sees it rather than a false success.\nconst response = $input.first().json;\nconst status = Number(response.statusCode);\nlet payload = response.body;\nif (typeof payload === 'string') {\n  try {\n    payload = JSON.parse(payload);\n  } catch (e) {\n    payload = { error: payload };\n  }\n}\npayload = payload || {};\n\nif (status === 201 && payload.data && payload.data.id) {\n  // status is \"confirmed\", or \"pending\" for a request the venue still has to accept.\n  return [{ json: { booked: true, bookingId: payload.data.id, status: payload.data.status, booking: payload.data } }];\n}\nif (status === 402 && payload.code === 'payment_required') {\n  throw new Error('Nothing was booked: this booking needs a deposit or card that only the guest can give. Send the guest this link: ' + (payload.bookingUrl || '(no booking page link returned)'));\n}\nif (status === 409) {\n  throw new Error('Nothing was booked (409): ' + (payload.error || 'conflict') + '. If it says the request is still in progress, run again with the same sourceEventId. Otherwise choose another time and a NEW sourceEventId, because a reused one replays this same answer.');\n}\nthrow new Error('Gaplessly refused the booking (HTTP ' + status + (payload.code ? ', ' + payload.code : '') + '): ' + (payload.error || 'no error message'));\n"
      }
    }
  ],
  "connections": {
    "Run manually": {
      "main": [
        [
          {
            "node": "Inputs",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Inputs": {
      "main": [
        [
          {
            "node": "Read organization",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read organization": {
      "main": [
        [
          {
            "node": "Build booking request",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build booking request": {
      "main": [
        [
          {
            "node": "Create booking",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Create booking": {
      "main": [
        [
          {
            "node": "Check result",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  },
  "pinData": {},
  "tags": []
}
