WFDASH-WEBAPP/scripts/migrations/2026-07-02-add-sprint-categories-and-claims.ts
SJCoetz 05abe499b0 feat(sprint): add new sprint pricing workflow and supporting code
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
2026-07-03 07:29:43 +02:00

408 lines
No EOL
13 KiB
TypeScript

/* eslint-disable */
/**
* Migration — Sprint Quote Items (V1 foundation)
*
* Adds the data model the new /sprint page and supplier-RFQ email
* flow sit on:
*
* 1. New `categories` collection (admin-managed lookup of the tabs
* on /sprint). Seeded with the user's initial buckets plus
* UNKNOWN. Three match signals — `match_brands` (highest
* priority), `match_supplier_tags` (medium), `match_keywords`
* (fallback against description/SKU). UNKNOWN has no matchers;
* it's the catch-all bucket.
*
* 2. New fields on `quote_items`:
* - category relation → categories (for tab bucketing)
* - claim_status select: unclaimed | claimed | priced
* | awaiting_supplier | send_failed
* - claimed_by text (user email)
* - claimed_at date (when claim was made)
* - claim_expires_at date (30 min soft TTL — auto-release)
* - outbound_message_id text (RFC 5322 Message-ID of the RFQ
* email we sent to the supplier;
* matched on In-Reply-To for
* inbound reply routing)
* - outbound_sent_at date (when we sent the RFQ)
* - supplier_replied_at date (set by Supplier RFQ Monitor on inbound)
* - supplier_reply_count number (incremented on each inbound reply)
*
* 3. Indexes:
* - idx_qi_claim_status — sprint list filter
* - idx_qi_outbound_message_id — supplier reply lookup (O(log n))
* - idx_qi_category — tab filter
*
* 4. Backfills (idempotent):
* - quote_items.category ← price_list.brand via match_brands
* - quote_items.claim_status ← is_processed (priced vs unclaimed)
*
* All steps are idempotent — re-running after a partial success is safe.
*
* Run with:
* npx tsx scripts/migrations/2026-07-02-add-sprint-categories-and-claims.ts
*/
import PocketBase from "pocketbase";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
// ─── env loading (matches sibling migrations) ───────────────────────────
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 — rely on shell env */
}
}
loadEnvFile(".env");
loadEnvFile(".env.local");
// ─── seed data for categories ────────────────────────────────────────────
// Keyword/tag lists are lowercased before match. Tune these freely —
// they're admin-curated, not AI-generated.
const SEED_CATEGORIES: Array<Record<string, unknown>> = [
{
name: "JEI",
slug: "jei",
match_brands: "jei",
color: "#4C96D7",
display_order: 10,
is_active: true,
},
{
name: "TOOLS",
slug: "tools",
match_keywords:
"tool,wrench,screwdriver,hammer,pliers,saw,drill,grinder,spanner,socket set",
match_supplier_tags:
"hand tools,stronghand tools,tools,generators / stronghand tools,dumpers and drill rigs - tools",
color: "#F2AF58",
display_order: 20,
is_active: true,
},
{
name: "FASTENERS",
slug: "fasteners",
match_keywords:
"bolt,nut,screw,washer,fastener,stud,anchor,thread,rod,pin,clamp,hoseclamp",
match_supplier_tags:
"bolts,fastners,fasteners,hardware items,engineering hoseclamps",
color: "#7c3aed",
display_order: 30,
is_active: true,
},
{
name: "OIL",
slug: "oil",
match_keywords:
"oil,lubricant,grease,fluid,hydrocarbon,diesel,petrol,cutting oil",
match_supplier_tags:
"f61 grease,grease pumps,oil,automotive spares,chemical,chemicals",
color: "#0f766e",
display_order: 40,
is_active: true,
},
{
name: "HUSQVARNA - GARDEN & FORESTRY",
slug: "husqvarna-garden-forestry",
match_keywords:
"husqvarna,chainsaw,forestry,garden,trimmer,brushcutter,leaf blower,hedge trimmer,lawnmower",
match_supplier_tags: "chainsaws,garden,forestry,camping supplies",
color: "#E94E49",
display_order: 50,
is_active: true,
},
{
name: "UNKNOWN",
slug: "unknown",
color: "#64748b",
display_order: 9999,
is_active: true,
},
];
// ─── main ────────────────────────────────────────────────────────────────
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(`[sprint] Authenticated against ${url}`);
// ─── 1. categories collection ──────────────────────────────────────
const cols = await pb.collections.getFullList();
let catsCol = cols.find((c) => c.name === "categories");
if (catsCol) {
console.log(`[sprint] categories collection already exists (${catsCol.id})`);
} else {
const def = JSON.parse(
readFileSync(
resolve(process.cwd(), "docs/pocketbase/categories.collection.json"),
"utf8",
),
);
catsCol = await pb.collections.create(def);
console.log(`[sprint] Created categories collection (${catsCol.id})`);
}
const catsId = catsCol.id;
// ─── 2. seed categories (idempotent via slug lookup) ───────────────
console.log(`[sprint] Seeding ${SEED_CATEGORIES.length} categories…`);
for (const seed of SEED_CATEGORIES) {
try {
const existing = await pb
.collection("categories")
.getFirstListItem(`slug = "${seed.slug}"`);
console.log(`[sprint] ${seed.slug} exists (${existing.id}) — skip`);
} catch (e: unknown) {
const status = (e as { status?: number }).status;
if (status === 404) {
const created = await pb.collection("categories").create(seed);
console.log(`[sprint] seeded ${seed.slug} (${created.id})`);
} else {
throw e;
}
}
}
// ─── 3. quote_items: add 9 new fields (idempotent) ────────────────
const qi = await pb.collections.getOne("quote_items");
const existing = new Set(qi.fields.map((f: { name: string }) => f.name));
const newFields: Array<Record<string, unknown>> = [];
if (!existing.has("category")) {
newFields.push({
name: "category",
type: "relation",
required: false,
collectionId: catsId,
maxSelect: 1,
minSelect: 0,
cascadeDelete: false,
});
}
if (!existing.has("claim_status")) {
newFields.push({
name: "claim_status",
type: "select",
required: false,
maxSelect: 1,
values: [
"unclaimed",
"claimed",
"priced",
"awaiting_supplier",
"send_failed",
],
});
}
if (!existing.has("claimed_by")) {
newFields.push({
name: "claimed_by",
type: "text",
required: false,
options: { max: 100, pattern: "" },
});
}
if (!existing.has("claimed_at")) {
newFields.push({
name: "claimed_at",
type: "date",
required: false,
options: { min: "", max: "" },
});
}
if (!existing.has("claim_expires_at")) {
newFields.push({
name: "claim_expires_at",
type: "date",
required: false,
options: { min: "", max: "" },
});
}
if (!existing.has("outbound_message_id")) {
newFields.push({
name: "outbound_message_id",
type: "text",
required: false,
options: { max: 255, pattern: "" },
});
}
if (!existing.has("outbound_sent_at")) {
newFields.push({
name: "outbound_sent_at",
type: "date",
required: false,
options: { min: "", max: "" },
});
}
if (!existing.has("supplier_replied_at")) {
newFields.push({
name: "supplier_replied_at",
type: "date",
required: false,
options: { min: "", max: "" },
});
}
if (!existing.has("supplier_reply_count")) {
newFields.push({
name: "supplier_reply_count",
type: "number",
required: false,
options: { min: 0, max: null, noDecimal: true },
});
}
if (newFields.length > 0) {
await pb.collections.update(qi.id, {
fields: [...qi.fields, ...newFields],
});
console.log(
`[sprint] Added quote_items fields: ${newFields
.map((f) => (f as { name: string }).name)
.join(", ")}`,
);
} else {
console.log("[sprint] All claim fields already present — skip");
}
// ─── 4. quote_items: add 3 indexes (idempotent) ──────────────────
const fresh = await pb.collections.getOne("quote_items");
const idxs = (fresh.indexes ?? []) as string[];
const newIndexes: string[] = [];
if (!idxs.some((i) => i.includes("idx_qi_claim_status"))) {
newIndexes.push(
"CREATE INDEX `idx_qi_claim_status` ON `quote_items` (`claim_status`)",
);
}
if (!idxs.some((i) => i.includes("idx_qi_outbound_message_id"))) {
newIndexes.push(
"CREATE INDEX `idx_qi_outbound_message_id` ON `quote_items` (`outbound_message_id`)",
);
}
if (!idxs.some((i) => i.includes("idx_qi_category"))) {
newIndexes.push(
"CREATE INDEX `idx_qi_category` ON `quote_items` (`category`)",
);
}
if (newIndexes.length > 0) {
await pb.collections.update(fresh.id, {
indexes: [...idxs, ...newIndexes],
});
console.log(
`[sprint] Added indexes: ${newIndexes
.map((i) => i.match(/`([^`]+)`/)?.[1])
.filter(Boolean)
.join(", ")}`,
);
} else {
console.log("[sprint] All sprint indexes already present — skip");
}
// ─── 5. backfill quote_items.category from price_list.brand ───────
console.log(
"[sprint] Backfilling quote_items.category from price_list.brand…",
);
const catsRows = await pb.collection("categories").getFullList({
batch: 50,
});
const brandToCat = new Map<string, string>();
for (const c of catsRows) {
const brands = String(c.match_brands || "")
.split(",")
.map((s) => s.trim().toLowerCase())
.filter(Boolean);
for (const b of brands) brandToCat.set(b, c.id);
}
console.log(
`[sprint] brand→category map: ${[...brandToCat.entries()]
.map(([b, id]) => `${b}→${id.slice(0, 6)}…`)
.join(", ") || "(empty)"}`,
);
let page = 1;
let scanned = 0;
let updated = 0;
let skipped = 0;
while (true) {
const batch = await pb.collection("quote_items").getList(page, 200, {
filter: 'matched_price_list_id != "" && category = ""',
fields: "id,matched_price_list_id",
});
if (batch.items.length === 0) break;
for (const item of batch.items) {
scanned++;
try {
const pl = await pb
.collection("price_list")
.getOne(item.matched_price_list_id, { fields: "brand" });
const catId = brandToCat.get(
String(pl.brand || "").toLowerCase().trim(),
);
if (catId) {
await pb.collection("quote_items").update(item.id, {
category: catId,
});
updated++;
} else {
skipped++;
}
} catch (_e) {
// price_list row deleted/missing — skip silently
skipped++;
}
}
if (batch.page >= batch.totalPages) break;
page++;
}
console.log(
`[sprint] scanned ${scanned}, backfilled ${updated}, skipped ${skipped}`,
);
// ─── 6. backfill quote_items.claim_status from is_processed ───────
console.log(
"[sprint] Backfilling quote_items.claim_status from is_processed…",
);
let bp = 1;
let bs = 0;
let bu = 0;
while (true) {
const batch = await pb.collection("quote_items").getList(bp, 200, {
filter: 'claim_status = "" || claim_status = null',
fields: "id,is_processed",
});
if (batch.items.length === 0) break;
for (const item of batch.items) {
bs++;
const status = item.is_processed ? "priced" : "unclaimed";
await pb.collection("quote_items").update(item.id, {
claim_status: status,
});
bu++;
}
if (batch.page >= batch.totalPages) break;
bp++;
}
console.log(`[sprint] scanned ${bs}, set claim_status on ${bu}`);
console.log("[sprint] Migration complete.");
}
main().catch((err) => {
console.error("[sprint] Migration failed:", err);
process.exit(1);
});