This workflow follows the Airtable → HTTP Request recipe pattern — see all workflows that pair these two integrations.
The workflow JSON
Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →
{
"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."
}
}
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
03 - Complete (F4). Uses telegramTrigger, httpRequest, airtable. Event-driven trigger; 18 nodes.
Source: https://github.com/ibragim-0202/employee-onboarding-automation/blob/main/workflows/03-complete.json — original creator credit. Request a take-down →
Related workflows
Workflows that share integrations, category, or trigger type with this one. All free to copy and import.
This n8n workflow receives files sent in a Telegram chat, uploads them to Google Drive, extracts text using OCR (for images and PDFs), and stores the extracted content in Airtable for quick search and
N8N Complete Final. Uses telegramTrigger, dataTable, telegram, mqtt. Event-driven trigger; 58 nodes.
Pede Ai. Uses httpRequest, telegram, postgres, telegramTrigger. Event-driven trigger; 57 nodes.
TextMain. Uses telegramTrigger, stopAndError, telegram, httpRequest. Event-driven trigger; 56 nodes.
Pede Ai. Uses httpRequest, telegram, postgres, telegramTrigger. Event-driven trigger; 53 nodes.