{
  "name": "Reconcile a Google Sheets roster against Discord roles and report the drift",
  "nodes": [
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "weeks",
              "triggerAtDay": [
                1
              ],
              "triggerAtHour": 8
            }
          ]
        }
      },
      "id": "32a99136-006f-4371-8ab3-219e6ba76369",
      "name": "Run Weekly Roster Check",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.3,
      "position": [
        0,
        304
      ]
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "guild-id",
              "name": "guild_id",
              "value": "<__PLACEHOLDER_VALUE__Discord server (guild) ID, e.g. 1043923086871392266__>",
              "type": "string"
            },
            {
              "id": "roster-sheet-url",
              "name": "roster_sheet_url",
              "value": "<__PLACEHOLDER_VALUE__URL of the Google Sheet that holds your roster__>",
              "type": "string"
            },
            {
              "id": "roster-tab-name",
              "name": "roster_tab_name",
              "value": "Roster",
              "type": "string"
            },
            {
              "id": "report-sheet-url",
              "name": "report_sheet_url",
              "value": "<__PLACEHOLDER_VALUE__URL of the Google Sheet the drift report is written to, can be the same file__>",
              "type": "string"
            },
            {
              "id": "report-tab-name",
              "name": "report_tab_name",
              "value": "Drift Report",
              "type": "string"
            },
            {
              "id": "report-channel-id",
              "name": "report_channel_id",
              "value": "<__PLACEHOLDER_VALUE__Discord channel ID the summary is posted to__>",
              "type": "string"
            },
            {
              "id": "gated-roles",
              "name": "gated_roles",
              "value": "Volunteer,Committee",
              "type": "string"
            },
            {
              "id": "active-status",
              "name": "active_status_values",
              "value": "active,current,paid",
              "type": "string"
            },
            {
              "id": "inactive-status",
              "name": "inactive_status_values",
              "value": "inactive,lapsed,expired,cancelled,left",
              "type": "string"
            },
            {
              "id": "grace-days",
              "name": "grace_period_days",
              "value": 7,
              "type": "number"
            },
            {
              "id": "ignore-user-ids",
              "name": "ignore_user_ids",
              "value": "",
              "type": "string"
            },
            {
              "id": "max-examples",
              "name": "max_examples_in_summary",
              "value": 5,
              "type": "number"
            }
          ]
        },
        "options": {}
      },
      "id": "bf91d9ef-c0db-445b-b658-30f55530c11e",
      "name": "Set Reconcile Settings",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        224,
        304
      ]
    },
    {
      "parameters": {
        "documentId": {
          "__rl": true,
          "mode": "url",
          "value": "={{ $(\"Set Reconcile Settings\").first().json.roster_sheet_url }}"
        },
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "={{ $(\"Set Reconcile Settings\").first().json.roster_tab_name }}"
        },
        "options": {}
      },
      "id": "7186b371-39d5-4f79-ae95-bbea34bb247f",
      "name": "Read Roster Sheet",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.7,
      "position": [
        688,
        304
      ],
      "alwaysOutputData": true,
      "executeOnce": true,
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "// Role IDs are resolved from the live guild rather than hand-maintained JSON, so the\n// user types role NAMES and there is no snowflake left to typo or to go stale on rename.\nconst cfg = $('Set Reconcile Settings').first().json;\nconst guildRoles = $('Get Guild Roles').all().map(function (i) { return i.json; });\n\nconst byName = {};\nfor (const r of guildRoles) {\n  if (r && r.id && r.name) { byName[String(r.name).trim().toLowerCase()] = String(r.id); }\n}\n\nconst gatedNames = String(cfg.gated_roles || '')\n  .split(',').map(function (s) { return s.trim(); }).filter(function (s) { return s.length > 0; });\nconst gatedByName = {};\nconst gatedMissing = [];\nfor (const name of gatedNames) {\n  const id = byName[name.toLowerCase()];\n  if (id) { gatedByName[name.toLowerCase()] = id; } else { gatedMissing.push(name); }\n}\n\nfunction csv(v) {\n  return String(v || '').split(',').map(function (s) { return s.trim().toLowerCase(); })\n    .filter(function (s) { return s.length > 0; });\n}\nconst activeValues = csv(cfg.active_status_values);\nconst inactiveValues = csv(cfg.inactive_status_values);\n\nconst out = [];\nfor (const item of $input.all()) {\n  const r = item.json || {};\n  const userId = String(r.discord_user_id == null ? '' : r.discord_user_id).trim();\n  const roleName = String(r.entitled_role == null ? '' : r.entitled_role).trim();\n  const status = String(r.status == null ? '' : r.status).trim();\n  const displayName = String(r.display_name == null ? '' : r.display_name).trim();\n\n  if (userId === '' && roleName === '' && status === '' && displayName === '') { continue; }\n\n  const idValid = /^[0-9]{17,20}$/.test(userId);\n  const roleId = gatedByName[roleName.toLowerCase()] || '';\n\n  let rosterActive = true;\n  let problem = '';\n\n  if (!idValid) {\n    problem = userId.length === 0 ? 'discord_user_id is blank' : 'discord_user_id is not a Discord snowflake';\n  } else if (roleId === '') {\n    problem = 'entitled_role \"' + roleName + '\" is not one of gated_roles, or no role with that name exists in this server';\n  } else if (status.length > 0) {\n    const s = status.toLowerCase();\n    if (activeValues.indexOf(s) >= 0) { rosterActive = true; }\n    else if (inactiveValues.indexOf(s) >= 0) { rosterActive = false; }\n    else {\n      // Previously an unrecognised status was silently treated as inactive, which told\n      // the coordinator to strip roles from members who were paid up.\n      problem = 'status \"' + status + '\" matches neither active_status_values nor inactive_status_values, so this row cannot be judged';\n    }\n  }\n\n  out.push({ json: {\n    discord_user_id: userId,\n    display_name: displayName,\n    entitled_role: roleName,\n    entitled_role_id: roleId,\n    status: status,\n    roster_active: rosterActive,\n    usable: problem === '',\n    problem: problem,\n    roster_empty: false,\n    gated_missing: gatedMissing\n  } });\n}\n\nif (out.length === 0) {\n  // A blank tab used to end the run silently, because n8n skips a node fed zero items.\n  out.push({ json: {\n    discord_user_id: '', display_name: '', entitled_role: '', entitled_role_id: '',\n    status: '', roster_active: false, usable: false,\n    problem: 'The roster tab returned no rows. Check roster_tab_name and that the tab has a header row plus data.',\n    roster_empty: true, gated_missing: gatedMissing\n  } });\n}\n\nreturn out;"
      },
      "id": "ca70b2fe-65ac-4968-8bce-65030a31a71c",
      "name": "Normalize Roster Rows",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        960,
        304
      ]
    },
    {
      "parameters": {
        "resource": "member",
        "guildId": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $(\"Set Reconcile Settings\").first().json.guild_id }}"
        },
        "returnAll": true,
        "options": {
          "simplify": false
        }
      },
      "id": "a16e4017-6d8b-4608-9051-48315030c07d",
      "name": "Get Server Members",
      "type": "n8n-nodes-base.discord",
      "typeVersion": 2,
      "position": [
        1200,
        304
      ],
      "executeOnce": true,
      "credentials": {
        "discordBotApi": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "const cfg = $('Set Reconcile Settings').first().json;\nconst roster = $('Normalize Roster Rows').all().map(function (i) { return i.json; });\nconst members = $input.all().map(function (i) { return i.json; });\nconst guildRoles = $('Get Guild Roles').all().map(function (i) { return i.json; });\n\nconst roleIdToName = {};\nfor (const r of guildRoles) {\n  if (r && r.id && r.name) { roleIdToName[String(r.id)] = String(r.name); }\n}\nconst gatedNames = String(cfg.gated_roles || '')\n  .split(',').map(function (s) { return s.trim().toLowerCase(); }).filter(function (s) { return s.length > 0; });\nconst gatedRoleIds = new Set();\nfor (const id of Object.keys(roleIdToName)) {\n  if (gatedNames.indexOf(roleIdToName[id].toLowerCase()) >= 0) { gatedRoleIds.add(id); }\n}\n\nconst ignored = new Set(String(cfg.ignore_user_ids || '')\n  .split(',').map(function (s) { return s.trim(); }).filter(function (s) { return s.length > 0; }));\n\nconst graceDays = Number(cfg.grace_period_days);\nconst graceMs = (isFinite(graceDays) && graceDays > 0) ? graceDays * 86400000 : 0;\nconst nowMs = Date.now();\n\nconst checkedAt = $now.toISO();\nconst runId = String($execution.id);\n\nconst inServer = new Map();\nfor (const m of members) {\n  const user = m.user || {};\n  const uid = String(user.id || m.id || '').trim();\n  if (uid.length === 0) continue;\n  if (user.bot === true) continue;\n  if (ignored.has(uid)) continue;\n  const joinedMs = m.joined_at ? new Date(m.joined_at).getTime() : 0;\n  inServer.set(uid, {\n    id: uid,\n    username: String(user.username || ''),\n    roles: (Array.isArray(m.roles) ? m.roles : []).map(String),\n    inGrace: graceMs > 0 && joinedMs > 0 && (nowMs - joinedMs) < graceMs\n  });\n}\n\nconst drift = [];\nfunction makeRow(bucket, uid, displayName, username, role, note) {\n  return { checked_at: checkedAt, run_id: runId, bucket: bucket, discord_user_id: uid,\n           display_name: displayName, discord_username: username, role: role, note: note };\n}\n\nconst rosterEmpty = roster.length === 1 && roster[0].roster_empty === true;\nconst gatedMissing = (roster[0] && Array.isArray(roster[0].gated_missing)) ? roster[0].gated_missing : [];\nfor (const name of gatedMissing) {\n  drift.push(makeRow('gated_role_not_found', '', '', '', name,\n    'gated_roles names a role that does not exist in this server. Check the spelling against Server Settings, Roles.'));\n}\n\nconst rosterByUser = new Map();\nconst unusable = [];\nif (!rosterEmpty) {\n  for (const r of roster) {\n    if (!r.usable) { unusable.push(r); continue; }\n    const list = rosterByUser.get(r.discord_user_id) || [];\n    list.push(r);\n    rosterByUser.set(r.discord_user_id, list);\n  }\n\n  for (const pair of rosterByUser) {\n    const uid = pair[0];\n    const member = inServer.get(uid);\n    for (const r of pair[1]) {\n      if (!r.roster_active) continue;\n      const holds = member ? member.roles.indexOf(r.entitled_role_id) >= 0 : false;\n      if (holds) continue;\n      if (member && member.inGrace) {\n        drift.push(makeRow('in_grace_period', uid, r.display_name, member.username, r.entitled_role,\n          'Joined within the last ' + graceDays + ' days and does not hold the role yet. Not counted as drift.'));\n        continue;\n      }\n      drift.push(makeRow('missing_role', uid, r.display_name, member ? member.username : '', r.entitled_role,\n        member ? 'In the server but does not hold the entitled role' : 'On the roster but not found in the server'));\n    }\n  }\n\n  for (const pair of inServer) {\n    const uid = pair[0];\n    const member = pair[1];\n    const held = member.roles.filter(function (rid) { return gatedRoleIds.has(rid); });\n    if (held.length === 0) continue;\n    const rows = rosterByUser.get(uid) || [];\n    for (const rid of held) {\n      const roleName = roleIdToName[rid];\n      if (rows.length === 0) {\n        drift.push(makeRow('no_roster_row', uid, '', member.username, roleName, 'Holds a gated role with no roster row at all'));\n        continue;\n      }\n      const entitled = rows.some(function (r) { return r.roster_active && r.entitled_role_id === rid; });\n      if (entitled) continue;\n      const staleRow = rows.some(function (r) { return r.entitled_role_id === rid; });\n      drift.push(makeRow('unexpected_role', uid, rows[0].display_name, member.username, roleName,\n        staleRow ? 'Roster row for this role is not an active status' : 'Roster does not entitle this member to the role'));\n    }\n  }\n}\n\nfor (const r of unusable) {\n  drift.push(makeRow('roster_row_unusable', r.discord_user_id, r.display_name, '', r.entitled_role, r.problem));\n}\nif (rosterEmpty) {\n  drift.push(makeRow('roster_empty', '', '', '', '', roster[0].problem));\n}\n\nconst order = { gated_role_not_found: 0, roster_empty: 1, no_roster_row: 2, missing_role: 3,\n                unexpected_role: 4, roster_row_unusable: 5, in_grace_period: 6 };\ndrift.sort(function (a, b) {\n  return (order[a.bucket] - order[b.bucket]) ||\n    String(a.display_name || a.discord_username).localeCompare(String(b.display_name || b.discord_username));\n});\n\nif (drift.length === 0) {\n  drift.push(makeRow('no_drift', '', '', '', '',\n    'Checked ' + inServer.size + ' members against ' + rosterByUser.size + ' rostered users and found no drift'));\n}\n\n// Counts travel with the rows so the channel post and the sheet can never disagree.\nconst summary = { members_scanned: inServer.size, roster_users: rosterByUser.size,\n                  roster_rows: rosterEmpty ? 0 : roster.length };\nreturn drift.map(function (d) { return { json: Object.assign({}, d, { __summary: summary }) }; });"
      },
      "id": "5ad39b7e-35b4-4c9b-a6b8-0cd35d87ce80",
      "name": "Reconcile Roster Against Discord",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1456,
        304
      ]
    },
    {
      "parameters": {
        "operation": "append",
        "documentId": {
          "__rl": true,
          "mode": "url",
          "value": "={{ $(\"Set Reconcile Settings\").first().json.report_sheet_url }}"
        },
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "={{ $(\"Set Reconcile Settings\").first().json.report_tab_name }}"
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "checked_at": "={{ $json.checked_at }}",
            "run_id": "={{ $json.run_id }}",
            "bucket": "={{ $json.bucket }}",
            "discord_user_id": "={{ $json.discord_user_id }}",
            "display_name": "={{ $json.display_name }}",
            "discord_username": "={{ $json.discord_username }}",
            "role": "={{ $json.role }}",
            "note": "={{ $json.note }}"
          },
          "schema": [
            {
              "id": "checked_at",
              "displayName": "checked_at",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "run_id",
              "displayName": "run_id",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "bucket",
              "displayName": "bucket",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "discord_user_id",
              "displayName": "discord_user_id",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "display_name",
              "displayName": "display_name",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "discord_username",
              "displayName": "discord_username",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "role",
              "displayName": "role",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "note",
              "displayName": "note",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            }
          ]
        },
        "options": {
          "cellFormat": "RAW"
        }
      },
      "id": "586b3da4-48d8-4a84-8207-f64e5029146c",
      "name": "Write Drift Report",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.7,
      "position": [
        1760,
        304
      ],
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "const cfg = $('Set Reconcile Settings').first().json;\nconst rows = $('Reconcile Roster Against Discord').all().map(function (i) { return i.json; });\n\n// Counts come from the reconcile step, so the channel post and the sheet cannot disagree.\nconst s = (rows[0] && rows[0].__summary) || { members_scanned: 0, roster_users: 0, roster_rows: 0 };\n\nconst maxExamples = Number(cfg.max_examples_in_summary || 5);\nfunction pick(bucket) { return rows.filter(function (r) { return r.bucket === bucket; }); }\n\nconst noRosterRow = pick('no_roster_row');\nconst missingRole = pick('missing_role');\nconst unexpectedRole = pick('unexpected_role');\nconst unusableRows = pick('roster_row_unusable');\nconst inGrace = pick('in_grace_period');\nconst roleNotFound = pick('gated_role_not_found');\nconst rosterEmpty = pick('roster_empty');\nconst notInServer = missingRole.filter(function (r) { return String(r.note).indexOf('not found in the server') !== -1; });\n\nfunction label(r) { return (r.display_name || r.discord_username || r.discord_user_id) + ' (' + r.role + ')'; }\nfunction examples(list) {\n  const shown = list.slice(0, maxExamples).map(label).join(', ');\n  return list.length > maxExamples ? shown + ', plus ' + (list.length - maxExamples) + ' more' : shown;\n}\n\nconst lines = [];\nlines.push('**Roster drift report, ' + $now.toFormat('d LLLL yyyy') + '**');\nlines.push('');\n\nif (roleNotFound.length > 0) {\n  lines.push('**Configuration problem.** ' + roleNotFound.length + ' name(s) in gated_roles do not exist in this server: ' +\n             roleNotFound.map(function (r) { return r.role; }).join(', ') + '. Counts below ignore those roles.');\n  lines.push('');\n}\nif (rosterEmpty.length > 0) {\n  lines.push('**The roster tab returned no rows.** Nothing could be reconciled. Check roster_tab_name.');\n  lines.push('');\n} else {\n  lines.push('**' + noRosterRow.length + '** members hold a gated role with no roster row.');\n  lines.push('**' + missingRole.length + '** roster members are missing their entitled role.');\n  lines.push('');\n  if (noRosterRow.length > 0) lines.push('No roster row: ' + examples(noRosterRow));\n  if (missingRole.length > 0) lines.push('Missing role: ' + examples(missingRole));\n  if (notInServer.length > 0) lines.push('Of those missing a role, ' + notInServer.length + ' are no longer in the server at all.');\n  if (unexpectedRole.length > 0) lines.push('Holding a role the roster does not grant: ' + unexpectedRole.length + '. ' + examples(unexpectedRole));\n  if (inGrace.length > 0) lines.push(inGrace.length + ' recent joiner(s) are inside the ' + cfg.grace_period_days + ' day grace period and were not counted.');\n  if (unusableRows.length > 0) lines.push('**' + unusableRows.length + ' roster row(s) could not be judged** and are excluded from every count above. Fix them before trusting these numbers: ' + examples(unusableRows));\n  if (rows.length === 1 && rows[0].bucket === 'no_drift') lines.push('No drift found this week.');\n}\n\nlines.push('');\nlines.push('Scanned ' + s.members_scanned + ' Discord members (bots and ignored IDs excluded) against ' +\n           s.roster_users + ' rostered users from ' + s.roster_rows + ' roster rows. Row level detail is on the ' +\n           (cfg.report_tab_name || 'drift report') + ' tab. This workflow only reads Discord, it never adds or removes a role.');\n\nlet content = lines.join('\\n');\nif (content.length > 1900) content = content.slice(0, 1880) + '\\n(truncated, see the sheet)';\n\nreturn [{ json: {\n  content: content,\n  no_roster_row_count: noRosterRow.length,\n  missing_role_count: missingRole.length,\n  unexpected_role_count: unexpectedRole.length,\n  unusable_row_count: unusableRows.length,\n  in_grace_count: inGrace.length,\n  role_not_found_count: roleNotFound.length,\n  members_scanned: s.members_scanned,\n  roster_users: s.roster_users,\n  roster_rows: s.roster_rows\n} }];"
      },
      "id": "75d940a3-a91a-4018-aba2-b97a4c48c7c4",
      "name": "Build Drift Summary",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2000,
        304
      ],
      "executeOnce": true
    },
    {
      "parameters": {
        "resource": "message",
        "guildId": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $(\"Set Reconcile Settings\").first().json.guild_id }}"
        },
        "channelId": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $(\"Set Reconcile Settings\").first().json.report_channel_id }}"
        },
        "content": "={{ $json.content }}",
        "options": {
          "flags": [
            "SUPPRESS_EMBEDS"
          ]
        }
      },
      "id": "f1113b3b-c55d-45af-ba88-fcb0fdf228b8",
      "name": "Post Drift Summary",
      "type": "n8n-nodes-base.discord",
      "typeVersion": 2,
      "position": [
        2240,
        304
      ],
      "executeOnce": true,
      "credentials": {
        "discordBotApi": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "content": "## Reconcile a Google Sheets roster against Discord roles and report the drift\n\nYour volunteer list, course cohort or club membership lives in a Google Sheet, and the Discord roles drift away from it within a week. Every Monday this reads the sheet, reads the server, and reports the gap. It is read only against Discord, so you can point it at a live 4,000 member server and it cannot break anything.\n\n### How it works\n1. A weekly schedule fires and `Set Reconcile Settings` supplies every ID, URL and tuning value from one node.\n2. `Get Guild Roles` fetches the server's roles live, so you configure role NAMES and never copy a role ID.\n3. `Read Roster Sheet` pulls the roster and `Normalize Roster Rows` flags any row that cannot be judged instead of guessing at it.\n4. `Get Server Members` pulls every member with their role IDs, paginating natively.\n5. `Reconcile Roster Against Discord` joins the two on `discord_user_id` and sorts every mismatch into a named bucket.\n6. One row per mismatch is appended to the report tab, then the two headline counts are posted to a channel.\n\n### Setup steps\n- [ ] Create a Discord application, add a bot to it, and invite that bot to your server.\n- [ ] Switch on the SERVER MEMBERS INTENT for the bot in the Discord developer portal. Reading members returns nothing without it.\n- [ ] Add the Discord Bot credential to both Discord nodes and to `Get Guild Roles`.\n- [ ] Put your server ID, report channel ID, roster sheet URL and report sheet URL into `Set Reconcile Settings`.\n- [ ] Set `gated_roles` to the role names you want checked, comma separated, spelled as they appear in Server Settings.\n- [ ] Give the roster tab the columns `discord_user_id`, `display_name`, `entitled_role`, and optionally `status`.\n- [ ] Give the report tab the header row `checked_at`, `run_id`, `bucket`, `discord_user_id`, `display_name`, `discord_username`, `role`, `note`.\n- [ ] Run it once by hand and read the drift report before you trust the counts.\n\n### Customization\n`active_status_values` and `inactive_status_values` decide which roster statuses count as entitled, and a status in neither list is reported rather than assumed. `grace_period_days` stops recent joiners being flagged before anyone has had a chance to give them their role. `ignore_user_ids` keeps bots and service accounts out of the report.",
        "height": 920,
        "width": 660
      },
      "id": "43da46a7-9f05-4d73-a507-71c4348f904b",
      "name": "Overview",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -816,
        -64
      ]
    },
    {
      "parameters": {
        "content": "## Configure once, then read both sides\n\nEvery ID, URL and tuning value lives in `Set Reconcile Settings`. Role IDs are fetched live from the guild, so you type role NAMES and there is no snowflake to typo or to go stale when somebody renames a role.",
        "height": 396,
        "width": 872,
        "color": 7
      },
      "id": "d0cd8951-d334-40bc-b85b-c0312e22e1e1",
      "name": "Section Configure And Read",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -48,
        112
      ]
    },
    {
      "parameters": {
        "content": "## Diff the roster against Discord\n\nA row whose `status` matches neither the active nor the inactive list is reported as unjudgeable, never quietly treated as inactive. Recent joiners inside the grace period are named separately rather than counted as drift.",
        "height": 396,
        "width": 750,
        "color": 7
      },
      "id": "de91aa05-3864-4e5e-b33f-09a78d27947e",
      "name": "Section Diff The Roster",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        880,
        112
      ]
    },
    {
      "parameters": {
        "content": "## Report two numbers, not three lists\n\nThe post leads with the two counts a coordinator can act on. Every count comes from the reconcile step, so the channel post and the sheet can never disagree.",
        "height": 380,
        "width": 718,
        "color": 7
      },
      "id": "c07b5fa7-1f5a-476e-8c9d-24407f50043e",
      "name": "Section Report And Post",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1712,
        128
      ]
    },
    {
      "parameters": {
        "content": "## Your sheet needs Discord user IDs\nThe join runs on discord_user_id, so the roster has to hold 18 digit Discord snowflakes. Nobody hand maintains a column of those, and once it decays the report starts crying wolf.\n\nTo read one: Discord settings, Advanced, switch on Developer Mode, then right click a member and choose Copy User ID.\n\nCapture the ID at signup rather than backfilling it later. Add the field to your intake form and the column keeps itself current.\n\nRows with a blank or malformed ID are not dropped quietly. They land in the report as roster_row_unusable and are counted on their own line in the channel post.",
        "height": 440,
        "width": 520,
        "color": 7
      },
      "id": "0c8b834e-09e6-482d-91d6-5d6472ab0899",
      "name": "Note Your Sheet Needs User IDs",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        544,
        592
      ]
    },
    {
      "parameters": {
        "content": "## Reads the member list, and nothing else\nThis workflow never calls roleAdd or roleRemove and there is no fix it branch. It reads members, writes a sheet, and posts a message, so it is safe to point at a production server.\n\nThe one hard blocker is the SERVER MEMBERS INTENT. In the Discord developer portal open your application, go to Bot, and switch on Server Members Intent. For a bot in fewer than 100 servers that is a toggle you flip yourself, not an approval queue, but the member list comes back empty until you do. The bot also has to be invited to the server first.\n\nBots that hold a gated role will show up as no_roster_row. Put their user IDs in ignore_user_ids to keep the report clean.",
        "height": 436,
        "width": 500,
        "color": 3
      },
      "id": "21f4531b-a0fc-4977-99b3-0c810720c284",
      "name": "Warning Read Only And Intents",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -48,
        592
      ]
    },
    {
      "parameters": {
        "url": "=https://discord.com/api/v10/guilds/{{ $('Set Reconcile Settings').first().json.guild_id }}/roles",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "discordBotApi",
        "options": {}
      },
      "id": "ec9531b4-ba84-4fe3-bd90-3415e2b5fa42",
      "name": "Get Guild Roles",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.4,
      "position": [
        448,
        304
      ],
      "credentials": {
        "discordBotApi": {
          "name": "<your credential>"
        }
      }
    }
  ],
  "connections": {
    "Run Weekly Roster Check": {
      "main": [
        [
          {
            "node": "Set Reconcile Settings",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read Roster Sheet": {
      "main": [
        [
          {
            "node": "Normalize Roster Rows",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Normalize Roster Rows": {
      "main": [
        [
          {
            "node": "Get Server Members",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get Server Members": {
      "main": [
        [
          {
            "node": "Reconcile Roster Against Discord",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Reconcile Roster Against Discord": {
      "main": [
        [
          {
            "node": "Write Drift Report",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Write Drift Report": {
      "main": [
        [
          {
            "node": "Build Drift Summary",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Drift Summary": {
      "main": [
        [
          {
            "node": "Post Drift Summary",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Set Reconcile Settings": {
      "main": [
        [
          {
            "node": "Get Guild Roles",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get Guild Roles": {
      "main": [
        [
          {
            "node": "Read Roster Sheet",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "active": false,
  "settings": {
    "executionOrder": "v1"
  },
  "tags": []
}