@@ -65,6 +65,7 @@ import {
6565 opportunityUpdateStateSchema ,
6666 createSharedSlackChannelSchema ,
6767 parseOpportunitySchema ,
68+ opportunityMatchesQuerySchema ,
6869} from '../common/schema/opportunities' ;
6970import { OpportunityKeyword } from '../entity/OpportunityKeyword' ;
7071import {
@@ -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