238 lines
8 KiB
JavaScript
238 lines
8 KiB
JavaScript
|
|
/**
|
||
|
|
* WFDASH — Purchase Order Outbound Send (Manual / Webhook Trigger)
|
||
|
|
*
|
||
|
|
* Operator-triggered PO send. Fires from Next.js
|
||
|
|
* `/api/v1/purchase-orders/[id]/send-email` after the route validates,
|
||
|
|
* persists the composition, resolves the current PO PDF, and builds
|
||
|
|
* the full send payload (recipient + subject + body + PDF
|
||
|
|
* attachment URL).
|
||
|
|
*
|
||
|
|
* Companion workflow `WFDASH — Purchase Order Send (Cron).js` handles
|
||
|
|
* the scheduled 15-minute pass for catching missed sends.
|
||
|
|
*
|
||
|
|
* Inputs (POST body) — every field is pushed by the dashboard route,
|
||
|
|
* n8n does NOT need to read from PocketBase directly:
|
||
|
|
* - trigger: 'manual_send' (informational, surfaced in logs)
|
||
|
|
* - poId: string (required; used to POST the outcome back)
|
||
|
|
* - poNumber: string (used for email subject fallback + log line)
|
||
|
|
* - supplierName: string (used in the log line)
|
||
|
|
* - recipient: string (the resolved `to` — operator override or
|
||
|
|
* supplier.email fallback)
|
||
|
|
* - cc, bcc: comma-separated strings (optional)
|
||
|
|
* - subject: string
|
||
|
|
* - body: string (plain text or HTML — caller decides)
|
||
|
|
* - attachments: array of { filename, url, contentType } for the
|
||
|
|
* PDF proxy fetch (built by the route)
|
||
|
|
* - agentApiKey: string (Bearer token for the outcome callback)
|
||
|
|
* - dashboardBaseUrl: string (base URL the route lives at)
|
||
|
|
* - fromEmail: string (used as the From header)
|
||
|
|
*
|
||
|
|
* Outcome callback:
|
||
|
|
* The SMTP node runs and the workflow POSTs the result back to
|
||
|
|
* `/api/v1/purchase-orders/[id]/send-email/outcome` with
|
||
|
|
* `{outcome, messageId?, errorMessage?}`. The dashboard route owns
|
||
|
|
* the canonical write-back (status flip + order_items item_status
|
||
|
|
* flip) using its own admin PB auth. n8n never carries an admin
|
||
|
|
* token.
|
||
|
|
*
|
||
|
|
* Why push-payload instead of read-from-PB:
|
||
|
|
* n8n-worker runs in a separate container from PB. PB is behind a
|
||
|
|
* Coolify reverse proxy that only forwards a specific Host header;
|
||
|
|
* the worker container can't reach it directly. By pushing the
|
||
|
|
* entire render-ready payload from the route (which does live near
|
||
|
|
* PB), the workflow stays free of DNS/HTTP plumbing entirely.
|
||
|
|
*
|
||
|
|
* Reply tracking is OUT OF SCOPE for this version. When the IMAP
|
||
|
|
* poller is built it will write inbound messages to a future
|
||
|
|
* `purchase_order_replies` collection, keyed on
|
||
|
|
* `in_reply_to == purchase_orders.email_message_id`.
|
||
|
|
*/
|
||
|
|
import { workflow, trigger, node } from '@n8n/workflow-sdk';
|
||
|
|
|
||
|
|
const webhook = trigger({
|
||
|
|
type: 'n8n-nodes-base.webhook',
|
||
|
|
version: 2,
|
||
|
|
config: {
|
||
|
|
name: 'PO Send Webhook',
|
||
|
|
parameters: {
|
||
|
|
httpMethod: 'POST',
|
||
|
|
path: 'wf-po-send',
|
||
|
|
responseMode: 'lastNode',
|
||
|
|
options: {},
|
||
|
|
},
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
const sendEmail = node({
|
||
|
|
type: 'n8n-nodes-base.emailSend',
|
||
|
|
version: 2.1,
|
||
|
|
config: {
|
||
|
|
name: 'Send PO email (SMTP)',
|
||
|
|
parameters: {
|
||
|
|
fromEmail: '={{ $json.body.fromEmail || "orders@wfdash.local" }}',
|
||
|
|
toEmail: '={{ $json.body.recipient }}',
|
||
|
|
ccEmail: '={{ $json.body.cc }}',
|
||
|
|
bccEmail: '={{ $json.body.bcc }}',
|
||
|
|
subject: '={{ $json.body.subject }}',
|
||
|
|
text: '={{ $json.body.body }}',
|
||
|
|
html: '={{ $json.body.body }}',
|
||
|
|
options: {
|
||
|
|
// The Fetch PDF bytes step populates `$json.attachmentsResolved`
|
||
|
|
// with `{ name, content (base64), type }` entries. Read it as an
|
||
|
|
// expression so the resolved PDF actually attaches.
|
||
|
|
attachments:
|
||
|
|
'={{ $json.attachmentsResolved && $json.attachmentsResolved.length ? $json.attachmentsResolved : [] }}',
|
||
|
|
},
|
||
|
|
},
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Resolve any attachment URLs into `{ name, content (base64), type }`
|
||
|
|
* entries the n8n emailSend node accepts. Returns the original input
|
||
|
|
* shape unchanged when there are no attachments.
|
||
|
|
*
|
||
|
|
* n8n Code node: no `fetch` global. Use this.helpers.httpRequest().
|
||
|
|
*/
|
||
|
|
const fetchAttachments = node({
|
||
|
|
type: 'n8n-nodes-base.code',
|
||
|
|
version: 2,
|
||
|
|
config: {
|
||
|
|
name: 'Fetch PDF attachment bytes',
|
||
|
|
parameters: {
|
||
|
|
jsCode: `// Resolve any attachment URLs into { name, content (base64), type }
|
||
|
|
// entries the n8n emailSend node accepts. Returns the original input
|
||
|
|
// shape unchanged when there are no attachments.
|
||
|
|
//
|
||
|
|
// n8n Code node: no $env.* access in this environment
|
||
|
|
// (N8N_BLOCK_ENV_ACCESS_IN_NODE=true in docker-compose). The dashboard
|
||
|
|
// route pushes everything we need (incl. the agentApiKey Bearer
|
||
|
|
// credential) on the webhook body. The file proxy whitelist now
|
||
|
|
// includes purchase_orders so the GET succeeds without further
|
||
|
|
// auth headers from the cookie-less n8n-worker.
|
||
|
|
const first = ($input.first() || {}).json || {};
|
||
|
|
const body = first.body || first;
|
||
|
|
const attachments = body.attachments || [];
|
||
|
|
const agentApiKey = body.agentApiKey || '';
|
||
|
|
|
||
|
|
if (!Array.isArray(attachments) || attachments.length === 0) {
|
||
|
|
return [{ json: { ...first, body: { ...body, attachments: [] }, attachmentsResolved: [] } }];
|
||
|
|
}
|
||
|
|
|
||
|
|
const resolved = [];
|
||
|
|
for (const att of attachments) {
|
||
|
|
try {
|
||
|
|
const res = await this.helpers.httpRequest({
|
||
|
|
method: 'GET',
|
||
|
|
url: att.url,
|
||
|
|
headers: agentApiKey
|
||
|
|
? { 'Authorization': 'Bearer ' + agentApiKey }
|
||
|
|
: {},
|
||
|
|
encoding: 'base64',
|
||
|
|
returnBinary: false,
|
||
|
|
timeout: 15000,
|
||
|
|
});
|
||
|
|
resolved.push({
|
||
|
|
name: att.filename || 'attachment.pdf',
|
||
|
|
content: typeof res === 'string' ? res : (res && res.data) || '',
|
||
|
|
type: att.contentType || 'application/pdf',
|
||
|
|
});
|
||
|
|
} catch (err) {
|
||
|
|
return [{ json: {
|
||
|
|
...first,
|
||
|
|
body: {
|
||
|
|
...body,
|
||
|
|
attachments: [],
|
||
|
|
_attachmentFetchError: 'PDF fetch failed: ' + (err.message || String(err)),
|
||
|
|
},
|
||
|
|
attachmentsResolved: [],
|
||
|
|
} }];
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return [{ json: {
|
||
|
|
...first,
|
||
|
|
body: { ...body, attachments: [] },
|
||
|
|
attachmentsResolved: resolved,
|
||
|
|
} }];
|
||
|
|
`,
|
||
|
|
},
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
const recordOutcome = node({
|
||
|
|
type: 'n8n-nodes-base.code',
|
||
|
|
version: 2,
|
||
|
|
config: {
|
||
|
|
name: 'Record outcome to PB',
|
||
|
|
parameters: {
|
||
|
|
jsCode: `// Resolve the SMTP result from the Send PO email (SMTP) node, then
|
||
|
|
// POST the outcome back to the dashboard's
|
||
|
|
// /api/v1/purchase-orders/[id]/send-email/outcome route. The dashboard
|
||
|
|
// owns the canonical write (status flip + order_items.item_status flip)
|
||
|
|
// using its own admin PB auth. n8n never carries the admin token.
|
||
|
|
//
|
||
|
|
// The Bearer key and dashboard base URL are pushed on the webhook body
|
||
|
|
// (not $env.*) because the docker-compose has
|
||
|
|
// N8N_BLOCK_ENV_ACCESS_IN_NODE=true — Code nodes cannot read env vars
|
||
|
|
// at runtime.
|
||
|
|
const first = ($input.first() || {}).json || {};
|
||
|
|
const body = first.body || first;
|
||
|
|
const agentApiKey = body.agentApiKey || '';
|
||
|
|
const dashboardBase = (body.dashboardBaseUrl || '').replace(/\\/$/, '');
|
||
|
|
const poId = body.poId;
|
||
|
|
|
||
|
|
if (!agentApiKey) {
|
||
|
|
return [{ json: {
|
||
|
|
poId,
|
||
|
|
ok: false,
|
||
|
|
error: 'agentApiKey missing from webhook body — route did not push it',
|
||
|
|
} }];
|
||
|
|
}
|
||
|
|
if (!dashboardBase) {
|
||
|
|
return [{ json: {
|
||
|
|
poId,
|
||
|
|
ok: false,
|
||
|
|
error: 'dashboardBaseUrl missing from webhook body — route did not push it',
|
||
|
|
} }];
|
||
|
|
}
|
||
|
|
|
||
|
|
const sendResult = ($('Send PO email (SMTP)').first().json) || {};
|
||
|
|
const ok = !sendResult.error && sendResult.ok !== false;
|
||
|
|
const messageId = sendResult.messageId || sendResult.message_id || '';
|
||
|
|
const errorMessage = ok ? '' : (sendResult.error || sendResult.response || 'Unknown SMTP error').toString().slice(0, 990);
|
||
|
|
|
||
|
|
try {
|
||
|
|
const out = await this.helpers.httpRequest({
|
||
|
|
method: 'POST',
|
||
|
|
url: dashboardBase + '/api/v1/purchase-orders/' + poId + '/send-email/outcome',
|
||
|
|
headers: {
|
||
|
|
'Content-Type': 'application/json',
|
||
|
|
'Authorization': 'Bearer ' + agentApiKey,
|
||
|
|
},
|
||
|
|
json: true,
|
||
|
|
body: {
|
||
|
|
outcome: ok ? 'success' : 'failure',
|
||
|
|
messageId,
|
||
|
|
errorMessage,
|
||
|
|
},
|
||
|
|
});
|
||
|
|
return [{ json: { poId, ok, messageId, error: errorMessage, outcome: out } }];
|
||
|
|
} catch (err) {
|
||
|
|
return [{ json: {
|
||
|
|
poId,
|
||
|
|
ok,
|
||
|
|
messageId,
|
||
|
|
error: 'Outcome callback failed: ' + (err.message || String(err)),
|
||
|
|
} }];
|
||
|
|
}
|
||
|
|
`,
|
||
|
|
},
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
export default workflow('wf-po-send', 'WFDASH — Purchase Order Send')
|
||
|
|
.add(webhook)
|
||
|
|
.to(fetchAttachments)
|
||
|
|
.to(sendEmail)
|
||
|
|
.to(recordOutcome);
|