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 { 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} */ async createItem(data: CWOpportunityCreate): Promise { 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} */ async fetchRecord( identifier: string | number ): Promise { 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} */ async fetchItem( identifier: string | number, opts?: { fresh?: boolean } ): Promise { 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} */ async fetchPages( page: number, rpp: number, opts?: { includeClosed?: boolean } ): Promise { 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} */ async search( query: string, page: number, rpp: number, opts?: { includeClosed?: boolean } ): Promise { 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} */ async count(opts?: { openOnly?: boolean }): Promise { return prisma.opportunity.count({ where: opts?.openOnly ? { closedFlag: false } : undefined, }); }, /** * Count Search Results * * @param query - Search query string * @param opts - Optional filters * @returns {Promise} */ async searchCount( query: string, opts?: { includeClosed?: boolean } ): Promise { 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} */ async fetchByCompany( companyId: number, opts?: { includeClosed?: boolean } ): Promise { 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} */ async fetchByUser( userId: string, opts?: { includeClosed?: boolean } ): Promise { 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, }) ); }, };