{
  "name": "Sync Asana project tasks to a Google Sheet mirror",
  "nodes": [
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "hours"
            }
          ]
        }
      },
      "id": "57957001-7a71-415a-b6b9-b8eb670696c3",
      "name": "Every Hour",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.3,
      "position": [
        -32,
        96
      ]
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "c1",
              "name": "asana_project_gid",
              "value": "REPLACE_WITH_ASANA_PROJECT_GID",
              "type": "string"
            },
            {
              "id": "c2",
              "name": "sheet_id",
              "value": "REPLACE_WITH_GOOGLE_SHEET_ID",
              "type": "string"
            },
            {
              "id": "c3",
              "name": "sheet_tab",
              "value": "Tasks",
              "type": "string"
            }
          ]
        },
        "options": {}
      },
      "id": "8ebfcd34-5dbe-4718-9e65-4f42c1a85796",
      "name": "Set Sync Config",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        192,
        96
      ]
    },
    {
      "parameters": {
        "url": "=https://app.asana.com/api/1.0/projects/{{ $json.asana_project_gid }}/tasks",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "asanaApi",
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "opt_fields",
              "value": "name,assignee.name,due_on,completed,memberships.section.name,permalink_url,modified_at"
            },
            {
              "name": "limit",
              "value": "100"
            }
          ]
        },
        "options": {
          "pagination": {
            "pagination": {
              "parameters": {
                "parameters": [
                  {
                    "name": "offset",
                    "value": "={{ $response.body.next_page?.offset }}"
                  }
                ]
              },
              "paginationCompleteWhen": "other",
              "completeExpression": "={{ !$response.body.next_page }}"
            }
          }
        }
      },
      "id": "e324eb19-1303-46e3-a921-e1f8aa52c48b",
      "name": "Fetch Asana Tasks",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.4,
      "position": [
        448,
        96
      ],
      "credentials": {
        "asanaApi": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "// Flatten Asana tasks into one flat sheet row each.\n// Handles the HTTP Request response ({ data: [...] } per page) and the Asana\n// node shape (one item per task). Each row is tagged _source='asana' so the\n// Diff node can tell it apart from existing sheet rows after the Merge.\nconst items = $input.all();\nconst tasks = [];\nfor (const item of items) {\n  const j = item.json || {};\n  if (Array.isArray(j.data)) {\n    for (const t of j.data) tasks.push(t);\n  } else {\n    tasks.push(j);\n  }\n}\nconst now = new Date().toISOString();\nreturn tasks\n  .filter((t) => t && t.gid)\n  .map((t) => {\n    let section = '';\n    if (Array.isArray(t.memberships)) {\n      const m = t.memberships.find((x) => x && x.section && x.section.name);\n      if (m) section = m.section.name;\n    }\n    return {\n      json: {\n        gid: String(t.gid),\n        name: t.name || '',\n        assignee: t.assignee && t.assignee.name ? t.assignee.name : '',\n        section,\n        due_on: t.due_on || '',\n        completed: t.completed === true ? 'Yes' : 'No',\n        permalink: t.permalink_url || '',\n        modified_at: t.modified_at || '',\n        synced_at: now,\n        present: 'Yes',\n        _source: 'asana',\n      },\n    };\n  });"
      },
      "id": "30414356-d218-473c-bcf4-62121658cff6",
      "name": "Flatten Tasks to Rows",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        704,
        96
      ]
    },
    {
      "parameters": {},
      "id": "7e084300-95c6-49d2-b0a5-c6adbb850cf7",
      "name": "Merge Tasks and Sheet Rows",
      "type": "n8n-nodes-base.merge",
      "typeVersion": 3.2,
      "position": [
        1008,
        112
      ]
    },
    {
      "parameters": {
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $(\"Set Sync Config\").first().json.sheet_id }}",
          "cachedResultName": "Your Spreadsheet"
        },
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "={{ $(\"Set Sync Config\").first().json.sheet_tab }}",
          "cachedResultName": "Tasks"
        },
        "options": {}
      },
      "id": "5b8d79da-e385-4f4f-b821-1f80946417d8",
      "name": "Read Existing Rows in Sheets",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.7,
      "position": [
        576,
        304
      ],
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "jsCode": "// Self-healing diff. Input = Asana rows (_source='asana') plus the existing\n// sheet rows, merged. Emit every Asana task as an upsert (present=Yes) and every\n// sheet row whose GID is gone from Asana as a soft-delete (present=No). Rows are\n// never dropped, only re-flagged. Counts ride on every item for the summary node.\nconst all = $input.all();\nconst now = new Date().toISOString();\nconst asanaRows = all.filter((i) => i.json && i.json._source === 'asana').map((i) => i.json);\nconst sheetRows = all.filter((i) => i.json && i.json._source !== 'asana').map((i) => i.json);\n\nconst asanaByGid = new Map(asanaRows.map((r) => [String(r.gid), r]));\nconst sheetByGid = new Map(sheetRows.filter((r) => r && r.gid).map((r) => [String(r.gid), r]));\n\nconst out = [];\nlet created = 0, updated = 0, removed = 0, alreadyRemoved = 0;\n\nfor (const [gid, row] of asanaByGid) {\n  const existed = sheetByGid.has(gid);\n  if (existed) updated++; else created++;\n  const { _source, ...clean } = row;\n  out.push({ json: { ...clean, present: 'Yes', _op: 'upsert', _status: existed ? 'updated' : 'created' } });\n}\n\nfor (const [gid, row] of sheetByGid) {\n  if (asanaByGid.has(gid)) continue;\n  const wasPresent = String(row.present).toLowerCase() !== 'no';\n  if (wasPresent) removed++; else alreadyRemoved++;\n  out.push({\n    json: {\n      gid: String(gid),\n      name: row.name || '',\n      assignee: row.assignee || '',\n      section: row.section || '',\n      due_on: row.due_on || '',\n      completed: row.completed || '',\n      permalink: row.permalink || '',\n      modified_at: row.modified_at || '',\n      synced_at: now,\n      present: 'No',\n      _op: 'softdelete',\n      _status: wasPresent ? 'removed' : 'already_removed',\n    },\n  });\n}\n\nconst counts = {\n  created, updated, removed, already_removed: alreadyRemoved,\n  soft_delete_writes: removed + alreadyRemoved,\n  asana_total: asanaByGid.size, sheet_total_before: sheetByGid.size,\n};\nfor (const o of out) o.json._counts = counts;\nreturn out;"
      },
      "id": "5d75edcf-d7ca-473a-846e-f20184a8e2b9",
      "name": "Diff Tasks by GID",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1232,
        112
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "leftValue": "={{ $json._op }}",
              "rightValue": "upsert",
              "operator": {
                "type": "string",
                "operation": "equals"
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "dbc7239e-b5b1-4c51-b313-8c0d2c1c2c98",
      "name": "Route Upsert and Soft-Delete",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.3,
      "position": [
        1456,
        112
      ]
    },
    {
      "parameters": {
        "operation": "appendOrUpdate",
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $(\"Set Sync Config\").first().json.sheet_id }}",
          "cachedResultName": "Your Spreadsheet"
        },
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "={{ $(\"Set Sync Config\").first().json.sheet_tab }}",
          "cachedResultName": "Tasks"
        },
        "columns": {
          "mappingMode": "defineBelow",
          "matchingColumns": [
            "gid"
          ],
          "value": {
            "gid": "={{ $json.gid }}",
            "name": "={{ $json.name }}",
            "assignee": "={{ $json.assignee }}",
            "section": "={{ $json.section }}",
            "due_on": "={{ $json.due_on }}",
            "completed": "={{ $json.completed }}",
            "permalink": "={{ $json.permalink }}",
            "modified_at": "={{ $json.modified_at }}",
            "synced_at": "={{ $json.synced_at }}",
            "present": "={{ $json.present }}"
          },
          "schema": [
            {
              "id": "gid",
              "displayName": "gid",
              "required": false,
              "defaultMatch": true,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "name",
              "displayName": "name",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "assignee",
              "displayName": "assignee",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "section",
              "displayName": "section",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "due_on",
              "displayName": "due_on",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "completed",
              "displayName": "completed",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "permalink",
              "displayName": "permalink",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "modified_at",
              "displayName": "modified_at",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "synced_at",
              "displayName": "synced_at",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "present",
              "displayName": "present",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            }
          ]
        },
        "options": {}
      },
      "id": "8ecc2578-c6f7-4470-b2f1-fcdfdc1829fd",
      "name": "Upsert Rows in Sheets",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.7,
      "position": [
        1744,
        0
      ],
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {},
      "id": "042924f9-5dac-4f3e-ae54-aa5aa3489175",
      "name": "Merge Write Results",
      "type": "n8n-nodes-base.merge",
      "typeVersion": 3.2,
      "position": [
        1952,
        112
      ]
    },
    {
      "parameters": {
        "operation": "update",
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $(\"Set Sync Config\").first().json.sheet_id }}",
          "cachedResultName": "Your Spreadsheet"
        },
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "={{ $(\"Set Sync Config\").first().json.sheet_tab }}",
          "cachedResultName": "Tasks"
        },
        "columns": {
          "mappingMode": "defineBelow",
          "matchingColumns": [
            "gid"
          ],
          "value": {
            "gid": "={{ $json.gid }}",
            "present": "={{ $json.present }}",
            "synced_at": "={{ $json.synced_at }}"
          },
          "schema": [
            {
              "id": "gid",
              "displayName": "gid",
              "required": false,
              "defaultMatch": true,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "present",
              "displayName": "present",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "synced_at",
              "displayName": "synced_at",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            }
          ]
        },
        "options": {}
      },
      "id": "db3fb65d-f05e-488c-b172-aa72be642c96",
      "name": "Flag Removed Rows in Sheets",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.7,
      "position": [
        1744,
        240
      ],
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "// One-line run summary from the counts the Diff node attached to each item.\nconst diff = $('Diff Tasks by GID').all();\nconst c = diff.length\n  ? diff[0].json._counts\n  : { created: 0, updated: 0, removed: 0, already_removed: 0, soft_delete_writes: 0, asana_total: 0, sheet_total_before: 0 };\nconst summary = 'Mirror synced: ' + c.created + ' created, ' + c.updated + ' updated, ' + c.removed + ' newly removed (present=No). ' + c.asana_total + ' live tasks now in the sheet.';\nreturn [{ json: Object.assign({ summary: summary }, c, { run_at: new Date().toISOString() }) }];"
      },
      "id": "122588dd-81f0-4628-b97d-8360245c2d64",
      "name": "Build Run Summary",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2208,
        112
      ],
      "executeOnce": true
    },
    {
      "parameters": {},
      "id": "2639bc6d-9849-4a2b-8a27-162985bd1546",
      "name": "Finish Sync Run",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        2448,
        112
      ]
    },
    {
      "parameters": {
        "content": "## Sync Asana project tasks to a Google Sheet mirror\n\n### How it works\n\n1. On a schedule, the workflow reads every task in one Asana project with its status, assignee, due date and section.\n2. It reads the current sheet, then diffs by task GID: tasks still in Asana are upserted, and rows whose task is gone are flagged `present` = `No`.\n3. The sheet becomes an always-current mirror you can pivot and filter, and no row is ever hard-deleted.\n\n### Setup steps\n\n- [ ] Add an Asana credential (Personal Access Token) and select it on `Fetch Asana Tasks`.\n- [ ] Add your Google Sheets credential and pick your spreadsheet and `Tasks` tab on the three Google Sheets nodes.\n- [ ] In `Set Sync Config`, set `asana_project_gid`, `sheet_id` and `sheet_tab`.\n- [ ] Add a header row to the tab: gid, name, assignee, section, due_on, completed, permalink, modified_at, synced_at, present.\n\n### Customization\n\nChange the schedule interval to sync more or less often. Optionally feed the run-summary counts to a Groq node for a one-line note on what changed; the mirror stays deterministic and the model never touches row data.",
        "height": 736,
        "width": 584
      },
      "id": "a6369dec-c912-47ce-9103-f21dbf1d2860",
      "name": "Sticky Note 751080a2",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -752,
        -224
      ]
    },
    {
      "parameters": {
        "content": "## Configure the sync\n\nSet the schedule, the Asana project GID and the target Sheet ID and tab in one place.",
        "height": 360,
        "width": 416,
        "color": 7
      },
      "id": "681e7ea1-f9f0-43e8-9988-aaf33e68f18b",
      "name": "Sticky Note 0cfb412a",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -80,
        -80
      ]
    },
    {
      "parameters": {
        "content": "## Read from Asana and Sheets\n\nPull tasks from Asana with `opt_fields` for the section, flatten them to rows, and read the current sheet.",
        "height": 560,
        "width": 484,
        "color": 7
      },
      "id": "62a6b5c8-16ee-4ae4-abd3-2ccab91b198a",
      "name": "Sticky Note 04fc7ee5",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        384,
        -80
      ]
    },
    {
      "parameters": {
        "content": "## Merge and diff by GID\n\nThe self-healing core: upsert tasks still in Asana, flag rows whose task is gone.",
        "height": 328,
        "width": 704,
        "color": 7
      },
      "id": "84ccdc61-0fb6-4096-9ae5-1496c90e4f0f",
      "name": "Sticky Note fdfce91d",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        928,
        -32
      ]
    },
    {
      "parameters": {
        "content": "## Write the mirror\n\nUpsert live tasks, set `present` = `No` on removed rows, and converge both branches.",
        "height": 624,
        "width": 432,
        "color": 7
      },
      "id": "5c3a6918-8f34-4e4a-abb5-c69f847a0c5a",
      "name": "Sticky Note c2b1b0e1",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1680,
        -192
      ]
    },
    {
      "parameters": {
        "content": "## Summarize the run\n\nLog a one-line count of created, updated and removed, kept for inspection.",
        "height": 360,
        "width": 496,
        "color": 7
      },
      "id": "62e3b998-2123-4aac-a3ab-2a850965326b",
      "name": "Sticky Note 81ad6fd5",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        2160,
        -64
      ]
    }
  ],
  "connections": {
    "Every Hour": {
      "main": [
        [
          {
            "node": "Set Sync Config",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Set Sync Config": {
      "main": [
        [
          {
            "node": "Fetch Asana Tasks",
            "type": "main",
            "index": 0
          },
          {
            "node": "Read Existing Rows in Sheets",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch Asana Tasks": {
      "main": [
        [
          {
            "node": "Flatten Tasks to Rows",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Flatten Tasks to Rows": {
      "main": [
        [
          {
            "node": "Merge Tasks and Sheet Rows",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Merge Tasks and Sheet Rows": {
      "main": [
        [
          {
            "node": "Diff Tasks by GID",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read Existing Rows in Sheets": {
      "main": [
        [
          {
            "node": "Merge Tasks and Sheet Rows",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "Diff Tasks by GID": {
      "main": [
        [
          {
            "node": "Route Upsert and Soft-Delete",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Route Upsert and Soft-Delete": {
      "main": [
        [
          {
            "node": "Upsert Rows in Sheets",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Flag Removed Rows in Sheets",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Upsert Rows in Sheets": {
      "main": [
        [
          {
            "node": "Merge Write Results",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Merge Write Results": {
      "main": [
        [
          {
            "node": "Build Run Summary",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Flag Removed Rows in Sheets": {
      "main": [
        [
          {
            "node": "Merge Write Results",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "Build Run Summary": {
      "main": [
        [
          {
            "node": "Finish Sync Run",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "active": false,
  "settings": {
    "executionOrder": "v1"
  },
  "tags": []
}