feat(quotes): add person_hint for customer resolution in forwarded emails

Introduces the `person_hint` field to the `EmailIntakePayload`, allowing the system to resolve the actual customer when a rep forwards an email. The `findOrCreateCustomer` function now prioritizes `person_hint` over the sender's email, enabling a more accurate attribution of quotes. This change includes a case-insensitive substring match with a token-drop fallback for multi-token names, ensuring better matching against existing customer records.

Additionally, new unit tests have been added to validate this functionality, covering various scenarios including token-drop behavior and ensuring the correct customer is linked when a rep forwards an RFQ.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Dave 2026-08-27 11:11:44 +02:00
parent dc84ce0f30
commit cfd3e3e6be
5 changed files with 512 additions and 48 deletions

View file

@ -0,0 +1,163 @@
# WFDASH API — Create Order (POST /api/v1/orders/create)
> **One payload, one call.** The full order header + items array goes
> in a single request body. There is no `/api/v1/orders` POST —
> `/orders` is GET-only.
---
## Endpoint
```
POST /api/v1/orders/create
```
Bearer-token auth via `AGENT_API_KEY` header. **Hermes drops
`AGENT_API_KEY` on direct REST calls** — use the MCP wrapper
`mcp__wfdash-mcp__orders__create` with the same body shape.
`runtime = "nodejs"` (PocketBase admin auth requires Node).
---
## Request body
```json
{
"customer_id": "<15-char PB id>", // required
"company_id": "<15-char PB id>", // required
"notes": "string?", // order-level notes
"contact_name":"string?", // operator-readable contact
"description": "string?", // short description of the order
"email_message_id": "string?", // dedup key
"rep": "string?", // sales rep free-text
"items": [
{
"description": "string", // required, trimmed
"quantity": 1, // default 1; must be > 0 when supplied
"cost_price": 123.45,
"markup": 25,
"selling_price": 154.31, // optional
"sku": "string?",
"unit": "EA",
"supplier_id": "<15-char PB id?>",
"notes": "string?"
}
]
}
```
### Field rules
- **`customer_id`, `company_id`** — must resolve in PB. Free-text
names are **not** accepted on this endpoint (use the MCP `orders.create`
wrapper if you need name-resolution; the REST handler requires ids).
- **`items[]`** — non-empty, every item needs `description`.
- **`selling_price` auto-derive** — when `cost_price` and `markup` are
both supplied and `selling_price` is omitted, it auto-derives as
`cost_price * (1 + markup / 100)` rounded to 2 dp. Pass
`selling_price` explicitly to override.
- **`email_message_id`** — optional idempotency key. If an order with
the same `email_message_id` already exists, the response returns the
existing record with `duplicate: true` and HTTP **200** instead of
201. Safe to retry.
- **Per-row independence** — each item creates one `order_item` row
with `item_status = "pending"` and `drv_status = "unassigned"`. One
item's secondary-field patch failure does **not** abort the rest of
the call; failures land in `items_failed[]` with the partial-success
shape.
---
## Response
### 201 — created
```json
{
"order": { /* full PB order record */ },
"order_items_collection": "<relation-id>",
"items_created": [ { "id": "...", "description": "..." } ],
"items_failed": []
}
```
### 200 — duplicate (`email_message_id` matched)
Same shape as 201, plus `"duplicate": true`. Nothing new was written.
### 400 — validation failure
```json
{ "error": "customer_id is required" }
{ "error": "items must be a non-empty array" }
```
### 404 — not found
```json
{ "error": "Requested resource not found..." }
```
`customer_id` or `company_id` doesn't resolve in PB.
### 500 — server / PB write failure
```json
{ "error": "<PocketBase error message>" }
```
---
## Common workflows
### Walk-in / counter sale
No upstream quote. Customer walks up, picks items, you write the
order straight from the till:
```bash
curl -X POST https://wfdash.example/api/v1/orders/create \
-H "Authorization: Bearer $AGENT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"customer_id": "<customer-id>",
"company_id": "<company-id>",
"notes": "Counter sale — 2026-08-27",
"items": [
{ "description": "SKF 6205 bearing", "quantity": 4,
"cost_price": 85, "markup": 30 }
]
}'
```
### Re-create from a quote
**Don't** use this endpoint — use
`POST /api/v1/orders/from-quote` (MCP: `orders.create_from_quote`).
It clones the matched quote into a fresh `ORD-YYYY-MM-NNN` order,
inheriting customer / company / rep / letterhead / line items.
### Append to an existing order
There **is** a separate endpoint:
`POST /api/v1/orders/{id}/items` (MCP: `order_items.create`). But it
**refuses with 409 `order_locked`** the moment any line on the order
is past `pending`. Use it only on fresh orders — for any order that
has a PO in flight, you must `orders.split` first.
---
## Pitfalls
- **Passing the order header without `items[]`** → 400. The handler
does not create empty orders.
- **Passing `quantity: 0` or negative** → rejected per item by the
service layer; the row lands in `items_failed[]` with
`validation_error`.
- **Forgetting to send `AGENT_API_KEY`** → 401 from
`assertAgentAuth`. From Hermes, use `mcp__wfdash-mcp__orders__create`
instead — the MCP layer injects the key.
- **Reusing `email_message_id` for genuinely different orders** →
the second call returns the first order with `duplicate: true`.
Don't reuse the key; generate a new one per logical order.

