{
  "name": "ChartPNG: Postgres query to a chart in an email",
  "nodes": [
    {
      "parameters": {
        "content": "**ChartPNG: Postgres query to a chart in an email**\n\n1. \"Example query (replace)\" is an example over a made-up `orders` table (SELECT only). Replace it with your own read-only query that returns the columns week, orders and revenue, or change \"Build chart\" to match yours.\n2. Pick your Postgres credential, preferably a database user that can only SELECT.\n3. In \"Send email\", pick your SMTP credential and set From and To.\n4. Run the workflow once, then publish it (Active on n8n 1.x).\n\nThe email links the chart as an image URL and repeats the numbers in a table, because some mail clients block remote images until the reader allows them. This URL is unsigned, so the image keeps the small watermark. A signed URL (see Docs, Signed URLs) removes it: https://chartpng.dev/docs#signed-urls\n\nDocs: https://chartpng.dev/guides/postgres-chart-to-email",
        "height": 420,
        "width": 460
      },
      "id": "note-setup",
      "name": "Setup notes",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -40,
        -180
      ]
    },
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "weeks",
              "weeksInterval": 1,
              "triggerAtDay": [
                1
              ],
              "triggerAtHour": 8
            }
          ]
        }
      },
      "id": "pg-trigger",
      "name": "Every Monday at 8",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        0,
        240
      ]
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "-- EXAMPLE over a made-up orders table (id, created_at, total). Replace it with your own query. SELECT only.\nSELECT to_char(date_trunc('week', created_at), 'DD Mon') AS week,\n       count(*) AS orders,\n       round(sum(total)) AS revenue\nFROM orders\nWHERE created_at >= date_trunc('week', now()) - interval '8 weeks'\n  AND created_at < date_trunc('week', now())\nGROUP BY date_trunc('week', created_at)\nORDER BY date_trunc('week', created_at);",
        "options": {}
      },
      "id": "pg-query",
      "name": "Example query (replace)",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.5,
      "position": [
        240,
        240
      ],
      "credentials": {
        "postgres": {
          "id": "REPLACE_WITH_CREDENTIAL_ID",
          "name": "Postgres account"
        }
      }
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// Rows come from the Postgres node, one item per week, oldest first.\nconst rows = $input.all().map((item) => item.json);\nif (rows.length === 0) throw new Error('The query returned no rows, so there is nothing to chart.');\n\nconst weeks = rows.map((r) => String(r.week));\n// Number(null) and Number('') are 0, so a missing figure has to be caught before Number().\nconst bad = (v) => v == null || String(v).trim() === '' || !Number.isFinite(Number(v));\nconst badRow = rows.findIndex((r) => bad(r.orders) || bad(r.revenue));\nif (badRow !== -1) {\n  throw new Error('Row ' + (badRow + 1) + ' (week ' + weeks[badRow] + ') has no number in orders or revenue. Every row needs numeric orders and revenue columns.');\n}\nconst orders = rows.map((r) => Number(r.orders));\nconst revenue = rows.map((r) => Number(r.revenue));\n\nconst esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\"/g, '&quot;');\nconst money = (n) => String(Math.round(n)).replace(/\\B(?=(\\d{3})+(?!\\d))/g, ',');\n\nconst chart = {\n  type: 'bar',\n  data: { labels: weeks, datasets: [{ label: 'Revenue', data: revenue }] },\n  options: { plugins: { title: { display: true, text: 'Revenue by week' }, chartpng: { source: 'Source: orders table' } } },\n};\nconst imageUrl = 'https://chartpng.dev/chart?w=800&h=450&dpr=2&c=' + encodeURIComponent(JSON.stringify(chart));\n\nconst last = revenue.length - 1;\nlet summary = 'Week of ' + weeks[last] + ': ' + money(revenue[last]) + ' revenue from ' + orders[last] + ' orders.';\nif (last > 0 && revenue[last - 1] > 0) {\n  const pct = ((revenue[last] - revenue[last - 1]) / revenue[last - 1]) * 100;\n  summary += ' ' + (pct >= 0 ? 'Up ' : 'Down ') + Math.abs(pct).toFixed(1) + '% on the week before.';\n}\nconst alt = 'Bar chart of revenue by week, ' + rows.length + ' weeks. ' + summary;\n\n// The same numbers as text, for mail clients that block remote images.\nconst cell = 'padding:4px 12px;border-bottom:1px solid #dddddd;';\nconst tableHtml = rows\n  .map((r, i) => '<tr><td style=\"' + cell + '\">' + esc(weeks[i]) + '</td><td style=\"' + cell + 'text-align:right\">' + orders[i] + '</td><td style=\"' + cell + 'text-align:right\">' + money(revenue[i]) + '</td></tr>')\n  .join('');\n\nreturn [{ json: { chart, imageUrl, alt: esc(alt), summary: esc(summary), tableHtml } }];"
      },
      "id": "pg-build",
      "name": "Build chart",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        480,
        240
      ]
    },
    {
      "parameters": {
        "fromEmail": "reports@example.com",
        "toEmail": "team@example.com",
        "subject": "Weekly orders and revenue",
        "emailFormat": "html",
        "html": "=<p>Revenue by week, the last 8 full weeks.</p>\n<img src=\"{{ $json.imageUrl }}\" alt=\"{{ $json.alt }}\" width=\"600\" style=\"max-width:100%;height:auto\">\n<p>{{ $json.summary }}</p>\n<table style=\"border-collapse:collapse;font-size:14px\">\n<tr><th style=\"padding:4px 12px;text-align:left\">Week</th><th style=\"padding:4px 12px;text-align:right\">Orders</th><th style=\"padding:4px 12px;text-align:right\">Revenue</th></tr>\n{{ $json.tableHtml }}\n</table>",
        "options": {}
      },
      "id": "pg-email",
      "name": "Send email",
      "type": "n8n-nodes-base.emailSend",
      "typeVersion": 2.1,
      "position": [
        720,
        240
      ],
      "credentials": {
        "smtp": {
          "id": "REPLACE_WITH_CREDENTIAL_ID",
          "name": "SMTP account"
        }
      }
    }
  ],
  "connections": {
    "Every Monday at 8": {
      "main": [
        [
          {
            "node": "Example query (replace)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Example query (replace)": {
      "main": [
        [
          {
            "node": "Build chart",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build chart": {
      "main": [
        [
          {
            "node": "Send email",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "active": false,
  "settings": {
    "executionOrder": "v1"
  },
  "pinData": {},
  "tags": []
}
