Skip to content

Commit da5e98b

Browse files
authored
feat: enhance opportunityMatches query with status filtering and total count (#3328)
1 parent 0488f97 commit da5e98b

4 files changed

Lines changed: 160 additions & 37 deletions

File tree

__tests__/schema/opportunity.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -774,11 +774,13 @@ describe('query opportunityMatches', () => {
774774
const GET_OPPORTUNITY_MATCHES_QUERY = /* GraphQL */ `
775775
query GetOpportunityMatches(
776776
$opportunityId: ID!
777+
$status: OpportunityMatchStatus
777778
$first: Int
778779
$after: String
779780
) {
780781
opportunityMatches(
781782
opportunityId: $opportunityId
783+
status: $status
782784
first: $first
783785
after: $after
784786
) {
@@ -787,6 +789,7 @@ describe('query opportunityMatches', () => {
787789
hasPreviousPage
788790
endCursor
789791
startCursor
792+
totalCount
790793
}
791794
edges {
792795
node {
@@ -1102,6 +1105,70 @@ describe('query opportunityMatches', () => {
11021105
// salaryExpectation should be null for recruiter viewing another candidate
11031106
expect(user2Match.node.candidatePreferences.salaryExpectation).toBeNull();
11041107
});
1108+
1109+
it('should filter by candidate_accepted status when provided', async () => {
1110+
loggedUser = '1';
1111+
1112+
const res = await client.query(GET_OPPORTUNITY_MATCHES_QUERY, {
1113+
variables: {
1114+
opportunityId: '550e8400-e29b-41d4-a716-446655440001',
1115+
status: 'candidate_accepted',
1116+
first: 10,
1117+
},
1118+
});
1119+
1120+
expect(res.errors).toBeFalsy();
1121+
expect(res.data.opportunityMatches.edges).toHaveLength(1);
1122+
expect(res.data.opportunityMatches.edges[0].node.status).toBe(
1123+
'candidate_accepted',
1124+
);
1125+
expect(res.data.opportunityMatches.edges[0].node.userId).toBe('2');
1126+
});
1127+
1128+
it('should reject invalid status values', async () => {
1129+
loggedUser = '1';
1130+
1131+
// 'pending' is not an allowed status for this query
1132+
await testQueryErrorCode(
1133+
client,
1134+
{
1135+
query: GET_OPPORTUNITY_MATCHES_QUERY,
1136+
variables: {
1137+
opportunityId: '550e8400-e29b-41d4-a716-446655440001',
1138+
status: 'pending',
1139+
first: 10,
1140+
},
1141+
},
1142+
'ZOD_VALIDATION_ERROR',
1143+
);
1144+
});
1145+
1146+
it('should return totalCount of matches for the given status filter', async () => {
1147+
loggedUser = '1';
1148+
1149+
// Test with no status filter - should count all allowed statuses
1150+
const resAll = await client.query(GET_OPPORTUNITY_MATCHES_QUERY, {
1151+
variables: {
1152+
opportunityId: '550e8400-e29b-41d4-a716-446655440001',
1153+
first: 10,
1154+
},
1155+
});
1156+
1157+
expect(resAll.errors).toBeFalsy();
1158+
expect(resAll.data.opportunityMatches.pageInfo.totalCount).toBe(3);
1159+
1160+
// Test with candidate_accepted status filter
1161+
const resFiltered = await client.query(GET_OPPORTUNITY_MATCHES_QUERY, {
1162+
variables: {
1163+
opportunityId: '550e8400-e29b-41d4-a716-446655440001',
1164+
status: 'candidate_accepted',
1165+
first: 10,
1166+
},
1167+
});
1168+
1169+
expect(resFiltered.errors).toBeFalsy();
1170+
expect(resFiltered.data.opportunityMatches.pageInfo.totalCount).toBe(1);
1171+
});
11051172
});
11061173

11071174
describe('query userOpportunityMatches', () => {

src/common/schema/opportunities.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import z from 'zod';
33
import { organizationLinksSchema } from './organizations';
44
import { fileUploadSchema, urlParseSchema } from './common';
55
import { parseBigInt } from '../utils';
6+
import { OpportunityMatchStatus } from '../../entity/opportunities/types';
67

78
export const opportunityMatchDescriptionSchema = z.object({
89
reasoning: z.string(),
@@ -255,3 +256,16 @@ export const createSharedSlackChannelSchema = z.object({
255256
'Channel name can only contain lowercase letters, numbers, hyphens, and underscores',
256257
),
257258
});
259+
260+
export const opportunityMatchesQuerySchema = z.object({
261+
opportunityId: z.string(),
262+
status: z
263+
.enum([
264+
OpportunityMatchStatus.CandidateAccepted,
265+
OpportunityMatchStatus.RecruiterAccepted,
266+
OpportunityMatchStatus.RecruiterRejected,
267+
])
268+
.optional(),
269+
after: z.string().optional(),
270+
first: z.number().optional(),
271+
});

src/graphorm/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1602,6 +1602,7 @@ const obj = new GraphORM({
16021602
},
16031603
},
16041604
OpportunityMatch: {
1605+
requiredColumns: ['updatedAt'],
16051606
ignoredColumns: ['engagementProfile'],
16061607
fields: {
16071608
createdAt: {

src/schema/opportunity.ts

Lines changed: 78 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ import {
6565
opportunityUpdateStateSchema,
6666
createSharedSlackChannelSchema,
6767
parseOpportunitySchema,
68+
opportunityMatchesQuerySchema,
6869
} from '../common/schema/opportunities';
6970
import { OpportunityKeyword } from '../entity/OpportunityKeyword';
7071
import {
@@ -310,8 +311,19 @@ export const typeDefs = /* GraphQL */ `
310311
cursor: String!
311312
}
312313
314+
type OpportunityMatchPageInfo {
315+
hasNextPage: Boolean!
316+
hasPreviousPage: Boolean
317+
startCursor: String
318+
endCursor: String
319+
"""
320+
Total number of matches for the given status filter
321+
"""
322+
totalCount: Int!
323+
}
324+
313325
type OpportunityMatchConnection {
314-
pageInfo: PageInfo!
326+
pageInfo: OpportunityMatchPageInfo!
315327
edges: [OpportunityMatchEdge!]!
316328
}
317329
@@ -498,6 +510,10 @@ export const typeDefs = /* GraphQL */ `
498510
"""
499511
opportunityId: ID!
500512
"""
513+
Filter by match status (allowed: candidate_accepted, recruiter_accepted, recruiter_rejected)
514+
"""
515+
status: OpportunityMatchStatus
516+
"""
501517
Paginate after opaque cursor
502518
"""
503519
after: String
@@ -1044,61 +1060,86 @@ export const resolvers: IResolvers<unknown, BaseContext> = traceResolvers<
10441060
},
10451061
opportunityMatches: async (
10461062
_,
1047-
args: ConnectionArguments & { opportunityId: string },
1063+
args: ConnectionArguments & {
1064+
opportunityId: string;
1065+
status?: OpportunityMatchStatus;
1066+
},
10481067
ctx: AuthContext,
10491068
info,
10501069
) => {
10511070
if (!ctx.userId) {
10521071
throw new NotFoundError('Not found!');
10531072
}
10541073

1074+
// Validate and parse args using Zod schema
1075+
const validatedArgs = opportunityMatchesQuerySchema.parse(args);
1076+
10551077
// First verify the user has access to this opportunity
10561078
await ensureOpportunityPermissions({
10571079
con: ctx.con.manager,
10581080
userId: ctx.userId,
1059-
opportunityId: args.opportunityId,
1081+
opportunityId: validatedArgs.opportunityId,
10601082
permission: OpportunityPermissions.UpdateState,
10611083
isTeamMember: ctx.isTeamMember,
10621084
});
10631085

1064-
return await queryPaginatedByDate<
1065-
GQLOpportunityMatch,
1066-
'updatedAt',
1067-
typeof args
1068-
>(
1069-
ctx,
1070-
info,
1071-
args,
1072-
{ key: 'updatedAt', maxSize: 50 },
1073-
{
1074-
queryBuilder: (builder) => {
1075-
builder.queryBuilder
1076-
.where({ opportunityId: args.opportunityId })
1077-
.andWhere(`${builder.alias}.status IN (:...statuses)`, {
1078-
statuses: [
1086+
// If status is provided, filter by that status; otherwise use all allowed statuses
1087+
const statusesToFilter = validatedArgs.status
1088+
? [validatedArgs.status]
1089+
: [
1090+
OpportunityMatchStatus.CandidateAccepted,
1091+
OpportunityMatchStatus.RecruiterAccepted,
1092+
OpportunityMatchStatus.RecruiterRejected,
1093+
];
1094+
1095+
const [connection, totalCount] = await Promise.all([
1096+
queryPaginatedByDate<GQLOpportunityMatch, 'updatedAt', typeof args>(
1097+
ctx,
1098+
info,
1099+
args,
1100+
{ key: 'updatedAt', maxSize: 50 },
1101+
{
1102+
queryBuilder: (builder) => {
1103+
builder.queryBuilder
1104+
.where({ opportunityId: validatedArgs.opportunityId })
1105+
.andWhere(`${builder.alias}.status IN (:...statuses)`, {
1106+
statuses: statusesToFilter,
1107+
})
1108+
// Order by candidate_accepted status first (priority 0), then others (priority 1)
1109+
// Then by updatedAt ascending (oldest first) within each group
1110+
.addOrderBy(
1111+
`CASE WHEN ${builder.alias}.status = :candidateAcceptedStatus THEN 0 ELSE 1 END`,
1112+
'ASC',
1113+
)
1114+
.addOrderBy(`${builder.alias}.updatedAt`, 'ASC')
1115+
.setParameter(
1116+
'candidateAcceptedStatus',
10791117
OpportunityMatchStatus.CandidateAccepted,
1080-
OpportunityMatchStatus.RecruiterAccepted,
1081-
OpportunityMatchStatus.RecruiterRejected,
1082-
],
1083-
})
1084-
// Order by candidate_accepted status first (priority 0), then others (priority 1)
1085-
// Then by updatedAt ascending (oldest first) within each group
1086-
.addOrderBy(
1087-
`CASE WHEN ${builder.alias}.status = :candidateAcceptedStatus THEN 0 ELSE 1 END`,
1088-
'ASC',
1089-
)
1090-
.addOrderBy(`${builder.alias}.updatedAt`, 'ASC')
1091-
.setParameter(
1092-
'candidateAcceptedStatus',
1093-
OpportunityMatchStatus.CandidateAccepted,
1094-
);
1118+
);
10951119

1096-
return builder;
1120+
return builder;
1121+
},
1122+
orderByKey: 'ASC',
1123+
readReplica: true,
10971124
},
1098-
orderByKey: 'ASC',
1099-
readReplica: true,
1125+
),
1126+
queryReadReplica(ctx.con, ({ queryRunner }) =>
1127+
queryRunner.manager.getRepository(OpportunityMatch).count({
1128+
where: {
1129+
opportunityId: validatedArgs.opportunityId,
1130+
status: In(statusesToFilter),
1131+
},
1132+
}),
1133+
),
1134+
]);
1135+
1136+
return {
1137+
...connection,
1138+
pageInfo: {
1139+
...connection.pageInfo,
1140+
totalCount,
11001141
},
1101-
);
1142+
};
11021143
},
11031144
userOpportunityMatches: async (
11041145
_,

0 commit comments

Comments
 (0)