feat(po-email): n8n workflow + runbook to auto-file POs from the MEGA mailbox
Polls ai@megainfra.co.za through Microsoft Graph, downloads the PO attachment and writes it into the same purchase_orders pending queue the browser upload uses, so the existing MEGA - Purchase Order Ingest workflow OCRs and finalises it. The source email is archived once filed; POs that cannot be matched to a site are tagged PO Needs Review instead of being filed against the wrong site.
This commit is contained in:
parent
8e1cbc8b75
commit
e7c8c90e32
3 changed files with 631 additions and 5 deletions
161
docs/n8n/po-email-intake.md
Normal file
161
docs/n8n/po-email-intake.md
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
# PO email intake — ai@megainfra.co.za → MEGA
|
||||
|
||||
**Status: built and tested. INACTIVE — waiting on two things from your IT admin (steps 1–2 below).**
|
||||
|
||||
Workflow: [MEGA - PO Email Intake (Microsoft Graph)](https://wfdash.n8n.apphub.co.za/workflow/bQ5PioGTSKnsWVh0)
|
||||
Source: [`po-email-intake.workflow.js`](./po-email-intake.workflow.js) (rebuildable from this file)
|
||||
|
||||
---
|
||||
|
||||
## What it does
|
||||
|
||||
Every 5 minutes the workflow:
|
||||
|
||||
1. lists the inbox of `ai@megainfra.co.za` through Microsoft Graph,
|
||||
2. finds emails carrying a purchase-order document (PDF / JPEG / PNG / WEBP),
|
||||
3. works out which MEGA site the PO belongs to,
|
||||
4. files the document into the **same PocketBase queue the browser upload button uses** — a
|
||||
`purchase_orders` row with `processing_status: pending`, the file attached, `file_hash` set,
|
||||
and a unique `PENDING-…` placeholder number,
|
||||
5. archives the source email once the row is filed.
|
||||
|
||||
Step 4 is the important part: the existing **MEGA - Purchase Order Ingest** workflow
|
||||
(`005iI4jX5mq9DsTR`) already polls that queue, so OCR, supplier matching, line-item extraction
|
||||
and finalisation all happen in the pipeline that is already running and trusted. **No changes to
|
||||
the dashboard app were needed** — the email path is just another way into the same door.
|
||||
|
||||
If a PO cannot be matched to a site, the email is tagged `PO Needs Review` in Outlook and left in
|
||||
the inbox. Nothing is filed against the wrong site and nothing is silently dropped.
|
||||
|
||||
## Why this is not an IMAP workflow
|
||||
|
||||
`megainfra.co.za` is a **Microsoft 365 / Exchange Online** custom domain
|
||||
(`autodiscover.megainfra.co.za → autodiscover.outlook.com`, SPF includes
|
||||
`spf.protection.outlook.com`). The tenant has password-based mail protocols switched off:
|
||||
|
||||
| Endpoint | Result |
|
||||
| --- | --- |
|
||||
| `outlook.office365.com:993` (IMAP) | `Basic authentication is disabled.` |
|
||||
| `smtp.office365.com:587` (SMTP) | `535 5.7.139 … SmtpClientAuthentication is disabled for the Tenant.` |
|
||||
|
||||
So no password-based mail client, n8n IMAP node or script can read this mailbox, whatever you
|
||||
type into a sign-in form. Reading it needs OAuth2 against Microsoft Graph. There is also no
|
||||
fallback IMAP route: `mail.megainfra.co.za` does not resolve, and HostH's
|
||||
`securemail.synaq.com` mail ports are unreachable from the app servers.
|
||||
|
||||
---
|
||||
|
||||
## Step 1 — your IT admin creates the app registration (once)
|
||||
|
||||
In the Azure portal → **App registrations** → **New registration**:
|
||||
|
||||
- Name: `MEGA PO Email Intake`
|
||||
- Supported account types: **Accounts in this organizational directory only**
|
||||
- Redirect URI: leave blank (this is a client-credentials app, no sign-in redirect)
|
||||
|
||||
Then:
|
||||
|
||||
- **Certificates & secrets** → **New client secret** → copy the secret value (shown once).
|
||||
- **API permissions** → **Add a permission** → **Microsoft Graph** → **Application permissions** →
|
||||
add **Mail.ReadWrite** → **Grant admin consent**.
|
||||
- `Mail.Read` would only allow reading. We also move and tag messages, so **`Mail.ReadWrite`
|
||||
(application)** is the one to grant.
|
||||
- Copy **Tenant ID**, **Application (client) ID** and the **client secret value**.
|
||||
|
||||
Send those three values to the person wiring up n8n. The account password is not needed and is
|
||||
not stored anywhere.
|
||||
|
||||
## Step 2 — create the n8n credential (once)
|
||||
|
||||
In n8n → **Credentials** → **New** → **OAuth2 API**:
|
||||
|
||||
| Field | Value |
|
||||
| --- | --- |
|
||||
| Name | `Microsoft Graph (MEGA PO)` |
|
||||
| Grant Type | **Client Credentials** |
|
||||
| Token URL | `https://login.microsoftonline.com/<TENANT_ID>/oauth2/v2.0/token` |
|
||||
| Client ID | Application (client) ID from the app registration |
|
||||
| Client Secret | the client secret value |
|
||||
| Scopes | `https://graph.microsoft.com/.default` |
|
||||
| Auth Style | Header → `Bearer` |
|
||||
|
||||
## Step 3 — bind the credential and switch it on
|
||||
|
||||
The six Microsoft Graph nodes still need the credential selected:
|
||||
|
||||
`List Inbox POs` · `Download Attachment` · `Archive Message` ·
|
||||
`Archive Message (already filed)` · `Flag Needs Review` ·
|
||||
`Flag Needs Review (no site)`
|
||||
|
||||
The three PocketBase nodes (`Read Sites`, `Duplicate Check`, `Create PO Row`) are already bound
|
||||
to the existing **`MEGAINFRASTRUCTURE`** credential — no secret sits inline in the workflow.
|
||||
|
||||
Once the credential is selected on those six nodes, activate the workflow (the schedule trigger
|
||||
starts polling every 5 minutes).
|
||||
|
||||
---
|
||||
|
||||
## How a PO is matched to a site
|
||||
|
||||
This is the one design decision worth understanding.
|
||||
|
||||
MEGA's finalisation step deliberately **never re-maps the site after OCR** —
|
||||
`src/app/api/purchase-orders/process-batch/route.ts` states it outright: *"`site` was set at
|
||||
upload by the person who uploaded the … and is NEVER touched here"*, because OCR only matches a
|
||||
real project code on roughly 8% of documents. The old
|
||||
`src/app/api/purchase-orders/ingest/route.ts`, which used to match the site from
|
||||
`project_code` server-side, is deprecated (see
|
||||
`docs/superpowers/plans/2026-09-11-ingestion-restructure.md`).
|
||||
|
||||
So the site has to be known when the row is created, and the workflow reads it out of the email
|
||||
itself, in this order:
|
||||
|
||||
1. exact project code — `C26/09`, `C26/13`, `C26/14`, `001`, `HEAD`
|
||||
2. project code appearing anywhere in subject + body preview + attachment filename
|
||||
3. site name — *The Hills*, *Cedar Gate*, *Munyaka 2*, *Nellmapius Ext 25*, *Mega Head Office*
|
||||
|
||||
If none of those match, the email is tagged `PO Needs Review` and left in the inbox for a human.
|
||||
It is never filed against a guessed site.
|
||||
|
||||
> If you would rather have the OCR decide the site, that needs a change to the
|
||||
> `process-batch` route (or to the queue) so the site can be corrected after extraction. That is
|
||||
> a small change to the app, not to this workflow — say the word and it can be done.
|
||||
|
||||
## Duplicate protection
|
||||
|
||||
Before filing, the workflow looks for an existing `purchase_orders` row with the **same file hash
|
||||
at the same site** (same rule the browser upload uses). A re-send of the same document is
|
||||
archived rather than filed twice. The placeholder `purchase_order_number` is derived from the
|
||||
file hash, so PocketBase's unique index is a second line of defence.
|
||||
|
||||
## What it deliberately does not do
|
||||
|
||||
- Only the inbox is read — not other folders. 25 most recent messages per run.
|
||||
- Only PDF / JPEG / PNG / WEBP attachments are considered. Everything else is ignored.
|
||||
- Messages already tagged `PO Needs Review` or `PO Ingested` are skipped on later runs, so a
|
||||
flagged email is not re-processed every 5 minutes.
|
||||
- Non-PO attachments that merely look PO-ish are flagged for review, not filed — MEGA's own
|
||||
document-kind check then refuses anything that turns out to be an invoice.
|
||||
|
||||
## Timing
|
||||
|
||||
| Step | When |
|
||||
| --- | --- |
|
||||
| Email picked up | within 5 minutes of arriving |
|
||||
| OCR + finalisation | the existing MEGA workflow wakes every 3 minutes and claims one pending row per run, so a PO reaches the dashboard roughly 3–8 minutes after the email lands |
|
||||
| Archived email | as soon as the row is filed |
|
||||
|
||||
## Rollback
|
||||
|
||||
Deactivate the workflow (n8n → the workflow → **Deactivate**). Nothing else changes and the
|
||||
dashboard keeps working. Rows already filed by email are ordinary `purchase_orders` rows and
|
||||
behave exactly like browser uploads.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Where to look |
|
||||
| --- | --- |
|
||||
| No emails picked up | n8n execution log for `List Inbox POs` — a 401/403 means the Graph credential is wrong or admin consent was not granted |
|
||||
| POs tagged `PO Needs Review` | the email did not name a site; add the site/project code to the subject or check the site table in `Read Sites` |
|
||||
| Row created but never finalised | the existing `MEGA - Purchase Order Ingest` workflow — check its `Fetch Pending Batches` / `Escalate Failure` nodes |
|
||||
| Same PO filed twice | `Duplicate Check` compares `file_hash` + `site`; a different site for the same document is deliberately not treated as a duplicate |
|
||||
463
docs/n8n/po-email-intake.workflow.js
Normal file
463
docs/n8n/po-email-intake.workflow.js
Normal file
|
|
@ -0,0 +1,463 @@
|
|||
/**
|
||||
* MEGA - PO Email Intake (Microsoft Graph)
|
||||
* -----------------------------------------
|
||||
* n8n Workflow SDK source. Rebuild with the n8n MCP `create_workflow_from_code` tool
|
||||
* (validate first), or rebuild by hand in the n8n UI using
|
||||
* docs/n8n/po-email-intake.md as the wiring reference.
|
||||
*
|
||||
* Live workflow: https://wfdash.n8n.apphub.co.za/workflow/bQ5PioGTSKnsWVh0
|
||||
*
|
||||
* What it does: polls ai@megainfra.co.za for purchase-order emails, downloads the PO
|
||||
* attachment, and files it into the same PocketBase pending queue the browser upload
|
||||
* button uses, so the existing "MEGA - Purchase Order Ingest" workflow OCRs and
|
||||
* finalises it. The source email is archived once the row is filed; anything that
|
||||
* cannot be matched to a site is tagged "PO Needs Review" in Outlook instead.
|
||||
*
|
||||
* Credentials referenced (none of them contain an inline secret):
|
||||
* - "Microsoft Graph (MEGA PO)" (oAuth2Api) -> created by the admin, see the runbook
|
||||
* - "MEGAINFRASTRUCTURE" (pocketbaseHttpApi) -> already exists on this instance
|
||||
*
|
||||
* Set the Graph credential on these six nodes after creating it:
|
||||
* List Inbox POs, Download Attachment, Archive Message,
|
||||
* Archive Message (already filed), Flag Needs Review, Flag Needs Review (no site)
|
||||
*/
|
||||
|
||||
import { workflow, node, trigger, ifElse, splitInBatches, nextBatch, newCredential, expr } from '@n8n/workflow-sdk';
|
||||
|
||||
const MAILBOX = 'ai@megainfra.co.za';
|
||||
const GRAPH = 'https://graph.microsoft.com/v1.0';
|
||||
const MSG = GRAPH + '/users/' + MAILBOX + '/messages/';
|
||||
|
||||
const GRAPH_CRED = { oAuth2Api: newCredential('Microsoft Graph (MEGA PO)') };
|
||||
const PB_CRED = { pocketbaseHttpApi: newCredential('MEGAINFRASTRUCTURE') };
|
||||
|
||||
const schedule = trigger({
|
||||
type: 'n8n-nodes-base.scheduleTrigger',
|
||||
version: 1.3,
|
||||
config: {
|
||||
name: 'Every 5 minutes',
|
||||
parameters: { rule: { interval: [{ field: 'minutes', minutesInterval: 5 }] } }
|
||||
}
|
||||
});
|
||||
|
||||
const readSites = node({
|
||||
type: 'n8n-nodes-pocketbase.pocketbaseHttp',
|
||||
version: 1,
|
||||
config: {
|
||||
name: 'Read Sites',
|
||||
parameters: {
|
||||
resource: 'sites',
|
||||
operation: 'search',
|
||||
parameters: { allElements: true, elementsPerPage: 50, fields: ['name', 'project_code'] }
|
||||
},
|
||||
credentials: PB_CRED
|
||||
}
|
||||
});
|
||||
|
||||
const collectSites = node({
|
||||
type: 'n8n-nodes-base.code',
|
||||
version: 2,
|
||||
config: {
|
||||
name: 'Collect Sites',
|
||||
parameters: {
|
||||
mode: 'runOnceForAllItems',
|
||||
jsCode: `const items = $input.all();
|
||||
const sites = [];
|
||||
for (const it of items) {
|
||||
const j = it && it.json ? it.json : {};
|
||||
if (j && j.id) sites.push({ id: j.id, name: j.name || '', project_code: j.project_code || '' });
|
||||
}
|
||||
return [{ json: { sites: sites } }];`
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const listInbox = node({
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
version: 4.5,
|
||||
config: {
|
||||
name: 'List Inbox POs',
|
||||
parameters: {
|
||||
method: 'GET',
|
||||
url: GRAPH + '/users/' + MAILBOX + '/mailFolders/inbox/messages',
|
||||
sendQuery: true,
|
||||
queryParameters: {
|
||||
parameters: [
|
||||
{ name: '$select', value: 'id,subject,from,bodyPreview,receivedDateTime,hasAttachments,categories' },
|
||||
{ name: '$expand', value: 'attachments' },
|
||||
{ name: '$orderby', value: 'receivedDateTime desc' },
|
||||
{ name: '$top', value: '25' }
|
||||
]
|
||||
},
|
||||
authentication: 'genericCredentialType',
|
||||
genericAuthType: 'oAuth2Api',
|
||||
options: { response: { response: { responseFormat: 'json', neverError: true } } }
|
||||
},
|
||||
credentials: GRAPH_CRED
|
||||
}
|
||||
});
|
||||
|
||||
const selectDocs = node({
|
||||
type: 'n8n-nodes-base.code',
|
||||
version: 2,
|
||||
config: {
|
||||
name: 'Select PO Documents',
|
||||
parameters: {
|
||||
mode: 'runOnceForAllItems',
|
||||
jsCode: `const body = $input.first() ? $input.first().json : {};
|
||||
const messages = body && Array.isArray(body.value) ? body.value : [];
|
||||
let sites = [];
|
||||
try { sites = ($('Collect Sites').item.json.sites) || []; } catch (e) { sites = []; }
|
||||
const DOC_TYPES = ['application/pdf', 'image/jpeg', 'image/png', 'image/webp'];
|
||||
const POISH = /(\\bp\\/?o\\b|\\bpo\\b|purchase\\s*order|\\border\\b)/i;
|
||||
function norm(s) { return String(s || '').toUpperCase().replace(/\\s+/g, ' ').trim(); }
|
||||
function matchSite(text) {
|
||||
const t = norm(text);
|
||||
if (!t) return null;
|
||||
const flat = t.replace(/[^A-Z0-9]/g, '');
|
||||
for (const s of sites) { const c = norm(s.project_code); if (c && t === c) return s; }
|
||||
for (const s of sites) { const c = norm(s.project_code); if (c && c.length >= 3 && t.indexOf(c) >= 0) return s; }
|
||||
for (const s of sites) { const n = norm(s.name); if (n && n.length >= 4 && t.indexOf(n) >= 0) return s; }
|
||||
for (const s of sites) { const c = norm(s.project_code).replace(/[^A-Z0-9]/g, ''); if (c && c.length >= 3 && flat.indexOf(c) >= 0) return s; }
|
||||
return null;
|
||||
}
|
||||
const out = [];
|
||||
for (const m of messages) {
|
||||
if (!m || !m.id || m.hasAttachments !== true) continue;
|
||||
const cats = Array.isArray(m.categories) ? m.categories : [];
|
||||
if (cats.indexOf('PO Needs Review') >= 0 || cats.indexOf('PO Ingested') >= 0) continue;
|
||||
const atts = Array.isArray(m.attachments) ? m.attachments : [];
|
||||
const subject = m.subject || '';
|
||||
const preview = m.bodyPreview || '';
|
||||
const from = (m.from && m.from.emailAddress && m.from.emailAddress.address) || '';
|
||||
for (const a of atts) {
|
||||
if (!a || a.isInline === true) continue;
|
||||
const ct = String(a.contentType || '').toLowerCase();
|
||||
const name = String(a.name || '');
|
||||
if (DOC_TYPES.indexOf(ct) < 0) continue;
|
||||
const hay = subject + ' ' + preview + ' ' + name;
|
||||
const site = matchSite(hay);
|
||||
const poish = POISH.test(hay);
|
||||
if (!site && !poish) continue;
|
||||
out.push({ json: {
|
||||
messageId: m.id,
|
||||
attachmentId: a.id,
|
||||
filename: name,
|
||||
contentType: ct,
|
||||
size: a.size || 0,
|
||||
subject: subject,
|
||||
bodyPreview: preview,
|
||||
from: from,
|
||||
received: m.receivedDateTime || '',
|
||||
ready: !!site,
|
||||
siteId: site ? site.id : '',
|
||||
siteCode: site ? site.project_code : '',
|
||||
siteName: site ? site.name : ''
|
||||
} });
|
||||
}
|
||||
}
|
||||
return out;`
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const loop = splitInBatches({
|
||||
version: 3,
|
||||
config: { name: 'Loop Documents', parameters: { batchSize: 1 } }
|
||||
});
|
||||
|
||||
const siteResolved = ifElse({
|
||||
version: 2.2,
|
||||
config: {
|
||||
name: 'Site Resolved?',
|
||||
parameters: {
|
||||
conditions: {
|
||||
options: { caseSensitive: true, leftValue: '', typeValidation: 'strict' },
|
||||
conditions: [{ leftValue: expr('{{ $json.ready }}'), operator: { type: 'boolean', operation: 'equals' }, rightValue: true }],
|
||||
combinator: 'and'
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const download = node({
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
version: 4.5,
|
||||
config: {
|
||||
name: 'Download Attachment',
|
||||
parameters: {
|
||||
method: 'GET',
|
||||
url: expr('"' + MSG + '" + $json.messageId + "/attachments/" + $json.attachmentId + "/$value"'),
|
||||
authentication: 'genericCredentialType',
|
||||
genericAuthType: 'oAuth2Api',
|
||||
options: { response: { response: { responseFormat: 'file', outputPropertyName: 'data' } } }
|
||||
},
|
||||
credentials: GRAPH_CRED
|
||||
}
|
||||
});
|
||||
|
||||
const capture = node({
|
||||
type: 'n8n-nodes-base.code',
|
||||
version: 2,
|
||||
config: {
|
||||
name: 'Capture File',
|
||||
parameters: {
|
||||
mode: 'runOnceForAllItems',
|
||||
jsCode: `const items = $input.all();
|
||||
const out = [];
|
||||
for (const item of items) {
|
||||
let meta = {};
|
||||
try { meta = $('Select PO Documents').item.json || {}; } catch (e) { meta = {}; }
|
||||
if (!meta.messageId) { try { meta = $('Loop Documents').item.json || {}; } catch (e) { meta = {}; } }
|
||||
const bin = (item.binary && item.binary.data) || {};
|
||||
const b64 = typeof bin.data === 'string' ? bin.data : '';
|
||||
let fileHash = '';
|
||||
try {
|
||||
const crypto = require('crypto');
|
||||
if (b64) fileHash = crypto.createHash('sha256').update(Buffer.from(b64, 'base64')).digest('hex');
|
||||
} catch (e) { fileHash = ''; }
|
||||
const stamp = Date.now().toString(36).toUpperCase();
|
||||
out.push({
|
||||
json: {
|
||||
messageId: meta.messageId || '',
|
||||
attachmentId: meta.attachmentId || '',
|
||||
filename: bin.fileName || meta.filename || 'purchase-order.pdf',
|
||||
contentType: bin.mimeType || meta.contentType || 'application/pdf',
|
||||
fileHash: fileHash,
|
||||
siteId: meta.siteId || '',
|
||||
siteCode: meta.siteCode || '',
|
||||
purchaseOrderNumber: fileHash ? ('PENDING-' + fileHash.slice(0, 12).toUpperCase()) : ('PENDING-' + stamp),
|
||||
emailMeta: {
|
||||
source: 'email',
|
||||
message_id: meta.messageId || '',
|
||||
subject: meta.subject || '',
|
||||
from: meta.from || '',
|
||||
received: meta.received || '',
|
||||
site_code: meta.siteCode || '',
|
||||
file_name: bin.fileName || meta.filename || ''
|
||||
}
|
||||
},
|
||||
binary: item.binary
|
||||
});
|
||||
}
|
||||
return out;`
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const dupCheck = node({
|
||||
type: 'n8n-nodes-pocketbase.pocketbaseHttp',
|
||||
version: 1,
|
||||
config: {
|
||||
name: 'Duplicate Check',
|
||||
alwaysOutputData: true,
|
||||
parameters: {
|
||||
resource: 'purchase_orders',
|
||||
operation: 'search',
|
||||
parameters: {
|
||||
elementsPerPage: 1,
|
||||
filter: expr('"=file_hash=\'" + $("Capture File").item.json.fileHash + "\' && site=\'" + $("Capture File").item.json.siteId + "\' && processing_status!=\'failed\'"')
|
||||
}
|
||||
},
|
||||
credentials: PB_CRED
|
||||
}
|
||||
});
|
||||
|
||||
const alreadyFiled = ifElse({
|
||||
version: 2.2,
|
||||
config: {
|
||||
name: 'Already Filed?',
|
||||
parameters: {
|
||||
conditions: {
|
||||
options: { caseSensitive: true, leftValue: '', typeValidation: 'loose' },
|
||||
conditions: [{ leftValue: expr('{{ $json.id }}'), operator: { type: 'string', operation: 'notEmpty' }, rightValue: '' }],
|
||||
combinator: 'and'
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const createPo = node({
|
||||
type: 'n8n-nodes-pocketbase.pocketbaseHttp',
|
||||
version: 1,
|
||||
config: {
|
||||
name: 'Create PO Row',
|
||||
onError: 'continueRegularOutput',
|
||||
parameters: {
|
||||
resource: 'purchase_orders',
|
||||
operation: 'create',
|
||||
bodyType: ['fields', 'binaryData'],
|
||||
binaryPropertyName: 'data',
|
||||
binaryFieldName: 'purchase_order_file',
|
||||
fields: {
|
||||
assignments: [
|
||||
{ id: 'f1', name: 'purchase_order_number', value: expr('{{ $("Capture File").item.json.purchaseOrderNumber }}'), type: 'string' },
|
||||
{ id: 'f2', name: 'site', value: expr('{{ $("Capture File").item.json.siteId }}'), type: 'string' },
|
||||
{ id: 'f3', name: 'processing_status', value: 'pending', type: 'string' },
|
||||
{ id: 'f4', name: 'attempts', value: 0, type: 'number' },
|
||||
{ id: 'f5', name: 'is_verified', value: false, type: 'boolean' },
|
||||
{ id: 'f6', name: 'file_hash', value: expr('{{ $("Capture File").item.json.fileHash }}'), type: 'string' },
|
||||
{ id: 'f7', name: 'date_uploaded', value: expr('{{ $now.toISO() }}'), type: 'string' },
|
||||
{ id: 'f8', name: 'raw_ocr_data', value: expr('{{ $("Capture File").item.json.emailMeta }}'), type: 'object' }
|
||||
]
|
||||
}
|
||||
},
|
||||
credentials: PB_CRED
|
||||
}
|
||||
});
|
||||
|
||||
const checkResult = node({
|
||||
type: 'n8n-nodes-base.code',
|
||||
version: 2,
|
||||
config: {
|
||||
name: 'Check Result',
|
||||
parameters: {
|
||||
mode: 'runOnceForAllItems',
|
||||
jsCode: `const j = $input.first() ? $input.first().json : {};
|
||||
let outcome = 'failed';
|
||||
let detail = '';
|
||||
let poId = '';
|
||||
if (j && j.id) {
|
||||
outcome = 'filed';
|
||||
poId = j.id;
|
||||
} else {
|
||||
const err = j && (j.error || j.message) ? String(j.error || j.message) : JSON.stringify(j).slice(0, 300);
|
||||
detail = err;
|
||||
}
|
||||
return [{ json: { outcome: outcome, detail: detail, poId: poId } }];`
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const filedOk = ifElse({
|
||||
version: 2.2,
|
||||
config: {
|
||||
name: 'Filed OK?',
|
||||
parameters: {
|
||||
conditions: {
|
||||
options: { caseSensitive: true, leftValue: '', typeValidation: 'loose' },
|
||||
conditions: [{ leftValue: expr('{{ $json.outcome }}'), operator: { type: 'string', operation: 'equals' }, rightValue: 'filed' }],
|
||||
combinator: 'and'
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const archiveNew = node({
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
version: 4.5,
|
||||
config: {
|
||||
name: 'Archive Message',
|
||||
parameters: {
|
||||
method: 'POST',
|
||||
url: expr('"' + MSG + '" + $("Capture File").item.json.messageId + "/move"'),
|
||||
sendHeaders: true,
|
||||
headerParameters: { parameters: [{ name: 'Content-Type', value: 'application/json' }] },
|
||||
sendBody: true,
|
||||
specifyBody: 'json',
|
||||
jsonBody: '={"destinationId":"archive"}',
|
||||
authentication: 'genericCredentialType',
|
||||
genericAuthType: 'oAuth2Api',
|
||||
options: { response: { response: { responseFormat: 'json', neverError: true } } }
|
||||
},
|
||||
credentials: GRAPH_CRED
|
||||
}
|
||||
});
|
||||
|
||||
const archiveDup = node({
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
version: 4.5,
|
||||
config: {
|
||||
name: 'Archive Message (already filed)',
|
||||
parameters: {
|
||||
method: 'POST',
|
||||
url: expr('"' + MSG + '" + $("Capture File").item.json.messageId + "/move"'),
|
||||
sendHeaders: true,
|
||||
headerParameters: { parameters: [{ name: 'Content-Type', value: 'application/json' }] },
|
||||
sendBody: true,
|
||||
specifyBody: 'json',
|
||||
jsonBody: '={"destinationId":"archive"}',
|
||||
authentication: 'genericCredentialType',
|
||||
genericAuthType: 'oAuth2Api',
|
||||
options: { response: { response: { responseFormat: 'json', neverError: true } } }
|
||||
},
|
||||
credentials: GRAPH_CRED
|
||||
}
|
||||
});
|
||||
|
||||
const flagReview = node({
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
version: 4.5,
|
||||
config: {
|
||||
name: 'Flag Needs Review',
|
||||
parameters: {
|
||||
method: 'PATCH',
|
||||
url: expr('"' + MSG + '" + $("Capture File").item.json.messageId'),
|
||||
sendHeaders: true,
|
||||
headerParameters: { parameters: [{ name: 'Content-Type', value: 'application/json' }] },
|
||||
sendBody: true,
|
||||
specifyBody: 'json',
|
||||
jsonBody: '={"categories":["PO Needs Review"]}',
|
||||
authentication: 'genericCredentialType',
|
||||
genericAuthType: 'oAuth2Api',
|
||||
options: { response: { response: { responseFormat: 'json', neverError: true } } }
|
||||
},
|
||||
credentials: GRAPH_CRED
|
||||
}
|
||||
});
|
||||
|
||||
const flagNoSite = node({
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
version: 4.5,
|
||||
config: {
|
||||
name: 'Flag Needs Review (no site)',
|
||||
parameters: {
|
||||
method: 'PATCH',
|
||||
url: expr('"' + MSG + '" + $json.messageId'),
|
||||
sendHeaders: true,
|
||||
headerParameters: { parameters: [{ name: 'Content-Type', value: 'application/json' }] },
|
||||
sendBody: true,
|
||||
specifyBody: 'json',
|
||||
jsonBody: '={"categories":["PO Needs Review"]}',
|
||||
authentication: 'genericCredentialType',
|
||||
genericAuthType: 'oAuth2Api',
|
||||
options: { response: { response: { responseFormat: 'json', neverError: true } } }
|
||||
},
|
||||
credentials: GRAPH_CRED
|
||||
}
|
||||
});
|
||||
|
||||
const summary = node({
|
||||
type: 'n8n-nodes-base.code',
|
||||
version: 2,
|
||||
config: {
|
||||
name: 'Run Summary',
|
||||
parameters: {
|
||||
mode: 'runOnceForAllItems',
|
||||
jsCode: `const items = $input.all();
|
||||
return [{ json: { finished: true, processed: items.length } }];`
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export default workflow('mega-po-email-intake', 'MEGA - PO Email Intake (Microsoft Graph)')
|
||||
.add(schedule)
|
||||
.to(readSites)
|
||||
.to(collectSites)
|
||||
.to(listInbox)
|
||||
.to(selectDocs)
|
||||
.to(loop
|
||||
.onDone(summary)
|
||||
.onEachBatch(siteResolved
|
||||
.onTrue(download
|
||||
.to(capture)
|
||||
.to(dupCheck)
|
||||
.to(alreadyFiled
|
||||
.onTrue(archiveDup.to(nextBatch(loop)))
|
||||
.onFalse(createPo
|
||||
.to(checkResult)
|
||||
.to(filedOk
|
||||
.onTrue(archiveNew.to(nextBatch(loop)))
|
||||
.onFalse(flagReview.to(nextBatch(loop)))))))
|
||||
.onFalse(flagNoSite.to(nextBatch(loop))))
|
||||
);
|
||||
|
|
@ -186,10 +186,12 @@ Test cases:
|
|||
4. **Duplicate PO** — upload the same PO twice. First call 200, second call 409.
|
||||
5. **Unknown project_code** — expect 200 with `warnings: ["site_unmatched"]`.
|
||||
|
||||
## Future: email ingestion
|
||||
## Email ingestion — built
|
||||
|
||||
The same `/api/purchase-orders/ingest` callback will be reused when email ingestion is built. Add a second n8n workflow with an **Email Trigger** (IMAP / Gmail / Outlook node) that:
|
||||
1. Downloads the PO attachment from the email.
|
||||
2. Runs the same OCR + parser + callback flow.
|
||||
Email ingestion exists now: **MEGA - PO Email Intake (Microsoft Graph)**
|
||||
(`bQ5PioGTSKnsWVh0`). See [po-email-intake.md](./po-email-intake.md).
|
||||
|
||||
No changes needed on the Next.js side.
|
||||
It does **not** use `/api/purchase-orders/ingest` — that route is deprecated (see
|
||||
`docs/superpowers/plans/2026-09-11-ingestion-restructure.md`). Instead the email workflow writes
|
||||
the PO document straight into the same `purchase_orders` pending queue this workflow polls, so
|
||||
the OCR + parser + callback flow above is reused unchanged. No changes on the Next.js side.
|
||||
|
|
|
|||
Loading…
Reference in a new issue