/** * WFDASH — Price List Match (v2a) * * Webhook-driven catalogue matcher. Called by the dashboard's * `/api/v1/quotes/[id]/items/[itemId]/match` route when the operator * hits the new "Help me find this" button on the process page. * * Inputs (POST body): * - sku: string (the buyer's SKU or partial code) * - description: string (free-text description from the operator) * - pbUrl: string (PocketBase base URL — passed in by Next.js * so the workflow stays portable across environments) * - pbToken: string (PocketBase admin JWT — Next.js refreshes * on every call) * - limit: number (default 4 — top-N to return) * * Behaviour: * - Build a single combined PB filter that covers Tier 1 (exact SKU), * Tier 2 (SKU substring), AND Tier 3 (search_text fuzzy). Run it * as one HTTP query. Downstream, classify each row by what it * actually matched (Tier 1 > 2 > 3), sort, cap at `limit`. * * - Combining tiers into one query is safe because `classifyMatch` * inspects the row, not the query — it correctly identifies which * tier each individual row matches. Single-query beats three-query * (faster, one round-trip, no item multiplication risk). * * Output: * - { candidates: Candidate[] } (Candidate = same shape the * dashboard's Picker modal already consumes) * * v2b swap-in: add an LLM agent node between HTTP Request and Rank + * Cap that re-ranks the raw candidates semantically (e.g. "turbo" * matching turbine-adjacent items even without an exact token hit). * The webhook contract stays identical. */ import { workflow, trigger, node } from '@n8n/workflow-sdk'; const matchWebhook = trigger({ type: 'n8n-nodes-base.webhook', version: 2, config: { name: 'Match Webhook', parameters: { httpMethod: 'POST', path: 'wf-price-list-match', responseMode: 'lastNode', options: {}, }, }, }); const buildFilter = node({ type: 'n8n-nodes-base.code', version: 2, config: { name: 'Build Filter', parameters: { jsCode: `// Parse webhook payload + build a single combined PB filter that // covers Tier 1 + Tier 2 + Tier 3. Downstream classifies each row. // // Always output data (even on error / no-input) so the HTTP Request // and Rank+Cap nodes run regardless — they handle the empty case. const src = ($json.body && typeof $json.body === 'object') ? $json.body : $json; const sku = String(src.sku || '').trim(); const description = String(src.description || '').trim(); const pbUrl = String(src.pbUrl || '').replace(/\\/$/, ''); const pbToken = String(src.pbToken || ''); const limit = Math.max(1, Math.min(20, Number(src.limit) || 4)); function escapeForPbString(value) { return String(value || '').replace(/"/g, '\\\\"'); } function normaliseSku(raw) { if (!raw) return ''; return raw.toUpperCase().replace(/[^A-Z0-9]/g, ''); } // Build the URL. When there's no usable input OR no PB creds, point // at a known-empty PB filter so downstream nodes still execute. let url; if (!pbUrl) { url = 'about:blank'; } else if (!sku && !description) { // No input — point at an always-empty filter so the query returns // 0 items. PB accepts \`filter=id="__none__"\` cleanly. url = pbUrl + '/api/collections/price_list/records?page=1&perPage=1&filter=' + encodeURIComponent('id="__none__"'); } else { const normalised = normaliseSku(sku); const parts = []; if (sku) { // Tier 1 — exact equality on raw + canonical form. parts.push('sku = "' + escapeForPbString(sku) + '"'); parts.push('sku_normalised = "' + escapeForPbString(normalised) + '"'); // Tier 2 — substring on canonical + raw form. parts.push('sku_normalised ~ "' + escapeForPbString(normalised) + '"'); parts.push('sku ~ "' + escapeForPbString(sku) + '"'); } // Tier 3 — fuzzy search_text (sku + description combined so we hit // both brand-only and description-only entries). const searchQuery = (sku + ' ' + description).trim(); if (searchQuery) { parts.push('search_text ~ "' + escapeForPbString(searchQuery) + '"'); } const filter = '(' + parts.join(' || ') + ')'; const fields = 'id,sku,description,cost_price,selling_price,unit,supplier,brand,category,subcategory,sku_normalised,updated,specifications,search_text'; url = pbUrl + '/api/collections/price_list/records?page=1&perPage=30' + '&fields=' + encodeURIComponent(fields) + '&filter=' + encodeURIComponent(filter); } return [{ json: { url, pbToken, sku, description, limit } }];`, }, }, }); const queryPb = node({ type: 'n8n-nodes-base.httpRequest', version: 4.4, config: { name: 'Query PB', parameters: { method: 'GET', url: '={{ $json.url }}', sendHeaders: true, headerParameters: { parameters: [ { name: 'Authorization', value: '={{ $json.pbToken }}', }, ], }, options: { timeout: 10000, // Continue even on non-2xx so Rank+Cap can return a friendly // empty response instead of throwing. response: { response: { neverError: true, }, }, }, }, }, }); const rankAndCap = node({ type: 'n8n-nodes-base.code', version: 2, config: { name: 'Rank and Cap', parameters: { jsCode: `// Read HTTP response + meta from Build Filter. Classify each row's // tier (independently — a single combined query returned everything), // generate the rule-based "why" line, sort by tier, cap at limit. // // On any failure here we still return \`{ candidates: [] }\` so the // dashboard always gets a parseable response shape. function normaliseSku(raw) { if (!raw) return ''; return raw.toUpperCase().replace(/[^A-Z0-9]/g, ''); } function classifyMatch(rawInput, match) { const raw = (rawInput || '').trim(); const norm = normaliseSku(raw); if (match.sku === raw || (norm && match.sku_normalised === norm)) return 1; if ((norm && (match.sku_normalised || '').includes(norm)) || (raw && (match.sku || '').includes(raw))) return 2; return 3; } function explainMatch(rawInput, match, tier) { const raw = (rawInput || '').trim(); const inputLower = raw.toLowerCase(); const inputWords = inputLower.split(/\\s+/).filter((w) => w.length >= 2); const bits = [match.sku || '(no sku)']; if (tier === 1) bits.push('exact SKU match'); else if (tier === 2) bits.push('SKU substring'); if (tier === 3) { const detailBits = []; if (match.brand && inputWords.some((w) => match.brand.toLowerCase().includes(w))) { detailBits.push('brand: ' + match.brand); } if (match.category && inputWords.some((w) => match.category.toLowerCase().includes(w))) { detailBits.push('category: ' + match.category); } if (match.supplier && inputWords.some((w) => match.supplier.toLowerCase().includes(w))) { detailBits.push('supplier: ' + match.supplier); } if (match.description) { const descLower = match.description.toLowerCase(); const matchedWords = inputWords.filter((w) => descLower.includes(w)); if (matchedWords.length > 0) { detailBits.push('description matches "' + matchedWords.slice(0, 3).join(', ') + '"'); } } if (match.specifications && typeof match.specifications === 'object') { for (const [k, v] of Object.entries(match.specifications)) { if (typeof v === 'string' && inputWords.some((w) => v.toLowerCase().includes(w))) { detailBits.push(k + ': ' + v); break; } } } if (detailBits.length === 0) detailBits.push('"' + raw + '" found in catalogue text'); bits.push(detailBits.join('; ')); } if (match.supplier) bits.push('from ' + match.supplier); return bits.slice(0, 4).join(' \\u2014 ').slice(0, 240); } try { const meta = ($('Build Filter').first().json) || {}; const sku = String(meta.sku || ''); const description = String(meta.description || ''); const limit = Math.max(1, Math.min(20, Number(meta.limit) || 4)); const classifyKey = sku || description; const items = (Array.isArray($json.items) ? $json.items : []); const enriched = items.map((c) => { const tier = classifyMatch(classifyKey, c); return { id: c.id, sku: c.sku || '', description: c.description || '', brand: c.brand || '', category: c.category || '', supplier: c.supplier || '', cost_price: Number(c.cost_price) || 0, selling_price: Number(c.selling_price) || 0, unit: c.unit || '', tier, why: explainMatch(classifyKey, c, tier), }; }); const tierRank = (t) => (t === 1 ? 0 : t === 2 ? 1 : 2); enriched.sort((a, b) => tierRank(a.tier) - tierRank(b.tier)); const top = enriched.slice(0, limit); return [{ json: { candidates: top, query: { sku, description } } }]; } catch (err) { return [{ json: { candidates: [], error: err && err.message ? err.message : String(err) } }]; } `, }, }, }); export default workflow('wf-price-list-match', 'WFDASH — Price List Match') .add(matchWebhook) .to(buildFilter) .to(queryPb) .to(rankAndCap);