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
+13 -5
View File
@@ -5,6 +5,7 @@ import { apiResponse } from "../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../middleware/authorization";
import GenericError from "../../../Errors/GenericError";
import { processObjectValuePerms } from "../../../modules/permission-utils/processObjectPermissions";
/* /v1/company/companies/[id] */
export default createRoute(
@@ -42,13 +43,20 @@ export default createRoute(
}
}
const companyData = company.toJson({
includeAddress,
includePrimaryContact,
includeAllContacts,
});
const gatedData = await processObjectValuePerms(
companyData,
"obj.company",
c.get("user"),
);
const response = apiResponse.successful(
"Company Fetched Successfully!",
company.toJson({
includeAddress,
includePrimaryContact,
includeAllContacts,
}),
gatedData,
);
return c.json(response, response.status as ContentfulStatusCode);
},
+9 -1
View File
@@ -4,6 +4,7 @@ import { companies } from "../../../managers/companies";
import { apiResponse } from "../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../middleware/authorization";
import { processObjectValuePerms } from "../../../modules/permission-utils/processObjectPermissions";
/* GET /v1/company/companies/:identifier/unifi/sites */
export default createRoute(
@@ -12,9 +13,16 @@ export default createRoute(
async (c) => {
const company = await companies.fetch(c.req.param("identifier"));
const sites = await unifiSites.fetchByCompany(company.id);
const gatedData = await Promise.all(
sites.map((site) =>
processObjectValuePerms(site, "obj.unifiSite", c.get("user")),
),
);
const response = apiResponse.successful(
"Company UniFi Sites Fetched Successfully!",
sites,
gatedData,
);
return c.json(response, response.status as ContentfulStatusCode);
},
+8 -1
View File
@@ -4,6 +4,7 @@ import { companies } from "../../managers/companies";
import { apiResponse } from "../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../middleware/authorization";
import { processObjectValuePerms } from "../../modules/permission-utils/processObjectPermissions";
/* /v1/company/companies */
export default createRoute(
@@ -22,9 +23,15 @@ export default createRoute(
? (await companies.search(search, 1, 999999)).length
: await companies.count();
const gatedData = await Promise.all(
data.map((item) =>
processObjectValuePerms(item, "obj.company", c.get("user")),
),
);
let response = apiResponse.successful(
"Companies Fetched Successfully!",
data,
gatedData,
{
pagination: {
previousPage: page == 1 ? null : page - 1, // Previous Page
+8 -1
View File
@@ -4,6 +4,7 @@ import { credentialTypes } from "../../managers/credentialTypes";
import { apiResponse } from "../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../middleware/authorization";
import { processObjectValuePerms } from "../../modules/permission-utils/processObjectPermissions";
/* /v1/credential-type/:identifier */
export default createRoute(
@@ -15,9 +16,15 @@ export default createRoute(
c.req.param("identifier"),
);
const gatedData = await processObjectValuePerms(
credentialType.toJson({ includeCredentialCount: true }),
"obj.credentialType",
c.get("user"),
);
const response = apiResponse.successful(
"Credential Type Fetched Successfully!",
credentialType.toJson({ includeCredentialCount: true }),
gatedData,
);
return c.json(response, response.status as ContentfulStatusCode);
},
+12 -3
View File
@@ -4,6 +4,7 @@ import { credentialTypes } from "../../managers/credentialTypes";
import { apiResponse } from "../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../middleware/authorization";
import { processObjectValuePerms } from "../../modules/permission-utils/processObjectPermissions";
/* /v1/credential-type */
export default createRoute(
@@ -13,11 +14,19 @@ export default createRoute(
async (c) => {
const allCredentialTypes = await credentialTypes.fetchAll();
const gatedData = await Promise.all(
allCredentialTypes.map((ct) =>
processObjectValuePerms(
ct.toJson({ includeCredentialCount: true }),
"obj.credentialType",
c.get("user"),
),
),
);
const response = apiResponse.successful(
"Credential Types Fetched Successfully!",
allCredentialTypes.map((ct) =>
ct.toJson({ includeCredentialCount: true }),
),
gatedData,
);
return c.json(response, response.status as ContentfulStatusCode);
},
+8 -1
View File
@@ -4,6 +4,7 @@ import { credentialTypes } from "../../managers/credentialTypes";
import { apiResponse } from "../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../middleware/authorization";
import { processObjectValuePerms } from "../../modules/permission-utils/processObjectPermissions";
/* /v1/credential-type/:id/credentials */
export default createRoute(
@@ -14,9 +15,15 @@ export default createRoute(
const credentialType = await credentialTypes.fetch(c.req.param("id"));
const credentials = await credentialType.fetchCredentials();
const gatedData = await Promise.all(
credentials.map((cred) =>
processObjectValuePerms(cred.toJson(), "obj.credential", c.get("user")),
),
);
const response = apiResponse.successful(
"Credentials Fetched Successfully!",
credentials.map((cred) => cred.toJson()),
gatedData,
);
return c.json(response, response.status as ContentfulStatusCode);
},
+7 -1
View File
@@ -4,6 +4,7 @@ import { credentials } from "../../managers/credentials";
import { apiResponse } from "../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../middleware/authorization";
import { processObjectValuePerms } from "../../modules/permission-utils/processObjectPermissions";
/* /v1/credential/:id */
export default createRoute(
@@ -12,10 +13,15 @@ export default createRoute(
async (c) => {
const credential = await credentials.fetch(c.req.param("id"));
const gatedData = await processObjectValuePerms(
credential.toJson(),
"obj.credential",
c.get("user"),
);
const response = apiResponse.successful(
"Credential Fetched Successfully!",
credential.toJson(),
gatedData,
);
return c.json(response, response.status as ContentfulStatusCode);
},
+8 -1
View File
@@ -4,6 +4,7 @@ import { credentials } from "../../managers/credentials";
import { apiResponse } from "../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../middleware/authorization";
import { processObjectValuePerms } from "../../modules/permission-utils/processObjectPermissions";
/* /v1/credential/company/:companyId */
export default createRoute(
@@ -15,9 +16,15 @@ export default createRoute(
c.req.param("companyId"),
);
const gatedData = await Promise.all(
companyCredentials.map((cred) =>
processObjectValuePerms(cred.toJson(), "obj.credential", c.get("user")),
),
);
const response = apiResponse.successful(
"Company Credentials Fetched Successfully!",
companyCredentials.map((cred) => cred.toJson()),
gatedData,
);
return c.json(response, response.status as ContentfulStatusCode);
},
+8 -1
View File
@@ -3,6 +3,7 @@ import { credentials } from "../../managers/credentials";
import { apiResponse } from "../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../middleware/authorization";
import { processObjectValuePerms } from "../../modules/permission-utils/processObjectPermissions";
/* GET /v1/credential/credentials/:id/sub-credentials */
export default createRoute(
@@ -17,9 +18,15 @@ export default createRoute(
const subCredentials = await credentials.fetchSubCredentials(parentId);
const gatedData = await Promise.all(
subCredentials.map((sc) =>
processObjectValuePerms(sc.toJson(), "obj.credential", c.get("user")),
),
);
const response = apiResponse.successful(
"Sub-Credentials Fetched Successfully!",
subCredentials.map((sc) => sc.toJson()),
gatedData,
);
return c.json(response, response.status as ContentfulStatusCode);
},
+8 -1
View File
@@ -3,6 +3,7 @@ import { procurement } from "../../../managers/procurement";
import { apiResponse } from "../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../middleware/authorization";
import { processObjectValuePerms } from "../../../modules/permission-utils/processObjectPermissions";
/* /v1/procurement/items/:identifier */
export default createRoute(
@@ -14,9 +15,15 @@ export default createRoute(
const item = await procurement.fetchItem(identifier);
const gatedData = await processObjectValuePerms(
item.toJson({ includeLinkedItems }),
"obj.catalogItem",
c.get("user"),
);
const response = apiResponse.successful(
"Catalog item fetched successfully!",
item.toJson({ includeLinkedItems }),
gatedData,
);
return c.json(response, response.status as ContentfulStatusCode);
+8 -1
View File
@@ -3,6 +3,7 @@ import { procurement } from "../../../managers/procurement";
import { apiResponse } from "../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../middleware/authorization";
import { processObjectValuePerms } from "../../../modules/permission-utils/processObjectPermissions";
/* GET /v1/procurement/items/:identifier/linked */
export default createRoute(
@@ -14,9 +15,15 @@ export default createRoute(
const linkedItems = item.getLinkedItems().map((linked) => linked.toJson());
const gatedData = await Promise.all(
linkedItems.map((linked) =>
processObjectValuePerms(linked, "obj.catalogItem", c.get("user")),
),
);
const response = apiResponse.successful(
"Linked catalog items fetched successfully!",
linkedItems,
gatedData,
);
return c.json(response, response.status as ContentfulStatusCode);
+26
View File
@@ -0,0 +1,26 @@
import { createRoute } from "../../modules/api-utils/createRoute";
import { apiResponse } from "../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../middleware/authorization";
import {
serializeCategoryTree,
serializeEcosystemTree,
} from "../../modules/catalog-categories/catalogCategories";
/* /v1/procurement/categories */
export default createRoute(
"get",
["/categories"],
async (c) => {
const categories = serializeCategoryTree();
const ecosystems = serializeEcosystemTree();
const response = apiResponse.successful(
"Category and ecosystem data fetched successfully!",
{ categories, ecosystems },
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["procurement.catalog.fetch.many"] }),
);
+45 -8
View File
@@ -1,8 +1,9 @@
import { createRoute } from "../../modules/api-utils/createRoute";
import { procurement } from "../../managers/procurement";
import { procurement, CatalogFilterOpts } from "../../managers/procurement";
import { apiResponse } from "../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../middleware/authorization";
import { processObjectValuePerms } from "../../modules/permission-utils/processObjectPermissions";
/* /v1/procurement/items */
export default createRoute(
@@ -14,17 +15,53 @@ export default createRoute(
const search = c.req.query("search") as string;
const includeInactive = c.req.query("includeInactive") === "true";
const data = search
? await procurement.search(search, page, rpp, { includeInactive })
: await procurement.fetchPages(page, rpp, { includeInactive });
// Category / filter params
const category = c.req.query("category") as string | undefined;
const subcategory = c.req.query("subcategory") as string | undefined;
const group = c.req.query("group") as string | undefined;
const manufacturer = c.req.query("manufacturer") as string | undefined;
const ecosystem = c.req.query("ecosystem") as string | undefined;
const inStock = c.req.query("inStock") === "true" ? true : undefined;
const minPrice = c.req.query("minPrice")
? Number(c.req.query("minPrice"))
: undefined;
const maxPrice = c.req.query("maxPrice")
? Number(c.req.query("maxPrice"))
: undefined;
const totalRecords = await procurement.count({
activeOnly: !includeInactive,
});
const filterOpts: CatalogFilterOpts = {
includeInactive,
category,
subcategory,
group,
manufacturer,
ecosystem,
inStock,
minPrice,
maxPrice,
};
const data = search
? await procurement.search(search, page, rpp, filterOpts)
: await procurement.fetchPages(page, rpp, filterOpts);
const totalRecords = search
? await procurement.countSearch(search, filterOpts)
: await procurement.count(filterOpts);
const gatedData = await Promise.all(
data.map((item) =>
processObjectValuePerms(
item.toJson(),
"obj.catalogItem",
c.get("user"),
),
),
);
const response = apiResponse.successful(
"Catalog items fetched successfully!",
data.map((item) => item.toJson()),
gatedData,
{
pagination: {
previousPage: page <= 1 ? null : page - 1,
+32
View File
@@ -0,0 +1,32 @@
import { createRoute } from "../../modules/api-utils/createRoute";
import { procurement } from "../../managers/procurement";
import { apiResponse } from "../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../middleware/authorization";
/* /v1/procurement/filters */
export default createRoute(
"get",
["/filters"],
async (c) => {
const category = c.req.query("category") as string | undefined;
const subcategory = c.req.query("subcategory") as string | undefined;
const includeInactive = c.req.query("includeInactive") === "true";
const filterOpts = { category, subcategory, includeInactive };
const [categories, subcategories, manufacturers] = await Promise.all([
procurement.fetchDistinctValues("category", filterOpts),
procurement.fetchDistinctValues("subcategory", filterOpts),
procurement.fetchDistinctValues("manufacturer", filterOpts),
]);
const response = apiResponse.successful(
"Available filter values fetched successfully!",
{ categories, subcategories, manufacturers },
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["procurement.catalog.fetch.many"] }),
);
+13 -1
View File
@@ -5,5 +5,17 @@ import { default as link } from "./[id]/link";
import { default as unlink } from "./[id]/unlink";
import { default as fetchLinked } from "./[id]/fetchLinked";
import { default as count } from "./count";
import { default as categories } from "./categories";
import { default as filters } from "./filters";
export { count, fetch, fetchAll, fetchLinked, link, refreshInventory, unlink };
export {
categories,
count,
fetch,
fetchAll,
fetchLinked,
filters,
link,
refreshInventory,
unlink,
};
+8 -1
View File
@@ -4,6 +4,7 @@ import { roles } from "../../managers/roles";
import { apiResponse } from "../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../middleware/authorization";
import { processObjectValuePerms } from "../../modules/permission-utils/processObjectPermissions";
/* GET /v1/role/:identifier */
export default createRoute(
@@ -15,9 +16,15 @@ export default createRoute(
const role = await roles.fetch(identifier);
const gatedData = await processObjectValuePerms(
role.toJson({ viewPermissions: true }),
"obj.role",
c.get("user"),
);
const response = apiResponse.successful(
"Role Fetched Successfully!",
role.toJson({ viewPermissions: true }),
gatedData,
);
return c.json(response, response.status as ContentfulStatusCode);
},
+10 -3
View File
@@ -4,6 +4,7 @@ import { roles } from "../../managers/roles";
import { apiResponse } from "../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../middleware/authorization";
import { processObjectValuePerms } from "../../modules/permission-utils/processObjectPermissions";
/* GET /v1/role */
export default createRoute(
@@ -13,13 +14,19 @@ export default createRoute(
async (c) => {
const allRoles = await roles.fetchAllRoles();
const rolesArray = allRoles.map((role) =>
role.toJson({ viewPermissions: true }),
const gatedData = await Promise.all(
allRoles.map((role) =>
processObjectValuePerms(
role.toJson({ viewPermissions: true }),
"obj.role",
c.get("user"),
),
),
);
const response = apiResponse.successful(
"Roles Fetched Successfully!",
rolesArray,
gatedData,
);
return c.json(response, response.status as ContentfulStatusCode);
},
+7 -2
View File
@@ -4,6 +4,7 @@ import { roles } from "../../managers/roles";
import { apiResponse } from "../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../middleware/authorization";
import { processObjectValuePerms } from "../../modules/permission-utils/processObjectPermissions";
/* GET /v1/role/:identifier/users */
export default createRoute(
@@ -16,11 +17,15 @@ export default createRoute(
const role = await roles.fetch(identifier);
const users = role.getUsers();
const usersArray = users.map((user) => user.toJson());
const gatedData = await Promise.all(
users.map((user) =>
processObjectValuePerms(user.toJson(), "obj.user", c.get("user")),
),
);
const response = apiResponse.successful(
"Users Fetched Successfully!",
usersArray,
gatedData,
);
return c.json(response, response.status as ContentfulStatusCode);
},
+1 -17
View File
@@ -3,7 +3,6 @@ import { opportunities } from "../../../managers/opportunities";
import { apiResponse } from "../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../middleware/authorization";
import { opportunityCw } from "../../../modules/cw-utils/opportunities/opportunities";
/* GET /v1/sales/opportunities/:identifier/contacts */
export default createRoute(
@@ -13,22 +12,7 @@ export default createRoute(
const identifier = c.req.param("identifier");
const item = await opportunities.fetchItem(identifier);
const contacts = await opportunityCw.fetchContacts(item.cwOpportunityId);
const data = contacts.map((ct) => ({
id: ct.id,
contact: ct.contact ? { id: ct.contact.id, name: ct.contact.name } : null,
company: ct.company
? {
id: ct.company.id,
identifier: ct.company.identifier,
name: ct.company.name,
}
: null,
role: ct.role ? { id: ct.role.id, name: ct.role.name } : null,
notes: ct.notes,
referralFlag: ct.referralFlag,
}));
const data = await item.fetchContacts();
const response = apiResponse.successful(
"Opportunity contacts fetched successfully!",
+47
View File
@@ -0,0 +1,47 @@
import { createRoute } from "../../../modules/api-utils/createRoute";
import { opportunities } from "../../../managers/opportunities";
import { apiResponse } from "../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../middleware/authorization";
import { resolveMember } from "../../../modules/cw-utils/members/memberCache";
import { z } from "zod";
/* POST /v1/sales/opportunities/:identifier/notes */
export default createRoute(
"post",
["/opportunities/:identifier/notes"],
async (c) => {
const identifier = c.req.param("identifier");
const body = await c.req.json();
const schema = z.object({
text: z.string().min(1, "Note text is required"),
flagged: z.boolean().optional(),
});
const data = schema.parse(body);
const item = await opportunities.fetchItem(identifier);
const user = c.get("user");
const created = await item.addNote(data.text, user.login, {
flagged: data.flagged,
});
const response = apiResponse.created(
"Opportunity note created successfully!",
{
id: created.id,
text: created.text,
type: created.type
? { id: created.type.id, name: created.type.name }
: null,
flagged: created.flagged,
enteredBy: await resolveMember(created.enteredBy),
},
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["sales.opportunity.note.create"] }),
);
+33
View File
@@ -0,0 +1,33 @@
import { createRoute } from "../../../modules/api-utils/createRoute";
import { opportunities } from "../../../managers/opportunities";
import { apiResponse } from "../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../middleware/authorization";
import GenericError from "../../../Errors/GenericError";
/* DELETE /v1/sales/opportunities/:identifier/notes/:noteId */
export default createRoute(
"delete",
["/opportunities/:identifier/notes/:noteId"],
async (c) => {
const identifier = c.req.param("identifier");
const noteId = Number(c.req.param("noteId"));
if (isNaN(noteId))
throw new GenericError({
status: 400,
name: "InvalidNoteId",
message: "Note ID must be a number",
});
const item = await opportunities.fetchItem(identifier);
await item.deleteNote(noteId);
const response = apiResponse.successful(
"Opportunity note deleted successfully!",
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["sales.opportunity.note.delete"] }),
);
+11 -1
View File
@@ -3,6 +3,7 @@ import { opportunities } from "../../../managers/opportunities";
import { apiResponse } from "../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../middleware/authorization";
import { processObjectValuePerms } from "../../../modules/permission-utils/processObjectPermissions";
/* GET /v1/sales/opportunities/:identifier */
export default createRoute(
@@ -13,9 +14,18 @@ export default createRoute(
const item = await opportunities.fetchItem(identifier);
// Eagerly load site data so toJson() includes full site info
await item.fetchSite();
const gatedData = await processObjectValuePerms(
item.toJson(),
"obj.opportunity",
c.get("user"),
);
const response = apiResponse.successful(
"Opportunity fetched successfully!",
item.toJson(),
gatedData,
);
return c.json(response, response.status as ContentfulStatusCode);
+34
View File
@@ -0,0 +1,34 @@
import { createRoute } from "../../../modules/api-utils/createRoute";
import { opportunities } from "../../../managers/opportunities";
import { apiResponse } from "../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../middleware/authorization";
import GenericError from "../../../Errors/GenericError";
/* GET /v1/sales/opportunities/:identifier/notes/:noteId */
export default createRoute(
"get",
["/opportunities/:identifier/notes/:noteId"],
async (c) => {
const identifier = c.req.param("identifier");
const noteId = Number(c.req.param("noteId"));
if (isNaN(noteId))
throw new GenericError({
status: 400,
name: "InvalidNoteId",
message: "Note ID must be a number",
});
const item = await opportunities.fetchItem(identifier);
const data = await item.fetchNote(noteId);
const response = apiResponse.successful(
"Opportunity note fetched successfully!",
data,
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["sales.opportunity.fetch"] }),
);
-39
View File
@@ -1,39 +0,0 @@
import { createRoute } from "../../../modules/api-utils/createRoute";
import { opportunities } from "../../../managers/opportunities";
import { apiResponse } from "../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../middleware/authorization";
import { opportunityCw } from "../../../modules/cw-utils/opportunities/opportunities";
/* GET /v1/sales/opportunities/:identifier/forecasts */
export default createRoute(
"get",
["/opportunities/:identifier/forecasts"],
async (c) => {
const identifier = c.req.param("identifier");
const item = await opportunities.fetchItem(identifier);
const forecasts = await opportunityCw.fetchForecasts(item.cwOpportunityId);
const data = forecasts.map((f) => ({
id: f.id,
forecastType: f.forecastType,
forecastMonth: f.forecastMonth,
revenue: f.revenue,
cost: f.cost,
forecastPercentage: f.forecastPercentage,
status: f.status ? { id: f.status.id, name: f.status.name } : null,
includedFlag: f.includedFlag,
linkedFlag: f.linkedFlag,
recurringFlag: f.recurringFlag,
}));
const response = apiResponse.successful(
"Opportunity forecasts fetched successfully!",
data,
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["sales.opportunity.fetch"] }),
);
+1 -10
View File
@@ -3,7 +3,6 @@ import { opportunities } from "../../../managers/opportunities";
import { apiResponse } from "../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../middleware/authorization";
import { opportunityCw } from "../../../modules/cw-utils/opportunities/opportunities";
/* GET /v1/sales/opportunities/:identifier/notes */
export default createRoute(
@@ -13,15 +12,7 @@ export default createRoute(
const identifier = c.req.param("identifier");
const item = await opportunities.fetchItem(identifier);
const notes = await opportunityCw.fetchNotes(item.cwOpportunityId);
const data = notes.map((n) => ({
id: n.id,
text: n.text,
type: n.type ? { id: n.type.id, name: n.type.name } : null,
flagged: n.flagged,
enteredBy: n.enteredBy,
}));
const data = await item.fetchNotes();
const response = apiResponse.successful(
"Opportunity notes fetched successfully!",
+25
View File
@@ -0,0 +1,25 @@
import { createRoute } from "../../../modules/api-utils/createRoute";
import { opportunities } from "../../../managers/opportunities";
import { apiResponse } from "../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../middleware/authorization";
/* GET /v1/sales/opportunities/:identifier/products */
export default createRoute(
"get",
["/opportunities/:identifier/products"],
async (c) => {
const identifier = c.req.param("identifier");
const item = await opportunities.fetchItem(identifier);
const data = await item.fetchProducts();
const response = apiResponse.successful(
"Opportunity products fetched successfully!",
data.map((p) => p.toJson()),
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["sales.opportunity.fetch"] }),
);
+44
View File
@@ -0,0 +1,44 @@
import { createRoute } from "../../../modules/api-utils/createRoute";
import { opportunities } from "../../../managers/opportunities";
import { apiResponse } from "../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../middleware/authorization";
import { z } from "zod";
/* PATCH /v1/sales/opportunities/:identifier/products/sequence */
export default createRoute(
"patch",
["/opportunities/:identifier/products/sequence"],
async (c) => {
const identifier = c.req.param("identifier");
const body = await c.req.json();
const schema = z.object({
orderedIds: z
.array(z.number().int().positive())
.min(1, "At least one forecast item ID is required"),
});
const { orderedIds } = schema.parse(body);
const item = await opportunities.fetchItem(identifier);
const updated = await item.resequenceProducts(orderedIds);
// Map original IDs to the new IDs returned by ConnectWise
const idMap: Record<number, number> = {};
for (let i = 0; i < orderedIds.length; i++) {
idMap[orderedIds[i]!] = updated[i]!.cwForecastId;
}
const response = apiResponse.successful(
"Product sequence updated successfully!",
{
products: updated.map((p) => p.toJson()),
idMap,
},
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["sales.opportunity.product.update"] }),
);
+57
View File
@@ -0,0 +1,57 @@
import { createRoute } from "../../../modules/api-utils/createRoute";
import { opportunities } from "../../../managers/opportunities";
import { apiResponse } from "../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../middleware/authorization";
import GenericError from "../../../Errors/GenericError";
import { resolveMember } from "../../../modules/cw-utils/members/memberCache";
import { z } from "zod";
/* PATCH /v1/sales/opportunities/:identifier/notes/:noteId */
export default createRoute(
"patch",
["/opportunities/:identifier/notes/:noteId"],
async (c) => {
const identifier = c.req.param("identifier");
const noteId = Number(c.req.param("noteId"));
if (isNaN(noteId))
throw new GenericError({
status: 400,
name: "InvalidNoteId",
message: "Note ID must be a number",
});
const body = await c.req.json();
const schema = z
.object({
text: z.string().min(1).optional(),
flagged: z.boolean().optional(),
})
.refine((d) => d.text !== undefined || d.flagged !== undefined, {
message: "At least one of 'text' or 'flagged' must be provided",
});
const data = schema.parse(body);
const item = await opportunities.fetchItem(identifier);
const updated = await item.updateNote(noteId, data);
const response = apiResponse.successful(
"Opportunity note updated successfully!",
{
id: updated.id,
text: updated.text,
type: updated.type
? { id: updated.type.id, name: updated.type.name }
: null,
flagged: updated.flagged,
enteredBy: await resolveMember(updated.enteredBy),
},
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["sales.opportunity.note.update"] }),
);
+15 -4
View File
@@ -3,6 +3,7 @@ import { opportunities } from "../../managers/opportunities";
import { apiResponse } from "../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../middleware/authorization";
import { processObjectValuePerms } from "../../modules/permission-utils/processObjectPermissions";
/* GET /v1/sales/opportunities */
export default createRoute(
@@ -18,13 +19,23 @@ export default createRoute(
? await opportunities.search(search, page, rpp, { includeClosed })
: await opportunities.fetchPages(page, rpp, { includeClosed });
const totalRecords = await opportunities.count({
openOnly: !includeClosed,
});
const totalRecords = search
? await opportunities.searchCount(search, { includeClosed })
: await opportunities.count({ openOnly: !includeClosed });
const gatedData = await Promise.all(
data.map((item) =>
processObjectValuePerms(
item.toJson(),
"obj.opportunity",
c.get("user"),
),
),
);
const response = apiResponse.successful(
"Opportunities fetched successfully!",
data.map((item) => item.toJson()),
gatedData,
{
pagination: {
previousPage: page <= 1 ? null : page - 1,
+20
View File
@@ -0,0 +1,20 @@
import { createRoute } from "../../modules/api-utils/createRoute";
import { apiResponse } from "../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../middleware/authorization";
import { QUOTE_STATUSES } from "../../types/QuoteStatuses";
/* GET /v1/sales/opportunity-types */
export default createRoute(
"get",
["/opportunity-types"],
async (c) => {
const response = apiResponse.successful(
"Opportunity Types Fetched Successfully!",
QUOTE_STATUSES,
);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ permissions: ["sales.opportunity.fetch.many"] }),
);
+22 -2
View File
@@ -1,9 +1,29 @@
import { default as fetchAll } from "./fetchAll";
import { default as fetchOpportunityTypes } from "./fetchOpportunityTypes";
import { default as count } from "./count";
import { default as fetch } from "./[id]/fetch";
import { default as refresh } from "./[id]/refresh";
import { default as forecasts } from "./[id]/forecasts";
import { default as products } from "./[id]/products";
import { default as resequenceProducts } from "./[id]/resequenceProducts";
import { default as notes } from "./[id]/notes";
import { default as fetchNote } from "./[id]/fetchNote";
import { default as createNote } from "./[id]/createNote";
import { default as updateNote } from "./[id]/updateNote";
import { default as deleteNote } from "./[id]/deleteNote";
import { default as contacts } from "./[id]/contacts";
export { count, fetch, fetchAll, forecasts, notes, contacts, refresh };
export {
count,
fetch,
fetchAll,
fetchOpportunityTypes,
products,
resequenceProducts,
notes,
fetchNote,
createNote,
updateNote,
deleteNote,
contacts,
refresh,
};
+9 -1
View File
@@ -3,6 +3,7 @@ import { unifiSites } from "../../../managers/unifiSites";
import { apiResponse } from "../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../middleware/authorization";
import { processObjectValuePerms } from "../../../modules/permission-utils/processObjectPermissions";
/* GET /v1/unifi/site/:id */
export default createRoute(
@@ -10,9 +11,16 @@ export default createRoute(
["/site/:id"],
async (c) => {
const site = await unifiSites.fetch(c.req.param("id"));
const gatedData = await processObjectValuePerms(
site,
"obj.unifiSite",
c.get("user"),
);
const response = apiResponse.successful(
"UniFi Site Fetched Successfully!",
site,
gatedData,
);
return c.json(response, response.status as ContentfulStatusCode);
},
+9 -1
View File
@@ -3,6 +3,7 @@ import { unifiSites } from "../../../managers/unifiSites";
import { apiResponse } from "../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../middleware/authorization";
import { processObjectValuePerms } from "../../../modules/permission-utils/processObjectPermissions";
/* GET /v1/unifi/sites */
export default createRoute(
@@ -10,9 +11,16 @@ export default createRoute(
["/sites"],
async (c) => {
const sites = await unifiSites.fetchAll();
const gatedData = await Promise.all(
sites.map((site) =>
processObjectValuePerms(site, "obj.unifiSite", c.get("user")),
),
);
const response = apiResponse.successful(
"UniFi Sites Fetched Successfully!",
sites,
gatedData,
);
return c.json(response, response.status as ContentfulStatusCode);
},
+7 -3
View File
@@ -2,16 +2,20 @@ import { ContentfulStatusCode } from "hono/utils/http-status";
import { apiResponse } from "../../../modules/api-utils/apiResponse";
import { createRoute } from "../../../modules/api-utils/createRoute";
import { authMiddleware } from "../../middleware/authorization";
import { processObjectValuePerms } from "../../../modules/permission-utils/processObjectPermissions";
// /v1/user/@me
export default createRoute(
"get",
["/@me"],
(c) => {
const response = apiResponse.successful(
"Fetched user.",
async (c) => {
const gatedData = await processObjectValuePerms(
c.get("user")?.toJson(),
"obj.user",
c.get("user"),
);
const response = apiResponse.successful("Fetched user.", gatedData);
return c.json(response, response.status as ContentfulStatusCode);
},
authMiddleware({ scopes: ["user.read"] }),
+8 -1
View File
@@ -4,6 +4,7 @@ import { createRoute } from "../../modules/api-utils/createRoute";
import { authMiddleware } from "../middleware/authorization";
import { users } from "../../managers/users";
import GenericError from "../../Errors/GenericError";
import { processObjectValuePerms } from "../../modules/permission-utils/processObjectPermissions";
/* GET /v1/user/users/:identifier */
export default createRoute(
@@ -21,9 +22,15 @@ export default createRoute(
status: 404,
});
const gatedData = await processObjectValuePerms(
user.toJson(),
"obj.user",
c.get("user"),
);
const response = apiResponse.successful(
"User Fetched Successfully!",
user.toJson(),
gatedData,
);
return c.json(response, response.status as ContentfulStatusCode);
},
+8 -2
View File
@@ -3,6 +3,7 @@ import { apiResponse } from "../../modules/api-utils/apiResponse";
import { createRoute } from "../../modules/api-utils/createRoute";
import { authMiddleware } from "../middleware/authorization";
import { users } from "../../managers/users";
import { processObjectValuePerms } from "../../modules/permission-utils/processObjectPermissions";
/* GET /v1/user/users */
export default createRoute(
@@ -11,11 +12,16 @@ export default createRoute(
async (c) => {
const allUsers = await users.fetchAllUsers();
const usersArray = allUsers.map((u) => u.toJson());
const gatedData = await Promise.all(
allUsers.map((u) =>
processObjectValuePerms(u.toJson(), "obj.user", c.get("user")),
),
);
const response = apiResponse.successful(
"Users Fetched Successfully!",
usersArray,
gatedData,
);
return c.json(response, response.status as ContentfulStatusCode);
},
+12 -2
View File
@@ -4,6 +4,7 @@ import { createRoute } from "../../modules/api-utils/createRoute";
import { authMiddleware } from "../middleware/authorization";
import { users } from "../../managers/users";
import GenericError from "../../Errors/GenericError";
import { processObjectValuePerms } from "../../modules/permission-utils/processObjectPermissions";
/* GET /v1/user/users/:identifier/roles */
export default createRoute(
@@ -22,11 +23,20 @@ export default createRoute(
});
const roles = await user.fetchRoles();
const rolesArray = roles.map((r) => r.toJson({ viewPermissions: true }));
const gatedData = await Promise.all(
roles.map((r) =>
processObjectValuePerms(
r.toJson({ viewPermissions: true }),
"obj.role",
c.get("user"),
),
),
);
const response = apiResponse.successful(
"User Roles Fetched Successfully!",
rolesArray,
gatedData,
);
return c.json(response, response.status as ContentfulStatusCode);
},
+5
View File
@@ -6,6 +6,7 @@ import { Server } from "socket.io";
import { Server as Engine } from "@socket.io/bun-engine";
import axios from "axios";
import { UnifiClient } from "./modules/unifi-api/UnifiClient";
import Redis from "ioredis";
const connectionString = `${process.env.DATABASE_URL}`;
const adapter = new PrismaPg({ connectionString });
@@ -22,6 +23,10 @@ export const API_BASE_URL =
export const prisma = new PrismaClient({ adapter });
// Redis Client
export const redis = new Redis(process.env.REDIS_URL!);
export const sessionDuration = 30 * 24 * 60 * 60000;
export const accessTokenDuration = "10min";
export const refreshTokenDuration = "30d";
+252
View File
@@ -0,0 +1,252 @@
import {
CWActivity,
CWActivityCustomField,
CWPatchOperation,
CWCreateActivity,
} from "../modules/cw-utils/activities/activity.types";
import { activityCw } from "../modules/cw-utils/activities/activities";
import { fetchActivity } from "../modules/cw-utils/activities/fetchActivity";
/**
* Activity Controller
*
* Domain model class that encapsulates a ConnectWise Activity entity.
* Activities are not persisted locally — all data is sourced directly
* from the ConnectWise API.
*/
export class ActivityController {
public readonly cwActivityId: number;
public name: string;
public notes: string | null;
public typeName: string | null;
public typeCwId: number | null;
public statusName: string | null;
public statusCwId: number | null;
public companyCwId: number | null;
public companyName: string | null;
public companyIdentifier: string | null;
public contactCwId: number | null;
public contactName: string | null;
public phoneNumber: string | null;
public email: string | null;
public opportunityCwId: number | null;
public opportunityName: string | null;
public ticketCwId: number | null;
public ticketName: string | null;
public agreementCwId: number | null;
public agreementName: string | null;
public campaignCwId: number | null;
public campaignName: string | null;
public assignToCwId: number | null;
public assignToName: string | null;
public assignToIdentifier: string | null;
public scheduleStatusCwId: number | null;
public scheduleStatusName: string | null;
public reminderCwId: number | null;
public reminderName: string | null;
public whereCwId: number | null;
public whereName: string | null;
public dateStart: Date | null;
public dateEnd: Date | null;
public notifyFlag: boolean;
public currencyCwId: number | null;
public currencyName: string | null;
public mobileGuid: string | null;
public customFields: CWActivityCustomField[];
public cwLastUpdated: Date | null;
public cwDateEntered: Date | null;
public cwEnteredBy: string | null;
public cwUpdatedBy: string | null;
constructor(data: CWActivity) {
this.cwActivityId = data.id;
this.name = data.name;
this.notes = data.notes ?? null;
this.typeName = data.type?.name ?? null;
this.typeCwId = data.type?.id ?? null;
this.statusName = data.status?.name ?? null;
this.statusCwId = data.status?.id ?? null;
this.companyCwId = data.company?.id ?? null;
this.companyName = data.company?.name ?? null;
this.companyIdentifier = data.company?.identifier ?? null;
this.contactCwId = data.contact?.id ?? null;
this.contactName = data.contact?.name ?? null;
this.phoneNumber = data.phoneNumber ?? null;
this.email = data.email ?? null;
this.opportunityCwId = data.opportunity?.id ?? null;
this.opportunityName = data.opportunity?.name ?? null;
this.ticketCwId = data.ticket?.id ?? null;
this.ticketName = data.ticket?.name ?? null;
this.agreementCwId = data.agreement?.id ?? null;
this.agreementName = data.agreement?.name ?? null;
this.campaignCwId = data.campaign?.id ?? null;
this.campaignName = data.campaign?.name ?? null;
this.assignToCwId = data.assignTo?.id ?? null;
this.assignToName = data.assignTo?.name ?? null;
this.assignToIdentifier = data.assignTo?.identifier ?? null;
this.scheduleStatusCwId = data.scheduleStatus?.id ?? null;
this.scheduleStatusName = data.scheduleStatus?.name ?? null;
this.reminderCwId = data.reminder?.id ?? null;
this.reminderName = data.reminder?.name ?? null;
this.whereCwId = data.where?.id ?? null;
this.whereName = data.where?.name ?? null;
this.dateStart = data.dateStart ? new Date(data.dateStart) : null;
this.dateEnd = data.dateEnd ? new Date(data.dateEnd) : null;
this.notifyFlag = data.notifyFlag ?? false;
this.currencyCwId = data.currency?.id ?? null;
this.currencyName = data.currency?.name ?? null;
this.mobileGuid = data.mobileGuid ?? null;
this.customFields = data.customFields ?? [];
this.cwLastUpdated = data._info?.lastUpdated
? new Date(data._info.lastUpdated)
: null;
this.cwDateEntered = data._info?.dateEntered
? new Date(data._info.dateEntered)
: null;
this.cwEnteredBy = data._info?.enteredBy ?? null;
this.cwUpdatedBy = data._info?.updatedBy ?? null;
}
/**
* Refresh from ConnectWise
*
* Fetches the latest activity data from CW and returns
* a new ActivityController instance with updated state.
*/
public async refreshFromCW(): Promise<ActivityController> {
const cwData = await fetchActivity(this.cwActivityId);
return new ActivityController(cwData);
}
/**
* Fetch raw CW data
*
* Returns the raw ConnectWise activity object.
*/
public async fetchCwData(): Promise<CWActivity> {
return fetchActivity(this.cwActivityId);
}
/**
* Update in ConnectWise
*
* Applies JSON Patch operations to this activity in ConnectWise
* and returns a new controller with the updated data.
*/
public async update(
operations: CWPatchOperation[],
): Promise<ActivityController> {
const updated = await activityCw.update(this.cwActivityId, operations);
return new ActivityController(updated);
}
/**
* Delete from ConnectWise
*
* Deletes this activity in ConnectWise.
*/
public async delete(): Promise<void> {
await activityCw.delete(this.cwActivityId);
}
/**
* Create Activity (static factory)
*
* Creates a new activity in ConnectWise and returns a controller instance.
*/
public static async create(
data: CWCreateActivity,
): Promise<ActivityController> {
const created = await activityCw.create(data);
return new ActivityController(created);
}
/**
* To JSON
*
* Serializes the activity into a safe, API-friendly object.
*/
public toJson(): Record<string, any> {
return {
cwActivityId: this.cwActivityId,
name: this.name,
notes: this.notes,
type: this.typeCwId ? { id: this.typeCwId, name: this.typeName } : null,
status: this.statusCwId
? { id: this.statusCwId, name: this.statusName }
: null,
company: this.companyCwId
? {
id: this.companyCwId,
identifier: this.companyIdentifier,
name: this.companyName,
}
: null,
contact: this.contactCwId
? { id: this.contactCwId, name: this.contactName }
: null,
phoneNumber: this.phoneNumber,
email: this.email,
opportunity: this.opportunityCwId
? { id: this.opportunityCwId, name: this.opportunityName }
: null,
ticket: this.ticketCwId
? { id: this.ticketCwId, name: this.ticketName }
: null,
agreement: this.agreementCwId
? { id: this.agreementCwId, name: this.agreementName }
: null,
campaign: this.campaignCwId
? { id: this.campaignCwId, name: this.campaignName }
: null,
assignTo: this.assignToCwId
? {
id: this.assignToCwId,
identifier: this.assignToIdentifier,
name: this.assignToName,
}
: null,
scheduleStatus: this.scheduleStatusCwId
? { id: this.scheduleStatusCwId, name: this.scheduleStatusName }
: null,
reminder: this.reminderCwId
? { id: this.reminderCwId, name: this.reminderName }
: null,
where: this.whereCwId
? { id: this.whereCwId, name: this.whereName }
: null,
dateStart: this.dateStart,
dateEnd: this.dateEnd,
notifyFlag: this.notifyFlag,
currency: this.currencyCwId
? { id: this.currencyCwId, name: this.currencyName }
: null,
mobileGuid: this.mobileGuid,
customFields: this.customFields,
cwLastUpdated: this.cwLastUpdated,
cwDateEntered: this.cwDateEntered,
cwEnteredBy: this.cwEnteredBy,
cwUpdatedBy: this.cwUpdatedBy,
};
}
}
+13
View File
@@ -21,6 +21,11 @@ export class CatalogItemController {
public readonly cwCatalogId: number;
public readonly identifier: string | null;
public category: string | null;
public categoryCwId: number | null;
public subcategory: string | null;
public subcategoryCwId: number | null;
public manufacturer: string | null;
public manufactureCwId: number | null;
public partNumber: string | null;
@@ -55,6 +60,10 @@ export class CatalogItemController {
this.internalNotes = itemData.internalNotes;
this.cwCatalogId = itemData.cwCatalogId;
this.identifier = itemData.identifier;
this.category = itemData.category;
this.categoryCwId = itemData.categoryCwId;
this.subcategory = itemData.subcategory;
this.subcategoryCwId = itemData.subcategoryCwId;
this.manufacturer = itemData.manufacturer;
this.manufactureCwId = itemData.manufactureCwId;
this.partNumber = itemData.partNumber;
@@ -196,6 +205,10 @@ export class CatalogItemController {
description: this.description,
customerDescription: this.customerDescription,
internalNotes: this.internalNotes,
category: this.category,
categoryCwId: this.categoryCwId,
subcategory: this.subcategory,
subcategoryCwId: this.subcategoryCwId,
manufacturer: this.manufacturer,
manufactureCwId: this.manufactureCwId,
partNumber: this.partNumber,
+63 -1
View File
@@ -1,7 +1,13 @@
import { Company } from "../../generated/prisma/client";
import { connectWiseApi } from "../constants";
import { fetchCwCompanyById } from "../modules/cw-utils/fetchCompany";
import { fetchCompanyConfigurations } from "../modules/cw-utils/configurations/fetchCompanyConfigurations";
import { updateCwInternalCompany } from "../modules/cw-utils/updateCompany";
import {
fetchCompanySites,
fetchCompanySite,
serializeCwSite,
} from "../modules/cw-utils/sites/companySites";
import { Company as CWCompany, Contact } from "../types/ConnectWiseTypes";
/**
@@ -16,7 +22,7 @@ export class CompanyController {
public name: string;
public readonly cw_Identifier: string;
public readonly cw_CompanyId: number;
public readonly cw_Data?: {
public cw_Data?: {
company: CWCompany;
defaultContact: Contact | null;
allContacts: Contact[];
@@ -30,6 +36,38 @@ export class CompanyController {
this.cw_Data = cwData;
}
/**
* Hydrate CW Data
*
* Fetches and populates the full ConnectWise company data
* (company, default contact, all contacts) if not already loaded.
*
* @returns {ThisType}
*/
public async hydrateCwData() {
if (this.cw_Data) return this;
const cwCompany = await fetchCwCompanyById(this.cw_CompanyId);
if (!cwCompany) return this;
const contactHref = cwCompany.defaultContact?._info?.contact_href;
const defaultContactData = contactHref
? await connectWiseApi.get(contactHref)
: undefined;
const allContactsData = await connectWiseApi.get(
`${cwCompany._info.contacts_href}&pageSize=1000`,
);
this.cw_Data = {
company: cwCompany,
defaultContact: defaultContactData?.data ?? null,
allContacts: allContactsData.data,
};
return this;
}
/**
* Refresh Internal Company Data from ConnectWise
*
@@ -71,6 +109,30 @@ export class CompanyController {
return data;
}
/**
* Fetch Company Sites
*
* Retrieves all sites for this company from ConnectWise
* and returns them as serialized site objects.
*/
public async fetchSites() {
const sites = await fetchCompanySites(this.cw_CompanyId);
return sites.map(serializeCwSite);
}
/**
* Fetch Company Site by ID
*
* Retrieves a single site by its ConnectWise site ID
* and returns a serialized site object.
*
* @param cwSiteId - The ConnectWise site ID
*/
public async fetchSite(cwSiteId: number) {
const site = await fetchCompanySite(this.cw_CompanyId, cwSiteId);
return serializeCwSite(site);
}
public toJson(opts?: {
includeAddress: boolean;
includePrimaryContact: boolean;
@@ -0,0 +1,226 @@
import { CWForecastItem } from "../modules/cw-utils/opportunities/opportunity.types";
/**
* Forecast Product Controller
*
* Domain model class that encapsulates a ConnectWise Forecast Item (product/
* revenue line item on an opportunity). Forecast products are not persisted
* locally — all data is sourced directly from the ConnectWise API.
*/
export class ForecastProductController {
public readonly cwForecastId: number;
public forecastDescription: string;
public opportunityCwId: number | null;
public opportunityName: string | null;
public quantity: number;
public statusCwId: number | null;
public statusName: string | null;
public catalogItemCwId: number | null;
public catalogItemIdentifier: string | null;
public productDescription: string;
public productClass: string;
public forecastType: string;
public revenue: number;
public cost: number;
public margin: number;
public percentage: number;
public includeFlag: boolean;
public linkFlag: boolean;
public recurringFlag: boolean;
public taxableFlag: boolean;
public recurringRevenue: number;
public recurringCost: number;
public cycles: number;
public sequenceNumber: number;
public subNumber: number;
public quoteWerksQuantity: number;
public cwLastUpdated: Date | null;
public cwUpdatedBy: string | null;
// Cancellation data (from procurement products endpoint)
public cancelledFlag: boolean;
public quantityCancelled: number;
public cancelledReason: string | null;
public cancelledBy: number | null;
public cancelledDate: Date | null;
// Internal inventory data (from local CatalogItem database)
public onHand: number | null;
public inStock: boolean | null;
constructor(data: CWForecastItem) {
this.cwForecastId = data.id;
this.forecastDescription = data.forecastDescription;
this.opportunityCwId = data.opportunity?.id ?? null;
this.opportunityName = data.opportunity?.name ?? null;
this.quantity = data.quantity;
this.statusCwId = data.status?.id ?? null;
this.statusName = data.status?.name ?? null;
this.catalogItemCwId = data.catalogItem?.id ?? null;
this.catalogItemIdentifier = data.catalogItem?.identifier ?? null;
this.productDescription = data.productDescription;
this.productClass = data.productClass;
this.forecastType = data.forecastType;
this.revenue = data.revenue;
this.cost = data.cost;
this.margin = data.margin;
this.percentage = data.percentage;
this.includeFlag = data.includeFlag ?? false;
this.linkFlag = data.linkFlag ?? false;
this.recurringFlag = data.recurringFlag ?? false;
this.taxableFlag = data.taxableFlag ?? false;
this.recurringRevenue = data.recurringRevenue ?? 0;
this.recurringCost = data.recurringCost ?? 0;
this.cycles = data.cycles ?? 0;
this.sequenceNumber = data.sequenceNumber ?? 0;
this.subNumber = data.subNumber ?? 0;
this.quoteWerksQuantity = data.quoteWerksQuantity ?? 0;
this.cwLastUpdated = data._info?.lastUpdated
? new Date(data._info.lastUpdated)
: null;
this.cwUpdatedBy = data._info?.updatedBy ?? null;
// Cancellation defaults — enriched later via applyCancellationData()
this.cancelledFlag = false;
this.quantityCancelled = 0;
this.cancelledReason = null;
this.cancelledBy = null;
this.cancelledDate = null;
// Inventory defaults — enriched later via applyInventoryData()
this.onHand = null;
this.inStock = null;
}
/**
* Apply Cancellation Data
*
* Enriches this forecast product with cancellation data from the
* procurement products endpoint.
*/
public applyCancellationData(data: {
cancelledFlag?: boolean;
quantityCancelled?: number;
cancelledReason?: string;
cancelledBy?: number;
cancelledDate?: string;
}): void {
this.cancelledFlag = data.cancelledFlag ?? false;
this.quantityCancelled = data.quantityCancelled ?? 0;
this.cancelledReason = data.cancelledReason ?? null;
this.cancelledBy = data.cancelledBy ?? null;
this.cancelledDate = data.cancelledDate
? new Date(data.cancelledDate)
: null;
}
/**
* Apply Inventory Data
*
* Enriches this forecast product with internal inventory data from
* the local CatalogItem database.
*/
public applyInventoryData(data: { onHand: number }): void {
this.onHand = data.onHand;
this.inStock = data.onHand > 0;
}
/**
* Profit
*
* Returns the calculated profit (revenue - cost).
*/
public get profit(): number {
return this.revenue - this.cost;
}
/**
* Cancelled
*
* Returns true if the forecast item has been cancelled (fully or partially).
*/
public get cancelled(): boolean {
return this.cancelledFlag;
}
/**
* Cancellation Type
*
* Returns the type of cancellation:
* - `"full"` — all units have been cancelled (`quantityCancelled >= quantity`)
* - `"partial"` — some units cancelled but not all
* - `null` — not cancelled
*/
public get cancellationType(): "full" | "partial" | null {
if (!this.cancelledFlag || this.quantityCancelled <= 0) return null;
return this.quantityCancelled >= this.quantity ? "full" : "partial";
}
/**
* To JSON
*
* Serializes the forecast product into a safe, API-friendly object.
*/
public toJson(): Record<string, any> {
return {
id: this.cwForecastId,
forecastDescription: this.forecastDescription,
opportunity: this.opportunityCwId
? { id: this.opportunityCwId, name: this.opportunityName }
: null,
quantity: this.quantity,
status: this.statusCwId
? { id: this.statusCwId, name: this.statusName }
: null,
cancelled: this.cancelled,
cancellationType: this.cancellationType,
quantityCancelled: this.quantityCancelled,
cancelledReason: this.cancelledReason,
cancelledDate: this.cancelledDate,
catalogItem: this.catalogItemCwId
? { id: this.catalogItemCwId, identifier: this.catalogItemIdentifier }
: null,
productDescription: this.productDescription,
productClass: this.productClass,
forecastType: this.forecastType,
revenue: this.revenue,
cost: this.cost,
margin: this.margin,
profit: this.profit,
percentage: this.percentage,
includeFlag: this.includeFlag,
linkFlag: this.linkFlag,
recurringFlag: this.recurringFlag,
taxableFlag: this.taxableFlag,
recurringRevenue: this.recurringRevenue,
recurringCost: this.recurringCost,
cycles: this.cycles,
sequenceNumber: this.sequenceNumber,
subNumber: this.subNumber,
cwLastUpdated: this.cwLastUpdated,
cwUpdatedBy: this.cwUpdatedBy,
onHand: this.onHand,
inStock: this.inStock,
};
}
}
+477 -7
View File
@@ -1,7 +1,22 @@
import { Opportunity } from "../../generated/prisma/client";
import { Company, Opportunity } from "../../generated/prisma/client";
import { prisma } from "../constants";
import { CompanyController } from "./CompanyController";
import { ActivityController } from "./ActivityController";
import { fetchOpportunity } from "../modules/cw-utils/opportunities/fetchOpportunity";
import { CWOpportunity } from "../modules/cw-utils/opportunities/opportunity.types";
import { opportunityCw } from "../modules/cw-utils/opportunities/opportunities";
import { activityCw } from "../modules/cw-utils/activities/activities";
import {
fetchCompanySite,
serializeCwSite,
} from "../modules/cw-utils/sites/companySites";
import {
CWCustomField,
CWOpportunity,
CWOpportunityNote,
} from "../modules/cw-utils/opportunities/opportunity.types";
import { resolveMember } from "../modules/cw-utils/members/memberCache";
import { ForecastProductController } from "./ForecastProductController";
import GenericError from "../Errors/GenericError";
/**
* Opportunity Controller
@@ -66,7 +81,19 @@ export class OpportunityController {
public readonly createdAt: Date;
public updatedAt: Date;
constructor(data: Opportunity) {
private _company: CompanyController | null = null;
private _siteData: ReturnType<typeof serializeCwSite> | null = null;
private _customFields: CWCustomField[] | null = null;
private _activities: ActivityController[] | null = null;
constructor(
data: Opportunity & { company?: Company | null },
opts?: {
company?: CompanyController;
customFields?: CWCustomField[];
activities?: ActivityController[];
},
) {
this.id = data.id;
this.cwOpportunityId = data.cwOpportunityId;
this.name = data.name;
@@ -121,6 +148,39 @@ export class OpportunityController {
this.createdAt = data.createdAt;
this.updatedAt = data.updatedAt;
this._company =
opts?.company ??
(data.company ? new CompanyController(data.company) : null);
this._customFields = opts?.customFields ?? null;
this._activities = opts?.activities ?? null;
}
/**
* Fetch Company
*
* Lazily loads the associated CompanyController from the database
* if not already loaded via the Prisma include.
*
* @returns {Promise<CompanyController | null>}
*/
public async fetchCompany(): Promise<CompanyController | null> {
if (this._company) {
await this._company.hydrateCwData();
return this._company;
}
if (!this.companyId) return null;
const companyData = await prisma.company.findUnique({
where: { id: this.companyId },
});
if (!companyData) return null;
this._company = new CompanyController(companyData);
await this._company.hydrateCwData();
return this._company;
}
/**
@@ -136,6 +196,7 @@ export class OpportunityController {
const updated = await prisma.opportunity.update({
where: { id: this.id },
data: mapped,
include: { company: true },
});
return new OpportunityController(updated);
@@ -216,6 +277,403 @@ export class OpportunityController {
};
}
/**
* Fetch Site
*
* Fetches the full site details (address, phone, flags) from ConnectWise
* for the site associated with this opportunity.
* Requires both companyCwId and siteCwId to be set.
*
* @returns Serialized site object or null
*/
public async fetchSite() {
if (this._siteData) return this._siteData;
if (!this.companyCwId || !this.siteCwId) return null;
const cwSite = await fetchCompanySite(this.companyCwId, this.siteCwId);
this._siteData = serializeCwSite(cwSite);
return this._siteData;
}
/**
* Fetch Contacts
*
* Fetches contacts associated with this opportunity from ConnectWise
* and returns a serialized array.
*/
public async fetchContacts() {
const contacts = await opportunityCw.fetchContacts(this.cwOpportunityId);
return contacts.map((ct) => ({
id: ct.id,
contact: ct.contact ? { id: ct.contact.id, name: ct.contact.name } : null,
company: ct.company
? {
id: ct.company.id,
identifier: ct.company.identifier,
name: ct.company.name,
}
: null,
role: ct.role ? { id: ct.role.id, name: ct.role.name } : null,
notes: ct.notes,
referralFlag: ct.referralFlag,
}));
}
/**
* Fetch Notes
*
* Fetches notes associated with this opportunity from ConnectWise
* and returns a serialized array.
*/
public async fetchNotes() {
const notes = await opportunityCw.fetchNotes(this.cwOpportunityId);
return Promise.all(
notes.map(async (n) => ({
id: n.id,
text: n.text,
type: n.type ? { id: n.type.id, name: n.type.name } : null,
flagged: n.flagged,
dateEntered: n._info?.lastUpdated
? new Date(n._info.lastUpdated)
: null,
enteredBy: await resolveMember(n.enteredBy),
})),
);
}
/**
* Fetch Single Note
*
* Fetches a single note by its ID from ConnectWise.
*
* @param noteId - The CW note ID
*/
public async fetchNote(noteId: number) {
const note = await opportunityCw.fetchNote(this.cwOpportunityId, noteId);
return {
id: note.id,
text: note.text,
type: note.type ? { id: note.type.id, name: note.type.name } : null,
flagged: note.flagged,
enteredBy: await resolveMember(note.enteredBy),
};
}
/**
* Fetch Activities
*
* Fetches activities associated with this opportunity from ConnectWise
* and returns an array of ActivityController instances.
* Results are cached after the first call.
*/
public async fetchActivities(): Promise<ActivityController[]> {
if (this._activities) return this._activities;
const collection = await activityCw.fetchByOpportunity(
this.cwOpportunityId,
);
this._activities = collection.map((item) => new ActivityController(item));
return this._activities;
}
/**
* Fetch Products
*
* Fetches products (forecast/revenue items) for this opportunity from
* ConnectWise and returns ForecastProductController instances.
*/
public async fetchProducts(): Promise<ForecastProductController[]> {
const [forecast, procProducts] = await Promise.all([
opportunityCw.fetchProducts(this.cwOpportunityId),
opportunityCw.fetchProcurementProducts(this.cwOpportunityId),
]);
// Build a map of forecastDetailId → procurement product cancellation data
const cancellationMap = new Map<number, Record<string, unknown>>();
for (const pp of procProducts) {
const forecastDetailId = pp.forecastDetailId as number | undefined;
if (forecastDetailId) {
cancellationMap.set(forecastDetailId, pp);
}
}
const controllers = (forecast.forecastItems ?? [])
.sort((a, b) => a.sequenceNumber - b.sequenceNumber)
.map((item) => {
const ctrl = new ForecastProductController(item);
const procData = cancellationMap.get(item.id);
if (procData) {
ctrl.applyCancellationData(procData as any);
}
return ctrl;
});
// Enrich with internal inventory data from local CatalogItem DB
const catalogCwIds = controllers
.map((c) => c.catalogItemCwId)
.filter((id): id is number => id !== null);
if (catalogCwIds.length > 0) {
const catalogItems = await prisma.catalogItem.findMany({
where: { cwCatalogId: { in: catalogCwIds } },
select: { cwCatalogId: true, onHand: true },
});
const inventoryMap = new Map(
catalogItems.map((ci) => [ci.cwCatalogId, ci]),
);
for (const ctrl of controllers) {
const inv = ctrl.catalogItemCwId
? inventoryMap.get(ctrl.catalogItemCwId)
: undefined;
if (inv) ctrl.applyInventoryData(inv);
}
}
return controllers;
}
// ---------------------------------------------------------------------------
// Opportunity Activity / Workflow Methods
// ---------------------------------------------------------------------------
/**
* Set Internal Review
*
* The quote is ready to be reviewed before it is ready to be sent.
*/
public async setInternalReview(): Promise<void> {
// TODO: implement
}
/**
* Set Internal Approved
*
* The quote has been approved and is ready to be sent out.
*/
public async setInternalApproved(): Promise<void> {
// TODO: implement
}
/**
* Set Quote Sent
*
* The quote has been sent to the customer.
*/
public async setQuoteSent(): Promise<void> {
// TODO: implement
}
/**
* Set Quote Confirmed
*
* The quote has been received by the customer.
*/
public async setQuoteConfirmed(): Promise<void> {
// TODO: implement
}
/**
* Set Revision Needed
*
* The quote needs to be revised and is set to stage revision.
*/
public async setRevisionNeeded(): Promise<void> {
// TODO: implement
}
/**
* Set Finalized
*
* Locks any non-admins from modifying the quote, indicating
* this is the final iteration of the quote.
*/
public async setFinalized(): Promise<void> {
// TODO: implement
}
/**
* Convert
*
* Converts the quote to a ticket and updates all necessary fields.
*/
public async convert(): Promise<void> {
// TODO: implement
}
/**
* Add Time
*
* Adds time to an activity on this opportunity.
*
* @param activityId - The CW activity ID to add time to
* @param user - The user identifier adding time
*/
public async addTime(activityId: number, user: string): Promise<void> {
// TODO: implement
}
/**
* Update Product
*
* Updates an existing product/line item on this opportunity via PATCH.
*
* @param forecastItemId - The CW forecast item ID to update
* @param data - Key/value pairs to patch
*/
public async updateProduct(
forecastItemId: number,
data: Record<string, unknown>,
): Promise<ForecastProductController> {
try {
const updated = await opportunityCw.updateProduct(
this.cwOpportunityId,
forecastItemId,
data,
);
return new ForecastProductController(updated);
} catch (err: any) {
console.error(
`[updateProduct] Failed to patch forecast item ${forecastItemId} on opportunity ${this.cwOpportunityId}`,
JSON.stringify(
{
data,
status: err?.response?.status,
statusText: err?.response?.statusText,
responseData: err?.response?.data,
message: err?.message,
},
null,
2,
),
);
throw err;
}
}
/**
* Resequence Products
*
* Updates the sequenceNumber on each forecast item to match the
* order provided. Fetches the current items first so the PUT
* includes all required fields. Expects an array of forecast item
* IDs in the desired order.
*
* @param orderedIds - Forecast item IDs in the desired sequence order
*/
public async resequenceProducts(
orderedIds: number[],
): Promise<ForecastProductController[]> {
// Fetch existing items so we can include required fields in the PUT
const forecast = await opportunityCw.fetchProducts(this.cwOpportunityId);
const itemMap = new Map(
(forecast.forecastItems ?? []).map((fi) => [fi.id, fi]),
);
// Validate all IDs exist before making any updates
for (const id of orderedIds) {
if (!itemMap.has(id)) {
throw new GenericError({
status: 404,
name: "ForecastItemNotFound",
message: `Forecast item ${id} not found on opportunity ${this.cwOpportunityId}`,
});
}
}
// Run updates in reverse order to CW
const results: ForecastProductController[] = new Array(orderedIds.length);
for (let index = orderedIds.length - 1; index >= 0; index--) {
const id = orderedIds[index]!;
const existing = itemMap.get(id)!;
const raw = JSON.parse(JSON.stringify(existing)) as Record<
string,
unknown
>;
// Strip read-only _info fields at top level and nested sub-objects
delete raw._info;
for (const key of ["opportunity", "status", "catalogItem"]) {
if (raw[key] && typeof raw[key] === "object") {
delete (raw[key] as Record<string, unknown>)._info;
}
}
const newSeq = index + 1;
const result = await this.updateProduct(id, {
...raw,
sequenceNumber: newSeq,
});
results[index] = result;
}
return results;
}
/**
* Add Product
*
* Adds a new product/line item to this opportunity.
*/
public async addProduct(): Promise<void> {
// TODO: implement
}
/**
* Add Note
*
* Creates a new note on this opportunity in ConnectWise.
*
* @param note - The note text to add
* @param user - The user identifier adding the note
* @param opts - Optional flags
*/
public async addNote(
note: string,
user: string,
opts?: { flagged?: boolean },
): Promise<CWOpportunityNote> {
const created = await opportunityCw.createNote(this.cwOpportunityId, {
text: note,
flagged: opts?.flagged ?? false,
});
return created;
}
/**
* Update Note
*
* Updates an existing note on this opportunity in ConnectWise.
*
* @param noteId - The CW note ID to update
* @param data - The fields to update
*/
public async updateNote(
noteId: number,
data: { text?: string; flagged?: boolean },
): Promise<CWOpportunityNote> {
const updated = await opportunityCw.updateNote(
this.cwOpportunityId,
noteId,
data,
);
return updated;
}
/**
* Delete Note
*
* Deletes a note from this opportunity in ConnectWise.
*
* @param noteId - The CW note ID to delete
*/
public async deleteNote(noteId: number): Promise<void> {
await opportunityCw.deleteNote(this.cwOpportunityId, noteId);
}
/**
* To JSON
*
@@ -258,13 +716,23 @@ export class OpportunityController {
name: this.secondarySalesRepName,
}
: null,
company: this.companyCwId
? { id: this.companyCwId, name: this.companyName }
: null,
company: this._company
? this._company.toJson({
includeAllContacts: true,
includeAddress: true,
includePrimaryContact: false,
})
: this.companyCwId
? { id: this.companyCwId, name: this.companyName }
: null,
contact: this.contactCwId
? { id: this.contactCwId, name: this.contactName }
: null,
site: this.siteCwId ? { id: this.siteCwId, name: this.siteName } : null,
site: this._siteData
? this._siteData
: this.siteCwId
? { id: this.siteCwId, name: this.siteName }
: null,
customerPO: this.customerPO,
totalSalesTax: this.totalSalesTax,
location: this.locationCwId
@@ -285,6 +753,8 @@ export class OpportunityController {
cwLastUpdated: this.cwLastUpdated,
createdAt: this.createdAt,
updatedAt: this.updatedAt,
customFields: this._customFields ?? [],
activities: this._activities?.map((a) => a.toJson()) ?? [],
};
}
}
+4
View File
@@ -19,6 +19,7 @@ export default class UserController {
public login: string;
public email: string;
public image: string | null;
public cwIdentifier: string | null;
private _roles: Collection<string, Role>;
private _permissions: string | null;
@@ -31,6 +32,7 @@ export default class UserController {
this.login = userdata.login;
this.email = userdata.email;
this.image = userdata.image;
this.cwIdentifier = userdata.cwIdentifier ?? null;
this.updatedAt = userdata.updatedAt;
this.createdAt = userdata.createdAt;
this._permissions = userdata.permissions ?? null;
@@ -57,6 +59,7 @@ export default class UserController {
this.login = userdata.login;
this.email = userdata.email;
this.image = userdata.image;
this.cwIdentifier = userdata.cwIdentifier ?? null;
this.updatedAt = userdata.updatedAt;
this.createdAt = userdata.createdAt;
}
@@ -314,6 +317,7 @@ export default class UserController {
})(),
login: opts?.safeReturn ? undefined : this.login,
email: opts?.safeReturn ? undefined : this.email,
cwIdentifier: opts?.safeReturn ? undefined : this.cwIdentifier,
image: this.image,
createdAt: this.createdAt,
updatedAt: this.updatedAt,
+24
View File
@@ -13,6 +13,8 @@ import { refreshCompanies } from "./modules/cw-utils/refreshCompanies";
import { refreshCatalog } from "./modules/cw-utils/procurement/refreshCatalog";
import { refreshInventory } from "./modules/cw-utils/procurement/refreshInventory";
import { refreshOpportunities } from "./modules/cw-utils/opportunities/refreshOpportunities";
import { refreshCwIdentifiers } from "./modules/cw-utils/members/refreshCwIdentifiers";
import { userDefinedFieldsCw } from "./modules/cw-utils/userDefinedFields";
import { events, setupEventDebugger } from "./modules/globalEvents";
import { signPermissions } from "./modules/permission-utils/signPermissions";
import { RoleController } from "./controllers/RoleController";
@@ -118,6 +120,28 @@ setInterval(() => {
);
}, 60 * 1000);
// Refresh User Defined Fields every 5 minutes
await safeStartup("refreshUDFs", () => userDefinedFieldsCw.refresh());
setInterval(
() => {
return userDefinedFieldsCw
.refresh()
.catch((err) => console.error("[interval] refreshUDFs failed", err));
},
5 * 60 * 1000,
);
// Refresh CW identifiers for all users every 30 minutes
await safeStartup("refreshCwIdentifiers", refreshCwIdentifiers);
setInterval(
() => {
return refreshCwIdentifiers().catch((err) =>
console.error("[interval] refreshCwIdentifiers failed", err),
);
},
30 * 60 * 1000,
);
await safeStartup("syncSites", () => unifiSites.syncSites());
setInterval(() => {
return unifiSites
+211
View File
@@ -0,0 +1,211 @@
import { ActivityController } from "../controllers/ActivityController";
import { connectWiseApi } from "../constants";
import GenericError from "../Errors/GenericError";
import { activityCw } from "../modules/cw-utils/activities/activities";
import {
CWCreateActivity,
CWPatchOperation,
} from "../modules/cw-utils/activities/activity.types";
export const activities = {
/**
* Fetch Activity
*
* Fetch a single activity by its ConnectWise activity ID
* and return an ActivityController instance.
*
* @param cwActivityId - The ConnectWise activity ID
* @returns {Promise<ActivityController>}
*/
async fetchItem(cwActivityId: number): Promise<ActivityController> {
try {
const cwData = await activityCw.fetch(cwActivityId);
return new ActivityController(cwData);
} catch (error) {
const errBody = (error as any).response?.data || error;
throw new GenericError({
name: "FetchActivityError",
message: `Failed to fetch activity ${cwActivityId}`,
cause: typeof errBody === "string" ? errBody : JSON.stringify(errBody),
status: (error as any).status ?? 502,
});
}
},
/**
* Fetch All Activities (Paginated)
*
* Fetches activities from ConnectWise with optional conditions and pagination.
*
* @param page - Page number (1-based)
* @param rpp - Records per page
* @param conditions - Optional CW conditions string for filtering
* @returns {Promise<ActivityController[]>}
*/
async fetchPages(
page: number,
rpp: number,
conditions?: string,
): Promise<ActivityController[]> {
try {
const pageNum = Math.max(page, 1);
const conditionsParam = conditions
? `&conditions=${encodeURIComponent(conditions)}`
: "";
const response = await connectWiseApi.get(
`/sales/activities?page=${pageNum}&pageSize=${rpp}${conditionsParam}`,
);
const items = response.data;
return items.map((item: any) => new ActivityController(item));
} catch (error) {
const errBody = (error as any).response?.data || error;
throw new GenericError({
name: "FetchActivitiesError",
message: "Failed to fetch activities from ConnectWise",
cause: typeof errBody === "string" ? errBody : JSON.stringify(errBody),
status: 502,
});
}
},
/**
* Fetch Activities by Company
*
* Fetches all activities for a company by its ConnectWise company ID.
*
* @param cwCompanyId - The ConnectWise company ID
* @returns {Promise<ActivityController[]>}
*/
async fetchByCompany(cwCompanyId: number): Promise<ActivityController[]> {
try {
const collection = await activityCw.fetchByCompany(cwCompanyId);
return collection.map((item) => new ActivityController(item));
} catch (error) {
const errBody = (error as any).response?.data || error;
throw new GenericError({
name: "FetchCompanyActivitiesError",
message: `Failed to fetch activities for company ${cwCompanyId}`,
cause: typeof errBody === "string" ? errBody : JSON.stringify(errBody),
status: 502,
});
}
},
/**
* Fetch Activities by Opportunity
*
* Fetches all activities for an opportunity by its ConnectWise opportunity ID.
*
* @param cwOpportunityId - The ConnectWise opportunity ID
* @returns {Promise<ActivityController[]>}
*/
async fetchByOpportunity(
cwOpportunityId: number,
): Promise<ActivityController[]> {
try {
const collection = await activityCw.fetchByOpportunity(cwOpportunityId);
return collection.map((item) => new ActivityController(item));
} catch (error) {
const errBody = (error as any).response?.data || error;
throw new GenericError({
name: "FetchOpportunityActivitiesError",
message: `Failed to fetch activities for opportunity ${cwOpportunityId}`,
cause: typeof errBody === "string" ? errBody : JSON.stringify(errBody),
status: 502,
});
}
},
/**
* Create Activity
*
* Creates a new activity in ConnectWise and returns an ActivityController.
*
* @param data - The activity data to create
* @returns {Promise<ActivityController>}
*/
async create(data: CWCreateActivity): Promise<ActivityController> {
try {
return await ActivityController.create(data);
} catch (error) {
const errBody = (error as any).response?.data || error;
throw new GenericError({
name: "CreateActivityError",
message: "Failed to create activity in ConnectWise",
cause: typeof errBody === "string" ? errBody : JSON.stringify(errBody),
status: 502,
});
}
},
/**
* Update Activity
*
* Updates an existing activity in ConnectWise using JSON Patch operations
* and returns an updated ActivityController.
*
* @param cwActivityId - The ConnectWise activity ID to update
* @param operations - Array of JSON Patch operations to apply
* @returns {Promise<ActivityController>}
*/
async update(
cwActivityId: number,
operations: CWPatchOperation[],
): Promise<ActivityController> {
try {
const updated = await activityCw.update(cwActivityId, operations);
return new ActivityController(updated);
} catch (error) {
const errBody = (error as any).response?.data || error;
throw new GenericError({
name: "UpdateActivityError",
message: `Failed to update activity ${cwActivityId}`,
cause: typeof errBody === "string" ? errBody : JSON.stringify(errBody),
status: 502,
});
}
},
/**
* Delete Activity
*
* Deletes an activity from ConnectWise.
*
* @param cwActivityId - The ConnectWise activity ID to delete
*/
async delete(cwActivityId: number): Promise<void> {
try {
await activityCw.delete(cwActivityId);
} catch (error) {
const errBody = (error as any).response?.data || error;
throw new GenericError({
name: "DeleteActivityError",
message: `Failed to delete activity ${cwActivityId}`,
cause: typeof errBody === "string" ? errBody : JSON.stringify(errBody),
status: 502,
});
}
},
/**
* Count Activities
*
* Returns the total number of activities, optionally filtered.
*
* @param conditions - Optional CW conditions string for filtering
* @returns {Promise<number>}
*/
async count(conditions?: string): Promise<number> {
try {
return await activityCw.countItems(conditions);
} catch (error) {
const errBody = (error as any).response?.data || error;
throw new GenericError({
name: "CountActivitiesError",
message: "Failed to count activities in ConnectWise",
cause: typeof errBody === "string" ? errBody : JSON.stringify(errBody),
status: 502,
});
}
},
};
+141 -7
View File
@@ -1,6 +1,32 @@
import { Company } from "../../generated/prisma/client";
import { prisma } from "../constants";
import { ActivityController } from "../controllers/ActivityController";
import { CompanyController } from "../controllers/CompanyController";
import { OpportunityController } from "../controllers/OpportunityController";
import GenericError from "../Errors/GenericError";
import { activityCw } from "../modules/cw-utils/activities/activities";
import { opportunityCw } from "../modules/cw-utils/opportunities/opportunities";
/**
* Build a CompanyController with hydrated CW data from a Prisma Company record.
*/
async function buildCompanyController(
company: Company,
): Promise<CompanyController> {
const ctrl = new CompanyController(company);
await ctrl.hydrateCwData();
return ctrl;
}
/**
* Fetch ActivityController[] for an opportunity from ConnectWise.
*/
async function buildActivities(
cwOpportunityId: number,
): Promise<ActivityController[]> {
const collection = await activityCw.fetchByOpportunity(cwOpportunityId);
return collection.map((item) => new ActivityController(item));
}
export const opportunities = {
/**
@@ -16,13 +42,15 @@ export const opportunities = {
const isNumeric =
typeof identifier === "number" || /^\d+$/.test(String(identifier));
const item = await prisma.opportunity.findFirst({
// Look up the existing DB record to get the cwOpportunityId
const existing = await prisma.opportunity.findFirst({
where: isNumeric
? { cwOpportunityId: Number(identifier) }
: { id: identifier as string },
select: { id: true, cwOpportunityId: true },
});
if (!item) {
if (!existing) {
throw new GenericError({
message: "Opportunity not found",
name: "OpportunityNotFound",
@@ -31,7 +59,37 @@ export const opportunities = {
});
}
return new OpportunityController(item);
// Fetch fresh data from ConnectWise
const cwData = await opportunityCw.fetch(existing.cwOpportunityId);
// Map and update the DB record
const mapped = OpportunityController.mapCwToDb(cwData);
// Resolve internal company link
const companyId = cwData.company?.id
? ((
await prisma.company.findFirst({
where: { cw_CompanyId: cwData.company.id },
select: { id: true },
})
)?.id ?? null)
: null;
const updated = await prisma.opportunity.update({
where: { id: existing.id },
data: { ...mapped, companyId },
include: { company: true },
});
const activities = await buildActivities(updated.cwOpportunityId);
return new OpportunityController(updated, {
company: updated.company
? await buildCompanyController(updated.company)
: undefined,
customFields: cwData.customFields ?? [],
activities,
});
},
/**
@@ -51,12 +109,23 @@ export const opportunities = {
const items = await prisma.opportunity.findMany({
where: opts?.includeClosed ? undefined : { closedFlag: false },
include: { company: true },
skip,
take: rpp,
orderBy: { expectedCloseDate: "asc" },
orderBy: { createdAt: "desc" },
});
return items.map((item) => new OpportunityController(item));
return Promise.all(
items.map(
async (item) =>
new OpportunityController(item, {
company: item.company
? await buildCompanyController(item.company)
: undefined,
activities: await buildActivities(item.cwOpportunityId),
}),
),
);
},
/**
@@ -78,6 +147,9 @@ export const opportunities = {
opts?: { includeClosed?: boolean },
): Promise<OpportunityController[]> {
const skip = (Math.max(page, 1) - 1) * rpp;
const numericQuery = /^\d+$/.test(query.trim())
? Number(query.trim())
: null;
const items = await prisma.opportunity.findMany({
where: {
@@ -90,14 +162,28 @@ export const opportunities = {
{ primarySalesRepName: { contains: query, mode: "insensitive" } },
{ statusName: { contains: query, mode: "insensitive" } },
{ stageName: { contains: query, mode: "insensitive" } },
...(numericQuery !== null
? [{ cwOpportunityId: { equals: numericQuery } }]
: []),
],
},
include: { company: true },
skip,
take: rpp,
orderBy: { expectedCloseDate: "asc" },
});
return items.map((item) => new OpportunityController(item));
return Promise.all(
items.map(
async (item) =>
new OpportunityController(item, {
company: item.company
? await buildCompanyController(item.company)
: undefined,
activities: await buildActivities(item.cwOpportunityId),
}),
),
);
},
/**
@@ -112,6 +198,43 @@ export const opportunities = {
});
},
/**
* Count Search Results
*
* Returns the total number of opportunities matching a search query,
* using the same filter logic as `search()`.
*
* @param query - Search query string
* @param opts - Optional filters
* @returns {Promise<number>}
*/
async searchCount(
query: string,
opts?: { includeClosed?: boolean },
): Promise<number> {
const numericQuery = /^\d+$/.test(query.trim())
? Number(query.trim())
: null;
return prisma.opportunity.count({
where: {
...(opts?.includeClosed ? {} : { closedFlag: false }),
OR: [
{ name: { contains: query, mode: "insensitive" } },
{ companyName: { contains: query, mode: "insensitive" } },
{ contactName: { contains: query, mode: "insensitive" } },
{ notes: { contains: query, mode: "insensitive" } },
{ primarySalesRepName: { contains: query, mode: "insensitive" } },
{ statusName: { contains: query, mode: "insensitive" } },
{ stageName: { contains: query, mode: "insensitive" } },
...(numericQuery !== null
? [{ cwOpportunityId: { equals: numericQuery } }]
: []),
],
},
});
},
/**
* Fetch Opportunities by Company
*
@@ -130,9 +253,20 @@ export const opportunities = {
companyId,
...(opts?.includeClosed ? {} : { closedFlag: false }),
},
include: { company: true },
orderBy: { expectedCloseDate: "asc" },
});
return items.map((item) => new OpportunityController(item));
return Promise.all(
items.map(
async (item) =>
new OpportunityController(item, {
company: item.company
? await buildCompanyController(item.company)
: undefined,
activities: await buildActivities(item.cwOpportunityId),
}),
),
);
},
};
+171 -10
View File
@@ -1,6 +1,11 @@
import { prisma } from "../constants";
import { CatalogItemController } from "../controllers/CatalogItemController";
import GenericError from "../Errors/GenericError";
import {
getSubcategoriesForCategory,
getSubcategoriesForGroup,
ECOSYSTEM_TREE,
} from "../modules/catalog-categories/catalogCategories";
/**
* Standard include clause used by catalog item queries.
@@ -10,6 +15,95 @@ const catalogItemInclude = {
linkedItems: true,
} as const;
/**
* Filter options for catalog item queries.
*/
export interface CatalogFilterOpts {
includeInactive?: boolean;
category?: string;
subcategory?: string;
group?: string;
manufacturer?: string;
ecosystem?: string;
inStock?: boolean;
minPrice?: number;
maxPrice?: number;
}
/**
* Builds a Prisma `where` clause from filter options.
*/
function buildFilterWhere(opts: CatalogFilterOpts = {}) {
const conditions: Record<string, unknown>[] = [];
if (!opts.includeInactive) {
conditions.push({ inactive: false });
}
if (opts.category) {
conditions.push({ category: opts.category });
}
if (opts.subcategory) {
conditions.push({ subcategory: opts.subcategory });
}
if (opts.group && opts.category) {
const subcats = getSubcategoriesForGroup(opts.category, opts.group);
if (subcats.length > 0) {
conditions.push({ subcategory: { in: subcats } });
}
} else if (opts.group && !opts.category) {
// Try to find the group in any category
const {
CATEGORY_TREE,
isCategoryGroup,
} = require("../modules/catalog-categories/catalogCategories");
for (const cat of CATEGORY_TREE) {
const subcats = getSubcategoriesForGroup(cat.name, opts.group);
if (subcats.length > 0) {
conditions.push({ category: cat.name, subcategory: { in: subcats } });
break;
}
}
}
if (opts.manufacturer) {
conditions.push({
manufacturer: { contains: opts.manufacturer, mode: "insensitive" },
});
}
if (opts.ecosystem) {
const eco = ECOSYSTEM_TREE.find(
(e) => e.name.toLowerCase() === opts.ecosystem!.toLowerCase(),
);
if (eco && eco.manufacturers.length > 0) {
conditions.push({
OR: eco.manufacturers.map((m) => ({
manufacturer: { contains: m.name, mode: "insensitive" as const },
subcategory: { startsWith: m.subcategoryPrefix },
category: m.category,
})),
});
}
}
if (opts.inStock) {
conditions.push({ onHand: { gt: 0 } });
}
if (opts.minPrice !== undefined) {
conditions.push({ price: { gte: opts.minPrice } });
}
if (opts.maxPrice !== undefined) {
conditions.push({ price: { lte: opts.maxPrice } });
}
return conditions.length > 0 ? { AND: conditions } : undefined;
}
export const procurement = {
/**
* Fetch Catalog Item
@@ -51,22 +145,23 @@ export const procurement = {
/**
* Fetch All Catalog Items (Paginated)
*
* Fetch pages of catalog items for pagination.
* Fetch pages of catalog items for pagination with optional filtering.
*
* @param page - Page number (1-based)
* @param rpp - Records per page
* @param opts - Filter options
* @returns {Promise<CatalogItemController[]>} - Array of catalog item controllers
*/
async fetchPages(
page: number,
rpp: number,
opts?: { includeInactive?: boolean },
opts?: CatalogFilterOpts,
): Promise<CatalogItemController[]> {
const skip = (Math.max(page, 1) - 1) * rpp;
const take = rpp;
const items = await prisma.catalogItem.findMany({
where: opts?.includeInactive ? undefined : { inactive: false },
where: buildFilterWhere(opts),
skip,
take,
include: catalogItemInclude,
@@ -80,25 +175,28 @@ export const procurement = {
* Search Catalog Items
*
* Search catalog items by name, description, part number, or vendor SKU
* with pagination support.
* with pagination support and optional category/subcategory/ecosystem filters.
*
* @param query - Search query string
* @param page - Page number (1-based)
* @param rpp - Records per page
* @param opts - Filter options
* @returns {Promise<CatalogItemController[]>} - Array of matching catalog item controllers
*/
async search(
query: string,
page: number,
rpp: number,
opts?: { includeInactive?: boolean },
opts?: CatalogFilterOpts,
): Promise<CatalogItemController[]> {
const skip = (Math.max(page, 1) - 1) * rpp;
const take = rpp;
const filterWhere = buildFilterWhere(opts) ?? {};
const items = await prisma.catalogItem.findMany({
where: {
...(opts?.includeInactive ? {} : { inactive: false }),
...filterWhere,
OR: [
{ identifier: { contains: query, mode: "insensitive" } },
{ name: { contains: query, mode: "insensitive" } },
@@ -120,17 +218,80 @@ export const procurement = {
/**
* Count Catalog Items
*
* Returns the total number of catalog items in the database.
* Returns the total number of catalog items matching the given filters.
*
* @param opts - Optional filters
* @param opts - Filter options
* @returns {Promise<number>} - Total count
*/
async count(opts?: { activeOnly?: boolean }): Promise<number> {
async count(
opts?: CatalogFilterOpts & { activeOnly?: boolean },
): Promise<number> {
// Support legacy `activeOnly` flag by mapping it to `includeInactive`
const filterOpts: CatalogFilterOpts = {
...opts,
includeInactive:
opts?.includeInactive ?? (opts?.activeOnly ? false : true),
};
if (opts?.activeOnly) filterOpts.includeInactive = false;
return prisma.catalogItem.count({
where: opts?.activeOnly ? { inactive: false } : undefined,
where: buildFilterWhere(filterOpts),
});
},
/**
* Count Catalog Items (with search query)
*
* Returns the total number of catalog items matching a search query and filters.
*
* @param query - Search query string
* @param opts - Filter options
* @returns {Promise<number>} - Total count
*/
async countSearch(query: string, opts?: CatalogFilterOpts): Promise<number> {
const filterWhere = buildFilterWhere(opts) ?? {};
return prisma.catalogItem.count({
where: {
...filterWhere,
OR: [
{ identifier: { contains: query, mode: "insensitive" } },
{ name: { contains: query, mode: "insensitive" } },
{ description: { contains: query, mode: "insensitive" } },
{ partNumber: { contains: query, mode: "insensitive" } },
{ vendorSku: { contains: query, mode: "insensitive" } },
{ manufacturer: { contains: query, mode: "insensitive" } },
],
},
});
},
/**
* Fetch Distinct Values
*
* Returns the distinct values for a given field across all catalog items.
* Useful for populating filter dropdowns in the UI.
*
* @param field - The field to get distinct values for
* @param opts - Filter options to scope the distinct query
* @returns {Promise<string[]>} - Sorted array of distinct non-null values
*/
async fetchDistinctValues(
field: "category" | "subcategory" | "manufacturer",
opts?: CatalogFilterOpts,
): Promise<string[]> {
const items = await prisma.catalogItem.findMany({
where: buildFilterWhere(opts),
select: { [field]: true },
distinct: [field],
orderBy: { [field]: "asc" },
});
return items
.map((item: Record<string, unknown>) => item[field] as string | null)
.filter((v): v is string => v !== null);
},
/**
* Link Catalog Items
*
+7
View File
@@ -4,6 +4,7 @@ import { prisma } from "../constants";
import { SessionTokensObject } from "../controllers/SessionController";
import UserController from "../controllers/UserController";
import { fetchMicrosoftUser } from "../modules/fetchMicrosoftUser";
import { findCwIdentifierByEmail } from "../modules/cw-utils/members/fetchAllMembers";
import { events } from "../modules/globalEvents";
import { sessions } from "./sessions";
import * as msal from "@azure/msal-node";
@@ -90,12 +91,18 @@ export const users = {
async createUser(token: string): Promise<UserController> {
const msData = await fetchMicrosoftUser(token);
// Attempt to resolve the user's ConnectWise identifier by email
const cwIdentifier = await findCwIdentifierByEmail(msData.mail).catch(
() => null,
);
const newUser = await prisma.user.create({
data: {
userId: msData.id,
email: msData.mail,
name: `${msData.givenName} ${msData.surname}`,
login: msData.userPrincipalName,
cwIdentifier,
token,
},
include: { roles: true },
@@ -0,0 +1,498 @@
/**
* Catalog Categories & Ecosystems
*
* This module defines the complete category/subcategory hierarchy and
* ecosystem decision trees used for product filtering in the UI.
*
* --- Terminology ---
*
* Category: Top-level CW category (e.g. "Technology", "Field", "General").
* A category is NEVER a subcategory.
*
* Subcategory: The CW subcategory name stored on each catalog item.
* At the second level of the tree, if there are no children
* beneath it then the node name IS the subcategory.
* If children exist, the second-level node is an *umbrella*
* that groups related subcategories — the children are the
* actual subcategory names.
*
* Ecosystem: A cross-cutting product grouping defined by manufacturer +
* category + subcategory-prefix rules. Ecosystems let the UI
* present a "Networking" or "Video Surveillance" view that
* spans manufacturers regardless of where CW filed them.
*
* --- Data shapes ---
*
* SubcategoryNode a leaf: `{ name, cwId? }`
* CategoryGroup an umbrella with children: `{ name, children[] }`
* CategoryEntry either a leaf OR a group at the 2nd level
* TopLevelCategory `{ name, cwId?, entries[] }`
*
* The `CATEGORY_TREE` export is the single source of truth; helpers derive
* flat lists, lookup maps, and search predicates from it.
*/
// ─── Data types ──────────────────────────────────────────────────────────────
export interface SubcategoryNode {
/** The exact CW subcategory name */
name: string;
/** CW subcategory id (optional, for reference) */
cwId?: number;
}
export interface CategoryGroup {
/** Display name of the umbrella (e.g. "Network", "Cables", "AlarmBurg") */
name: string;
/** The subcategories that belong to this umbrella */
children: SubcategoryNode[];
}
/** A second-level entry is either a direct subcategory or an umbrella group */
export type CategoryEntry = SubcategoryNode | CategoryGroup;
export interface TopLevelCategory {
/** The CW category name */
name: string;
/** CW category id (optional, for reference) */
cwId?: number;
/** Second-level entries under this category */
entries: CategoryEntry[];
}
/** Helper type guard */
export function isCategoryGroup(entry: CategoryEntry): entry is CategoryGroup {
return "children" in entry;
}
// ─── Ecosystem types ─────────────────────────────────────────────────────────
export interface EcosystemManufacturer {
/** Manufacturer name as stored in CW */
name: string;
/** CW manufacturer id */
cwId?: number;
/** Which CW category these products fall under */
category: string;
/** Subcategory prefix — matches any subcategory starting with this string */
subcategoryPrefix: string;
}
export interface Ecosystem {
/** Display name (e.g. "Networking", "Video Surveillance") */
name: string;
/** Manufacturers that belong to this ecosystem */
manufacturers: EcosystemManufacturer[];
}
// ─── Category Tree ───────────────────────────────────────────────────────────
export const CATEGORY_TREE: TopLevelCategory[] = [
{
name: "Technology",
cwId: 18,
entries: [
{ name: "GeneralEquip", cwId: 57 },
{ name: "Home Entertainment", cwId: 114 },
{ name: "Monitor", cwId: 115 },
{ name: "Printers", cwId: 120 },
{ name: "Storage", cwId: 108 },
{
name: "Network",
children: [
{ name: "Network-Other", cwId: 174 },
{ name: "Network-Router", cwId: 119 },
{ name: "Network-Switch", cwId: 112 },
{ name: "Network-Wireless", cwId: 111 },
],
},
{
name: "Computer",
children: [
{ name: "Computer-Components", cwId: 109 },
{ name: "Computer-Desktop", cwId: 106 },
{ name: "Computer-Laptop", cwId: 107 },
],
},
{
name: "Recurring",
children: [
{ name: "Recurring - Online", cwId: 83 },
{ name: "Recurring - Other", cwId: 84 },
{ name: "Recurring - Protection", cwId: 81 },
{ name: "Recurring - Telephone", cwId: 133 },
],
},
{
name: "Telephone",
children: [
{ name: "Tele-HSet-Digital", cwId: 116 },
{ name: "Tele-HSet-IP", cwId: 206 },
{ name: "Tele-HSet-SLT" },
{ name: "Tele-Misc", cwId: 75 },
{ name: "Tele-Paging", cwId: 76 },
{ name: "Tele-SystemCards", cwId: 135 },
{ name: "Tele-Systems", cwId: 78 },
],
},
],
},
{
name: "General",
cwId: 25,
entries: [
{ name: "Batteries", cwId: 80 },
{ name: "Battery Backups", cwId: 144 },
{ name: "BulkWire", cwId: 200 },
{
name: "Cables",
children: [
{ name: "Cables-Adapters", cwId: 182 },
{ name: "Cables-HDMI", cwId: 176 },
{ name: "Cables-Network", cwId: 87 },
{ name: "Cables-Other", cwId: 177 },
{ name: "Cables-USB", cwId: 178 },
{ name: "Cables-VGA", cwId: 179 },
],
},
{ name: "Elec Cords & Adapters", cwId: 142 },
{ name: "Enclosures", cwId: 141 },
{ name: "PowerSupply", cwId: 167 },
{
name: "RackEquip",
children: [
{ name: "RackEquip-Rack", cwId: 143 },
{ name: "RackEquip-Shelves", cwId: 190 },
],
},
],
},
{
name: "Field",
cwId: 28,
entries: [
{ name: "Conduit" },
{ name: "Electric", cwId: 199 },
{ name: "GateControl", cwId: 45 },
{ name: "Locksets" },
{ name: "Other", cwId: 46 },
{ name: "Relays", cwId: 168 },
{
name: "AccessControl",
children: [
{ name: "AccessControl-Controllers", cwId: 137 },
{ name: "AccessControl-Credential", cwId: 183 },
{ name: "AccessControl-LockDevices", cwId: 138 },
{ name: "AccessControl-Other", cwId: 44 },
{ name: "AccessControl-Readers", cwId: 136 },
{ name: "AccessControl-VideoEntry", cwId: 139 },
],
},
{
name: "AlarmBurg",
children: [
{ name: "AlarmBurg-Communicators", cwId: 96 },
{ name: "AlarmBurg-Keypads", cwId: 93 },
{ name: "AlarmBurg-Modules", cwId: 140 },
{ name: "AlarmBurg-Other", cwId: 92 },
{ name: "AlarmBurg-Panels", cwId: 42 },
{ name: "AlarmBurg-Sensors-Wireless", cwId: 147 },
{ name: "AlarmBurg-Sensors-Wired", cwId: 146 },
{ name: "AlarmBurg-Siren", cwId: 145 },
],
},
{
name: "AlarmFire",
children: [
{ name: "AlarmFire-Communicators", cwId: 97 },
{ name: "AlarmFire-Devices", cwId: 169 },
{ name: "AlarmFire-Modules", cwId: 170 },
{ name: "AlarmFire-Other", cwId: 98 },
{ name: "AlarmFire-Panels", cwId: 95 },
{ name: "AlarmFire-Sensors", cwId: 94 },
],
},
{
name: "Automation",
children: [
{ name: "Automation-General", cwId: 99 },
{ name: "Automation-HVAC", cwId: 181 },
{ name: "Automation-Lights", cwId: 180 },
{ name: "Automation-Locks", cwId: 192 },
{ name: "Automation-Thermostat" },
],
},
{
name: "AV",
children: [
{ name: "AV-Adapters&Cables", cwId: 171 },
{ name: "AV-Components", cwId: 172 },
{ name: "AV-Mounts", cwId: 191 },
{ name: "AV-Other", cwId: 184 },
{ name: "AV-Speakers", cwId: 173 },
{ name: "AV-Television", cwId: 175 },
],
},
{
name: "StrCbl",
children: [
{ name: "StrCbl-Jacks", cwId: 186 },
{ name: "StrCbl-PatchPanel", cwId: 187 },
{ name: "StrCbl-Plates", cwId: 185 },
],
},
{
name: "Surveillance",
children: [
{ name: "Surveillance-Accs", cwId: 90 },
{ name: "Surveillance-CamerasAnalog", cwId: 89 },
{ name: "Surveillance-CamerasIP", cwId: 88 },
{ name: "Surveillance-NVR", cwId: 43 },
],
},
],
},
];
// ─── Ecosystem Tree ──────────────────────────────────────────────────────────
export const ECOSYSTEM_TREE: Ecosystem[] = [
{
name: "Networking",
manufacturers: [
{
name: "Ubiquiti",
cwId: 248,
category: "Technology",
subcategoryPrefix: "Network-",
},
{
name: "TP-Link",
cwId: 259,
category: "Technology",
subcategoryPrefix: "Network-",
},
],
},
{
name: "Video Surveillance",
manufacturers: [
{
name: "Uniview",
cwId: 239,
category: "Field",
subcategoryPrefix: "Surveillance-",
},
{
name: "Hikvision",
cwId: 299,
category: "Field",
subcategoryPrefix: "Surveillance-",
},
{
name: "Alarm.com",
cwId: 294,
category: "Field",
subcategoryPrefix: "Surveillance-",
},
],
},
{
name: "Burg/Alarm",
manufacturers: [
{
name: "Qolsys",
cwId: 376,
category: "Field",
subcategoryPrefix: "AlarmBurg-",
},
{
name: "DSC",
cwId: 287,
category: "Field",
subcategoryPrefix: "AlarmBurg-",
},
],
},
];
// ─── Derived helpers ─────────────────────────────────────────────────────────
/**
* Returns a flat list of all subcategory names under a given category.
*/
export function getSubcategoriesForCategory(categoryName: string): string[] {
const category = CATEGORY_TREE.find((c) => c.name === categoryName);
if (!category) return [];
const subcats: string[] = [];
for (const entry of category.entries) {
if (isCategoryGroup(entry)) {
for (const child of entry.children) {
subcats.push(child.name);
}
} else {
subcats.push(entry.name);
}
}
return subcats;
}
/**
* Returns all subcategory names under a given umbrella group within a category.
* e.g. getSubcategoriesForGroup("Field", "AlarmBurg") → ["AlarmBurg-Communicators", ...]
*/
export function getSubcategoriesForGroup(
categoryName: string,
groupName: string,
): string[] {
const category = CATEGORY_TREE.find((c) => c.name === categoryName);
if (!category) return [];
const group = category.entries.find(
(e) => isCategoryGroup(e) && e.name === groupName,
);
if (!group || !isCategoryGroup(group)) return [];
return group.children.map((c) => c.name);
}
/**
* Returns all top-level category names.
*/
export function getCategoryNames(): string[] {
return CATEGORY_TREE.map((c) => c.name);
}
/**
* Returns the umbrella group name for a given subcategory, or null if it's a
* direct entry (not under an umbrella).
*/
export function getGroupForSubcategory(
subcategoryName: string,
): { category: string; group: string } | null {
for (const cat of CATEGORY_TREE) {
for (const entry of cat.entries) {
if (isCategoryGroup(entry)) {
if (entry.children.some((c) => c.name === subcategoryName)) {
return { category: cat.name, group: entry.name };
}
}
}
}
return null;
}
/**
* Returns the full tree serialized for the API / UI consumption.
* Each top-level category includes its entries, with umbrella groups
* expanded to show children.
*/
export function serializeCategoryTree() {
return CATEGORY_TREE.map((cat) => ({
name: cat.name,
cwId: cat.cwId ?? null,
entries: cat.entries.map((entry) => {
if (isCategoryGroup(entry)) {
return {
type: "group" as const,
name: entry.name,
subcategories: entry.children.map((c) => ({
name: c.name,
cwId: c.cwId ?? null,
})),
};
}
return {
type: "subcategory" as const,
name: entry.name,
cwId: (entry as SubcategoryNode).cwId ?? null,
};
}),
}));
}
/**
* Returns the ecosystem tree serialized for the API / UI consumption.
*/
export function serializeEcosystemTree() {
return ECOSYSTEM_TREE.map((eco) => ({
name: eco.name,
manufacturers: eco.manufacturers.map((m) => ({
name: m.name,
cwId: m.cwId ?? null,
category: m.category,
subcategoryPrefix: m.subcategoryPrefix,
})),
}));
}
/**
* Returns a flat list of every known subcategory name across all categories.
*/
export function getAllSubcategoryNames(): string[] {
const names: string[] = [];
for (const cat of CATEGORY_TREE) {
for (const entry of cat.entries) {
if (isCategoryGroup(entry)) {
for (const child of entry.children) {
names.push(child.name);
}
} else {
names.push(entry.name);
}
}
}
return names;
}
/**
* Given a CW subcategory name, resolves which top-level category it belongs to.
*/
export function getCategoryForSubcategory(
subcategoryName: string,
): string | null {
for (const cat of CATEGORY_TREE) {
for (const entry of cat.entries) {
if (isCategoryGroup(entry)) {
if (entry.children.some((c) => c.name === subcategoryName)) {
return cat.name;
}
} else if (entry.name === subcategoryName) {
return cat.name;
}
}
}
return null;
}
/**
* Given a CW manufacturer name, returns which ecosystems it belongs to.
*/
export function getEcosystemsForManufacturer(
manufacturerName: string,
): string[] {
return ECOSYSTEM_TREE.filter((eco) =>
eco.manufacturers.some(
(m) => m.name.toLowerCase() === manufacturerName.toLowerCase(),
),
).map((eco) => eco.name);
}
/**
* Checks if a catalog item (by manufacturer + subcategory) matches a given ecosystem.
*/
export function matchesEcosystem(
ecosystemName: string,
manufacturer: string | null,
subcategory: string | null,
): boolean {
const eco = ECOSYSTEM_TREE.find((e) => e.name === ecosystemName);
if (!eco) return false;
return eco.manufacturers.some(
(m) =>
m.name.toLowerCase() === (manufacturer ?? "").toLowerCase() &&
(subcategory ?? "").startsWith(m.subcategoryPrefix),
);
}
@@ -0,0 +1,168 @@
import { Collection } from "@discordjs/collection";
import { connectWiseApi } from "../../../constants";
import {
CWActivity,
CWActivitySummary,
CWCreateActivity,
CWPatchOperation,
} from "./activity.types";
export const activityCw = {
/**
* Count Activities
*
* Returns the total number of activities in ConnectWise.
* Optionally accepts CW conditions string for filtered counts.
*/
countItems: async (conditions?: string): Promise<number> => {
const query = conditions
? `/sales/activities/count?conditions=${encodeURIComponent(conditions)}`
: "/sales/activities/count";
const response = await connectWiseApi.get(query);
return response.data.count;
},
/**
* Fetch All Activity Summaries
*
* Lightweight fetch returning only id and _info (for lastUpdated comparison).
* Paginates through all activities.
*/
fetchAllSummaries: async (): Promise<
Collection<number, CWActivitySummary>
> => {
const allItems = new Collection<number, CWActivitySummary>();
const pageSize = 1000;
const count = await activityCw.countItems();
const totalPages = Math.ceil(count / pageSize);
for (let page = 0; page < totalPages; page++) {
const response = await connectWiseApi.get(
`/sales/activities?page=${page + 1}&pageSize=${pageSize}&fields=id,_info`,
);
const items: CWActivitySummary[] = response.data;
for (const item of items) {
allItems.set(item.id, item);
}
}
return allItems;
},
/**
* Fetch All Activities (Full)
*
* Fetches all activities with complete data. Paginates through
* the full list. Optionally accepts CW conditions string for filtering.
*/
fetchAll: async (
conditions?: string,
): Promise<Collection<number, CWActivity>> => {
const allItems = new Collection<number, CWActivity>();
const pageSize = 1000;
const count = await activityCw.countItems(conditions);
const totalPages = Math.ceil(count / pageSize);
for (let page = 0; page < totalPages; page++) {
const conditionsParam = conditions
? `&conditions=${encodeURIComponent(conditions)}`
: "";
const response = await connectWiseApi.get(
`/sales/activities?page=${page + 1}&pageSize=${pageSize}${conditionsParam}`,
);
const items: CWActivity[] = response.data;
for (const item of items) {
allItems.set(item.id, item);
}
}
return allItems;
},
/**
* Fetch Single Activity
*
* Fetches a single activity by its ConnectWise ID.
*/
fetch: async (id: number): Promise<CWActivity> => {
const response = await connectWiseApi.get(`/sales/activities/${id}`);
return response.data;
},
/**
* Fetch Activities by Company
*
* Fetches all activities associated with a specific ConnectWise company ID.
*/
fetchByCompany: async (
cwCompanyId: number,
): Promise<Collection<number, CWActivity>> => {
return activityCw.fetchAll(`company/id=${cwCompanyId}`);
},
/**
* Fetch Activities by Opportunity
*
* Fetches all activities associated with a specific opportunity ID.
*/
fetchByOpportunity: async (
opportunityId: number,
): Promise<Collection<number, CWActivity>> => {
return activityCw.fetchAll(`opportunity/id=${opportunityId}`);
},
/**
* Create Activity
*
* Creates a new activity in ConnectWise.
*/
create: async (activity: CWCreateActivity): Promise<CWActivity> => {
const response = await connectWiseApi.post("/sales/activities", activity);
return response.data;
},
/**
* Update Activity (PATCH)
*
* Updates an existing activity using JSON Patch operations.
*/
update: async (
id: number,
operations: CWPatchOperation[],
): Promise<CWActivity> => {
const response = await connectWiseApi.patch(
`/sales/activities/${id}`,
operations,
);
return response.data;
},
/**
* Replace Activity (PUT)
*
* Replaces an entire activity record in ConnectWise.
*/
replace: async (
id: number,
activity: CWCreateActivity,
): Promise<CWActivity> => {
const response = await connectWiseApi.put(
`/sales/activities/${id}`,
activity,
);
return response.data;
},
/**
* Delete Activity
*
* Deletes an activity by its ConnectWise ID.
*/
delete: async (id: number): Promise<void> => {
await connectWiseApi.delete(`/sales/activities/${id}`);
},
};
@@ -0,0 +1,123 @@
interface CWReference {
id: number;
name: string;
_info?: Record<string, string>;
}
interface CWMemberReference {
id: number;
identifier: string;
name: string;
_info?: Record<string, string>;
}
interface CWCompanyReference {
id: number;
identifier: string;
name: string;
_info?: Record<string, string>;
}
interface CWContactReference {
id: number;
name: string;
_info?: Record<string, string>;
}
export interface CWActivity {
id: number;
name: string;
type: CWReference;
company: CWCompanyReference;
contact: CWContactReference;
phoneNumber: string;
email: string;
status: CWReference;
opportunity: CWReference;
ticket: CWReference;
agreement: CWReference;
campaign: CWReference;
notes: string;
dateStart: string;
dateEnd: string;
assignTo: CWMemberReference;
scheduleStatus: CWReference;
reminder: CWReference;
where: CWReference;
notifyFlag: boolean;
mobileGuid: string;
currency: CWReference;
customFields: CWActivityCustomField[];
_info: CWActivityInfo;
}
export interface CWActivityCustomField {
id: number;
caption: string;
type: string;
entryMethod: string;
numberOfDecimals: number;
value: unknown;
}
export interface CWActivityInfo {
lastUpdated: string;
updatedBy: string;
dateEntered: string;
enteredBy: string;
}
export interface CWActivitySummary {
id: number;
_info?: Record<string, string>;
}
export interface CWCreateActivity {
name: string;
type?: { id: number };
company?: { id: number };
contact?: { id: number };
phoneNumber?: string;
email?: string;
status?: { id: number };
opportunity?: { id: number };
ticket?: { id: number };
agreement?: { id: number };
campaign?: { id: number };
notes?: string;
dateStart?: string;
dateEnd?: string;
assignTo?: { id: number };
scheduleStatus?: { id: number };
reminder?: { id: number };
where?: { id: number };
notifyFlag?: boolean;
}
export interface CWUpdateActivity {
name?: string;
type?: { id: number };
company?: { id: number };
contact?: { id: number };
phoneNumber?: string;
email?: string;
status?: { id: number };
opportunity?: { id: number };
ticket?: { id: number };
agreement?: { id: number };
campaign?: { id: number };
notes?: string;
dateStart?: string;
dateEnd?: string;
assignTo?: { id: number };
scheduleStatus?: { id: number };
reminder?: { id: number };
where?: { id: number };
notifyFlag?: boolean;
}
export interface CWPatchOperation {
op: "replace" | "add" | "remove";
path: string;
value: unknown;
}
@@ -0,0 +1,27 @@
import GenericError from "../../../Errors/GenericError";
import { activityCw } from "./activities";
import { CWActivity, CWCreateActivity } from "./activity.types";
/**
* Create a new activity in ConnectWise.
*
* @param activity - The activity data to create
* @returns The newly created CW activity object
* @throws GenericError if the creation fails
*/
export const createActivity = async (
activity: CWCreateActivity,
): Promise<CWActivity> => {
try {
return await activityCw.create(activity);
} catch (error) {
const errBody = (error as any).response?.data || error;
console.error("Error creating activity:", errBody);
throw new GenericError({
name: "CreateActivityError",
message: "Failed to create activity in ConnectWise",
cause: typeof errBody === "string" ? errBody : JSON.stringify(errBody),
status: 502,
});
}
};
@@ -0,0 +1,27 @@
import GenericError from "../../../Errors/GenericError";
import { activityCw } from "./activities";
import { CWActivity } from "./activity.types";
/**
* Fetch a single activity by its ConnectWise ID.
*
* @param cwActivityId - The ConnectWise activity ID
* @returns The full CW activity object
* @throws GenericError if the fetch fails
*/
export const fetchActivity = async (
cwActivityId: number,
): Promise<CWActivity> => {
try {
return await activityCw.fetch(cwActivityId);
} catch (error) {
const errBody = (error as any).response?.data || error;
console.error(`Error fetching activity with ID ${cwActivityId}:`, errBody);
throw new GenericError({
name: "FetchActivityError",
message: `Failed to fetch activity ${cwActivityId}`,
cause: typeof errBody === "string" ? errBody : JSON.stringify(errBody),
status: 502,
});
}
};
@@ -0,0 +1,28 @@
import { Collection } from "@discordjs/collection";
import GenericError from "../../../Errors/GenericError";
import { activityCw } from "./activities";
import { CWActivity } from "./activity.types";
/**
* Fetch all activities from ConnectWise with optional conditions.
*
* @param conditions - Optional CW conditions string for filtering
* @returns A Collection of CW activities keyed by their ID
* @throws GenericError if the fetch fails
*/
export const fetchAllActivities = async (
conditions?: string,
): Promise<Collection<number, CWActivity>> => {
try {
return await activityCw.fetchAll(conditions);
} catch (error) {
const errBody = (error as any).response?.data || error;
console.error("Error fetching all activities:", errBody);
throw new GenericError({
name: "FetchAllActivitiesError",
message: "Failed to fetch activities from ConnectWise",
cause: typeof errBody === "string" ? errBody : JSON.stringify(errBody),
status: 502,
});
}
};
+15
View File
@@ -0,0 +1,15 @@
export { activityCw } from "./activities";
export { fetchActivity } from "./fetchActivity";
export { fetchAllActivities } from "./fetchAllActivities";
export { createActivity } from "./createActivity";
export { updateActivity } from "./updateActivity";
export type {
CWActivity,
CWActivitySummary,
CWActivityCustomField,
CWActivityInfo,
CWCreateActivity,
CWUpdateActivity,
CWPatchOperation,
} from "./activity.types";
@@ -0,0 +1,29 @@
import GenericError from "../../../Errors/GenericError";
import { activityCw } from "./activities";
import { CWActivity, CWPatchOperation } from "./activity.types";
/**
* Update an existing activity in ConnectWise using JSON Patch operations.
*
* @param cwActivityId - The ConnectWise activity ID to update
* @param operations - Array of JSON Patch operations to apply
* @returns The updated CW activity object
* @throws GenericError if the update fails
*/
export const updateActivity = async (
cwActivityId: number,
operations: CWPatchOperation[],
): Promise<CWActivity> => {
try {
return await activityCw.update(cwActivityId, operations);
} catch (error) {
const errBody = (error as any).response?.data || error;
console.error(`Error updating activity with ID ${cwActivityId}:`, errBody);
throw new GenericError({
name: "UpdateActivityError",
message: `Failed to update activity ${cwActivityId}`,
cause: typeof errBody === "string" ? errBody : JSON.stringify(errBody),
status: 502,
});
}
};
@@ -0,0 +1,67 @@
import { Collection } from "@discordjs/collection";
import { connectWiseApi } from "../../../constants";
export interface CWMember {
id: number;
identifier: string;
firstName: string;
lastName: string;
officeEmail: string;
inactiveFlag: boolean;
_info: Record<string, string>;
}
/**
* Fetch All CW Members
*
* Fetches every member from ConnectWise using pagination and returns them
* in a Collection keyed by their identifier (e.g. "jroberts").
*
* @returns {Promise<Collection<string, CWMember>>} Collection of CW members keyed by identifier
*/
export const fetchAllCwMembers = async (): Promise<
Collection<string, CWMember>
> => {
const members = new Collection<string, CWMember>();
const pageSize = 1000;
const { data: countData } = await connectWiseApi.get("/system/members/count");
const totalPages = Math.ceil(countData.count / pageSize);
for (let page = 0; page < totalPages; page++) {
const { data } = await connectWiseApi.get<CWMember[]>(
`/system/members?page=${page + 1}&pageSize=${pageSize}`,
);
for (const member of data) {
members.set(member.identifier, member);
}
}
return members;
};
/**
* Find CW Member Identifier by Email
*
* Looks up a ConnectWise member whose `officeEmail` matches the provided
* email address (case-insensitive) and returns their `identifier` string
* (e.g. "jroberts"). Returns `null` if no match is found.
*
* @param email - The email address to search for
* @param members - Optional pre-fetched member collection to search against (avoids extra API call)
* @returns {Promise<string | null>} The CW identifier or null
*/
export const findCwIdentifierByEmail = async (
email: string,
members?: Collection<string, CWMember>,
): Promise<string | null> => {
const allMembers = members ?? (await fetchAllCwMembers());
const normalised = email.toLowerCase();
const match = allMembers.find(
(m) => m.officeEmail?.toLowerCase() === normalised,
);
return match?.identifier ?? null;
};
+104
View File
@@ -0,0 +1,104 @@
import { Collection } from "@discordjs/collection";
import { prisma } from "../../../constants";
import { redis } from "../../../constants";
import { CWMember } from "./fetchAllMembers";
const REDIS_KEY = "cw:members";
export interface ResolvedMember {
/** Local database user ID (null if no matching local user) */
id: string | null;
/** CW member identifier (e.g. "jroberts") */
identifier: string;
/** Full name resolved from CW member cache, or raw identifier as fallback */
name: string;
/** ConnectWise member ID */
cwMemberId: number | null;
}
/**
* CW Member Cache
*
* Dual-layer cache (in-memory + Redis) of ConnectWise members keyed by
* their identifier (e.g. "jroberts"). Populated by `refreshCwIdentifiers`
* on startup and every 30 minutes thereafter.
*/
let memberCache = new Collection<string, CWMember>();
/**
* Set the member cache contents.
*
* Replaces both the in-memory Collection and the Redis snapshot.
*
* @param members - Collection of CW members keyed by identifier
*/
export const setMemberCache = async (members: Collection<string, CWMember>) => {
memberCache = members;
await redis.set(REDIS_KEY, JSON.stringify([...members.values()]));
};
/**
* Get the current member cache.
*
* Returns the in-memory Collection. If empty, attempts to hydrate from Redis
* first. Returns whatever is available (may be empty if Redis is also cold).
*/
export const getMemberCache = async (): Promise<
Collection<string, CWMember>
> => {
if (memberCache.size > 0) return memberCache;
const stored = await redis.get(REDIS_KEY);
if (stored) {
const parsed: CWMember[] = JSON.parse(stored);
memberCache = new Collection(parsed.map((m) => [m.identifier, m]));
}
return memberCache;
};
/**
* Resolve CW Identifier to Full Name
*
* Looks up a ConnectWise member by their identifier in the in-memory cache
* and returns their full name. Falls back to the raw identifier if not found.
*
* @param identifier - The CW member identifier (e.g. "jroberts")
* @returns The member's full name (e.g. "John Roberts") or the raw identifier
*/
export const resolveMemberName = (identifier: string): string => {
const member = memberCache.get(identifier);
if (!member) return identifier;
return `${member.firstName} ${member.lastName}`.trim() || identifier;
};
/**
* Resolve CW Identifier to Full Member Info
*
* Looks up a ConnectWise member by their identifier in the in-memory cache
* and cross-references with the local database to return a complete member
* reference including local user ID, CW identifier, full name, and CW member ID.
*
* @param identifier - The CW member identifier (e.g. "jroberts")
* @returns {Promise<ResolvedMember>} Resolved member info
*/
export const resolveMember = async (
identifier: string,
): Promise<ResolvedMember> => {
const cwMember = memberCache.get(identifier);
const name = cwMember
? `${cwMember.firstName} ${cwMember.lastName}`.trim() || identifier
: identifier;
const localUser = await prisma.user.findFirst({
where: { cwIdentifier: identifier },
select: { id: true },
});
return {
id: localUser?.id ?? null,
identifier,
name,
cwMemberId: cwMember?.id ?? null,
};
};
@@ -0,0 +1,46 @@
import { connectWiseApi, prisma } from "../../../constants";
import { events } from "../../globalEvents";
import { fetchAllCwMembers, findCwIdentifierByEmail } from "./fetchAllMembers";
import { setMemberCache } from "./memberCache";
/**
* Refresh CW Identifiers
*
* Fetches all CW members and all users from the database, then updates
* each user's `cwIdentifier` field by matching their email to a CW member's
* `officeEmail`. Only users whose identifier has changed (or was previously
* null) are updated to avoid unnecessary writes.
*
* Also refreshes the in-memory member cache used for name resolution.
*/
export const refreshCwIdentifiers = async () => {
events.emit("cw:members:refresh:started");
const allMembers = await fetchAllCwMembers();
await setMemberCache(allMembers);
const allUsers = await prisma.user.findMany({
select: { id: true, email: true, cwIdentifier: true },
});
let updatedCount = 0;
await Promise.all(
allUsers.map(async (user) => {
const identifier = await findCwIdentifierByEmail(user.email, allMembers);
if (identifier !== user.cwIdentifier) {
await prisma.user.update({
where: { id: user.id },
data: { cwIdentifier: identifier },
});
updatedCount++;
}
}),
);
events.emit("cw:members:refresh:completed", {
totalMembers: allMembers.size,
totalUsers: allUsers.length,
usersUpdated: updatedCount,
});
};
@@ -3,8 +3,11 @@ import { connectWiseApi } from "../../../constants";
import {
CWOpportunity,
CWOpportunitySummary,
CWForecast,
CWForecastItem,
CWOpportunityNote,
CWOpportunityNoteCreate,
CWOpportunityNoteUpdate,
CWOpportunityContact,
} from "./opportunity.types";
@@ -106,14 +109,35 @@ export const opportunityCw = {
},
/**
* Fetch Opportunity Forecasts
* Fetch Opportunity Products
*
* Fetches forecast/revenue items for a given opportunity.
* Fetches the full forecast object (products, revenue summaries, totals)
* for a given opportunity.
*/
fetchForecasts: async (opportunityId: number): Promise<CWForecastItem[]> => {
fetchProducts: async (opportunityId: number): Promise<CWForecast> => {
const response = await connectWiseApi.get(
`/sales/opportunities/${opportunityId}/forecast`,
);
console.log(
`[CW fetchProducts] Opportunity ${opportunityId} forecast raw data:`,
JSON.stringify(response.data, null, 2),
);
return response.data;
},
/**
* Update Forecast Item
*
* Updates a single forecast item (product) on an opportunity using PUT.
*/
updateProduct: async (
opportunityId: number,
forecastItemId: number,
data: Record<string, unknown>,
): Promise<CWForecastItem> => {
const url = `/sales/opportunities/${opportunityId}/forecast/${forecastItemId}`;
const response = await connectWiseApi.put(url, data);
return response.data;
},
@@ -129,6 +153,69 @@ export const opportunityCw = {
return response.data;
},
/**
* Fetch Single Note
*
* Fetches a single note by its ID on the given opportunity.
*/
fetchNote: async (
opportunityId: number,
noteId: number,
): Promise<CWOpportunityNote> => {
const response = await connectWiseApi.get(
`/sales/opportunities/${opportunityId}/notes/${noteId}`,
);
return response.data;
},
/**
* Create Note
*
* Creates a new note on the given opportunity.
*/
createNote: async (
opportunityId: number,
data: CWOpportunityNoteCreate,
): Promise<CWOpportunityNote> => {
const response = await connectWiseApi.post(
`/sales/opportunities/${opportunityId}/notes`,
data,
);
return response.data;
},
/**
* Update Note
*
* Updates an existing note on the given opportunity.
*/
updateNote: async (
opportunityId: number,
noteId: number,
data: CWOpportunityNoteUpdate,
): Promise<CWOpportunityNote> => {
const response = await connectWiseApi.patch(
`/sales/opportunities/${opportunityId}/notes/${noteId}`,
Object.entries(data).map(([key, value]) => ({
op: "replace",
path: key,
value,
})),
);
return response.data;
},
/**
* Delete Note
*
* Deletes a note from the given opportunity.
*/
deleteNote: async (opportunityId: number, noteId: number): Promise<void> => {
await connectWiseApi.delete(
`/sales/opportunities/${opportunityId}/notes/${noteId}`,
);
},
/**
* Fetch Opportunity Contacts
*
@@ -142,4 +229,20 @@ export const opportunityCw = {
);
return response.data;
},
/**
* Fetch Procurement Products
*
* Fetches procurement product records linked to an opportunity.
* These contain cancellation data (cancelledFlag, cancelledReason, etc.)
* that the forecast endpoint does not provide.
*/
fetchProcurementProducts: async (
opportunityId: number,
): Promise<Record<string, unknown>[]> => {
const response = await connectWiseApi.get(
`/procurement/products?conditions=${encodeURIComponent(`opportunity/id=${opportunityId}`)}&fields=id,forecastDetailId,cancelledFlag,quantityCancelled,cancelledReason,cancelledBy,cancelledDate`,
);
return response.data;
},
};
@@ -30,7 +30,7 @@ interface CWSiteReference {
_info?: Record<string, string>;
}
interface CWCustomField {
export interface CWCustomField {
id: number;
caption: string;
type: string;
@@ -103,16 +103,72 @@ export interface CWOpportunityInfo {
export interface CWForecastItem {
id: number;
forecastDescription: string;
opportunity: CWReference;
forecastType: string;
forecastMonth: string;
quantity: number;
status: CWReference;
catalogItem?: {
id: number;
identifier: string;
_info?: Record<string, string>;
};
productDescription: string;
productClass: string;
revenue: number;
cost: number;
forecastPercentage: number;
status: CWReference;
includedFlag: boolean;
linkedFlag: boolean;
margin: number;
percentage: number;
includeFlag: boolean;
quoteWerksQuantity: number;
forecastType: string;
linkFlag: boolean;
recurringRevenue: number;
recurringCost: number;
cycles: number;
recurringFlag: boolean;
sequenceNumber: number;
subNumber: number;
taxableFlag: boolean;
_info?: Record<string, string>;
}
export interface CWForecastRevenueSummary {
id: number;
revenue: number;
cost: number;
margin: number;
percentage: number;
_info?: Record<string, string>;
}
export interface CWForecast {
id: number;
forecastItems: CWForecastItem[];
productRevenue: CWForecastRevenueSummary;
serviceRevenue: CWForecastRevenueSummary;
agreementRevenue: CWForecastRevenueSummary;
timeRevenue: CWForecastRevenueSummary;
expenseRevenue: CWForecastRevenueSummary;
forecastRevenueTotals: CWForecastRevenueSummary;
inclusiveRevenueTotals: CWForecastRevenueSummary;
recurringTotal: number;
wonRevenue: CWForecastRevenueSummary;
lostRevenue: CWForecastRevenueSummary;
openRevenue: CWForecastRevenueSummary;
otherRevenue1: CWForecastRevenueSummary;
otherRevenue2: CWForecastRevenueSummary;
salesTaxRevenue: number;
forecastTotalWithTaxes: number;
expectedProbability: number;
taxCode: CWReference;
billingTerms: CWReference;
currency: {
id: number;
symbol: string;
currencyCode: string;
name: string;
_info?: Record<string, string>;
};
_info?: Record<string, string>;
}
@@ -127,6 +183,18 @@ export interface CWOpportunityNote {
_info?: Record<string, string>;
}
export interface CWOpportunityNoteCreate {
text: string;
type?: { id: number };
flagged?: boolean;
}
export interface CWOpportunityNoteUpdate {
text?: string;
type?: { id: number };
flagged?: boolean;
}
export interface CWOpportunityContact {
id: number;
opportunity: CWReference;
@@ -96,6 +96,10 @@ export const refreshCatalog = async () => {
description: item.description,
customerDescription: item.customerDescription,
internalNotes: item.notes,
category: item.category?.name,
categoryCwId: item.category?.id,
subcategory: item.subcategory?.name,
subcategoryCwId: item.subcategory?.id,
manufacturer: item.manufacturer?.name,
manufactureCwId: item.manufacturer?.id,
partNumber: item.manufacturerPartNumber,
@@ -115,6 +119,10 @@ export const refreshCatalog = async () => {
description: item.description,
customerDescription: item.customerDescription,
internalNotes: item.notes,
category: item.category?.name,
categoryCwId: item.category?.id,
subcategory: item.subcategory?.name,
subcategoryCwId: item.subcategory?.id,
manufacturer: item.manufacturer?.name,
manufactureCwId: item.manufacturer?.id,
partNumber: item.manufacturerPartNumber,
@@ -0,0 +1,79 @@
import { connectWiseApi } from "../../../constants";
export interface CWCompanySite {
id: number;
name: string;
addressLine1: string;
addressLine2?: string;
city: string;
stateReference: { id: number; identifier: string; name: string } | null;
zip: string;
country: { id: number; name: string } | null;
phoneNumber: string;
faxNumber: string;
taxCodeId: number | null;
expenseReimbursement: number;
primaryAddressFlag: boolean;
defaultShippingFlag: boolean;
defaultBillingFlag: boolean;
defaultMailingFlag: boolean;
mobileGuid: string;
calendar: { id: number; name: string } | null;
timeZone: { id: number; name: string } | null;
company: { id: number; identifier: string; name: string };
_info: Record<string, string>;
}
/**
* Fetch all sites for a ConnectWise company.
*
* @param cwCompanyId - The ConnectWise company ID
* @returns Array of CW company sites
*/
export const fetchCompanySites = async (
cwCompanyId: number,
): Promise<CWCompanySite[]> => {
const response = await connectWiseApi.get(
`/company/companies/${cwCompanyId}/sites?pageSize=1000`,
);
return response.data;
};
/**
* Fetch a single site by CW site ID for a given company.
*
* @param cwCompanyId - The ConnectWise company ID
* @param cwSiteId - The ConnectWise site ID
* @returns The CW company site
*/
export const fetchCompanySite = async (
cwCompanyId: number,
cwSiteId: number,
): Promise<CWCompanySite> => {
const response = await connectWiseApi.get(
`/company/companies/${cwCompanyId}/sites/${cwSiteId}`,
);
return response.data;
};
/**
* Serialize a CW site into a clean API-friendly object.
*/
export const serializeCwSite = (site: CWCompanySite) => ({
id: site.id,
name: site.name,
address: {
line1: site.addressLine1,
line2: site.addressLine2 ?? null,
city: site.city,
state: site.stateReference?.name ?? null,
zip: site.zip,
country: site.country?.name ?? "United States",
},
phoneNumber: site.phoneNumber || null,
faxNumber: site.faxNumber || null,
primaryAddressFlag: site.primaryAddressFlag,
defaultShippingFlag: site.defaultShippingFlag,
defaultBillingFlag: site.defaultBillingFlag,
defaultMailingFlag: site.defaultMailingFlag,
});
@@ -0,0 +1,6 @@
export { userDefinedFieldsCw } from "./userDefinedFields";
export type {
CWUserDefinedField,
CWUserDefinedFieldOption,
CWUserDefinedFieldInfo,
} from "./udf.types";
@@ -0,0 +1,34 @@
export interface CWUserDefinedFieldOption {
id: number;
optionValue: string;
defaultFlag: boolean;
inactiveFlag: boolean;
sortOrder: number;
}
export interface CWUserDefinedFieldInfo {
lastUpdated: string;
updatedBy: string;
}
export interface CWUserDefinedField {
id: number;
podId: number;
caption: string;
sequenceNumber: number;
screenId: string;
helpText?: string;
fieldTypeIdentifier: string;
numberDecimals: number;
entryTypeIdentifier: string;
requiredFlag: boolean;
displayOnScreenFlag: boolean;
readOnlyFlag: boolean;
listViewFlag: boolean;
options?: CWUserDefinedFieldOption[];
businessUnitIds: number[];
locationIds: number[];
connectWiseID: string;
dateCreated: string;
_info: CWUserDefinedFieldInfo;
}
@@ -0,0 +1,119 @@
import { Collection } from "@discordjs/collection";
import { connectWiseApi, redis } from "../../../constants";
import { events } from "../../globalEvents";
import { CWUserDefinedField } from "./udf.types";
const REDIS_KEY = "cw:userDefinedFields";
/** In-memory cache of all CW User Defined Fields, keyed by UDF id */
let cache: Collection<number, CWUserDefinedField> = new Collection();
export const userDefinedFieldsCw = {
/**
* Get Cache
*
* Returns the current in-memory Collection of all User Defined Fields.
* If the cache is empty, it will attempt to hydrate from Redis first,
* then fall back to a live API fetch.
*/
get: async (): Promise<Collection<number, CWUserDefinedField>> => {
if (cache.size > 0) return cache;
// Try hydrating from Redis
const stored = await redis.get(REDIS_KEY);
if (stored) {
const parsed: CWUserDefinedField[] = JSON.parse(stored);
cache = new Collection(parsed.map((udf) => [udf.id, udf]));
return cache;
}
// Nothing cached anywhere — do a live fetch
return userDefinedFieldsCw.refresh();
},
/**
* Fetch All User Defined Fields
*
* Fetches all UDFs from the ConnectWise API.
* Does NOT update the cache — use `refresh()` for that.
*/
fetchAll: async (): Promise<Collection<number, CWUserDefinedField>> => {
const allItems = new Collection<number, CWUserDefinedField>();
const pageSize = 1000;
const response = await connectWiseApi.get(
`/system/userDefinedFields?pageSize=${pageSize}`,
);
const items: CWUserDefinedField[] = response.data;
for (const item of items) {
allItems.set(item.id, item);
}
return allItems;
},
/**
* Refresh
*
* Fetches all UDFs from ConnectWise, replaces the in-memory cache
* and persists the snapshot to Redis.
*/
refresh: async (): Promise<Collection<number, CWUserDefinedField>> => {
events.emit("cw:udf:refresh:started");
const allItems = await userDefinedFieldsCw.fetchAll();
cache = allItems;
// Persist to Redis
await redis.set(REDIS_KEY, JSON.stringify([...allItems.values()]));
events.emit("cw:udf:refresh:completed", { count: allItems.size });
return cache;
},
/**
* Find by ID
*
* Returns a single UDF by its ConnectWise ID from the cache.
*/
findById: async (id: number): Promise<CWUserDefinedField | undefined> => {
const items = await userDefinedFieldsCw.get();
return items.get(id);
},
/**
* Find by Caption
*
* Returns the first UDF matching the given caption (case-insensitive).
*/
findByCaption: async (
caption: string,
): Promise<CWUserDefinedField | undefined> => {
const items = await userDefinedFieldsCw.get();
const lowerCaption = caption.toLowerCase();
return items.find((udf) => udf.caption.toLowerCase() === lowerCaption);
},
/**
* Find by Screen ID
*
* Returns all UDFs associated with a given screenId.
*/
findByScreenId: async (
screenId: string,
): Promise<Collection<number, CWUserDefinedField>> => {
const items = await userDefinedFieldsCw.get();
return items.filter((udf) => udf.screenId === screenId);
},
/**
* Invalidate
*
* Clears the in-memory cache and removes the Redis key.
*/
invalidate: async (): Promise<void> => {
cache = new Collection();
await redis.del(REDIS_KEY);
},
};
+12
View File
@@ -177,6 +177,18 @@ interface EventTypes {
totalDb: number;
staleCount: number;
}) => void;
// ConnectWise User Defined Fields Events
"cw:udf:refresh:started": () => void;
"cw:udf:refresh:completed": (data: { count: number }) => void;
// ConnectWise Members Events
"cw:members:refresh:started": () => void;
"cw:members:refresh:completed": (data: {
totalMembers: number;
totalUsers: number;
usersUpdated: number;
}) => void;
}
export const events = new Eventra<EventTypes>();
+336 -7
View File
@@ -31,6 +31,8 @@ export interface PermissionCategory {
description: string;
/** Permission nodes in this category */
permissions: PermissionNode[];
/** Optional nested sub-categories for hierarchical grouping */
subCategories?: Record<string, PermissionCategory>;
}
export const PERMISSION_NODES = {
@@ -353,10 +355,13 @@ export const PERMISSION_NODES = {
},
{
node: "procurement.catalog.fetch.many",
description: "Fetch multiple catalog items or count",
description:
"Fetch multiple catalog items, count, categories/ecosystems, or filter values",
usedIn: [
"src/api/procurement/fetchAll.ts",
"src/api/procurement/count.ts",
"src/api/procurement/categories.ts",
"src/api/procurement/filters.ts",
],
},
{
@@ -385,18 +390,24 @@ export const PERMISSION_NODES = {
{
node: "sales.opportunity.fetch",
description:
"Fetch a single opportunity and its sub-resources (forecasts, notes, contacts)",
"Fetch a single opportunity and its sub-resources (products, notes, contacts)",
usedIn: [
"src/api/sales/[id]/fetch.ts",
"src/api/sales/[id]/forecasts.ts",
"src/api/sales/[id]/products.ts",
"src/api/sales/[id]/notes.ts",
"src/api/sales/[id]/fetchNote.ts",
"src/api/sales/[id]/contacts.ts",
],
},
{
node: "sales.opportunity.fetch.many",
description: "Fetch multiple opportunities or count",
usedIn: ["src/api/sales/fetchAll.ts", "src/api/sales/count.ts"],
description:
"Fetch multiple opportunities, count, or opportunity types",
usedIn: [
"src/api/sales/fetchAll.ts",
"src/api/sales/count.ts",
"src/api/sales/fetchOpportunityTypes.ts",
],
},
{
node: "sales.opportunity.refresh",
@@ -404,6 +415,31 @@ export const PERMISSION_NODES = {
usedIn: ["src/api/sales/[id]/refresh.ts"],
dependencies: ["sales.opportunity.fetch"],
},
{
node: "sales.opportunity.note.create",
description: "Create a new note on an opportunity",
usedIn: ["src/api/sales/[id]/createNote.ts"],
dependencies: ["sales.opportunity.fetch"],
},
{
node: "sales.opportunity.note.update",
description: "Update an existing note on an opportunity",
usedIn: ["src/api/sales/[id]/updateNote.ts"],
dependencies: ["sales.opportunity.fetch"],
},
{
node: "sales.opportunity.note.delete",
description: "Delete a note from an opportunity",
usedIn: ["src/api/sales/[id]/deleteNote.ts"],
dependencies: ["sales.opportunity.fetch"],
},
{
node: "sales.opportunity.product.update",
description:
"Update products (forecast items) on an opportunity, including resequencing",
usedIn: ["src/api/sales/[id]/resequenceProducts.ts"],
dependencies: ["sales.opportunity.fetch"],
},
],
},
@@ -642,14 +678,307 @@ export const PERMISSION_NODES = {
},
],
},
objectTypes: {
name: "Object Types",
description:
"Field-level read permissions that control which keys are visible on API response objects. Each sub-category corresponds to a domain object type. Use <scope>.* to grant access to all fields.",
permissions: [],
subCategories: {
company: {
name: "Company",
description:
"Field-level read permissions for Company response objects",
permissions: [
{
node: "obj.company",
description:
"Field-level gate for Company objects. Each key on the response is checked as obj.company.<field>. Only fields the user has permission for are included.",
usedIn: [
"src/api/companies/[id]/fetch.ts",
"src/api/companies/fetchAll.ts",
],
fieldLevelPermissions: [
"obj.company.id",
"obj.company.name",
"obj.company.cw_Identifier",
"obj.company.cw_CompanyId",
"obj.company.cw_Data",
"obj.company.createdAt",
"obj.company.updatedAt",
],
},
],
},
credential: {
name: "Credential",
description:
"Field-level read permissions for Credential response objects",
permissions: [
{
node: "obj.credential",
description:
"Field-level gate for Credential objects. Each key on the response is checked as obj.credential.<field>. Only fields the user has permission for are included.",
usedIn: [
"src/api/credentials/fetch.ts",
"src/api/credentials/fetchByCompany.ts",
"src/api/credentials/fetchSubCredentials.ts",
"src/api/credential-types/fetchCredentials.ts",
],
fieldLevelPermissions: [
"obj.credential.id",
"obj.credential.name",
"obj.credential.notes",
"obj.credential.typeId",
"obj.credential.companyId",
"obj.credential.subCredentialOfId",
"obj.credential.fields",
"obj.credential.type",
"obj.credential.company",
"obj.credential.subCredentials",
"obj.credential.secureFieldIds",
"obj.credential.createdAt",
"obj.credential.updatedAt",
],
},
],
},
credentialType: {
name: "Credential Type",
description:
"Field-level read permissions for Credential Type response objects",
permissions: [
{
node: "obj.credentialType",
description:
"Field-level gate for Credential Type objects. Each key on the response is checked as obj.credentialType.<field>. Only fields the user has permission for are included.",
usedIn: [
"src/api/credential-types/fetch.ts",
"src/api/credential-types/fetchAll.ts",
],
fieldLevelPermissions: [
"obj.credentialType.id",
"obj.credentialType.name",
"obj.credentialType.permissionScope",
"obj.credentialType.icon",
"obj.credentialType.fields",
"obj.credentialType.credentialCount",
"obj.credentialType.createdAt",
"obj.credentialType.updatedAt",
],
},
],
},
user: {
name: "User",
description: "Field-level read permissions for User response objects",
permissions: [
{
node: "obj.user",
description:
"Field-level gate for User objects. Each key on the response is checked as obj.user.<field>. Only fields the user has permission for are included.",
usedIn: [
"src/api/user/@me/fetch.ts",
"src/api/user/fetch.ts",
"src/api/user/fetchAll.ts",
"src/api/roles/getUsers.ts",
],
fieldLevelPermissions: [
"obj.user.id",
"obj.user.name",
"obj.user.roles",
"obj.user.permissions",
"obj.user.login",
"obj.user.email",
"obj.user.image",
"obj.user.createdAt",
"obj.user.updatedAt",
],
},
],
},
role: {
name: "Role",
description: "Field-level read permissions for Role response objects",
permissions: [
{
node: "obj.role",
description:
"Field-level gate for Role objects. Each key on the response is checked as obj.role.<field>. Only fields the user has permission for are included.",
usedIn: [
"src/api/roles/fetch.ts",
"src/api/roles/fetchAll.ts",
"src/api/user/fetchRoles.ts",
],
fieldLevelPermissions: [
"obj.role.id",
"obj.role.title",
"obj.role.moniker",
"obj.role.permissions",
"obj.role.users",
"obj.role.createdAt",
"obj.role.updatedAt",
],
},
],
},
catalogItem: {
name: "Catalog Item",
description:
"Field-level read permissions for Catalog Item (procurement) response objects",
permissions: [
{
node: "obj.catalogItem",
description:
"Field-level gate for Catalog Item objects. Each key on the response is checked as obj.catalogItem.<field>. Only fields the user has permission for are included.",
usedIn: [
"src/api/procurement/fetchAll.ts",
"src/api/procurement/[id]/fetch.ts",
"src/api/procurement/[id]/fetchLinked.ts",
],
fieldLevelPermissions: [
"obj.catalogItem.id",
"obj.catalogItem.cwCatalogId",
"obj.catalogItem.identifier",
"obj.catalogItem.name",
"obj.catalogItem.description",
"obj.catalogItem.customerDescription",
"obj.catalogItem.internalNotes",
"obj.catalogItem.manufacturer",
"obj.catalogItem.manufactureCwId",
"obj.catalogItem.partNumber",
"obj.catalogItem.vendorName",
"obj.catalogItem.vendorSku",
"obj.catalogItem.vendorCwId",
"obj.catalogItem.price",
"obj.catalogItem.cost",
"obj.catalogItem.inactive",
"obj.catalogItem.salesTaxable",
"obj.catalogItem.onHand",
"obj.catalogItem.cwLastUpdated",
"obj.catalogItem.linkedItems",
"obj.catalogItem.createdAt",
"obj.catalogItem.updatedAt",
],
},
],
},
opportunity: {
name: "Opportunity",
description:
"Field-level read permissions for Opportunity (sales) response objects",
permissions: [
{
node: "obj.opportunity",
description:
"Field-level gate for Opportunity objects. Each key on the response is checked as obj.opportunity.<field>. Only fields the user has permission for are included.",
usedIn: [
"src/api/sales/fetchAll.ts",
"src/api/sales/[id]/fetch.ts",
],
fieldLevelPermissions: [
"obj.opportunity.id",
"obj.opportunity.cwOpportunityId",
"obj.opportunity.name",
"obj.opportunity.notes",
"obj.opportunity.type",
"obj.opportunity.stage",
"obj.opportunity.status",
"obj.opportunity.priority",
"obj.opportunity.rating",
"obj.opportunity.source",
"obj.opportunity.campaign",
"obj.opportunity.primarySalesRep",
"obj.opportunity.secondarySalesRep",
"obj.opportunity.company",
"obj.opportunity.contact",
"obj.opportunity.site",
"obj.opportunity.customerPO",
"obj.opportunity.totalSalesTax",
"obj.opportunity.location",
"obj.opportunity.department",
"obj.opportunity.expectedCloseDate",
"obj.opportunity.pipelineChangeDate",
"obj.opportunity.dateBecameLead",
"obj.opportunity.closedDate",
"obj.opportunity.closedFlag",
"obj.opportunity.closedBy",
"obj.opportunity.companyId",
"obj.opportunity.cwLastUpdated",
"obj.opportunity.createdAt",
"obj.opportunity.updatedAt",
],
},
],
},
unifiSite: {
name: "UniFi Site",
description:
"Field-level read permissions for UniFi Site response objects",
permissions: [
{
node: "obj.unifiSite",
description:
"Field-level gate for UniFi Site objects. Each key on the response is checked as obj.unifiSite.<field>. Only fields the user has permission for are included.",
usedIn: [
"src/api/unifi/sites/fetchAll.ts",
"src/api/unifi/site/fetch.ts",
"src/api/companies/[id]/unifiSites.ts",
],
fieldLevelPermissions: [
"obj.unifiSite.id",
"obj.unifiSite.name",
"obj.unifiSite.siteId",
"obj.unifiSite.companyId",
"obj.unifiSite.company",
"obj.unifiSite.createdAt",
"obj.unifiSite.updatedAt",
],
},
],
},
wifiNetwork: {
name: "WiFi Network",
description:
"Field-level read permissions for UniFi WiFi Network (WLAN) response objects. See the unifi category for the full field-level permission list under unifi.site.wifi.read.",
permissions: [
{
node: "unifi.site.wifi.read",
description:
"Field-level gate for WiFi network response data (defined in the unifi category). Each key on the WlanConf object is checked as unifi.site.wifi.read.<field>.",
usedIn: ["src/api/unifi/site/wifi/fetchAll.ts"],
dependencies: ["unifi.access", "unifi.site.wifi"],
},
],
},
},
},
} as const satisfies Record<string, PermissionCategory>;
/**
* Recursively collects permission nodes from a category and its sub-categories.
*/
function collectPermissions(category: PermissionCategory): PermissionNode[] {
const direct = category.permissions as PermissionNode[];
const nested = category.subCategories
? Object.values(category.subCategories).flatMap(collectPermissions)
: [];
return [...direct, ...nested];
}
/**
* Utility function to get all permission nodes flattened into a single array
*/
export function getAllPermissionNodes(): PermissionNode[] {
return Object.values(PERMISSION_NODES).flatMap(
(category) => category.permissions as PermissionNode[],
return Object.values(PERMISSION_NODES).flatMap((category) =>
collectPermissions(category),
);
}
+198
View File
@@ -0,0 +1,198 @@
export interface QuoteStatus {
id: number;
name: string;
wonFlag: boolean;
lostFlag: boolean;
closedFlag: boolean;
inactiveFlag: boolean;
defaultFlag: boolean;
enteredBy: string;
dateEntered: string;
_info: {
lastUpdated: string;
updatedBy: string;
};
connectWiseId: string;
optimaEquivalency: number[];
}
export const QUOTE_STATUSES: QuoteStatus[] = [
//
// FUTURE
//
{
id: 51,
name: "FutureLead",
wonFlag: false,
lostFlag: false,
closedFlag: false,
inactiveFlag: false,
defaultFlag: false,
enteredBy: "crobinso",
dateEntered: "2023-07-11T23:13:19Z",
_info: {
lastUpdated: "2024-04-28T15:03:57Z",
updatedBy: "crobinso",
},
connectWiseId: "070f72a3-70d0-ef11-b2e0-000c29c55070",
optimaEquivalency: [
35, // Z9. Later
36, // Z0. TT Identified Need
],
},
//
// NEW
//
{
id: 24,
name: "New",
wonFlag: false,
lostFlag: false,
closedFlag: false,
inactiveFlag: false,
defaultFlag: true,
enteredBy: "CRobinso",
dateEntered: "2021-01-03T15:06:59Z",
_info: {
lastUpdated: "2024-04-28T15:04:43Z",
updatedBy: "crobinso",
},
connectWiseId: "ec0e72a3-70d0-ef11-b2e0-000c29c55070",
optimaEquivalency: [
1, // Pre2021-1) New
13, // Pre2021-Initial Contact Made
37, // 00. Pending New
],
},
//
// INTERNAL REVIEW
//
{
id: 56,
name: "Internal Review",
wonFlag: false,
lostFlag: false,
closedFlag: false,
inactiveFlag: false,
defaultFlag: false,
enteredBy: "crobinso",
dateEntered: "2024-04-28T15:05:09Z",
_info: {
lastUpdated: "2024-04-28T15:05:09Z",
updatedBy: "crobinso",
},
connectWiseId: "0c0f72a3-70d0-ef11-b2e0-000c29c55070",
optimaEquivalency: [
10, // Pre2021-Order Approved
26, // Z3. ConfirmedQuote
27, // Z4. Waiting-VendorInfo
28, // Z5. Waiting-OtherTTStaff
41, // PRE2405. Review Ready
54, // PRE24_90. Customer Approved
],
},
//
// ACTIVE
//
{
id: 58,
name: "Active",
wonFlag: false,
lostFlag: false,
closedFlag: false,
inactiveFlag: false,
defaultFlag: false,
enteredBy: "crobinso",
dateEntered: "2024-04-28T15:07:17Z",
_info: {
lastUpdated: "2024-04-28T15:07:17Z",
updatedBy: "crobinso",
},
connectWiseId: "0e0f72a3-70d0-ef11-b2e0-000c29c55070",
optimaEquivalency: [
9, // Pre2021-Recommendation
15, // Pre2021-3) Onsite Assess Sch'd
16, // Pre2021-4) Quote Info Gathered
17, // Pre2021-5) Quote Sent
18, // Pre2021-6) Follow-up #1 Made
19, // Pre2021-7) Follow-up #2 Made
20, // Pre2021-8) Follow-up #3 Made
25, // ZOLD---Quote Sent
43, // 03. Quote Sent
38, // PRE2402. On-Site Ready
39, // PRE2403. On-Site Scheduled
40, // PRE2404. On-Site Complete
42, // PRE2407. Reviewed
44, // PRE2409. Follow-Up 1
45, // PRE2410. Changes Needed
46, // PRE2411. Follow-Up 2
47, // PRE2412. Follow-Up3
48, // PRE2413. Follow-Up Extended
52, // PRE2489. Overdue
55, // PRE24_70. Quote Sent - Sell
],
},
//
// WON
//
{
id: 29,
name: "Won",
wonFlag: true,
lostFlag: false,
closedFlag: true,
inactiveFlag: false,
defaultFlag: false,
enteredBy: "CRobinso",
dateEntered: "2021-01-03T15:07:44Z",
_info: {
lastUpdated: "2024-01-21T20:39:41Z",
updatedBy: "crobinso",
},
connectWiseId: "f10e72a3-70d0-ef11-b2e0-000c29c55070",
optimaEquivalency: [
2, // Pre2021-8) Won
54, // PRE24_90. Customer Approved (if you treat as effectively Won)
49, // 91. Pending Won
],
},
//
// LOST
//
{
id: 53,
name: "Lost",
wonFlag: false,
lostFlag: true,
closedFlag: true,
inactiveFlag: false,
defaultFlag: false,
enteredBy: "crobinso",
dateEntered: "2024-01-20T20:51:35Z",
_info: {
lastUpdated: "2024-01-20T20:51:41Z",
updatedBy: "crobinso",
},
connectWiseId: "090f72a3-70d0-ef11-b2e0-000c29c55070",
optimaEquivalency: [
3, // Pre2021-9) Lost
4, // Pre2021-No Decision
12, // Pre2021-OLD
30, // Pre2024_99. Lost-Competitor
31, // Pre2024_99. Lost-DIY
32, // Pre2024_99. Lost-NoDecision
33, // Pre2024_99. Lost-Pricing
34, // Pre2024_99. Lost-OtherTTQuote
50, // 98. Pending Lost
],
},
];