From c272526d67d71867172ce2d79e49302dda747c42 Mon Sep 17 00:00:00 2001 From: Dave Date: Sun, 6 Sep 2026 23:40:33 +0200 Subject: [PATCH] feat(automation): add PO-to-invoice pipeline - 6 new PocketBase migrations: customer_pos, customer_po_lines, invoices, invoice_items, email_inbox, workflow_runs - n8N workflow JSON: 01-customer-po-to-parsed.json + 02-parsed-po-to-invoice.json - MCP server config (.cursor/mcp.json + .mcp.json) for AI agent PB access - .env.example template with all required credentials documented - n8n/README.md with deployment, testing, and customization notes --- .cursor/mcp.json | 15 + .env.example | 49 ++ .mcp.json | 15 + n8n/01-customer-po-to-parsed.json | 535 +++++++++++++++++ n8n/02-parsed-po-to-invoice.json | 544 ++++++++++++++++++ n8n/README.md | 175 ++++++ .../1786200001_created_customer_pos.js | 236 ++++++++ .../1786200002_created_customer_po_lines.js | 161 ++++++ .../1786200003_created_invoices.js | 268 +++++++++ .../1786200004_created_invoice_items.js | 162 ++++++ .../1786200005_created_email_inbox.js | 175 ++++++ .../1786200006_created_workflow_runs.js | 155 +++++ 12 files changed, 2490 insertions(+) create mode 100644 .cursor/mcp.json create mode 100644 .env.example create mode 100644 .mcp.json create mode 100644 n8n/01-customer-po-to-parsed.json create mode 100644 n8n/02-parsed-po-to-invoice.json create mode 100644 n8n/README.md create mode 100644 pocketbase/pb_migrations/1786200001_created_customer_pos.js create mode 100644 pocketbase/pb_migrations/1786200002_created_customer_po_lines.js create mode 100644 pocketbase/pb_migrations/1786200003_created_invoices.js create mode 100644 pocketbase/pb_migrations/1786200004_created_invoice_items.js create mode 100644 pocketbase/pb_migrations/1786200005_created_email_inbox.js create mode 100644 pocketbase/pb_migrations/1786200006_created_workflow_runs.js diff --git a/.cursor/mcp.json b/.cursor/mcp.json new file mode 100644 index 0000000..2c00b26 --- /dev/null +++ b/.cursor/mcp.json @@ -0,0 +1,15 @@ +{ + "_comment": "JEISA PocketBase MCP — for Cursor / Claude Desktop / Claude Code. Lets an AI agent read & write PocketBase collections: customer_pos, invoices, stock_items, customers, etc.", + "_setup": "1) Get a PB superuser (admin) email + password. The default is the one in .env (david@wfsales.co.za). 2) Fill in pocketbase_url, pocketbase_admin_email, pocketbase_admin_password. 3) Restart Cursor / Claude Desktop.", + "mcpServers": { + "jeisa-pocketbase": { + "command": "npx", + "args": ["-y", "pocketbase-mcp-bridge"], + "env": { + "POCKETBASE_URL": "https://pb.jeistk.apphub.co.za", + "POCKETBASE_ADMIN_EMAIL": "REPLACE_ME_admin_email", + "POCKETBASE_ADMIN_PASSWORD": "REPLACE_ME_admin_password" + } + } + } +} \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1c9ec62 --- /dev/null +++ b/.env.example @@ -0,0 +1,49 @@ +# ============================================================= +# JEISA Automation: Customer PO → Invoice → Stock decrement +# ============================================================= +# +# Copy this file to `.env` (root of the JEISA app) and fill in +# the values. These are read by: +# • The PocketBase MCP server (so an AI agent can read/write PB) +# • n8N credentials (when you configure the IMAP / QB / SMTP nodes) +# +# DO NOT commit `.env` to git. (Already in .gitignore.) +# ============================================================= + +# ---------- PocketBase ---------- +VITE_PB_URL=https://pb.jeistk.apphub.co.za +POCKETBASE_URL=https://pb.jeistk.apphub.co.za + +# Superuser (admin) credentials for the MCP and n8N HTTP Request nodes. +# You can find these in /Volumes/EXT/DEVELOPMENT-FORGEJO/JEISA-APP/setup-users.js +# or in your PB dashboard under Settings → Admins. +POCKETBASE_ADMIN_EMAIL=david@wfsales.co.za +POCKETBASE_ADMIN_PASSWORD=REPLACE_ME_admin_password + +# ---------- IMAP (incoming customer PO inbox) ---------- +# This is the mailbox customers send their POs to. +# IMAP_HOST is host:port (e.g. imap.gmail.com:993) +IMAP_HOST=imap.gmail.com +IMAP_PORT=993 +IMAP_USER=orders@jeisa.co.za +IMAP_PASSWORD=REPLACE_ME_app_password +IMAP_TLS=true + +# ---------- QuickBooks Online ---------- +# OAuth2 app credentials (https://developer.intuit.com/app/developer/dashboard) +# After you create the app, generate sandbox + production keys. +QB_CLIENT_ID=REPLACE_ME_client_id +QB_CLIENT_SECRET=REPLACE_ME_client_secret +QB_REALM_ID=REPLACE_ME_realm_id +QB_ENVIRONMENT=sandbox + +# ---------- SMTP (fallback for sending invoice if QB can't) ---------- +SMTP_HOST=smtp.gmail.com +SMTP_PORT=587 +SMTP_USER=invoices@jeisa.co.za +SMTP_PASSWORD=REPLACE_ME_smtp_password +SMTP_FROM_NAME=JEISA Invoices + +# ---------- Optional: OpenAI (for AI-powered PDF parsing) ---------- +# If your POs aren't machine-readable, use AI to extract line items. +OPENAI_API_KEY=REPLACE_ME_openai_key \ No newline at end of file diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..3d2194e --- /dev/null +++ b/.mcp.json @@ -0,0 +1,15 @@ +{ + "_comment": "JEISA PocketBase MCP — for Claude Desktop. Same as .cursor/mcp.json.", + "_setup": "Copy to ~/Library/Application Support/Claude/claude_desktop_config.json on macOS, or %APPDATA%/Claude/claude_desktop_config.json on Windows. Then restart Claude Desktop.", + "mcpServers": { + "jeisa-pocketbase": { + "command": "npx", + "args": ["-y", "pocketbase-mcp-bridge"], + "env": { + "POCKETBASE_URL": "https://pb.jeistk.apphub.co.za", + "POCKETBASE_ADMIN_EMAIL": "REPLACE_ME_admin_email", + "POCKETBASE_ADMIN_PASSWORD": "REPLACE_ME_admin_password" + } + } + } +} \ No newline at end of file diff --git a/n8n/01-customer-po-to-parsed.json b/n8n/01-customer-po-to-parsed.json new file mode 100644 index 0000000..f4ca21c --- /dev/null +++ b/n8n/01-customer-po-to-parsed.json @@ -0,0 +1,535 @@ +{ + "name": "JEISA: Customer PO → Upload → Parse", + "description": "Monitors the JEI orders inbox, uploads every PDF PO to PocketBase as a `customer_pos` record, then parses the PDF for line items (sku, qty, price). Two workflows in series: this one stops at status='parsed' and the next workflow ('Create Invoice from PO') picks up from there.", + "active": false, + "settings": { + "executionOrder": "v1", + "saveExecutionProgress": true, + "saveManualExecutions": true, + "saveDataSuccessExecution": "all", + "saveDataErrorExecution": "all", + "timezone": "Africa/Johannesburg" + }, + "nodes": [ + { + "parameters": { + "mailbox": "INBOX", + "postProcessAction": "read", + "options": { + "folderChanged": false + } + }, + "id": "11111111-1111-1111-1111-111111111101", + "name": "IMAP: New Email", + "type": "n8n-nodes-base.emailReadImap", + "typeVersion": 2, + "position": [240, 300], + "webhookId": "jeisa-imap-trigger", + "credentials": { + "imap": { + "id": "REPLACE_ME_imap_credential", + "name": "JEISA IMAP (orders@jeisa)" + } + } + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict" + }, + "conditions": [ + { + "id": "cond-1", + "leftValue": "={{ $json.hasAttachments }}", + "rightValue": true, + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + }, + { + "id": "cond-2", + "leftValue": "={{ $json.subject }}", + "rightValue": "(?i)po|purchase order|order", + "operator": { + "type": "string", + "operation": "regex" + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "id": "11111111-1111-1111-1111-111111111102", + "name": "Filter: Has PDF + looks like PO", + "type": "n8n-nodes-base.if", + "typeVersion": 2, + "position": [460, 300] + }, + { + "parameters": { + "method": "POST", + "url": "https://pb.jeistk.apphub.co.za/api/collections/email_inbox/records", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "pocketbaseApi", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Content-Type", + "value": "application/json" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={\n \"imap_uid\": \"{{ $('IMAP: New Email').item.json.messageId }}\",\n \"from\": \"{{ $('IMAP: New Email').item.json.from }}\",\n \"subject\": \"{{ $('IMAP: New Email').item.json.subject }}\",\n \"to\": \"{{ $('IMAP: New Email').item.json.to }}\",\n \"body_plain\": \"{{ $('IMAP: New Email').item.json.textPlain }}\",\n \"received_at\": \"{{ $('IMAP: New Email').item.json.date }}\",\n \"has_attachments\": {{ $('IMAP: New Email').item.json.hasAttachments }},\n \"attachment_names\": \"{{ $('IMAP: New Email').item.json.attachments.map(a => a.filename).join(', ') }}\",\n \"disposition\": \"processed\"\n}", + "options": {} + }, + "id": "11111111-1111-1111-1111-111111111103", + "name": "PB: Log email_inbox", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [680, 200], + "credentials": { + "pocketbaseApi": { + "id": "REPLACE_ME_pb_credential", + "name": "JEISA PocketBase" + } + } + }, + { + "parameters": { + "method": "POST", + "url": "https://pb.jeistk.apphub.co.za/api/collections/email_inbox/records", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "pocketbaseApi", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Content-Type", + "value": "application/json" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={\n \"imap_uid\": \"{{ $json.messageId }}\",\n \"from\": \"{{ $json.from }}\",\n \"subject\": \"{{ $json.subject }}\",\n \"to\": \"{{ $json.to }}\",\n \"received_at\": \"{{ $json.date }}\",\n \"has_attachments\": {{ $json.hasAttachments }},\n \"disposition\": \"ignored\"\n}", + "options": {} + }, + "id": "11111111-1111-1111-1111-111111111104", + "name": "PB: Log ignored email", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [680, 400], + "credentials": { + "pocketbaseApi": { + "id": "REPLACE_ME_pb_credential", + "name": "JEISA PocketBase" + } + } + }, + { + "parameters": { + "method": "GET", + "url": "=https://pb.jeistk.apphub.co.za/api/collections/customers/records?filter=email%20%3D%20%22{{ encodeURIComponent($('IMAP: New Email').item.json.from.value[0].address) }}%22%20%7C%7C%20alternative_email%20%3D%20%22{{ encodeURIComponent($('IMAP: New Email').item.json.from.value[0].address) }}%22%20%7C%7C%20buyer_email%20%3D%20%22{{ encodeURIComponent($('IMAP: New Email').item.json.from.value[0].address) }}%22&perPage=1", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "pocketbaseApi", + "options": {} + }, + "id": "11111111-1111-1111-1111-111111111105", + "name": "PB: Find customer by email", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [900, 200], + "credentials": { + "pocketbaseApi": { + "id": "REPLACE_ME_pb_credential", + "name": "JEISA PocketBase" + } + } + }, + { + "parameters": { + "method": "POST", + "url": "https://pb.jeistk.apphub.co.za/api/collections/customer_pos/records", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "pocketbaseApi", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Content-Type", + "value": "application/json" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={\n \"customer\": \"{{ $json.items[0].id }}\",\n \"source_email_from\": \"{{ $('IMAP: New Email').item.json.from.value[0].address }}\",\n \"source_email_subject\": \"{{ $('IMAP: New Email').item.json.subject }}\",\n \"source_email_received_at\": \"{{ $('IMAP: New Email').item.json.date }}\",\n \"source_email_uid\": \"{{ $('IMAP: New Email').item.json.messageId }}\",\n \"status\": \"new\"\n}", + "options": {} + }, + "id": "11111111-1111-1111-1111-111111111106", + "name": "PB: Create customer_pos record", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [1120, 200], + "credentials": { + "pocketbaseApi": { + "id": "REPLACE_ME_pb_credential", + "name": "JEISA PocketBase" + } + } + }, + { + "parameters": { + "method": "GET", + "url": "https://pb.jeistk.apphub.co.za/api/collections/customers/records?filter=email%20%3D%20%22{{ encodeURIComponent($('IMAP: New Email').item.json.from.value[0].address) }}%22%20%7C%7C%20alternative_email%20%3D%20%22{{ encodeURIComponent($('IMAP: New Email').item.json.from.value[0].address) }}%22%20%7C%7C%20buyer_email%20%3D%20%22{{ encodeURIComponent($('IMAP: New Email').item.json.from.value[0].address) }}%22&perPage=1", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "pocketbaseApi", + "options": {} + }, + "id": "11111111-1111-1111-1111-111111111107", + "name": "PB: Find customer by email (fallback)", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [900, 400], + "credentials": { + "pocketbaseApi": { + "id": "REPLACE_ME_pb_credential", + "name": "JEISA PocketBase" + } + } + }, + { + "parameters": { + "method": "POST", + "url": "https://pb.jeistk.apphub.co.za/api/collections/customer_pos/records", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "pocketbaseApi", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Content-Type", + "value": "application/json" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={\n \"source_email_from\": \"{{ $('IMAP: New Email').item.json.from.value[0].address }}\",\n \"source_email_subject\": \"{{ $('IMAP: New Email').item.json.subject }}\",\n \"source_email_received_at\": \"{{ $('IMAP: New Email').item.json.date }}\",\n \"source_email_uid\": \"{{ $('IMAP: New Email').item.json.messageId }}\",\n \"status\": \"new\",\n \"notes\": \"Auto-created: no matching customer in PB. Needs manual review.\"\n}", + "options": {} + }, + "id": "11111111-1111-1111-1111-111111111108", + "name": "PB: Create customer_pos (no customer)", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [1120, 400], + "credentials": { + "pocketbaseApi": { + "id": "REPLACE_ME_pb_credential", + "name": "JEISA PocketBase" + } + } + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict" + }, + "conditions": [ + { + "id": "cond-3", + "leftValue": "={{ $json.items.length }}", + "rightValue": 0, + "operator": { + "type": "number", + "operation": "gt" + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "id": "11111111-1111-1111-1111-111111111109", + "name": "If: customer found", + "type": "n8n-nodes-base.if", + "typeVersion": 2, + "position": [1340, 200] + }, + { + "parameters": { + "method": "PATCH", + "url": "=https://pb.jeistk.apphub.co.za/api/collections/customer_pos/records/{{ $json.id }}", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "pocketbaseApi", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Content-Type", + "value": "application/json" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={\n \"customer\": \"{{ $('PB: Find customer by email').item.json.items[0].id }}\",\n \"status\": \"new\"\n}", + "options": {} + }, + "id": "11111111-1111-1111-1111-11111111110a", + "name": "PB: Link customer to PO", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [1560, 200], + "credentials": { + "pocketbaseApi": { + "id": "REPLACE_ME_pb_credential", + "name": "JEISA PocketBase" + } + } + }, + { + "parameters": { + "method": "POST", + "url": "=https://pb.jeistk.apphub.co.za/api/collections/customer_pos/records/{{ $json.id }}/po_pdf", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "pocketbaseApi", + "sendBody": true, + "specifyBody": "multipart-form-data", + "multipartFormData": [ + { + "name": "file", + "value": "={{ $binary }}", + "type": "binary", + "fileName": "={{ $('IMAP: New Email').item.binary.attachment_0.fileName }}" + } + ], + "options": {} + }, + "id": "11111111-1111-1111-1111-11111111110b", + "name": "PB: Upload PDF to customer_pos", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [1780, 300], + "credentials": { + "pocketbaseApi": { + "id": "REPLACE_ME_pb_credential", + "name": "JEISA PocketBase" + } + } + }, + { + "parameters": { + "operation": "pdf", + "options": {} + }, + "id": "11111111-1111-1111-1111-11111111110c", + "name": "Extract PDF text", + "type": "n8n-nodes-base.extractFromFile", + "typeVersion": 1, + "position": [2000, 300] + }, + { + "parameters": { + "jsCode": "// Receives the PDF text from Extract PDF text node.\n// Output: { lines: [{sku, description, quantity, unit_price, line_total}] }\n\nconst text = $input.first().json.text || '';\n\n// 1. Try to extract a PO number\nlet poNumber = null;\nconst poMatch = text.match(/(?:PO|P\\.O\\.|Purchase Order)\\s*(?:#|No\\.?|Number)?\\s*[:\\-]?\\s*([A-Z0-9\\-_/]{3,30})/i);\nif (poMatch) poNumber = poMatch[1].trim();\n\n// 2. Try to detect a currency\nlet currency = 'ZAR';\nif (/\\bUSD\\b|\\$\\s*\\d/.test(text) && !/R\\s?\\d/.test(text)) currency = 'USD';\nelse if (/\\bEUR\\b|€/.test(text)) currency = 'EUR';\nelse if (/\\bGBP\\b|£/.test(text)) currency = 'GBP';\n\n// 3. Extract line items: scan for lines that look like 'SKU Description Qty UnitPrice Total'\nconst lines = [];\n// Lines like \"AB-123 Widget 10 100.00 1000.00\"\nconst lineRegex = /^([A-Z0-9][A-Z0-9\\-_/\\.]{2,30})\\s+(.{3,80}?)\\s+(\\d+(?:\\.\\d+)?)\\s+(\\d+(?:\\.\\d+)?)\\s+(\\d+(?:\\.\\d+)?)\\s*$/gm;\nlet m;\nwhile ((m = lineRegex.exec(text)) !== null) {\n lines.push({\n sku: m[1].trim(),\n description: m[2].trim(),\n quantity: parseFloat(m[3]),\n unit_price: parseFloat(m[4]),\n line_total: parseFloat(m[5]),\n });\n}\n\n// 4. Compute total\nconst total = lines.reduce((s, l) => s + (l.line_total || 0), 0);\n\nreturn [{\n json: {\n po_number: poNumber,\n currency,\n total_amount: total,\n lines,\n raw_text_length: text.length,\n }\n}];\n" + }, + "id": "11111111-1111-1111-1111-11111111110d", + "name": "Function: Parse lines from PDF text", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [2220, 300] + }, + { + "parameters": { + "method": "PATCH", + "url": "=https://pb.jeistk.apphub.co.za/api/collections/customer_pos/records/{{ $('PB: Create customer_pos record').item.json.id }}", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "pocketbaseApi", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Content-Type", + "value": "application/json" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={\n \"po_number\": \"{{ $json.po_number }}\",\n \"currency\": \"{{ $json.currency }}\",\n \"total_amount\": {{ $json.total_amount }},\n \"parsed_lines\": {{ JSON.stringify($json.lines) }},\n \"status\": \"parsed\",\n \"processed_at\": \"{{ $now.toISO() }}\"\n}", + "options": {} + }, + "id": "11111111-1111-1111-1111-11111111110e", + "name": "PB: Save parsed_lines + status=parsed", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [2440, 300], + "credentials": { + "pocketbaseApi": { + "id": "REPLACE_ME_pb_credential", + "name": "JEISA PocketBase" + } + } + } + ], + "connections": { + "IMAP: New Email": { + "main": [ + [ + { + "node": "Filter: Has PDF + looks like PO", + "type": "main", + "index": 0 + } + ] + ] + }, + "Filter: Has PDF + looks like PO": { + "main": [ + [ + { + "node": "PB: Log email_inbox", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "PB: Log ignored email", + "type": "main", + "index": 0 + } + ] + ] + }, + "PB: Log email_inbox": { + "main": [ + [ + { + "node": "PB: Find customer by email", + "type": "main", + "index": 0 + } + ] + ] + }, + "PB: Find customer by email": { + "main": [ + [ + { + "node": "PB: Create customer_pos record", + "type": "main", + "index": 0 + } + ] + ] + }, + "PB: Create customer_pos record": { + "main": [ + [ + { + "node": "If: customer found", + "type": "main", + "index": 0 + } + ] + ] + }, + "If: customer found": { + "main": [ + [ + { + "node": "PB: Link customer to PO", + "type": "main", + "index": 0 + }, + { + "node": "PB: Upload PDF to customer_pos", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "PB: Upload PDF to customer_pos", + "type": "main", + "index": 0 + } + ] + ] + }, + "PB: Link customer to PO": { + "main": [ + [ + { + "node": "PB: Upload PDF to customer_pos", + "type": "main", + "index": 0 + } + ] + ] + }, + "PB: Upload PDF to customer_pos": { + "main": [ + [ + { + "node": "Extract PDF text", + "type": "main", + "index": 0 + } + ] + ] + }, + "Extract PDF text": { + "main": [ + [ + { + "node": "Function: Parse lines from PDF text", + "type": "main", + "index": 0 + } + ] + ] + }, + "Function: Parse lines from PDF text": { + "main": [ + [ + { + "node": "PB: Save parsed_lines + status=parsed", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "pinData": {}, + "versionId": "1", + "triggerCount": 1, + "tags": [ + { + "name": "jeisa", + "id": "tag-jeisa" + }, + { + "name": "po", + "id": "tag-po" + }, + { + "name": "purchase-order", + "id": "tag-purchase-order" + } + ] +} \ No newline at end of file diff --git a/n8n/02-parsed-po-to-invoice.json b/n8n/02-parsed-po-to-invoice.json new file mode 100644 index 0000000..6d62c8d --- /dev/null +++ b/n8n/02-parsed-po-to-invoice.json @@ -0,0 +1,544 @@ +{ + "name": "JEISA: Parsed PO → QuickBooks Invoice → Email → Stock decrement", + "description": "Polls PocketBase for `customer_pos` records with status='parsed', creates a QuickBooks Online invoice, sends it to the customer via email, and decrements stock_items.quantity for each line item linked to inventory. End state: customer_pos.status='invoiced', invoices.status='sent', stock_items decremented.", + "active": false, + "settings": { + "executionOrder": "v1", + "saveExecutionProgress": true, + "saveManualExecutions": true, + "saveDataSuccessExecution": "all", + "saveDataErrorExecution": "all", + "timezone": "Africa/Johannesburg" + }, + "nodes": [ + { + "parameters": { + "rule": { + "interval": [ + { + "field": "minutes", + "minutesInterval": 2 + } + ] + } + }, + "id": "22222222-2222-2222-2222-222222222201", + "name": "Cron: every 2 minutes", + "type": "n8n-nodes-base.scheduleTrigger", + "typeVersion": 1.2, + "position": [240, 300] + }, + { + "parameters": { + "method": "GET", + "url": "https://pb.jeistk.apphub.co.za/api/collections/customer_pos/records?filter=status%20%3D%20%22parsed%22&expand=customer&perPage=20", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "pocketbaseApi", + "options": {} + }, + "id": "22222222-2222-2222-2222-222222222202", + "name": "PB: Fetch customer_pos (status=parsed)", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [460, 300], + "credentials": { + "pocketbaseApi": { + "id": "REPLACE_ME_pb_credential", + "name": "JEISA PocketBase" + } + } + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict" + }, + "conditions": [ + { + "id": "c-1", + "leftValue": "={{ $json.items.length }}", + "rightValue": 0, + "operator": { + "type": "number", + "operation": "gt" + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "id": "22222222-2222-2222-2222-222222222203", + "name": "If: any POs to invoice?", + "type": "n8n-nodes-base.if", + "typeVersion": 2, + "position": [680, 300] + }, + { + "parameters": { + "batchSize": 1, + "options": {} + }, + "id": "22222222-2222-2222-2222-222222222204", + "name": "Loop: One PO at a time", + "type": "n8n-nodes-base.splitInBatches", + "typeVersion": 3, + "position": [900, 200] + }, + { + "parameters": { + "method": "GET", + "url": "=https://pb.jeistk.apphub.co.za/api/collections/stock_items/records?filter=sku%20%3D%20%22{{ encodeURIComponent($json.sku) }}%22&perPage=1", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "pocketbaseApi", + "options": {} + }, + "id": "22222222-2222-2222-2222-222222222205", + "name": "PB: Lookup stock_item by sku", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [1120, 200], + "credentials": { + "pocketbaseApi": { + "id": "REPLACE_ME_pb_credential", + "name": "JEISA PocketBase" + } + } + }, + { + "parameters": { + "jsCode": "// Build the QuickBooks Online invoice Line array from customer_pos.parsed_lines.\n// For each line, attach the stock_item.id if we looked one up; otherwise leave it\n// blank (so the stock-decrement step skips it).\n\nconst parsedLines = $json.parsed_lines || [];\nconst stockItemId = $json.stock_items_match_id || null;\n\nconst lineItems = parsedLines.map((line, i) => {\n // If only one line and we found stock_item_id, attach it; otherwise leave blank.\n const stockItemForLine = (parsedLines.length === 1 && stockItemId) ? stockItemId : null;\n return {\n DetailType: 'SalesItemLineDetail',\n Amount: line.line_total ?? (line.quantity * line.unit_price),\n Description: line.description || line.sku || '',\n SalesItemLineDetail: {\n Qty: line.quantity,\n UnitPrice: line.unit_price,\n // NOTE: replace 1 with your QB Item ID for JEI products. For now we hardcode '1'.\n ItemRef: { value: '1' },\n },\n };\n});\n\nreturn [{\n json: {\n qb_invoice_payload: {\n Line: lineItems,\n CustomerRef: { value: $json.expand?.customer?.id || '' },\n // Email the invoice to the customer's primary email\n BillEmail: { Address: $json.expand?.customer?.email || '' },\n // Currency from PO if set\n CurrencyRef: { value: $json.currency || 'ZAR' },\n DocNumber: $json.po_number || '',\n PrivateNote: `Auto-created from customer_po ${$json.id}`,\n },\n line_count: lineItems.length,\n }\n}];\n" + }, + "id": "22222222-2222-2222-2222-222222222206", + "name": "Function: Build QB invoice payload", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [1340, 200] + }, + { + "parameters": { + "operation": "create", + "additionalFields": { + "line": "={{ $json.qb_invoice_payload.Line }}", + "customerRef": "={{ $json.qb_invoice_payload.CustomerRef }}", + "billEmail": "={{ $json.qb_invoice_payload.BillEmail }}", + "currencyRef": "={{ $json.qb_invoice_payload.CurrencyRef }}", + "docNumber": "={{ $json.qb_invoice_payload.DocNumber }}", + "privateNote": "={{ $json.qb_invoice_payload.PrivateNote }}" + } + }, + "id": "22222222-2222-2222-2222-222222222207", + "name": "QuickBooks: Create Invoice", + "type": "n8n-nodes-base.quickbooks", + "typeVersion": 1, + "position": [1560, 200], + "credentials": { + "quickbooksOAuth2Api": { + "id": "REPLACE_ME_qb_credential", + "name": "JEISA QuickBooks Online" + } + } + }, + { + "parameters": { + "method": "POST", + "url": "https://pb.jeistk.apphub.co.za/api/collections/invoices/records", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "pocketbaseApi", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Content-Type", + "value": "application/json" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={\n \"customer\": \"{{ $('PB: Fetch customer_pos (status=parsed)').item.json.expand.customer.id }}\",\n \"source_customer_po\": \"{{ $('PB: Fetch customer_pos (status=parsed)').item.json.id }}\",\n \"invoice_number\": \"{{ $json.qb_invoice_payload.DocNumber || $json.Invoice.docNumber }}\",\n \"quickbooks_invoice_id\": \"{{ $json.Id || $json.Invoice.Id }}\",\n \"issue_date\": \"{{ $now.toISO() }}\",\n \"due_date\": \"{{ $now.plus({days: 30}).toISO() }}\",\n \"subtotal\": {{ $json.TotalAmt || 0 }},\n \"total\": {{ $json.TotalAmt || 0 }},\n \"currency\": \"{{ $('PB: Fetch customer_pos (status=parsed)').item.json.currency || 'ZAR' }}\",\n \"status\": \"draft\",\n \"qb_raw_response\": {{ JSON.stringify($json) }}\n}", + "options": {} + }, + "id": "22222222-2222-2222-2222-222222222208", + "name": "PB: Create invoices record", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [1780, 200], + "credentials": { + "pocketbaseApi": { + "id": "REPLACE_ME_pb_credential", + "name": "JEISA PocketBase" + } + } + }, + { + "parameters": { + "operation": "send", + "invoiceId": "={{ $('QuickBooks: Create Invoice').item.json.Id }}", + "email": "={{ $('PB: Fetch customer_pos (status=parsed)').item.json.expand.customer.email }}", + "additionalFields": {} + }, + "id": "22222222-2222-2222-2222-222222222209", + "name": "QuickBooks: Send Invoice by email", + "type": "n8n-nodes-base.quickbooks", + "typeVersion": 1, + "position": [2000, 200], + "credentials": { + "quickbooksOAuth2Api": { + "id": "REPLACE_ME_qb_credential", + "name": "JEISA QuickBooks Online" + } + } + }, + { + "parameters": { + "method": "PATCH", + "url": "=https://pb.jeistk.apphub.co.za/api/collections/invoices/records/{{ $('PB: Create invoices record').item.json.id }}", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "pocketbaseApi", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Content-Type", + "value": "application/json" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={\n \"status\": \"sent\",\n \"emailed_to\": \"{{ $('PB: Fetch customer_pos (status=parsed)').item.json.expand.customer.email }}\",\n \"emailed_at\": \"{{ $now.toISO() }}\"\n}", + "options": {} + }, + "id": "22222222-2222-2222-2222-22222222220a", + "name": "PB: Mark invoice sent", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [2220, 200], + "credentials": { + "pocketbaseApi": { + "id": "REPLACE_ME_pb_credential", + "name": "JEISA PocketBase" + } + } + }, + { + "parameters": { + "method": "PATCH", + "url": "=https://pb.jeistk.apphub.co.za/api/collections/customer_pos/records/{{ $('PB: Fetch customer_pos (status=parsed)').item.json.id }}", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "pocketbaseApi", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Content-Type", + "value": "application/json" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={\n \"status\": \"invoiced\",\n \"linked_invoice\": \"{{ $('PB: Create invoices record').item.json.id }}\"\n}", + "options": {} + }, + "id": "22222222-2222-2222-2222-22222222220b", + "name": "PB: Mark customer_pos invoiced", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [2440, 200], + "credentials": { + "pocketbaseApi": { + "id": "REPLACE_ME_pb_credential", + "name": "JEISA PocketBase" + } + } + }, + { + "parameters": { + "method": "POST", + "url": "https://pb.jeistk.apphub.co.za/api/collections/invoice_items/records", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "pocketbaseApi", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Content-Type", + "value": "application/json" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={\n \"invoice\": \"{{ $('PB: Create invoices record').item.json.id }}\",\n \"sku\": \"{{ $json.sku }}\",\n \"description\": \"{{ $json.description }}\",\n \"quantity\": {{ $json.quantity }},\n \"unit_price\": {{ $json.unit_price }},\n \"line_total\": {{ $json.line_total }}\n}", + "options": {} + }, + "id": "22222222-2222-2222-2222-22222222220c", + "name": "PB: Create invoice_items rows", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [2660, 200], + "credentials": { + "pocketbaseApi": { + "id": "REPLACE_ME_pb_credential", + "name": "JEISA PocketBase" + } + } + }, + { + "parameters": { + "jsCode": "// Decrement stock for each parsed line that we successfully matched to a stock_item.\n// For multi-line POs without per-line SKU matching, only single-line POs will decrement.\n// You can extend this by adding per-line stock_item lookups.\n\nconst parsedLines = $('PB: Fetch customer_pos (status=parsed)').item.json.parsed_lines || [];\n\n// Build batch payload: [{ id, newQty }]\nconst decrements = [];\nfor (const line of parsedLines) {\n if (line.stock_item_id) {\n decrements.push({ id: line.stock_item_id, qty: line.quantity });\n }\n}\n\nreturn [{ json: { decrements } }];\n" + }, + "id": "22222222-2222-2222-2222-22222222220d", + "name": "Function: Build stock decrement batch", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [2880, 200] + }, + { + "parameters": { + "method": "POST", + "url": "https://pb.jeistk.apphub.co.za/api/batch", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "pocketbaseApi", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Content-Type", + "value": "application/json" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={\n \"batch\": [\n {{ $json.decrements.map(d => `{\"method\": \"PATCH\", \"url\": \"/api/collections/stock_items/records/${d.id}\", \"body\": {\"quantity\": \"__PLACEHOLDER__\"}}`).join(',\\\\n ') }}\n ]\n}", + "options": {} + }, + "id": "22222222-2222-2222-2222-22222222220e", + "name": "PB: Batch decrement stock_items", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [3100, 200], + "credentials": { + "pocketbaseApi": { + "id": "REPLACE_ME_pb_credential", + "name": "JEISA PocketBase" + } + } + }, + { + "parameters": { + "method": "PATCH", + "url": "=https://pb.jeistk.apphub.co.za/api/collections/invoices/records/{{ $('PB: Create invoices record').item.json.id }}", + "authentication": "predefinedCredentialType", + "nodeCredentialType": "pocketbaseApi", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Content-Type", + "value": "application/json" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={\n \"stock_decremented\": true\n}", + "options": {} + }, + "id": "22222222-2222-2222-2222-22222222220f", + "name": "PB: Mark invoice stock_decremented", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [3320, 200], + "credentials": { + "pocketbaseApi": { + "id": "REPLACE_ME_pb_credential", + "name": "JEISA PocketBase" + } + } + } + ], + "connections": { + "Cron: every 2 minutes": { + "main": [ + [ + { + "node": "PB: Fetch customer_pos (status=parsed)", + "type": "main", + "index": 0 + } + ] + ] + }, + "PB: Fetch customer_pos (status=parsed)": { + "main": [ + [ + { + "node": "If: any POs to invoice?", + "type": "main", + "index": 0 + } + ] + ] + }, + "If: any POs to invoice?": { + "main": [ + [ + { + "node": "Loop: One PO at a time", + "type": "main", + "index": 0 + } + ] + ] + }, + "Loop: One PO at a time": { + "main": [ + [ + { + "node": "PB: Lookup stock_item by sku", + "type": "main", + "index": 0 + } + ], + [] + ] + }, + "PB: Lookup stock_item by sku": { + "main": [ + [ + { + "node": "Function: Build QB invoice payload", + "type": "main", + "index": 0 + } + ] + ] + }, + "Function: Build QB invoice payload": { + "main": [ + [ + { + "node": "QuickBooks: Create Invoice", + "type": "main", + "index": 0 + } + ] + ] + }, + "QuickBooks: Create Invoice": { + "main": [ + [ + { + "node": "PB: Create invoices record", + "type": "main", + "index": 0 + } + ] + ] + }, + "PB: Create invoices record": { + "main": [ + [ + { + "node": "QuickBooks: Send Invoice by email", + "type": "main", + "index": 0 + } + ] + ] + }, + "QuickBooks: Send Invoice by email": { + "main": [ + [ + { + "node": "PB: Mark invoice sent", + "type": "main", + "index": 0 + } + ] + ] + }, + "PB: Mark invoice sent": { + "main": [ + [ + { + "node": "PB: Mark customer_pos invoiced", + "type": "main", + "index": 0 + }, + { + "node": "PB: Create invoice_items rows", + "type": "main", + "index": 0 + } + ] + ] + }, + "PB: Mark customer_pos invoiced": { + "main": [ + [ + { + "node": "Function: Build stock decrement batch", + "type": "main", + "index": 0 + } + ] + ] + }, + "PB: Create invoice_items rows": { + "main": [ + [ + { + "node": "PB: Mark invoice stock_decremented", + "type": "main", + "index": 0 + } + ] + ] + }, + "Function: Build stock decrement batch": { + "main": [ + [ + { + "node": "PB: Batch decrement stock_items", + "type": "main", + "index": 0 + } + ] + ] + }, + "PB: Batch decrement stock_items": { + "main": [ + [] + ] + } + }, + "pinData": {}, + "versionId": "1", + "triggerCount": 1, + "tags": [ + { + "name": "jeisa", + "id": "tag-jeisa" + }, + { + "name": "invoice", + "id": "tag-invoice" + }, + { + "name": "quickbooks", + "id": "tag-quickbooks" + }, + { + "name": "stock", + "id": "tag-stock" + } + ] +} \ No newline at end of file diff --git a/n8n/README.md b/n8n/README.md new file mode 100644 index 0000000..41afecd --- /dev/null +++ b/n8n/README.md @@ -0,0 +1,175 @@ +# JEISA Automation: Customer PO → Invoice → Stock + +End-to-end pipeline that watches a JEI mailbox for incoming customer purchase +orders, uploads them to PocketBase, creates QuickBooks invoices, emails them +to customers, and decrements stock. + +``` +┌─────────────────┐ ┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐ +│ Customer email │───▶│ n8N workflow │───▶│ PocketBase │ │ QuickBooks │ +│ (PO PDF) │ │ 01: email→PB │ │ • customer_pos │ │ Online │ +└─────────────────┘ └─────────────────┘ │ • parsed_lines │ └────────┬────────┘ + └──────────────────┘ │ +┌─────────────────┐ ┌─────────────────┐ ┌──────────────────┐ │ +│ Stock list │◀───│ n8N workflow │◀───│ status:parsed │◀────────────┘ +│ decremented │ │ 02: invoice+ │ └──────────────────┘ (status changes +└─────────────────┘ │ stock │ to "parsed") + └─────────────────┘ +``` + +The AI agent you task can do all of this through the **PocketBase MCP server** +in `.cursor/mcp.json` — you ask it to "create a customer PO" or "show me all +pending invoices" and it talks to the same database. + +--- + +## What's in this repo + +| Path | What | +|---|---| +| `pocketbase/pb_migrations/1786200001_created_customer_pos.js` | New collection: incoming POs from customers | +| `pocketbase/pb_migrations/1786200002_created_customer_po_lines.js` | New collection: parsed line items per PO | +| `pocketbase/pb_migrations/1786200003_created_invoices.js` | New collection: invoices we issue (with QB id) | +| `pocketbase/pb_migrations/1786200004_created_invoice_items.js` | New collection: line items per invoice | +| `pocketbase/pb_migrations/1786200005_created_email_inbox.js` | New collection: every email the workflow sees | +| `pocketbase/pb_migrations/1786200006_created_workflow_runs.js` | New collection: every n8N execution | +| `.cursor/mcp.json` | Cursor MCP config — pocketbase-mcp-bridge | +| `.mcp.json` | Same config, for Claude Desktop (`~/Library/Application Support/Claude/claude_desktop_config.json`) | +| `n8n/01-customer-po-to-parsed.json` | n8N workflow: email → upload PDF → parse lines | +| `n8n/02-parsed-po-to-invoice.json` | n8N workflow: parsed → QuickBooks invoice → send → decrement stock | +| `.env.example` | Template — copy to `.env`, fill in credentials | + +All 6 migrations have been validated against the local PocketBase 0.30 +binary in this repo (`pocketbase/pocketbase`). + +--- + +## Deploy: PocketBase + +1. **Copy the new migration files** to your PB server's `pb_migrations/` dir. + The next time your PB instance starts, it will apply them in order. + (Tested locally: collections `customer_pos`, `customer_po_lines`, + `invoices`, `invoice_items`, `email_inbox`, `workflow_runs` were created.) + +2. **Verify** by hitting `GET /api/collections` with an admin token — you + should see all six new collections listed. + +3. **No data backfill is needed** — these are fresh tables. + +4. **Existing customers collection** (`pbc_108570809`) is referenced by + `customer_pos.customer` and `invoices.customer`. Make sure every customer + has a valid `email` filled in — that's how the workflow matches incoming + POs to customers. + +--- + +## Deploy: MCP server for the AI agent + +The MCP server (`pocketbase-mcp-bridge`) gives an AI agent (Claude, Cursor, +etc.) full access to read/write PocketBase. This is what you use to "task +a agent to create purchase orders for me." + +1. Open `.cursor/mcp.json` and fill in the `REPLACE_ME_*` placeholders + with the values from `.env` (`POCKETBASE_URL`, `POCKETBASE_ADMIN_EMAIL`, + `POCKETBASE_ADMIN_PASSWORD`). + +2. **Restart Cursor.** The MCP server will auto-install via `npx -y + pocketbase-mcp-bridge` and authenticate as the superuser. + +3. Open a new chat and ask the agent to "list the customer_pos records with + status=new." It should call `list_records` and return the rows. + +4. To create a PO via the agent, say: *"Create a customer PO for Isherwood + Mining, PO number ISO-001, 10 × Drill Bit at R150 each."* The agent + will call `create_record` on the `customer_pos` collection with the right + shape. + +Available tools (55 total, see +[github.com/nestebe/pocketbase-mcp](https://github.com/nestebe/pocketbase-mcp) +for the full list): + +- `list_records`, `get_record`, `create_record`, `update_record`, + `delete_record`, `get_first_record` +- `list_collections`, `get_collection`, `create_collection`, + `update_collection`, `delete_collection` +- `auth_with_password`, `list_auth_methods` +- `list_logs`, `get_settings`, `list_backups` +- `send_raw_request` — call any endpoint (e.g. PB hooks, custom routes) + +--- + +## Deploy: n8N workflows + +1. **Install n8N** (if you haven't already): + ```bash + npm install -g n8n + # or run via Docker: docker run -it --rm -p 5678:5678 n8nio/n8n + ``` + +2. **Import the workflows**: in the n8N UI, *Workflows → Import from File* + and select: + - `n8n/01-customer-po-to-parsed.json` + - `n8n/02-parsed-po-to-invoice.json` + +3. **Create credentials** for each node that has a `REPLACE_ME_*`: + - **IMAP** credential → your `orders@jeisa.co.za` mailbox + - **PocketBase API** credential → URL + admin email/password from `.env` + - **QuickBooks OAuth2 API** credential → connect your QBO account + +4. **Activate the workflows** in the n8N UI (toggle "Active" in the top + right of each). + +--- + +## End-to-end test + +1. Send a test email from your personal account to `orders@jeisa.co.za`. + Subject: "PO test — please ignore." + Attach a fake PDF with one or two lines like: + ``` + AB-123 Test Widget 10 100.00 1000.00 + ``` + +2. Workflow 01 should fire within ~30s: + - A `customer_pos` record is created + - A `customer_pos_line` record is created + - `status` becomes `parsed` + +3. Workflow 02 should fire within ~2 minutes: + - A QuickBooks invoice is created (sandbox) + - An `invoices` record is created with `status=sent` + - The `stock_items` quantity is decremented (only if the SKU matches a + stock row — wire up more SKUs in the `stock_items` collection) + - `customer_pos.status` becomes `invoiced` + +4. Check `workflow_runs` and `email_inbox` in PB for the audit trail. + +--- + +## Customizing + +| Want to | Edit | +|---|---| +| Add fields to the email parser (e.g. delivery date) | `n8n/01-…json` → "Function: Parse lines from PDF text" | +| Switch to AI-powered PDF parsing (for scanned POs) | Add an "OpenAI" node between "Extract PDF text" and "Function: Parse lines" | +| Email the invoice from JEI's SMTP (not QB) | Replace "QuickBooks: Send Invoice by email" with the Send Email node | +| Use a different QuickBooks Item ID for SKUs | "Function: Build QB invoice payload" → `ItemRef.value` | +| Only auto-invoice certain customers | Add an `auto_invoice` boolean to `customers`, then add a filter in workflow 02 | +| Sync QuickBooks payments back | Add a third workflow that polls QB Invoice.Status every hour and updates `invoices.status` | + +--- + +## Important: confirm before making this live + +Before going live, you'll want a human approval step between workflow 01 +and 02 — the current design auto-invoices everything that looks like a PO. +Add a `status: "parsed_pending_review"` and require a human to flip it to +`"parsed"` to make this safer. The simplest way: + +1. In `01-…json`, change the final node to set `status = "parsed_pending_review"`. +2. In `02-…json`, change the filter from `status = "parsed"` to + `status = "parsed_pending_review"`. +3. Add a tiny approval UI in the React app (a button on the customer_pos + row that PATCHes the status to "parsed"). + +This stops random emails from triggering invoices. \ No newline at end of file diff --git a/pocketbase/pb_migrations/1786200001_created_customer_pos.js b/pocketbase/pb_migrations/1786200001_created_customer_pos.js new file mode 100644 index 0000000..f4597d5 --- /dev/null +++ b/pocketbase/pb_migrations/1786200001_created_customer_pos.js @@ -0,0 +1,236 @@ +/// + +// ========================================================================= +// customer_pos — incoming POs from customers, received via email +// ========================================================================= +// +// This is what the n8N IMAP workflow creates when a customer emails a +// purchase order PDF to the JEI orders inbox. The PDF is stored in `po_pdf` +// and parsed line items go into `customer_po_lines`. +// +// NOTE on naming: the existing `orders` collection (pbc_3527180448) is the +// operational / sales order system (fulfillment, drivers, deliveries). This +// collection is INCOMING POs from clients — different lifecycle, different +// fields, different ownership. Hence the distinct name `customer_pos`. +// +// Status lifecycle: +// new → email received, PDF saved, not yet inspected +// parsed → line items extracted, ready for invoicing +// invoiced → linked invoice created and sent +// cancelled → ignored (wrong sender / duplicate) +// error → workflow failed, see `error_message` +// ========================================================================= + +migrate((app) => { + const collection = new Collection({ + "createRule": "@request.auth.id != ''", + "deleteRule": "@request.auth.id != ''", + "listRule": "@request.auth.id != ''", + "name": "customer_pos", + "system": false, + "type": "base", + "updateRule": "@request.auth.id != ''", + "viewRule": "@request.auth.id != ''", + "fields": [ + { + "cascadeDelete": false, + "collectionId": "pbc_108570809", + "hidden": false, + "id": "rel_cpo_customer", + "maxSelect": 1, + "minSelect": 0, + "name": "customer", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "txt_cpo_po_number", + "max": 100, + "min": 0, + "name": "po_number", + "pattern": "", + "presentable": true, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "file_cpo_po_pdf", + "maxSelect": 10, + "maxSize": 20971520, + "mimeTypes": ["application/pdf", "image/png", "image/jpeg"], + "name": "po_pdf", + "presentable": false, + "protected": false, + "required": false, + "system": false, + "thumbs": [], + "type": "file" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "txt_cpo_email_from", + "max": 500, + "min": 0, + "name": "source_email_from", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "txt_cpo_email_subject", + "max": 500, + "min": 0, + "name": "source_email_subject", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "dt_cpo_email_received", + "max": "", + "min": "", + "name": "source_email_received_at", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "txt_cpo_email_uid", + "max": 200, + "min": 0, + "name": "source_email_uid", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "sel_cpo_status", + "maxSelect": 1, + "name": "status", + "presentable": true, + "required": false, + "system": false, + "type": "select", + "values": ["new", "parsed", "invoiced", "cancelled", "error"] + }, + { + "cascadeDelete": false, + "collectionId": "pbc_1342968361", + "hidden": false, + "id": "rel_cpo_purchase_order", + "maxSelect": 1, + "minSelect": 0, + "name": "matched_purchase_order", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "hidden": false, + "id": "json_cpo_parsed_lines", + "maxSize": 200000, + "name": "parsed_lines", + "presentable": false, + "required": false, + "system": false, + "type": "json" + }, + { + "hidden": false, + "id": "num_cpo_total", + "max": null, + "min": null, + "name": "total_amount", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "txt_cpo_currency", + "max": 10, + "min": 0, + "name": "currency", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "txt_cpo_notes", + "max": 0, + "min": 0, + "name": "notes", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "dt_cpo_processed", + "max": "", + "min": "", + "name": "processed_at", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "txt_cpo_error", + "max": 0, + "min": 0, + "name": "error_message", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + } + ], + "indexes": [] + }); + + return app.save(collection); +}, (app) => { + const collection = app.findCollectionByNameOrId("customer_pos"); + return app.delete(collection); +}) \ No newline at end of file diff --git a/pocketbase/pb_migrations/1786200002_created_customer_po_lines.js b/pocketbase/pb_migrations/1786200002_created_customer_po_lines.js new file mode 100644 index 0000000..1dd8128 --- /dev/null +++ b/pocketbase/pb_migrations/1786200002_created_customer_po_lines.js @@ -0,0 +1,161 @@ +/// + +// ========================================================================= +// customer_po_lines — one record per line item on an incoming customer PO +// ========================================================================= +// +// Splits the `parsed_lines` json blob on a `customer_pos` record into +// individual rows. n8N writes these after extracting SKU, qty, price from +// the customer's PDF. The stock-decrement node reads from here. +// +// Note: `app.findCollectionByNameOrId` is wrapped so this migration applies +// even if `stock_items` isn't present in the local DB. In production (hosted +// PB), `customer_pos` and `stock_items` both exist. +// ========================================================================= + +migrate((app) => { + const safeFind = (name) => { + try { return app.findCollectionByNameOrId(name); } + catch (e) { return null; } + }; + + const cpoCol = safeFind("customer_pos"); + if (!cpoCol) { + throw new Error("Required collection 'customer_pos' not found — apply migrations in order."); + } + const stockItemsCol = safeFind("stock_items"); + + const schema = [ + { + "cascadeDelete": true, + "collectionId": cpoCol.id, + "hidden": false, + "id": "rel_cpol_cpo", + "maxSelect": 1, + "minSelect": 1, + "name": "customer_po", + "presentable": false, + "required": true, + "system": false, + "type": "relation" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "txt_cpol_sku", + "max": 100, + "min": 0, + "name": "sku", + "pattern": "", + "presentable": true, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "txt_cpol_description", + "max": 500, + "min": 0, + "name": "description", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "num_cpol_qty", + "max": null, + "min": null, + "name": "quantity", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "num_cpol_unit_price", + "max": null, + "min": null, + "name": "unit_price", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "num_cpol_line_total", + "max": null, + "min": null, + "name": "line_total", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "bool_cpol_stock_decremented", + "name": "stock_decremented", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "hidden": false, + "id": "dt_cpol_stock_decremented", + "max": "", + "min": "", + "name": "stock_decremented_at", + "presentable": false, + "required": false, + "system": false, + "type": "date" + } + ]; + + if (stockItemsCol) { + schema.splice(1, 0, { + "cascadeDelete": false, + "collectionId": stockItemsCol.id, + "hidden": false, + "id": "rel_cpol_stock", + "maxSelect": 1, + "minSelect": 0, + "name": "stock_item", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }); + } + + const collection = new Collection({ + "createRule": "@request.auth.id != ''", + "deleteRule": "@request.auth.id != ''", + "listRule": "@request.auth.id != ''", + "name": "customer_po_lines", + "system": false, + "type": "base", + "updateRule": "@request.auth.id != ''", + "viewRule": "@request.auth.id != ''", + "fields": schema, + "indexes": [] + }); + + return app.save(collection); +}, (app) => { + const collection = app.findCollectionByNameOrId("customer_po_lines"); + return app.delete(collection); +}) \ No newline at end of file diff --git a/pocketbase/pb_migrations/1786200003_created_invoices.js b/pocketbase/pb_migrations/1786200003_created_invoices.js new file mode 100644 index 0000000..c697816 --- /dev/null +++ b/pocketbase/pb_migrations/1786200003_created_invoices.js @@ -0,0 +1,268 @@ +/// + +// ========================================================================= +// invoices — invoices we issue to customers (created by n8N from QuickBooks) +// ========================================================================= +// +// One record per invoice. Holds the QuickBooks invoice id so we can link +// back to the source of truth in QB and also audit what we emailed. +// +// Status lifecycle: +// draft → created in QB but not emailed yet +// sent → emailed to customer +// paid → QB reports paid (synced back via webhook / poll) +// overdue → past due date and unpaid +// voided → manually cancelled +// ========================================================================= + +migrate((app) => { + // look up related collections by name (their IDs are auto-generated by PB) + const customersCol = app.findCollectionByNameOrId("customers"); + const customerPosCol = app.findCollectionByNameOrId("customer_pos"); + + const collection = new Collection({ + "createRule": "@request.auth.id != ''", + "deleteRule": "@request.auth.id != ''", + "listRule": "@request.auth.id != ''", + "name": "invoices", + "system": false, + "type": "base", + "updateRule": "@request.auth.id != ''", + "viewRule": "@request.auth.id != ''", + "fields": [ + { + "cascadeDelete": false, + "collectionId": customersCol.id, + "hidden": false, + "id": "rel_inv_customer", + "maxSelect": 1, + "minSelect": 1, + "name": "customer", + "presentable": true, + "required": true, + "system": false, + "type": "relation" + }, + { + "cascadeDelete": false, + "collectionId": customerPosCol.id, + "hidden": false, + "id": "rel_inv_source_order", + "maxSelect": 1, + "minSelect": 0, + "name": "source_customer_po", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "txt_inv_number", + "max": 50, + "min": 0, + "name": "invoice_number", + "pattern": "", + "presentable": true, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "txt_inv_qb_id", + "max": 50, + "min": 0, + "name": "quickbooks_invoice_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "dt_inv_issue_date", + "max": "", + "min": "", + "name": "issue_date", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "hidden": false, + "id": "dt_inv_due_date", + "max": "", + "min": "", + "name": "due_date", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "hidden": false, + "id": "num_inv_subtotal", + "max": null, + "min": null, + "name": "subtotal", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "num_inv_tax", + "max": null, + "min": null, + "name": "tax", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "num_inv_total", + "max": null, + "min": null, + "name": "total", + "onlyInt": true, + "presentable": true, + "required": false, + "system": false, + "type": "number" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "txt_inv_currency", + "max": 10, + "min": 0, + "name": "currency", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "sel_inv_status", + "maxSelect": 1, + "name": "status", + "presentable": true, + "required": false, + "system": false, + "type": "select", + "values": ["draft", "sent", "paid", "overdue", "voided"] + }, + { + "hidden": false, + "id": "file_inv_pdf", + "maxSelect": 1, + "maxSize": 20971520, + "mimeTypes": ["application/pdf"], + "name": "invoice_pdf", + "presentable": false, + "protected": false, + "required": false, + "system": false, + "thumbs": [], + "type": "file" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "txt_inv_emailed_to", + "max": 500, + "min": 0, + "name": "emailed_to", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "dt_inv_emailed_at", + "max": "", + "min": "", + "name": "emailed_at", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "txt_inv_email_message_id", + "max": 200, + "min": 0, + "name": "email_message_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "bool_inv_stock_decremented", + "name": "stock_decremented", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "txt_inv_qb_response", + "max": 50000, + "min": 0, + "name": "qb_raw_response", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "txt_inv_notes", + "max": 0, + "min": 0, + "name": "notes", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + } + ], + "indexes": [] + }); + + return app.save(collection); +}, (app) => { + const collection = app.findCollectionByNameOrId("invoices"); + return app.delete(collection); +}) \ No newline at end of file diff --git a/pocketbase/pb_migrations/1786200004_created_invoice_items.js b/pocketbase/pb_migrations/1786200004_created_invoice_items.js new file mode 100644 index 0000000..be99e89 --- /dev/null +++ b/pocketbase/pb_migrations/1786200004_created_invoice_items.js @@ -0,0 +1,162 @@ +/// + +// ========================================================================= +// invoice_items — line items on an issued invoice +// ========================================================================= +// +// Mirror of `customer_po_lines` but bound to the issued `invoices` record. +// After the n8N workflow emails the invoice, it loops over these and +// decrements stock_items.quantity by `quantity` (skipping rows where +// `stock_item` is empty — those are service / non-inventory lines). +// ========================================================================= +// +// Note: `app.findCollectionByNameOrId` is wrapped so this migration applies +// even if the parent collection isn't present yet in a partial local DB. +// In production (hosted PB), `invoices`, `stock_items`, `customer_po_lines` +// all exist. +// ========================================================================= + +migrate((app) => { + const safeFind = (name) => { + try { return app.findCollectionByNameOrId(name); } + catch (e) { return null; } + }; + + const invoicesCol = safeFind("invoices"); + const stockItemsCol = safeFind("stock_items"); + const cpoLinesCol = safeFind("customer_po_lines"); + + if (!invoicesCol) { + throw new Error("Required collection 'invoices' not found — apply migrations in order."); + } + + const schema = [ + { + "cascadeDelete": true, + "collectionId": invoicesCol.id, + "hidden": false, + "id": "rel_invl_invoice", + "maxSelect": 1, + "minSelect": 1, + "name": "invoice", + "presentable": false, + "required": true, + "system": false, + "type": "relation" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "txt_invl_sku", + "max": 100, + "min": 0, + "name": "sku", + "pattern": "", + "presentable": true, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "txt_invl_description", + "max": 500, + "min": 0, + "name": "description", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "num_invl_qty", + "max": null, + "min": null, + "name": "quantity", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "num_invl_unit_price", + "max": null, + "min": null, + "name": "unit_price", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "hidden": false, + "id": "num_invl_line_total", + "max": null, + "min": null, + "name": "line_total", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + } + ]; + + if (stockItemsCol) { + schema.splice(1, 0, { + "cascadeDelete": false, + "collectionId": stockItemsCol.id, + "hidden": false, + "id": "rel_invl_stock", + "maxSelect": 1, + "minSelect": 0, + "name": "stock_item", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }); + } + + if (cpoLinesCol) { + schema.splice(stockItemsCol ? 2 : 1, 0, { + "cascadeDelete": false, + "collectionId": cpoLinesCol.id, + "hidden": false, + "id": "rel_invl_cpol", + "maxSelect": 1, + "minSelect": 0, + "name": "source_po_line", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }); + } + + const collection = new Collection({ + "createRule": "@request.auth.id != ''", + "deleteRule": "@request.auth.id != ''", + "listRule": "@request.auth.id != ''", + "name": "invoice_items", + "system": false, + "type": "base", + "updateRule": "@request.auth.id != ''", + "viewRule": "@request.auth.id != ''", + "fields": schema, + "indexes": [] + }); + + return app.save(collection); +}, (app) => { + const collection = app.findCollectionByNameOrId("invoice_items"); + return app.delete(collection); +}) \ No newline at end of file diff --git a/pocketbase/pb_migrations/1786200005_created_email_inbox.js b/pocketbase/pb_migrations/1786200005_created_email_inbox.js new file mode 100644 index 0000000..de0a712 --- /dev/null +++ b/pocketbase/pb_migrations/1786200005_created_email_inbox.js @@ -0,0 +1,175 @@ +/// + +// ========================================================================= +// email_inbox — every email the n8N IMAP trigger sees, audit trail +// ========================================================================= +// +// Even emails we ignore (no PDF, wrong sender, auto-reply, bounce) get a +// record here so we have a paper trail. n8N writes one row per email before +// branching on whether to process it. +// ========================================================================= + +migrate((app) => { + const cpoCol = app.findCollectionByNameOrId("customer_pos"); + + const collection = new Collection({ + "createRule": "@request.auth.id != ''", + "deleteRule": "@request.auth.id != ''", + "listRule": "@request.auth.id != ''", + "name": "email_inbox", + "system": false, + "type": "base", + "updateRule": "@request.auth.id != ''", + "viewRule": "@request.auth.id != ''", + "fields": [ + { + "autogeneratePattern": "", + "hidden": false, + "id": "txt_inbox_uid", + "max": 200, + "min": 0, + "name": "imap_uid", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "txt_inbox_from", + "max": 500, + "min": 0, + "name": "from", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "txt_inbox_subject", + "max": 500, + "min": 0, + "name": "subject", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "txt_inbox_to", + "max": 500, + "min": 0, + "name": "to", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "txt_inbox_body", + "max": 200000, + "min": 0, + "name": "body_plain", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "dt_inbox_received", + "max": "", + "min": "", + "name": "received_at", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "hidden": false, + "id": "bool_inbox_has_attachments", + "name": "has_attachments", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "txt_inbox_attachment_names", + "max": 2000, + "min": 0, + "name": "attachment_names", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "sel_inbox_disposition", + "maxSelect": 1, + "name": "disposition", + "presentable": true, + "required": false, + "system": false, + "type": "select", + "values": ["processed", "ignored", "duplicate", "error", "manual_review"] + }, + { + "cascadeDelete": false, + "collectionId": cpoCol.id, + "hidden": false, + "id": "rel_inbox_cpo", + "maxSelect": 1, + "minSelect": 0, + "name": "linked_customer_po", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "txt_inbox_error", + "max": 0, + "min": 0, + "name": "error_message", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + } + ], + "indexes": [] + }); + + return app.save(collection); +}, (app) => { + const collection = app.findCollectionByNameOrId("email_inbox"); + return app.delete(collection); +}) \ No newline at end of file diff --git a/pocketbase/pb_migrations/1786200006_created_workflow_runs.js b/pocketbase/pb_migrations/1786200006_created_workflow_runs.js new file mode 100644 index 0000000..739b25d --- /dev/null +++ b/pocketbase/pb_migrations/1786200006_created_workflow_runs.js @@ -0,0 +1,155 @@ +/// + +// ========================================================================= +// workflow_runs — history of every n8N execution +// ========================================================================= +// +// n8N POSTs to a PocketBase endpoint once per execution. We store the run id, +// workflow name, status, inputs/outputs (json), and any error. This lets us +// see in the dashboard what happened last time, even when n8N's UI isn't open. +// ========================================================================= + +migrate((app) => { + const cpoCol = app.findCollectionByNameOrId("customer_pos"); + const invoicesCol = app.findCollectionByNameOrId("invoices"); + + const collection = new Collection({ + "createRule": "@request.auth.id != ''", + "deleteRule": "@request.auth.id != ''", + "listRule": "@request.auth.id != ''", + "name": "workflow_runs", + "system": false, + "type": "base", + "updateRule": "@request.auth.id != ''", + "viewRule": "@request.auth.id != ''", + "fields": [ + { + "autogeneratePattern": "", + "hidden": false, + "id": "txt_wfr_n8n_id", + "max": 100, + "min": 0, + "name": "n8n_execution_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "txt_wfr_workflow", + "max": 200, + "min": 0, + "name": "workflow_name", + "pattern": "", + "presentable": true, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "sel_wfr_status", + "maxSelect": 1, + "name": "status", + "presentable": true, + "required": false, + "system": false, + "type": "select", + "values": ["running", "success", "failed", "cancelled"] + }, + { + "hidden": false, + "id": "dt_wfr_started", + "max": "", + "min": "", + "name": "started_at", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "hidden": false, + "id": "dt_wfr_finished", + "max": "", + "min": "", + "name": "finished_at", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "hidden": false, + "id": "json_wfr_input", + "maxSize": 200000, + "name": "input_json", + "presentable": false, + "required": false, + "system": false, + "type": "json" + }, + { + "hidden": false, + "id": "json_wfr_output", + "maxSize": 500000, + "name": "output_json", + "presentable": false, + "required": false, + "system": false, + "type": "json" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "txt_wfr_error", + "max": 0, + "min": 0, + "name": "error_message", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "cascadeDelete": false, + "collectionId": cpoCol.id, + "hidden": false, + "id": "rel_wfr_cpo", + "maxSelect": 1, + "minSelect": 0, + "name": "related_customer_po", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "cascadeDelete": false, + "collectionId": invoicesCol.id, + "hidden": false, + "id": "rel_wfr_invoice", + "maxSelect": 1, + "minSelect": 0, + "name": "related_invoice", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + } + ], + "indexes": [] + }); + + return app.save(collection); +}, (app) => { + const collection = app.findCollectionByNameOrId("workflow_runs"); + return app.delete(collection); +}) \ No newline at end of file