{
  "name": "Data-quality monitor",
  "nodes": [
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "hours",
              "hoursInterval": 1
            }
          ]
        }
      },
      "id": "babd4901-aee1-4a58-ae5a-2e2cd69999a7",
      "name": "Hourly",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        240,
        534
      ]
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "SELECT 'freshness' AS check, extract(epoch FROM (now() - max(loaded_at))) / 60 AS value FROM analytics.orders;",
        "options": {}
      },
      "id": "bdf3e71a-c6ed-4d6a-abf0-c551f6f0d60a",
      "name": "Freshness",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.5,
      "position": [
        512,
        300
      ],
      "credentials": {
        "postgres": {
          "id": "REPLACE_ME",
          "name": "Postgres (self-hosted)"
        }
      }
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "SELECT 'null_rate' AS check, avg((customer_id IS NULL)::int) * 100 AS value FROM analytics.orders WHERE loaded_at > now() - interval '1 day';",
        "options": {}
      },
      "id": "088bb4ea-7a7c-423f-aae3-8fc8c8a6e420",
      "name": "Null rate",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.5,
      "position": [
        512,
        534
      ],
      "credentials": {
        "postgres": {
          "id": "REPLACE_ME",
          "name": "Postgres (self-hosted)"
        }
      }
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "SELECT 'duplicates' AS check, count(*) AS value FROM (SELECT order_id FROM analytics.orders GROUP BY order_id HAVING count(*) > 1) d;",
        "options": {}
      },
      "id": "9e4e50ef-e600-45c9-a368-5e91c5472c04",
      "name": "Duplicates",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.5,
      "position": [
        512,
        767
      ],
      "credentials": {
        "postgres": {
          "id": "REPLACE_ME",
          "name": "Postgres (self-hosted)"
        }
      }
    },
    {
      "parameters": {
        "mode": "append",
        "numberInputs": 3
      },
      "id": "a5208c45-e1ae-48b7-ac3f-69ed903d3743",
      "name": "Collect checks",
      "type": "n8n-nodes-base.merge",
      "typeVersion": 3,
      "position": [
        784,
        534
      ]
    },
    {
      "parameters": {
        "jsCode": "const LIMITS = { freshness: 90, null_rate: 1.0, duplicates: 0 };\nconst results = $input.all().map(i => ({ check: i.json.check, value: Number(i.json.value), limit: LIMITS[i.json.check], failed: Number(i.json.value) > LIMITS[i.json.check] }));\nreturn [{ json: { results, failed: results.filter(r => r.failed), any_failed: results.some(r => r.failed) } }];"
      },
      "id": "d737fa86-da8e-4f63-a3fb-ff251cd8d823",
      "name": "Compare to thresholds",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1056,
        534
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict"
          },
          "conditions": [
            {
              "id": "f1",
              "leftValue": "={{ $json.any_failed }}",
              "rightValue": true,
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        }
      },
      "id": "665613f5-c14c-453a-a6ff-fb62e4f0b4e9",
      "name": "Any failures?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1328,
        534
      ]
    },
    {
      "parameters": {
        "promptType": "define",
        "text": "=These data-quality checks failed: {{ JSON.stringify($json.failed) }}. In three sentences, say what is likely wrong and which dashboards or reports could be affected. Do not invent table names."
      },
      "id": "3f5fcd34-fee4-4c75-ad9e-f65bb2645508",
      "name": "Explain the break",
      "type": "@n8n/n8n-nodes-langchain.chainLlm",
      "typeVersion": 1.5,
      "position": [
        1600,
        388
      ]
    },
    {
      "parameters": {
        "model": {
          "__rl": true,
          "value": "claude-sonnet-4-5",
          "mode": "list"
        },
        "options": {
          "temperature": 0.1
        }
      },
      "id": "14b18cc2-a256-4311-aca0-f55284b3e213",
      "name": "Claude",
      "type": "@n8n/n8n-nodes-langchain.lmChatAnthropic",
      "typeVersion": 1.3,
      "position": [
        1600,
        621
      ],
      "credentials": {
        "anthropicApi": {
          "id": "REPLACE_ME",
          "name": "Anthropic"
        }
      }
    },
    {
      "parameters": {
        "select": "channel",
        "channelId": {
          "__rl": true,
          "value": "data-quality",
          "mode": "name"
        },
        "text": "=Data-quality check failed: {{ $json.text }}",
        "otherOptions": {}
      },
      "id": "32877503-b358-4464-a2e5-99b89da24ddf",
      "name": "Alert data channel",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.3,
      "position": [
        1872,
        300
      ],
      "credentials": {
        "slackApi": {
          "id": "REPLACE_ME",
          "name": "Slack"
        }
      }
    },
    {
      "parameters": {
        "resource": "issue",
        "operation": "create",
        "project": {
          "__rl": true,
          "value": "DATA",
          "mode": "id"
        },
        "issueType": {
          "__rl": true,
          "value": "Bug",
          "mode": "id"
        },
        "summary": "Data-quality check failed",
        "additionalFields": {
          "description": "={{ $json.text }}"
        }
      },
      "id": "24f1270d-51bb-40d5-ae3e-0ab3f6d525e6",
      "name": "Open issue",
      "type": "n8n-nodes-base.jira",
      "typeVersion": 1,
      "position": [
        1872,
        475
      ],
      "credentials": {
        "jiraSoftwareCloudApi": {
          "id": "REPLACE_ME",
          "name": "Jira"
        }
      }
    },
    {
      "parameters": {
        "operation": "insert",
        "schema": {
          "__rl": true,
          "value": "public",
          "mode": "list"
        },
        "table": {
          "__rl": true,
          "value": "dq_runs",
          "mode": "list"
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "ran_at": "={{ $now.toISO() }}",
            "any_failed": "={{ $json.any_failed }}",
            "detail": "={{ JSON.stringify($json.results) }}"
          }
        }
      },
      "id": "568d2a78-24f1-49c7-a518-802e9f41c4da",
      "name": "Log the run",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.5,
      "position": [
        1600,
        826
      ],
      "credentials": {
        "postgres": {
          "id": "REPLACE_ME",
          "name": "Postgres (self-hosted)"
        }
      }
    }
  ],
  "connections": {
    "Hourly": {
      "main": [
        [
          {
            "node": "Freshness",
            "type": "main",
            "index": 0
          },
          {
            "node": "Null rate",
            "type": "main",
            "index": 0
          },
          {
            "node": "Duplicates",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Freshness": {
      "main": [
        [
          {
            "node": "Collect checks",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Null rate": {
      "main": [
        [
          {
            "node": "Collect checks",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "Duplicates": {
      "main": [
        [
          {
            "node": "Collect checks",
            "type": "main",
            "index": 2
          }
        ]
      ]
    },
    "Collect checks": {
      "main": [
        [
          {
            "node": "Compare to thresholds",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Compare to thresholds": {
      "main": [
        [
          {
            "node": "Any failures?",
            "type": "main",
            "index": 0
          },
          {
            "node": "Log the run",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Any failures?": {
      "main": [
        [
          {
            "node": "Explain the break",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Claude": {
      "ai_languageModel": [
        [
          {
            "node": "Explain the break",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    },
    "Explain the break": {
      "main": [
        [
          {
            "node": "Alert data channel",
            "type": "main",
            "index": 0
          },
          {
            "node": "Open issue",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "active": false,
  "settings": {
    "executionOrder": "v1"
  },
  "pinData": {},
  "meta": {
    "templateCredsSetupCompleted": false,
    "description": "Checks the data before the dashboards do."
  },
  "tags": []
}