feat: sales activities, forecast products, catalog categories, member cache, procurement filters, and comprehensive tests

New features:
- ActivityController and manager for CW sales activities (CRUD)
- ForecastProductController for opportunity forecast/product lines
- CW member cache with dual-layer (in-memory + Redis) resolution
- Catalog category/subcategory/ecosystem taxonomy module
- Quote statuses type definitions with CW mapping
- User-defined fields (UDF) module with cache and event refresh
- Company sites CW module with serialization
- Procurement manager filters (category, ecosystem, manufacturer, price, stock)
- Opportunity notes CRUD and product line management via CW API
- Opportunity type definitions endpoint

Updates:
- OpportunityController: CW refresh, company hydration, activities, custom fields
- UserController: cwIdentifier field for CW member linking
- CatalogItemController: category/subcategory fields from CW
- PermissionNodes: sales note/product CRUD nodes, subCategories, collectPermissions
- API routes: procurement categories/filters, sales notes/products, opportunity types
- Global events: UDF and member refresh intervals on startup

Tests (414 passing):
- ActivityController, ForecastProductController, OpportunityController unit tests
- UserController cwIdentifier tests
- catalogCategories, companySites, memberCache, procurement module tests
- activityTypes, opportunityTypes, quoteStatuses type tests
- permissionNodes subCategories and getAllPermissionNodes tests
- Updated test setup with redis mock, API method mocks, and builder helpers
This commit is contained in:
2026-03-01 13:19:00 -06:00
parent 883b648d5e
commit d7b374f8ab
96 changed files with 7752 additions and 205 deletions
+93
View File
@@ -0,0 +1,93 @@
/**
* One-time backfill script to populate category/subcategory fields
* on existing CatalogItem records from ConnectWise data.
*
* Usage: bun utils/backfillCatalogCategories.ts
*/
import { prisma, connectWiseApi } from "../src/constants";
interface CWReference {
id: number;
name: string;
}
interface CatalogItemPartial {
id: number;
category: CWReference;
subcategory: CWReference;
}
async function main() {
// 1. Find all DB items that are missing category data
const dbItems = await prisma.catalogItem.findMany({
where: { category: null },
select: { cwCatalogId: true, id: true },
});
if (dbItems.length === 0) {
console.log("All catalog items already have category data. Nothing to do.");
return;
}
console.log(`Found ${dbItems.length} catalog items missing category data.`);
// 2. Fetch all items from CW with category/subcategory fields
const pageSize = 1000;
const countRes = await connectWiseApi.get("/procurement/catalog/count");
const totalCount = countRes.data.count;
const totalPages = Math.ceil(totalCount / pageSize);
const cwMap = new Map<
number,
{ category: CWReference; subcategory: CWReference }
>();
for (let page = 1; page <= totalPages; page++) {
const res = await connectWiseApi.get(
`/procurement/catalog?page=${page}&pageSize=${pageSize}&fields=id,category,subcategory`,
);
const items: CatalogItemPartial[] = res.data;
for (const item of items) {
cwMap.set(item.id, {
category: item.category,
subcategory: item.subcategory,
});
}
}
console.log(`Fetched ${cwMap.size} items from CW. Updating DB...`);
// 3. Batch update
let updated = 0;
const batchSize = 50;
for (let i = 0; i < dbItems.length; i += batchSize) {
const batch = dbItems.slice(i, i + batchSize);
await Promise.all(
batch.map(async (dbItem) => {
const cwData = cwMap.get(dbItem.cwCatalogId);
if (!cwData) return;
await prisma.catalogItem.update({
where: { id: dbItem.id },
data: {
category: cwData.category?.name ?? null,
categoryCwId: cwData.category?.id ?? null,
subcategory: cwData.subcategory?.name ?? null,
subcategoryCwId: cwData.subcategory?.id ?? null,
},
});
updated++;
}),
);
console.log(
` Updated ${Math.min(i + batchSize, dbItems.length)}/${dbItems.length}...`,
);
}
console.log(`Done. Updated ${updated} catalog items with category data.`);
}
main()
.catch(console.error)
.finally(() => prisma.$disconnect());
+123
View File
@@ -0,0 +1,123 @@
/**
* Quick utility to fetch all distinct categories, subcategories, and manufacturers
* from the ConnectWise catalog and print them for reference.
*/
import { connectWiseApi } from "../src/constants";
interface CWReference {
id: number;
name: string;
}
interface CatalogItem {
id: number;
identifier: string;
description: string;
category: CWReference;
subcategory: CWReference;
manufacturer: CWReference;
inactiveFlag: boolean;
}
async function main() {
const pageSize = 1000;
// Get total count
const countRes = await connectWiseApi.get("/procurement/catalog/count");
const totalCount = countRes.data.count;
const totalPages = Math.ceil(totalCount / pageSize);
console.log(`Total catalog items: ${totalCount}`);
const categories = new Map<number, string>();
const subcategories = new Map<
number,
{ name: string; categoryId: number; categoryName: string }
>();
const manufacturers = new Map<number, string>();
const catSubcatPairs = new Map<
string,
{ category: string; subcategory: string; count: number }
>();
for (let page = 1; page <= totalPages; page++) {
const res = await connectWiseApi.get(
`/procurement/catalog?page=${page}&pageSize=${pageSize}&fields=id,identifier,description,category,subcategory,manufacturer,inactiveFlag`,
);
const items: CatalogItem[] = res.data;
for (const item of items) {
if (item.category) {
categories.set(item.category.id, item.category.name);
}
if (item.subcategory) {
subcategories.set(item.subcategory.id, {
name: item.subcategory.name,
categoryId: item.category?.id,
categoryName: item.category?.name,
});
}
if (item.manufacturer) {
manufacturers.set(item.manufacturer.id, item.manufacturer.name);
}
const key = `${item.category?.name ?? "None"}::${item.subcategory?.name ?? "None"}`;
const existing = catSubcatPairs.get(key);
if (existing) {
existing.count++;
} else {
catSubcatPairs.set(key, {
category: item.category?.name ?? "None",
subcategory: item.subcategory?.name ?? "None",
count: 1,
});
}
}
}
console.log("\n=== CATEGORIES ===");
const sortedCats = [...categories.entries()].sort((a, b) =>
a[1].localeCompare(b[1]),
);
for (const [id, name] of sortedCats) {
console.log(` [${id}] ${name}`);
}
console.log("\n=== SUBCATEGORIES (grouped by category) ===");
const groupedSubs = new Map<string, { id: number; name: string }[]>();
for (const [id, sub] of subcategories) {
const catName = sub.categoryName ?? "None";
if (!groupedSubs.has(catName)) groupedSubs.set(catName, []);
groupedSubs.get(catName)!.push({ id, name: sub.name });
}
for (const [catName, subs] of [...groupedSubs.entries()].sort((a, b) =>
a[0].localeCompare(b[0]),
)) {
console.log(`\n ${catName}:`);
for (const sub of subs.sort((a, b) => a.name.localeCompare(b.name))) {
console.log(` [${sub.id}] ${sub.name}`);
}
}
console.log("\n=== MANUFACTURERS ===");
const sortedMfgs = [...manufacturers.entries()].sort((a, b) =>
a[1].localeCompare(b[1]),
);
for (const [id, name] of sortedMfgs) {
console.log(` [${id}] ${name}`);
}
console.log("\n=== CATEGORY → SUBCATEGORY PAIRS (with item counts) ===");
const sortedPairs = [...catSubcatPairs.values()].sort(
(a, b) =>
a.category.localeCompare(b.category) ||
a.subcategory.localeCompare(b.subcategory),
);
for (const pair of sortedPairs) {
console.log(
` ${pair.category}${pair.subcategory} (${pair.count} items)`,
);
}
}
main().catch(console.error);