{
  "name": "Deduplicate a Notion database by keeping the newest row and archiving the rest",
  "tags": [],
  "nodes": [
    {
      "name": "Every Day at 2am",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [
        240,
        400
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "triggerAtHour": 2
            }
          ]
        }
      },
      "typeVersion": 1.3
    },
    {
      "name": "Get Database Rows",
      "type": "n8n-nodes-base.notion",
      "position": [
        448,
        400
      ],
      "parameters": {
        "simple": false,
        "options": {},
        "resource": "databasePage",
        "operation": "getAll",
        "returnAll": true,
        "databaseId": {
          "__rl": true,
          "mode": "list",
          "value": ""
        }
      },
      "credentials": {
        "notionApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2.2
    },
    {
      "name": "Find Duplicates and Build Recap",
      "type": "n8n-nodes-base.code",
      "position": [
        720,
        400
      ],
      "parameters": {
        "jsCode": "// ==== CONFIG: edit these two lines to match your database ====\nconst MATCH_PROPERTY = 'Name';   // property used to detect duplicates\nconst KEEP_RULE = 'newest';      // 'newest' | 'oldest' | 'mostFilled'\n// ============================================================\n\nconst pages = items.map((i) => i.json);\n\nfunction readValue(page, propName) {\n  const props = (page && page.properties) || {};\n  const p = props[propName];\n  if (!p) return '';\n  const t = p.type;\n  let v = '';\n  if (t === 'title') v = (p.title || []).map((x) => x.plain_text).join('');\n  else if (t === 'rich_text') v = (p.rich_text || []).map((x) => x.plain_text).join('');\n  else if (t === 'url') v = p.url || '';\n  else if (t === 'email') v = p.email || '';\n  else if (t === 'phone_number') v = p.phone_number || '';\n  else if (t === 'number') v = p.number == null ? '' : String(p.number);\n  else if (t === 'select') v = (p.select && p.select.name) || '';\n  else if (t === 'status') v = (p.status && p.status.name) || '';\n  else if (t === 'formula') v = p.formula ? (p.formula.string ?? p.formula.number ?? '') : '';\n  else v = '';\n  return String(v).trim().toLowerCase();\n}\n\nfunction filledCount(page) {\n  const props = (page && page.properties) || {};\n  let n = 0;\n  for (const k of Object.keys(props)) {\n    if (readValue(page, k) !== '') n += 1;\n  }\n  return n;\n}\n\nfunction editedTime(page) {\n  return page.last_edited_time || page.created_time || '';\n}\n\nconst groups = {};\nlet keyless = 0;\nfor (const page of pages) {\n  const key = readValue(page, MATCH_PROPERTY);\n  if (!key) { keyless += 1; continue; }\n  (groups[key] = groups[key] || []).push(page);\n}\n\nconst losers = [];\nlet dupGroups = 0;\nfor (const key of Object.keys(groups)) {\n  const group = groups[key];\n  if (group.length < 2) continue;\n  dupGroups += 1;\n  const sorted = group.slice();\n  if (KEEP_RULE === 'mostFilled') {\n    sorted.sort((a, b) => filledCount(b) - filledCount(a) || editedTime(b).localeCompare(editedTime(a)));\n  } else if (KEEP_RULE === 'oldest') {\n    sorted.sort((a, b) => editedTime(a).localeCompare(editedTime(b)));\n  } else {\n    sorted.sort((a, b) => editedTime(b).localeCompare(editedTime(a)));\n  }\n  const keeper = sorted[0];\n  for (let i = 1; i < sorted.length; i++) {\n    const loser = sorted[i];\n    losers.push({ json: {\n      _type: 'archive',\n      pageId: loser.id,\n      keyValue: key,\n      keptPageId: keeper.id,\n      keptUrl: keeper.url || '',\n      reason: 'duplicate of kept page ' + keeper.id\n    } });\n  }\n}\n\nconst recap = { json: {\n  _type: 'recap',\n  scanned: pages.length,\n  keyless: keyless,\n  duplicateGroups: dupGroups,\n  toArchive: losers.length,\n  property: MATCH_PROPERTY,\n  keepRule: KEEP_RULE\n} };\n\nreturn [...losers, recap];"
      },
      "typeVersion": 2
    },
    {
      "name": "Route by Item Type",
      "type": "n8n-nodes-base.switch",
      "position": [
        944,
        400
      ],
      "parameters": {
        "rules": {
          "values": [
            {
              "outputKey": "archive",
              "conditions": {
                "options": {
                  "version": 1,
                  "leftValue": "",
                  "caseSensitive": true,
                  "typeValidation": "strict"
                },
                "combinator": "and",
                "conditions": [
                  {
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    },
                    "leftValue": "={{ $json._type }}",
                    "rightValue": "archive"
                  }
                ]
              },
              "renameOutput": true
            },
            {
              "outputKey": "recap",
              "conditions": {
                "options": {
                  "version": 1,
                  "leftValue": "",
                  "caseSensitive": true,
                  "typeValidation": "strict"
                },
                "combinator": "and",
                "conditions": [
                  {
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    },
                    "leftValue": "={{ $json._type }}",
                    "rightValue": "recap"
                  }
                ]
              },
              "renameOutput": true
            }
          ]
        },
        "options": {
          "fallbackOutput": "none"
        }
      },
      "typeVersion": 3.4
    },
    {
      "name": "Archive Duplicate Page",
      "type": "n8n-nodes-base.httpRequest",
      "onError": "continueRegularOutput",
      "position": [
        1280,
        336
      ],
      "parameters": {
        "url": "=https://api.notion.com/v1/pages/{{ $json.pageId }}",
        "method": "PATCH",
        "options": {},
        "jsonBody": "{\"archived\": true}",
        "sendBody": true,
        "sendHeaders": true,
        "specifyBody": "json",
        "authentication": "predefinedCredentialType",
        "headerParameters": {
          "parameters": [
            {
              "name": "Notion-Version",
              "value": "2022-06-28"
            }
          ]
        },
        "nodeCredentialType": "notionApi"
      },
      "credentials": {
        "notionApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.2
    },
    {
      "name": "Append Run to Audit Log",
      "type": "n8n-nodes-base.notion",
      "position": [
        1280,
        480
      ],
      "parameters": {
        "blockId": {
          "__rl": true,
          "mode": "url",
          "value": ""
        },
        "blockUi": {
          "blockValues": [
            {
              "textContent": "=Dedup run {{ $now.toFormat(\"yyyy-LL-dd HH:mm\") }}: scanned {{ $json.scanned }} rows, {{ $json.keyless }} without a key, {{ $json.duplicateGroups }} duplicate groups, archived {{ $json.toArchive }} duplicates. Match property: {{ $json.property }}. Keep rule: {{ $json.keepRule }}."
            }
          ]
        },
        "resource": "block",
        "operation": "append"
      },
      "credentials": {
        "notionApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2.2
    },
    {
      "name": "Overview Note",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -448,
        64
      ],
      "parameters": {
        "width": 588,
        "height": 748,
        "content": "## Deduplicate a Notion database by keeping the newest row and archiving the rest\n\n### How it works\n\n1. On a daily schedule, it reads every row from the Notion database you choose.\n2. A Code node groups the rows by a property you set and finds any group with more than one row.\n3. For each group it keeps one row (the newest by default) and marks the others as duplicates.\n4. Each duplicate is archived to the Notion trash, where it stays recoverable for 30 days.\n5. A one-line recap is appended to a log page on every run, including runs that find no duplicates.\n\n### Setup steps\n\n- [ ] Connect a Notion credential and share the target database and the log page with the integration.\n- [ ] In Get Database Rows, select the database you want to deduplicate.\n- [ ] In Find Duplicates and Build Recap, set MATCH_PROPERTY and KEEP_RULE at the top of the code.\n- [ ] In Append Run to Audit Log, paste the URL of the page that should receive the recap.\n\n### Customization\n\nSet KEEP_RULE to newest, oldest, or mostFilled to decide which row survives, and adjust Every Day at 2am to run on any schedule you prefer."
      },
      "typeVersion": 1
    },
    {
      "name": "Fetch Section Note",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        160,
        224
      ],
      "parameters": {
        "color": 7,
        "width": 460,
        "height": 356,
        "content": "## Fetch database rows\n\nRuns on a daily schedule and pulls every row with full property data."
      },
      "typeVersion": 1
    },
    {
      "name": "Find Section Note",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        640,
        224
      ],
      "parameters": {
        "color": 7,
        "width": 484,
        "height": 356,
        "content": "## Find and route duplicates\n\nGroups rows by your chosen property, picks a keeper per group, and routes the duplicates onward."
      },
      "typeVersion": 1
    },
    {
      "name": "Archive Section Note",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1152,
        160
      ],
      "parameters": {
        "color": 7,
        "width": 372,
        "height": 504,
        "content": "## Archive duplicates and log the run\n\nArchives each duplicate to the Notion trash and appends a recap line to your log page."
      },
      "typeVersion": 1
    }
  ],
  "active": false,
  "settings": {
    "executionOrder": "v1"
  },
  "connections": {
    "Every Day at 2am": {
      "main": [
        [
          {
            "node": "Get Database Rows",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get Database Rows": {
      "main": [
        [
          {
            "node": "Find Duplicates and Build Recap",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Route by Item Type": {
      "main": [
        [
          {
            "node": "Archive Duplicate Page",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Append Run to Audit Log",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Find Duplicates and Build Recap": {
      "main": [
        [
          {
            "node": "Route by Item Type",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}