diff --git a/__tests__/schema/opportunity.ts b/__tests__/schema/opportunity.ts index bd1dcf4c22..628cd88b78 100644 --- a/__tests__/schema/opportunity.ts +++ b/__tests__/schema/opportunity.ts @@ -774,11 +774,13 @@ describe('query opportunityMatches', () => { const GET_OPPORTUNITY_MATCHES_QUERY = /* GraphQL */ ` query GetOpportunityMatches( $opportunityId: ID! + $status: OpportunityMatchStatus $first: Int $after: String ) { opportunityMatches( opportunityId: $opportunityId + status: $status first: $first after: $after ) { @@ -787,6 +789,7 @@ describe('query opportunityMatches', () => { hasPreviousPage endCursor startCursor + totalCount } edges { node { @@ -1102,6 +1105,70 @@ describe('query opportunityMatches', () => { // salaryExpectation should be null for recruiter viewing another candidate expect(user2Match.node.candidatePreferences.salaryExpectation).toBeNull(); }); + + it('should filter by candidate_accepted status when provided', async () => { + loggedUser = '1'; + + const res = await client.query(GET_OPPORTUNITY_MATCHES_QUERY, { + variables: { + opportunityId: '550e8400-e29b-41d4-a716-446655440001', + status: 'candidate_accepted', + first: 10, + }, + }); + + expect(res.errors).toBeFalsy(); + expect(res.data.opportunityMatches.edges).toHaveLength(1); + expect(res.data.opportunityMatches.edges[0].node.status).toBe( + 'candidate_accepted', + ); + expect(res.data.opportunityMatches.edges[0].node.userId).toBe('2'); + }); + + it('should reject invalid status values', async () => { + loggedUser = '1'; + + // 'pending' is not an allowed status for this query + await testQueryErrorCode( + client, + { + query: GET_OPPORTUNITY_MATCHES_QUERY, + variables: { + opportunityId: '550e8400-e29b-41d4-a716-446655440001', + status: 'pending', + first: 10, + }, + }, + 'ZOD_VALIDATION_ERROR', + ); + }); + + it('should return totalCount of matches for the given status filter', async () => { + loggedUser = '1'; + + // Test with no status filter - should count all allowed statuses + const resAll = await client.query(GET_OPPORTUNITY_MATCHES_QUERY, { + variables: { + opportunityId: '550e8400-e29b-41d4-a716-446655440001', + first: 10, + }, + }); + + expect(resAll.errors).toBeFalsy(); + expect(resAll.data.opportunityMatches.pageInfo.totalCount).toBe(3); + + // Test with candidate_accepted status filter + const resFiltered = await client.query(GET_OPPORTUNITY_MATCHES_QUERY, { + variables: { + opportunityId: '550e8400-e29b-41d4-a716-446655440001', + status: 'candidate_accepted', + first: 10, + }, + }); + + expect(resFiltered.errors).toBeFalsy(); + expect(resFiltered.data.opportunityMatches.pageInfo.totalCount).toBe(1); + }); }); describe('query userOpportunityMatches', () => { diff --git a/src/common/schema/opportunities.ts b/src/common/schema/opportunities.ts index d5d1b55715..84dbf45dd9 100644 --- a/src/common/schema/opportunities.ts +++ b/src/common/schema/opportunities.ts @@ -3,6 +3,7 @@ import z from 'zod'; import { organizationLinksSchema } from './organizations'; import { fileUploadSchema, urlParseSchema } from './common'; import { parseBigInt } from '../utils'; +import { OpportunityMatchStatus } from '../../entity/opportunities/types'; export const opportunityMatchDescriptionSchema = z.object({ reasoning: z.string(), @@ -255,3 +256,16 @@ export const createSharedSlackChannelSchema = z.object({ 'Channel name can only contain lowercase letters, numbers, hyphens, and underscores', ), }); + +export const opportunityMatchesQuerySchema = z.object({ + opportunityId: z.string(), + status: z + .enum([ + OpportunityMatchStatus.CandidateAccepted, + OpportunityMatchStatus.RecruiterAccepted, + OpportunityMatchStatus.RecruiterRejected, + ]) + .optional(), + after: z.string().optional(), + first: z.number().optional(), +}); diff --git a/src/graphorm/index.ts b/src/graphorm/index.ts index c098e852d4..9ec15aa368 100644 --- a/src/graphorm/index.ts +++ b/src/graphorm/index.ts @@ -1602,6 +1602,7 @@ const obj = new GraphORM({ }, }, OpportunityMatch: { + requiredColumns: ['updatedAt'], ignoredColumns: ['engagementProfile'], fields: { createdAt: { diff --git a/src/schema/opportunity.ts b/src/schema/opportunity.ts index 8e07d28a8d..92ef188d1e 100644 --- a/src/schema/opportunity.ts +++ b/src/schema/opportunity.ts @@ -65,6 +65,7 @@ import { opportunityUpdateStateSchema, createSharedSlackChannelSchema, parseOpportunitySchema, + opportunityMatchesQuerySchema, } from '../common/schema/opportunities'; import { OpportunityKeyword } from '../entity/OpportunityKeyword'; import { @@ -310,8 +311,19 @@ export const typeDefs = /* GraphQL */ ` cursor: String! } + type OpportunityMatchPageInfo { + hasNextPage: Boolean! + hasPreviousPage: Boolean + startCursor: String + endCursor: String + """ + Total number of matches for the given status filter + """ + totalCount: Int! + } + type OpportunityMatchConnection { - pageInfo: PageInfo! + pageInfo: OpportunityMatchPageInfo! edges: [OpportunityMatchEdge!]! } @@ -498,6 +510,10 @@ export const typeDefs = /* GraphQL */ ` """ opportunityId: ID! """ + Filter by match status (allowed: candidate_accepted, recruiter_accepted, recruiter_rejected) + """ + status: OpportunityMatchStatus + """ Paginate after opaque cursor """ after: String @@ -1044,7 +1060,10 @@ export const resolvers: IResolvers = traceResolvers< }, opportunityMatches: async ( _, - args: ConnectionArguments & { opportunityId: string }, + args: ConnectionArguments & { + opportunityId: string; + status?: OpportunityMatchStatus; + }, ctx: AuthContext, info, ) => { @@ -1052,53 +1071,75 @@ export const resolvers: IResolvers = traceResolvers< throw new NotFoundError('Not found!'); } + // Validate and parse args using Zod schema + const validatedArgs = opportunityMatchesQuerySchema.parse(args); + // First verify the user has access to this opportunity await ensureOpportunityPermissions({ con: ctx.con.manager, userId: ctx.userId, - opportunityId: args.opportunityId, + opportunityId: validatedArgs.opportunityId, permission: OpportunityPermissions.UpdateState, isTeamMember: ctx.isTeamMember, }); - return await queryPaginatedByDate< - GQLOpportunityMatch, - 'updatedAt', - typeof args - >( - ctx, - info, - args, - { key: 'updatedAt', maxSize: 50 }, - { - queryBuilder: (builder) => { - builder.queryBuilder - .where({ opportunityId: args.opportunityId }) - .andWhere(`${builder.alias}.status IN (:...statuses)`, { - statuses: [ + // If status is provided, filter by that status; otherwise use all allowed statuses + const statusesToFilter = validatedArgs.status + ? [validatedArgs.status] + : [ + OpportunityMatchStatus.CandidateAccepted, + OpportunityMatchStatus.RecruiterAccepted, + OpportunityMatchStatus.RecruiterRejected, + ]; + + const [connection, totalCount] = await Promise.all([ + queryPaginatedByDate( + ctx, + info, + args, + { key: 'updatedAt', maxSize: 50 }, + { + queryBuilder: (builder) => { + builder.queryBuilder + .where({ opportunityId: validatedArgs.opportunityId }) + .andWhere(`${builder.alias}.status IN (:...statuses)`, { + statuses: statusesToFilter, + }) + // Order by candidate_accepted status first (priority 0), then others (priority 1) + // Then by updatedAt ascending (oldest first) within each group + .addOrderBy( + `CASE WHEN ${builder.alias}.status = :candidateAcceptedStatus THEN 0 ELSE 1 END`, + 'ASC', + ) + .addOrderBy(`${builder.alias}.updatedAt`, 'ASC') + .setParameter( + 'candidateAcceptedStatus', OpportunityMatchStatus.CandidateAccepted, - OpportunityMatchStatus.RecruiterAccepted, - OpportunityMatchStatus.RecruiterRejected, - ], - }) - // Order by candidate_accepted status first (priority 0), then others (priority 1) - // Then by updatedAt ascending (oldest first) within each group - .addOrderBy( - `CASE WHEN ${builder.alias}.status = :candidateAcceptedStatus THEN 0 ELSE 1 END`, - 'ASC', - ) - .addOrderBy(`${builder.alias}.updatedAt`, 'ASC') - .setParameter( - 'candidateAcceptedStatus', - OpportunityMatchStatus.CandidateAccepted, - ); + ); - return builder; + return builder; + }, + orderByKey: 'ASC', + readReplica: true, }, - orderByKey: 'ASC', - readReplica: true, + ), + queryReadReplica(ctx.con, ({ queryRunner }) => + queryRunner.manager.getRepository(OpportunityMatch).count({ + where: { + opportunityId: validatedArgs.opportunityId, + status: In(statusesToFilter), + }, + }), + ), + ]); + + return { + ...connection, + pageInfo: { + ...connection.pageInfo, + totalCount, }, - ); + }; }, userOpportunityMatches: async ( _,