WFDASH-WEBAPP/n8n_workfows/WFDASH — Purchase Order Send.js
SJCoetz 7f924ccc45 refactor: switch hardcoded UI colors to semantic theme tokens
Overhaul the app's color system by replacing all hardcoded Tailwind color classes with semantic theme variables to ensure consistent theming across all components, pages, and tests.

Additional changes:
- Add assigned date support for order item status updates: add `assignedDate` field to the order item status PATCH API, implement server-side normalization/validation, and add corresponding tests
- Refactor purchase order PDF workflows to use the consolidated `purchase_orders.documents` field instead of the legacy separate `purchase_order_pdfs` collection
- Fix quote number parsing and formatting to support both dashed (Q-YYYY-MM-NNN) and dashless (QYYYYMMNNN) formats
- Rename `driver_pool` item status to `collection_pool` across the codebase
- Add null handling for order item quantities and pricing to display "—" for missing values
- Improve filter tab styling: make mobile tabs scrollable, prevent text wrapping, hide scrollbar
- Add new utility scripts, API routes, migrations, and polish small UI/UX fixes including tsconfig updates and theme hook improvements
2026-07-07 17:22:03 +02:00

238 lines
No EOL
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);