This commit adds the full end-to-end SKU pricing match flow for quote items: - Add new QuoteItem and PriceListRow fields for SKU and pricing metadata - Add sku_normalised to price_list for robust format-agnostic SKU matching - Refactor price list search to use modular search builder and filter helpers - Add database migrations, backfill scripts, and new package commands - Update catalogue search UI and fix quote list accidental click behavior - Add comprehensive unit, smoke, and end-to-end test suites - Update documentation for the new price search and extraction logic
196 lines
6.2 KiB
TypeScript
196 lines
6.2 KiB
TypeScript
/* eslint-disable */
|
|
/**
|
|
* Migration — create the empty `rfq_outbound` PocketBase collection.
|
|
*
|
|
* RFQ-by-email is v2's "Send to supplier and let them reply" loop.
|
|
* This migration creates the table now (zero rows, zero behaviour
|
|
* in v1) so the schema is ready when v2 lands — no retrofit, no
|
|
* migration-with-data-backfill.
|
|
*
|
|
* One row per outbound RFQ email we sent on a quote_item. When the
|
|
* supplier replies, the existing inbound email monitor drops a row
|
|
* into `quote_replies` as today; a v2 branch in the Switch (or a
|
|
* new workflow) also resolves the In-Reply-To header back to an
|
|
* rfq_outbound row and links the response to the same item.
|
|
*
|
|
* Fields (deliberately lean — we can extend when v2 is real):
|
|
*
|
|
* - quote_item relation → quote_items
|
|
* - supplier relation → suppliers (resolved from the
|
|
* price_list row we matched against, or the
|
|
* operator's override)
|
|
* - email_subject text
|
|
* - email_body text
|
|
* - email_message_id text (Message-ID header we set when
|
|
* sending — used for In-Reply-To
|
|
* resolution when the supplier replies)
|
|
* - sent_at date
|
|
* - status text pending | replied | expired | no_reply
|
|
* - raw_email_id text (optional — the raw SMTP / n8n
|
|
* delivery id for audit)
|
|
*
|
|
* Idempotent — re-runs skip silently if the collection already
|
|
* exists.
|
|
*
|
|
* Run with:
|
|
* npx tsx scripts/migrations/2026-06-30-create-rfq-outbound.ts
|
|
*/
|
|
import PocketBase from "pocketbase";
|
|
import { readFileSync } from "node:fs";
|
|
import { resolve } from "node:path";
|
|
|
|
function loadEnvFile(name: string) {
|
|
try {
|
|
const raw = readFileSync(resolve(process.cwd(), name), "utf8");
|
|
for (const line of raw.split("\n")) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
const m = trimmed.match(/^([A-Z0-9_]+)\s*=\s*(.+)$/i);
|
|
if (m && !process.env[m[1]]) {
|
|
process.env[m[1]] = m[2].replace(/^["']|["']$/g, "").trim();
|
|
}
|
|
}
|
|
} catch {
|
|
/* fine */
|
|
}
|
|
}
|
|
loadEnvFile(".env");
|
|
loadEnvFile(".env.local");
|
|
|
|
async function main() {
|
|
const url = process.env.NEXT_PUBLIC_POCKETBASE_URL;
|
|
const email = process.env.PB_ADMIN_EMAIL;
|
|
const password = process.env.PB_ADMIN_PASSWORD;
|
|
if (!url || !email || !password) {
|
|
throw new Error(
|
|
"Set NEXT_PUBLIC_POCKETBASE_URL, PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD (e.g. sourced from .env.local).",
|
|
);
|
|
}
|
|
|
|
const pb = new PocketBase(url);
|
|
await pb.collection("_superusers").authWithPassword(email, password);
|
|
console.log(`[rfq-outbound] Authenticated against ${url}`);
|
|
|
|
// 1. Look up the related-collection ids we need for the relations.
|
|
const allCols = await pb.collections.getFullList();
|
|
const quoteItemsCol = allCols.find((c) => c.name === "quote_items");
|
|
const suppliersCol = allCols.find((c) => c.name === "suppliers");
|
|
if (!quoteItemsCol) throw new Error("quote_items collection not found.");
|
|
if (!suppliersCol) throw new Error("suppliers collection not found.");
|
|
|
|
// 2. Skip if already exists.
|
|
const existing = allCols.find((c) => c.name === "rfq_outbound");
|
|
if (existing) {
|
|
console.log(`[rfq-outbound] rfq_outbound already exists (${existing.id}) — skipping.`);
|
|
console.log("[rfq-outbound] Migration complete (no-op).");
|
|
return;
|
|
}
|
|
|
|
// 3. Create it. Auth rules match the rest of the app — any
|
|
// logged-in user can CRUD. Adjust if you want stricter.
|
|
//
|
|
// Note on field shape: PB's REST API expects the relation +
|
|
// text/date options to be FLATTENED onto the field object
|
|
// (`{ name, type, collectionId, maxSelect, … }`), NOT wrapped
|
|
// inside an `options` key. Wrapping works for `collections.update`
|
|
// but `collections.create` rejects the wrapped format with a
|
|
// 400. `docs/pocketbase/raw_price_list.collection.json` is the
|
|
// canonical reference for the flat shape.
|
|
const AUTH_RULE = "@request.auth.id != ''";
|
|
|
|
const created = await pb.collections.create({
|
|
name: "rfq_outbound",
|
|
type: "base",
|
|
system: false,
|
|
listRule: AUTH_RULE,
|
|
viewRule: AUTH_RULE,
|
|
createRule: AUTH_RULE,
|
|
updateRule: AUTH_RULE,
|
|
deleteRule: AUTH_RULE,
|
|
fields: [
|
|
{
|
|
name: "quote_item",
|
|
type: "relation",
|
|
required: true,
|
|
collectionId: quoteItemsCol.id,
|
|
cascadeDelete: true,
|
|
maxSelect: 1,
|
|
minSelect: 0,
|
|
displayFields: ["description"],
|
|
},
|
|
{
|
|
name: "supplier",
|
|
type: "relation",
|
|
required: false,
|
|
collectionId: suppliersCol.id,
|
|
cascadeDelete: false,
|
|
maxSelect: 1,
|
|
minSelect: 0,
|
|
displayFields: ["suppliername"],
|
|
},
|
|
{
|
|
name: "email_subject",
|
|
type: "text",
|
|
required: true,
|
|
max: 500,
|
|
min: 1,
|
|
pattern: "",
|
|
},
|
|
{
|
|
name: "email_body",
|
|
type: "text",
|
|
required: false,
|
|
max: 20000,
|
|
min: 0,
|
|
pattern: "",
|
|
},
|
|
{
|
|
name: "email_message_id",
|
|
type: "text",
|
|
required: false,
|
|
max: 255,
|
|
min: 0,
|
|
pattern: "",
|
|
},
|
|
{
|
|
name: "sent_at",
|
|
type: "date",
|
|
required: false,
|
|
min: "",
|
|
max: "",
|
|
},
|
|
// Free-text status on purpose — the lifecycle grows (v2 may
|
|
// add "bounced", "auto_followed_up", "matched_to_x_supplier",
|
|
// etc.) and we want zero migrations per addition.
|
|
{
|
|
name: "status",
|
|
type: "text",
|
|
required: false,
|
|
max: 32,
|
|
min: 0,
|
|
pattern: "",
|
|
},
|
|
{
|
|
name: "raw_email_id",
|
|
type: "text",
|
|
required: false,
|
|
max: 255,
|
|
min: 0,
|
|
pattern: "",
|
|
},
|
|
],
|
|
indexes: [
|
|
"CREATE INDEX idx_rfq_outbound_quote_item ON rfq_outbound (quote_item)",
|
|
"CREATE INDEX idx_rfq_outbound_message_id ON rfq_outbound (email_message_id)",
|
|
"CREATE INDEX idx_rfq_outbound_status ON rfq_outbound (status)",
|
|
],
|
|
});
|
|
|
|
console.log(`[rfq-outbound] Created rfq_outbound (${created.id}) — v1 leaves it empty.`);
|
|
console.log("[rfq-outbound] Migration complete.");
|
|
}
|
|
|
|
main().catch((err) => {
|
|
console.error("[rfq-outbound] Migration failed:", err);
|
|
process.exit(1);
|
|
});
|