Add full sprint pricing workflow and supporting infrastructure: - New sidebar navigation entry for the sprint dashboard - PocketBase category collection schema and migration script - Server API routes for sprint item claims, attachments, counts, and file proxying - Dev identity utilities and UI switcher for multi-user testing - Reusable FilterTabs component replacing ItemStatusTabs - Updated TypeScript types for sprint-related entities Also include: - Fix PocketBase file URL method casing (getUrl → getURL) across codebase - Add development utility, smoke test, and helper scripts - Update package.json with new sprint migration command
260 lines
No EOL
9 KiB
JavaScript
260 lines
No EOL
9 KiB
JavaScript
/**
|
|
* 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); |