View file

@ -0,0 +1,179 @@
# WFDASH API — Quote Create + Customer Lookup Reference
> What the WFDASH REST/MCP API actually exposes today for the two
> tasks you keep hitting during intake: creating a quote, and
> finding an existing customer.
---
## Quote create — three flavours
There are three different quote-creation endpoints. Pick by **where
the data came from**, not by what feels closest:
### 1. `quotes.create` (walk-in) — **the one for your intake**
```
POST /api/v1/quotes/create
```
- Use when: there's no inbound RFQ email. Operator types it in, you
extracted items from a PDF / phone-call / WhatsApp, or you're
driving the quote from the in-store counter PWA.
- Body has `customer` (id OR name), `company` (id OR name), and
`items[]`. **Names are resolved case-insensitive exact** — if
there's no match, a new customer / company row is created with
empty defaults. So passing a name is safe; passing the wrong name
silently creates a duplicate.
- Returns `{ quote, customer, company, items, quote_number }` on 201.
- Status default is `"new"`. `quote_number` is auto-generated unless
you supply one (operator-supplied conflicts return 409).
- MCP wrapper: `mcp__wfdash-mcp__quotes__create`. Same body shape.
### 2. `quotes.ingest_from_email` (inbound RFQ email)
```
POST /api/v1/quotes/ingest-from-email
```
- Use when: the customer actually emailed in. Returns immediately
with `ai_status="pending"`; the background processor runs Claude
against the body + attachments to extract items. Don't extract
items yourself — let the pipeline do it.
- MCP wrapper: `mcp__wfdash-mcp__quotes__ingest_from_email`.
### 3. `quotes.create_from_email` (legacy, **avoid**)
```
POST /api/v1/quotes/create-from-email
```
- Caller passes a pre-extracted `extracted_items[]`. Only use this if
you're calling on behalf of a parser that already did the
extraction. **Wrong tool if you don't have an inbound email** —
it requires `subject` + parsed body and silently rewrites
`email_message_id` to a `synth-…@wfdash.local` sentinel.
- MCP wrapper: `mcp__wfdash-mcp__quotes__create_from_email`.
---
## Customer lookup — fuzzy search by name / company
### `customers.list` — substring search
```
GET /api/v1/customers
```
Query params:
| Param | Type | What it does |
|-----------|------|--------------|
| `search` | string | Substring match on **customer name / email / phone**, OR'd together; also matches **rep name / rep email** (the user that owns this customer). One query covers all four fields. Case-sensitive PB `~` operator. |
| `rep` | string | Exact filter on the rep relation. Accepts a 15-char PB user id OR a user name / email (case-insensitive). |
| `page` | number | 1-based, default 1 |
| `perPage` | number | default 50, hard cap 100 |
| `sort` | string | PB sort expression, default `-created` |
| `expand` | string | Comma-separated, e.g. `"company,rep"` to inline relations |
Returns `{ items, totalItems, totalPages, page, perPage }`.
MCP wrapper: `mcp__wfdash-mcp__customers__list`. Same params.
### `customers.get` — fetch by id
```
GET /api/v1/customers/{id}
```
For when you already have the PB id and just need the full row +
relations.
MCP wrapper: `mcp__wfdash-mcp__customers__get`.
### What we DO NOT have today
- **No direct "filter customers by company name"** on `customers.list`.
The `search` param doesn't search across the `company` relation.
Two workarounds:
1. Pass `expand: "company"` on the list call, then filter the
returned `items[]` client-side by company name.
2. First `companies.list({search: "<name>"})` to get the company
id, then iterate the matching customers client-side. (The
`customers.list` `search` filter does not accept a company
relation id today.)
- **No dedicated `customers.lookup` endpoint** — `customers.list`
with a small `perPage` (e.g. 5) is the closest equivalent.
- **No fuzzy / Levenshtein matching.** It's PB's literal substring
`~` operator. "AB" won't match "ABC Trading" if you typed it
wrong-case, and partial-token matches like "Bear" against "Bearings
Co" will work — but "Bearings" against "Bear" works too (substring
is bidirectional). Typos are not forgiven.
---
## The intake pattern that works
Given what we have, the cleanest customer-resolution flow at intake
is:
```ts
// 1. Try the most specific lookup first.
const hit = await customers.list({
search: customerName,
expand: "company",
perPage: 5,
});
// 2. If the caller ALSO gave a company name, narrow client-side.
const filtered = companyName
? hit.items.filter(c => c.expand?.company?.company === companyName)
: hit.items;
// 3. If 0 hits → create via customers.create (auto-creates company
// if company name doesn't resolve either). Use quotes.create with
// the names directly — it has the same auto-create behaviour, so
// you don't need a separate customers.create call unless you want
// to backfill email/phone/etc.
```
For quote creation specifically, **just pass `customer` and `company`
as names to `quotes.create`** — the service layer resolves them by
case-insensitive exact match and creates missing rows. You only need
the lookup step when:
- You're filling in `email` / `phone` from a contact form.
- You want to verify the customer exists before showing the operator
a "is this the right John Smith?" confirmation.
- You're merging two customer records.
---
## Companies (related, since you mentioned name + company)
### `companies.list` — substring search
```
GET /api/v1/companies
```
Query params: `page`, `perPage`, `search` (substring on
`company.company` / address / vendor), `sort`. Same shape as
`customers.list`.
MCP wrapper: `mcp__wfdash-mcp__companies__list`.
### `companies.get` — fetch by id
```
GET /api/v1/companies/{id}
```
MCP wrapper: `mcp__wfdash-mcp__companies__get`.
### `companies.create` / `companies.update`
Free-text company name, optional address / vendor / coordinates.
Same auto-resolve-and-create-on-no-match behaviour as customers.

