feat: add CW members, opportunity create/update, and integrator interceptor
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
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 { getMemberCache } from "../../modules/cw-utils/members/memberCache";
|
||||
|
||||
/* GET /v1/cw/members */
|
||||
export default createRoute(
|
||||
"get",
|
||||
["/members"],
|
||||
async (c) => {
|
||||
const cache = await getMemberCache();
|
||||
|
||||
const activeOnly = c.req.query("active") !== "false";
|
||||
|
||||
const members = cache
|
||||
.filter((m) => (activeOnly ? !m.inactiveFlag : true))
|
||||
.map((m) => ({
|
||||
id: m.id,
|
||||
identifier: m.identifier,
|
||||
firstName: m.firstName,
|
||||
lastName: m.lastName,
|
||||
name: `${m.firstName} ${m.lastName}`.trim(),
|
||||
officeEmail: m.officeEmail,
|
||||
inactive: m.inactiveFlag,
|
||||
}));
|
||||
|
||||
const sorted = members.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"CW members fetched successfully!",
|
||||
sorted,
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware(),
|
||||
);
|
||||
+2
-1
@@ -1,3 +1,4 @@
|
||||
import { default as callback } from "./callback";
|
||||
import { default as fetchMembers } from "./fetchMembers";
|
||||
|
||||
export { callback };
|
||||
export { callback, fetchMembers };
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { default as fetchAll } from "./opportunities/fetchAll";
|
||||
import { default as createOpportunity } from "./opportunities/create";
|
||||
import { default as fetchOpportunityTypes } from "./fetchOpportunityTypes";
|
||||
import { default as count } from "./opportunities/count";
|
||||
import { default as fetch } from "./opportunities/[id]/fetch";
|
||||
import { default as refresh } from "./opportunities/[id]/refresh";
|
||||
import { default as updateOpportunity } from "./opportunities/[id]/update";
|
||||
import { default as products } from "./opportunities/[id]/products/fetchAll";
|
||||
import { default as addProduct } from "./opportunities/[id]/products/add";
|
||||
import { default as addSpecialOrderProduct } from "./opportunities/[id]/products/addSpecialOrder";
|
||||
@@ -29,6 +31,7 @@ export {
|
||||
laborOptions,
|
||||
addSpecialOrderProduct,
|
||||
count,
|
||||
createOpportunity,
|
||||
fetch,
|
||||
fetchAll,
|
||||
fetchOpportunityTypes,
|
||||
@@ -48,4 +51,5 @@ export {
|
||||
downloadQuote,
|
||||
fetchDownloads,
|
||||
refresh,
|
||||
updateOpportunity,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
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 { z } from "zod";
|
||||
|
||||
const updateSchema = z
|
||||
.object({
|
||||
name: z.string().min(1).optional(),
|
||||
notes: z.string().optional(),
|
||||
rating: z.object({ id: z.number() }).optional(),
|
||||
type: z.object({ id: z.number() }).optional(),
|
||||
stage: z.object({ id: z.number() }).optional(),
|
||||
status: z.object({ id: z.number() }).optional(),
|
||||
priority: z.object({ id: z.number() }).optional(),
|
||||
campaign: z.object({ id: z.number() }).optional(),
|
||||
primarySalesRep: z.object({ id: z.number() }).optional(),
|
||||
secondarySalesRep: z.object({ id: z.number() }).nullable().optional(),
|
||||
company: z.object({ id: z.number() }).optional(),
|
||||
contact: z.object({ id: z.number() }).nullable().optional(),
|
||||
site: z.object({ id: z.number() }).nullable().optional(),
|
||||
expectedCloseDate: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((v) => (v ? new Date(v).toISOString() : v)),
|
||||
customerPO: z.string().nullable().optional(),
|
||||
source: z.string().nullable().optional(),
|
||||
locationId: z.number().optional(),
|
||||
businessUnitId: z.number().optional(),
|
||||
})
|
||||
.refine((d) => Object.values(d).some((v) => v !== undefined), {
|
||||
message: "At least one field must be provided",
|
||||
});
|
||||
|
||||
/* PATCH /v1/sales/opportunities/:identifier */
|
||||
export default createRoute(
|
||||
"patch",
|
||||
["/opportunities/:identifier"],
|
||||
async (c) => {
|
||||
const identifier = c.req.param("identifier");
|
||||
const body = await c.req.json();
|
||||
const data = updateSchema.parse(body);
|
||||
|
||||
const item = await opportunities.fetchRecord(identifier);
|
||||
|
||||
try {
|
||||
const updated = await item.updateOpportunity(data);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Opportunity updated successfully!",
|
||||
updated.toJson(),
|
||||
);
|
||||
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
} catch (err) {
|
||||
const isAxios =
|
||||
err != null && typeof err === "object" && "isAxiosError" in err;
|
||||
|
||||
if (isAxios) {
|
||||
const axiosErr = err as any;
|
||||
const cwStatus: number = axiosErr.response?.status ?? 502;
|
||||
const cwData = axiosErr.response?.data;
|
||||
const cwMessage: string =
|
||||
cwData?.message ?? "Failed to update the opportunity in ConnectWise";
|
||||
const cwErrors: unknown[] | undefined = Array.isArray(cwData?.errors)
|
||||
? cwData.errors
|
||||
: undefined;
|
||||
|
||||
return c.json(
|
||||
{
|
||||
status: cwStatus,
|
||||
message: cwMessage,
|
||||
error: "ConnectWiseUpdateError",
|
||||
successful: false,
|
||||
errors: cwErrors,
|
||||
meta: { timestamp: Date.now() },
|
||||
},
|
||||
cwStatus as ContentfulStatusCode,
|
||||
);
|
||||
}
|
||||
|
||||
throw new GenericError({
|
||||
status: 500,
|
||||
name: "OpportunitySaveError",
|
||||
message: "Failed to save opportunity data",
|
||||
cause: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.update"] }),
|
||||
);
|
||||
@@ -0,0 +1,86 @@
|
||||
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 { z } from "zod";
|
||||
|
||||
const createSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
expectedCloseDate: z
|
||||
.string()
|
||||
.min(1)
|
||||
.transform((v) => new Date(v).toISOString().replace(/\.\d{3}Z$/, "Z")),
|
||||
notes: z.string().optional(),
|
||||
rating: z.object({ id: z.number() }).optional(),
|
||||
type: z.object({ id: z.number() }).optional(),
|
||||
stage: z.object({ id: z.number() }).optional(),
|
||||
status: z.object({ id: z.number() }).optional(),
|
||||
priority: z.object({ id: z.number() }).optional(),
|
||||
campaign: z.object({ id: z.number() }).optional(),
|
||||
primarySalesRep: z.object({ id: z.number() }),
|
||||
secondarySalesRep: z.object({ id: z.number() }).nullable().optional(),
|
||||
company: z.object({ id: z.number() }),
|
||||
contact: z.object({ id: z.number() }),
|
||||
site: z.object({ id: z.number() }).nullable().optional(),
|
||||
source: z.string().nullable().optional(),
|
||||
customerPO: z.string().nullable().optional(),
|
||||
locationId: z.number().optional(),
|
||||
businessUnitId: z.number().optional(),
|
||||
});
|
||||
|
||||
/* POST /v1/sales/opportunities */
|
||||
export default createRoute(
|
||||
"post",
|
||||
["/opportunities"],
|
||||
async (c) => {
|
||||
const body = await c.req.json();
|
||||
const data = createSchema.parse(body);
|
||||
|
||||
try {
|
||||
const item = await opportunities.createItem(data);
|
||||
|
||||
const response = apiResponse.created(
|
||||
"Opportunity created successfully!",
|
||||
item.toJson(),
|
||||
);
|
||||
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
} catch (err) {
|
||||
const isAxios =
|
||||
err != null && typeof err === "object" && "isAxiosError" in err;
|
||||
|
||||
if (isAxios) {
|
||||
const axiosErr = err as any;
|
||||
const cwStatus: number = axiosErr.response?.status ?? 502;
|
||||
const cwData = axiosErr.response?.data;
|
||||
const cwMessage: string =
|
||||
cwData?.message ?? "Failed to create the opportunity in ConnectWise";
|
||||
const cwErrors: unknown[] | undefined = Array.isArray(cwData?.errors)
|
||||
? cwData.errors
|
||||
: undefined;
|
||||
|
||||
return c.json(
|
||||
{
|
||||
status: cwStatus,
|
||||
message: cwMessage,
|
||||
error: "ConnectWiseCreateError",
|
||||
successful: false,
|
||||
errors: cwErrors,
|
||||
meta: { timestamp: Date.now() },
|
||||
},
|
||||
cwStatus as ContentfulStatusCode,
|
||||
);
|
||||
}
|
||||
|
||||
throw new GenericError({
|
||||
status: 500,
|
||||
name: "OpportunityCreateError",
|
||||
message: "Failed to create opportunity",
|
||||
cause: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.create"] }),
|
||||
);
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { CwMember } from "../../generated/prisma/client";
|
||||
import type { CWMember } from "../modules/cw-utils/members/fetchAllMembers";
|
||||
|
||||
/**
|
||||
* CW Member Controller
|
||||
*
|
||||
* Domain model class that encapsulates a ConnectWise Member entity,
|
||||
* providing access to member data and serialization for the API.
|
||||
*/
|
||||
export class CwMemberController {
|
||||
public readonly id: string;
|
||||
public readonly cwMemberId: number;
|
||||
public readonly identifier: string;
|
||||
public firstName: string;
|
||||
public lastName: string;
|
||||
public officeEmail: string | null;
|
||||
public inactiveFlag: boolean;
|
||||
public apiKey: string | null;
|
||||
public cwLastUpdated: Date | null;
|
||||
public readonly createdAt: Date;
|
||||
public readonly updatedAt: Date;
|
||||
|
||||
constructor(data: CwMember) {
|
||||
this.id = data.id;
|
||||
this.cwMemberId = data.cwMemberId;
|
||||
this.identifier = data.identifier;
|
||||
this.firstName = data.firstName;
|
||||
this.lastName = data.lastName;
|
||||
this.officeEmail = data.officeEmail;
|
||||
this.inactiveFlag = data.inactiveFlag;
|
||||
this.apiKey = data.apiKey;
|
||||
this.cwLastUpdated = data.cwLastUpdated;
|
||||
this.createdAt = data.createdAt;
|
||||
this.updatedAt = data.updatedAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full Name
|
||||
*
|
||||
* Returns the member's full name, falling back to the identifier.
|
||||
*/
|
||||
public get fullName(): string {
|
||||
const name = `${this.firstName} ${this.lastName}`.trim();
|
||||
return name || this.identifier;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map CW Member → Prisma create/update payload
|
||||
*
|
||||
* Static helper used by both the controller and the refresh sync.
|
||||
*/
|
||||
public static mapCwToDb(item: CWMember) {
|
||||
return {
|
||||
identifier: item.identifier,
|
||||
firstName: item.firstName ?? "",
|
||||
lastName: item.lastName ?? "",
|
||||
officeEmail: item.officeEmail ?? null,
|
||||
inactiveFlag: item.inactiveFlag ?? false,
|
||||
cwLastUpdated: item._info?.lastUpdated
|
||||
? new Date(item._info.lastUpdated)
|
||||
: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* To JSON
|
||||
*
|
||||
* Serializes the member into a safe, API-friendly object.
|
||||
*/
|
||||
public toJson(): Record<string, any> {
|
||||
return {
|
||||
id: this.id,
|
||||
cwMemberId: this.cwMemberId,
|
||||
identifier: this.identifier,
|
||||
firstName: this.firstName,
|
||||
lastName: this.lastName,
|
||||
fullName: this.fullName,
|
||||
officeEmail: this.officeEmail,
|
||||
inactiveFlag: this.inactiveFlag,
|
||||
apiKey: this.apiKey,
|
||||
cwLastUpdated: this.cwLastUpdated,
|
||||
createdAt: this.createdAt,
|
||||
updatedAt: this.updatedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
CWForecastItemCreate,
|
||||
CWOpportunity,
|
||||
CWOpportunityNote,
|
||||
CWOpportunityUpdate,
|
||||
CWProcurementProduct,
|
||||
CWProcurementProductCreate,
|
||||
} from "../modules/cw-utils/opportunities/opportunity.types";
|
||||
@@ -292,6 +293,30 @@ export class OpportunityController {
|
||||
return new OpportunityController(updated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update Opportunity
|
||||
*
|
||||
* Patches the opportunity in ConnectWise with the provided fields,
|
||||
* then syncs the updated data back to the local database.
|
||||
*
|
||||
* @param data — Partial fields to update on the CW opportunity
|
||||
* @returns A fresh OpportunityController with the updated data
|
||||
*/
|
||||
public async updateOpportunity(
|
||||
data: CWOpportunityUpdate,
|
||||
): Promise<OpportunityController> {
|
||||
const cwData = await opportunityCw.update(this.cwOpportunityId, data);
|
||||
const mapped = OpportunityController.mapCwToDb(cwData);
|
||||
|
||||
const updated = await prisma.opportunity.update({
|
||||
where: { id: this.id },
|
||||
data: mapped,
|
||||
include: { company: true },
|
||||
});
|
||||
|
||||
return new OpportunityController(updated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch raw CW data
|
||||
*
|
||||
|
||||
@@ -17,6 +17,7 @@ import { listenInventoryAdjustments } from "./modules/cw-utils/procurement/liste
|
||||
import { refreshOpportunities } from "./modules/cw-utils/opportunities/refreshOpportunities";
|
||||
import { refreshOpportunityCache } from "./modules/cache/opportunityCache";
|
||||
import { refreshCwIdentifiers } from "./modules/cw-utils/members/refreshCwIdentifiers";
|
||||
import { refreshCwMembers } from "./modules/cw-utils/members/refreshCwMembers";
|
||||
import { userDefinedFieldsCw } from "./modules/cw-utils/userDefinedFields";
|
||||
import { events, setupEventDebugger } from "./modules/globalEvents";
|
||||
import { signPermissions } from "./modules/permission-utils/signPermissions";
|
||||
@@ -188,6 +189,17 @@ setInterval(
|
||||
30 * 60 * 1000,
|
||||
);
|
||||
|
||||
// Refresh CW members DB table every hour
|
||||
await safeStartup("refreshCwMembers", refreshCwMembers);
|
||||
setInterval(
|
||||
() => {
|
||||
return refreshCwMembers().catch((err) =>
|
||||
console.error(`[interval] refreshCwMembers failed: ${briefErr(err)}`),
|
||||
);
|
||||
},
|
||||
60 * 60 * 1000,
|
||||
);
|
||||
|
||||
await safeStartup("syncSites", () => unifiSites.syncSites());
|
||||
setInterval(() => {
|
||||
return unifiSites
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { prisma } from "../constants";
|
||||
import { CwMemberController } from "../controllers/CwMemberController";
|
||||
import GenericError from "../Errors/GenericError";
|
||||
|
||||
/**
|
||||
* CW Members Manager
|
||||
*
|
||||
* Thin persistence layer wrapping Prisma calls for the CwMember model.
|
||||
* Returns CwMemberController instances as domain objects.
|
||||
*/
|
||||
export const cwMembers = {
|
||||
/**
|
||||
* Fetch a single CW member by internal ID, CW member ID, or identifier.
|
||||
*/
|
||||
fetch: async (idOrIdentifier: string): Promise<CwMemberController> => {
|
||||
const isNumeric = /^\d+$/.test(idOrIdentifier);
|
||||
|
||||
const record = await prisma.cwMember.findFirst({
|
||||
where: isNumeric
|
||||
? { cwMemberId: Number(idOrIdentifier) }
|
||||
: {
|
||||
OR: [{ id: idOrIdentifier }, { identifier: idOrIdentifier }],
|
||||
},
|
||||
});
|
||||
|
||||
if (!record) {
|
||||
throw new GenericError({
|
||||
status: 404,
|
||||
name: "CwMemberNotFound",
|
||||
message: `CW Member "${idOrIdentifier}" not found`,
|
||||
});
|
||||
}
|
||||
|
||||
return new CwMemberController(record);
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch all CW members with optional filtering.
|
||||
*/
|
||||
fetchAll: async (opts?: {
|
||||
includeInactive?: boolean;
|
||||
}): Promise<CwMemberController[]> => {
|
||||
const where = opts?.includeInactive ? {} : { inactiveFlag: false };
|
||||
|
||||
const records = await prisma.cwMember.findMany({
|
||||
where,
|
||||
orderBy: { lastName: "asc" },
|
||||
});
|
||||
|
||||
return records.map((r) => new CwMemberController(r));
|
||||
},
|
||||
|
||||
/**
|
||||
* Count CW members.
|
||||
*/
|
||||
count: async (opts?: { includeInactive?: boolean }): Promise<number> => {
|
||||
const where = opts?.includeInactive ? {} : { inactiveFlag: false };
|
||||
return prisma.cwMember.count({ where });
|
||||
},
|
||||
|
||||
/**
|
||||
* Update the API key for a CW member.
|
||||
*/
|
||||
updateApiKey: async (
|
||||
idOrIdentifier: string,
|
||||
apiKey: string | null,
|
||||
): Promise<CwMemberController> => {
|
||||
const member = await cwMembers.fetch(idOrIdentifier);
|
||||
|
||||
const updated = await prisma.cwMember.update({
|
||||
where: { id: member.id },
|
||||
data: { apiKey },
|
||||
});
|
||||
|
||||
return new CwMemberController(updated);
|
||||
},
|
||||
};
|
||||
@@ -6,6 +6,7 @@ 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";
|
||||
import { CWOpportunityCreate } from "../modules/cw-utils/opportunities/opportunity.types";
|
||||
import { computeCacheTTL } from "../modules/algorithms/computeCacheTTL";
|
||||
import {
|
||||
getCachedActivities,
|
||||
@@ -127,6 +128,45 @@ async function buildActivities(
|
||||
}
|
||||
|
||||
export const opportunities = {
|
||||
/**
|
||||
* Create Opportunity
|
||||
*
|
||||
* Creates a new opportunity in ConnectWise, then stores the resulting
|
||||
* record in the local database and returns an OpportunityController.
|
||||
*
|
||||
* @param data — Fields required by the ConnectWise `POST /sales/opportunities` endpoint
|
||||
* @returns {Promise<OpportunityController>}
|
||||
*/
|
||||
async createItem(data: CWOpportunityCreate): Promise<OpportunityController> {
|
||||
const cwData = await opportunityCw.create(data);
|
||||
const mapped = OpportunityController.mapCwToDb(cwData);
|
||||
|
||||
// Resolve optional local company relation
|
||||
const companyId = cwData.company?.id
|
||||
? ((
|
||||
await prisma.company.findFirst({
|
||||
where: { cw_CompanyId: cwData.company.id },
|
||||
select: { id: true },
|
||||
})
|
||||
)?.id ?? null)
|
||||
: null;
|
||||
|
||||
const record = await prisma.opportunity.create({
|
||||
data: {
|
||||
cwOpportunityId: cwData.id,
|
||||
...mapped,
|
||||
companyId,
|
||||
},
|
||||
include: { company: true },
|
||||
});
|
||||
|
||||
return new OpportunityController(record, {
|
||||
company: record.company
|
||||
? new CompanyController(record.company)
|
||||
: undefined,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch Record (lightweight)
|
||||
*
|
||||
|
||||
@@ -17,20 +17,26 @@ export interface CWMember {
|
||||
* Fetches every member from ConnectWise using pagination and returns them
|
||||
* in a Collection keyed by their identifier (e.g. "jroberts").
|
||||
*
|
||||
* @param opts.conditions - Optional CW conditions string to filter members
|
||||
* @returns {Promise<Collection<string, CWMember>>} Collection of CW members keyed by identifier
|
||||
*/
|
||||
export const fetchAllCwMembers = async (): Promise<
|
||||
Collection<string, CWMember>
|
||||
> => {
|
||||
export const fetchAllCwMembers = async (opts?: {
|
||||
conditions?: string;
|
||||
}): Promise<Collection<string, CWMember>> => {
|
||||
const members = new Collection<string, CWMember>();
|
||||
const pageSize = 1000;
|
||||
const conditionsParam = opts?.conditions
|
||||
? `&conditions=${encodeURIComponent(opts.conditions)}`
|
||||
: "";
|
||||
|
||||
const { data: countData } = await connectWiseApi.get("/system/members/count");
|
||||
const { data: countData } = await connectWiseApi.get(
|
||||
`/system/members/count${conditionsParam ? `?${conditionsParam.slice(1)}` : ""}`,
|
||||
);
|
||||
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}`,
|
||||
`/system/members?page=${page + 1}&pageSize=${pageSize}${conditionsParam}`,
|
||||
);
|
||||
|
||||
for (const member of data) {
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { prisma } from "../../../constants";
|
||||
import { events } from "../../globalEvents";
|
||||
import { fetchAllCwMembers, type CWMember } from "./fetchAllMembers";
|
||||
import { setMemberCache } from "./memberCache";
|
||||
import { CwMemberController } from "../../../controllers/CwMemberController";
|
||||
|
||||
/**
|
||||
* Is Regular User
|
||||
*
|
||||
* Returns true if the CW member looks like a real person rather than
|
||||
* a service account (e.g. "labtech", "Admin"). A regular user must
|
||||
* have a last name and an email address.
|
||||
*/
|
||||
const isRegularUser = (member: CWMember): boolean =>
|
||||
!member.inactiveFlag &&
|
||||
Boolean(member.lastName?.trim()) &&
|
||||
Boolean(member.officeEmail?.trim());
|
||||
|
||||
/**
|
||||
* Refresh CW Members
|
||||
*
|
||||
* Syncs local CwMember records with ConnectWise using a stale-check
|
||||
* pattern:
|
||||
* 1. Fetch all members from CW
|
||||
* 2. Filter to regular users (active, non-service accounts)
|
||||
* 3. Compare against local cwLastUpdated timestamps
|
||||
* 4. Upsert stale/new records
|
||||
* 5. Also refreshes the in-memory member cache
|
||||
*/
|
||||
export const refreshCwMembers = async () => {
|
||||
events.emit("cw:members:db:refresh:check");
|
||||
|
||||
// 1. Fetch all members from CW
|
||||
const allCwMembers = await fetchAllCwMembers();
|
||||
|
||||
// Also refresh the in-memory cache with ALL members (used for name resolution)
|
||||
await setMemberCache(allCwMembers);
|
||||
|
||||
// 2. Filter to regular users only (active, has last name + email)
|
||||
const cwMembers = allCwMembers.filter(isRegularUser);
|
||||
|
||||
// 2. Fetch all DB records with their identifier and cwLastUpdated
|
||||
const dbItems = await prisma.cwMember.findMany({
|
||||
select: { cwMemberId: true, cwLastUpdated: true },
|
||||
});
|
||||
const dbMap = new Map(
|
||||
dbItems.map((item) => [item.cwMemberId, item.cwLastUpdated]),
|
||||
);
|
||||
|
||||
// 3. Determine stale / new IDs
|
||||
const staleIds: number[] = [];
|
||||
|
||||
for (const [, member] of cwMembers) {
|
||||
const cwLastUpdated = member._info?.lastUpdated
|
||||
? new Date(member._info.lastUpdated)
|
||||
: null;
|
||||
const dbLastUpdated = dbMap.get(member.id) ?? null;
|
||||
|
||||
if (!dbLastUpdated || (cwLastUpdated && cwLastUpdated > dbLastUpdated)) {
|
||||
staleIds.push(member.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (staleIds.length === 0) {
|
||||
events.emit("cw:members:db:refresh:skipped", {
|
||||
totalCw: cwMembers.size,
|
||||
totalDb: dbItems.length,
|
||||
staleCount: 0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
events.emit("cw:members:db:refresh:started", {
|
||||
totalCw: cwMembers.size,
|
||||
totalDb: dbItems.length,
|
||||
staleCount: staleIds.length,
|
||||
});
|
||||
|
||||
// 4. Upsert stale/new items
|
||||
const staleIdSet = new Set(staleIds);
|
||||
const updatedCount = (
|
||||
await Promise.all(
|
||||
[...cwMembers.values()]
|
||||
.filter((m) => staleIdSet.has(m.id))
|
||||
.map(async (member) => {
|
||||
const mapped = CwMemberController.mapCwToDb(member);
|
||||
|
||||
return prisma.cwMember.upsert({
|
||||
where: { cwMemberId: member.id },
|
||||
create: {
|
||||
cwMemberId: member.id,
|
||||
...mapped,
|
||||
},
|
||||
update: mapped,
|
||||
});
|
||||
}),
|
||||
)
|
||||
).filter(Boolean).length;
|
||||
|
||||
events.emit("cw:members:db:refresh:completed", {
|
||||
totalCw: cwMembers.size,
|
||||
totalDb: dbItems.length,
|
||||
staleCount: staleIds.length,
|
||||
itemsUpdated: updatedCount,
|
||||
});
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import { Collection } from "@discordjs/collection";
|
||||
import { connectWiseApi } from "../../../constants";
|
||||
import {
|
||||
CWOpportunity,
|
||||
CWOpportunityCreate,
|
||||
CWOpportunitySummary,
|
||||
CWForecast,
|
||||
CWForecastItem,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
CWOpportunityNoteCreate,
|
||||
CWOpportunityNoteUpdate,
|
||||
CWOpportunityContact,
|
||||
CWOpportunityUpdate,
|
||||
} from "./opportunity.types";
|
||||
|
||||
export const opportunityCw = {
|
||||
@@ -100,6 +102,45 @@ export const opportunityCw = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Create Opportunity
|
||||
*
|
||||
* Creates a new opportunity in ConnectWise via POST.
|
||||
* Strips null/undefined values from the payload — CW rejects
|
||||
* null reference objects on create; omitting them lets CW apply
|
||||
* its own defaults.
|
||||
*/
|
||||
create: async (data: CWOpportunityCreate): Promise<CWOpportunity> => {
|
||||
const cleaned = Object.fromEntries(
|
||||
Object.entries(data).filter(([, v]) => v != null),
|
||||
);
|
||||
const response = await connectWiseApi.post("/sales/opportunities", cleaned);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Update Opportunity
|
||||
*
|
||||
* Applies a JSON Patch update to an opportunity record in ConnectWise.
|
||||
* Each key in `data` produces a replace operation.
|
||||
*/
|
||||
update: async (
|
||||
opportunityId: number,
|
||||
data: CWOpportunityUpdate,
|
||||
): Promise<CWOpportunity> => {
|
||||
const operations = Object.entries(data).map(([key, value]) => ({
|
||||
op: "replace" as const,
|
||||
path: key,
|
||||
value,
|
||||
}));
|
||||
|
||||
const response = await connectWiseApi.patch(
|
||||
`/sales/opportunities/${opportunityId}`,
|
||||
operations,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch Opportunities by Company
|
||||
*
|
||||
|
||||
@@ -263,6 +263,48 @@ export interface CWProcurementProduct {
|
||||
_info?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface CWOpportunityUpdate {
|
||||
name?: string;
|
||||
notes?: string;
|
||||
rating?: { id: number };
|
||||
type?: { id: number };
|
||||
stage?: { id: number };
|
||||
status?: { id: number };
|
||||
priority?: { id: number };
|
||||
campaign?: { id: number };
|
||||
primarySalesRep?: { id: number };
|
||||
secondarySalesRep?: { id: number } | null;
|
||||
company?: { id: number };
|
||||
contact?: { id: number } | null;
|
||||
site?: { id: number } | null;
|
||||
expectedCloseDate?: string;
|
||||
customerPO?: string | null;
|
||||
source?: string | null;
|
||||
locationId?: number;
|
||||
businessUnitId?: number;
|
||||
}
|
||||
|
||||
export interface CWOpportunityCreate {
|
||||
name: string;
|
||||
expectedCloseDate: string;
|
||||
primarySalesRep: { id: number };
|
||||
company: { id: number };
|
||||
contact: { id: number };
|
||||
type?: { id: number };
|
||||
stage?: { id: number };
|
||||
status?: { id: number };
|
||||
priority?: { id: number };
|
||||
campaign?: { id: number };
|
||||
secondarySalesRep?: { id: number } | null;
|
||||
site?: { id: number } | null;
|
||||
notes?: string;
|
||||
rating?: { id: number };
|
||||
source?: string | null;
|
||||
customerPO?: string | null;
|
||||
locationId?: number;
|
||||
businessUnitId?: number;
|
||||
}
|
||||
|
||||
export interface CWOpportunitySummary {
|
||||
id: number;
|
||||
_info?: Record<string, string>;
|
||||
|
||||
@@ -205,6 +205,25 @@ interface EventTypes {
|
||||
totalUsers: number;
|
||||
usersUpdated: number;
|
||||
}) => void;
|
||||
|
||||
// ConnectWise Members DB Sync Events
|
||||
"cw:members:db:refresh:check": () => void;
|
||||
"cw:members:db:refresh:started": (data: {
|
||||
totalCw: number;
|
||||
totalDb: number;
|
||||
staleCount: number;
|
||||
}) => void;
|
||||
"cw:members:db:refresh:completed": (data: {
|
||||
totalCw: number;
|
||||
totalDb: number;
|
||||
staleCount: number;
|
||||
itemsUpdated: number;
|
||||
}) => void;
|
||||
"cw:members:db:refresh:skipped": (data: {
|
||||
totalCw: number;
|
||||
totalDb: number;
|
||||
staleCount: number;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export const events = new Eventra<EventTypes>();
|
||||
|
||||
@@ -423,6 +423,18 @@ export const PERMISSION_NODES = {
|
||||
usedIn: ["src/api/sales/opportunities/[id]/refresh.ts"],
|
||||
dependencies: ["sales.opportunity.fetch"],
|
||||
},
|
||||
{
|
||||
node: "sales.opportunity.update",
|
||||
description:
|
||||
"Update an opportunity's fields (rating, sales rep, company, contact, site, description, etc.) in ConnectWise",
|
||||
usedIn: ["src/api/sales/opportunities/[id]/update.ts"],
|
||||
dependencies: ["sales.opportunity.fetch"],
|
||||
},
|
||||
{
|
||||
node: "sales.opportunity.create",
|
||||
description: "Create a new opportunity in ConnectWise",
|
||||
usedIn: ["src/api/sales/opportunities/create.ts"],
|
||||
},
|
||||
{
|
||||
node: "sales.opportunity.note.create",
|
||||
description: "Create a new note on an opportunity",
|
||||
@@ -531,6 +543,20 @@ export const PERMISSION_NODES = {
|
||||
usedIn: ["src/api/sales/opportunities/[id]/quotes/fetchDownloads.ts"],
|
||||
dependencies: ["sales.opportunity.fetch"],
|
||||
},
|
||||
{
|
||||
node: "sales.opportunity.view_margin",
|
||||
description:
|
||||
"View margin and markup data on opportunity products. Controls visibility of margin %, markup %, and related progress bars in the UI.",
|
||||
usedIn: [],
|
||||
dependencies: ["sales.opportunity.fetch"],
|
||||
},
|
||||
{
|
||||
node: "sales.opportunity.view_cost",
|
||||
description:
|
||||
"View cost data on opportunity products. Controls visibility of unit cost, total cost, and recurring cost in the UI.",
|
||||
usedIn: [],
|
||||
dependencies: ["sales.opportunity.fetch"],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user