Skip to content

Commit 24b84c6

Browse files
authored
feat: add opportunity stats (#3340)
1 parent 9ba2191 commit 24b84c6

2 files changed

Lines changed: 164 additions & 0 deletions

File tree

__tests__/schema/opportunity.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5953,3 +5953,47 @@ describe('query opportunityPreview', () => {
59535953
expect(res.data.opportunityPreview.result.totalCount).toBe(2);
59545954
});
59555955
});
5956+
5957+
describe('query opportunityStats', () => {
5958+
const OPPORTUNITY_STATS_QUERY = /* GraphQL */ `
5959+
query OpportunityStats($opportunityId: ID!) {
5960+
opportunityStats(opportunityId: $opportunityId) {
5961+
matched
5962+
reached
5963+
considered
5964+
decided
5965+
forReview
5966+
introduced
5967+
}
5968+
}
5969+
`;
5970+
5971+
it('should return stats for opportunity with multiple match statuses', async () => {
5972+
loggedUser = '1';
5973+
5974+
const res = await client.query(OPPORTUNITY_STATS_QUERY, {
5975+
variables: { opportunityId: opportunitiesFixture[0].id },
5976+
});
5977+
5978+
expect(res.errors).toBeFalsy();
5979+
expect(res.data.opportunityStats).toEqual({
5980+
matched: 12_000, // Mock value
5981+
reached: 4, // Total: pending(1), candidate_accepted(1), recruiter_accepted(1), recruiter_rejected(1)
5982+
considered: 3, // All except pending
5983+
decided: 0, // No candidate_rejected in fixture
5984+
forReview: 1, // candidate_accepted
5985+
introduced: 1, // recruiter_accepted
5986+
});
5987+
});
5988+
5989+
it('should deny access if user is not a recruiter for the opportunity', async () => {
5990+
loggedUser = '3'; // User 3 is not a recruiter for opportunity 0
5991+
5992+
const res = await client.query(OPPORTUNITY_STATS_QUERY, {
5993+
variables: { opportunityId: opportunitiesFixture[0].id },
5994+
});
5995+
5996+
expect(res.errors).toBeTruthy();
5997+
expect(res.errors[0].extensions.code).toBe('FORBIDDEN');
5998+
});
5999+
});

src/schema/opportunity.ts

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,15 @@ export interface GQLOpportunityPreviewConnection {
182182
result: GQLOpportunityPreviewResult;
183183
}
184184

185+
export interface GQLOpportunityStats {
186+
matched: number;
187+
reached: number;
188+
considered: number;
189+
decided: number;
190+
forReview: number;
191+
introduced: number;
192+
}
193+
185194
export const typeDefs = /* GraphQL */ `
186195
${toGQLEnum(OpportunityMatchStatus, 'OpportunityMatchStatus')}
187196
${toGQLEnum(OrganizationLinkType, 'OrganizationLinkType')}
@@ -476,6 +485,38 @@ export const typeDefs = /* GraphQL */ `
476485
result: OpportunityPreviewResult
477486
}
478487
488+
type OpportunityStats {
489+
"""
490+
Mock value for matched candidates
491+
"""
492+
matched: Int!
493+
494+
"""
495+
Total count of all matches regardless of status
496+
"""
497+
reached: Int!
498+
499+
"""
500+
Count of matches that are not pending (all statuses except pending)
501+
"""
502+
considered: Int!
503+
504+
"""
505+
Count of candidate_rejected matches
506+
"""
507+
decided: Int!
508+
509+
"""
510+
Count of candidate_accepted matches
511+
"""
512+
forReview: Int!
513+
514+
"""
515+
Count of recruiter_accepted matches
516+
"""
517+
introduced: Int!
518+
}
519+
479520
extend type Query {
480521
"""
481522
Get the public information about a Opportunity listing
@@ -574,6 +615,16 @@ export const typeDefs = /* GraphQL */ `
574615
"""
575616
opportunityId: ID
576617
): OpportunityPreviewConnection!
618+
619+
"""
620+
Get statistics for an opportunity's matches
621+
"""
622+
opportunityStats(
623+
"""
624+
Id of the Opportunity
625+
"""
626+
opportunityId: ID!
627+
): OpportunityStats! @auth
577628
}
578629
579630
input SalaryExpectationInput {
@@ -1331,6 +1382,75 @@ export const resolvers: IResolvers<unknown, BaseContext> = traceResolvers<
13311382
},
13321383
};
13331384
},
1385+
opportunityStats: async (
1386+
_,
1387+
{ opportunityId }: { opportunityId: string },
1388+
ctx: AuthContext,
1389+
): Promise<GQLOpportunityStats> => {
1390+
// Verify the user has access to this opportunity
1391+
await ensureOpportunityPermissions({
1392+
con: ctx.con.manager,
1393+
userId: ctx.userId,
1394+
opportunityId,
1395+
permission: OpportunityPermissions.UpdateState,
1396+
isTeamMember: ctx.isTeamMember,
1397+
});
1398+
1399+
const result = await queryReadReplica(ctx.con, ({ queryRunner }) =>
1400+
queryRunner.manager
1401+
.getRepository(OpportunityMatch)
1402+
.createQueryBuilder('match')
1403+
.select('COUNT(*)', 'reached')
1404+
.addSelect(
1405+
`COUNT(CASE WHEN match.status != :pending THEN 1 END)`,
1406+
'considered',
1407+
)
1408+
.addSelect(
1409+
`COUNT(CASE WHEN match.status = :candidateRejected THEN 1 END)`,
1410+
'decided',
1411+
)
1412+
.addSelect(
1413+
`COUNT(CASE WHEN match.status = :candidateAccepted THEN 1 END)`,
1414+
'forReview',
1415+
)
1416+
.addSelect(
1417+
`COUNT(CASE WHEN match.status = :recruiterAccepted THEN 1 END)`,
1418+
'introduced',
1419+
)
1420+
.where('match.opportunityId = :opportunityId', { opportunityId })
1421+
.setParameter('pending', OpportunityMatchStatus.Pending)
1422+
.setParameter(
1423+
'candidateRejected',
1424+
OpportunityMatchStatus.CandidateRejected,
1425+
)
1426+
.setParameter(
1427+
'candidateAccepted',
1428+
OpportunityMatchStatus.CandidateAccepted,
1429+
)
1430+
.setParameter(
1431+
'recruiterAccepted',
1432+
OpportunityMatchStatus.RecruiterAccepted,
1433+
)
1434+
.getRawOne<{
1435+
reached: string;
1436+
considered: string;
1437+
decided: string;
1438+
forReview: string;
1439+
introduced: string;
1440+
}>(),
1441+
);
1442+
1443+
// Get counts per status using SQL aggregation
1444+
1445+
return {
1446+
matched: 12_000, // Mock value as requested
1447+
reached: parseInt(result?.reached || '0', 10),
1448+
considered: parseInt(result?.considered || '0', 10),
1449+
decided: parseInt(result?.decided || '0', 10),
1450+
forReview: parseInt(result?.forReview || '0', 10),
1451+
introduced: parseInt(result?.introduced || '0', 10),
1452+
};
1453+
},
13341454
},
13351455
Mutation: {
13361456
updateCandidatePreferences: async (

0 commit comments

Comments
 (0)