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
314 lines
9.8 KiB
JavaScript
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);
|