From e5b211e9e5c5c216fa84824f9823c7aa398b90f7 Mon Sep 17 00:00:00 2001 From: erdemerikci Date: Thu, 7 May 2026 15:32:04 +0300 Subject: [PATCH] Add functions for handling UTF-8 encoding and attachment headers in report_service; enhance chart time parsing in docx_sections; improve report generation logic in docx.py to conditionally include charts for Cloud-to-Ground and Intercloud lightnings based on data availability. --- Lightning_Report_Automatic.json | 1771 +++++++++++++++++++++++++++++++ report_service/main.py | 44 +- src/reporting/docx.py | 90 +- src/reporting/docx_sections.py | 65 +- 4 files changed, 1911 insertions(+), 59 deletions(-) create mode 100644 Lightning_Report_Automatic.json diff --git a/Lightning_Report_Automatic.json b/Lightning_Report_Automatic.json new file mode 100644 index 0000000..df5e0f8 --- /dev/null +++ b/Lightning_Report_Automatic.json @@ -0,0 +1,1771 @@ +{ + "name": "Lightning_Report_Automatic", + "nodes": [ + { + "parameters": { + "content": "Slack Interactivity Request URL → Webhook \"Slack Interaction Webhook\" production URL (same Slack app as Tarla Slack Account bot token)." + }, + "type": "n8n-nodes-base.stickyNote", + "position": [ + 111936, + 45568 + ], + "typeVersion": 1, + "id": "7f76c972-e794-4cb8-a044-8f7989243b64", + "name": "Sticky Note" + }, + { + "parameters": { + "content": "export REPORT_SERVICE_TOKEN=\"your-long-random-secret\"\nTo run uvicorn report_service.main:app --host 0.0.0.0 --port 8000\n\nOn another terminal: ngrok http 8000\n", + "height": 128, + "width": 512 + }, + "type": "n8n-nodes-base.stickyNote", + "position": [ + 114864, + 45536 + ], + "typeVersion": 1, + "id": "a3f1f98e-df9d-4c11-b7c8-3b02edcd29bf", + "name": "Sticky Note1" + }, + { + "parameters": { + "method": "POST", + "url": "https://api-test.iklim.co/v1/auth/login", + "sendBody": true, + "bodyParameters": { + "parameters": [ + { + "name": "username", + "value": "info@iklim.co" + }, + { + "name": "password", + "value": "gn6aV01Z759HTw9I" + } + ] + }, + "options": {} + }, + "id": "0d7575f2-b40c-465f-aa83-f41a1c134a0d", + "name": "Login to iklim.co", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.1, + "position": [ + 110912, + 45264 + ], + "onError": "continueErrorOutput" + }, + { + "parameters": { + "assignments": { + "assignments": [ + { + "id": "5c1668fb-f97b-4ec8-80f9-61bfb0b8274e", + "name": "username", + "value": "={{ $json.username }}", + "type": "string" + }, + { + "id": "97c76bac-aa08-437f-ac48-a0cad9b93526", + "name": "type", + "value": "={{ $json.type }}", + "type": "string" + }, + { + "id": "fc59d51f-b225-4abb-9f17-93173e650652", + "name": "accessToken", + "value": "={{ $json.accessToken }}", + "type": "string" + }, + { + "id": "95e5ec41-2369-492b-9950-fc230a47f7fa", + "name": "accessTokenExpiration", + "value": "={{ $now.plus({ hours: 1 }).toMillis() }}", + "type": "string" + }, + { + "id": "3d79d06a-ed55-48af-b4bc-b7fd67d80be1", + "name": "refreshToken", + "value": "={{ $json.refreshToken }}", + "type": "string" + }, + { + "id": "aedf0ef4-9971-42e8-8b32-7d039342dd88", + "name": "refreshTokenExpiration", + "value": "={{ $now.plus({ days: 1 }).toMillis() }}", + "type": "string" + } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [ + 111152, + 45248 + ], + "id": "01d82e72-f57e-4967-9557-a834fa3adbe6", + "name": "CalculateExpirations" + }, + { + "parameters": { + "assignments": { + "assignments": [ + { + "id": "5c1668fb-f97b-4ec8-80f9-61bfb0b8274e", + "name": "username", + "value": "={{ $json.username }}", + "type": "string" + }, + { + "id": "97c76bac-aa08-437f-ac48-a0cad9b93526", + "name": "type", + "value": "={{ $json.type }}", + "type": "string" + }, + { + "id": "fc59d51f-b225-4abb-9f17-93173e650652", + "name": "accessToken", + "value": "={{ $json.accessToken }}", + "type": "string" + }, + { + "id": "95e5ec41-2369-492b-9950-fc230a47f7fa", + "name": "accessTokenExpiration", + "value": "={{ $now.plus({ hours: 1 }).toMillis() }}", + "type": "string" + }, + { + "id": "3d79d06a-ed55-48af-b4bc-b7fd67d80be1", + "name": "refreshToken", + "value": "={{ $json.refreshToken }}", + "type": "string" + }, + { + "id": "aedf0ef4-9971-42e8-8b32-7d039342dd88", + "name": "refreshTokenExpiration", + "value": "={{ $now.plus({ days: 1 }).toMillis() }}", + "type": "string" + } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [ + 111216, + 45504 + ], + "id": "07091b81-3720-4f97-95c8-a008747759a8", + "name": "CalculateExpirations1" + }, + { + "parameters": { + "method": "POST", + "url": "https://api-test.iklim.co/v1/auth/refresh", + "sendBody": true, + "bodyParameters": { + "parameters": [ + { + "name": "refreshToken", + "value": "={{ $input.auth..refreshToken }}" + } + ] + }, + "options": {} + }, + "id": "53a6862d-052d-41c4-8e98-95b5b3731be2", + "name": "Refresh Token", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.1, + "position": [ + 110992, + 45520 + ], + "onError": "continueErrorOutput" + }, + { + "parameters": { + "rules": { + "values": [ + { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "leftValue": "={{ DateTime.fromMillis($json.refreshTokenExpiration - 300000) }}", + "rightValue": "={{ $('Daily Trigger').item.json.timestamp }}", + "operator": { + "type": "dateTime", + "operation": "before" + }, + "id": "2e2eea90-cc23-412f-a0f3-c8b9747d0fae" + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "Relogin" + }, + { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "id": "2c01f003-c0d1-4ed7-b41b-5e281b7353bb", + "leftValue": "={{ DateTime.fromMillis($json.accessTokenExpiration - 600000)}}", + "rightValue": "={{ $('Daily Trigger').item.json.timestamp }}", + "operator": { + "type": "dateTime", + "operation": "before" + } + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "Refresh" + }, + { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "id": "590bec27-6e6f-4f0b-a701-3a359032c4ed", + "leftValue": "={{ $('Daily Trigger').item.json.timestamp }}", + "rightValue": "", + "operator": { + "type": "dateTime", + "operation": "exists", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "Valid" + } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.switch", + "typeVersion": 3.4, + "position": [ + 110688, + 45488 + ], + "id": "eef379cf-d802-40be-a123-8d95a6329015", + "name": "Switch" + }, + { + "parameters": { + "jsCode": "const staticData = $getWorkflowStaticData('global');\n\n// initialize accessToken on staticData if it desn't exist yet\nif (!staticData.hasOwnProperty('user')) {\n staticData.user = null;\n}\n\nif (!staticData.hasOwnProperty('authType')) {\n staticData.authType = 'Bearer';\n}\n\nif (!staticData.hasOwnProperty('accessToken')) {\n staticData.accessToken = null;\n}\n\nif (!staticData.hasOwnProperty('accessTokenExpiration')) {\n staticData.accessTokenExpiration = 0;\n}\n\nif (!staticData.hasOwnProperty('refreshToken')) {\n staticData.refreshToken = null;\n}\n\nif (!staticData.hasOwnProperty('refreshTokenExpiration')) {\n staticData.refreshTokenExpiration = 0;\n}\n\nconst currentTime = $input.first().json.timestamp;\n\n\nreturn [\n {\n user: staticData.user,\n bearer: staticData.bearer,\n accessToken: staticData.accessToken,\n accessTokenExpiration: staticData.accessTokenExpiration,\n refreshToken: staticData.refreshToken,\n refreshTokenExpiration: staticData.refreshTokenExpiration,\n timestamp: currentTime\n }\n];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 110496, + 45504 + ], + "id": "4711e596-71a8-4fbf-ba74-263a08e8257a", + "name": "Restore Credentials", + "retryOnFail": false + }, + { + "parameters": { + "jsCode": "const staticData = $getWorkflowStaticData('global');\n// Loop over input items and add a new field called 'myNewField' to the JSON of each one\nfor (const item of $input.all()) {\n staticData.user = item.json.username;\n staticData.authType = item.json.type;\n staticData.accessToken = item.json.accessToken;\n staticData.accessTokenExpiration = item.json.accessTokenExpiration;\n staticData.refreshToken = item.json.refreshToken;\n staticData.refreshTokenExpiration = item.json.refreshTokenExpiration;\n}\n\n//return [{ json: $getWorkflowStaticData('global') }];\nreturn $input.all();" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 111440, + 45504 + ], + "id": "b1ab85ff-78ec-402c-913e-0eee4d103b08", + "name": "Store Refresh Credentials" + }, + { + "parameters": { + "jsCode": "const staticData = $getWorkflowStaticData('global');\n// Loop over input items and add a new field called 'myNewField' to the JSON of each one\nfor (const item of $input.all()) {\n staticData.user = item.json.username;\n staticData.authType = item.json.type;\n staticData.accessToken = item.json.accessToken;\n staticData.accessTokenExpiration = item.json.accessTokenExpiration;\n staticData.refreshToken = item.json.refreshToken;\n staticData.refreshTokenExpiration = item.json.refreshTokenExpiration;\n}\n\n//return [{ json: $getWorkflowStaticData('global') }];\nreturn $input.all();" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 111360, + 45248 + ], + "id": "38cf504f-6164-4dce-9a23-99027739135b", + "name": "Store Login Credentials" + }, + { + "parameters": { + "select": "channel", + "channelId": { + "__rl": true, + "value": "C09K30RDEE9", + "mode": "list", + "cachedResultName": "n8n-events" + }, + "text": "=⚠️ {{ $workflow.name }} isimli iş akışı çalıştırılırken bir yetkilendirme hatası oluştu \n{{ $json.error?.message || '' }}", + "otherOptions": { + "includeLinkToWorkflow": false + } + }, + "type": "n8n-nodes-base.slack", + "typeVersion": 2.4, + "position": [ + 111152, + 45072 + ], + "id": "43113413-4cd9-41c9-896e-2bbb288eee62", + "name": "Send Error Notification", + "webhookId": "0b3582cf-042e-43d6-bc02-4c9debab0566", + "credentials": { + "slackApi": { + "id": "OKgM8VkM05pJl9kU", + "name": "Tarla Slack Account" + } + } + }, + { + "parameters": { + "rule": { + "interval": [ + { + "field": "hours", + "hoursInterval": 12 + } + ] + } + }, + "id": "a1daad99-6e47-488f-981a-9e1d14c50497", + "name": "Daily Trigger", + "type": "n8n-nodes-base.scheduleTrigger", + "typeVersion": 1, + "position": [ + 109952, + 45488 + ] + }, + { + "parameters": { + "method": "POST", + "url": "https://api-test.iklim.co/v1/lightnings/within", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Authorization", + "value": "={{ $json.headers.Authorization }}" + }, + { + "name": "X-Signature", + "value": "={{ $json.headers['X-Signature'] }}" + }, + { + "name": "X-Timestamp", + "value": "={{ $json.headers['X-Timestamp'] }}" + }, + { + "name": "X-Nonce", + "value": "={{ $json.headers['X-Nonce'] }}" + }, + { + "name": "X-Idempotency-Key", + "value": "={{ $json.headers['X-Idempotency-Key'] }}" + }, + { + "name": "Content-Type", + "value": "={{ $json.headers['Content-Type'] }}" + }, + { + "name": "Accept", + "value": "={{ $json.headers['Content-Type'] }}" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ $json.requestBody }}", + "options": {} + }, + "id": "e64a9282-c1d5-47b3-905f-56c61c67fc4a", + "name": "Lightning Request", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.1, + "position": [ + 112016, + 45776 + ], + "onError": "continueErrorOutput" + }, + { + "parameters": { + "operation": "get", + "dataTableId": { + "__rl": true, + "value": "bMJhm1lTDbCj9eY1", + "mode": "list", + "cachedResultName": "customers", + "cachedResultUrl": "/projects/dWFeIPZ8ouir7Ex0/datatables/bMJhm1lTDbCj9eY1" + } + }, + "type": "n8n-nodes-base.dataTable", + "typeVersion": 1.1, + "position": [ + 110496, + 45760 + ], + "id": "b1d352cc-5608-46b5-9662-39d08a55835d", + "name": "Iterate Customers" + }, + { + "parameters": { + "operation": "get", + "dataTableId": { + "__rl": true, + "value": "e2gQ68IGiYmCZkES", + "mode": "list", + "cachedResultName": "wind_turbine_farm", + "cachedResultUrl": "/projects/dWFeIPZ8ouir7Ex0/datatables/e2gQ68IGiYmCZkES" + }, + "filters": { + "conditions": [ + { + "keyName": "customer_id", + "keyValue": "={{ $json.id }}" + } + ] + } + }, + "type": "n8n-nodes-base.dataTable", + "typeVersion": 1.1, + "position": [ + 111232, + 45776 + ], + "id": "76d808f7-6ece-4de1-b46f-f49df940adf4", + "name": "Get Customer Wind Turbines" + }, + { + "parameters": { + "select": "channel", + "channelId": { + "__rl": true, + "value": "C0A9K1AC7SN", + "mode": "list", + "cachedResultName": "n8n-events" + }, + "text": "=⚠️ {{ $workflow.name }} isimli iş akışı çalıştırılırken bir hata oluştu. \n{{ $('Iterate Customers').item.json.customer_name }} müşterisine ait {{ $('Get Customer Wind Turbines').item.json.name }} isimli türbin için yıldırımlar sorgulanırken şu hata oluştu: {{ $json.error.message }}", + "otherOptions": { + "includeLinkToWorkflow": false + } + }, + "type": "n8n-nodes-base.slack", + "typeVersion": 2.4, + "position": [ + 112160, + 46064 + ], + "id": "bf94a207-c74e-436e-b102-1cd2c472af25", + "name": "Request Error Notification", + "webhookId": "38b3ebc5-a136-4b78-9c7a-98f40e92a9d5", + "notesInFlow": false, + "executeOnce": true, + "credentials": { + "slackApi": { + "id": "OKgM8VkM05pJl9kU", + "name": "Tarla Slack Account" + } + } + }, + { + "parameters": { + "jsCode": "const turbines = $input.all();\n\n// 1. Safeguard: Skip if no turbines are found\nif (turbines.length === 0) {\n return { skip_customer: true };\n}\n\nconst lats = turbines.map(t => Number(t.json.latitude));\nconst lons = turbines.map(t => Number(t.json.longitude));\n\n// 2. Calculate Centroid\nconst n = lats.length;\nconst centroid_lat = lats.reduce((a, b) => a + b, 0) / n;\nconst centroid_lon = lons.reduce((a, b) => a + b, 0) / n;\n\n// 3. Haversine Distance Helper\nconst getDist = (lat1, lon1, lat2, lon2) => {\n const R = 6371e3; // Earth radius in meters\n const dLat = (lat2 - lat1) * Math.PI / 180;\n const dLon = (lon2 - lon1) * Math.PI / 180;\n const a = Math.sin(dLat/2)**2 + Math.cos(lat1*Math.PI/180)*Math.cos(lat2*Math.PI/180)*Math.sin(dLon/2)**2;\n return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));\n};\n\n// 4. Find Farthest Turbine\nlet maxDist = 0;\nfor (let i = 0; i < n; i++) {\n const d = getDist(centroid_lat, centroid_lon, lats[i], lons[i]);\n if (d > maxDist) maxDist = d;\n}\n\n// 5. Calculate Rings\n// Updated: Ensures a minimum 1000m radius even for a single turbine point\nconst r1 = Math.max(1000, Math.ceil(maxDist / 1000) * 1000);\nconst r4 = r1 + 6000;\nconst boundary = Math.min(r4 + 2000, 49999); \n\nreturn {\n skip_customer: false,\n farm_id: turbines[0].json.customer_id,\n customer_name: $node[\"Loop Over Items\"].json.customer_name,\n centroid_latitude: centroid_lat,\n centroid_longitude: centroid_lon,\n monitoring_boundary_m: boundary,\n rings: { r1, r2: r1+2000, r3: r1+4000, r4 }\n};" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 111440, + 45776 + ], + "id": "53e74d39-3891-43c8-aadc-3f91e5a9d651", + "name": "Centroid & Distance Ring calculation" + }, + { + "parameters": { + "jsCode": "// Merge the centroid/rings data with the new loop variables\nreturn {\n ...$input.item.json, \n pageNumber: 0,\n stopLoop: false,\n allStrikes: [],\n tStart: null,\n tLast: null\n};" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 111632, + 45776 + ], + "id": "7aafc3a0-1717-4e5d-9eee-d9bf0bf887e6", + "name": "Loop init" + }, + { + "parameters": { + "jsCode": "const SILENCE_MS = 12 * 60 * 60 * 1000;\nconst response = $input.first().json; \nconst batchStrikes = response.lightnings || []; \nconst currentPage = response.pageNumber || 0; \n\nconst farm = $('Loop init').all().pop().json;\nlet allStrikes = [];\nlet tLast = null;\nlet tStart = null;\nlet stopLoop = false;\n\n// FIX: Clone the array to avoid 'Read Only' errors\nif (currentPage > 0) {\n try {\n const prevState = $('Logic Gate').all().pop().json;\n allStrikes = prevState.allStrikes ? [...prevState.allStrikes] : [];\n tLast = prevState.tLast;\n tStart = prevState.tStart;\n } catch (e) { }\n}\n\nif (batchStrikes.length === 0 && currentPage === 0) {\n return { stopLoop: true, allStrikes: [], pageNumber: 0, status: \"EMPTY\", customer_name: farm.customer_name };\n}\n\nif (currentPage === 0 && batchStrikes.length > 0) {\n tLast = new Date(batchStrikes[0].captured).getTime();\n}\n\nfor (let i = 0; i < batchStrikes.length; i++) {\n const currentTs = new Date(batchStrikes[i].captured).getTime();\n batchStrikes[i].timestamp = currentTs; \n allStrikes.push(batchStrikes[i]);\n\n if (batchStrikes[i + 1]) {\n const nextTs = new Date(batchStrikes[i + 1].captured).getTime();\n if (currentTs - nextTs > SILENCE_MS) {\n tStart = currentTs;\n stopLoop = true;\n break;\n }\n }\n}\n\nif (batchStrikes.length < 100) {\n stopLoop = true;\n if (!tStart && allStrikes.length > 0) {\n tStart = allStrikes[allStrikes.length - 1].timestamp;\n }\n}\n\n// Ensure End is later than Start\nconst timestamps = allStrikes.map(s => s.timestamp);\nconst final_tLast = timestamps.length > 0 ? Math.max(...timestamps) : tLast;\nconst final_tStart = tStart || (timestamps.length > 0 ? Math.min(...timestamps) : tLast);\n\nconst roundToMinute = (ts) => Math.floor(Number(ts || 0) / 60000);\nconst stormKey = [\n String(farm.customer_name || ''),\n roundToMinute(final_tStart),\n roundToMinute(final_tLast),\n].join('_');\n\nreturn { \n stopLoop, \n pageNumber: currentPage + 1,\n allStrikes, \n tStart: final_tStart, \n tLast: final_tLast,\n storm_start_min: roundToMinute(final_tStart),\n storm_end_min: roundToMinute(final_tLast),\n storm_key: stormKey,\n customer_name: farm.customer_name,\n status: stopLoop ? \"FINISHED\" : \"PAGING\"\n};" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 112256, + 45776 + ], + "id": "45fce946-69cc-4fd0-bce2-42d584f20c37", + "name": "Logic Gate" + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "id": "68ba538e-8070-4470-b0fa-30106a1f65b5", + "leftValue": "={{ $node[\"Logic Gate\"].json.stopLoop }}", + "rightValue": "", + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "type": "n8n-nodes-base.if", + "typeVersion": 2.3, + "position": [ + 112464, + 45776 + ], + "id": "31610ff2-9211-40fe-8257-bdd7c6e74b38", + "name": "Stop Loop" + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "id": "8b6c4fdd-0f94-4bc8-82ac-0996af68c534", + "leftValue": "={{ $node[\"Logic Gate\"].json.allStrikes.length }}", + "rightValue": 0, + "operator": { + "type": "number", + "operation": "gt" + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "type": "n8n-nodes-base.if", + "typeVersion": 2.3, + "position": [ + 112640, + 45760 + ], + "id": "98ed23a6-bca9-4638-b47e-47da4b46be49", + "name": "Lightning found?" + }, + { + "parameters": { + "operation": "get", + "dataTableId": { + "__rl": true, + "value": "PCYhmruG5hu52Alf", + "mode": "list", + "cachedResultName": "storm_logs", + "cachedResultUrl": "/projects/dWFeIPZ8ouir7Ex0/datatables/PCYhmruG5hu52Alf" + }, + "matchType": "allConditions", + "filters": { + "conditions": [ + { + "keyName": "storm_key", + "keyValue": "={{ $json.storm_key }}" + } + ] + }, + "limit": 1 + }, + "type": "n8n-nodes-base.dataTable", + "typeVersion": 1.1, + "position": [ + 112768, + 45552 + ], + "id": "559baa58-401d-4f8c-bc56-3ced36e88ba2", + "name": "Get row(s)", + "alwaysOutputData": true + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "id": "02a4a5c6-2645-4d72-9952-9a62c69fa19f", + "leftValue": "={{ $json.skip_insert }}", + "rightValue": "", + "operator": { + "type": "boolean", + "operation": "false", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "type": "n8n-nodes-base.if", + "typeVersion": 2.3, + "position": [ + 113152, + 45552 + ], + "id": "5131ea42-6675-4999-867f-7db6c89c76f8", + "name": "If" + }, + { + "parameters": { + "dataTableId": { + "__rl": true, + "value": "PCYhmruG5hu52Alf", + "mode": "list", + "cachedResultName": "storm_logs", + "cachedResultUrl": "/projects/dWFeIPZ8ouir7Ex0/datatables/PCYhmruG5hu52Alf" + }, + "columns": { + "mappingMode": "defineBelow", + "value": { + "storm_start": "={{ $json.tStart }}", + "storm_end": "={{ $json.tLast }}", + "total_strikes": "={{ $json.allStrikes.length }}", + "customer_name": "={{ $json.customer_name }}", + "status": "waiting for confirmation", + "storm_key": "={{ $json.storm_key }}", + "storm_start_min": "={{ $json.storm_start_min }}", + "storm_end_min": "={{ $json.storm_end_min }}" + }, + "matchingColumns": [], + "schema": [ + { + "id": "customer_name", + "displayName": "customer_name", + "required": false, + "defaultMatch": false, + "display": true, + "type": "string", + "readOnly": false, + "removed": false + }, + { + "id": "total_strikes", + "displayName": "total_strikes", + "required": false, + "defaultMatch": false, + "display": true, + "type": "number", + "readOnly": false, + "removed": false + }, + { + "id": "storm_start", + "displayName": "storm_start", + "required": false, + "defaultMatch": false, + "display": true, + "type": "number", + "readOnly": false, + "removed": false + }, + { + "id": "storm_end", + "displayName": "storm_end", + "required": false, + "defaultMatch": false, + "display": true, + "type": "number", + "readOnly": false, + "removed": false + }, + { + "id": "status", + "displayName": "status", + "required": false, + "defaultMatch": false, + "display": true, + "type": "string", + "readOnly": false, + "removed": false + }, + { + "id": "storm_key", + "displayName": "storm_key", + "required": false, + "defaultMatch": false, + "display": true, + "type": "string", + "readOnly": false, + "removed": false + }, + { + "id": "storm_start_min", + "displayName": "storm_start_min", + "required": false, + "defaultMatch": false, + "display": true, + "type": "number", + "readOnly": false, + "removed": false + }, + { + "id": "storm_end_min", + "displayName": "storm_end_min", + "required": false, + "defaultMatch": false, + "display": true, + "type": "number", + "readOnly": false, + "removed": false + } + ], + "attemptToConvertTypes": true, + "convertFieldsToString": false + }, + "options": {} + }, + "type": "n8n-nodes-base.dataTable", + "typeVersion": 1.1, + "position": [ + 113392, + 45552 + ], + "id": "b25febb7-b023-4b47-acc5-d527fd8dbd5f", + "name": "Insert row" + }, + { + "parameters": { + "options": {} + }, + "type": "n8n-nodes-base.splitInBatches", + "typeVersion": 3, + "position": [ + 110992, + 45760 + ], + "id": "f7d058be-24ba-4296-9245-5d74687a7950", + "name": "Loop Over Items" + }, + { + "parameters": { + "jsCode": "const crypto = require('crypto');\nconst HMAC_SECRET = 'c88f845bd6d520ded507ef6b02efc223019ccf68f41d9070705712d480ba5166';\nconst URI = '/v1/thunderstorms/within';\n\nconst storm = $('Logic Gate').all().pop().json;\nconst farm = $('Loop init').all().pop().json;\nconst auth = $('Restore Credentials').all().pop().json;\n\nif (!storm.tLast || !storm.tStart) {\n throw new Error(\"Missing storm timestamps. Skipping Thunderstorm query.\");\n}\n\nconst durationSeconds = Math.floor((storm.tLast - storm.tStart) / 1000);\nconst timestamp = Date.now().toString();\n\nconst bodyPayload = {\n latitude: Number(Number(farm.centroid_latitude).toFixed(6)),\n longitude: Number(Number(farm.centroid_longitude).toFixed(6)),\n radius: Math.max(50000, farm.monitoring_boundary_m), // Ensure coverage[cite: 2]\n backwardInterval: Math.max(3600, durationSeconds + 600), // Dynamic padding[cite: 2]\n endTimeEpoch: Number(storm.tLast), // Use actual storm end[cite: 2]\n intersectsWith: \"THREAT_POLYGON\",\n pageNumber: 0,\n pageSize: 10\n};\n\nconst bodyString = JSON.stringify(bodyPayload);\nconst dataToSign = `POST|${URI}|${timestamp}|${bodyString}`;\nconst signature = crypto.createHmac('sha256', HMAC_SECRET).update(dataToSign).digest('hex').toLowerCase();\n\nreturn {\n requestBody: bodyPayload,\n headers: {\n \"X-Signature\": signature,\n \"X-Timestamp\": timestamp,\n \"X-Nonce\": crypto.randomUUID(),\n \"X-Idempotency-Key\": crypto.randomUUID(),\n \"Authorization\": \"Bearer \" + auth.accessToken,\n \"Content-Type\": \"application/json\"\n }\n};" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 113440, + 45312 + ], + "id": "870bef51-d739-4f6b-a56d-57045b932c2d", + "name": "Calculate Thunderstorm Headers" + }, + { + "parameters": { + "method": "POST", + "url": "https://api-test.iklim.co/v1/thunderstorms/within", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Authorization", + "value": "={{ $json.headers.Authorization }}" + }, + { + "name": "X-Signature", + "value": "={{ $json.headers['X-Signature'] }}" + }, + { + "name": "X-Timestamp", + "value": "={{ $json.headers['X-Timestamp'] }}" + }, + { + "name": "X-Nonce", + "value": "={{ $json.headers['X-Nonce'] }}" + }, + { + "name": "X-Idempotency-Key", + "value": "={{ $json.headers['X-Idempotency-Key'] }}" + }, + { + "name": "Content-Type", + "value": "={{ $json.headers['Content-Type'] }}" + }, + { + "name": "Accept", + "value": "={{ $json.headers['Content-Type'] }}" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ $json.requestBody }}", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.3, + "position": [ + 113648, + 45312 + ], + "id": "51d39941-fe41-45ea-9422-13cc9bb91247", + "name": "HTTP Request" + }, + { + "parameters": { + "jsCode": "const crypto = require('crypto');\nconst HMAC_SECRET = 'c88f845bd6d520ded507ef6b02efc223019ccf68f41d9070705712d480ba5166';\nconst METHOD = 'POST'; \nconst URI = '/v1/lightnings/within';\n\n// Get the override timestamp\nconst testConfig = $('Test Configuration').first().json;\nconst endTime = Number(testConfig.testTimestamp);\n\nconst allItems = $input.all();\nconst processedItems = [];\n\nfor (const item of allItems) {\n const farmData = $node[\"Loop init\"].json;\n const auth = $node[\"Restore Credentials\"].json;\n const pageNumber = item.json.pageNumber;\n const timestamp = Date.now().toString();\n \n const bodyPayload = {\n latitude: farmData.centroid_latitude,\n longitude: farmData.centroid_longitude,\n radius: farmData.monitoring_boundary_m, // Use dynamic radius[cite: 2]\n backwardInterval: 43200, // Fixed 12-hour window[cite: 2]\n endTimeEpoch: endTime, // Dynamic override[cite: 2]\n pageNumber: pageNumber,\n pageSize: 100\n };\n\n const bodyString = JSON.stringify(bodyPayload);\n const dataToSign = `${METHOD.toUpperCase()}|${URI}|${timestamp}|${bodyString}`;\n const signature = crypto.createHmac('sha256', HMAC_SECRET).update(dataToSign).digest('hex').toLowerCase();\n\n processedItems.push({\n json: {\n requestBody: bodyPayload,\n headers: {\n \"X-Signature\": signature,\n \"X-Timestamp\": timestamp,\n \"X-Nonce\": crypto.randomUUID(),\n \"X-Idempotency-Key\": crypto.randomUUID(),\n \"Authorization\": \"Bearer \" + auth.accessToken,\n \"Content-Type\": \"application/json\"\n }\n }\n });\n}\n\nreturn processedItems;" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 111824, + 45776 + ], + "id": "b01e06dd-7776-48b7-af3a-10555656e749", + "name": "Calculate Lightning Headers" + }, + { + "parameters": { + "jsCode": "const turbines = $('Get Customer Wind Turbines').all().map((t) => t.json);\nconst storm = $('Logic Gate').all().pop().json;\nconst farm = $('Loop init').all().pop().json;\nconst thunder = $input.all().map((t) => t.json);\n\nconst getNodeRowsSafely = (nodeName) => {\n try {\n return $(nodeName)\n .all()\n .map((i) => i.json)\n .filter((r) => r && r.id !== undefined && r.id !== null);\n } catch (error) {\n return [];\n }\n};\n\nconst existingRows = getNodeRowsSafely('Get row(s)');\nconst insertedRows = getNodeRowsSafely('Insert row');\nconst stormLogId = insertedRows[0]?.id ?? existingRows[0]?.id ?? null;\n\nreturn {\n customer_name: storm.customer_name,\n storm_log_id: stormLogId,\n centroid_lat: farm.centroid_latitude,\n centroid_lon: farm.centroid_longitude,\n boundary_m: farm.monitoring_boundary_m,\n rings: farm.rings,\n timezone: 'Europe/Istanbul',\n t_start: storm.tStart,\n t_end: storm.tLast,\n n_strikes: (storm.allStrikes || []).length,\n turbines: turbines,\n strikes: storm.allStrikes || [],\n storm_records: thunder,\n};" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 113856, + 45312 + ], + "id": "ff7641de-d15a-4c44-a37d-a808236578e9", + "name": "Build Report Payload" + }, + { + "parameters": { + "method": "POST", + "url": "https://patrol-plural-gooey.ngrok-free.dev/generate", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "X-Report-Token", + "value": "test-secret-123" + }, + { + "name": "Content-Type", + "value": "application/json" + }, + { + "name": "ngrok-skip-browser-warning", + "value": "1" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ $json }}", + "options": { + "response": { + "response": { + "responseFormat": "file" + } + } + } + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.3, + "position": [ + 114064, + 45312 + ], + "id": "3d94c0d5-6972-47ac-a36d-b2688f6462f0", + "name": "Generate Report", + "onError": "continueErrorOutput" + }, + { + "parameters": { + "jsCode": "const customerName = String($node['Build Report Payload'].json.customer_name || 'customer').trim().replace(/\\s+/g, '_');\nconst now = new Date();\nconst year = String(now.getFullYear()).slice(-2);\nconst month = String(now.getMonth() + 1).padStart(2, '0');\nconst day = String(now.getDate()).padStart(2, '0');\nconst hour = String(now.getHours()).padStart(2, '0');\nconst minute = String(now.getMinutes()).padStart(2, '0');\nconst generatedAt = `${year}-${month}-${day}_${hour}-${minute}`;\nconst fileName = `${generatedAt}_${customerName}_iklimco_lightning_report.docx`;\n\nfor (const item of $input.all()) {\n if (!item.binary || !item.binary.data) {\n continue;\n }\n\n item.binary.data.fileName = fileName;\n item.binary.data.fileExtension = 'docx';\n item.binary.data.mimeType = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';\n}\n\nreturn $input.all();" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 114336, + 45312 + ], + "id": "c8e28536-a780-4d44-a6b2-fcfa7a2a2681", + "name": "Prepare DOCX Metadata" + }, + { + "parameters": { + "operation": "get", + "dataTableId": { + "__rl": true, + "value": "PCYhmruG5hu52Alf", + "mode": "list", + "cachedResultName": "storm_logs", + "cachedResultUrl": "/projects/dWFeIPZ8ouir7Ex0/datatables/PCYhmruG5hu52Alf" + }, + "matchType": "allConditions", + "filters": { + "conditions": [] + }, + "limit": 1000 + }, + "type": "n8n-nodes-base.dataTable", + "typeVersion": 1.1, + "position": [ + 114544, + 45312 + ], + "id": "2029bece-7a0c-45b5-8b29-38f284c38014", + "name": "Get Storm Logs For Next Id", + "alwaysOutputData": true + }, + { + "parameters": { + "jsCode": "const payload = $node['Build Report Payload'].json || {};\nconst reportFileItem = $('Prepare DOCX Metadata').all()[0] || {};\nconst rows = $input.all().map((item) => item.json || {});\n\nconst normalizeId = (row) => {\n const raw = row?.id ?? row?.ID ?? row?._id ?? null;\n\n if (raw === null || raw === undefined) {\n return null;\n }\n\n if (typeof raw === 'string' && raw.trim() === '') {\n return null;\n }\n\n const num = Number(raw);\n if (Number.isFinite(num) && num > 0) {\n return num;\n }\n\n return raw;\n};\n\nconst hasValue = (v) => !(v === null || v === undefined || v === '');\n\nlet resolvedStormLogId = hasValue(payload.storm_log_id) ? payload.storm_log_id : null;\n\nif (!hasValue(resolvedStormLogId)) {\n const byKey = rows.find((row) => String(row.storm_key || '') === String(payload.storm_key || ''));\n if (byKey) {\n resolvedStormLogId = normalizeId(byKey);\n }\n}\n\nif (!hasValue(resolvedStormLogId)) {\n const ids = rows\n .map(normalizeId)\n .map((id) => Number(id))\n .filter((id) => Number.isFinite(id) && id > 0);\n\n if (ids.length > 0) {\n resolvedStormLogId = Math.max(...ids);\n }\n}\n\nreturn [{\n json: {\n ...(reportFileItem.json || {}),\n ...payload,\n storm_log_id: hasValue(resolvedStormLogId) ? resolvedStormLogId : 'unknown',\n },\n binary: reportFileItem.binary,\n}];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 114736, + 45312 + ], + "id": "a803d533-d467-432c-af2c-4ef05d3d9eaf", + "name": "Resolve Storm Log Id" + }, + { + "parameters": { + "resource": "file", + "options": { + "channelId": "C0A9K1AC7SN", + "initialComment": "=✅ Lightning report uploaded for *{{ $json.customer_name }}* (log id: *{{ $json.storm_log_id }}*)." + } + }, + "type": "n8n-nodes-base.slack", + "typeVersion": 2.4, + "position": [ + 114912, + 45312 + ], + "id": "f29bdf4e-05b7-4a6f-bded-17cfd92c363b", + "name": "Upload Report to Slack", + "webhookId": "c4d5e6f7-a8b9-4012-cdef-345678901234", + "credentials": { + "slackApi": { + "id": "S5M0i1zlyebqU6oP", + "name": "n8n_lightning_report_bot" + } + } + }, + { + "parameters": { + "select": "channel", + "channelId": { + "__rl": true, + "value": "C0A9K1AC7SN", + "mode": "list", + "cachedResultName": "n8n-events" + }, + "messageType": "block", + "blocksUi": "={{ {\n \"blocks\": [\n {\n \"type\": \"section\",\n \"text\": {\n \"type\": \"mrkdwn\",\n \"text\": `Approve or reject this report for *${$('Resolve Storm Log Id').item.json.customer_name ?? ''}* (log id: \\`${$('Resolve Storm Log Id').item.json.storm_log_id}\\`).`\n }\n },\n {\n \"type\": \"actions\",\n \"elements\": [\n {\n \"type\": \"button\",\n \"text\": { \"type\": \"plain_text\", \"text\": \"Approve\", \"emoji\": true },\n \"style\": \"primary\",\n \"action_id\": \"approve_storm_log\",\n \"value\": String($('Resolve Storm Log Id').item.json.storm_log_id)\n },\n {\n \"type\": \"button\",\n \"text\": { \"type\": \"plain_text\", \"text\": \"Reject\", \"emoji\": true },\n \"style\": \"danger\",\n \"action_id\": \"reject_storm_log\",\n \"value\": String($('Resolve Storm Log Id').item.json.storm_log_id)\n }\n ]\n }\n ]\n} }}", + "text": "Lightning storm report approval", + "otherOptions": {} + }, + "type": "n8n-nodes-base.slack", + "typeVersion": 2.4, + "position": [ + 115120, + 45312 + ], + "id": "ef2d29d2-8512-4409-9008-89f363c988dc", + "name": "Send Approval Buttons", + "webhookId": "d4e5f6a7-b8c9-0123-def0-456789abcdef", + "credentials": { + "slackApi": { + "id": "S5M0i1zlyebqU6oP", + "name": "n8n_lightning_report_bot" + } + } + }, + { + "parameters": { + "httpMethod": "POST", + "path": "slack-storm-log-approval", + "responseMode": "responseNode", + "options": {} + }, + "type": "n8n-nodes-base.webhook", + "typeVersion": 2, + "position": [ + 110160, + 46224 + ], + "id": "be48a900-a5bc-499c-aefa-dceeb9f5f0be", + "name": "Slack Interaction Webhook", + "webhookId": "c9d8e7f6-a5b4-4321-fedc-ba9876543211" + }, + { + "parameters": { + "jsCode": "const raw = $input.first().json;\nlet payloadRaw = raw.payload;\nif (payloadRaw === undefined && raw.body !== undefined) {\n const b = raw.body;\n if (typeof b === 'string' && b.includes('payload=')) {\n try {\n payloadRaw = new URLSearchParams(b).get('payload');\n } catch {\n payloadRaw = undefined;\n }\n } else if (b && typeof b === 'object' && b.payload !== undefined) {\n payloadRaw = b.payload;\n }\n}\nlet payload;\nif (payloadRaw !== null && payloadRaw !== undefined && typeof payloadRaw === 'object') {\n payload = payloadRaw;\n} else if (typeof payloadRaw === 'string') {\n try {\n payload = JSON.parse(payloadRaw);\n } catch {\n return [{ json: { valid: false } }];\n }\n} else {\n return [{ json: { valid: false } }];\n}\nif (payload.type !== 'block_actions') {\n return [{ json: { valid: false } }];\n}\nconst action = payload.actions?.[0];\nconst rawId = String(action?.value ?? '').trim();\nif (!rawId || !action?.action_id) {\n return [{ json: { valid: false } }];\n}\nlet status = null;\nif (action.action_id === 'approve_storm_log') {\n status = 'confirmed';\n} else if (action.action_id === 'reject_storm_log') {\n status = 'rejected';\n}\nif (!status) {\n return [{ json: { valid: false } }];\n}\n\nconst sectionText = payload?.message?.blocks?.find((block) => block?.type === 'section')?.text?.text || '';\nconst fallbackText = payload?.message?.text || '';\nconst sourceText = sectionText || fallbackText;\nconst customerMatch = sourceText.match(/for \\*(.*?)\\* \\(log id:/i) || sourceText.match(/for (.*?) \\(log id:/i);\nconst customer_name = customerMatch?.[1]?.trim() || 'unknown customer';\n\nconst numId = Number(rawId);\nconst id = Number.isFinite(numId) ? numId : rawId;\nconst slackResponseUrl = typeof payload.response_url === 'string' ? payload.response_url.trim() : '';\nreturn [{ json: { valid: true, id, status, customer_name, slack_response_url: slackResponseUrl } }];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 110400, + 46224 + ], + "id": "c2d0291b-1cd6-4858-a59b-e4a9bc6a8d5d", + "name": "Parse Slack Button Payload" + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "id": "b8c9d0e1-f2a3-4455-b667-c78890123456", + "leftValue": "={{ $json.valid }}", + "rightValue": "", + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "type": "n8n-nodes-base.if", + "typeVersion": 2.3, + "position": [ + 110640, + 46224 + ], + "id": "1df1032d-dcd0-4b76-a442-a2329a7ef26f", + "name": "Valid Storm Approval Button?" + }, + { + "parameters": { + "operation": "update", + "dataTableId": { + "__rl": true, + "value": "PCYhmruG5hu52Alf", + "mode": "list", + "cachedResultName": "storm_logs", + "cachedResultUrl": "/projects/dWFeIPZ8ouir7Ex0/datatables/PCYhmruG5hu52Alf" + }, + "matchType": "allConditions", + "filters": { + "conditions": [ + { + "keyValue": "={{ $json.id }}" + } + ] + }, + "columns": { + "mappingMode": "defineBelow", + "value": { + "status": "={{ $json.status }}" + }, + "matchingColumns": [], + "schema": [ + { + "id": "status", + "displayName": "status", + "required": false, + "defaultMatch": false, + "display": true, + "type": "string", + "readOnly": false, + "removed": false + } + ], + "attemptToConvertTypes": false, + "convertFieldsToString": false + }, + "options": {} + }, + "type": "n8n-nodes-base.dataTable", + "typeVersion": 1.1, + "position": [ + 111392, + 46224 + ], + "id": "8d664796-186c-4074-b4c0-2c3914823f9b", + "name": "Approve Pending Row" + }, + { + "parameters": { + "method": "POST", + "url": "={{ $('Still Valid After Ack?').first().json.slack_response_url }}", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Content-Type", + "value": "application/json" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ response_type: 'in_channel', replace_original: false, text: $('Still Valid After Ack?').first().json.status === 'confirmed' ? ('Storm log #' + String($('Still Valid After Ack?').first().json.id) + ' for ' + String($('Still Valid After Ack?').first().json.customer_name || 'unknown customer') + ' approved and recorded in storm_logs.') : ('Storm log #' + String($('Still Valid After Ack?').first().json.id) + ' for ' + String($('Still Valid After Ack?').first().json.customer_name || 'unknown customer') + ' rejected and recorded in storm_logs.') }) }}", + "options": {} + }, + "id": "86388d90-0625-4062-853e-9ee8e72be88a", + "name": "Notify Slack Recording Result", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.1, + "position": [ + 111632, + 46224 + ] + }, + { + "parameters": { + "respondWith": "json", + "responseBody": "{}", + "options": {} + }, + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "position": [ + 110896, + 46224 + ], + "id": "20d9d846-ec6f-4029-aeeb-11540bacbae4", + "name": "Respond Slack Interaction" + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "id": "f7e8d9c0-a1b2-4333-c445-d56677889901", + "leftValue": "={{ $json.valid }}", + "rightValue": "", + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "type": "n8n-nodes-base.if", + "typeVersion": 2.3, + "position": [ + 111088, + 46224 + ], + "id": "dc4a936d-46a5-4ac3-9af9-ca38400a2bb6", + "name": "Still Valid After Ack?" + }, + { + "parameters": { + "assignments": { + "assignments": [ + { + "id": "8a6510f7-a33d-4c87-9472-44329c24e08e", + "name": "testTimestamp", + "value": "=1777824000000", + "type": "number" + } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [ + 110224, + 45488 + ], + "id": "43deee45-026b-4e8d-9be1-b7d90796f8ab", + "name": "Test Configuration" + }, + { + "parameters": { + "jsCode": "\n\n// Grab the data from the Logic Gate node explicitly\nconst stormData = $('Logic Gate').item.json;\n\n// Check if \"Get row(s)\" found anything\nconst existing = $input.all().filter(i => i.json && i.json.id !== undefined && i.json.id !== null);\n\nif (existing.length > 0) {\n // If found, merge and skip\n return [{ json: { ...stormData, skip_insert: true } }];\n}\n\n// If not found, merge and allow insert\nreturn [{ json: { ...stormData, skip_insert: false } }];" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 112944, + 45552 + ], + "id": "71ff30c4-efec-4a11-81af-bc68b7f8a58c", + "name": "Prepare Insert Decision" + } + ], + "pinData": {}, + "connections": { + "Login to iklim.co": { + "main": [ + [ + { + "node": "CalculateExpirations", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Send Error Notification", + "type": "main", + "index": 0 + } + ] + ] + }, + "CalculateExpirations": { + "main": [ + [ + { + "node": "Store Login Credentials", + "type": "main", + "index": 0 + } + ] + ] + }, + "CalculateExpirations1": { + "main": [ + [ + { + "node": "Store Refresh Credentials", + "type": "main", + "index": 0 + } + ] + ] + }, + "Refresh Token": { + "main": [ + [ + { + "node": "CalculateExpirations1", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Send Error Notification", + "type": "main", + "index": 0 + } + ] + ] + }, + "Restore Credentials": { + "main": [ + [ + { + "node": "Switch", + "type": "main", + "index": 0 + } + ] + ] + }, + "Switch": { + "main": [ + [ + { + "node": "Login to iklim.co", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Refresh Token", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Iterate Customers", + "type": "main", + "index": 0 + } + ] + ] + }, + "Store Login Credentials": { + "main": [ + [ + { + "node": "Restore Credentials", + "type": "main", + "index": 0 + } + ] + ] + }, + "Store Refresh Credentials": { + "main": [ + [ + { + "node": "Restore Credentials", + "type": "main", + "index": 0 + } + ] + ] + }, + "Daily Trigger": { + "main": [ + [ + { + "node": "Test Configuration", + "type": "main", + "index": 0 + } + ] + ] + }, + "Lightning Request": { + "main": [ + [ + { + "node": "Logic Gate", + "type": "main", + "index": 0 + } + ] + ] + }, + "Iterate Customers": { + "main": [ + [ + { + "node": "Loop Over Items", + "type": "main", + "index": 0 + } + ] + ] + }, + "Get Customer Wind Turbines": { + "main": [ + [ + { + "node": "Centroid & Distance Ring calculation", + "type": "main", + "index": 0 + } + ] + ] + }, + "Centroid & Distance Ring calculation": { + "main": [ + [ + { + "node": "Loop init", + "type": "main", + "index": 0 + } + ] + ] + }, + "Loop init": { + "main": [ + [ + { + "node": "Calculate Lightning Headers", + "type": "main", + "index": 0 + } + ] + ] + }, + "Logic Gate": { + "main": [ + [ + { + "node": "Stop Loop", + "type": "main", + "index": 0 + } + ] + ] + }, + "Stop Loop": { + "main": [ + [ + { + "node": "Lightning found?", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Calculate Lightning Headers", + "type": "main", + "index": 0 + } + ] + ] + }, + "Lightning found?": { + "main": [ + [ + { + "node": "Get row(s)", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Loop Over Items", + "type": "main", + "index": 0 + } + ] + ] + }, + "Get row(s)": { + "main": [ + [ + { + "node": "Prepare Insert Decision", + "type": "main", + "index": 0 + } + ] + ] + }, + "If": { + "main": [ + [ + { + "node": "Insert row", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Loop Over Items", + "type": "main", + "index": 0 + } + ] + ] + }, + "Insert row": { + "main": [ + [ + { + "node": "Calculate Thunderstorm Headers", + "type": "main", + "index": 0 + } + ] + ] + }, + "Loop Over Items": { + "main": [ + [], + [ + { + "node": "Get Customer Wind Turbines", + "type": "main", + "index": 0 + } + ] + ] + }, + "Calculate Thunderstorm Headers": { + "main": [ + [ + { + "node": "HTTP Request", + "type": "main", + "index": 0 + } + ] + ] + }, + "Calculate Lightning Headers": { + "main": [ + [ + { + "node": "Lightning Request", + "type": "main", + "index": 0 + } + ] + ] + }, + "HTTP Request": { + "main": [ + [ + { + "node": "Build Report Payload", + "type": "main", + "index": 0 + } + ] + ] + }, + "Build Report Payload": { + "main": [ + [ + { + "node": "Generate Report", + "type": "main", + "index": 0 + } + ] + ] + }, + "Generate Report": { + "main": [ + [ + { + "node": "Prepare DOCX Metadata", + "type": "main", + "index": 0 + } + ] + ] + }, + "Prepare DOCX Metadata": { + "main": [ + [ + { + "node": "Get Storm Logs For Next Id", + "type": "main", + "index": 0 + } + ] + ] + }, + "Get Storm Logs For Next Id": { + "main": [ + [ + { + "node": "Resolve Storm Log Id", + "type": "main", + "index": 0 + } + ] + ] + }, + "Resolve Storm Log Id": { + "main": [ + [ + { + "node": "Upload Report to Slack", + "type": "main", + "index": 0 + } + ] + ] + }, + "Upload Report to Slack": { + "main": [ + [ + { + "node": "Send Approval Buttons", + "type": "main", + "index": 0 + } + ] + ] + }, + "Send Approval Buttons": { + "main": [ + [ + { + "node": "Loop Over Items", + "type": "main", + "index": 0 + } + ] + ] + }, + "Slack Interaction Webhook": { + "main": [ + [ + { + "node": "Parse Slack Button Payload", + "type": "main", + "index": 0 + } + ] + ] + }, + "Parse Slack Button Payload": { + "main": [ + [ + { + "node": "Valid Storm Approval Button?", + "type": "main", + "index": 0 + } + ] + ] + }, + "Valid Storm Approval Button?": { + "main": [ + [ + { + "node": "Respond Slack Interaction", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Respond Slack Interaction", + "type": "main", + "index": 0 + } + ] + ] + }, + "Respond Slack Interaction": { + "main": [ + [ + { + "node": "Still Valid After Ack?", + "type": "main", + "index": 0 + } + ] + ] + }, + "Still Valid After Ack?": { + "main": [ + [ + { + "node": "Approve Pending Row", + "type": "main", + "index": 0 + } + ] + ] + }, + "Approve Pending Row": { + "main": [ + [ + { + "node": "Notify Slack Recording Result", + "type": "main", + "index": 0 + } + ] + ] + }, + "Test Configuration": { + "main": [ + [ + { + "node": "Restore Credentials", + "type": "main", + "index": 0 + } + ] + ] + }, + "Prepare Insert Decision": { + "main": [ + [ + { + "node": "If", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": false, + "settings": { + "executionOrder": "v1", + "binaryMode": "separate" + }, + "versionId": "4a1ab303-2e6c-46cf-84be-93330ae75b74", + "meta": { + "instanceId": "15c4ff3a74619031c77894fe5fb8c0fd585362ef637b1873abd56a139f543e12" + }, + "id": "0GaXSvJtxW6JR6hv", + "tags": [] +} \ No newline at end of file diff --git a/report_service/main.py b/report_service/main.py index b0a51c4..e5c04d0 100644 --- a/report_service/main.py +++ b/report_service/main.py @@ -87,6 +87,31 @@ def _build_filename(payload: dict[str, Any]) -> str: return "_".join(parts) +def _pct_encode_utf8(text: str) -> str: + return "".join(f"%{b:02X}" for b in text.encode("utf-8")) + + +def _latin1_header_values(headers: dict[str, str]) -> dict[str, str]: + return { + k: v.encode("latin-1", errors="replace").decode("latin-1") for k, v in headers.items() + } + + +def _ascii_attachment_filename_from_stem(stem: str) -> str: + slug = slugify_ascii_underscore(stem) + safe = slug.encode("ascii", "ignore").decode("ascii").strip("._-") or "report" + return f"{safe}.docx" + + +def _content_disposition_attachment(filename: str) -> str: + lower = filename.lower() + stem = filename[: -len(".docx")] if lower.endswith(".docx") else filename + ascii_fn = _ascii_attachment_filename_from_stem(stem) + ascii_fn = "".join(ch for ch in ascii_fn if ord(ch) < 128) or "report.docx" + encoded = _pct_encode_utf8(filename) + return f'attachment; filename="{ascii_fn}"; filename*=UTF-8\'\'{encoded}' + + @app.get("/health") def health() -> JSONResponse: return JSONResponse({"ok": True, "service": "lightning-report", "version": app.version}) @@ -147,13 +172,20 @@ async def generate(request: Request) -> Response: pass logger.info("Generated %s (%d bytes) for %s", filename, len(data), customer_name) + stem = filename[: -len(".docx")] if filename.lower().endswith(".docx") else filename + ascii_filename = _ascii_attachment_filename_from_stem(stem) + ascii_filename = "".join(ch for ch in ascii_filename if ord(ch) < 128) or "report.docx" + customer_hdr = _pct_encode_utf8(str(customer_name)) + hdrs = _latin1_header_values( + { + "Content-Disposition": _content_disposition_attachment(filename), + "X-Report-Filename": ascii_filename, + "X-Report-Customer": customer_hdr, + "X-Report-Strikes": str(n_strikes), + } + ) return Response( content=data, media_type=DOCX_MIME, - headers={ - "Content-Disposition": f'attachment; filename="{filename}"', - "X-Report-Filename": filename, - "X-Report-Customer": str(customer_name), - "X-Report-Strikes": str(n_strikes), - }, + headers=hdrs, ) diff --git a/src/reporting/docx.py b/src/reporting/docx.py index 66f651d..e9ad2f2 100644 --- a/src/reporting/docx.py +++ b/src/reporting/docx.py @@ -682,52 +682,58 @@ def create_docx_report( "name": config.wind_farm_name or "Wind Farm", }) pre = precompute_distances_and_rings(centroid_lat, centroid_lng, lightning_df, config.distance_rings) + type_values = lightning_df.get("p_type", pd.Series(dtype=str)).astype(str) + mask_within = pre["mask_within"] + has_cg = bool(((type_values == "0") & mask_within).any()) + has_ic = bool(((type_values != "0") & mask_within).any()) - _add_title(doc, "Cloud-to-Ground Lightnings", size_pt=14) - if start_date and end_date: - _add_paragraph(doc, f"{start_date} - {end_date}", size_pt=10) - cg_fig = plot_cloud_to_ground_coordinate_plane(centroid_row, lightning_df, turbine_df, storm_data, precomputed=pre) - _add_image_from_bytes(doc, cg_fig.to_image(format="png", width=1200, height=900, scale=2, engine="kaleido"), content_width) - doc.add_page_break() + if has_cg: + _add_title(doc, "Cloud-to-Ground Lightnings", size_pt=14) + if start_date and end_date: + _add_paragraph(doc, f"{start_date} - {end_date}", size_pt=10) + cg_fig = plot_cloud_to_ground_coordinate_plane(centroid_row, lightning_df, turbine_df, storm_data, precomputed=pre) + _add_image_from_bytes(doc, cg_fig.to_image(format="png", width=1200, height=900, scale=2, engine="kaleido"), content_width) + doc.add_page_break() - _add_title(doc, "Cloud-to-Ground — Current vs Distance", size_pt=14) - if start_date and end_date: - _add_paragraph(doc, f"{start_date} - {end_date}", size_pt=10) - cg_chart = _build_current_vs_distance_chart( - lightning_df, - pre["dists_km"], - pre["mask_within"], - "cg", - "Cloud-to-Ground Lightning — Current vs Distance from Centroid", - 900, - 650, - ) - if cg_chart is not None: - _add_image_from_bytes(doc, cg_chart.to_image(format="png", width=1200, height=900, scale=2, engine="kaleido"), content_width) - doc.add_page_break() + _add_title(doc, "Cloud-to-Ground — Current vs Distance", size_pt=14) + if start_date and end_date: + _add_paragraph(doc, f"{start_date} - {end_date}", size_pt=10) + cg_chart = _build_current_vs_distance_chart( + lightning_df, + pre["dists_km"], + pre["mask_within"], + "cg", + "Cloud-to-Ground Lightning — Current vs Distance from Centroid", + 900, + 650, + ) + if cg_chart is not None: + _add_image_from_bytes(doc, cg_chart.to_image(format="png", width=1200, height=900, scale=2, engine="kaleido"), content_width) + doc.add_page_break() - _add_title(doc, "Intercloud Lightnings", size_pt=14) - if start_date and end_date: - _add_paragraph(doc, f"{start_date} - {end_date}", size_pt=10) - ic_fig = plot_intercloud_coordinate_plane(centroid_row, lightning_df, turbine_df, storm_data, precomputed=pre) - _add_image_from_bytes(doc, ic_fig.to_image(format="png", width=1200, height=900, scale=2, engine="kaleido"), content_width) - doc.add_page_break() + if has_ic: + _add_title(doc, "Intercloud Lightnings", size_pt=14) + if start_date and end_date: + _add_paragraph(doc, f"{start_date} - {end_date}", size_pt=10) + ic_fig = plot_intercloud_coordinate_plane(centroid_row, lightning_df, turbine_df, storm_data, precomputed=pre) + _add_image_from_bytes(doc, ic_fig.to_image(format="png", width=1200, height=900, scale=2, engine="kaleido"), content_width) + doc.add_page_break() - _add_title(doc, "Intercloud — Current vs Distance", size_pt=14) - if start_date and end_date: - _add_paragraph(doc, f"{start_date} - {end_date}", size_pt=10) - ic_chart = _build_current_vs_distance_chart( - lightning_df, - pre["dists_km"], - pre["mask_within"], - "ic", - "Intercloud Lightning — Current vs Distance from Centroid", - 900, - 650, - ) - if ic_chart is not None: - _add_image_from_bytes(doc, ic_chart.to_image(format="png", width=1200, height=900, scale=2, engine="kaleido"), content_width) - doc.add_page_break() + _add_title(doc, "Intercloud — Current vs Distance", size_pt=14) + if start_date and end_date: + _add_paragraph(doc, f"{start_date} - {end_date}", size_pt=10) + ic_chart = _build_current_vs_distance_chart( + lightning_df, + pre["dists_km"], + pre["mask_within"], + "ic", + "Intercloud Lightning — Current vs Distance from Centroid", + 900, + 650, + ) + if ic_chart is not None: + _add_image_from_bytes(doc, ic_chart.to_image(format="png", width=1200, height=900, scale=2, engine="kaleido"), content_width) + doc.add_page_break() risk_table_data, risk_row_colors = build_risk_table_data(turbine_df) if risk_table_data and len(risk_table_data) > 1: diff --git a/src/reporting/docx_sections.py b/src/reporting/docx_sections.py index 1db77e5..019847f 100644 --- a/src/reporting/docx_sections.py +++ b/src/reporting/docx_sections.py @@ -8,6 +8,38 @@ from src.config import config from src.reporting.precompute import precompute_distances_and_rings +def _parse_chart_times(values: pd.Series) -> pd.Series: + def _to_local_naive(ts: pd.Series) -> pd.Series: + if config.timezone: + try: + ts = ts.dt.tz_convert(config.timezone) + except Exception: + pass + try: + return ts.dt.tz_localize(None) + except Exception: + return ts + + numeric = pd.to_numeric(values, errors="coerce") + numeric_valid = numeric.dropna() + + if len(numeric_valid) > 0 and len(numeric_valid) >= max(1, int(len(values) * 0.7)): + abs_max = float(numeric_valid.abs().max()) + if abs_max >= 1e17: + unit = "ns" + elif abs_max >= 1e14: + unit = "us" + elif abs_max >= 1e11: + unit = "ms" + else: + unit = "s" + parsed = pd.to_datetime(numeric, errors="coerce", unit=unit, utc=True) + return _to_local_naive(parsed) + + parsed = pd.to_datetime(values, errors="coerce", utc=True) + return _to_local_naive(parsed) + + def _build_current_vs_distance_chart( lightning_df: pd.DataFrame, dists_km: np.ndarray, @@ -30,8 +62,19 @@ def _build_current_vs_distance_chart( distances = dists_km[combined_mask] currents = subset["current"].values.astype(float) - time_series = pd.to_datetime(subset["local_time"]) - time_dt = time_series.sort_values() + time_series = _parse_chart_times(subset["local_time"]) + valid_mask = ~time_series.isna() + if valid_mask.sum() == 0: + return None + + time_series = time_series.loc[valid_mask] + distances = distances[valid_mask.values] + currents = currents[valid_mask.values] + + sort_idx = np.argsort(time_series.values.astype("datetime64[ns]")) + time_values = time_series.values[sort_idx] + distances = distances[sort_idx] + currents = currents[sort_idx] rings_km = np.array(config.distance_rings, dtype=float) / 1000.0 ring_colors_cfg = getattr(config, "ring_colors", None) or [] @@ -45,21 +88,18 @@ def _build_current_vs_distance_chart( else: ring_names.append(f"{rings_km[i - 1]:.1f}-{rings_km[i]:.1f} km") - t_min = time_dt.min() - t_max = time_dt.max() - tick_vals = pd.date_range(t_min, t_max, periods=4) - tick_text = [t.strftime("%d-%m-%Y %H:%M") for t in tick_vals] - fig = go.Figure() for i in range(len(rings_km)): mask_ring = ring_indices == i if mask_ring.sum() == 0: continue color = ring_colors_cfg[i] if i < len(ring_colors_cfg) else "gray" - r_times = time_series.values[mask_ring] + r_times = time_values[mask_ring] r_currents = currents[mask_ring] r_dists = distances[mask_ring] + tz_suffix = f" ({config.timezone})" if config.timezone else "" r_time_labels = pd.to_datetime(r_times).strftime("%d-%m-%Y %H:%M").values + r_time_labels = np.array([f"{t}{tz_suffix}" for t in r_time_labels]) fig.add_trace( go.Scatter( x=r_times, @@ -78,19 +118,22 @@ def _build_current_vs_distance_chart( ) ) + timezone_label = config.timezone or "UTC" + fig.update_layout( font=dict(size=16), title=dict(text=title, x=0.5, font=dict(size=22)), - xaxis_title="Time", + xaxis_title=f"Time ({timezone_label})", yaxis_title="Current (A)", plot_bgcolor="white", paper_bgcolor="white", xaxis=dict( + type="date", showgrid=True, gridcolor="lightgray", zeroline=False, - tickvals=tick_vals, - ticktext=tick_text, + tickformat="%d-%m-%Y %H:%M", + nticks=6, tickangle=-25, tickfont=dict(size=22), title_font=dict(size=28),