490 lines
14 KiB
TypeScript
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,
|
|
})
|
|
);
|
|
},
|
|
};
|