Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions __tests__/schema/opportunity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
120 changes: 120 additions & 0 deletions src/schema/opportunity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -1331,6 +1382,75 @@ export const resolvers: IResolvers<unknown, BaseContext> = traceResolvers<
},
};
},
opportunityStats: async (
_,
{ opportunityId }: { opportunityId: string },
ctx: AuthContext,
): Promise<GQLOpportunityStats> => {
// 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 (
Expand Down
Loading