WFDASH-WEBAPP/n8n_workfows/WFDASH — Purchase Order Send (Cron).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

314 lines
9.8 KiB
JavaScript

/**
* WFDASH — Purchase Order Outbound Send (Cron / Scheduled Trigger)
*
* Companion to `wf-po-send` (webhook). Same execution chain, different
* trigger: every 15 minutes, scans for POs in `status="draft"` older
* than 15min and queues them for send.
*
* Why a cron pass when the manual trigger is synchronous:
* - Catches POs where the operator started the send modal and walked
* away (status got flipped but webhook never reached n8n).
* - Catches a webhook fire that hit a transient network blip.
* - Catches operators working from a different page where the
* modal-driven send isn't available.
*
* Filtering: only `status="draft"` rows are processed. Status flips to
* `sending`/`sent` by the in-flight path; cron doesn't re-send those.
*
* Cron still reads from PocketBase (only on the scan tick — every
* 15min — and only for the list + per-row fetch). The DNS-resolution
* flake we hit on the webhook path matters less here because a
* transient miss just rolls to the next tick. After fetch, the cron
* calls back into the same dashboard endpoints:
* 1. Dashboard route returns the same push-payload the manual path
* sends (recipient + subject + body + PDF attachment URL).
* 2. SMTP send happens.
* 3. Outcome callback posts to /send-email/outcome with the result.
*
* Auth: n8n carries AGENT_API_KEY (Bearer) + DASHBOARD_URL (env).
* n8n never holds the PocketBase admin token — that stays in the
* dashboard's process env.
*
* Reply tracking is OUT OF SCOPE for this version.
*/
import { workflow, trigger, node } from '@n8n/workflow-sdk';
const cron = trigger({
type: 'n8n-nodes-base.scheduleTrigger',
version: 1.2,
config: {
name: 'PO Send Cron (every 15min)',
parameters: {
rule: {
interval: [{ field: 'cronExpression', expression: '*/15 * * * *' }],
},
},
},
});
/**
* Scan PB for draft POs older than 15min, return IDs only. We use
* PB env (PB_URL + PB_ADMIN_TOKEN) for the scan because that's the
* bulk query that's too expensive to push from the dashboard. DNS
* issues here are self-healing: a missed scan rolls to the next tick.
*/
const scanDrafts = node({
type: 'n8n-nodes-base.code',
version: 2,
config: {
name: 'Scan for draft POs older than 15min',
parameters: {
jsCode: `// Scan PB for draft POs older than 15min. Emit one item per
// candidate. Per-PO payload is fetched downstream via the dashboard
// /api/v1/purchase-orders/[id]/dispatch endpoint (so we don't carry
// the admin token here).
//
// n8n Code node sandbox has no global 'fetch' — use
// this.helpers.httpRequest().
const pbUrl = ($env.PB_URL || '').replace(/\\/$/, '');
const pbToken = $env.PB_ADMIN_TOKEN || '';
const agentKey = $env.AGENT_API_KEY || '';
const dashboardBase = ($env.DASHBOARD_URL || '').replace(/\\/$/, '');
if (!dashboardBase || !agentKey) {
return [{ json: { skip: true, reason: 'DASHBOARD_URL or AGENT_API_KEY missing in env' } }];
}
if (!pbUrl || !pbToken) {
return [{ json: { skip: true, reason: 'PB_URL or PB_ADMIN_TOKEN missing in env (cold-start probe)' } }];
}
const cutoff = new Date(Date.now() - 15 * 60 * 1000).toISOString();
const filter = encodeURIComponent('status="draft" && created<"' + cutoff + '"');
let list;
try {
list = await this.helpers.httpRequest({
method: 'GET',
url: pbUrl + '/api/collections/purchase_orders/records?perPage=50&filter=' + filter,
headers: { 'Authorization': pbToken, 'Content-Type': 'application/json' },
json: true,
timeout: 8000,
});
} catch (err) {
return [{ json: { skip: true, reason: 'PB scan failed (DNS or auth): ' + (err.message || String(err)) } }];
}
const candidates = (list.items || []).map((p) => p.id);
if (candidates.length === 0) {
return [{ json: { skip: true, reason: 'No draft POs older than 15min' } }];
}
return candidates.map((poId) => ({
json: {
trigger: 'cron',
poId,
dashboardBase,
},
}));
`,
},
},
});
/**
* Per-PO dispatch: calls /api/v1/purchase-orders/[id]/dispatch which
* (a) runs the same pre-send validation + status flip as the manual
* path, (b) resolves the current PDF, (c) returns the push-payload.
* Returns `skip: { reason }` if the row is no longer in draft.
*/
const resolvePerPo = node({
type: 'n8n-nodes-base.code',
version: 2,
config: {
name: 'Resolve per-PO payload (cron path)',
parameters: {
jsCode: `// For each candidate poId, ask the dashboard to flip status
// "draft" → "sending" + return the same push-payload the manual path
// uses. If the row is no longer draft (race with manual flow), the
// dashboard returns { skip: { reason } } and we silently drop this
// item.
//
// n8n Code nodes don't expose fetch — use this.helpers.httpRequest().
const cur = ($('Scan for draft POs older than 15min').first().json || {});
const dashboardBase = (cur.dashboardBase || $env.DASHBOARD_URL || '').replace(/\\/$/, '');
const agentKey = $env.AGENT_API_KEY || '';
const poId = cur.poId;
if (!poId) {
return [{ json: { skip: true, reason: 'No poId in scan row' } }];
}
try {
const res = await this.helpers.httpRequest({
method: 'POST',
url: dashboardBase + '/api/v1/purchase-orders/' + poId + '/dispatch',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + agentKey,
},
json: true,
body: { trigger: 'cron' },
timeout: 10000,
});
if (res && res.skip) {
return [{ json: { skip: true, reason: res.skip.reason || 'dismissed by route', poId } }];
}
if (!res || !res.poId) {
return [{ json: { skip: true, reason: 'dispatch returned no payload', poId } }];
}
return [{ json: {
trigger: 'cron',
poId: res.poId,
body: res, // so the SMTP node reads $json.body.{recipient,subject,body,...}
} }];
} catch (err) {
return [{ json: { skip: true, reason: 'dispatch failed: ' + (err.message || String(err)), poId } }];
}
`,
},
},
});
const sendEmail = node({
type: 'n8n-nodes-base.emailSend',
version: 2.1,
config: {
name: 'Send PO email (SMTP)',
parameters: {
fromEmail: '={{ $env.PO_FROM_EMAIL || "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: {
attachments: [
// Filled in by the Fetch PDF bytes step below.
],
},
},
},
});
const fetchAttachments = node({
type: 'n8n-nodes-base.code',
version: 2,
config: {
name: 'Fetch PDF attachment bytes',
parameters: {
jsCode: `// Resolve attachment URLs into { name, content (base64), type }.
// Same shape as the webhook-path attachment node.
//
// n8n Code nodes don't expose fetch — use this.helpers.httpRequest().
const first = ($input.first() || {}).json || {};
const body = first.body || first;
const attachments = (body && body.attachments) || [];
if (!Array.isArray(attachments) || attachments.length === 0) {
return [{ json: { ...first, attachmentsResolved: [] } }];
}
const agentKey = $env.AGENT_API_KEY || '';
const resolved = [];
for (const att of attachments) {
try {
const res = await this.helpers.httpRequest({
method: 'GET',
url: att.url,
headers: agentKey ? { 'Authorization': 'Bearer ' + agentKey } : {},
encoding: 'base64',
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, attachmentsResolved: [], _attachmentFetchError: 'PDF fetch failed: ' + (err.message || String(err)) } }];
}
}
return [{ json: { ...first, attachmentsResolved: resolved } }];
`,
},
},
});
/**
* Same callback semantics as the webhook path: POST the SMTP outcome
* back to /api/v1/purchase-orders/[id]/send-email/outcome on the
* dashboard.
*/
const recordOutcome = node({
type: 'n8n-nodes-base.code',
version: 2,
config: {
name: 'Record outcome to PB',
parameters: {
jsCode: `// POST the SMTP outcome back to the dashboard's outcome route.
// The dashboard owns the canonical write using its own admin PB auth.
//
// n8n Code nodes don't expose fetch — use this.helpers.httpRequest().
const first = ($input.first() || {}).json || {};
const body = first.body || first;
const agentKey = $env.AGENT_API_KEY || '';
const dashboardBase = ($env.DASHBOARD_URL || '').replace(/\\/$/, '');
const poId = body.poId;
if (!agentKey || !dashboardBase) {
return [{ json: {
poId,
ok: false,
error: 'AGENT_API_KEY or DASHBOARD_URL missing in n8n env',
} }];
}
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 ' + agentKey,
},
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-cron',
'WFDASH — Purchase Order Send (Cron)'
)
.add(cron)
.to(scanDrafts)
.to(resolvePerPo)
.to(fetchAttachments)
.to(sendEmail)
.to(recordOutcome);