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