{
  "name": "Weekly leaderboard (final)",
  "nodes": [
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "weeks",
              "triggerAtDay": [
                1
              ],
              "triggerAtHour": 9
            }
          ]
        }
      },
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.3,
      "position": [
        -416,
        0
      ],
      "id": "7c322795-1d61-45da-abe1-339fdafe527f",
      "name": "Schedule Trigger"
    },
    {
      "parameters": {
        "resource": "finding",
        "additionalFields": {
          "filter_fields": {
            "filters": [
              {
                "field": "created_at_after",
                "value": "={{ $now.minus({days: 7}).format('yyyy-MM-dd') }}"
              }
            ]
          }
        },
        "fetchAllPages": true
      },
      "type": "@dongit/n8n-nodes-reporter.reporter",
      "typeVersion": 1,
      "position": [
        0,
        0
      ],
      "id": "17b36fda-df02-43ef-8cfa-f8818f7524a5",
      "name": "List findings",
      "credentials": {
        "reporterApi": {
          "id": "mTeBoQOSWKFyasMI",
          "name": "Reporter account"
        }
      }
    },
    {
      "parameters": {
        "additionalFields": {
          "filter_fields": {
            "filters": [
              {
                "field": "created_at_after",
                "value": "={{ $now.minus({days: 7}).format('yyyy-MM-dd') }}"
              },
              {
                "field": "type",
                "value": "43,61,174,60"
              }
            ]
          },
          "include": ""
        }
      },
      "type": "@dongit/n8n-nodes-reporter.reporter",
      "typeVersion": 1,
      "position": [
        -208,
        0
      ],
      "id": "49b52de8-f9a0-4215-80f8-90f968e2f8e7",
      "name": "List activities",
      "credentials": {
        "reporterApi": {
          "id": "mTeBoQOSWKFyasMI",
          "name": "Reporter account"
        }
      }
    },
    {
      "parameters": {
        "resource": "user",
        "operation": "listUsers",
        "additionalFields": {
          "filter_fields": {
            "filters": [
              {
                "field": "id",
                "value": "={{ $json.userIds }}"
              }
            ]
          }
        },
        "fetchAllPages": true
      },
      "type": "@dongit/n8n-nodes-reporter.reporter",
      "typeVersion": 1,
      "position": [
        416,
        0
      ],
      "id": "cfd39fa7-ab58-4b8a-bfb5-54424827d0da",
      "name": "List users",
      "credentials": {
        "reporterApi": {
          "id": "mTeBoQOSWKFyasMI",
          "name": "Reporter account"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "const findings = $('List findings').all().map(i => i.json.data || []).flat();\nconst activities = $('List activities').all().map(i => i.json.data || []).flat();\n\nconst userIds = new Set();\nfor (const f of findings) if (f.user_id) userIds.add(f.user_id);\nfor (const a of activities) if (a.user_id) userIds.add(a.user_id);\n\nreturn [{ json: { userIds: Array.from(userIds).join(',') } }];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        208,
        0
      ],
      "id": "d0d22fc2-14d2-4297-adf2-33bb8161dae2",
      "name": "Get user IDs"
    },
    {
      "parameters": {
        "jsCode": "const SEVERITY_POINTS = { 12: 15, 11: 10, 10: 6, 9: 4, 8: 3, 2: 2 };\nconst SEVERITY_NAMES = { 12: 'Critical', 11: 'High', 10: 'Medium', 9: 'Low', 8: 'Info', 2: 'OK' };\nconst SEVERITY_SHORT = { 12: 'C', 11: 'H', 10: 'M', 9: 'L', 8: 'I', 2: 'OK' };\nconst ACTIVITY_POINTS = { 43: 1, 61: 1, 174: 1, 60: 3 };\nconst REVIEW_TYPES = [43, 61, 174];\nconst RETEST_TYPE = 60;\n\n// -- Load data from the upstream nodes --\nconst findings = $('List findings').all().map(i => i.json.data || []).flat();\nconst activities = $('List activities').all().map(i => i.json.data || []).flat();\nconst users = $('List users').all().map(i => i.json.data || []).flat();\n\n// -- Build user lookup: id → full name --\nconst userNames = {};\nfor (const u of users) {\n  userNames[u.id] = `${u.first_name} ${u.last_name}`;\n}\n\n// -- Helper: get or create a score object for a user --\nconst scores = {};\nfunction scoreFor(userId) {\n  if (!userId) return null;\n  if (!scores[userId]) {\n    scores[userId] = {\n      name: userNames[userId] || 'Unknown User',\n      score: 0, findingCount: 0, sevCounts: {}, reviews: 0, retests: 0,\n    };\n  }\n  return scores[userId];\n}\n\n// -- Score findings (by severity) --\nlet totalFindings = 0, totalSevCounts = {};\n\nfor (const f of findings) {\n  const r = scoreFor(f.user_id);\n  if (!r) continue;\n\n  r.score += SEVERITY_POINTS[f.severity] || 0;\n  r.findingCount++;\n  r.sevCounts[f.severity] = (r.sevCounts[f.severity] || 0) + 1;\n  totalSevCounts[f.severity] = (totalSevCounts[f.severity] || 0) + 1;\n  totalFindings++;\n}\n\n// -- Score activities (reviews, retests) --\nlet totalReviews = 0, totalRetests = 0;\n\nfor (const a of activities) {\n  const r = scoreFor(a.user_id);\n  if (!r) continue;\n\n  r.score += ACTIVITY_POINTS[a.type] || 0;\n\n  if (REVIEW_TYPES.includes(a.type)) { r.reviews++; totalReviews++; }\n  if (a.type === RETEST_TYPE) { r.retests++; totalRetests++; }\n}\n\n// -- Rank by score --\nconst ranked = Object.values(scores).filter(r => r.score > 0).sort((a, b) => b.score - a.score);\nconst teamScore = ranked.reduce((sum, r) => sum + r.score, 0);\n\n// -- Date range --\nconst now = new Date();\nconst weekAgo = new Date(now - 7 * 86400000);\n\nreturn [{\n  json: {\n    ranked,\n    teamScore,\n    totals: {\n      findings: totalFindings,\n      reviews: totalReviews,\n      retests: totalRetests,\n      sevCounts: totalSevCounts,\n    },\n    dateRange: {\n      start: weekAgo.toISOString(),\n      end: now.toISOString(),\n    },\n    severityNames: SEVERITY_NAMES,\n    severityShort: SEVERITY_SHORT,\n  },\n}];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        624,
        0
      ],
      "id": "f6ce1fc7-b929-4d27-b011-82833a7ee0df",
      "name": "Calculate score"
    },
    {
      "parameters": {
        "jsCode": "const { ranked, teamScore, totals, dateRange, severityNames, severityShort } = $input.first().json;\n\n// -- Format severity breakdown, e.g. \"(2C 1H 3M)\" --\nfunction sevBreakdown(counts) {\n  const parts = [12, 11, 10, 9, 8].filter(s => counts[s]).map(s => `${counts[s]}${severityShort[s]}`);\n  return parts.length ? `(${parts.join(' ')})` : '';\n}\n\n// -- Date range for header --\nconst weekAgo = new Date(dateRange.start);\nconst now = new Date(dateRange.end);\nconst fmt = d => `${d.toLocaleString('en-US', { month: 'short' })} ${d.getDate()}`;\n\n// -- Each cell in a Slack table block must be a rich_text block containing\n// a rich_text_section with text elements. Helper to keep it readable:\nfunction cell(text, style = {}) {\n  return {\n    type: 'rich_text',\n    elements: [\n      {\n        type: 'rich_text_section',\n        elements: [\n          { type: 'text', text: text || ' ', ...(Object.keys(style).length ? { style } : {}) },\n        ],\n      },\n    ],\n  };\n}\n\n// -- Table header row (bold) --\nconst headerRow = [\n  cell('#', { bold: true }),\n  cell('Researcher', { bold: true }),\n  cell('Score', { bold: true }),\n  cell('Findings', { bold: true }),\n  cell('Reviews', { bold: true }),\n  cell('Retests', { bold: true }),\n];\n\n// -- One row per ranked researcher: medal for top 3, rank number for the rest --\nconst medals = ['🥇', '🥈', '🥉'];\nconst bodyRows = ranked.map((r, i) => [\n  cell(medals[i] || String(i + 1)),\n  cell(r.name),\n  cell(`${r.score} pts`),\n  cell(`${r.findingCount} ${sevBreakdown(r.sevCounts)}`.trim()),\n  cell(String(r.reviews)),\n  cell(String(r.retests)),\n]);\n\n// -- Quick stats as bullet text --\nconst sevList = [12, 11, 10, 9, 8].filter(s => totals.sevCounts[s]).map(s => `${totals.sevCounts[s]} ${severityNames[s]}`);\nconst statsLines = [\n  `• ${totals.findings} findings created${sevList.length ? ` (${sevList.join(', ')})` : ''}`,\n  `• ${totals.reviews} reviews · ${totals.retests} retests`,\n];\n\n// -- Build Block Kit blocks --\nconst blocks = [\n  {\n    type: 'header',\n    text: { type: 'plain_text', text: `📊 Weekly Researcher Scorecard (${fmt(weekAgo)}–${fmt(now)})` },\n  },\n  {\n    type: 'section',\n    text: { type: 'mrkdwn', text: `🏆 *Team Score: ${teamScore} pts*` },\n  },\n];\n\nif (ranked.length === 0) {\n  blocks.push({\n    type: 'section',\n    text: { type: 'mrkdwn', text: '_No researcher activity this week._' },\n  });\n} else {\n  blocks.push({\n    type: 'table',\n    column_settings: [\n      { align: 'center' }, // # column\n    ],\n    rows: [headerRow, ...bodyRows],\n  });\n}\n\nblocks.push({\n  type: 'section',\n  text: { type: 'mrkdwn', text: statsLines.join('\\n') },\n});\n\n// Slack requires a fallback \"text\" field for notifications and older clients.\nconst fallback = `Weekly Researcher Scorecard — Team Score: ${teamScore} pts`;\n\n// n8n's Slack node expects the Blocks field to be an object with a \"blocks\"\n// property, not a raw array. See: https://community.n8n.io/t/154767\nreturn [{ json: { blocks: { blocks }, text: fallback } }];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        832,
        0
      ],
      "id": "f2e1a01b-5e40-4c25-8bc3-423575b6bf1b",
      "name": "Format leaderboard"
    },
    {
      "parameters": {
        "select": "channel",
        "channelId": {
          "__rl": true,
          "value": "",
          "mode": "id"
        },
        "messageType": "block",
        "blocksUi": "={{ $json.blocks }}",
        "text": "={{ $json.text }}",
        "otherOptions": {
          "includeLinkToWorkflow": false
        }
      },
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.4,
      "position": [
        1040,
        0
      ],
      "id": "ed6516c1-1513-4f1a-884b-24ac36f7313a",
      "name": "Send a message",
      "webhookId": "287b246b-f360-4480-b9e9-3545a79b60ca",
      "credentials": {
        "slackApi": {
          "id": "dv4JKiUGLbWXXBT2",
          "name": "Slack account"
        }
      }
    }
  ],
  "pinData": {},
  "connections": {
    "Schedule Trigger": {
      "main": [
        [
          {
            "node": "List activities",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "List activities": {
      "main": [
        [
          {
            "node": "List findings",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "List findings": {
      "main": [
        [
          {
            "node": "Get user IDs",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get user IDs": {
      "main": [
        [
          {
            "node": "List users",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "List users": {
      "main": [
        [
          {
            "node": "Calculate score",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Calculate score": {
      "main": [
        [
          {
            "node": "Format leaderboard",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Format leaderboard": {
      "main": [
        [
          {
            "node": "Send a message",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "active": false,
  "settings": {
    "executionOrder": "v1",
    "binaryMode": "separate",
    "availableInMCP": false
  },
  "versionId": "54e756d1-8ca8-4a7c-a59a-23fce23ab4f5",
  "meta": {
    "templateCredsSetupCompleted": true,
    "instanceId": "56bfeee74a39560e3a5eb9e4eb7af65001d78077a85a733fb445c67f3f7f3e45"
  },
  "id": "qb7Vz9UT4q0WCgrdRMEhd",
  "tags": []
}