WFDASH-WEBAPP/scripts/migrations/2026-06-30-create-rfq-outbound.ts
SJCoetz 9cdcb3b096 feat: implement sku-based catalogue pricing matching workflow
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
2026-06-30 21:20:53 +02:00

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);
});