Fix UserController permission serialization and include current updates
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
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/items/:identifier */
|
||||
export default createRoute(
|
||||
"get",
|
||||
["/items/:identifier"],
|
||||
async (c) => {
|
||||
const identifier = c.req.param("identifier");
|
||||
const includeLinkedItems = c.req.query("includeLinkedItems") === "true";
|
||||
|
||||
const item = await procurement.fetchItem(identifier);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Catalog item fetched successfully!",
|
||||
item.toJson({ includeLinkedItems }),
|
||||
);
|
||||
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["procurement.catalog.fetch"] }),
|
||||
);
|
||||
@@ -0,0 +1,25 @@
|
||||
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";
|
||||
|
||||
/* GET /v1/procurement/items/:identifier/linked */
|
||||
export default createRoute(
|
||||
"get",
|
||||
["/items/:identifier/linked"],
|
||||
async (c) => {
|
||||
const identifier = c.req.param("identifier");
|
||||
const item = await procurement.fetchItem(identifier);
|
||||
|
||||
const linkedItems = item.getLinkedItems().map((linked) => linked.toJson());
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Linked catalog items fetched successfully!",
|
||||
linkedItems,
|
||||
);
|
||||
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["procurement.catalog.fetch"] }),
|
||||
);
|
||||
@@ -0,0 +1,28 @@
|
||||
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";
|
||||
import { z } from "zod";
|
||||
|
||||
/* POST /v1/procurement/items/:identifier/link */
|
||||
export default createRoute(
|
||||
"post",
|
||||
["/items/:identifier/link"],
|
||||
async (c) => {
|
||||
const identifier = c.req.param("identifier");
|
||||
const body = await c.req.json();
|
||||
const schema = z.object({ targetId: z.string() }).strict();
|
||||
const { targetId } = schema.parse(body);
|
||||
|
||||
const item = await procurement.linkItems(identifier, targetId);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Catalog item linked successfully!",
|
||||
item.toJson({ includeLinkedItems: true }),
|
||||
);
|
||||
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["procurement.catalog.link"] }),
|
||||
);
|
||||
@@ -0,0 +1,25 @@
|
||||
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/items/:identifier/refresh-inventory */
|
||||
export default createRoute(
|
||||
"post",
|
||||
["/items/:identifier/refresh-inventory"],
|
||||
async (c) => {
|
||||
const identifier = c.req.param("identifier");
|
||||
const item = await procurement.fetchItem(identifier);
|
||||
|
||||
await item.refreshInventory();
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Inventory refreshed successfully!",
|
||||
item.toJson(),
|
||||
);
|
||||
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["procurement.catalog.inventory.refresh"] }),
|
||||
);
|
||||
@@ -0,0 +1,28 @@
|
||||
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";
|
||||
import { z } from "zod";
|
||||
|
||||
/* POST /v1/procurement/items/:identifier/unlink */
|
||||
export default createRoute(
|
||||
"post",
|
||||
["/items/:identifier/unlink"],
|
||||
async (c) => {
|
||||
const identifier = c.req.param("identifier");
|
||||
const body = await c.req.json();
|
||||
const schema = z.object({ targetId: z.string() }).strict();
|
||||
const { targetId } = schema.parse(body);
|
||||
|
||||
const item = await procurement.unlinkItems(identifier, targetId);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Catalog item unlinked successfully!",
|
||||
item.toJson({ includeLinkedItems: true }),
|
||||
);
|
||||
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["procurement.catalog.link"] }),
|
||||
);
|
||||
@@ -0,0 +1,24 @@
|
||||
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/count */
|
||||
export default createRoute(
|
||||
"get",
|
||||
["/count"],
|
||||
async (c) => {
|
||||
const activeOnly = c.req.query("activeOnly") === "true";
|
||||
|
||||
const count = await procurement.count({ activeOnly });
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Catalog item count fetched successfully!",
|
||||
{ count },
|
||||
);
|
||||
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["procurement.catalog.fetch.many"] }),
|
||||
);
|
||||
@@ -0,0 +1,43 @@
|
||||
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/items */
|
||||
export default createRoute(
|
||||
"get",
|
||||
["/items"],
|
||||
async (c) => {
|
||||
const page = Number(c.req.query("page") ?? 1);
|
||||
const rpp = Number(c.req.query("rpp") ?? 30);
|
||||
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 });
|
||||
|
||||
const totalRecords = await procurement.count({
|
||||
activeOnly: !includeInactive,
|
||||
});
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Catalog items fetched successfully!",
|
||||
data.map((item) => item.toJson()),
|
||||
{
|
||||
pagination: {
|
||||
previousPage: page <= 1 ? null : page - 1,
|
||||
currentPage: page,
|
||||
nextPage: page >= totalRecords / rpp ? null : page + 1,
|
||||
totalPages: Math.ceil(totalRecords / rpp),
|
||||
totalRecords,
|
||||
listedRecords: rpp,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["procurement.catalog.fetch.many"] }),
|
||||
);
|
||||
@@ -0,0 +1,9 @@
|
||||
import { default as fetchAll } from "./fetchAll";
|
||||
import { default as fetch } from "./[id]/fetch";
|
||||
import { default as refreshInventory } from "./[id]/refreshInventory";
|
||||
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";
|
||||
|
||||
export { count, fetch, fetchAll, fetchLinked, link, refreshInventory, unlink };
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Hono } from "hono";
|
||||
import * as procurementRoutes from "../procurement";
|
||||
|
||||
const procurementRouter = new Hono();
|
||||
Object.values(procurementRoutes).map((r) => procurementRouter.route("/", r));
|
||||
|
||||
export default procurementRouter;
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Hono } from "hono";
|
||||
import * as salesRoutes from "../sales";
|
||||
|
||||
const salesRouter = new Hono();
|
||||
Object.values(salesRoutes).map((r) => salesRouter.route("/", r));
|
||||
|
||||
export default salesRouter;
|
||||
@@ -0,0 +1,41 @@
|
||||
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/contacts */
|
||||
export default createRoute(
|
||||
"get",
|
||||
["/opportunities/:identifier/contacts"],
|
||||
async (c) => {
|
||||
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 response = apiResponse.successful(
|
||||
"Opportunity contacts fetched successfully!",
|
||||
data,
|
||||
);
|
||||
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.fetch"] }),
|
||||
);
|
||||
@@ -0,0 +1,24 @@
|
||||
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 */
|
||||
export default createRoute(
|
||||
"get",
|
||||
["/opportunities/:identifier"],
|
||||
async (c) => {
|
||||
const identifier = c.req.param("identifier");
|
||||
|
||||
const item = await opportunities.fetchItem(identifier);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Opportunity fetched successfully!",
|
||||
item.toJson(),
|
||||
);
|
||||
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.fetch"] }),
|
||||
);
|
||||
@@ -0,0 +1,39 @@
|
||||
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"] }),
|
||||
);
|
||||
@@ -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 { opportunityCw } from "../../../modules/cw-utils/opportunities/opportunities";
|
||||
|
||||
/* GET /v1/sales/opportunities/:identifier/notes */
|
||||
export default createRoute(
|
||||
"get",
|
||||
["/opportunities/:identifier/notes"],
|
||||
async (c) => {
|
||||
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 response = apiResponse.successful(
|
||||
"Opportunity notes fetched successfully!",
|
||||
data,
|
||||
);
|
||||
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.fetch"] }),
|
||||
);
|
||||
@@ -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";
|
||||
|
||||
/* POST /v1/sales/opportunities/:identifier/refresh */
|
||||
export default createRoute(
|
||||
"post",
|
||||
["/opportunities/:identifier/refresh"],
|
||||
async (c) => {
|
||||
const identifier = c.req.param("identifier");
|
||||
const item = await opportunities.fetchItem(identifier);
|
||||
|
||||
const refreshed = await item.refreshFromCW();
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Opportunity refreshed from ConnectWise successfully!",
|
||||
refreshed.toJson(),
|
||||
);
|
||||
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.refresh"] }),
|
||||
);
|
||||
@@ -0,0 +1,24 @@
|
||||
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/count */
|
||||
export default createRoute(
|
||||
"get",
|
||||
["/opportunities/count"],
|
||||
async (c) => {
|
||||
const openOnly = c.req.query("openOnly") === "true";
|
||||
|
||||
const count = await opportunities.count({ openOnly });
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Opportunity count fetched successfully!",
|
||||
{ count },
|
||||
);
|
||||
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.fetch.many"] }),
|
||||
);
|
||||
@@ -0,0 +1,43 @@
|
||||
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 */
|
||||
export default createRoute(
|
||||
"get",
|
||||
["/opportunities"],
|
||||
async (c) => {
|
||||
const page = Number(c.req.query("page") ?? 1);
|
||||
const rpp = Number(c.req.query("rpp") ?? 30);
|
||||
const search = c.req.query("search") as string;
|
||||
const includeClosed = c.req.query("includeClosed") === "true";
|
||||
|
||||
const data = search
|
||||
? await opportunities.search(search, page, rpp, { includeClosed })
|
||||
: await opportunities.fetchPages(page, rpp, { includeClosed });
|
||||
|
||||
const totalRecords = await opportunities.count({
|
||||
openOnly: !includeClosed,
|
||||
});
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Opportunities fetched successfully!",
|
||||
data.map((item) => item.toJson()),
|
||||
{
|
||||
pagination: {
|
||||
previousPage: page <= 1 ? null : page - 1,
|
||||
currentPage: page,
|
||||
nextPage: page >= totalRecords / rpp ? null : page + 1,
|
||||
totalPages: Math.ceil(totalRecords / rpp),
|
||||
totalRecords,
|
||||
listedRecords: rpp,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.fetch.many"] }),
|
||||
);
|
||||
@@ -0,0 +1,9 @@
|
||||
import { default as fetchAll } from "./fetchAll";
|
||||
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 notes } from "./[id]/notes";
|
||||
import { default as contacts } from "./[id]/contacts";
|
||||
|
||||
export { count, fetch, fetchAll, forecasts, notes, contacts, refresh };
|
||||
@@ -55,6 +55,8 @@ v1.route("/credential-type", require("./routers/credentialTypeRouter").default);
|
||||
v1.route("/role", require("./routers/roleRouter").default);
|
||||
v1.route("/permissions", require("./routers/permissionRouter").default);
|
||||
v1.route("/unifi", require("./routers/unifiRouter").default);
|
||||
v1.route("/procurement", require("./routers/procurementRouter").default);
|
||||
v1.route("/sales", require("./routers/salesRouter").default);
|
||||
app.route("/v1", v1);
|
||||
|
||||
export default app;
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
import { CatalogItem } from "../../generated/prisma/client";
|
||||
import { prisma } from "../constants";
|
||||
import { catalogCw } from "../modules/cw-utils/procurement/catalog";
|
||||
import { CatalogItem as CWCatalogItem } from "../modules/cw-utils/procurement/catalog.types";
|
||||
import GenericError from "../Errors/GenericError";
|
||||
|
||||
/**
|
||||
* Catalog Item Controller
|
||||
*
|
||||
* This class encapsulates a catalog item entity and provides domain methods
|
||||
* for accessing, refreshing, and serializing catalog item data. It bridges
|
||||
* the internal database representation with ConnectWise catalog data.
|
||||
*/
|
||||
export class CatalogItemController {
|
||||
public readonly id: string;
|
||||
public name: string;
|
||||
public description: string | null;
|
||||
public customerDescription: string | null;
|
||||
public internalNotes: string | null;
|
||||
|
||||
public readonly cwCatalogId: number;
|
||||
public readonly identifier: string | null;
|
||||
|
||||
public manufacturer: string | null;
|
||||
public manufactureCwId: number | null;
|
||||
public partNumber: string | null;
|
||||
|
||||
public vendorName: string | null;
|
||||
public vendorSku: string | null;
|
||||
public vendorCwId: number | null;
|
||||
|
||||
public price: number;
|
||||
public cost: number;
|
||||
|
||||
public inactive: boolean;
|
||||
public salesTaxable: boolean;
|
||||
|
||||
public onHand: number;
|
||||
public cwLastUpdated: Date | null;
|
||||
|
||||
private _linkedItems: CatalogItemController[];
|
||||
|
||||
public readonly createdAt: Date;
|
||||
public updatedAt: Date;
|
||||
|
||||
constructor(
|
||||
itemData: CatalogItem & {
|
||||
linkedItems?: CatalogItem[];
|
||||
},
|
||||
) {
|
||||
this.id = itemData.id;
|
||||
this.name = itemData.name;
|
||||
this.description = itemData.description;
|
||||
this.customerDescription = itemData.customerDescription;
|
||||
this.internalNotes = itemData.internalNotes;
|
||||
this.cwCatalogId = itemData.cwCatalogId;
|
||||
this.identifier = itemData.identifier;
|
||||
this.manufacturer = itemData.manufacturer;
|
||||
this.manufactureCwId = itemData.manufactureCwId;
|
||||
this.partNumber = itemData.partNumber;
|
||||
this.vendorName = itemData.vendorName;
|
||||
this.vendorSku = itemData.vendorSku;
|
||||
this.vendorCwId = itemData.vendorCwId;
|
||||
this.price = itemData.price;
|
||||
this.cost = itemData.cost;
|
||||
this.inactive = itemData.inactive;
|
||||
this.salesTaxable = itemData.salesTaxable;
|
||||
this.onHand = itemData.onHand;
|
||||
this.cwLastUpdated = itemData.cwLastUpdated;
|
||||
this.createdAt = itemData.createdAt;
|
||||
this.updatedAt = itemData.updatedAt;
|
||||
|
||||
this._linkedItems = (itemData.linkedItems ?? []).map(
|
||||
(linked) => new CatalogItemController(linked),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh Inventory
|
||||
*
|
||||
* Fetches the latest on-hand inventory count from ConnectWise
|
||||
* and updates both the controller state and the database.
|
||||
*
|
||||
* @returns {Promise<CatalogItemController>} - The updated controller
|
||||
*/
|
||||
public async refreshInventory(): Promise<CatalogItemController> {
|
||||
const onHand = await catalogCw.fetchInventoryOnHand(this.cwCatalogId);
|
||||
|
||||
if (onHand !== this.onHand) {
|
||||
await prisma.catalogItem.update({
|
||||
where: { id: this.id },
|
||||
data: { onHand },
|
||||
});
|
||||
this.onHand = onHand;
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Linked Items
|
||||
*
|
||||
* Returns the linked catalog items as an array of controllers.
|
||||
*
|
||||
* @returns {CatalogItemController[]} - Array of linked item controllers
|
||||
*/
|
||||
public getLinkedItems(): CatalogItemController[] {
|
||||
return this._linkedItems;
|
||||
}
|
||||
|
||||
/**
|
||||
* Link Item
|
||||
*
|
||||
* Links another catalog item to this item. The relationship is bidirectional
|
||||
* via the Prisma implicit many-to-many.
|
||||
*
|
||||
* @param targetId - The internal ID of the catalog item to link
|
||||
* @returns {Promise<CatalogItemController>} - The updated controller
|
||||
*/
|
||||
public async linkItem(targetId: string): Promise<CatalogItemController> {
|
||||
if (targetId === this.id) {
|
||||
throw new GenericError({
|
||||
message: "Cannot link a catalog item to itself",
|
||||
name: "InvalidLinkTarget",
|
||||
cause: `Item '${this.id}' cannot be linked to itself`,
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const target = await prisma.catalogItem.findFirst({
|
||||
where: { id: targetId },
|
||||
});
|
||||
|
||||
if (!target) {
|
||||
throw new GenericError({
|
||||
message: "Target catalog item not found",
|
||||
name: "CatalogItemNotFound",
|
||||
cause: `No catalog item exists with ID '${targetId}'`,
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
|
||||
const updated = await prisma.catalogItem.update({
|
||||
where: { id: this.id },
|
||||
data: {
|
||||
linkedItems: { connect: { id: targetId } },
|
||||
},
|
||||
include: { linkedItems: true },
|
||||
});
|
||||
|
||||
this._linkedItems = (updated.linkedItems ?? []).map(
|
||||
(linked) => new CatalogItemController(linked),
|
||||
);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlink Item
|
||||
*
|
||||
* Removes the link between this catalog item and another.
|
||||
*
|
||||
* @param targetId - The internal ID of the catalog item to unlink
|
||||
* @returns {Promise<CatalogItemController>} - The updated controller
|
||||
*/
|
||||
public async unlinkItem(targetId: string): Promise<CatalogItemController> {
|
||||
const updated = await prisma.catalogItem.update({
|
||||
where: { id: this.id },
|
||||
data: {
|
||||
linkedItems: { disconnect: { id: targetId } },
|
||||
},
|
||||
include: { linkedItems: true },
|
||||
});
|
||||
|
||||
this._linkedItems = (updated.linkedItems ?? []).map(
|
||||
(linked) => new CatalogItemController(linked),
|
||||
);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* To JSON
|
||||
*
|
||||
* Serializes the catalog item into a safe, API-friendly object.
|
||||
*
|
||||
* @param opts - Options to control output
|
||||
* @returns - A JSON-safe representation of the catalog item
|
||||
*/
|
||||
public toJson(opts?: { includeLinkedItems?: boolean }): Record<string, any> {
|
||||
return {
|
||||
id: this.id,
|
||||
cwCatalogId: this.cwCatalogId,
|
||||
identifier: this.identifier,
|
||||
name: this.name,
|
||||
description: this.description,
|
||||
customerDescription: this.customerDescription,
|
||||
internalNotes: this.internalNotes,
|
||||
manufacturer: this.manufacturer,
|
||||
manufactureCwId: this.manufactureCwId,
|
||||
partNumber: this.partNumber,
|
||||
vendorName: this.vendorName,
|
||||
vendorSku: this.vendorSku,
|
||||
vendorCwId: this.vendorCwId,
|
||||
price: this.price,
|
||||
cost: this.cost,
|
||||
inactive: this.inactive,
|
||||
salesTaxable: this.salesTaxable,
|
||||
onHand: this.onHand,
|
||||
cwLastUpdated: this.cwLastUpdated,
|
||||
linkedItems: opts?.includeLinkedItems
|
||||
? this._linkedItems.map((item) => item.toJson())
|
||||
: undefined,
|
||||
createdAt: this.createdAt,
|
||||
updatedAt: this.updatedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ export class CompanyController {
|
||||
public readonly cw_CompanyId: number;
|
||||
public readonly cw_Data?: {
|
||||
company: CWCompany;
|
||||
defaultContact: Contact;
|
||||
defaultContact: Contact | null;
|
||||
allContacts: Contact[];
|
||||
};
|
||||
|
||||
@@ -96,23 +96,25 @@ export class CompanyController {
|
||||
},
|
||||
primaryContact: !opts?.includePrimaryContact
|
||||
? undefined
|
||||
: {
|
||||
firstName: this.cw_Data?.defaultContact.firstName,
|
||||
lastName: this.cw_Data?.defaultContact.lastName,
|
||||
cwId: this.cw_Data?.defaultContact.id,
|
||||
inactive: this.cw_Data?.defaultContact.inactiveFlag,
|
||||
title: this.cw_Data?.defaultContact.title,
|
||||
phone: this.cw_Data?.defaultContact.defaultPhoneNbr,
|
||||
email: (() => {
|
||||
if (!this.cw_Data?.defaultContact.communicationItems)
|
||||
return null;
|
||||
return (
|
||||
this.cw_Data?.defaultContact.communicationItems.find(
|
||||
(v) => v.type.name === "Email",
|
||||
)?.value ?? null
|
||||
);
|
||||
})(),
|
||||
},
|
||||
: this.cw_Data?.defaultContact
|
||||
? {
|
||||
firstName: this.cw_Data.defaultContact.firstName,
|
||||
lastName: this.cw_Data.defaultContact.lastName,
|
||||
cwId: this.cw_Data.defaultContact.id,
|
||||
inactive: this.cw_Data.defaultContact.inactiveFlag,
|
||||
title: this.cw_Data.defaultContact.title,
|
||||
phone: this.cw_Data.defaultContact.defaultPhoneNbr,
|
||||
email: (() => {
|
||||
if (!this.cw_Data?.defaultContact?.communicationItems)
|
||||
return null;
|
||||
return (
|
||||
this.cw_Data.defaultContact.communicationItems.find(
|
||||
(v) => v.type.name === "Email",
|
||||
)?.value ?? null
|
||||
);
|
||||
})(),
|
||||
}
|
||||
: null,
|
||||
allContacts: !opts?.includeAllContacts
|
||||
? undefined
|
||||
: this.cw_Data?.allContacts.map((contact) => ({
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
import { Opportunity } from "../../generated/prisma/client";
|
||||
import { prisma } from "../constants";
|
||||
import { fetchOpportunity } from "../modules/cw-utils/opportunities/fetchOpportunity";
|
||||
import { CWOpportunity } from "../modules/cw-utils/opportunities/opportunity.types";
|
||||
|
||||
/**
|
||||
* Opportunity Controller
|
||||
*
|
||||
* Domain model class that encapsulates an Opportunity entity and provides
|
||||
* methods for accessing, refreshing from ConnectWise, and serializing
|
||||
* opportunity data.
|
||||
*/
|
||||
export class OpportunityController {
|
||||
public readonly id: string;
|
||||
public readonly cwOpportunityId: number;
|
||||
public name: string;
|
||||
public notes: string | null;
|
||||
|
||||
public typeName: string | null;
|
||||
public typeCwId: number | null;
|
||||
public stageName: string | null;
|
||||
public stageCwId: number | null;
|
||||
public statusName: string | null;
|
||||
public statusCwId: number | null;
|
||||
public priorityName: string | null;
|
||||
public priorityCwId: number | null;
|
||||
public ratingName: string | null;
|
||||
public ratingCwId: number | null;
|
||||
public source: string | null;
|
||||
public campaignName: string | null;
|
||||
public campaignCwId: number | null;
|
||||
|
||||
public primarySalesRepName: string | null;
|
||||
public primarySalesRepIdentifier: string | null;
|
||||
public primarySalesRepCwId: number | null;
|
||||
public secondarySalesRepName: string | null;
|
||||
public secondarySalesRepIdentifier: string | null;
|
||||
public secondarySalesRepCwId: number | null;
|
||||
|
||||
public companyCwId: number | null;
|
||||
public companyName: string | null;
|
||||
public contactCwId: number | null;
|
||||
public contactName: string | null;
|
||||
public siteCwId: number | null;
|
||||
public siteName: string | null;
|
||||
public customerPO: string | null;
|
||||
|
||||
public totalSalesTax: number;
|
||||
|
||||
public locationName: string | null;
|
||||
public locationCwId: number | null;
|
||||
public departmentName: string | null;
|
||||
public departmentCwId: number | null;
|
||||
|
||||
public expectedCloseDate: Date | null;
|
||||
public pipelineChangeDate: Date | null;
|
||||
public dateBecameLead: Date | null;
|
||||
public closedDate: Date | null;
|
||||
public closedFlag: boolean;
|
||||
public closedByName: string | null;
|
||||
public closedByCwId: number | null;
|
||||
|
||||
public companyId: string | null;
|
||||
public cwLastUpdated: Date | null;
|
||||
|
||||
public readonly createdAt: Date;
|
||||
public updatedAt: Date;
|
||||
|
||||
constructor(data: Opportunity) {
|
||||
this.id = data.id;
|
||||
this.cwOpportunityId = data.cwOpportunityId;
|
||||
this.name = data.name;
|
||||
this.notes = data.notes;
|
||||
|
||||
this.typeName = data.typeName;
|
||||
this.typeCwId = data.typeCwId;
|
||||
this.stageName = data.stageName;
|
||||
this.stageCwId = data.stageCwId;
|
||||
this.statusName = data.statusName;
|
||||
this.statusCwId = data.statusCwId;
|
||||
this.priorityName = data.priorityName;
|
||||
this.priorityCwId = data.priorityCwId;
|
||||
this.ratingName = data.ratingName;
|
||||
this.ratingCwId = data.ratingCwId;
|
||||
this.source = data.source;
|
||||
this.campaignName = data.campaignName;
|
||||
this.campaignCwId = data.campaignCwId;
|
||||
|
||||
this.primarySalesRepName = data.primarySalesRepName;
|
||||
this.primarySalesRepIdentifier = data.primarySalesRepIdentifier;
|
||||
this.primarySalesRepCwId = data.primarySalesRepCwId;
|
||||
this.secondarySalesRepName = data.secondarySalesRepName;
|
||||
this.secondarySalesRepIdentifier = data.secondarySalesRepIdentifier;
|
||||
this.secondarySalesRepCwId = data.secondarySalesRepCwId;
|
||||
|
||||
this.companyCwId = data.companyCwId;
|
||||
this.companyName = data.companyName;
|
||||
this.contactCwId = data.contactCwId;
|
||||
this.contactName = data.contactName;
|
||||
this.siteCwId = data.siteCwId;
|
||||
this.siteName = data.siteName;
|
||||
this.customerPO = data.customerPO;
|
||||
|
||||
this.totalSalesTax = data.totalSalesTax;
|
||||
|
||||
this.locationName = data.locationName;
|
||||
this.locationCwId = data.locationCwId;
|
||||
this.departmentName = data.departmentName;
|
||||
this.departmentCwId = data.departmentCwId;
|
||||
|
||||
this.expectedCloseDate = data.expectedCloseDate;
|
||||
this.pipelineChangeDate = data.pipelineChangeDate;
|
||||
this.dateBecameLead = data.dateBecameLead;
|
||||
this.closedDate = data.closedDate;
|
||||
this.closedFlag = data.closedFlag;
|
||||
this.closedByName = data.closedByName;
|
||||
this.closedByCwId = data.closedByCwId;
|
||||
|
||||
this.companyId = data.companyId;
|
||||
this.cwLastUpdated = data.cwLastUpdated;
|
||||
|
||||
this.createdAt = data.createdAt;
|
||||
this.updatedAt = data.updatedAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh from ConnectWise
|
||||
*
|
||||
* Fetches the latest opportunity data from CW and updates
|
||||
* the local database record and controller state.
|
||||
*/
|
||||
public async refreshFromCW(): Promise<OpportunityController> {
|
||||
const cwData = await fetchOpportunity(this.cwOpportunityId);
|
||||
const mapped = OpportunityController.mapCwToDb(cwData);
|
||||
|
||||
const updated = await prisma.opportunity.update({
|
||||
where: { id: this.id },
|
||||
data: mapped,
|
||||
});
|
||||
|
||||
return new OpportunityController(updated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch raw CW data
|
||||
*
|
||||
* Returns the raw ConnectWise opportunity object without updating the DB.
|
||||
*/
|
||||
public async fetchCwData(): Promise<CWOpportunity> {
|
||||
return fetchOpportunity(this.cwOpportunityId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map CW Opportunity → Prisma create/update payload
|
||||
*
|
||||
* Static helper used by both the controller and the refresh sync.
|
||||
*/
|
||||
public static mapCwToDb(item: CWOpportunity) {
|
||||
return {
|
||||
name: item.name,
|
||||
notes: item.notes ?? null,
|
||||
|
||||
typeName: item.type?.name ?? null,
|
||||
typeCwId: item.type?.id ?? null,
|
||||
stageName: item.stage?.name ?? null,
|
||||
stageCwId: item.stage?.id ?? null,
|
||||
statusName: item.status?.name ?? null,
|
||||
statusCwId: item.status?.id ?? null,
|
||||
priorityName: item.priority?.name ?? null,
|
||||
priorityCwId: item.priority?.id ?? null,
|
||||
ratingName: item.rating?.name ?? null,
|
||||
ratingCwId: item.rating?.id ?? null,
|
||||
source: item.source ?? null,
|
||||
campaignName: item.campaign?.name ?? null,
|
||||
campaignCwId: item.campaign?.id ?? null,
|
||||
|
||||
primarySalesRepName: item.primarySalesRep?.name ?? null,
|
||||
primarySalesRepIdentifier: item.primarySalesRep?.identifier ?? null,
|
||||
primarySalesRepCwId: item.primarySalesRep?.id ?? null,
|
||||
secondarySalesRepName: item.secondarySalesRep?.name ?? null,
|
||||
secondarySalesRepIdentifier: item.secondarySalesRep?.identifier ?? null,
|
||||
secondarySalesRepCwId: item.secondarySalesRep?.id ?? null,
|
||||
|
||||
companyCwId: item.company?.id ?? null,
|
||||
companyName: item.company?.name ?? null,
|
||||
contactCwId: item.contact?.id ?? null,
|
||||
contactName: item.contact?.name ?? null,
|
||||
siteCwId: item.site?.id ?? null,
|
||||
siteName: item.site?.name ?? null,
|
||||
customerPO: item.customerPO ?? null,
|
||||
|
||||
totalSalesTax: item.totalSalesTax ?? 0,
|
||||
|
||||
locationName: item.location?.name ?? null,
|
||||
locationCwId: item.location?.id ?? null,
|
||||
departmentName: item.department?.name ?? null,
|
||||
departmentCwId: item.department?.id ?? null,
|
||||
|
||||
expectedCloseDate: item.expectedCloseDate
|
||||
? new Date(item.expectedCloseDate)
|
||||
: null,
|
||||
pipelineChangeDate: item.pipelineChangeDate
|
||||
? new Date(item.pipelineChangeDate)
|
||||
: null,
|
||||
dateBecameLead: item.dateBecameLead
|
||||
? new Date(item.dateBecameLead)
|
||||
: null,
|
||||
closedDate: item.closedDate ? new Date(item.closedDate) : null,
|
||||
closedFlag: item.closedFlag ?? false,
|
||||
closedByName: item.closedBy?.name ?? null,
|
||||
closedByCwId: item.closedBy?.id ?? null,
|
||||
|
||||
cwLastUpdated: item._info?.lastUpdated
|
||||
? new Date(item._info.lastUpdated)
|
||||
: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* To JSON
|
||||
*
|
||||
* Serializes the opportunity into a safe, API-friendly object.
|
||||
*/
|
||||
public toJson(): Record<string, any> {
|
||||
return {
|
||||
id: this.id,
|
||||
cwOpportunityId: this.cwOpportunityId,
|
||||
name: this.name,
|
||||
notes: this.notes,
|
||||
type: this.typeCwId ? { id: this.typeCwId, name: this.typeName } : null,
|
||||
stage: this.stageCwId
|
||||
? { id: this.stageCwId, name: this.stageName }
|
||||
: null,
|
||||
status: this.statusCwId
|
||||
? { id: this.statusCwId, name: this.statusName }
|
||||
: null,
|
||||
priority: this.priorityCwId
|
||||
? { id: this.priorityCwId, name: this.priorityName }
|
||||
: null,
|
||||
rating: this.ratingCwId
|
||||
? { id: this.ratingCwId, name: this.ratingName }
|
||||
: null,
|
||||
source: this.source,
|
||||
campaign: this.campaignCwId
|
||||
? { id: this.campaignCwId, name: this.campaignName }
|
||||
: null,
|
||||
primarySalesRep: this.primarySalesRepCwId
|
||||
? {
|
||||
id: this.primarySalesRepCwId,
|
||||
identifier: this.primarySalesRepIdentifier,
|
||||
name: this.primarySalesRepName,
|
||||
}
|
||||
: null,
|
||||
secondarySalesRep: this.secondarySalesRepCwId
|
||||
? {
|
||||
id: this.secondarySalesRepCwId,
|
||||
identifier: this.secondarySalesRepIdentifier,
|
||||
name: this.secondarySalesRepName,
|
||||
}
|
||||
: null,
|
||||
company: 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,
|
||||
customerPO: this.customerPO,
|
||||
totalSalesTax: this.totalSalesTax,
|
||||
location: this.locationCwId
|
||||
? { id: this.locationCwId, name: this.locationName }
|
||||
: null,
|
||||
department: this.departmentCwId
|
||||
? { id: this.departmentCwId, name: this.departmentName }
|
||||
: null,
|
||||
expectedCloseDate: this.expectedCloseDate,
|
||||
pipelineChangeDate: this.pipelineChangeDate,
|
||||
dateBecameLead: this.dateBecameLead,
|
||||
closedDate: this.closedDate,
|
||||
closedFlag: this.closedFlag,
|
||||
closedBy: this.closedByCwId
|
||||
? { id: this.closedByCwId, name: this.closedByName }
|
||||
: null,
|
||||
companyId: this.companyId,
|
||||
cwLastUpdated: this.cwLastUpdated,
|
||||
createdAt: this.createdAt,
|
||||
updatedAt: this.updatedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -178,6 +178,46 @@ export default class UserController {
|
||||
return decoded.permissions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read Role Permissions
|
||||
*
|
||||
* Verifies and decodes a role permissions JWT and returns the permission nodes.
|
||||
* Returns an empty array if verification fails.
|
||||
*
|
||||
* @param role - Role record containing the signed permissions token
|
||||
* @returns {string[]} The role permission nodes
|
||||
*/
|
||||
private _readRolePermissions(role: Role): string[] {
|
||||
try {
|
||||
const decoded = jwt.verify(role.permissions, permissionsPrivateKey, {
|
||||
algorithms: ["RS256"],
|
||||
issuer: "roles",
|
||||
subject: role.id,
|
||||
}) as DecodedPermissionsBlock;
|
||||
|
||||
return decoded.permissions;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read All Permissions
|
||||
*
|
||||
* Aggregates the user's direct permissions and all permissions from their assigned roles
|
||||
* into a single deduplicated array.
|
||||
*
|
||||
* @returns {Promise<string[]>} Combined array of all permission nodes
|
||||
*/
|
||||
public async readAllPermissions(): Promise<string[]> {
|
||||
const directPermissions = this.readPermissions();
|
||||
const rolePermissions = this._roles
|
||||
.map((role) => this._readRolePermissions(role))
|
||||
.flatMap((permissions) => permissions);
|
||||
|
||||
return [...new Set([...directPermissions, ...rolePermissions])];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Roles
|
||||
*
|
||||
@@ -262,7 +302,16 @@ export default class UserController {
|
||||
: this._roles.size > 0
|
||||
? this._roles.map((v) => v.moniker)
|
||||
: undefined,
|
||||
permissions: opts?.safeReturn ? undefined : this.readPermissions(),
|
||||
permissions: opts?.safeReturn
|
||||
? undefined
|
||||
: (() => {
|
||||
const directPermissions = this.readPermissions();
|
||||
const rolePermissions = this._roles
|
||||
.map((role) => this._readRolePermissions(role))
|
||||
.flatMap((permissions) => permissions);
|
||||
|
||||
return [...new Set([...directPermissions, ...rolePermissions])];
|
||||
})(),
|
||||
login: opts?.safeReturn ? undefined : this.login,
|
||||
email: opts?.safeReturn ? undefined : this.email,
|
||||
image: this.image,
|
||||
|
||||
@@ -12,6 +12,7 @@ import { unifiSites } from "./managers/unifiSites";
|
||||
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 { events, setupEventDebugger } from "./modules/globalEvents";
|
||||
import { signPermissions } from "./modules/permission-utils/signPermissions";
|
||||
import { RoleController } from "./controllers/RoleController";
|
||||
@@ -65,6 +66,12 @@ setInterval(
|
||||
2 * 60 * 1000,
|
||||
);
|
||||
|
||||
// Refresh opportunities every minute
|
||||
await refreshOpportunities();
|
||||
setInterval(() => {
|
||||
return refreshOpportunities();
|
||||
}, 60 * 1000);
|
||||
|
||||
await unifiSites.syncSites();
|
||||
setInterval(() => {
|
||||
return unifiSites.syncSites();
|
||||
|
||||
@@ -15,16 +15,19 @@ export const companies = {
|
||||
const freshCwData: { data: Company } = await connectWiseApi.get(
|
||||
`/company/companies/${search.cw_CompanyId}`,
|
||||
);
|
||||
const defaultContactData = await connectWiseApi.get(
|
||||
(freshCwData.data as Company).defaultContact._info.contact_href,
|
||||
);
|
||||
|
||||
const contactHref = freshCwData.data.defaultContact?._info?.contact_href;
|
||||
const defaultContactData = contactHref
|
||||
? await connectWiseApi.get(contactHref)
|
||||
: undefined;
|
||||
|
||||
const allContactsData = await connectWiseApi.get(
|
||||
`${freshCwData.data._info.contacts_href}&pageSize=1000`,
|
||||
);
|
||||
|
||||
return new CompanyController(search, {
|
||||
company: freshCwData.data,
|
||||
defaultContact: defaultContactData.data,
|
||||
defaultContact: defaultContactData?.data ?? null,
|
||||
allContacts: allContactsData.data,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { prisma } from "../constants";
|
||||
import { OpportunityController } from "../controllers/OpportunityController";
|
||||
import GenericError from "../Errors/GenericError";
|
||||
|
||||
export const opportunities = {
|
||||
/**
|
||||
* Fetch Opportunity
|
||||
*
|
||||
* Fetch an opportunity by its internal ID or ConnectWise opportunity ID
|
||||
* and return an OpportunityController instance.
|
||||
*
|
||||
* @param identifier - The internal ID (string) or CW opportunity ID (number)
|
||||
* @returns {Promise<OpportunityController>}
|
||||
*/
|
||||
async fetchItem(identifier: string | number): Promise<OpportunityController> {
|
||||
const isNumeric =
|
||||
typeof identifier === "number" || /^\d+$/.test(String(identifier));
|
||||
|
||||
const item = await prisma.opportunity.findFirst({
|
||||
where: isNumeric
|
||||
? { cwOpportunityId: Number(identifier) }
|
||||
: { id: identifier as string },
|
||||
});
|
||||
|
||||
if (!item) {
|
||||
throw new GenericError({
|
||||
message: "Opportunity not found",
|
||||
name: "OpportunityNotFound",
|
||||
cause: `No opportunity exists with identifier '${identifier}'`,
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
|
||||
return new OpportunityController(item);
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch All Opportunities (Paginated)
|
||||
*
|
||||
* @param page - Page number (1-based)
|
||||
* @param rpp - Records per page
|
||||
* @param opts - Optional filters
|
||||
* @returns {Promise<OpportunityController[]>}
|
||||
*/
|
||||
async fetchPages(
|
||||
page: number,
|
||||
rpp: number,
|
||||
opts?: { includeClosed?: boolean },
|
||||
): Promise<OpportunityController[]> {
|
||||
const skip = (Math.max(page, 1) - 1) * rpp;
|
||||
|
||||
const items = await prisma.opportunity.findMany({
|
||||
where: opts?.includeClosed ? undefined : { closedFlag: false },
|
||||
skip,
|
||||
take: rpp,
|
||||
orderBy: { expectedCloseDate: "asc" },
|
||||
});
|
||||
|
||||
return items.map((item) => new OpportunityController(item));
|
||||
},
|
||||
|
||||
/**
|
||||
* Search Opportunities
|
||||
*
|
||||
* Search opportunities by name, company name, contact name, notes,
|
||||
* sales rep, or status with pagination support.
|
||||
*
|
||||
* @param query - Search query string
|
||||
* @param page - Page number (1-based)
|
||||
* @param rpp - Records per page
|
||||
* @param opts - Optional filters
|
||||
* @returns {Promise<OpportunityController[]>}
|
||||
*/
|
||||
async search(
|
||||
query: string,
|
||||
page: number,
|
||||
rpp: number,
|
||||
opts?: { includeClosed?: boolean },
|
||||
): Promise<OpportunityController[]> {
|
||||
const skip = (Math.max(page, 1) - 1) * rpp;
|
||||
|
||||
const items = await prisma.opportunity.findMany({
|
||||
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" } },
|
||||
],
|
||||
},
|
||||
skip,
|
||||
take: rpp,
|
||||
orderBy: { expectedCloseDate: "asc" },
|
||||
});
|
||||
|
||||
return items.map((item) => new OpportunityController(item));
|
||||
},
|
||||
|
||||
/**
|
||||
* Count Opportunities
|
||||
*
|
||||
* @param opts - Optional filters
|
||||
* @returns {Promise<number>}
|
||||
*/
|
||||
async count(opts?: { openOnly?: boolean }): Promise<number> {
|
||||
return prisma.opportunity.count({
|
||||
where: opts?.openOnly ? { closedFlag: false } : undefined,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch Opportunities by Company
|
||||
*
|
||||
* Fetch all opportunities for a company by its internal company ID.
|
||||
*
|
||||
* @param companyId - The internal company ID
|
||||
* @param opts - Optional filters
|
||||
* @returns {Promise<OpportunityController[]>}
|
||||
*/
|
||||
async fetchByCompany(
|
||||
companyId: string,
|
||||
opts?: { includeClosed?: boolean },
|
||||
): Promise<OpportunityController[]> {
|
||||
const items = await prisma.opportunity.findMany({
|
||||
where: {
|
||||
companyId,
|
||||
...(opts?.includeClosed ? {} : { closedFlag: false }),
|
||||
},
|
||||
orderBy: { expectedCloseDate: "asc" },
|
||||
});
|
||||
|
||||
return items.map((item) => new OpportunityController(item));
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,171 @@
|
||||
import { prisma } from "../constants";
|
||||
import { CatalogItemController } from "../controllers/CatalogItemController";
|
||||
import GenericError from "../Errors/GenericError";
|
||||
|
||||
/**
|
||||
* Standard include clause used by catalog item queries.
|
||||
* Includes one level of linked items.
|
||||
*/
|
||||
const catalogItemInclude = {
|
||||
linkedItems: true,
|
||||
} as const;
|
||||
|
||||
export const procurement = {
|
||||
/**
|
||||
* Fetch Catalog Item
|
||||
*
|
||||
* Fetch a catalog item by its internal ID or ConnectWise catalog ID
|
||||
* and return a CatalogItemController instance.
|
||||
*
|
||||
* @param identifier - The internal ID (string) or CW catalog ID (number)
|
||||
* @returns {Promise<CatalogItemController>} - The catalog item controller
|
||||
*/
|
||||
async fetchItem(identifier: string | number): Promise<CatalogItemController> {
|
||||
const isNumeric =
|
||||
typeof identifier === "number" || /^\d+$/.test(String(identifier));
|
||||
|
||||
const item = await prisma.catalogItem.findFirst({
|
||||
where: isNumeric
|
||||
? { cwCatalogId: Number(identifier) }
|
||||
: {
|
||||
OR: [
|
||||
{ id: identifier as string },
|
||||
{ identifier: identifier as string },
|
||||
],
|
||||
},
|
||||
include: catalogItemInclude,
|
||||
});
|
||||
|
||||
if (!item) {
|
||||
throw new GenericError({
|
||||
message: "Catalog item not found",
|
||||
name: "CatalogItemNotFound",
|
||||
cause: `No catalog item exists with identifier '${identifier}'`,
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
|
||||
return new CatalogItemController(item);
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch All Catalog Items (Paginated)
|
||||
*
|
||||
* Fetch pages of catalog items for pagination.
|
||||
*
|
||||
* @param page - Page number (1-based)
|
||||
* @param rpp - Records per page
|
||||
* @returns {Promise<CatalogItemController[]>} - Array of catalog item controllers
|
||||
*/
|
||||
async fetchPages(
|
||||
page: number,
|
||||
rpp: number,
|
||||
opts?: { includeInactive?: boolean },
|
||||
): 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 },
|
||||
skip,
|
||||
take,
|
||||
include: catalogItemInclude,
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
|
||||
return items.map((item) => new CatalogItemController(item));
|
||||
},
|
||||
|
||||
/**
|
||||
* Search Catalog Items
|
||||
*
|
||||
* Search catalog items by name, description, part number, or vendor SKU
|
||||
* with pagination support.
|
||||
*
|
||||
* @param query - Search query string
|
||||
* @param page - Page number (1-based)
|
||||
* @param rpp - Records per page
|
||||
* @returns {Promise<CatalogItemController[]>} - Array of matching catalog item controllers
|
||||
*/
|
||||
async search(
|
||||
query: string,
|
||||
page: number,
|
||||
rpp: number,
|
||||
opts?: { includeInactive?: boolean },
|
||||
): Promise<CatalogItemController[]> {
|
||||
const skip = (Math.max(page, 1) - 1) * rpp;
|
||||
const take = rpp;
|
||||
|
||||
const items = await prisma.catalogItem.findMany({
|
||||
where: {
|
||||
...(opts?.includeInactive ? {} : { inactive: false }),
|
||||
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" } },
|
||||
],
|
||||
},
|
||||
skip,
|
||||
take,
|
||||
include: catalogItemInclude,
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
|
||||
return items.map((item) => new CatalogItemController(item));
|
||||
},
|
||||
|
||||
/**
|
||||
* Count Catalog Items
|
||||
*
|
||||
* Returns the total number of catalog items in the database.
|
||||
*
|
||||
* @param opts - Optional filters
|
||||
* @returns {Promise<number>} - Total count
|
||||
*/
|
||||
async count(opts?: { activeOnly?: boolean }): Promise<number> {
|
||||
return prisma.catalogItem.count({
|
||||
where: opts?.activeOnly ? { inactive: false } : undefined,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Link Catalog Items
|
||||
*
|
||||
* Links a target catalog item to a source catalog item.
|
||||
*
|
||||
* @param sourceIdentifier - The source item's internal ID, identifier, or CW catalog ID
|
||||
* @param targetIdentifier - The target item's internal ID, identifier, or CW catalog ID
|
||||
* @returns {Promise<CatalogItemController>} - The updated source controller with linked items
|
||||
*/
|
||||
async linkItems(
|
||||
sourceIdentifier: string | number,
|
||||
targetIdentifier: string | number,
|
||||
): Promise<CatalogItemController> {
|
||||
const source = await procurement.fetchItem(sourceIdentifier);
|
||||
const target = await procurement.fetchItem(targetIdentifier);
|
||||
|
||||
return source.linkItem(target.id);
|
||||
},
|
||||
|
||||
/**
|
||||
* Unlink Catalog Items
|
||||
*
|
||||
* Removes the link between a source catalog item and a target catalog item.
|
||||
*
|
||||
* @param sourceIdentifier - The source item's internal ID, identifier, or CW catalog ID
|
||||
* @param targetIdentifier - The target item's internal ID, identifier, or CW catalog ID
|
||||
* @returns {Promise<CatalogItemController>} - The updated source controller
|
||||
*/
|
||||
async unlinkItems(
|
||||
sourceIdentifier: string | number,
|
||||
targetIdentifier: string | number,
|
||||
): Promise<CatalogItemController> {
|
||||
const source = await procurement.fetchItem(sourceIdentifier);
|
||||
const target = await procurement.fetchItem(targetIdentifier);
|
||||
|
||||
return source.unlinkItem(target.id);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Collection } from "@discordjs/collection";
|
||||
import GenericError from "../../../Errors/GenericError";
|
||||
import { opportunityCw } from "./opportunities";
|
||||
import { CWOpportunity } from "./opportunity.types";
|
||||
|
||||
/**
|
||||
* Fetch all opportunities from ConnectWise with optional conditions.
|
||||
*
|
||||
* @param conditions - Optional CW conditions string for filtering
|
||||
* @returns A Collection of CW opportunities keyed by their ID
|
||||
* @throws GenericError if the fetch fails
|
||||
*/
|
||||
export const fetchAllOpportunities = async (
|
||||
conditions?: string,
|
||||
): Promise<Collection<number, CWOpportunity>> => {
|
||||
try {
|
||||
return await opportunityCw.fetchAll(conditions);
|
||||
} catch (error) {
|
||||
const errBody = (error as any).response?.data || error;
|
||||
console.error("Error fetching all opportunities:", errBody);
|
||||
throw new GenericError({
|
||||
name: "FetchAllOpportunitiesError",
|
||||
message: "Failed to fetch opportunities from ConnectWise",
|
||||
cause: typeof errBody === "string" ? errBody : JSON.stringify(errBody),
|
||||
status: 502,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Collection } from "@discordjs/collection";
|
||||
import GenericError from "../../../Errors/GenericError";
|
||||
import { opportunityCw } from "./opportunities";
|
||||
import { CWOpportunity } from "./opportunity.types";
|
||||
|
||||
/**
|
||||
* Fetch all opportunities for a specific company from ConnectWise.
|
||||
*
|
||||
* @param cwCompanyId - The ConnectWise company ID
|
||||
* @returns A Collection of CW opportunities for the company keyed by their ID
|
||||
* @throws GenericError if the fetch fails
|
||||
*/
|
||||
export const fetchCompanyOpportunities = async (
|
||||
cwCompanyId: number,
|
||||
): Promise<Collection<number, CWOpportunity>> => {
|
||||
try {
|
||||
return await opportunityCw.fetchByCompany(cwCompanyId);
|
||||
} catch (error) {
|
||||
const errBody = (error as any).response?.data || error;
|
||||
console.error(
|
||||
`Error fetching opportunities for company ${cwCompanyId}:`,
|
||||
errBody,
|
||||
);
|
||||
throw new GenericError({
|
||||
name: "FetchCompanyOpportunitiesError",
|
||||
message: `Failed to fetch opportunities for company ${cwCompanyId}`,
|
||||
cause: typeof errBody === "string" ? errBody : JSON.stringify(errBody),
|
||||
status: 502,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import GenericError from "../../../Errors/GenericError";
|
||||
import { opportunityCw } from "./opportunities";
|
||||
import { CWOpportunity } from "./opportunity.types";
|
||||
|
||||
/**
|
||||
* Fetch a single opportunity by its ConnectWise ID.
|
||||
*
|
||||
* @param cwOpportunityId - The ConnectWise opportunity ID
|
||||
* @returns The full CW opportunity object
|
||||
* @throws GenericError if the fetch fails
|
||||
*/
|
||||
export const fetchOpportunity = async (
|
||||
cwOpportunityId: number,
|
||||
): Promise<CWOpportunity> => {
|
||||
try {
|
||||
return await opportunityCw.fetch(cwOpportunityId);
|
||||
} catch (error) {
|
||||
const errBody = (error as any).response?.data || error;
|
||||
console.error(
|
||||
`Error fetching opportunity with ID ${cwOpportunityId}:`,
|
||||
errBody,
|
||||
);
|
||||
throw new GenericError({
|
||||
name: "FetchOpportunityError",
|
||||
message: `Failed to fetch opportunity ${cwOpportunityId}`,
|
||||
cause: typeof errBody === "string" ? errBody : JSON.stringify(errBody),
|
||||
status: 502,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,145 @@
|
||||
import { Collection } from "@discordjs/collection";
|
||||
import { connectWiseApi } from "../../../constants";
|
||||
import {
|
||||
CWOpportunity,
|
||||
CWOpportunitySummary,
|
||||
CWForecastItem,
|
||||
CWOpportunityNote,
|
||||
CWOpportunityContact,
|
||||
} from "./opportunity.types";
|
||||
|
||||
export const opportunityCw = {
|
||||
/**
|
||||
* Count Opportunities
|
||||
*
|
||||
* Returns the total number of opportunities in ConnectWise.
|
||||
* Optionally accepts CW conditions string for filtered counts.
|
||||
*/
|
||||
countItems: async (conditions?: string): Promise<number> => {
|
||||
const query = conditions
|
||||
? `/sales/opportunities/count?conditions=${encodeURIComponent(conditions)}`
|
||||
: "/sales/opportunities/count";
|
||||
const response = await connectWiseApi.get(query);
|
||||
return response.data.count;
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch All Opportunity Summaries
|
||||
*
|
||||
* Lightweight fetch returning only id and _info (for lastUpdated comparison).
|
||||
* Paginates through all opportunities.
|
||||
*/
|
||||
fetchAllSummaries: async (): Promise<
|
||||
Collection<number, CWOpportunitySummary>
|
||||
> => {
|
||||
const allItems = new Collection<number, CWOpportunitySummary>();
|
||||
const pageSize = 1000;
|
||||
|
||||
const count = await opportunityCw.countItems();
|
||||
const totalPages = Math.ceil(count / pageSize);
|
||||
|
||||
for (let page = 0; page < totalPages; page++) {
|
||||
const response = await connectWiseApi.get(
|
||||
`/sales/opportunities?page=${page + 1}&pageSize=${pageSize}&fields=id,_info`,
|
||||
);
|
||||
const items: CWOpportunitySummary[] = response.data;
|
||||
|
||||
for (const item of items) {
|
||||
allItems.set(item.id, item);
|
||||
}
|
||||
}
|
||||
|
||||
return allItems;
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch All Opportunities (Full)
|
||||
*
|
||||
* Fetches all opportunities with complete data. Paginates through
|
||||
* the full list.
|
||||
*/
|
||||
fetchAll: async (
|
||||
conditions?: string,
|
||||
): Promise<Collection<number, CWOpportunity>> => {
|
||||
const allItems = new Collection<number, CWOpportunity>();
|
||||
const pageSize = 1000;
|
||||
|
||||
const count = await opportunityCw.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/opportunities?page=${page + 1}&pageSize=${pageSize}${conditionsParam}`,
|
||||
);
|
||||
const items: CWOpportunity[] = response.data;
|
||||
|
||||
for (const item of items) {
|
||||
allItems.set(item.id, item);
|
||||
}
|
||||
}
|
||||
|
||||
return allItems;
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch Single Opportunity
|
||||
*
|
||||
* Fetches a single opportunity by its ConnectWise ID.
|
||||
*/
|
||||
fetch: async (id: number): Promise<CWOpportunity> => {
|
||||
const response = await connectWiseApi.get(`/sales/opportunities/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch Opportunities by Company
|
||||
*
|
||||
* Fetches all opportunities associated with a specific ConnectWise company ID.
|
||||
*/
|
||||
fetchByCompany: async (
|
||||
cwCompanyId: number,
|
||||
): Promise<Collection<number, CWOpportunity>> => {
|
||||
return opportunityCw.fetchAll(`company/id=${cwCompanyId}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch Opportunity Forecasts
|
||||
*
|
||||
* Fetches forecast/revenue items for a given opportunity.
|
||||
*/
|
||||
fetchForecasts: async (opportunityId: number): Promise<CWForecastItem[]> => {
|
||||
const response = await connectWiseApi.get(
|
||||
`/sales/opportunities/${opportunityId}/forecast`,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch Opportunity Notes
|
||||
*
|
||||
* Fetches notes associated with a given opportunity.
|
||||
*/
|
||||
fetchNotes: async (opportunityId: number): Promise<CWOpportunityNote[]> => {
|
||||
const response = await connectWiseApi.get(
|
||||
`/sales/opportunities/${opportunityId}/notes`,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch Opportunity Contacts
|
||||
*
|
||||
* Fetches contacts associated with a given opportunity.
|
||||
*/
|
||||
fetchContacts: async (
|
||||
opportunityId: number,
|
||||
): Promise<CWOpportunityContact[]> => {
|
||||
const response = await connectWiseApi.get(
|
||||
`/sales/opportunities/${opportunityId}/contacts`,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,144 @@
|
||||
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>;
|
||||
}
|
||||
|
||||
interface CWSiteReference {
|
||||
id: number;
|
||||
name: string;
|
||||
_info?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface CWCustomField {
|
||||
id: number;
|
||||
caption: string;
|
||||
type: string;
|
||||
entryMethod: string;
|
||||
numberOfDecimals: number;
|
||||
value: unknown;
|
||||
connectWiseId: string;
|
||||
rowNum: number;
|
||||
userDefinedFieldRecId: number;
|
||||
podId: string;
|
||||
}
|
||||
|
||||
export interface CWOpportunity {
|
||||
id: number;
|
||||
name: string;
|
||||
expectedCloseDate: string;
|
||||
type: CWReference;
|
||||
stage: CWReference;
|
||||
status: CWReference;
|
||||
priority: CWReference;
|
||||
notes: string;
|
||||
source: string;
|
||||
rating: CWReference;
|
||||
campaign: CWReference;
|
||||
primarySalesRep: CWMemberReference;
|
||||
secondarySalesRep: CWMemberReference;
|
||||
locationId: number;
|
||||
businessUnitId: number;
|
||||
company: CWCompanyReference;
|
||||
contact: CWContactReference;
|
||||
site: CWSiteReference;
|
||||
customerPO: string;
|
||||
pipelineChangeDate: string;
|
||||
dateBecameLead: string;
|
||||
closedDate: string;
|
||||
closedBy: CWMemberReference;
|
||||
totalSalesTax: number;
|
||||
shipToCompany: CWCompanyReference;
|
||||
shipToContact: CWContactReference;
|
||||
shipToSite: CWSiteReference;
|
||||
billToCompany: CWCompanyReference;
|
||||
billToContact: CWContactReference;
|
||||
billToSite: CWSiteReference;
|
||||
billingTerms: CWReference;
|
||||
taxCode: CWReference;
|
||||
currency: CWReference;
|
||||
companyLocationId: number;
|
||||
location: CWReference;
|
||||
department: CWReference;
|
||||
closedFlag: boolean;
|
||||
mobileGuid: string;
|
||||
customFields: CWCustomField[];
|
||||
_info: CWOpportunityInfo;
|
||||
}
|
||||
|
||||
export interface CWOpportunityInfo {
|
||||
lastUpdated: string;
|
||||
updatedBy: string;
|
||||
dateEntered: string;
|
||||
enteredBy: string;
|
||||
forecasts_href: string;
|
||||
notes_href: string;
|
||||
products_href: string;
|
||||
contacts_href: string;
|
||||
configurations_href: string;
|
||||
team_href: string;
|
||||
documents_href: string;
|
||||
activities_href: string;
|
||||
}
|
||||
|
||||
export interface CWForecastItem {
|
||||
id: number;
|
||||
opportunity: CWReference;
|
||||
forecastType: string;
|
||||
forecastMonth: string;
|
||||
revenue: number;
|
||||
cost: number;
|
||||
forecastPercentage: number;
|
||||
status: CWReference;
|
||||
includedFlag: boolean;
|
||||
linkedFlag: boolean;
|
||||
recurringFlag: boolean;
|
||||
_info?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface CWOpportunityNote {
|
||||
id: number;
|
||||
opportunity: CWReference;
|
||||
text: string;
|
||||
type: CWReference;
|
||||
flagged: boolean;
|
||||
enteredBy: string;
|
||||
mobileGuid: string;
|
||||
_info?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface CWOpportunityContact {
|
||||
id: number;
|
||||
opportunity: CWReference;
|
||||
contact: CWContactReference;
|
||||
company: CWCompanyReference;
|
||||
role: CWReference;
|
||||
notes: string;
|
||||
referralFlag: boolean;
|
||||
_info?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface CWOpportunitySummary {
|
||||
id: number;
|
||||
_info?: Record<string, string>;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { CWOpportunity } from "./opportunity.types";
|
||||
|
||||
export type ProcessedOpportunity = ReturnType<
|
||||
typeof processOpportunityResponse
|
||||
>;
|
||||
|
||||
/**
|
||||
* Processes raw CW opportunity data into a cleaner, normalized shape
|
||||
* suitable for API responses and internal consumption.
|
||||
*/
|
||||
export const processOpportunityResponse = (opportunity: CWOpportunity) => ({
|
||||
id: opportunity.id,
|
||||
name: opportunity.name,
|
||||
expectedCloseDate: opportunity.expectedCloseDate,
|
||||
closedDate: opportunity.closedDate,
|
||||
closedFlag: opportunity.closedFlag,
|
||||
type: opportunity.type
|
||||
? { id: opportunity.type.id, name: opportunity.type.name }
|
||||
: null,
|
||||
stage: opportunity.stage
|
||||
? { id: opportunity.stage.id, name: opportunity.stage.name }
|
||||
: null,
|
||||
status: opportunity.status
|
||||
? { id: opportunity.status.id, name: opportunity.status.name }
|
||||
: null,
|
||||
priority: opportunity.priority
|
||||
? { id: opportunity.priority.id, name: opportunity.priority.name }
|
||||
: null,
|
||||
rating: opportunity.rating
|
||||
? { id: opportunity.rating.id, name: opportunity.rating.name }
|
||||
: null,
|
||||
source: opportunity.source,
|
||||
notes: opportunity.notes,
|
||||
customerPO: opportunity.customerPO,
|
||||
company: opportunity.company
|
||||
? {
|
||||
id: opportunity.company.id,
|
||||
identifier: opportunity.company.identifier,
|
||||
name: opportunity.company.name,
|
||||
}
|
||||
: null,
|
||||
contact: opportunity.contact
|
||||
? { id: opportunity.contact.id, name: opportunity.contact.name }
|
||||
: null,
|
||||
site: opportunity.site
|
||||
? { id: opportunity.site.id, name: opportunity.site.name }
|
||||
: null,
|
||||
primarySalesRep: opportunity.primarySalesRep
|
||||
? {
|
||||
id: opportunity.primarySalesRep.id,
|
||||
identifier: opportunity.primarySalesRep.identifier,
|
||||
name: opportunity.primarySalesRep.name,
|
||||
}
|
||||
: null,
|
||||
secondarySalesRep: opportunity.secondarySalesRep
|
||||
? {
|
||||
id: opportunity.secondarySalesRep.id,
|
||||
identifier: opportunity.secondarySalesRep.identifier,
|
||||
name: opportunity.secondarySalesRep.name,
|
||||
}
|
||||
: null,
|
||||
closedBy: opportunity.closedBy
|
||||
? {
|
||||
id: opportunity.closedBy.id,
|
||||
identifier: opportunity.closedBy.identifier,
|
||||
name: opportunity.closedBy.name,
|
||||
}
|
||||
: null,
|
||||
campaign: opportunity.campaign
|
||||
? { id: opportunity.campaign.id, name: opportunity.campaign.name }
|
||||
: null,
|
||||
totalSalesTax: opportunity.totalSalesTax,
|
||||
location: opportunity.location
|
||||
? { id: opportunity.location.id, name: opportunity.location.name }
|
||||
: null,
|
||||
department: opportunity.department
|
||||
? { id: opportunity.department.id, name: opportunity.department.name }
|
||||
: null,
|
||||
pipelineChangeDate: opportunity.pipelineChangeDate,
|
||||
dateBecameLead: opportunity.dateBecameLead,
|
||||
info: opportunity._info,
|
||||
});
|
||||
|
||||
/**
|
||||
* Processes an array of raw CW opportunities.
|
||||
*/
|
||||
export const processOpportunitiesResponse = (opportunities: CWOpportunity[]) =>
|
||||
opportunities.map(processOpportunityResponse);
|
||||
@@ -0,0 +1,110 @@
|
||||
import { prisma } from "../../../constants";
|
||||
import { events } from "../../globalEvents";
|
||||
import { opportunityCw } from "./opportunities";
|
||||
import { OpportunityController } from "../../../controllers/OpportunityController";
|
||||
|
||||
/**
|
||||
* Refresh Opportunities
|
||||
*
|
||||
* Syncs local opportunity records with ConnectWise using the same
|
||||
* stale-check pattern as refreshCatalog:
|
||||
* 1. Fetch lightweight summaries (id + _info.lastUpdated)
|
||||
* 2. Compare against local cwLastUpdated timestamps
|
||||
* 3. Full-fetch only stale/new records
|
||||
* 4. Upsert stale items, optionally linking to internal Company
|
||||
*/
|
||||
export const refreshOpportunities = async () => {
|
||||
events.emit("cw:opportunities:refresh:check");
|
||||
|
||||
// 1. Fetch lightweight summaries from CW
|
||||
const cwSummaries = await opportunityCw.fetchAllSummaries();
|
||||
|
||||
// 2. Fetch all DB items with their cwOpportunityId and cwLastUpdated
|
||||
const dbItems = await prisma.opportunity.findMany({
|
||||
select: { cwOpportunityId: true, cwLastUpdated: true },
|
||||
});
|
||||
const dbMap = new Map(
|
||||
dbItems.map((item) => [item.cwOpportunityId, item.cwLastUpdated]),
|
||||
);
|
||||
|
||||
// 3. Determine stale / new IDs
|
||||
const staleIds: number[] = [];
|
||||
|
||||
for (const [cwId, summary] of cwSummaries) {
|
||||
const cwLastUpdated = summary._info?.lastUpdated
|
||||
? new Date(summary._info.lastUpdated)
|
||||
: null;
|
||||
const dbLastUpdated = dbMap.get(cwId) ?? null;
|
||||
|
||||
if (!dbLastUpdated || (cwLastUpdated && cwLastUpdated > dbLastUpdated)) {
|
||||
staleIds.push(cwId);
|
||||
}
|
||||
}
|
||||
|
||||
if (staleIds.length === 0) {
|
||||
events.emit("cw:opportunities:refresh:skipped", {
|
||||
totalCw: cwSummaries.size,
|
||||
totalDb: dbItems.length,
|
||||
staleCount: 0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
events.emit("cw:opportunities:refresh:started", {
|
||||
totalCw: cwSummaries.size,
|
||||
totalDb: dbItems.length,
|
||||
staleCount: staleIds.length,
|
||||
});
|
||||
|
||||
// 4. Full-fetch all opportunities, filter to stale set
|
||||
const staleIdSet = new Set(staleIds);
|
||||
const allCwItems = await opportunityCw.fetchAll();
|
||||
const staleItems = new Map<number, any>();
|
||||
|
||||
for (const [id, item] of allCwItems) {
|
||||
if (staleIdSet.has(id)) {
|
||||
staleItems.set(id, item);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Build a company CW ID → internal ID lookup for linking
|
||||
const companies = await prisma.company.findMany({
|
||||
select: { id: true, cw_CompanyId: true },
|
||||
});
|
||||
const companyMap = new Map(companies.map((c) => [c.cw_CompanyId, c.id]));
|
||||
|
||||
// 6. Upsert stale/new items
|
||||
const updatedCount = (
|
||||
await Promise.all(
|
||||
staleIds.map(async (cwId) => {
|
||||
const item = staleItems.get(cwId);
|
||||
if (!item) return null;
|
||||
|
||||
const mapped = OpportunityController.mapCwToDb(item);
|
||||
const companyId = item.company?.id
|
||||
? (companyMap.get(item.company.id) ?? null)
|
||||
: null;
|
||||
|
||||
return prisma.opportunity.upsert({
|
||||
where: { cwOpportunityId: cwId },
|
||||
create: {
|
||||
cwOpportunityId: cwId,
|
||||
...mapped,
|
||||
companyId,
|
||||
},
|
||||
update: {
|
||||
...mapped,
|
||||
companyId,
|
||||
},
|
||||
});
|
||||
}),
|
||||
)
|
||||
).filter(Boolean).length;
|
||||
|
||||
events.emit("cw:opportunities:refresh:completed", {
|
||||
totalCw: cwSummaries.size,
|
||||
totalDb: dbItems.length,
|
||||
staleCount: staleIds.length,
|
||||
itemsUpdated: updatedCount,
|
||||
});
|
||||
};
|
||||
@@ -91,6 +91,7 @@ export const refreshCatalog = async () => {
|
||||
where: { cwCatalogId: cwId },
|
||||
create: {
|
||||
cwCatalogId: cwId,
|
||||
identifier: item.identifier,
|
||||
name: item.description,
|
||||
description: item.description,
|
||||
customerDescription: item.customerDescription,
|
||||
@@ -110,6 +111,7 @@ export const refreshCatalog = async () => {
|
||||
},
|
||||
update: {
|
||||
name: item.description,
|
||||
identifier: item.identifier,
|
||||
description: item.description,
|
||||
customerDescription: item.customerDescription,
|
||||
internalNotes: item.notes,
|
||||
|
||||
@@ -158,6 +158,25 @@ interface EventTypes {
|
||||
totalItems: number;
|
||||
updatedCount: number;
|
||||
}) => void;
|
||||
|
||||
// ConnectWise Opportunities Events
|
||||
"cw:opportunities:refresh:check": () => void;
|
||||
"cw:opportunities:refresh:started": (data: {
|
||||
totalCw: number;
|
||||
totalDb: number;
|
||||
staleCount: number;
|
||||
}) => void;
|
||||
"cw:opportunities:refresh:completed": (data: {
|
||||
totalCw: number;
|
||||
totalDb: number;
|
||||
staleCount: number;
|
||||
itemsUpdated: number;
|
||||
}) => void;
|
||||
"cw:opportunities:refresh:skipped": (data: {
|
||||
totalCw: number;
|
||||
totalDb: number;
|
||||
staleCount: number;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export const events = new Eventra<EventTypes>();
|
||||
|
||||
@@ -341,6 +341,72 @@ export const PERMISSION_NODES = {
|
||||
],
|
||||
},
|
||||
|
||||
procurement: {
|
||||
name: "Procurement Permissions",
|
||||
description:
|
||||
"Permissions for accessing and managing procurement catalog items",
|
||||
permissions: [
|
||||
{
|
||||
node: "procurement.catalog.fetch",
|
||||
description: "Fetch a single catalog item",
|
||||
usedIn: ["src/api/procurement/[id]/fetch.ts"],
|
||||
},
|
||||
{
|
||||
node: "procurement.catalog.fetch.many",
|
||||
description: "Fetch multiple catalog items or count",
|
||||
usedIn: [
|
||||
"src/api/procurement/fetchAll.ts",
|
||||
"src/api/procurement/count.ts",
|
||||
],
|
||||
},
|
||||
{
|
||||
node: "procurement.catalog.inventory.refresh",
|
||||
description:
|
||||
"Refresh on-hand inventory for a catalog item from ConnectWise",
|
||||
usedIn: ["src/api/procurement/[id]/refreshInventory.ts"],
|
||||
dependencies: ["procurement.catalog.fetch"],
|
||||
},
|
||||
{
|
||||
node: "procurement.catalog.link",
|
||||
description: "Link or unlink catalog items to each other",
|
||||
usedIn: [
|
||||
"src/api/procurement/[id]/link.ts",
|
||||
"src/api/procurement/[id]/unlink.ts",
|
||||
],
|
||||
dependencies: ["procurement.catalog.fetch"],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
sales: {
|
||||
name: "Sales Permissions",
|
||||
description: "Permissions for accessing and managing sales opportunities",
|
||||
permissions: [
|
||||
{
|
||||
node: "sales.opportunity.fetch",
|
||||
description:
|
||||
"Fetch a single opportunity and its sub-resources (forecasts, notes, contacts)",
|
||||
usedIn: [
|
||||
"src/api/sales/[id]/fetch.ts",
|
||||
"src/api/sales/[id]/forecasts.ts",
|
||||
"src/api/sales/[id]/notes.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"],
|
||||
},
|
||||
{
|
||||
node: "sales.opportunity.refresh",
|
||||
description: "Refresh a single opportunity from ConnectWise",
|
||||
usedIn: ["src/api/sales/[id]/refresh.ts"],
|
||||
dependencies: ["sales.opportunity.fetch"],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
unifi: {
|
||||
name: "UniFi Permissions",
|
||||
description:
|
||||
|
||||
Reference in New Issue
Block a user