{
  "name": "Validate staff certifications against upcoming session dates using Google Calendar, Sheets, Slack and Gmail",
  "nodes": [
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "weeks",
              "triggerAtDay": [
                1
              ],
              "triggerAtHour": 7
            }
          ]
        }
      },
      "id": "26d3638b-a752-42a9-9a37-12543ba52e8f",
      "name": "Weekly Radar Trigger",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.3,
      "position": [
        32,
        -160
      ]
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "cfg-roster-sheet",
              "name": "rosterSheetId",
              "value": "<__PLACEHOLDER_VALUE__ROSTER_SPREADSHEET_ID__>",
              "type": "string"
            },
            {
              "id": "cfg-roster-tab",
              "name": "rosterTabName",
              "value": "Roster",
              "type": "string"
            },
            {
              "id": "cfg-staffing-tab",
              "name": "staffingTabName",
              "value": "Staffing",
              "type": "string"
            },
            {
              "id": "cfg-calendar-id",
              "name": "calendarId",
              "value": "<__PLACEHOLDER_VALUE__GOOGLE_CALENDAR_ID__>",
              "type": "string"
            },
            {
              "id": "cfg-slack-channel",
              "name": "slackChannelId",
              "value": "<__PLACEHOLDER_VALUE__SLACK_CHANNEL_ID__>",
              "type": "string"
            },
            {
              "id": "cfg-lookahead",
              "name": "lookaheadWeeks",
              "value": 10,
              "type": "number"
            }
          ]
        },
        "options": {}
      },
      "id": "243ebf71-1eb6-4c19-9c59-6b6561cbc11a",
      "name": "Workflow Configuration",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        304,
        -160
      ]
    },
    {
      "parameters": {
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $('Workflow Configuration').first().json.rosterSheetId }}"
        },
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "={{ $('Workflow Configuration').first().json.rosterTabName }}"
        },
        "options": {
          "outputFormatting": {
            "values": {
              "general": "UNFORMATTED_VALUE",
              "date": "FORMATTED_STRING"
            }
          }
        }
      },
      "id": "758843b9-cff3-4a69-8434-cbe678709001",
      "name": "Read Staff Roster",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.7,
      "position": [
        560,
        -160
      ],
      "alwaysOutputData": true,
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $('Workflow Configuration').first().json.rosterSheetId }}"
        },
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "={{ $('Workflow Configuration').first().json.staffingTabName }}"
        },
        "options": {
          "outputFormatting": {
            "values": {
              "general": "UNFORMATTED_VALUE",
              "date": "FORMATTED_STRING"
            }
          }
        }
      },
      "id": "499366fe-64ba-4ca6-b1e3-0aa450df746e",
      "name": "Read Session Staffing Map",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.7,
      "position": [
        816,
        -160
      ],
      "executeOnce": true,
      "alwaysOutputData": true,
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "operation": "get",
        "dataTableId": {
          "__rl": true,
          "mode": "name",
          "value": "Certification Lapse Radar Rules",
          "cachedResultName": null
        },
        "returnAll": true
      },
      "id": "dad40680-f424-41e4-bf59-968c943f0cac",
      "name": "Load Coverage Rules",
      "type": "n8n-nodes-base.dataTable",
      "typeVersion": 1.1,
      "position": [
        1072,
        -160
      ],
      "executeOnce": true,
      "alwaysOutputData": true
    },
    {
      "parameters": {
        "operation": "getAll",
        "calendar": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $('Workflow Configuration').first().json.calendarId }}"
        },
        "returnAll": true,
        "timeMin": "={{ $now.toISO() }}",
        "timeMax": "={{ $now.plus({ weeks: $('Workflow Configuration').first().json.lookaheadWeeks }).toISO() }}",
        "options": {
          "recurringEventHandling": "expand"
        }
      },
      "id": "94c2ede1-2c7f-4f00-a3e3-deb086f22ced",
      "name": "Fetch Upcoming Sessions",
      "type": "n8n-nodes-base.googleCalendar",
      "typeVersion": 1.3,
      "position": [
        1344,
        -160
      ],
      "executeOnce": true,
      "credentials": {
        "googleCalendarOAuth2Api": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "ext-event-id",
              "name": "eventId",
              "value": "={{ $json.id }}",
              "type": "string"
            },
            {
              "id": "ext-title",
              "name": "sessionTitle",
              "value": "={{ $json.summary || \"(untitled event)\" }}",
              "type": "string"
            },
            {
              "id": "ext-code",
              "name": "sessionCode",
              "value": "={{ ((($json.summary || \"\").match(/\\[([A-Za-z0-9_-]+)\\]/) || [])[1] || \"\").toUpperCase() }}",
              "type": "string"
            },
            {
              "id": "ext-start",
              "name": "sessionStartRaw",
              "value": "={{ $json.start ? ($json.start.dateTime || $json.start.date) : \"\" }}",
              "type": "string"
            }
          ]
        },
        "options": {}
      },
      "id": "99074362-b645-4a1b-90d7-a07da6e1ed43",
      "name": "Extract Session Code Token",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        1712,
        -160
      ]
    },
    {
      "parameters": {
        "jsCode": "const rosterRows = $('Read Staff Roster').all().map(i => i.json);\nconst staffingRows = $('Read Session Staffing Map').all().map(i => i.json);\nconst ruleRows = $('Load Coverage Rules').all().map(i => i.json);\nconst sessions = $input.all().map(i => i.json);\n\nfunction parseDateOnly(value) {\n  if (value === null || value === undefined || value === '') return null;\n  if (typeof value === 'number' && isFinite(value)) {\n    const serial = DateTime.fromObject({ year: 1899, month: 12, day: 30 }).plus({ days: Math.floor(value) });\n    return serial.isValid ? serial.startOf('day') : null;\n  }\n  const text = String(value).trim();\n  const attempts = [\n    DateTime.fromISO(text, { setZone: true }),\n    DateTime.fromFormat(text, 'yyyy-MM-dd'),\n    DateTime.fromFormat(text, 'M/d/yyyy'),\n    DateTime.fromFormat(text, 'MMM d, yyyy'),\n    DateTime.fromFormat(text, 'd-MMM-yyyy')\n  ];\n  for (const dt of attempts) {\n    if (dt.isValid) return dt.startOf('day');\n  }\n  const js = DateTime.fromJSDate(new Date(text));\n  return js.isValid ? js.startOf('day') : null;\n}\n\nconst clean = v => String(v === null || v === undefined ? '' : v).trim();\n\nconst roster = {};\nfor (const row of rosterRows) {\n  const person = clean(row.Person !== undefined ? row.Person : row.person);\n  const cert = clean(row.Certification !== undefined ? row.Certification : row.certification);\n  if (!person || !cert) continue;\n  if (!roster[person]) roster[person] = { email: '', certs: {} };\n  const email = clean(row.Email !== undefined ? row.Email : row.email);\n  if (email) roster[person].email = email;\n  const expiryRaw = row.Expiry !== undefined ? row.Expiry : row.expiry;\n  roster[person].certs[cert.toLowerCase()] = { label: cert, expiry: parseDateOnly(expiryRaw), expiryRaw: clean(expiryRaw) };\n}\n\nconst staffing = {};\nfor (const row of staffingRows) {\n  const code = clean(row.SessionCode !== undefined ? row.SessionCode : row.sessionCode).toUpperCase();\n  const person = clean(row.Person !== undefined ? row.Person : row.person);\n  if (!code || !person) continue;\n  if (!staffing[code]) staffing[code] = [];\n  if (!staffing[code].includes(person)) staffing[code].push(person);\n}\n\nconst rules = {};\nfor (const row of ruleRows) {\n  const type = clean(row.session_type).toUpperCase();\n  if (!type) continue;\n  rules[type] = { requiredCert: clean(row.required_cert), minCertified: Number(row.min_certified) || 0 };\n}\n\nconst results = [];\nfor (const s of sessions) {\n  const base = {\n    eventId: s.eventId || '',\n    sessionTitle: s.sessionTitle || '(untitled event)',\n    sessionCode: s.sessionCode || '',\n    sessionDateIso: '',\n    status: '',\n    reasonText: '',\n    requiredCert: '',\n    minRequired: 0,\n    assignedCount: 0,\n    certifiedCount: 0,\n    shortfall: 0,\n    lapsed: [],\n    fixPlan: [],\n    fixSummary: '',\n    renewBy: ''\n  };\n  const sessionDate = parseDateOnly(s.sessionStartRaw);\n  base.sessionDateIso = sessionDate ? sessionDate.toISODate() : clean(s.sessionStartRaw);\n  if (!s.sessionCode) {\n    base.status = 'unmatched';\n    base.reasonText = 'no [CODE] token found in the event title';\n    results.push(base);\n    continue;\n  }\n  const type = s.sessionCode.split('-')[0];\n  const rule = rules[type];\n  if (!rule) {\n    base.status = 'unmatched';\n    base.reasonText = 'no rule for session type ' + type + ' in the rules Data Table';\n    results.push(base);\n    continue;\n  }\n  const assigned = staffing[s.sessionCode] || [];\n  if (assigned.length === 0) {\n    base.status = 'unmatched';\n    base.reasonText = 'no staffing rows for code ' + s.sessionCode + ' in the Staffing tab';\n    results.push(base);\n    continue;\n  }\n  if (!sessionDate) {\n    base.status = 'unmatched';\n    base.reasonText = 'could not parse the session start date';\n    results.push(base);\n    continue;\n  }\n  base.requiredCert = rule.requiredCert;\n  base.minRequired = rule.minCertified;\n  base.assignedCount = assigned.length;\n  base.renewBy = sessionDate.toISODate();\n  const sessionDateIso = sessionDate.toISODate();\n  const lapsed = [];\n  let certifiedCount = 0;\n  for (const person of assigned) {\n    const entry = roster[person];\n    const cert = entry ? entry.certs[rule.requiredCert.toLowerCase()] : undefined;\n    if (cert && cert.expiry && cert.expiry.toISODate() >= sessionDateIso) {\n      certifiedCount += 1;\n    } else {\n      let expiredOn = 'person not on roster';\n      if (entry) {\n        if (cert && cert.expiry) expiredOn = cert.expiry.toISODate();\n        else if (cert) expiredOn = 'unparseable date: ' + cert.expiryRaw;\n        else expiredOn = 'cert not on roster';\n      }\n      lapsed.push({\n        person: person,\n        email: entry ? entry.email : '',\n        cert: rule.requiredCert,\n        expiredOn: expiredOn,\n        renewable: Boolean(cert && cert.expiry)\n      });\n    }\n  }\n  base.certifiedCount = certifiedCount;\n  base.lapsed = lapsed;\n  const shortfall = Math.max(0, rule.minCertified - certifiedCount);\n  base.shortfall = shortfall;\n  if (shortfall === 0) {\n    base.status = 'covered';\n    results.push(base);\n    continue;\n  }\n  base.status = 'under_covered';\n  const renewable = lapsed.filter(l => l.renewable).sort((a, b) => (a.expiredOn < b.expiredOn ? 1 : -1));\n  const picks = renewable.slice(0, shortfall).map(l => ({ person: l.person, email: l.email, cert: l.cert, expiredOn: l.expiredOn, renewBy: base.renewBy }));\n  base.fixPlan = picks;\n  const parts = picks.map(p => 'renew ' + p.person + ' (' + p.cert + ', expired ' + p.expiredOn + ')');\n  let summary = parts.length ? parts.join('; ') + ' by ' + base.renewBy : '';\n  const stillShort = shortfall - picks.length;\n  if (stillShort > 0) {\n    summary += (summary ? '. ' : '') + 'Renewals alone cannot close the gap: assign ' + stillShort + ' more ' + rule.requiredCert + ' holder(s)';\n  }\n  base.fixSummary = summary;\n  results.push(base);\n}\n\nreturn results.map(r => ({ json: r }));"
      },
      "id": "e7e7a1ac-86f0-4796-9f57-4b8747de4c80",
      "name": "Validate Certs On Session Date",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1952,
        -160
      ]
    },
    {
      "parameters": {
        "rules": {
          "values": [
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 1
                },
                "conditions": [
                  {
                    "leftValue": "={{ $json.status }}",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    },
                    "rightValue": "under_covered"
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "Under Covered"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 1
                },
                "conditions": [
                  {
                    "leftValue": "={{ $json.status }}",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    },
                    "rightValue": "unmatched"
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "Unmatched"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 1
                },
                "conditions": [
                  {
                    "leftValue": "={{ $json.status }}",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    },
                    "rightValue": "covered"
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "Covered"
            }
          ]
        },
        "options": {
          "fallbackOutput": "none"
        }
      },
      "id": "5bd478b1-9397-4a72-a220-396bb3cb5198",
      "name": "Route Sessions By Coverage Status",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3.4,
      "position": [
        48,
        416
      ]
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "fix-line-type",
              "name": "lineType",
              "value": "fix",
              "type": "string"
            },
            {
              "id": "fix-report-line",
              "name": "reportLine",
              "value": "={{ \":rotating_light: *\" + $json.sessionCode + \"* \" + $json.sessionTitle + \" on \" + $json.sessionDateIso + \": \" + $json.certifiedCount + \" of \" + $json.minRequired + \" required \" + $json.requiredCert + \" holders are valid on the session date. Minimal fix: \" + $json.fixSummary }}",
              "type": "string"
            }
          ]
        },
        "options": {}
      },
      "id": "3c6f2c70-53c6-498e-853e-b0d3090624c5",
      "name": "Mark Fix Required",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        304,
        256
      ]
    },
    {
      "parameters": {
        "numberInputs": 3
      },
      "id": "34232cea-9f2e-4af8-942e-01f2de411cbb",
      "name": "Collect Report Sections",
      "type": "n8n-nodes-base.merge",
      "typeVersion": 3.2,
      "position": [
        560,
        416
      ]
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "warn-line-type",
              "name": "lineType",
              "value": "unmatched",
              "type": "string"
            },
            {
              "id": "warn-report-line",
              "name": "reportLine",
              "value": "={{ \":warning: \" + $json.sessionTitle + \" on \" + $json.sessionDateIso + \": \" + $json.reasonText + \". This session was NOT validated.\" }}",
              "type": "string"
            }
          ]
        },
        "options": {}
      },
      "id": "f7da7ae9-ee34-435e-909f-d913181c486a",
      "name": "Mark Unmatched Warning",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        304,
        432
      ]
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "ok-line-type",
              "name": "lineType",
              "value": "covered",
              "type": "string"
            },
            {
              "id": "ok-report-line",
              "name": "reportLine",
              "value": "={{ \":white_check_mark: *\" + $json.sessionCode + \"* \" + $json.sessionTitle + \" on \" + $json.sessionDateIso + \": \" + $json.certifiedCount + \" of \" + $json.minRequired + \" required \" + $json.requiredCert + \" holders valid.\" }}",
              "type": "string"
            }
          ]
        },
        "options": {}
      },
      "id": "64ad778d-420d-4757-b70a-5d77d7c3f1ec",
      "name": "Mark Covered Session",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        304,
        640
      ]
    },
    {
      "parameters": {
        "jsCode": "const cfg = $('Workflow Configuration').first().json;\nconst lines = $input.all().map(i => i.json);\nconst fixes = lines.filter(l => l.lineType === 'fix');\nconst warns = lines.filter(l => l.lineType === 'unmatched');\nconst oks = lines.filter(l => l.lineType === 'covered');\n\nconst out = [];\nout.push(':radar: *Certification Lapse Radar*: next ' + cfg.lookaheadWeeks + ' weeks, ' + lines.length + ' session(s) checked.');\nout.push('');\nif (fixes.length) {\n  out.push('*Under covered sessions (' + fixes.length + ')*');\n  for (const l of fixes) out.push(l.reportLine);\n  out.push('');\n}\nif (warns.length) {\n  out.push('*Unmatched calendar events (' + warns.length + ')*');\n  out.push('These events could not be joined to staffing and rules, so they were NOT validated. Fix the [CODE] token in the event title, the Staffing tab, or the rules Data Table.');\n  for (const l of warns) out.push(l.reportLine);\n  out.push('');\n}\nif (!fixes.length && !warns.length) {\n  out.push(':white_check_mark: All clear. Every matched session meets its certified headcount on the session date.');\n  out.push('');\n}\nout.push('Fully covered sessions: ' + oks.length + '.');\n\nreturn [{ json: { slackText: out.join('\\n'), fixCount: fixes.length, unmatchedCount: warns.length, coveredCount: oks.length } }];"
      },
      "id": "89859709-51ec-42cd-bf68-0a62bf3e9833",
      "name": "Compose Coverage Report",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        800,
        432
      ]
    },
    {
      "parameters": {
        "select": "channel",
        "channelId": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $('Workflow Configuration').first().json.slackChannelId }}"
        },
        "text": "={{ $json.slackText }}",
        "otherOptions": {
          "includeLinkToWorkflow": false,
          "mrkdwn": true
        }
      },
      "id": "4c46e4f8-d811-42ba-b453-fca1aa043a27",
      "name": "Post Fix List To Slack",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.5,
      "position": [
        1008,
        432
      ],
      "credentials": {
        "slackApi": {
          "name": "<your credential>"
        }
      },
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "jsCode": "const sessions = $('Validate Certs On Session Date').all().map(i => i.json);\nconst byKey = {};\nfor (const s of sessions) {\n  if (s.status !== 'under_covered' && s.status !== 'covered') continue;\n  for (const l of (s.lapsed || [])) {\n    if (!l.email || !l.renewable) continue;\n    const key = l.person + '|' + l.cert;\n    if (!byKey[key]) {\n      byKey[key] = { person: l.person, email: l.email, cert: l.cert, expiredOn: l.expiredOn, sessions: [] };\n    }\n    byKey[key].sessions.push({ code: s.sessionCode, date: s.sessionDateIso });\n  }\n}\nconst notices = Object.values(byKey).map(n => {\n  const sorted = n.sessions.slice().sort((a, b) => (a.date < b.date ? -1 : 1));\n  return {\n    person: n.person,\n    email: n.email,\n    cert: n.cert,\n    expiredOn: n.expiredOn,\n    sessionCount: sorted.length,\n    sessionList: sorted.map(x => x.code + ' on ' + x.date).join('; '),\n    renewBy: sorted.length ? sorted[0].date : ''\n  };\n});\nreturn notices.map(n => ({ json: n }));"
      },
      "id": "b0f8f423-35fe-4673-b51d-390c93710919",
      "name": "Build Staffer Expiry Notices",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1344,
        432
      ]
    },
    {
      "parameters": {
        "sendTo": "={{ $json.email }}",
        "subject": "=Certification renewal needed: {{ $json.cert }} by {{ $json.renewBy }}",
        "emailType": "text",
        "message": "=Hi {{ $json.person }},\n\nOur weekly certification check found that your {{ $json.cert }} certification expired on {{ $json.expiredOn }}, which leaves it invalid for these upcoming sessions you are assigned to:\n\n{{ $json.sessionList }}\n\nPlease renew by {{ $json.renewBy }} so the required certified headcount is met on the session date.\n\nThank you,\nCertification Lapse Radar",
        "options": {
          "appendAttribution": false
        }
      },
      "id": "138f343b-607d-44f9-9ba1-703fdd69ca2c",
      "name": "Send Expiry Renewal Email",
      "type": "n8n-nodes-base.gmail",
      "typeVersion": 2.2,
      "position": [
        1840,
        448
      ],
      "credentials": {
        "gmailOAuth2": {
          "name": "<your credential>"
        }
      },
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "options": {}
      },
      "id": "83b12876-b381-4805-90c8-5f0c8bdee2f8",
      "name": "Loop Staffer Notices",
      "type": "n8n-nodes-base.splitInBatches",
      "typeVersion": 3,
      "position": [
        1584,
        432
      ]
    },
    {
      "parameters": {
        "content": "# Certification Lapse Radar\n\nValidates every staffer's certification on the date of each upcoming session, not today. The radar joins a Google Sheets roster and staffing map, a rules Data Table, and Google Calendar events for the configured window. Sessions that fall below their required certified headcount get a greedy minimal fix: the fewest renewals that restore coverage and the date they must land by. Slack gets one fix list, each lapsed staffer gets one renewal email.\n\n## How it works\n1. The weekly trigger loads Workflow Configuration, the single node holding every id and setting.\n2. Google Sheets supplies the Roster tab (Person, Email, Certification, Expiry) and the Staffing tab (SessionCode, Person).\n3. The Certification Lapse Radar Rules Data Table supplies session_type, required_cert, min_certified.\n4. Google Calendar returns every event inside the lookahead window with recurring events expanded into dated instances.\n5. The [CODE] token in each event title joins the event to staffing rows, and its prefix before the first hyphen picks the rule.\n6. Each assigned staffer's expiry is compared to the session date, both sides reduced to date only with Luxon, so midnight timezone drift cannot flip a verdict.\n7. Under covered sessions get the smallest renewal set, latest lapsed certs first, due by the session date. Unmatched events get their own warning section.\n8. One Slack post carries fixes, unmatched warnings, and the covered count. One Gmail per lapsed staffer lists every affected session.\n\n## Setup\n1. Create a Google Sheet and put its id into Workflow Configuration as rosterSheetId, replacing ROSTER_SPREADSHEET_ID.\n2. Add tab Roster with columns Person, Email, Certification, Expiry. One row per person per certification. Type Expiry as ISO text (YYYY-MM-DD) with the column formatted as plain text. The parser also survives serial numbers and common formats, but ISO text is the contract.\n3. Add tab Staffing with columns SessionCode, Person. One row per assignment. Codes look like FA-101, the prefix before the first hyphen is the session type.\n4. Review the Certification Lapse Radar Rules Data Table: session_type, required_cert (must match the Roster Certification text exactly), min_certified. Three sample rows are seeded, edit them to your programs.\n5. Put the [CODE] token in every session calendar title, for example First Aid Refresher [FA-101]. No token means the event lands in the unmatched warning section.\n6. Replace GOOGLE_CALENDAR_ID and SLACK_CHANNEL_ID in Workflow Configuration and set lookaheadWeeks between 8 and 12.\n7. Connect the Google Sheets, Google Calendar, Slack (invite the bot to the channel), and Gmail credentials, then activate.",
        "height": 812,
        "width": 1300
      },
      "id": "d0aa4096-9f09-4d17-9c7d-fd5a9dd21024",
      "name": "Overview And Setup",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        0,
        -1264
      ]
    },
    {
      "parameters": {
        "content": "## 1. Gather\nWeekly trigger plus one config node holding every placeholder. Four reads follow: Roster tab, Staffing tab, rules Data Table, calendar window. Fetches after the roster carry executeOnce so upstream row counts never multiply API calls.",
        "height": 388,
        "width": 1588,
        "color": 7
      },
      "id": "f8a1af10-2c9e-4a7e-8c9e-505354c73730",
      "name": "Section 1 Gather Notes",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -48,
        -352
      ]
    },
    {
      "parameters": {
        "content": "## 2. Validate on the session date\nThe [CODE] token joins each event to staffing and rules. Expiry versus session date is compared date only on both sides with Luxon. The fix is greedy and per session, no global optimizer.",
        "height": 404,
        "width": 560,
        "color": 7
      },
      "id": "d2f171da-20c4-411f-b4dd-7e042f249bb9",
      "name": "Section 2 Validate Notes",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1600,
        -368
      ]
    },
    {
      "parameters": {
        "content": "## 3. Report\nSessions route three ways and each branch formats its own line. The merge collects fixes, unmatched warnings, and covered lines into one Slack post.",
        "height": 736,
        "width": 1248,
        "color": 7
      },
      "id": "f4201745-aaf9-4f77-abbb-170452446b7f",
      "name": "Section 3 Report Notes",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -32,
        96
      ]
    },
    {
      "parameters": {
        "content": "## 4. Email lapsed staffers\nOne Gmail per staffer per lapsed cert, deduped across sessions. The earliest affected session sets the renew by date. Sends continue on error, one bad address never kills the run.",
        "height": 436,
        "width": 756,
        "color": 7
      },
      "id": "b224fd69-d697-4833-bae1-704e8c881ca2",
      "name": "Section 4 Email Notes",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1280,
        240
      ]
    },
    {
      "parameters": {
        "content": "## Two ways this radar can lie\n1. Unmatched events are warned, never dropped. An event missing its [CODE] token, its staffing rows, or its rule cannot be validated. Silently skipping it would fake full coverage, so it gets its own Slack warning section.\n2. Keep recurringEventHandling on expand. Validating only the recurring master checks only the first date of the series. Expansion checks every occurrence inside the window.",
        "height": 216,
        "width": 820,
        "color": 3
      },
      "id": "cd4b8bd1-7053-47f2-9493-29812d48f836",
      "name": "Warning Unmatched Events And Recurrence",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1360,
        -704
      ]
    }
  ],
  "connections": {
    "Weekly Radar Trigger": {
      "main": [
        [
          {
            "node": "Workflow Configuration",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Workflow Configuration": {
      "main": [
        [
          {
            "node": "Read Staff Roster",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read Staff Roster": {
      "main": [
        [
          {
            "node": "Read Session Staffing Map",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read Session Staffing Map": {
      "main": [
        [
          {
            "node": "Load Coverage Rules",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Load Coverage Rules": {
      "main": [
        [
          {
            "node": "Fetch Upcoming Sessions",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch Upcoming Sessions": {
      "main": [
        [
          {
            "node": "Extract Session Code Token",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Extract Session Code Token": {
      "main": [
        [
          {
            "node": "Validate Certs On Session Date",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Validate Certs On Session Date": {
      "main": [
        [
          {
            "node": "Route Sessions By Coverage Status",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Route Sessions By Coverage Status": {
      "main": [
        [
          {
            "node": "Mark Fix Required",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Mark Unmatched Warning",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Mark Covered Session",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Mark Fix Required": {
      "main": [
        [
          {
            "node": "Collect Report Sections",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Collect Report Sections": {
      "main": [
        [
          {
            "node": "Compose Coverage Report",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Mark Unmatched Warning": {
      "main": [
        [
          {
            "node": "Collect Report Sections",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "Mark Covered Session": {
      "main": [
        [
          {
            "node": "Collect Report Sections",
            "type": "main",
            "index": 2
          }
        ]
      ]
    },
    "Compose Coverage Report": {
      "main": [
        [
          {
            "node": "Post Fix List To Slack",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Post Fix List To Slack": {
      "main": [
        [
          {
            "node": "Build Staffer Expiry Notices",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Staffer Expiry Notices": {
      "main": [
        [
          {
            "node": "Loop Staffer Notices",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Send Expiry Renewal Email": {
      "main": [
        [
          {
            "node": "Loop Staffer Notices",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Loop Staffer Notices": {
      "main": [
        [],
        [
          {
            "node": "Send Expiry Renewal Email",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "active": false,
  "settings": {
    "executionOrder": "v1"
  },
  "tags": []
}