{
  "name": "02 - Notify (F3)",
  "nodes": [
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "cronExpression",
              "expression": "0 9 * * *"
            }
          ]
        }
      },
      "id": "Schedule 09:00",
      "name": "Schedule 09:00",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        -400,
        300
      ],
      "notes": "Office tz comes from workflow Settings -> Timezone (Europe/Istanbul)."
    },
    {
      "parameters": {
        "resource": "record",
        "operation": "search",
        "base": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $env.AIRTABLE_BASE_ID }}"
        },
        "table": {
          "__rl": true,
          "mode": "name",
          "value": "Employees"
        },
        "filterByFormula": "",
        "options": {}
      },
      "id": "Fetch Employees",
      "name": "Fetch Employees",
      "type": "n8n-nodes-base.airtable",
      "typeVersion": 2.1,
      "position": [
        -180,
        300
      ],
      "executeOnce": true,
      "notes": "Attach Airtable credential after import."
    },
    {
      "parameters": {
        "resource": "record",
        "operation": "search",
        "base": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $env.AIRTABLE_BASE_ID }}"
        },
        "table": {
          "__rl": true,
          "mode": "name",
          "value": "Onboarding_Tasks"
        },
        "filterByFormula": "AND({Status} = 'Pending', {Notified_At} = BLANK())",
        "options": {}
      },
      "id": "Fetch Pending Tasks",
      "name": "Fetch Pending Tasks",
      "type": "n8n-nodes-base.airtable",
      "typeVersion": 2.1,
      "position": [
        40,
        300
      ],
      "executeOnce": true,
      "notes": "Attach Airtable credential after import."
    },
    {
      "parameters": {
        "language": "javaScript",
        "jsCode": "// ============================================================================\n// GENERATED by `npm run build:nodes` \u2014 DO NOT EDIT.\n// Wrapper: workflows/code-nodes/notify.wrapper.js\n// Core inlined verbatim from: scripts/notify.js\n// Paste this whole file into the n8n Code node.\n// ============================================================================\n\n// n8n Code node \u2014 \"Build Messages\" (F3)  (THIN WRAPPER \u2014 do not put logic here)\n//\n// Core (notify.js) is inlined by `npm run build:nodes`. Edit logic in scripts/notify.js.\n// Paste the BUILT file (notify.built.js) into the n8n Code node.\n//\n// Upstream node names (rename the strings below if your workflow differs):\n//   input items         -> Pending, un-notified Onboarding_Tasks\n//   \"Fetch Employees\"   -> Employees rows (live name + start date, joined not snapshotted)\n// Env: OFFICE_TIMEZONE.\n//\n// Output: one item per assignee = { assignee_telegram_id, text, reply_markup, task_ids }.\n// Unresolved tasks (empty assignee) are skipped \u2014 there is no one to message; they wait\n// until HR fixes the assignee, then a later run picks them up.\n\n// ---- inlined from scripts/notify.js ----\n// notify.js \u2014 pure logic for F3 (grouped notifications) and F4 (completion).\n//\n// No network, no Telegram, no Date.now(). The only time-dependent input, \"today\", is\n// injected as an ISO date. `officeToday` derives that date in a timezone from a given\n// moment \u2014 kept here so the day-boundary rule is testable, not buried in n8n.\n\n/**\n * The office-local calendar date (ISO YYYY-MM-DD) for a given moment.\n * en-CA formats as YYYY-MM-DD; the timeZone makes it office-local, not UTC \u2014 this is\n * what makes \"Due_Date <= today\" correct regardless of what UTC clock the job runs on.\n * @param {Date} now\n * @param {string} timeZone - e.g. \"Europe/Istanbul\"\n */\nfunction officeToday(now, timeZone) {\n  return now.toLocaleDateString('en-CA', { timeZone });\n}\n\n/**\n * Tasks due for a notification today: Pending, due on/before today, not yet notified.\n * Dates are compared as ISO strings (YYYY-MM-DD sorts chronologically), so this is a\n * date comparison, never a moment comparison.\n */\nfunction selectDueTasks(tasks, today) {\n  return tasks.filter(\n    (t) => t.status === 'Pending' && !t.notified_at && String(t.due_date) <= today,\n  );\n}\n\n/**\n * Group tasks by assignee Telegram id, preserving first-seen order \u2014 one message per\n * person, not one per task.\n * @returns {{ assignee_telegram_id: string, tasks: object[] }[]}\n */\nfunction groupByAssignee(tasks) {\n  const groups = new Map();\n  for (const t of tasks) {\n    const key = String(t.assignee_telegram_id);\n    if (!groups.has(key)) groups.set(key, []);\n    groups.get(key).push(t);\n  }\n  return [...groups.entries()].map(([assignee_telegram_id, ts]) => ({\n    assignee_telegram_id,\n    tasks: ts,\n  }));\n}\n\n/**\n * Render a grouped message as Telegram payload pieces: `text` plus an inline keyboard.\n * Works for the first send (all Pending) and for re-render after a tap (mixed statuses):\n * final tasks show a marker and lose their buttons; Pending tasks keep Done / Not applicable.\n * Blocking tasks are flagged. Callback data is `done:<id>` / `skip:<id>`.\n * @returns {{ text: string, inline_keyboard: object[][] }}\n */\nfunction renderMessage(tasks) {\n  const byEmployee = new Map();\n  for (const t of tasks) {\n    if (!byEmployee.has(t.employee_id)) byEmployee.set(t.employee_id, []);\n    byEmployee.get(t.employee_id).push(t);\n  }\n\n  const lines = ['Onboarding tasks:'];\n  const inline_keyboard = [];\n\n  for (const [, empTasks] of byEmployee) {\n    const { employee_name, start_date } = empTasks[0];\n    lines.push('', `${employee_name} \u2014 starts ${start_date}`);\n    for (const t of empTasks) {\n      if (t.status === 'Done') {\n        lines.push(`  [done] ${t.title}`);\n      } else if (t.status === 'Skipped') {\n        lines.push(`  [n/a] ${t.title}`);\n      } else {\n        const flag = t.blocking ? '\u26a0\ufe0f ' : '';\n        lines.push(`  ${flag}${t.title} (due ${t.due_date})`);\n        inline_keyboard.push([\n          { text: '\u2705 Done', callback_data: `done:${t.id}` },\n          { text: '\ud83d\udeab Not applicable', callback_data: `skip:${t.id}` },\n        ]);\n      }\n    }\n  }\n\n  return { text: lines.join('\\n'), inline_keyboard };\n}\n\nconst CALLBACK_RE = /^(done|skip):(rec[A-Za-z0-9]+)$/;\n\n/**\n * Parse Telegram callback_data. Strict: anything that is not exactly `done:<recId>` or\n * `skip:<recId>` returns null (caller logs and ignores) \u2014 callbacks can arrive from a\n * stale message or another bot, so this must never throw.\n * @returns {{ action: 'done'|'skip', taskId: string } | null}\n */\nfunction parseCallback(data) {\n  if (typeof data !== 'string') return null;\n  const m = CALLBACK_RE.exec(data);\n  return m ? { action: m[1], taskId: m[2] } : null;\n}\n\n/**\n * Decide what a callback should do, given the current task, who tapped, and the action.\n * - not the assignee            -> 'unauthorized'\n * - task already Done/Skipped   -> 'already-final' (idempotent: do not rewrite Completed_At)\n * - otherwise                   -> 'apply' with the target status\n * @returns {{ outcome: 'unauthorized'|'already-final'|'apply', status?: 'Done'|'Skipped' }}\n */\nfunction resolveCallback({ task, fromTelegramId, action }) {\n  if (String(task.assignee_telegram_id) !== String(fromTelegramId)) {\n    return { outcome: 'unauthorized' };\n  }\n  if (task.status !== 'Pending') {\n    return { outcome: 'already-final' };\n  }\n  return { outcome: 'apply', status: action === 'done' ? 'Done' : 'Skipped' };\n}\n\n/** True when none of an employee's tasks are still Pending (all Done or Skipped). */\nfunction isEmployeeComplete(employeeTasks) {\n  return employeeTasks.length > 0 && employeeTasks.every((t) => t.status !== 'Pending');\n}\n\n// --- n8n glue ---------------------------------------------------------------\nconst flat = (v) => (Array.isArray(v) ? v[0] : v);\nconst tz = $env.OFFICE_TIMEZONE || 'UTC';\nconst today = officeToday(new Date(), tz);\n\nconst empById = {};\nfor (const i of $('Fetch Employees').all()) {\n  empById[i.json.id] = { name: i.json.Full_Name, start_date: i.json.Start_Date };\n}\n\nconst tasks = items.map((i) => {\n  const f = i.json;\n  const employee_id = flat(f.Employee);\n  const emp = empById[employee_id] || {};\n  return {\n    id: f.id,\n    title: f.Title,\n    due_date: f.Due_Date,\n    blocking: f.Blocking === true,\n    assignee_telegram_id: f.Assignee_Telegram_ID,\n    status: f.Status,\n    notified_at: f.Notified_At || '',\n    employee_id,\n    employee_name: emp.name,\n    start_date: emp.start_date,\n  };\n});\n\nreturn groupByAssignee(selectDueTasks(tasks, today))\n  .filter((g) => g.assignee_telegram_id && g.assignee_telegram_id !== 'undefined')\n  .map((g) => {\n    const { text, inline_keyboard } = renderMessage(g.tasks);\n    return { json: { assignee_telegram_id: g.assignee_telegram_id, text, reply_markup: { inline_keyboard }, task_ids: g.tasks.map((t) => t.id) } };\n  });\n"
      },
      "id": "Build Messages",
      "name": "Build Messages",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        260,
        300
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "=https://api.telegram.org/bot{{ $env.TELEGRAM_BOT_TOKEN }}/sendMessage",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ chat_id: $json.assignee_telegram_id, text: $json.text, reply_markup: $json.reply_markup }) }}",
        "options": {}
      },
      "id": "Send Telegram",
      "name": "Send Telegram",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        480,
        300
      ],
      "onError": "continueRegularOutput",
      "notes": "Continue on fail: one bad send must not halt the batch or block write-back for the others."
    },
    {
      "parameters": {
        "language": "javaScript",
        "jsCode": "const built = $('Build Messages').all();\nconst okByChat = {};\nfor (const s of $('Send Telegram').all()) { const r = s.json && s.json.result; if (r && r.chat) okByChat[String(r.chat.id)] = r; }\nconst now = new Date().toISOString();\nconst out = [];\nfor (const g of built) {\n  const r = okByChat[String(g.json.assignee_telegram_id)];\n  if (!r) continue; // no successful send for this group -> leave un-notified, retried next run\n  for (const taskId of g.json.task_ids || []) {\n    out.push({ json: { id: taskId, Notified_At: now, Telegram_Message_ID: String(r.message_id), Telegram_Chat_ID: String(r.chat.id) } });\n  }\n}\nreturn out;"
      },
      "id": "Notified Updates",
      "name": "Notified Updates",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        700,
        200
      ]
    },
    {
      "parameters": {
        "method": "PATCH",
        "url": "=https://api.airtable.com/v0/{{ $env.AIRTABLE_BASE_ID }}/Onboarding_Tasks/{{ $json.id }}",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "airtableTokenApi",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ fields: { Notified_At: $json.Notified_At, Telegram_Message_ID: $json.Telegram_Message_ID, Telegram_Chat_ID: $json.Telegram_Chat_ID } }) }}",
        "options": {}
      },
      "id": "Mark Notified",
      "name": "Mark Notified",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        920,
        200
      ],
      "notes": "Airtable update via HTTP PATCH; attach the Airtable PAT credential (Predefined Credential Type)."
    },
    {
      "parameters": {
        "language": "javaScript",
        "jsCode": "const okChats = new Set();\nfor (const s of $('Send Telegram').all()) { const r = s.json && s.json.result; if (r && r.chat) okChats.add(String(r.chat.id)); }\nconst failed = $('Build Messages').all().filter((g) => !okChats.has(String(g.json.assignee_telegram_id)));\nif (failed.length === 0) return [{ json: { hasFailures: false, message: '' } }];\nconst lines = failed.map((g) => '- assignee ' + g.json.assignee_telegram_id + ': ' + g.json.task_ids.length + ' task(s) not delivered');\nconst message = 'F3: ' + failed.length + ' onboarding notification(s) could not be delivered (assignee unreachable in Telegram). These tasks were NOT marked notified and will retry:\\n' + lines.join('\\n');\nreturn [{ json: { hasFailures: true, message } }];"
      },
      "id": "Failed Sends",
      "name": "Failed Sends",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        700,
        420
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "leftValue": "={{ $json.hasFailures }}",
              "rightValue": true,
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ]
        }
      },
      "id": "IF Has Failures",
      "name": "IF Has Failures",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        920,
        420
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "=https://api.telegram.org/bot{{ $env.TELEGRAM_BOT_TOKEN }}/sendMessage",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ chat_id: $env.TELEGRAM_HR_CHAT_ID, text: $json.message }) }}",
        "options": {}
      },
      "id": "Notify HR (failed sends)",
      "name": "Notify HR (failed sends)",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1140,
        420
      ]
    }
  ],
  "connections": {
    "Schedule 09:00": {
      "main": [
        [
          {
            "node": "Fetch Employees",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch Employees": {
      "main": [
        [
          {
            "node": "Fetch Pending Tasks",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch Pending Tasks": {
      "main": [
        [
          {
            "node": "Build Messages",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Messages": {
      "main": [
        [
          {
            "node": "Send Telegram",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Send Telegram": {
      "main": [
        [
          {
            "node": "Notified Updates",
            "type": "main",
            "index": 0
          },
          {
            "node": "Failed Sends",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Notified Updates": {
      "main": [
        [
          {
            "node": "Mark Notified",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Failed Sends": {
      "main": [
        [
          {
            "node": "IF Has Failures",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "IF Has Failures": {
      "main": [
        [
          {
            "node": "Notify HR (failed sends)",
            "type": "main",
            "index": 0
          }
        ],
        []
      ]
    }
  },
  "active": false,
  "settings": {
    "executionOrder": "v1",
    "timezone": "Europe/Istanbul"
  },
  "meta": {
    "note": "Day boundary handled in code (officeToday). Sends matched back by chat id, not position. Undelivered notifications are reported to HR and retried (not marked notified). Set Settings -> Error Workflow too. TELEGRAM_BOT_TOKEN / TELEGRAM_HR_CHAT_ID + Airtable credential required."
  }
}