View file

@ -97,6 +97,18 @@ export interface EmailIntakePayload {
attachments?: EmailIntakeAttachment[];
/** Optional override for company matching — used by the AI extraction layer. */
company_hint?: string;
/**
* Optional override for customer matching — used when the inbound email
* is forwarded by a rep (e.g. Gary) who named the actual customer in
* the subject line. When set, `findOrCreateCustomer` resolves this
* name against existing customers (case-insensitive substring match,
* with token-drop fallback for "First Last Company" subjects) BEFORE
* the `from_email` sender. If it resolves, the quote is linked to
* the named person, not the rep. If it doesn't resolve, the sender
* (rep) wins so the quote still lands attributable rather than on
* the Unknown Sender sentinel.
*/
person_hint?: string;
}
export interface IngestQuoteFromEmailResult {
@ -151,11 +163,14 @@ export async function ingestQuoteFromEmail(
}
// ── 2. Resolve or create the customer. Empty from_email → sentinel. ──
// `person_hint` is forwarded to `findOrCreateCustomer` so the named
// person wins over the rep's email when a rep (Gary) forwards an RFQ.
const { customer } = fromEmail
? await findOrCreateCustomer({
email: fromEmail,
name: payload.from_name,
companyHint: payload.company_hint,
personHint: payload.person_hint,
})
: await findOrCreateUnknownSenderCustomer(pb);

View file

@ -506,6 +506,85 @@ describe("findOrCreateCustomer", () => {
expect(created).toBe(false);
expect(customer.id).toBe(johnAcme.id);
});
it("person_hint wins over the sender's existing email match", async () => {
// The real-world bug this fixes: Gary (gary@wfsales.co.za) has his
// own customer row from years of forwarding. Before the fix, the
// email-exact-match check ran BEFORE person_hint, so every
// Gary-forwarded RFQ landed on Gary's row even when the subject
// named the actual customer. After the fix, person_hint resolves
// first when it's set (the n8n workflow only sets it for forwarded
// emails where the named person is the real customer), so the
// quoted-for person wins.
const gary = await pbMock.collection("customers").create({
customer: "Gary",
email: "gary@wfsales.co.za",
company: "",
});
const john = await pbMock.collection("customers").create({
customer: "John Smith",
email: "john@personal.co.za",
company: "",
});
const { customer, created } = await findOrCreateCustomer({
email: "gary@wfsales.co.za",
personHint: "John Smith",
});
expect(created).toBe(false);
expect(customer.id).toBe(john.id);
expect(customer.id).not.toBe(gary.id);
});
it("token-drop: 3-token subject 'Jose Elia Redpath' finds 2-token customer 'Elia Jose'", async () => {
// Gary's bare-subject format is "FirstName LastName Company" — the
// pure-substring match on the whole string misses because the
// customer table stores "LastName FirstName" (SA convention). The
// token-drop fallback walks the prefix ladder (3 → 2 → 1 token)
// and finds "Elia Jose" via the single-token rung ("Jose").
const elia = await pbMock.collection("customers").create({
customer: "Elia Jose",
email: "Elia.Jose@redpathmining.com",
company: "",
});
const { customer, created } = await findOrCreateCustomer({
email: "gary@wfsales.co.za",
personHint: "Jose Elia Redpath",
});
expect(created).toBe(false);
expect(customer.id).toBe(elia.id);
});
it("token-drop: single-token rung only fires for 2- or 3-token hints", async () => {
// A 5-token sentence hint ("Please quote John Smith urgently")
// must NOT degrade to matching every "John" in the database. The
// single-token rung is gated to 2- and 3-token hints only, so
// longer hints stay on their full prefix ladder and either match
// on a multi-token rung or fall through.
const john = await pbMock.collection("customers").create({
customer: "John Smith",
email: "john@personal.co.za",
company: "",
});
await pbMock.collection("customers").create({
customer: "Bob Johnson",
email: "bob@bobcorp.co.za",
company: "",
});
// "John Smith" should match via the 2-token rung (whole string is
// already a substring of the customer name). The single-token
// "John" rung would also match Bob Johnson? No — "John" in "Bob
// Johnson" IS a substring. So we need a hint where neither rung
// matches anything: "Xavier Quigley urgently please" → no match.
// Then we expect fall-through to email → new Gary customer.
const result = await findOrCreateCustomer({
email: "gary-fresh@wfsales.co.za",
personHint: "Xavier Quigley urgently please",
});
expect(result.created).toBe(true);
expect(result.customer.email).toBe("gary-fresh@wfsales.co.za");
// sanity: the 2-token rung DOES still work for the simple case
expect(john.id).toBeDefined();
});
});
describe("findOrCreateCompany", () => {

View file

@ -1006,11 +1006,13 @@ export async function findOrCreateCustomer(input: {
* email is forwarded by a rep (e.g. Gary) who named the actual
* customer in the subject line — the from_email then matches the rep,
* not the customer, so `person_hint` lets us link the quote to the
* right person. Resolution is case-insensitive exact match on the
* `customer.customer` field; on a tie (multiple matches) we prefer
* the one whose company matches `companyHint`. If no match, falls
* through to the existing sender-based create-new path — Gary's
* existing customer row wins, so the quote still lands attributable.
* right person. Resolution is case-insensitive substring match on
* the `customer.customer` field, with a token-drop fallback so a
* 3-token subject like "Jose Elia Redpath" still finds the 2-token
* customer "Elia Jose". On a tie (multiple matches) we prefer the
* one whose company matches `companyHint`. If no match, falls
* through to the sender's email — the rep's existing customer row
* wins, so the quote still lands attributable.
*/
personHint?: string;
}): Promise<{ customer: Customer; created: boolean }> {
@ -1020,16 +1022,12 @@ export async function findOrCreateCustomer(input: {
throw new Error("Customer email is required for findOrCreateCustomer.");
}
const exact = await findCustomerByExactEmail(pb, email);
if (exact) {
return { customer: exact, created: false };
}
// Rep-forwarded case: the sender (e.g. Gary) is not the customer
// being quoted for. Try to resolve the named person against the
// customer table before falling back to creating a new customer row
// tied to the sender's email — otherwise we'd auto-create a junk
// "Gary" customer or duplicate him across every forwarded quote.
// Rep-forwarded case runs FIRST when a person_hint is supplied. The
// n8n workflow only sets `person_hint` when it has decided the email
// is forwarded and the named person is the real customer — so when
// the hint resolves, the named person is always preferred over the
// rep's email. Without this, every Gary-forwarded RFQ would land on
// Gary's existing customer row.
const personHint = (input.personHint || "").trim();
if (personHint) {
const personMatch = await findCustomerByNameHint(pb, personHint, input.companyHint);
@ -1038,6 +1036,11 @@ export async function findOrCreateCustomer(input: {
}
}
const exact = await findCustomerByExactEmail(pb, email);
if (exact) {
return { customer: exact, created: false };
}
const senderDomain = email.split("@")[1] || "";
const fallbackName = input.name?.trim() || senderDomain || email;
// Resolve the company hint to a relation id, not a free-text name —
@ -1139,10 +1142,18 @@ async function findCustomerByExactEmail(
/**
* Resolve a free-text person name (e.g. "John Smith" extracted from the
* email subject) to an existing customer row. Two-step match:
* email subject) to an existing customer row. Two-step match with a
* token-drop fallback for multi-token subjects:
*
* 1. Case-insensitive substring match on `customer.customer` via PB's
* `~` operator. Returns up to N candidates — usually one.
* `~` operator. Tries the longest prefix first ("Jose Elia
* Redpath"), then drops one trailing token at a time ("Jose
* Elia", then "Jose") until either a match is found or only one
* token remains. This catches the common SA rep-forwarded
* pattern where the subject is "FirstName LastName CompanyName"
* and the customer DB has "LastName FirstName" — pure substring
* on the 3-token string misses but the 1-token suffix still
* hits.
* 2. If multiple candidates come back (two "John Smith"s in the
* database), prefer the one whose company relation matches
* `companyHint` (case-insensitive exact on `company.company`).
@ -1150,9 +1161,9 @@ async function findCustomerByExactEmail(
* or no company match was found.
*
* Returns `null` when no candidate matches. The caller falls through
* to the sender-based create-new path in that case — the rep (Gary)
* becomes the customer, which is at least attributable and the
* quoter can reassign in the WFDASH UI.
* to the sender's email — the rep (Gary) becomes the customer, which
* is at least attributable and the quoter can reassign in the WFDASH
* UI.
*/
async function findCustomerByNameHint(
pb: Awaited<ReturnType<typeof getAdminPocketBase>>,
@ -1161,40 +1172,57 @@ async function findCustomerByNameHint(
): Promise<Customer | null> {
const trimmed = personHint.trim();
if (!trimmed) return null;
const esc = trimmed.replace(/"/g, '\\"');
// PB `~` is case-insensitive substring. We grab a small page; in
// practice this returns 1-3 rows for any non-trivial name.
let candidates: Customer[] = [];
try {
candidates = (
await pb.collection(CUSTOMERS_COLLECTION).getList<Customer>(1, 5, {
filter: `customer ~ "${esc}"`,
fields: "id,customer,company",
expand: "company",
})
).items;
} catch {
return null;
// Build the prefix ladder: longest, longest-1, longest-2, ... down to
// one token. We skip the single-token rung for hints longer than 3
// tokens to avoid wildly-over-broad substring matches (e.g.
// "Mary" matching every Maria / Marie / Mariana in the book).
const tokens = trimmed.split(/\s+/).filter(Boolean);
const rungs: string[] = [];
for (let len = tokens.length; len >= 2; len--) {
rungs.push(tokens.slice(0, len).join(" "));
}
// Single-token rung only for 2- and 3-token hints — covers the
// common "Jose Elia Redpath" → "Jose" → "Elia Jose" case without
// over-matching on full sentences.
if (tokens.length >= 2 && tokens.length <= 3) {
rungs.push(tokens[0]);
}
if (candidates.length === 0) return null;
if (candidates.length === 1) return candidates[0];
// Tie-break by company hint when available. The AI usually passes
// both, so the hint effectively narrows the candidate set.
const hint = (companyHint || "").trim();
if (hint) {
const hintEsc = hint.replace(/"/g, '\\"');
for (const c of candidates) {
const companyName = (c.expand?.company?.company || "") as string;
if (companyName && companyName.toLowerCase() === hintEsc.toLowerCase()) {
return c;
for (const rung of rungs) {
const esc = rung.replace(/"/g, '\\"');
let candidates: Customer[] = [];
try {
candidates = (
await pb.collection(CUSTOMERS_COLLECTION).getList<Customer>(1, 5, {
filter: `customer ~ "${esc}"`,
fields: "id,customer,company",
expand: "company",
})
).items;
} catch {
return null;
}
if (candidates.length === 0) continue;
if (candidates.length === 1) return candidates[0];
// Tie-break by company hint when available. The AI usually passes
// both, so the hint effectively narrows the candidate set.
const hint = (companyHint || "").trim();
if (hint) {
const hintEsc = hint.replace(/"/g, '\\"');
for (const c of candidates) {
const companyName = (c.expand?.company?.company || "") as string;
if (companyName && companyName.toLowerCase() === hintEsc.toLowerCase()) {
return c;
}
}
}
// No company hint or no company match — return the first candidate
// rather than creating a duplicate. Quoter can disambiguate.
return candidates[0];
}
// No company hint or no company match — return the first candidate
// rather than creating a duplicate. Quoter can disambiguate.
return candidates[0];
return null;
}
// ──────────────────────────────────────────────────────────────────────────────