Files
optima/api/src/managers/opportunities.ts
T
2026-04-07 23:56:31 +00:00

490 lines
14 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] = 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,
]);
const companyId = companyExists?.id ?? null;
const contactId = contactExists?.id ?? null;
const siteId = siteExists?.id ?? null;
const record = await prisma.opportunity.create({
data: {
id: cwData.id,
...mapped,
companyId,
contactId,
siteId,
},
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
? { 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 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,
})
);
},
};