Files
optima/api/src/managers/opportunities.ts
T

588 lines
18 KiB
TypeScript

import { prisma } from "../constants";
import { CompanyController } from "../controllers/CompanyController";
import { OpportunityController } from "../controllers/OpportunityController";
import UserController from "../controllers/UserController";
import GenericError from "../Errors/GenericError";
import { opportunityCw } from "../modules/cw-utils/opportunities/opportunities";
import { CWOpportunityCreate } from "../modules/cw-utils/opportunities/opportunity.types";
// ---------------------------------------------------------------------------
// Data-source helpers (DB-only, no cache)
// ---------------------------------------------------------------------------
/**
* Build a CompanyController from a Prisma Company record.
* DB-only - no cache or CW API calls.
*/
async function buildCompanyController(
companyId: number
): Promise<CompanyController | undefined> {
const company = await prisma.company.findFirst({
where: { id: companyId },
include: {
contacts: true,
companyAddresses: true,
},
});
if (!company) return undefined;
return new CompanyController(company);
}
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 FKs — nullify any that don't exist locally yet
// (the sync may be behind; these are all nullable in the schema)
const [
companyExists,
contactExists,
siteExists,
typeExists,
stageExists,
statusExists,
locationExists,
departmentExists,
primaryRepExists,
secondaryRepExists,
] = await Promise.all([
cwData.company?.id
? prisma.company.findFirst({ where: { id: cwData.company.id }, select: { id: true } })
: null,
mapped.contactId != null
? prisma.contact.findFirst({ where: { id: mapped.contactId }, select: { id: true } })
: null,
mapped.siteId != null
? prisma.companyAddress.findFirst({ where: { id: mapped.siteId }, select: { id: true } })
: null,
mapped.typeId != null
? prisma.opportunityType.findFirst({ where: { id: mapped.typeId }, select: { id: true } })
: null,
mapped.stageId != null
? prisma.opportunityStage.findFirst({ where: { id: mapped.stageId }, select: { id: true } })
: null,
mapped.statusId != null
? prisma.opportunityStatus.findFirst({ where: { id: mapped.statusId }, select: { id: true } })
: null,
mapped.locationId != null
? prisma.corporateLocation.findFirst({ where: { id: mapped.locationId }, select: { id: true } })
: null,
mapped.departmentId != null
? prisma.internalDepartment.findFirst({ where: { id: mapped.departmentId }, select: { id: true } })
: null,
mapped.primarySalesRepId != null
? prisma.user.findFirst({ where: { cwIdentifier: mapped.primarySalesRepId }, select: { cwIdentifier: true } })
: null,
mapped.secondarySalesRepId != null
? prisma.user.findFirst({ where: { cwIdentifier: mapped.secondarySalesRepId }, select: { cwIdentifier: true } })
: null,
]);
const companyId = companyExists?.id ?? null;
const contactId = contactExists?.id ?? null;
const siteId = siteExists?.id ?? null;
const typeId = typeExists?.id ?? null;
const stageId = stageExists?.id ?? null;
const statusId = statusExists?.id ?? null;
const locationId = locationExists?.id ?? null;
const departmentId = departmentExists?.id ?? null;
const primarySalesRepId = primaryRepExists?.cwIdentifier ?? null;
const secondarySalesRepId = secondaryRepExists?.cwIdentifier ?? null;
// Strip fields returned by mapCwToDb that are not columns in the Prisma schema
// (ratingName, ratingCwId, campaignName, primarySalesRepName, primarySalesRepIdentifier,
// secondarySalesRepName, secondarySalesRepIdentifier, cwLastUpdated).
// Prisma will throw a validation error if unknown fields are passed to create().
const {
ratingName: _ratingName,
ratingCwId: _ratingCwId,
campaignName: _campaignName,
primarySalesRepName: _primarySalesRepName,
primarySalesRepIdentifier: _primarySalesRepIdentifier,
secondarySalesRepName: _secondarySalesRepName,
secondarySalesRepIdentifier: _secondarySalesRepIdentifier,
cwLastUpdated: _cwLastUpdated,
...dbFields
} = mapped;
const record = await prisma.opportunity.create({
data: {
id: cwData.id,
...dbFields,
typeId,
stageId,
statusId,
locationId,
departmentId,
companyId,
contactId,
siteId,
primarySalesRepId,
secondarySalesRepId,
},
include: {
company: { include: { contacts: true, companyAddresses: true } },
contact: true,
type: true,
status: true,
site: true,
primarySalesRep: { include: { roles: true } },
secondarySalesRep: { include: { roles: true } },
stage: true,
taxCode: true,
},
});
return new OpportunityController(record, {
company: record.company
? new CompanyController(record.company)
: undefined,
primarySalesRep: record.primarySalesRep
? new UserController(record.primarySalesRep)
: undefined,
secondarySalesRep: record.secondarySalesRep
? new UserController(record.secondarySalesRep)
: undefined,
});
},
/**
* Fetch Record (lightweight)
*
* Returns an OpportunityController backed only by the **database record**.
* No ConnectWise API calls, no Redis lookups.
*
* @param identifier - The CW opportunity ID (number) or internal uid (string)
* @returns {Promise<OpportunityController>}
*/
async fetchRecord(
identifier: string | number
): Promise<OpportunityController> {
const isNumeric =
typeof identifier === "number" || /^\d+$/.test(String(identifier));
const record = await prisma.opportunity.findFirst({
where: isNumeric
? { cwOpportunityId: Number(identifier) }
: { uid: identifier as string },
include: {
company: { include: { contacts: true, companyAddresses: true } },
contact: true,
type: true,
status: true,
site: true,
primarySalesRep: { include: { roles: true } },
secondarySalesRep: { include: { roles: true } },
stage: true,
taxCode: true,
},
});
if (!record) {
throw new GenericError({
message: "Opportunity not found",
name: "OpportunityNotFound",
cause: `No opportunity exists with identifier '${identifier}'`,
status: 404,
});
}
return new OpportunityController(record, {
company: record.company
? new CompanyController(record.company)
: undefined,
primarySalesRep: record.primarySalesRep
? new UserController(record.primarySalesRep)
: undefined,
secondarySalesRep: record.secondarySalesRep
? new UserController(record.secondarySalesRep)
: undefined,
});
},
/**
* Fetch Opportunity
*
* Fetch an opportunity by its internal uid or CW opportunity ID
* and return an OpportunityController instance.
*
* Data is loaded from the local database only.
*
* @param identifier - The internal uid (string) or CW opportunity ID (number)
* @param opts - Optional flags
* @param opts.fresh - Ignored (kept for API compatibility)
* @returns {Promise<OpportunityController>}
*/
async fetchItem(
identifier: string | number,
opts?: { fresh?: boolean }
): Promise<OpportunityController> {
const isNumeric =
typeof identifier === "number" || /^\d+$/.test(String(identifier));
const record = await prisma.opportunity.findFirst({
where: isNumeric
? { id: Number(identifier) }
: { uid: identifier as string },
include: {
company: { include: { contacts: true, companyAddresses: true } },
contact: true,
type: true,
status: true,
site: true,
primarySalesRep: { include: { roles: true } },
secondarySalesRep: { include: { roles: true } },
stage: true,
taxCode: true,
},
});
if (!record) {
throw new GenericError({
message: "Opportunity not found",
name: "OpportunityNotFound",
cause: `No opportunity exists with identifier '${identifier}'`,
status: 404,
});
}
return new OpportunityController(record, {
company: record.company
? new CompanyController(record.company)
: undefined,
primarySalesRep: record.primarySalesRep
? new UserController(record.primarySalesRep)
: undefined,
secondarySalesRep: record.secondarySalesRep
? new UserController(record.secondarySalesRep)
: undefined,
});
},
/**
* 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 },
include: {
company: { include: { contacts: true, companyAddresses: true } },
contact: true,
type: true,
status: true,
primarySalesRep: { include: { roles: true } },
secondarySalesRep: { include: { roles: true } },
stage: true,
taxCode: true,
},
skip,
take: rpp,
orderBy: { id: "desc" },
});
return items.map(
(item) =>
new OpportunityController(item, {
company: item.company
? new CompanyController(item.company)
: undefined,
primarySalesRep: item.primarySalesRep
? new UserController(item.primarySalesRep)
: undefined,
secondarySalesRep: item.secondarySalesRep
? new UserController(item.secondarySalesRep)
: undefined,
})
);
},
/**
* Search Opportunities
*
* Search opportunities by name 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 numericQuery = /^\d+$/.test(query.trim())
? Number(query.trim())
: null;
const items = await prisma.opportunity.findMany({
where: {
...(opts?.includeClosed ? {} : { closedFlag: false }),
OR: [
{ name: { contains: query, mode: "insensitive" } },
{ notes: { contains: query, mode: "insensitive" } },
...(numericQuery !== null ? [{ id: { equals: numericQuery } }] : []),
],
},
include: {
company: { include: { contacts: true, companyAddresses: true } },
contact: true,
type: true,
status: true,
primarySalesRep: { include: { roles: true } },
secondarySalesRep: { include: { roles: true } },
stage: true,
taxCode: true,
},
skip,
take: rpp,
orderBy: { createdAt: "desc" },
});
return items.map(
(item) =>
new OpportunityController(item, {
company: item.company
? new CompanyController(item.company)
: undefined,
primarySalesRep: item.primarySalesRep
? new UserController(item.primarySalesRep)
: undefined,
secondarySalesRep: item.secondarySalesRep
? new UserController(item.secondarySalesRep)
: undefined,
})
);
},
/**
* 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,
});
},
/**
* Count Search Results
*
* @param query - Search query string
* @param opts - Optional filters
* @returns {Promise<number>}
*/
async searchCount(
query: string,
opts?: { includeClosed?: boolean }
): Promise<number> {
const numericQuery = /^\d+$/.test(query.trim())
? Number(query.trim())
: null;
return prisma.opportunity.count({
where: {
...(opts?.includeClosed ? {} : { closedFlag: false }),
OR: [
{ name: { contains: query, mode: "insensitive" } },
{ notes: { contains: query, mode: "insensitive" } },
...(numericQuery !== null ? [{ id: { equals: numericQuery } }] : []),
],
},
});
},
/**
* 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: number,
opts?: { includeClosed?: boolean }
): Promise<OpportunityController[]> {
const items = await prisma.opportunity.findMany({
where: {
companyId,
...(opts?.includeClosed ? {} : { closedFlag: false }),
},
include: {
company: { include: { contacts: true, companyAddresses: true } },
contact: true,
type: true,
status: true,
primarySalesRep: { include: { roles: true } },
secondarySalesRep: { include: { roles: true } },
stage: true,
taxCode: true,
},
orderBy: { expectedCloseDate: "asc" },
});
return items.map(
(item) =>
new OpportunityController(item, {
company: item.company
? new CompanyController(item.company)
: undefined,
primarySalesRep: item.primarySalesRep
? new UserController(item.primarySalesRep)
: undefined,
secondarySalesRep: item.secondarySalesRep
? new UserController(item.secondarySalesRep)
: undefined,
})
);
},
/**
* Fetch Opportunities by User
*
* Returns all opportunities where the given user (by internal User ID) is
* assigned as the primary or secondary sales rep.
*
* @param userId - Internal User `id` (cuid)
* @param opts - Optional filters
* @returns {Promise<OpportunityController[]>}
*/
async fetchByUser(
userId: string,
opts?: { includeClosed?: boolean }
): Promise<OpportunityController[]> {
const user = await prisma.user.findFirst({
where: { id: userId },
select: { cwIdentifier: true },
});
if (!user) {
throw new GenericError({
message: "User not found",
name: "UserNotFound",
cause: `No user exists with id '${userId}'`,
status: 404,
});
}
if (!user.cwIdentifier) {
return [];
}
const items = await prisma.opportunity.findMany({
where: {
OR: [
{ primarySalesRepId: user.cwIdentifier },
{ secondarySalesRepId: user.cwIdentifier },
],
...(opts?.includeClosed
? {}
: {
closedFlag: false,
NOT: {
status: {
OR: [
{ wonFlag: true },
{ lostFlag: true },
{ closeFlag: true },
],
},
},
}),
},
include: {
company: { include: { contacts: true, companyAddresses: true } },
contact: true,
type: true,
status: true,
primarySalesRep: { include: { roles: true } },
secondarySalesRep: { include: { roles: true } },
stage: true,
taxCode: true,
},
orderBy: { expectedCloseDate: "asc" },
});
return items.map(
(item) =>
new OpportunityController(item, {
company: item.company
? new CompanyController(item.company)
: undefined,
primarySalesRep: item.primarySalesRep
? new UserController(item.primarySalesRep)
: undefined,
secondarySalesRep: item.secondarySalesRep
? new UserController(item.secondarySalesRep)
: undefined,
})
);
},
/**
* Delete Opportunity
*
* Deletes an opportunity from ConnectWise and removes the corresponding
* record (along with its associated ProductData) from the local database.
*
* @param identifier - The internal uid (string) or CW opportunity ID (number)
*/
async deleteItem(identifier: string | number): Promise<void> {
const isNumeric =
typeof identifier === "number" || /^\d+$/.test(String(identifier));
const record = await prisma.opportunity.findFirst({
where: isNumeric
? { id: Number(identifier) }
: { uid: identifier as string },
select: { uid: true, id: true },
});
if (!record) {
throw new GenericError({
message: "Opportunity not found",
name: "OpportunityNotFound",
cause: `No opportunity exists with identifier '${identifier}'`,
status: 404,
});
}
await opportunityCw.delete(record.id);
await prisma.$transaction([
prisma.productData.deleteMany({ where: { opportunityId: record.id } }),
prisma.opportunity.delete({ where: { uid: record.uid } }),
]);
},
};