{
  "name": "MADIAD Hub - Publish from Google Sheets on a schedule",
  "nodes": [
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "minutes",
              "minutesInterval": 15
            }
          ]
        }
      },
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        -160,
        0
      ],
      "id": "Every 15 minutes",
      "name": "Every 15 minutes"
    },
    {
      "parameters": {
        "documentId": {
          "__rl": true,
          "value": "",
          "mode": "list",
          "cachedResultName": "[MADIAD Template] Publishing Schedule"
        },
        "sheetName": {
          "__rl": true,
          "value": "Config",
          "mode": "name"
        },
        "options": {}
      },
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.7,
      "executeOnce": true,
      "position": [
        60,
        0
      ],
      "id": "Read Config",
      "name": "Read Config"
    },
    {
      "parameters": {
        "documentId": {
          "__rl": true,
          "value": "",
          "mode": "list",
          "cachedResultName": "[MADIAD Template] Publishing Schedule"
        },
        "sheetName": {
          "__rl": true,
          "value": "Schedule",
          "mode": "name"
        },
        "options": {
          "dataLocationOnSheet": {
            "values": {
              "rangeDefinition": "specifyRange",
              "headerRow": 1,
              "firstDataRow": 3
            }
          }
        }
      },
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.7,
      "executeOnce": true,
      "position": [
        280,
        0
      ],
      "id": "Read Schedule",
      "name": "Read Schedule"
    },
    {
      "parameters": {
        "jsCode": "\nconst cfg = {};\nfor (const it of $('Read Config').all()) {\n  const k = String(it.json['Key'] ?? '').trim();\n  if (k) cfg[k] = String(it.json['Value'] ?? '').trim();\n}\nif (!cfg.profile_id) throw new Error('No profile_id in the Config tab. Copy it from your MADIAD Hub dashboard.');\nconst KENH = { 'Facebook': 'facebook', 'Instagram': 'instagram', 'TikTok': 'tiktok', 'YouTube': 'youtube', 'LinkedIn': 'linkedin', 'X': 'x', 'Threads': 'threads', 'Pinterest': 'pinterest', 'Reddit': 'reddit', 'Bluesky': 'bluesky', 'Google Business': 'google_business', 'Telegram': 'telegram', 'Discord': 'discord', 'Slack': 'slack', 'Mastodon': 'mastodon', 'Nostr': 'nostr', 'Lemmy': 'lemmy', 'dev.to': 'devto', 'Hashnode': 'hashnode', 'WordPress': 'wordpress', 'Whop': 'whop', 'listmonk': 'listmonk' };\nconst IMG = ['.jpg', '.jpeg', '.png', '.webp', '.gif'];\nconst VID = ['.mp4', '.mov', '.m4v', '.webm'];\nconst NEEDS_TITLE = ['youtube', 'reddit', 'pinterest'];\nconst OPEN = ['', 'Draft', 'Queued'];\n\nconst SYS_COLS_ = ['Post ID', 'Status', 'Request ID', 'Post Link',\n  'Platform Post IDs', 'Published At', 'Error'];\nfunction hash32_(s, h) {\n  for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619) >>> 0; }\n  return h >>> 0;\n}\n// Namespace = which sheet + which profile. The gateway scopes keys per customer, not per sheet: two\n// copies of the template under one API key with the same content at the same row on the same day\n// would otherwise share a key (review 22/09). Both wf-01 and wf-05 read the same sheet, so both see\n// the same documentId. Reading it can fail in an old n8n — then the profile alone still separates.\nfunction sheetNs_(cfg) {\n  let doc = '';\n  try { const d = $('Read Schedule').params.documentId; doc = String((d && (d.value ?? d)) || ''); } catch (e) { doc = ''; }\n  return String(cfg.profile_id || '') + '|' + doc;\n}\n// Known limit, accepted: two runs that straddle midnight in the configured timezone (seconds apart)\n// get different day parts and two keys. Dropping the day is worse — a row cleared and re-pasted with\n// the same content on a later day would replay the old result and publish NOTHING, silently.\nfunction rowKey(r, tz, nowMs, ns) {\n  const skip = new Set(SYS_COLS_.map(x => String(x).trim().toLowerCase()));\n  const s = String(ns ?? '') + '\u0003' + Object.keys(r).filter(k => k !== 'row_number' && !skip.has(String(k).trim().toLowerCase()))\n    .sort().map(k => k + '\\u0001' + String(r[k] ?? '').trim()).join('\\u0002');\n  const h = hash32_(s, 2166136261).toString(16).padStart(8, '0') + hash32_(s, 3735928559).toString(16).padStart(8, '0');\n  let day;\n  try { day = (nowMs == null ? DateTime.now() : DateTime.fromMillis(nowMs)).setZone(tz).toFormat('yyyyMMdd'); }\n  catch (e) { day = new Date(nowMs ?? Date.now()).toISOString().slice(0, 10).replace(/-/g, ''); }\n  if (!/^\\d{8}$/.test(day)) day = new Date(nowMs ?? Date.now()).toISOString().slice(0, 10).replace(/-/g, '');\n  return 'P-' + r.row_number + '-' + day + '-' + h;\n}\n\nfunction buildPost(r, cfg, KENH, IMG, VID, NEEDS_TITLE) {\n  const caption = String(r['Caption'] ?? '').trim();\n  const raw = String(r['Channels'] ?? '').trim();\n  // A row that is not finished yet is skipped quietly rather than reported as an error.\n  if (!caption || !raw) return null;\n\n  const platforms = raw.split(',').map(s => KENH[s.trim()]).filter(Boolean);\n  if (!platforms.length) return { _row: r.row_number, _err: 'The Channels cell matches no platform. Pick one from the dropdown.' };\n\n  const links = String(r['Media'] ?? '').split(/[\\n,]/).map(s => s.trim()).filter(Boolean);\n  const bad = links.filter(l => !l.startsWith('https://'));\n  if (bad.length) return { _row: r.row_number, _err: 'Media links must start with https:// , this one is: ' + bad[0] };\n\n  const low = links.map(l => l.toLowerCase());\n  let endpoint = 'text';\n  if (links.length) endpoint = low.some(l => VID.some(e => l.includes(e))) ? 'video' : 'photos';\n\n  const body = { profile_id: cfg.profile_id, platforms, caption };\n  const title = String(r['Title'] ?? '').trim();\n  if (title) for (const p of platforms) if (NEEDS_TITLE.includes(p)) body[p + '_title'] = title;\n  const fc = String(r['First Comment'] ?? '').trim();\n  if (fc) body.first_comment = fc;\n  if (platforms.includes('pinterest')) {\n    if (!cfg.pinterest_board_id) return { _row: r.row_number, _err: 'Posting to Pinterest needs pinterest_board_id in the Config tab.' };\n    body.pinterest_board_id = cfg.pinterest_board_id;\n  }\n  if (endpoint === 'video') body.video_url = links[0];\n  if (endpoint === 'photos') body.photo_urls = links;\n\n  // The post ID is what stops a post going out twice. It is derived from the ROW itself (row number + what you typed + the date) BEFORE the API call and written straight to the sheet, so two runs reading the same row (webhook and scan) get the same key and the Hub publishes once.\n  const id = String(r['Post ID'] ?? '').trim() || rowKey(r, cfg.timezone || 'UTC', null, sheetNs_(cfg));\n  return { _row: r.row_number, _id: id, _endpoint: endpoint, body };\n}\n\nconst perRun = parseInt(cfg.rows_per_run || '10', 10) || 10;\nconst out = [];\nfor (const it of $input.all()) {\n  if (out.length >= perRun) break;\n  const r = it.json;\n  if (String(r['Action'] ?? '').trim() !== 'Publish now') continue;\n  if (!OPEN.includes(String(r['Status'] ?? '').trim())) continue;\n  const b = buildPost(r, cfg, KENH, IMG, VID, NEEDS_TITLE);\n  if (b) out.push({ json: b });\n}\nreturn out;\n"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        500,
        0
      ],
      "id": "Build the post",
      "name": "Build the post"
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "version": 2
          },
          "conditions": [
            {
              "id": "c",
              "operator": {
                "type": "string",
                "operation": "notExists"
              },
              "leftValue": "={{ $json._err }}",
              "rightValue": ""
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.3,
      "position": [
        720,
        0
      ],
      "id": "Row usable?",
      "name": "Row usable?"
    },
    {
      "parameters": {
        "operation": "update",
        "documentId": {
          "__rl": true,
          "value": "",
          "mode": "list",
          "cachedResultName": "[MADIAD Template] Publishing Schedule"
        },
        "sheetName": {
          "__rl": true,
          "value": "Schedule",
          "mode": "name"
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "row_number": "={{ $json._row }}",
            "Post ID": "={{ $json._id }}",
            "Status": "Publishing",
            "Error": ""
          },
          "matchingColumns": [
            "row_number"
          ],
          "schema": []
        },
        "options": {
          "dataLocationOnSheet": {
            "values": {
              "rangeDefinition": "specifyRange",
              "headerRow": 1,
              "firstDataRow": 3
            }
          }
        }
      },
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.7,
      "position": [
        940,
        -120
      ],
      "id": "Mark as publishing",
      "name": "Mark as publishing",
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "method": "POST",
        "url": "=https://api.madiad.com/v1/posts/{{ $('Build the post').item.json._endpoint }}",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "X-Madiad-Source",
              "value": "tpl-schedule-cron-v1"
            },
            {
              "name": "Idempotency-Key",
              "value": "={{ $('Build the post').item.json._id }}"
            }
          ]
        },
        "options": {
          "response": {
            "response": {
              "neverError": true,
              "fullResponse": true
            }
          }
        },
        "sendBody": true,
        "contentType": "json",
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify($('Build the post').item.json.body) }}"
      },
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.4,
      "position": [
        1160,
        -120
      ],
      "id": "MADIAD Hub",
      "name": "MADIAD Hub",
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "version": 2
          },
          "conditions": [
            {
              "id": "c",
              "operator": {
                "type": "number",
                "operation": "lt"
              },
              "leftValue": "={{ $json.statusCode }}",
              "rightValue": 300
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.3,
      "position": [
        1380,
        -120
      ],
      "id": "Published?",
      "name": "Published?"
    },
    {
      "parameters": {
        "operation": "update",
        "documentId": {
          "__rl": true,
          "value": "",
          "mode": "list",
          "cachedResultName": "[MADIAD Template] Publishing Schedule"
        },
        "sheetName": {
          "__rl": true,
          "value": "Schedule",
          "mode": "name"
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "row_number": "={{ $('Build the post').item.json._row }}",
            "Status": "={{ $json.body?.request_id ? 'Publishing' : 'Published' }}",
            "Request ID": "={{ $json.body?.request_id ?? '' }}",
            "Post Link": "={{ Object.entries($json.body?.results ?? {}).map(([k,v]) => k + ': ' + (v?.url ?? (v?.success === false ? ('failed, ' + (v?.error ?? '')) : 'accepted'))).join('\\n') }}",
            "Platform Post IDs": "={{ Object.entries($json.body?.results ?? {}).filter(([,v]) => v?.publish_id).map(([k,v]) => k + '=' + v.publish_id).join(';') }}",
            "Published At": "={{ $json.body?.request_id ? '' : $now.setZone('UTC').toFormat('yyyy-MM-dd HH:mm') }}",
            "Error": ""
          },
          "matchingColumns": [
            "row_number"
          ],
          "schema": []
        },
        "options": {
          "dataLocationOnSheet": {
            "values": {
              "rangeDefinition": "specifyRange",
              "headerRow": 1,
              "firstDataRow": 3
            }
          }
        }
      },
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.7,
      "position": [
        1600,
        -220
      ],
      "id": "Write result",
      "name": "Write result",
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "version": 2
          },
          "conditions": [
            {
              "id": "c",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              },
              "leftValue": "={{ $json.statusCode === 409 && $json.body?.error?.code === 'idempotency_in_progress' }}",
              "rightValue": ""
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.3,
      "position": [
        1600,
        -20
      ],
      "id": "Another run publishing?",
      "name": "Another run publishing?"
    },
    {
      "parameters": {
        "operation": "update",
        "documentId": {
          "__rl": true,
          "value": "",
          "mode": "list",
          "cachedResultName": "[MADIAD Template] Publishing Schedule"
        },
        "sheetName": {
          "__rl": true,
          "value": "Schedule",
          "mode": "name"
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "row_number": "={{ $('Build the post').item.json._row }}",
            "Status": "Error",
            "Error": "={{ $json.body?.error?.message ?? ('HTTP ' + ($json.statusCode ?? '?')) }}"
          },
          "matchingColumns": [
            "row_number"
          ],
          "schema": []
        },
        "options": {
          "dataLocationOnSheet": {
            "values": {
              "rangeDefinition": "specifyRange",
              "headerRow": 1,
              "firstDataRow": 3
            }
          }
        }
      },
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.7,
      "position": [
        1820,
        0
      ],
      "id": "Write error",
      "name": "Write error",
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "operation": "update",
        "documentId": {
          "__rl": true,
          "value": "",
          "mode": "list",
          "cachedResultName": "[MADIAD Template] Publishing Schedule"
        },
        "sheetName": {
          "__rl": true,
          "value": "Schedule",
          "mode": "name"
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "row_number": "={{ $json._row }}",
            "Status": "Error",
            "Error": "={{ $json._err }}"
          },
          "matchingColumns": [
            "row_number"
          ],
          "schema": []
        },
        "options": {
          "dataLocationOnSheet": {
            "values": {
              "rangeDefinition": "specifyRange",
              "headerRow": 1,
              "firstDataRow": 3
            }
          }
        }
      },
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.7,
      "position": [
        940,
        140
      ],
      "id": "Write input error",
      "name": "Write input error",
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "content": "## MADIAD Hub - Publish from Google Sheets on a schedule\n\n**Three things before you switch this on:**\n1. Pick your own copy of the sheet in EVERY Google Sheets node.\n2. Create a **Header Auth** credential named `MADIAD Hub`:\n   Name `Authorization` , Value `Bearer <your API key>`\n   then select it on the node that calls the API.\n3. Put your `profile_id` in the sheet's Config tab.\n\nThe post ID is what stops a post going out twice. It is derived from the ROW itself (row number + what you typed + the date) BEFORE the API call and written straight to the sheet, so two runs reading the same row (webhook and scan) get the same key and the Hub publishes once.\n\nhttps://hub.madiad.com/mau",
        "height": 470,
        "width": 440,
        "color": 4
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -620,
        -200
      ],
      "id": "Setup",
      "name": "Setup"
    }
  ],
  "connections": {
    "Every 15 minutes": {
      "main": [
        [
          {
            "node": "Read Config",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read Config": {
      "main": [
        [
          {
            "node": "Read Schedule",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read Schedule": {
      "main": [
        [
          {
            "node": "Build the post",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build the post": {
      "main": [
        [
          {
            "node": "Row usable?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Row usable?": {
      "main": [
        [
          {
            "node": "Mark as publishing",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Write input error",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Mark as publishing": {
      "main": [
        [
          {
            "node": "MADIAD Hub",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "MADIAD Hub": {
      "main": [
        [
          {
            "node": "Published?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Published?": {
      "main": [
        [
          {
            "node": "Write result",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Another run publishing?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Another run publishing?": {
      "main": [
        [],
        [
          {
            "node": "Write error",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  },
  "pinData": {},
  "meta": {
    "templateCredsSetupCompleted": false
  }
}