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
408 lines
No EOL
13 KiB
TypeScript
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);
|
|
}); |