{
  "name": "03 - Complete (F4)",
  "nodes": [
    {
      "parameters": {
        "updates": [
          "callback_query"
        ],
        "additionalFields": {}
      },
      "id": "On Callback",
      "name": "On Callback",
      "type": "n8n-nodes-base.telegramTrigger",
      "typeVersion": 1.2,
      "position": [
        -560,
        300
      ],
      "notes": "Attach the Telegram credential for the SAME bot as TELEGRAM_BOT_TOKEN."
    },
    {
      "parameters": {
        "language": "javaScript",
        "jsCode": "// ============================================================================\n// GENERATED by `npm run build:nodes` \u2014 DO NOT EDIT.\n// Wrapper: workflows/code-nodes/completeParse.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 \"Parse Callback\" (F4)  (THIN WRAPPER \u2014 do not put logic here)\n// Core (notify.js) inlined by `npm run build:nodes`. Paste completeParse.built.js.\n//\n// Input: one item = the Telegram update from the trigger (callback_query).\n// Output: { ignore, taskId, action, from_id, from_username, callback_query_id }.\n// Strict: a callback that is not done:<recId> / skip:<recId> sets ignore=true (the\n// workflow just answers it and stops) \u2014 never throws.\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\nconst u = items[0].json;\nconst cq = u.callback_query || {};\nconst parsed = parseCallback(cq.data);\nreturn [{ json: {\n  ignore: parsed === null,\n  taskId: parsed ? parsed.taskId : null,\n  action: parsed ? parsed.action : null,\n  from_id: cq.from ? cq.from.id : null,\n  from_username: cq.from ? cq.from.username : null,\n  callback_query_id: cq.id,\n} }];\n"
      },
      "id": "Parse Callback",
      "name": "Parse Callback",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -340,
        300
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "leftValue": "={{ $json.ignore === false }}",
              "rightValue": true,
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ]
        }
      },
      "id": "IF Recognized",
      "name": "IF Recognized",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        -120,
        300
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "=https://api.telegram.org/bot{{ $env.TELEGRAM_BOT_TOKEN }}/answerCallbackQuery",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ callback_query_id: $json.callback_query_id, text: 'Unknown or expired action' }) }}",
        "options": {}
      },
      "id": "Answer Unknown",
      "name": "Answer Unknown",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        100,
        480
      ]
    },
    {
      "parameters": {
        "base": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $env.AIRTABLE_BASE_ID }}"
        },
        "resource": "record",
        "operation": "get",
        "table": {
          "__rl": true,
          "mode": "name",
          "value": "Onboarding_Tasks"
        },
        "id": "={{ $('Parse Callback').first().json.taskId }}",
        "options": {}
      },
      "id": "Fetch Task",
      "name": "Fetch Task",
      "type": "n8n-nodes-base.airtable",
      "typeVersion": 2.1,
      "position": [
        100,
        300
      ],
      "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/completeResolve.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 \"Resolve\" (F4)  (THIN WRAPPER \u2014 do not put logic here)\n// Core (notify.js) inlined by `npm run build:nodes`. Paste completeResolve.built.js.\n//\n// Input: the fetched Onboarding_Tasks record. Ref: \"Parse Callback\".\n// Output: outcome (unauthorized | already-final | apply) + everything the downstream\n// branches need (status, ids, message id/chat for the edit, answer text).\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\nconst flat = (v) => (Array.isArray(v) ? v[0] : v);\nconst p = $('Parse Callback').first().json;\nconst f = items[0].json;\n\nconst task = {\n  id: f.id,\n  assignee_telegram_id: f.Assignee_Telegram_ID,\n  status: f.Status,\n  employee_id: flat(f.Employee),\n};\nconst r = resolveCallback({ task, fromTelegramId: p.from_id, action: p.action });\n\nconst answerText =\n  r.outcome === 'unauthorized' ? 'This task is not assigned to you'\n  : r.outcome === 'already-final' ? 'Already marked'\n  : (r.status === 'Done' ? 'Marked done' : 'Marked not applicable');\n\nreturn [{ json: {\n  outcome: r.outcome,\n  status: r.status || null,\n  task_id: f.id,\n  employee_id: task.employee_id,\n  telegram_message_id: f.Telegram_Message_ID,\n  telegram_chat_id: f.Telegram_Chat_ID,\n  callback_query_id: p.callback_query_id,\n  answerText,\n  // Column-named fields for the autoMap update on \"Update Task\" (id = record to update).\n  id: f.id,\n  Status: r.status || null,\n  Completed_At: new Date().toISOString(),\n  Completed_By: p.from_username || String(p.from_id),\n} }];\n"
      },
      "id": "Resolve",
      "name": "Resolve",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        320,
        300
      ]
    },
    {
      "parameters": {
        "rules": {
          "values": [
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "typeValidation": "strict"
                },
                "combinator": "and",
                "conditions": [
                  {
                    "leftValue": "={{ $json.outcome }}",
                    "rightValue": "unauthorized",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ]
              },
              "outputKey": "unauthorized"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "typeValidation": "strict"
                },
                "combinator": "and",
                "conditions": [
                  {
                    "leftValue": "={{ $json.outcome }}",
                    "rightValue": "already-final",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ]
              },
              "outputKey": "already-final"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "typeValidation": "strict"
                },
                "combinator": "and",
                "conditions": [
                  {
                    "leftValue": "={{ $json.outcome }}",
                    "rightValue": "apply",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ]
              },
              "outputKey": "apply"
            }
          ]
        },
        "options": {}
      },
      "id": "Switch Outcome",
      "name": "Switch Outcome",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3,
      "position": [
        540,
        300
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "=https://api.telegram.org/bot{{ $env.TELEGRAM_BOT_TOKEN }}/answerCallbackQuery",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ callback_query_id: $json.callback_query_id, text: $json.answerText, show_alert: true }) }}",
        "options": {}
      },
      "id": "Answer Unauthorized",
      "name": "Answer Unauthorized",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        760,
        120
      ]
    },
    {
      "parameters": {
        "method": "PATCH",
        "url": "=https://api.airtable.com/v0/{{ $env.AIRTABLE_BASE_ID }}/Onboarding_Tasks/{{ $json.task_id }}",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "airtableTokenApi",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ fields: { Status: $json.status, Completed_At: $json.Completed_At, Completed_By: $json.Completed_By } }) }}",
        "options": {}
      },
      "id": "Update Task",
      "name": "Update Task",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        760,
        300
      ],
      "notes": "Airtable update via HTTP PATCH; attach the Airtable PAT credential (Predefined Credential Type)."
    },
    {
      "parameters": {
        "base": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $env.AIRTABLE_BASE_ID }}"
        },
        "resource": "record",
        "operation": "search",
        "table": {
          "__rl": true,
          "mode": "name",
          "value": "Onboarding_Tasks"
        },
        "filterByFormula": "={{ \"FIND('\" + $('Resolve').first().json.employee_id + \"::', {Task_Key}) > 0\" }}",
        "options": {}
      },
      "id": "Fetch Employee Tasks",
      "name": "Fetch Employee Tasks",
      "type": "n8n-nodes-base.airtable",
      "typeVersion": 2.1,
      "position": {
        "0": 980,
        "1": 300
      },
      "notes": "Attach Airtable credential after import.",
      "executeOnce": true
    },
    {
      "parameters": {
        "language": "javaScript",
        "jsCode": "// ============================================================================\n// GENERATED by `npm run build:nodes` \u2014 DO NOT EDIT.\n// Wrapper: workflows/code-nodes/completeCheck.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 \"Complete Check\" (F4)  (THIN WRAPPER \u2014 do not put logic here)\n// Core (notify.js) inlined by `npm run build:nodes`. Paste completeCheck.built.js.\n//\n// Input: all of the employee's tasks (fetched AFTER the update committed). Ref: \"Resolve\".\n// Output: { complete, employee_id } \u2014 complete=true when nothing is Pending anymore.\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\nconst tasks = items.map((i) => ({ status: i.json.Status }));\nreturn [{ json: {\n  complete: isEmployeeComplete(tasks),\n  // Column-named fields for the autoMap update on \"Mark Employee Complete\".\n  id: $('Resolve').first().json.employee_id,\n  Status: 'Complete',\n} }];\n"
      },
      "id": "Complete Check",
      "name": "Complete Check",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1200,
        300
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "leftValue": "={{ $json.complete === true }}",
              "rightValue": true,
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ]
        }
      },
      "id": "IF Employee Complete",
      "name": "IF Employee Complete",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        1420,
        300
      ]
    },
    {
      "parameters": {
        "method": "PATCH",
        "url": "=https://api.airtable.com/v0/{{ $env.AIRTABLE_BASE_ID }}/Employees/{{ $json.id }}",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "airtableTokenApi",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ fields: { Status: 'Complete' } }) }}",
        "options": {}
      },
      "id": "Mark Employee Complete",
      "name": "Mark Employee Complete",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1640,
        200
      ],
      "notes": "Airtable update via HTTP PATCH; attach the Airtable PAT credential (Predefined Credential Type)."
    },
    {
      "parameters": {
        "base": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $env.AIRTABLE_BASE_ID }}"
        },
        "resource": "record",
        "operation": "search",
        "table": {
          "__rl": true,
          "mode": "name",
          "value": "Employees"
        },
        "filterByFormula": "",
        "options": {}
      },
      "id": "Fetch Employees",
      "name": "Fetch Employees",
      "type": "n8n-nodes-base.airtable",
      "typeVersion": 2.1,
      "position": {
        "0": 1860,
        "1": 300
      },
      "notes": "Attach Airtable credential after import.",
      "executeOnce": true
    },
    {
      "parameters": {
        "base": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $env.AIRTABLE_BASE_ID }}"
        },
        "resource": "record",
        "operation": "search",
        "table": {
          "__rl": true,
          "mode": "name",
          "value": "Onboarding_Tasks"
        },
        "filterByFormula": "={{ \"{Telegram_Message_ID} = '\" + $('Resolve').first().json.telegram_message_id + \"'\" }}",
        "options": {}
      },
      "id": "Fetch Siblings",
      "name": "Fetch Siblings",
      "type": "n8n-nodes-base.airtable",
      "typeVersion": 2.1,
      "position": {
        "0": 2080,
        "1": 300
      },
      "notes": "Attach Airtable credential after import.",
      "executeOnce": true
    },
    {
      "parameters": {
        "language": "javaScript",
        "jsCode": "// ============================================================================\n// GENERATED by `npm run build:nodes` \u2014 DO NOT EDIT.\n// Wrapper: workflows/code-nodes/completeRender.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 Edit\" (F4)  (THIN WRAPPER \u2014 do not put logic here)\n// Core (notify.js) inlined by `npm run build:nodes`. Paste completeRender.built.js.\n//\n// Input: the sibling tasks of this message (all tasks sharing Telegram_Message_ID),\n// fetched AFTER the update. Ref: \"Resolve\", \"Fetch Employees\".\n// Output: { editBody, callback_query_id, answerText } \u2014 editBody is the JSON body for\n// Telegram editMessageText, re-rendering the whole message in its current state.\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\nconst flat = (v) => (Array.isArray(v) ? v[0] : v);\nconst r = $('Resolve').first().json;\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 e = empById[employee_id] || {};\n  return {\n    id: f.id, title: f.Title, due_date: f.Due_Date, blocking: f.Blocking === true,\n    status: f.Status, employee_id, employee_name: e.name, start_date: e.start_date,\n  };\n});\n\nconst { text, inline_keyboard } = renderMessage(tasks);\nconst body = { chat_id: r.telegram_chat_id, message_id: Number(r.telegram_message_id), text };\nif (inline_keyboard.length) body.reply_markup = { inline_keyboard };\n\nreturn [{ json: { editBody: JSON.stringify(body), callback_query_id: r.callback_query_id, answerText: r.answerText } }];\n"
      },
      "id": "Build Edit",
      "name": "Build Edit",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2300,
        300
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "=https://api.telegram.org/bot{{ $env.TELEGRAM_BOT_TOKEN }}/editMessageText",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ $json.editBody }}",
        "options": {}
      },
      "id": "Edit Message",
      "name": "Edit Message",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        2520,
        300
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "=https://api.telegram.org/bot{{ $env.TELEGRAM_BOT_TOKEN }}/answerCallbackQuery",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ callback_query_id: $json.callback_query_id, text: $json.answerText }) }}",
        "options": {}
      },
      "id": "Answer Callback",
      "name": "Answer Callback",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        2740,
        300
      ]
    }
  ],
  "connections": {
    "On Callback": {
      "main": [
        [
          {
            "node": "Parse Callback",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse Callback": {
      "main": [
        [
          {
            "node": "IF Recognized",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "IF Recognized": {
      "main": [
        [
          {
            "node": "Fetch Task",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Answer Unknown",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch Task": {
      "main": [
        [
          {
            "node": "Resolve",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Resolve": {
      "main": [
        [
          {
            "node": "Switch Outcome",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Switch Outcome": {
      "main": [
        [
          {
            "node": "Answer Unauthorized",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Fetch Employees",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Update Task",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Update Task": {
      "main": [
        [
          {
            "node": "Fetch Employee Tasks",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch Employee Tasks": {
      "main": [
        [
          {
            "node": "Complete Check",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Complete Check": {
      "main": [
        [
          {
            "node": "IF Employee Complete",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "IF Employee Complete": {
      "main": [
        [
          {
            "node": "Mark Employee Complete",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Fetch Employees",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Mark Employee Complete": {
      "main": [
        [
          {
            "node": "Fetch Employees",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch Employees": {
      "main": [
        [
          {
            "node": "Fetch Siblings",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch Siblings": {
      "main": [
        [
          {
            "node": "Build Edit",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Edit": {
      "main": [
        [
          {
            "node": "Edit Message",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Edit Message": {
      "main": [
        [
          {
            "node": "Answer Callback",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "active": false,
  "settings": {
    "executionOrder": "v1"
  },
  "meta": {
    "note": "Telegram Trigger must be the SAME bot as TELEGRAM_BOT_TOKEN. Re-render (Fetch Siblings) is downstream of Update Task by connection order, so it sees the committed state. Callback race between Fetch Task and Update Task is a documented, harmless limitation (see SPEC F4). Set Settings -> Error Workflow to notify HR."
  }
}