WFDASH-WEBAPP/n8n_workfows/WFDASH — Purchase Order Send.js

238 lines
8 KiB
JavaScript
Raw Normal View History

/**
* 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);