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