diff --git a/__tests__/schema/opportunity.ts b/__tests__/schema/opportunity.ts index e93020ce01..6a0c4313f5 100644 --- a/__tests__/schema/opportunity.ts +++ b/__tests__/schema/opportunity.ts @@ -5953,3 +5953,47 @@ describe('query opportunityPreview', () => { expect(res.data.opportunityPreview.result.totalCount).toBe(2); }); }); + +describe('query opportunityStats', () => { + const OPPORTUNITY_STATS_QUERY = /* GraphQL */ ` + query OpportunityStats($opportunityId: ID!) { + opportunityStats(opportunityId: $opportunityId) { + matched + reached + considered + decided + forReview + introduced + } + } + `; + + it('should return stats for opportunity with multiple match statuses', async () => { + loggedUser = '1'; + + const res = await client.query(OPPORTUNITY_STATS_QUERY, { + variables: { opportunityId: opportunitiesFixture[0].id }, + }); + + expect(res.errors).toBeFalsy(); + expect(res.data.opportunityStats).toEqual({ + matched: 12_000, // Mock value + reached: 4, // Total: pending(1), candidate_accepted(1), recruiter_accepted(1), recruiter_rejected(1) + considered: 3, // All except pending + decided: 0, // No candidate_rejected in fixture + forReview: 1, // candidate_accepted + introduced: 1, // recruiter_accepted + }); + }); + + it('should deny access if user is not a recruiter for the opportunity', async () => { + loggedUser = '3'; // User 3 is not a recruiter for opportunity 0 + + const res = await client.query(OPPORTUNITY_STATS_QUERY, { + variables: { opportunityId: opportunitiesFixture[0].id }, + }); + + expect(res.errors).toBeTruthy(); + expect(res.errors[0].extensions.code).toBe('FORBIDDEN'); + }); +}); diff --git a/src/schema/opportunity.ts b/src/schema/opportunity.ts index 62f57a6a89..011ece41e3 100644 --- a/src/schema/opportunity.ts +++ b/src/schema/opportunity.ts @@ -182,6 +182,15 @@ export interface GQLOpportunityPreviewConnection { result: GQLOpportunityPreviewResult; } +export interface GQLOpportunityStats { + matched: number; + reached: number; + considered: number; + decided: number; + forReview: number; + introduced: number; +} + export const typeDefs = /* GraphQL */ ` ${toGQLEnum(OpportunityMatchStatus, 'OpportunityMatchStatus')} ${toGQLEnum(OrganizationLinkType, 'OrganizationLinkType')} @@ -476,6 +485,38 @@ export const typeDefs = /* GraphQL */ ` result: OpportunityPreviewResult } + type OpportunityStats { + """ + Mock value for matched candidates + """ + matched: Int! + + """ + Total count of all matches regardless of status + """ + reached: Int! + + """ + Count of matches that are not pending (all statuses except pending) + """ + considered: Int! + + """ + Count of candidate_rejected matches + """ + decided: Int! + + """ + Count of candidate_accepted matches + """ + forReview: Int! + + """ + Count of recruiter_accepted matches + """ + introduced: Int! + } + extend type Query { """ Get the public information about a Opportunity listing @@ -574,6 +615,16 @@ export const typeDefs = /* GraphQL */ ` """ opportunityId: ID ): OpportunityPreviewConnection! + + """ + Get statistics for an opportunity's matches + """ + opportunityStats( + """ + Id of the Opportunity + """ + opportunityId: ID! + ): OpportunityStats! @auth } input SalaryExpectationInput { @@ -1331,6 +1382,75 @@ export const resolvers: IResolvers = traceResolvers< }, }; }, + opportunityStats: async ( + _, + { opportunityId }: { opportunityId: string }, + ctx: AuthContext, + ): Promise => { + // Verify the user has access to this opportunity + await ensureOpportunityPermissions({ + con: ctx.con.manager, + userId: ctx.userId, + opportunityId, + permission: OpportunityPermissions.UpdateState, + isTeamMember: ctx.isTeamMember, + }); + + const result = await queryReadReplica(ctx.con, ({ queryRunner }) => + queryRunner.manager + .getRepository(OpportunityMatch) + .createQueryBuilder('match') + .select('COUNT(*)', 'reached') + .addSelect( + `COUNT(CASE WHEN match.status != :pending THEN 1 END)`, + 'considered', + ) + .addSelect( + `COUNT(CASE WHEN match.status = :candidateRejected THEN 1 END)`, + 'decided', + ) + .addSelect( + `COUNT(CASE WHEN match.status = :candidateAccepted THEN 1 END)`, + 'forReview', + ) + .addSelect( + `COUNT(CASE WHEN match.status = :recruiterAccepted THEN 1 END)`, + 'introduced', + ) + .where('match.opportunityId = :opportunityId', { opportunityId }) + .setParameter('pending', OpportunityMatchStatus.Pending) + .setParameter( + 'candidateRejected', + OpportunityMatchStatus.CandidateRejected, + ) + .setParameter( + 'candidateAccepted', + OpportunityMatchStatus.CandidateAccepted, + ) + .setParameter( + 'recruiterAccepted', + OpportunityMatchStatus.RecruiterAccepted, + ) + .getRawOne<{ + reached: string; + considered: string; + decided: string; + forReview: string; + introduced: string; + }>(), + ); + + // Get counts per status using SQL aggregation + + return { + matched: 12_000, // Mock value as requested + reached: parseInt(result?.reached || '0', 10), + considered: parseInt(result?.considered || '0', 10), + decided: parseInt(result?.decided || '0', 10), + forReview: parseInt(result?.forReview || '0', 10), + introduced: parseInt(result?.introduced || '0', 10), + }; + }, }, Mutation: { updateCandidatePreferences: async (