app.bettersight.io/n8n/workflows/daily_digest.json

127 lines
6.3 KiB
JSON

{
"name": "Bettersight — Daily Digest (timezone-aware, hourly check)",
"nodes": [
{
"parameters": {
"rule": {
"interval": [{ "field": "cronExpression", "expression": "0 * * * *" }]
}
},
"id": "schedule-trigger",
"name": "Schedule Trigger (hourly, on the hour)",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.2,
"position": [0, 0]
},
{
"parameters": {
"method": "GET",
"url": "={{ $env.POCKETBASE_URL }}/api/collections/tenants/records",
"sendQuery": true,
"queryParameters": {
"parameters": [
{ "name": "filter", "value": "status = 'active' || status = 'trial'" },
{ "name": "perPage", "value": "200" }
]
},
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth"
},
"id": "list-active-tenants",
"name": "List active tenants (PocketBase)",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [220, 0],
"credentials": {
"httpHeaderAuth": { "id": "pocketbase-admin-auth", "name": "PocketBase Admin Auth" }
}
},
{
"parameters": {
"method": "GET",
"url": "={{ $env.POCKETBASE_URL }}/api/collections/tenant_notifications/records",
"sendQuery": true,
"queryParameters": {
"parameters": [{ "name": "perPage", "value": "200" }]
},
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth"
},
"id": "list-tenant-notifications",
"name": "List tenant_notifications (PocketBase)",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [220, 160],
"credentials": {
"httpHeaderAuth": { "id": "pocketbase-admin-auth", "name": "PocketBase Admin Auth" }
}
},
{
"parameters": {
"jsCode": "const { DateTime } = require('luxon')\n\n// CLAUDE.md §6 timezone-aware digest scheduling — this Code node is\n// the direct implementation of the snippet documented there. Runs\n// every hour on the hour; for each active tenant, converts their\n// configured local send time (tenant_notifications.email_digest_time,\n// default \"18:00\") from their timezone (tenants.timezone) to UTC, and\n// keeps only the tenants whose target UTC hour matches the current\n// hour. A tenant with no tenant_notifications row yet gets the\n// schema's stated defaults (email_digest=true, email_digest_time=\"18:00\")\n// rather than being silently skipped forever.\nconst tenants = $('List active tenants (PocketBase)').first().json.items || []\nconst notifications = $('List tenant_notifications (PocketBase)').first().json.items || []\nconst notifByTenant = Object.fromEntries(notifications.map(n => [n.tenant_id, n]))\n\nconst nowUtc = DateTime.utc()\nconst due = []\n\nfor (const tenant of tenants) {\n const notif = notifByTenant[tenant.id] || {}\n const emailDigestEnabled = notif.email_digest !== false // default true\n if (!emailDigestEnabled) continue\n\n const localTime = notif.email_digest_time || '18:00'\n const tenantTz = tenant.timezone || 'UTC'\n const sendAtUtc = DateTime.fromFormat(localTime, 'HH:mm', { zone: tenantTz }).toUTC()\n\n if (sendAtUtc.hour === nowUtc.hour) {\n due.push({ id: tenant.id })\n }\n}\n\nreturn due.map(t => ({ json: t }))"
},
"id": "filter-due-tenants",
"name": "Filter tenants due this hour (luxon)",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [660, 0]
},
{
"parameters": { "batchSize": 1 },
"id": "loop-tenants",
"name": "Loop Over Due Tenants",
"type": "n8n-nodes-base.splitInBatches",
"typeVersion": 3,
"position": [880, 0]
},
{
"parameters": {
"method": "POST",
"url": "={{ $env.BETTERSIGHT_API_BASE_URL }}/internal/daily-digest/{{ $json.id }}",
"sendHeaders": true,
"headerParameters": {
"parameters": [{ "name": "X-API-Key", "value": "={{ $env.API_SECRET_KEY }}" }]
}
},
"id": "trigger-digest",
"name": "POST /internal/daily-digest/{tenant_id}",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [1100, 0]
}
],
"connections": {
"Schedule Trigger (hourly, on the hour)": {
"main": [
[
{ "node": "List active tenants (PocketBase)", "type": "main", "index": 0 },
{ "node": "List tenant_notifications (PocketBase)", "type": "main", "index": 0 }
]
]
},
"List active tenants (PocketBase)": {
"main": [[{ "node": "Filter tenants due this hour (luxon)", "type": "main", "index": 0 }]]
},
"List tenant_notifications (PocketBase)": {
"main": [[{ "node": "Filter tenants due this hour (luxon)", "type": "main", "index": 0 }]]
},
"Filter tenants due this hour (luxon)": {
"main": [[{ "node": "Loop Over Due Tenants", "type": "main", "index": 0 }]]
},
"Loop Over Due Tenants": {
"main": [
[],
[{ "node": "POST /internal/daily-digest/{tenant_id}", "type": "main", "index": 0 }]
]
},
"POST /internal/daily-digest/{tenant_id}": {
"main": [[{ "node": "Loop Over Due Tenants", "type": "main", "index": 0 }]]
}
},
"active": false,
"settings": { "executionOrder": "v1" },
"meta": {
"description": "CLAUDE.md §6 timezone-aware daily digest — runs hourly, converts each tenant's tenant_notifications.email_digest_time (default 18:00) from tenants.timezone to UTC via luxon, and only triggers /internal/daily-digest for tenants whose local send hour matches the current UTC hour. Replaces the earlier version of this workflow which fired at one fixed UTC hour for every tenant regardless of their timezone. All digest content/rendering logic lives in services/alert_service.py's send_daily_digest(); this workflow only decides WHEN to call it per tenant. Both PocketBase list nodes connect their output to the same Code node input, so n8n waits for both branches to complete before it runs; the Code node then reads each dataset via named node references ($('List active tenants (PocketBase)') / $('List tenant_notifications (PocketBase)')) rather than a wired Merge node. Not yet imported/run against a live n8n instance."
}
}