{
  "name": "Watch Licences, Insurances and Tickets for Expiry and Warn Before They Lapse",
  "nodes": [
    {
      "parameters": {
        "content": "# Nothing expires without warning\n\nLicences, insurances, white cards, trade tickets, police checks, first aid certificates. Every one of them has a date on it, and none of them announce themselves.\n\nThis reads a register each morning, works out what is approaching expiry, and warns at 60, 30 and 7 days, then daily once something has actually lapsed.\n\n**Sequence**\n1. Runs once each morning\n2. Reads the register in Google Sheets\n3. Works out days remaining and which warning band each item is in\n4. Emails the holder\n5. Escalates to Slack at 7 days and once expired\n6. Records the band so the same warning never sends twice\n\n**You need**\n- Google Sheets\n- Gmail\n- Slack\n\nBuilt by Better Automations (Melbourne, Australia).\nhttps://www.betterautomations.com.au/services/n8n-workflow-automation",
        "height": 660,
        "width": 460,
        "color": 4
      },
      "id": "d4e5f6a7-0004-4000-8000-000000000001",
      "name": "Sticky Note - Overview",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [-660, -160]
    },
    {
      "parameters": {
        "content": "## Your register\n\nOne row per document. Column headers, spelled exactly:\n\n`item_id` · `holder_name` · `holder_email` · `document_type` · `document_number` · `expiry_date` · `manager_email` · `last_notified_band`\n\n`expiry_date` must be `YYYY-MM-DD`. Google Sheets loves reformatting dates, so set that column to plain text before you paste anything in.\n\nLeave `last_notified_band` blank. The workflow owns it.",
        "height": 400,
        "width": 360,
        "color": 7
      },
      "id": "d4e5f6a7-0004-4000-8000-000000000002",
      "name": "Sticky Note - Register",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [-160, -580]
    },
    {
      "parameters": {
        "content": "## Why one warning per band\n\n**Assess Expiry** records which band an item was last warned about, so 60 days sends once, 30 days sends once, and 7 days sends once.\n\nWithout that, a 60-day warning would fire every morning for a month and people would filter the whole lot into a folder they never open. A warning nobody reads is worse than no warning, because it feels like cover.\n\nOnce something has actually expired it does warn daily, deliberately.",
        "height": 400,
        "width": 360,
        "color": 7
      },
      "id": "d4e5f6a7-0004-4000-8000-000000000003",
      "name": "Sticky Note - Bands",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [720, -580]
    },
    {
      "parameters": {
        "rule": { "interval": [{ "triggerAtHour": 7, "triggerAtMinute": 0 }] }
      },
      "id": "d4e5f6a7-0004-4000-8000-000000000010",
      "name": "Every Morning at 7am",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [-160, 0]
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            { "id": "cfg-bands", "name": "warning_days", "value": "60,30,7", "type": "string" },
            { "id": "cfg-chan", "name": "escalation_channel", "value": "#compliance", "type": "string" },
            { "id": "cfg-esc", "name": "escalate_at_days", "value": 7, "type": "number" },
            { "id": "cfg-org", "name": "organisation_name", "value": "Better Automations", "type": "string" }
          ]
        },
        "options": {}
      },
      "id": "d4e5f6a7-0004-4000-8000-000000000011",
      "name": "Configuration",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [60, 0]
    },
    {
      "parameters": {
        "documentId": { "__rl": true, "value": "", "mode": "list", "cachedResultName": "Compliance Register" },
        "sheetName": { "__rl": true, "value": "", "mode": "list", "cachedResultName": "Documents" },
        "options": {}
      },
      "id": "d4e5f6a7-0004-4000-8000-000000000012",
      "name": "Read the Register",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [280, 0]
    },
    {
      "parameters": {
        "jsCode": "// Works out days remaining, assigns a warning band, and suppresses anything\n// already warned about in that same band.\nconst cfg = $('Configuration').first().json;\nconst bands = String(cfg.warning_days).split(',').map((d) => parseInt(d.trim(), 10)).sort((a, b) => a - b);\n\nconst DAY = 24 * 60 * 60 * 1000;\nconst today = new Date();\ntoday.setHours(0, 0, 0, 0);\n\nconst out = [];\n\nfor (const item of $input.all()) {\n  const r = item.json;\n  if (!r.expiry_date || !r.holder_email) continue;\n\n  const expiry = new Date(r.expiry_date);\n  if (Number.isNaN(expiry.getTime())) continue;\n  expiry.setHours(0, 0, 0, 0);\n\n  const daysLeft = Math.round((expiry - today) / DAY);\n\n  // Smallest band the item has fallen inside. 45 days left with bands 60/30/7 is the 60 band.\n  let band = null;\n  if (daysLeft < 0) band = 'expired';\n  else {\n    const hit = bands.filter((b) => daysLeft <= b);\n    if (hit.length) band = String(Math.min(...hit));\n  }\n\n  if (band === null) continue;                       // plenty of time, nothing to do\n  if (band !== 'expired' && String(r.last_notified_band) === band) continue;  // already warned in this band\n\n  const expired = band === 'expired';\n  out.push({\n    json: {\n      ...r,\n      days_left: daysLeft,\n      band,\n      is_expired: expired,\n      needs_escalation: expired || daysLeft <= cfg.escalate_at_days,\n      headline: expired\n        ? `${r.document_type} expired ${Math.abs(daysLeft)} day${Math.abs(daysLeft) === 1 ? '' : 's'} ago`\n        : `${r.document_type} expires in ${daysLeft} day${daysLeft === 1 ? '' : 's'}`,\n    },\n  });\n}\n\nreturn out;"
      },
      "id": "d4e5f6a7-0004-4000-8000-000000000013",
      "name": "Assess Expiry",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [500, 0]
    },
    {
      "parameters": {
        "sendTo": "={{ $json.holder_email }}",
        "subject": "={{ $json.headline }}",
        "emailType": "text",
        "message": "=Hi {{ $json.holder_name.split(' ')[0] }},\n\n{{ $json.headline }}.\n\nDocument: {{ $json.document_type }}{{ $json.document_number ? ' (' + $json.document_number + ')' : '' }}\nExpiry date: {{ $json.expiry_date }}\n\n{{ $json.is_expired ? 'This one has already lapsed, so it needs sorting today. Send the renewed copy through once you have it and the register gets updated.' : 'Renewing takes longer than people expect, so it is worth starting now. Send the renewed copy through when it arrives.' }}\n\n{{ $('Configuration').first().json.organisation_name }}",
        "options": { "ccList": "={{ $json.needs_escalation ? $json.manager_email : '' }}" }
      },
      "id": "d4e5f6a7-0004-4000-8000-000000000014",
      "name": "Warn the Holder",
      "type": "n8n-nodes-base.gmail",
      "typeVersion": 2.1,
      "position": [760, 0],
      "webhookId": "d4e5f6a7-0004-4000-8000-00000000f001"
    },
    {
      "parameters": {
        "operation": "update",
        "documentId": { "__rl": true, "value": "", "mode": "list", "cachedResultName": "Compliance Register" },
        "sheetName": { "__rl": true, "value": "", "mode": "list", "cachedResultName": "Documents" },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "item_id": "={{ $json.item_id }}",
            "last_notified_band": "={{ $json.band }}"
          },
          "matchingColumns": ["item_id"]
        },
        "options": {}
      },
      "id": "d4e5f6a7-0004-4000-8000-000000000015",
      "name": "Record the Warning",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [1020, 0]
    },
    {
      "parameters": {
        "conditions": {
          "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2 },
          "conditions": [
            { "id": "chk-esc", "leftValue": "={{ $json.needs_escalation }}", "rightValue": true, "operator": { "type": "boolean", "operation": "true", "singleValue": true } }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "d4e5f6a7-0004-4000-8000-000000000016",
      "name": "Close to the Wire?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [1280, 0]
    },
    {
      "parameters": {
        "select": "channel",
        "channelId": { "__rl": true, "value": "={{ $('Configuration').first().json.escalation_channel }}", "mode": "name" },
        "text": "={{ $json.is_expired ? ':rotating_light: *Expired*' : ':warning: *Expiring this week*' }}\n\n*{{ $json.holder_name }}* · {{ $json.document_type }}{{ $json.document_number ? ' (' + $json.document_number + ')' : '' }}\n{{ $json.headline }} · expiry {{ $json.expiry_date }}\nManager: {{ $json.manager_email }}\n\n{{ $json.is_expired ? 'Check whether this person should be on site or on the tools today.' : 'The holder has been emailed and the manager copied in.' }}",
        "otherOptions": { "includeLinkToWorkflow": false }
      },
      "id": "d4e5f6a7-0004-4000-8000-000000000017",
      "name": "Escalate to Slack",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.3,
      "position": [1540, -100],
      "webhookId": "d4e5f6a7-0004-4000-8000-00000000f002"
    },
    {
      "parameters": {},
      "id": "d4e5f6a7-0004-4000-8000-000000000018",
      "name": "Warned, Nothing Urgent",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [1540, 120]
    }
  ],
  "connections": {
    "Every Morning at 7am": { "main": [[{ "node": "Configuration", "type": "main", "index": 0 }]] },
    "Configuration": { "main": [[{ "node": "Read the Register", "type": "main", "index": 0 }]] },
    "Read the Register": { "main": [[{ "node": "Assess Expiry", "type": "main", "index": 0 }]] },
    "Assess Expiry": { "main": [[{ "node": "Warn the Holder", "type": "main", "index": 0 }]] },
    "Warn the Holder": { "main": [[{ "node": "Record the Warning", "type": "main", "index": 0 }]] },
    "Record the Warning": { "main": [[{ "node": "Close to the Wire?", "type": "main", "index": 0 }]] },
    "Close to the Wire?": {
      "main": [
        [{ "node": "Escalate to Slack", "type": "main", "index": 0 }],
        [{ "node": "Warned, Nothing Urgent", "type": "main", "index": 0 }]
      ]
    }
  },
  "settings": { "executionOrder": "v1" },
  "pinData": {},
  "meta": {},
  "tags": []
}
