WFDASH-WEBAPP/n8n_workfows/WFDASH — Purchase Order Send (Cron).js

315 lines
9.8 KiB
JavaScript
Raw Normal View History

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