- Introduced a new toggle in the quotes and archive pages to allow users to include older quotes beyond the default 90-day cutoff. - Updated the search parameters in the quotes and archive APIs to handle the new `includeOlder` flag, enabling full history searches. - Enhanced the user interface to reflect the current search scope, providing clear feedback on whether the search is limited to recent quotes or includes the full archive. - Adjusted the quotes search service to support the new functionality, ensuring efficient retrieval of older quotes when requested. - Added relevant tests to verify the correct behavior of the new search feature and its integration with existing functionality.
23 KiB
Order Pipeline Investigation — 2026-08-20
Scope: investigate (1) hit-and-miss contact_name population on orders,
(2) duplicate-order creation when the same RFQ email arrives more than
once, and (3) general email-intake failures. Audit covers the n8n
workflows, the WFDASH Next.js app, and the live PocketBase data.
TL;DR
| Symptom | Root cause | Severity |
|---|---|---|
| 84.6% of auto-created orders are duplicates of the same email | The IMAP poll keeps returning the same message every 5 minutes; the workflow has no email_message_id dedup check before pb.collection('orders').create(...) |
Critical |
contact_name is "hit and miss" (sometimes "Neville Benadé", sometimes "Nbenade\n", sometimes \n) |
The contact name is sourced from a non-deterministic LLM call (no temperature cap, no fallback to deterministic email-signature parsing); the same email produces a different name on every poll cycle | High |
The 9 most recent orders (2026-08-19) have empty email_message_id / email_subject |
Those are counter-created orders — the post-create email field is not set on the manual /counter form. Not a bug, but it makes the "duplicate by msgid" dedup path useless for counter orders |
Low |
"Some emails fail" — orders created with NO-REF, [NEEDS REVIEW], or description = '[NEEDS REVIEW] AUTO-FLAGGED: ...' |
The AI Agent returns order_items = [] whenever the PDF text extraction can't find a clean line-items table. The workflow then takes the "no items extracted" branch which writes a flag and a raw blob, but the customer / contact_name / order_number come from the same non-deterministic AI call so they may still be wrong |
Medium |
The dedup mechanism that already exists (POST /api/v1/orders/duplicates/cancel) is reactive — it finds duplicates after the fact and lets the operator cancel them. Nothing prevents the duplicates from being created in the first place.
1. Pipeline overview
Active order-related n8n workflows (as of 2026-08-19 23:46 UTC)
| ID | Name | Trigger | Purpose |
|---|---|---|---|
DWkxko0SS1uoelsg |
WFDASH — ORDER MONITOR (Unified) | Schedule (5 min) | The only pipeline that creates orders records. Polls IMAP, classifies, extracts via AI Agent, creates order + items. |
DWkxko0SS1uoelsg (multi branch) |
(same workflow) | (same) | The (Multi) branch handles multi-PO emails by splitting attachments by PO number detected in subject/body. |
O0yDcNVHEhA0Dd5n |
WFDASH — Multi-Order Fulfillment Pipeline | (other) | Creates quotes (not orders) for multi-PO emails via /api/v1/quotes/from-email. |
45h7IYegCmhtkV9t |
WFDASH — Multi-Order RFQ Fulfillment Pipeline | Schedule (2 min) | Creates quotes (not orders) for multi-PO emails via /api/v1/quotes/from-email. |
mXX2yu5svRurnfwO |
WFDASH - CATEGORISE ORDERS ITEMS | Schedule (1 min) | Backfills ai_item_insights and category enums. Does not create orders. |
paqlZICDbXbmY61V |
WFDASH — Populate Item Intel (Live) | Schedule | Item intel backfill. |
UqO3ayETx9cQHOzF |
WFDASH — Purchase Order Send | Webhook | Sends PO emails. |
GGS5zgVbdfsRyLcY |
ORDER-MARKUP-APPROVAL-LINKS | Webhook | WhatsApp/email markup-approval links. |
kVylcetxy858PcfP |
WFDASH — Error Handler | Error trigger | Telegram error alerts. |
aI0Ze2vwgp25ZgGH |
WFDASH — Error Notifier (Telegram) | Error trigger | Telegram error alerts. |
DDFN9NBmh95yG2Jk |
WFDASH — AI Insights Generator (v2) | Webhook | Re-runs AI insights on demand. |
The ORDER MONITOR is the only thing that creates orders records from email. Its flow is:
Schedule Trigger (every 5 min)
-> CHECK FOR NEW MAIL (IMAP read, seen:false)
-> SetEmailFlags (mark as seen immediately)
-> Classify Email (Deterministic) (bounce / supplier / multi_order / customer / uncertain)
-> Route Email (Switch) (route to one of 5 branches)
-> Limit (limit to 1 — but parameters: {}, so probably a no-op)
-> Download ATTACHMENT
-> Split Attachments1
-> Switch1 (by MIME type -> PDF / CSV / XLS / XLSX / TXT / image)
-> Extract PDF / CSV / XLS / XLSX / TXT (or Analyze image for image attachments)
-> Merge2
-> Code in JavaScript4 (combine all extracted text)
-> AI Agent (LLM extraction -> JSON { order_info, order_items })
-> If1 (if order_items.length > 0 -> single-order branch)
-> Search customers (PB getList by name/email fuzzy)
-> Create orders (PB create on orders collection)
-> Code in JavaScript2 (split order_items -> order_items rows)
-> Create order_items1 (PB create per item)
-> Aggregate
-> Merge1 + PocketBase1 (none) + MoveEmail email2 (move to INBOX.202608.Completed)
The single-order and multi-order branches converge at Merge order completion and
finally move the email to INBOX.202608.Completed (or Failed on the failure
branch).
PocketBase orders schema (live — pbc_3527180448)
30 fields, 0 indexes, 1 unique constraint (the implicit primary key). Relevant fields:
order_number(text, required) — no unique constraintstatus(select:new/processing/processed/cancelled)company(relation, required)customer(relation, optional)contact_name(text, optional) — written by theCreate ordersn8n node from the AI agent'sorder_info.contact_nameemail_subject,email_body,email_sender,email_message_id(text, optional)rfq_number,description,notes,rep(text, optional)
The n8n Create orders node writes email_message_id = $('Limit').item.json.headers['message-id'] — but the field is read from the email header, not from the actual Message-ID envelope of the IMAP fetch. If the IMAP proxy / email provider rewrites the header, this might come back empty or differ from what emailMessageId exposes elsewhere.
2. The duplicate-order problem
What the data shows
500 most-recent auto-created orders queried on 2026-08-19 23:46 UTC:
- 423 / 500 (84.6%) are duplicates — i.e. the same
email_message_idappears on 2 or moreordersrecords - 50 / 127 distinct message_ids are duplicated (the rest are singletons)
- The worst case: a single email
<GV4PR05MB12159B25B35D2BD7FA50838D4D1A62@...>produced 138 duplicate orders — one every 5 minutes from 15:31 to 17:51 UTC on 2026-08-18, all withorder_number = ZA02PO26002142 - Several emails have 9–10 duplicates created in the same second (e.g.
MSP17933at 19:10:27–19:10:28,MSP17934at 19:15:31). The same-second pattern means multiple workflow executions are processing the same email concurrently — the IMAP poll'sseen:falsefilter is not preventing parallel runs from picking it up
Why it's happening
Looking at the ORDER MONITOR (DWkxko0SS1uoelsg):
- Schedule Trigger fires every 5 minutes (
{"field":"minutes"}with no value, n8n defaults to 5min — confirmed by the 5-min spacing in execution log56337 (23:45)->56320 (23:40)->56301 (23:35)). - CHECK FOR NEW MAIL (IMAP-enhanced node) — fetches every email with
seen: falsefromINBOX. Include partstextContent, htmlContent, markdownContent, attachmentsInfo, flags, size, bodyStructure, headers. - SetEmailFlags (mark on fetch) — marks the fetched email as
Seenimmediately. This is the ONLY thing preventing the same email from being picked up again on the next 5-min tick. - Limit — parameters
{}. Confirmed by inspecting the workflow JSON. In n8n the Limit node with nomaxItemsdefaults to 1, but the "merge-2" + multi-attachment paths also feed back into it, so this limit is unreliable across branches. - Create orders — the
n8n-nodes-pocketbase.pocketbaseHttpnode doespb.collection('orders').create({...})with no pre-check for an existing record with the sameemail_message_id.
Three things have to be true for duplicates to happen, and they all are:
- A. The IMAP poll keeps returning the same email. It happens because:
the
seen:falseflag is set in the SAME workflow execution that processes the email — but if the workflow is retried (e.g. n8n re-triggers it after a transient error), or if the user reloads the IMAP mailbox and the IMAP server returns the same UID before the seen flag is committed, the next poll picks it up again. Same-second duplicates (9 in19:10:27–19:10:28) mean the workflow is running in parallel for the same email. - B. There is no server-side constraint preventing it. The PocketBase
orderscollection has no unique index onemail_message_idand no application-level pre-check increateOrder(src/lib/server/orders-service.ts:1828).pb.collection('orders').create({...})will happily insert a duplicate. - C. The 5-min polling cadence multiplies the damage: every 5 minutes
the same email gets reprocessed, and every cycle the AI extracts a
slightly different
contact_name(see §3) and a slightly differentorder_numberif the AI misses the PO number on that particular call (the duplicateNO-REFrecords are the AI failing to extract the PO).
Mitigations That Already Exist (Reactive Only)
- POST /api/v1/orders/duplicates (
src/app/api/v1/orders/duplicates/route.ts) —findDuplicateOrders()groups byorder_number(within 60s) and by a line-aggregate heuristic. Returns the grouped list. The UI (src/components/duplicate-orders-dialog.tsx) lets the operator bulk-cancel. - POST /api/v1/orders/{id}/cancel — soft-cancels an order (all items
flip to
cancelled, record stays in DB for audit).
These are post-creation tools. They do not prevent the duplicates from appearing in the first place.
Fixes (proposed)
A. Add a unique constraint on email_message_id in PB. This is the
most robust fix — it makes the database reject duplicate inserts. Since the
field is currently required: false, the migration is:
- In the PB admin: index
CREATE UNIQUE INDEX idx_orders_email_message_id ON orders (email_message_id) WHERE email_message_id != ''(or a partial unique index — Postgres/SQLite both support it). - Backfill: cancel the existing 423 duplicates (or use the existing
cancelOrdersBulkop) so the unique constraint can be created. - In the ORDER MONITOR workflow, before
Create orders, add aSearch orders (email_message_id = X)step. If a record exists, short-circuit and move the email toINBOX.202608.Completedwith areplay_skippedflag in the post-create log. This makes the workflow idempotent even if the IMAP poll does fire on the same email.
B. In the app, harden createOrder to refuse duplicates. With a
unique constraint in place, the second create() will throw a 409 — wrap
the pb.collection('orders').create(...) call in
src/lib/server/orders-service.ts:1828 to catch the constraint violation
and treat it as a "duplicate email, skipped" success. The function should
return the existing record.
C. Tighten the IMAP poll. Replace the seen:false filter with an
explicit since cursor: store the last-fetched UID in the workflow's
staticData and only fetch UID > lastUID. This works even if the seen
flag is reset by a reconnection.
D. Move the email ingestion off the 5-min poll. The poll is the only
reason duplicates get 5-min apart. A webhook-based trigger (e.g.
Postmark inbound webhook -> Next.js /api/v1/orders/intake) would
eliminate the polling surface entirely. The CREATE QUOTE FROM EMAIL
endpoint (POST /api/v1/quotes/from-email) already accepts the same
payload, so the work is mostly plumbing.
3. The contact_name hit-and-miss problem
What the data shows
Same email <GV4PR05MB12159B25B35D2BD7FA50838D4D1A62@...> (ZA02PO26002142
from Nbenade@masterdrilling.com) produced these contact_name values across
its 138 duplicate orders:
Nbenade\n
Nbenade\n
N. Benade\n
Nicole Benade\n
Nbenade\n
Not specified\n
Nbenade\n
Drilling Technical Services (PTY) Ltd\n
Nbenade\n
Nico Benade\n
N. Benade\n
Nbenade\n
Nbenade\n
Plus empty-string "" records (~8% of the same-email sample). Some
records have literally "\n" as the contact name — the AI returned JSON
like {"contact_name": "\n"} and it was stored verbatim.
The relevant emails are simple:
Good Day.
Please see attached copy of the order, please deliver soonest possible date.
Regards
There is no signature in the email body for the AI to extract from. The PO attachment is what contains the actual name (Neville Benadé), and the AI Agent prompt requires it to look at the attachments first — but it's not deterministic about what it picks out.
Why it's "hit and miss"
The contact_name pipeline:
- AI Agent (
DWkxko0SS1uoelsg"AI Agent" node) — Anthropic LLM call withhasOutputParser: true. The system prompt:- "Extract the actual End-User or Requester of the goods (Look for fields like 'Requester', 'Deliver To', 'Attention', or names in the Notes). Do NOT use the 'Buyer' or 'Created By' name if a Requester is listed."
- There is no
temperatureoverride — Claude's default is 1.0. - The LLM is non-deterministic by design and the prompt does not enforce a JSON retry on invalid output.
- Search customers (PB getList) — builds a filter from the LLM's
contact_name:const nameKeywords = name .replace(/mr\.|mrs\.|ms\.|mr|mrs|ms/gi, '') .split(/[\s,]+/) .filter(w => w.length > 2);replace(/mr\.|.../gi, '')only strips the literal prefix "Mr./Mrs./Ms." — it doesn't strip "Mr " (no dot) or "N." (no Mr.).filter(w => w.length > 2)drops keywords of length ≤ 2. So "N. Benade" -> ["Benade"], "Nbenade" -> ["Nbenade"], "Neville Benadé" -> ["Neville", "Benadé"].- The
customer ~ 'X'substring filter then matches the customer collection. If the AI returned "Nbenade" and the customer is "Neville Benadé", the substring match succeeds because "Nbenade" is a substring of no one — but the search is fuzzy enough to return multiple matches (theelementsPerPage: 12returns up to 12).
- Create orders —
customer = $('Search customers').item.json?.id || 'xmd68ps32k1x5fu'— the first match wins. If the AI returned names that don't match any customer, the fallback ID is the placeholderxmd68ps32k1x5fu(which is "Unknown - needs review" in the customers table). - Page rendering —
src/app/(app)/orders/page.tsx:942:
Empty string{order.contact_name && ( <span>{order.contact_name}</span> )}""is falsy -> row shows no name. Newline"\n"is truthy -> row shows just a separator dot with nothing after it. The operator sees "GARY-JV-MR · Master Drilling · ·" with a dangling separator and no name — clearly "miss".
Fixes (proposed)
A. Stop using the LLM for the contact name. The email body has a signature; the PDF has a "Requester:" line. Both are deterministic. Add a pre-LLM regex pass:
- Parse the email body for the signature (everything after the last
Regards/Kind regards/Best/Thanksblock, or the last 5 non-empty lines). - Parse the PDF text for
Requester:,Deliver To:,Attention:,End-User:. The first one is the contact name. - Only call the LLM if both fail.
If a deterministic parse succeeds, the LLM is skipped entirely — the hit/miss disappears.
B. If you keep the LLM, constrain it. Add to the system prompt:
- "If you cannot find a Requester/Deliver-To/Attention name, return the empty string — do NOT return placeholder text like 'Unknown', 'Not specified', or the company name."
- Emit
contact_name = ""(not"\n") when uncertain. The page's truthy-check will hide it cleanly. - Set
temperature: 0on the Anthropic node. The LLM is still non-deterministic for the same prompt + temperature, but the variance drops substantially.
C. Post-process the LLM output. Wrap the AI Agent call in a Code node that:
- trims trailing newlines (
s.replace(/\s+$/,'')) - validates the result against the customers collection (search by
email first, then by name) — if a
customerrow exists with the sameemail, usecustomer.customeras the contact name regardless of what the LLM said.
D. Move the display sanitisation into the renderer. Even if the LLM
keeps hallucinating newlines and trailing whitespace, the row should not
show a dangling separator. Patch order.contact_name to a JSX-safe
display:
const displayContact = (order.contact_name || "").trim();
{displayContact && <span>· {displayContact}</span>}
This is a one-line fix and removes the visible "miss" symptom even if the data is still messy.
4. The "some emails fail" question
Looking at the live data, two failure modes are visible:
4a. AI returns empty contact_name / order_items
The If1 / If1 (Multi) branches check $json.output.order_items.length
and route:
<= 0-> "no items extracted" branch ->Create order (no items extracted - raw)/Create order (single - no items - raw)/Create order (no PO detected - raw). The resulting order has:description = "[NEEDS REVIEW] AUTO-FLAGGED: AI Agent could not extract any line items for this PO. Raw extracted text: ..."- the raw extracted text dumped in the description
- the email still moved to
INBOX.202608.Completed
This is the right fallback — the operator sees the flag. But the customer search may still be wrong because the LLM returned garbage for the contact name (see §3). The order lands on the board with a flag, but the customer/contact fields may be incorrect.
4b. AI returns "Unknown" / "Not specified" / company name as contact
Same root cause as §3. The LLM hallucinates an answer instead of returning empty. The Search customers query then searches for "Unknown" or "Not specified" or "Drilling Technical Services" — and finds the company record instead of the customer record. The order is created with the company as the customer, which is the wrong relation.
4c. The IMAP poll loses emails
When the IMAP mailbox is polled, the seen:false filter is set in the
SAME workflow execution as the processing. If the workflow crashes
(retry, timeout, network error, PocketBase 500), the email is still
marked as seen but never processed. The next 5-min poll won't pick it up.
This is silent failure — the email is in limbo until the operator manually
checks the inbox.
There is no onError feedback to the ORDER MONITOR. The "Error Handler"
n8n workflow (kVylcetxy858PcfP) only catches errors from workflows that
have the error workflow set. ORDER MONITOR does not.
Fixes (proposed)
A. Make the email flag roll back on error. Wrap the Create orders
Create order_items1chain in a Rollback node: if either fails, setSeen = falseon the email so the next poll picks it up.
B. Mark the workflow as error-to-Workflow. In n8n workflow Settings,
set errorWorkflow: kVylcetxy858PcfP so the Telegram error notifier
fires on any failure.
C. Add a sender-domain blacklist to the deterministic classifier. The
classifier already has supplierDomains = ['festo.com'] but that's
incomplete — most of the duplicates come from
masterdrilling.com, mreee.co.za, harmony.co.za. Add a customer
domain list (the inverse of supplier). The classifier already routes
"supplier" -> ignored. Mirror it with "customer" -> process, but make sure
the operator can override the list.
D. Add a fallback "create order from raw email" path. If the AI Agent fails to extract structured data after N retries, the workflow should still create an order with the raw email body as the description and a flag. This is already the "no items extracted" branch — but verify the branch is reachable for ALL routes (not just the customer branch).
5. Recommended fixes, ranked by impact / effort
| # | Fix | Impact | Effort |
|---|---|---|---|
| 1 | Add UNIQUE index on orders.email_message_id in PB, backfill the 423 existing duplicates via the bulk-cancel tool, and pre-check in createOrder |
Stops the duplicate flood at the DB level | 2 hours (admin UI + service-layer try/catch) |
| 2 | Add a "Search orders by email_message_id" pre-check in the ORDER MONITOR workflow before Create orders |
Stops duplicates even if the IMAP poll is unreliable | 30 min in n8n |
| 3 | Deterministic contact_name extraction (regex signature + PO requester line) before the LLM call | Eliminates the "hit and miss" name pop | 1 day (depends on how many PO formats need handling) |
| 4 | Constrain the LLM: temperature 0, "return empty string when uncertain" rule | Reduces LLM hallucination | 15 min |
| 5 | Renderer sanitisation: trim contact_name and only render when non-empty |
Hides the visible "miss" symptom with no data loss | 5 min |
| 6 | Replace IMAP poll with a webhook intake (Postmark inbound -> /api/v1/orders/intake) |
Eliminates the 5-min polling surface entirely | 1 week (depends on the email provider) |
| 7 | Wire the ORDER MONITOR to the error workflow | Surfaces failures on Telegram instead of silently losing emails | 15 min |
| 8 | Roll back Seen flag on workflow error | Recovers emails that fail to process | 30 min in n8n |
If we only do one thing: #1 + #2 together. They stop the bleeding. If we do two more: #3 + #5 clean up the visible UX. #6 is the long-term fix that fixes all polling-class bugs at once.
6. Files / artefacts that were touched or referenced
- n8n workflows (live, active):
DWkxko0SS1uoelsg,O0yDcNVHEhA0Dd5n,45h7IYegCmhtkV9t,mXX2yu5svRurnfwO,UqO3ayETx9cQHOzF,GGS5zgVbdfsRyLcY,kVylcetxy858PcfP,aI0Ze2vwgp25ZgGH,DDFN9NBmh95yG2Jk - App sources:
src/app/(app)/orders/page.tsx,src/components/duplicate-orders-dialog.tsx,src/app/api/v1/orders/duplicates/route.ts,src/app/api/v1/orders/create/route.ts,src/lib/server/orders-service.ts(createOrder, findDuplicateOrders) - PocketBase collections:
orders(pbc_3527180448),customers(pbc_108570809),companies(pbc_3866053794) - Docs:
docs/order-workflow-rules.md,docs/AGENT_INSTRUCTIONS.md
Query commands used (reproducible)
# List all orders, look at duplicates
curl -H "Authorization: Bearer $AGENT_API_KEY" \
"https://wfdash.apphub.co.za/api/v1/orders?perPage=500&sort=-created&fields=id,order_number,contact_name,email_message_id,email_sender,created,email_subject"
# Get a single n8n workflow's JSON
curl -H "X-N8N-API-KEY: $N8N_API_KEY" \
"http://n8n-...sslip.io/api/v1/workflows/DWkxko0SS1uoelsg"
# Get a single execution's data
curl -H "X-N8N-API-KEY: $N8N_API_KEY" \
"http://n8n-...sslip.io/api/v1/executions/56337?includeData=true"