From cfd3e3e6be8d9f4c0fb23136410b67652ea501b9 Mon Sep 17 00:00:00 2001 From: Dave Date: Thu, 27 Aug 2026 11:11:44 +0200 Subject: [PATCH] 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 --- docs/api-reference/orders-create.md | 163 ++++++++++++++++ .../quote-create-and-customer-lookup.md | 179 ++++++++++++++++++ src/lib/server/quotes-from-email-service.ts | 15 ++ src/lib/server/quotes-service.test.ts | 79 ++++++++ src/lib/server/quotes-service.ts | 124 +++++++----- 5 files changed, 512 insertions(+), 48 deletions(-) create mode 100644 docs/api-reference/orders-create.md create mode 100644 docs/api-reference/quote-create-and-customer-lookup.md diff --git a/docs/api-reference/orders-create.md b/docs/api-reference/orders-create.md new file mode 100644 index 0000000..4f3d4e2 --- /dev/null +++ b/docs/api-reference/orders-create.md @@ -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": "", + "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": "" } +``` + +--- + +## 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": "", + "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. diff --git a/docs/api-reference/quote-create-and-customer-lookup.md b/docs/api-reference/quote-create-and-customer-lookup.md new file mode 100644 index 0000000..fc951b8 --- /dev/null +++ b/docs/api-reference/quote-create-and-customer-lookup.md @@ -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: ""})` 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. diff --git a/src/lib/server/quotes-from-email-service.ts b/src/lib/server/quotes-from-email-service.ts index ecf2ede..91bf227 100644 --- a/src/lib/server/quotes-from-email-service.ts +++ b/src/lib/server/quotes-from-email-service.ts @@ -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); diff --git a/src/lib/server/quotes-service.test.ts b/src/lib/server/quotes-service.test.ts index 07a48b4..0a38c1e 100644 --- a/src/lib/server/quotes-service.test.ts +++ b/src/lib/server/quotes-service.test.ts @@ -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", () => { diff --git a/src/lib/server/quotes-service.ts b/src/lib/server/quotes-service.ts index 52e59d1..c6e8519 100644 --- a/src/lib/server/quotes-service.ts +++ b/src/lib/server/quotes-service.ts @@ -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>, @@ -1161,40 +1172,57 @@ async function findCustomerByNameHint( ): Promise { 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(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(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; } // ──────────────────────────────────────────────────────────────────────────────