diff --git a/.gitignore b/.gitignore index 69ec29ae..ea148e00 100644 --- a/.gitignore +++ b/.gitignore @@ -74,6 +74,7 @@ web_modules/ # dotenv environment variables file .env .env.test +taas-es-processor.env # parcel-bundler cache (https://parceljs.org/) .cache diff --git a/README.md b/README.md index 287bf7cb..a61b99dd 100644 --- a/README.md +++ b/README.md @@ -220,7 +220,8 @@ To be able to change and test `taas-es-processor` locally you can follow the nex | `npm run cov` | Code Coverage Report. | | `npm run migrate` | Run any migration files which haven't run yet. | | `npm run migrate:undo` | Revert most recent migration. | -| `npm run demo-payment-scheduler` | Create 1000 Work Periods Payment records in with status "scheduled" and various "amount" | +| `npm run demo-payment-scheduler` | Create 1000 Work Periods Payment records in with status "scheduled" and various "amount" | +| `npm run emsi-mapping` | mapping EMSI tags to topcoder skills | ## Import and Export data @@ -334,3 +335,8 @@ When we add, update or delete models and/or endpoints we have to make sure that - **DB Migration** - If there are any updates in DB schemas, create a DB migration script inside `migrations` folder which would make any necessary updates to the DB schema. - Test, that when we migrate DB from the previous state using `npm run migrate`, we get exactly the same DB schema as if we create DB from scratch using command `npm run init-db force`. + +## EMSI mapping +mapping EMSI tags to topcoder skills +Run `npm run emsi-mapping` to create the mapping file +It will take about 15 minutes to create the mapping file `script/emsi-mapping/emsi-skils-mapping.js` diff --git a/config/default.js b/config/default.js index 3298fb10..cb589290 100644 --- a/config/default.js +++ b/config/default.js @@ -214,5 +214,17 @@ module.exports = { FIX_DELAY_STEP_ASSIGN_MEMBER: parseInt(process.env.PAYMENT_PROCESSING_FIX_DELAY_STEP_ASSIGN_MEMBER || process.env.PAYMENT_PROCESSING_FIX_DELAY_STEP || 500), // the fix delay after step of activate challenge, unit: ms FIX_DELAY_STEP_ACTIVATE_CHALLENGE: parseInt(process.env.PAYMENT_PROCESSING_FIX_DELAY_STEP_ACTIVATE_CHALLENGE || process.env.PAYMENT_PROCESSING_FIX_DELAY_STEP || 500) + }, + // if a job reach this critier, system will automatically withdrawn other job applications. + JOBS_HOUR_PER_WEEK: 20, + // the mapping includes the status transformation when auto-withdrawn feature is performed on job candidates. + WITHDRAWN_STATUS_CHANGE_MAPPING: { + applied: 'withdrawn-prescreen', + 'skills-test': 'withdrawn-prescreen', + 'phone-screen': 'withdrawn-prescreen', + open: 'withdrawn', + interview: 'withdrawn', + selected: 'withdrawn', + offered: 'withdrawn' } } diff --git a/data/demo-data.json b/data/demo-data.json index aad3fbe7..9d939c4c 100644 --- a/data/demo-data.json +++ b/data/demo-data.json @@ -7732,6 +7732,9 @@ "global": 50, "offShore": 10, "inCountry": 20, + "niche": 50, + "rate20Niche": 20, + "rate30Niche": 10, "rate20Global": 20, "rate30Global": 20, "rate20OffShore": 35, @@ -7743,6 +7746,9 @@ "global": 25, "offShore": 5, "inCountry": 15, + "niche": 50, + "rate20Niche": 20, + "rate30Niche": 10, "rate20Global": 20, "rate30Global": 20, "rate20OffShore": 35, @@ -7762,4 +7768,4 @@ "updatedAt": "2021-05-27T21:43:09.342Z" } ] -} \ No newline at end of file +} diff --git a/docs/Topcoder-bookings-api.postman_collection.json b/docs/Topcoder-bookings-api.postman_collection.json index 35244bee..8ee3ef14 100644 --- a/docs/Topcoder-bookings-api.postman_collection.json +++ b/docs/Topcoder-bookings-api.postman_collection.json @@ -1,6 +1,6 @@ { "info": { - "_postman_id": "87477d86-2d08-40b6-93c6-99a394193e28", + "_postman_id": "0bd597ba-4bc2-4ea1-be33-45776b80c1ce", "name": "Topcoder-bookings-api", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" }, @@ -11647,6 +11647,11 @@ "key": "projectIds", "value": "111, 16705", "disabled": true + }, + { + "key": "billingAccountId", + "value": "0", + "disabled": true } ] } @@ -11725,6 +11730,11 @@ "key": "status", "value": "assigned", "disabled": true + }, + { + "key": "billingAccountId", + "value": "0", + "disabled": true } ] } @@ -11797,6 +11807,11 @@ { "key": "sortOrder", "value": "desc" + }, + { + "key": "billingAccountId", + "value": "0", + "disabled": true } ] } @@ -11867,6 +11882,11 @@ { "key": "sortOrder", "value": "desc" + }, + { + "key": "billingAccountId", + "value": "0", + "disabled": true } ] } @@ -12537,6 +12557,218 @@ }, "response": [] }, + { + "name": "search resource bookings with parameters 13", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Status code is 200', function () {\r", + " pm.response.to.have.status(200);\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "type": "text", + "value": "Bearer {{token_bookingManager}}" + } + ], + "url": { + "raw": "{{URL}}/resourceBookings?fields=id,startDate,endDate,billingAccountId,workPeriods&billingAccountId=80000071&workPeriods.startDate=2021-01-03&workPeriods.isFirstWeek=true", + "host": [ + "{{URL}}" + ], + "path": [ + "resourceBookings" + ], + "query": [ + { + "key": "fields", + "value": "id,startDate,endDate,billingAccountId,workPeriods" + }, + { + "key": "billingAccountId", + "value": "80000071" + }, + { + "key": "workPeriods.startDate", + "value": "2021-01-03" + }, + { + "key": "workPeriods.isFirstWeek", + "value": "true" + } + ] + } + }, + "response": [] + }, + { + "name": "search resource bookings with parameters 14", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Status code is 200', function () {\r", + " pm.response.to.have.status(200);\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "type": "text", + "value": "Bearer {{token_bookingManager}}" + } + ], + "url": { + "raw": "{{URL}}/resourceBookings?fields=id,startDate,endDate,billingAccountId,workPeriods&billingAccountId=80000071&workPeriods.startDate=2021-02-07&workPeriods.isLastWeek=true", + "host": [ + "{{URL}}" + ], + "path": [ + "resourceBookings" + ], + "query": [ + { + "key": "fields", + "value": "id,startDate,endDate,billingAccountId,workPeriods" + }, + { + "key": "billingAccountId", + "value": "80000071" + }, + { + "key": "workPeriods.startDate", + "value": "2021-02-07" + }, + { + "key": "workPeriods.isLastWeek", + "value": "true" + } + ] + } + }, + "response": [] + }, + { + "name": "search resource bookings with parameters 15", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Status code is 400', function () {\r", + " pm.response.to.have.status(400);\r", + " const response = pm.response.json()\r", + " pm.expect(response.message).to.eq(\"Cannot filter by \\\"isLastWeek\\\" without \\\"startDate\\\"\")\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "type": "text", + "value": "Bearer {{token_bookingManager}}" + } + ], + "url": { + "raw": "{{URL}}/resourceBookings?fields=id,startDate,endDate,billingAccountId,workPeriods&billingAccountId=80000071&workPeriods.isLastWeek=true", + "host": [ + "{{URL}}" + ], + "path": [ + "resourceBookings" + ], + "query": [ + { + "key": "fields", + "value": "id,startDate,endDate,billingAccountId,workPeriods" + }, + { + "key": "billingAccountId", + "value": "80000071" + }, + { + "key": "workPeriods.isLastWeek", + "value": "true" + } + ] + } + }, + "response": [] + }, + { + "name": "search resource bookings with parameters 16", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Status code is 400', function () {\r", + " pm.response.to.have.status(400);\r", + " const response = pm.response.json()\r", + " pm.expect(response.message).to.eq(\"Cannot filter by \\\"isFirstWeek\\\" without \\\"startDate\\\"\")\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "type": "text", + "value": "Bearer {{token_bookingManager}}" + } + ], + "url": { + "raw": "{{URL}}/resourceBookings?fields=id,startDate,endDate,billingAccountId,workPeriods&billingAccountId=80000071&workPeriods.isFirstWeek=true", + "host": [ + "{{URL}}" + ], + "path": [ + "resourceBookings" + ], + "query": [ + { + "key": "fields", + "value": "id,startDate,endDate,billingAccountId,workPeriods" + }, + { + "key": "billingAccountId", + "value": "80000071" + }, + { + "key": "workPeriods.isFirstWeek", + "value": "true" + } + ] + } + }, + "response": [] + }, { "name": "put resource booking with booking manager", "event": [ diff --git a/docs/swagger.yaml b/docs/swagger.yaml index b7705889..6eb3ef5d 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -624,6 +624,11 @@ paths: schema: type: string description: The external id. + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/JobCandidateSearchBody" responses: "200": description: OK @@ -1501,6 +1506,12 @@ paths: schema: type: string description: comma separated project ids. + - in: query + name: billingAccountId + required: false + schema: + type: integer + description: billing account id. 0 represents null value. - in: query name: workPeriods.paymentStatus required: false @@ -1522,7 +1533,7 @@ paths: type: string format: date pattern: '^\d{4}-\d{2}-\d{2}$' - description: The work period start date. + description: The work period start date. Should be Sunday. - in: query name: workPeriods.endDate required: false @@ -1530,13 +1541,33 @@ paths: type: string format: date pattern: '^\d{4}-\d{2}-\d{2}$' - description: The work period end date. + description: The work period end date. Should be Saturday. - in: query name: workPeriods.userHandle required: false schema: type: string description: The user handle. + - in: query + name: workPeriods.isFirstWeek + required: false + schema: + type: boolean + default: false + description: | + the week which matches workPeriods.startDate is the first one in the RB. + workPeriods.startDate is required. + only one of workPeriods.isFirstWeek and workPeriods.isLastWeek is allowed. + - in: query + name: workPeriods.isLastWeek + required: false + schema: + type: boolean + default: false + description: | + the week which matches workPeriods.startDate is the last one in the RB. + workPeriods.startDate is required. + only one of workPeriods.isFirstWeek and workPeriods.isLastWeek is allowed. - in: query name: workPeriods.payments.status required: false @@ -1552,6 +1583,11 @@ paths: maximum: 5 example: 3 description: The workdays to pay + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/ResourceBookingSearchBody" responses: "200": @@ -3901,6 +3937,14 @@ components: "cancelled", "interview", "topcoder-rejected", + "applied", + "rejected-pre-screen", + "skills-test", + "phone-screen", + "job-closed", + "offered", + "withdrawn", + "withdrawn-prescreen" ] description: "The job candidate status." externalId: @@ -3996,6 +4040,14 @@ components: type: string example: "topcoder user" description: "The user who updated the job last time.(Will get the user info from the token)" + JobCandidateSearchBody: + properties: + statuses: + type: array + items: + type: string + enum: ["open", "placed", "selected", "client rejected - screening", "client rejected - interview", "rejected - other", "cancelled", "interview", "topcoder-rejected", "applied", "rejected-pre-screen", "skills-test", "phone-screen", "job-closed", "offered", "withdrawn", "withdrawn-prescreen"] + description: "The array of job Candidates status" JobCandidateRequestBody: required: - jobId @@ -4023,6 +4075,14 @@ components: "cancelled", "interview", "topcoder-rejected", + "applied", + "rejected-pre-screen", + "skills-test", + "phone-screen", + "job-closed", + "offered", + "withdrawn", + "withdrawn-prescreen" ] description: "The job candidate status." default: open @@ -4053,6 +4113,14 @@ components: "cancelled", "interview", "topcoder-rejected", + "applied", + "rejected-pre-screen", + "skills-test", + "phone-screen", + "job-closed", + "offered", + "withdrawn", + "withdrawn-prescreen" ] externalId: type: string @@ -4436,6 +4504,14 @@ components: type: string example: "topcoder user" description: "The user who updated the job last time.(Will get the user info from the token)" + ResourceBookingSearchBody: + properties: + jobIds: + type: array + items: + type: string + format: uuid + description: "The array of job ids" ResourceBookingRequestBody: required: - projectId @@ -5189,6 +5265,14 @@ components: "cancelled", "interview", "topcoder-rejected", + "applied", + "rejected-pre-screen", + "skills-test", + "phone-screen", + "job-closed", + "offered", + "withdrawn", + "withdrawn-prescreen" ] description: "The job candidate status." skills: @@ -5331,6 +5415,9 @@ components: teamDescription: type: string description: "The description of the team" + refCode: + type: string + description: "Optional referral code" positions: type: array description: "The array of positions" @@ -5555,12 +5642,11 @@ components: example: 300 description: "The time to interview." RoleRates: - required: - - global - - inCountry - - offShore type: object properties: + niche: + type: integer + example: 10 global: type: integer example: 10 @@ -5570,6 +5656,9 @@ components: offShore: type: integer example: 30 + rate30Niche: + type: integer + example: 10 rate30Global: type: integer example: 10 @@ -5579,6 +5668,9 @@ components: rate30OffShore: type: integer example: 30 + rate20Niche: + type: integer + example: 10 rate20Global: type: integer example: 10 @@ -5772,3 +5864,4 @@ components: properties: message: type: string + diff --git a/local/docker-compose.yml b/local/docker-compose.yml index 34229bde..e8db578a 100644 --- a/local/docker-compose.yml +++ b/local/docker-compose.yml @@ -61,6 +61,11 @@ services: environment: - KAFKA_URL=kafka:9093 - ES_HOST=http://elasticsearch:9200 + - BUSAPI_URL=http://tc-bus-api:8002/v5 + - AUTH0_URL=${AUTH0_URL} + - AUTH0_AUDIENCE=${AUTH0_AUDIENCE} + - AUTH0_CLIENT_ID=${AUTH0_CLIENT_ID} + - AUTH0_CLIENT_SECRET=${AUTH0_CLIENT_SECRET} tc-bus-api: container_name: tc-bus-api diff --git a/migrations/2021-07-15-role-search-request-make-job-description-length-100K.js b/migrations/2021-07-15-role-search-request-make-job-description-length-100K.js new file mode 100644 index 00000000..08b610dd --- /dev/null +++ b/migrations/2021-07-15-role-search-request-make-job-description-length-100K.js @@ -0,0 +1,10 @@ +const config = require('config') + +module.exports = { + up: async (queryInterface, Sequelize) => { + await queryInterface.changeColumn({ tableName: 'role_search_requests', schema: config.DB_SCHEMA_NAME}, 'job_description', {type: Sequelize.STRING(100000)}) + }, + down: async (queryInterface, Sequelize) => { + await queryInterface.changeColumn({ tableName: 'role_search_requests', schema: config.DB_SCHEMA_NAME}, 'job_description', {type: Sequelize.STRING(2000)}) + }, +} \ No newline at end of file diff --git a/package.json b/package.json index 3e2e7b18..31009f5f 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "init-db": "node src/init-db.js", "create-index": "node scripts/es/createIndex.js", "delete-index": "node scripts/es/deleteIndex.js", + "emsi-mapping": "node scripts/emsi-mapping/index.js", "index:all": "node scripts/es/reIndexAll.js", "index:jobs": "node scripts/es/reIndexJobs.js", "index:job-candidates": "node scripts/es/reIndexJobCandidates.js", @@ -21,7 +22,7 @@ "migrate": "npx sequelize db:migrate", "migrate:undo": "npx sequelize db:migrate:undo", "test": "mocha test/unit/*.test.js --timeout 30000 --require test/prepare.js --exit", - "services:up": "docker-compose -f ./local/docker-compose.yml up -d", + "services:up": "docker-compose -f ./local/docker-compose.yml --env-file .env up -d", "services:down": "docker-compose -f ./local/docker-compose.yml down", "services:logs": "docker-compose -f ./local/docker-compose.yml logs", "local:init": "npm run local:reset && npm run data:import -- --force", @@ -87,4 +88,4 @@ "test/unit/**" ] } -} \ No newline at end of file +} diff --git a/scripts/emsi-mapping/esmi-skills-mapping.js b/scripts/emsi-mapping/esmi-skills-mapping.js new file mode 100644 index 00000000..4f070178 --- /dev/null +++ b/scripts/emsi-mapping/esmi-skills-mapping.js @@ -0,0 +1,419 @@ +module.exports = { + matchedSkills: { + Dropwizard: 'Dropwizard', + Nginx: 'NGINX', + 'Machine Learning': 'Machine Learning', + 'Force.Com': 'Force.Com Sites', + 'User Interface': 'UI Prototype', + Docker: 'Docker', + Appcelerator: 'appcelerator', + Flux: 'Flux', + 'Bootstrap (FRONT-END FRAMEWORK)': 'Twitter Bootstrap', + Financialforce: 'FinancialForce', + Redis: 'Redis', + Hybris: 'Hybris', + Splunk: 'Splunk', + 'Lua (SCRIPTING LANGUAGE)': 'Lua', + 'Jface (UI TOOLKIT)': 'Jface', + Recursion: 'Recursion', + Blackberry: 'Blackberry SDK', + Xul: 'XUL', + Mapreduce: 'MapReduce', + Nosql: 'NoSQL', + Linux: 'Linux', + Elasticsearch: 'Elasticsearch', + 'Microsoft Silverlight': 'Microsoft Silverlight', + Vertica: 'Vertica', + 'Windows Servers': 'Windows Server', + 'Haskell (PROGRAMMING LANGUAGE)': 'Haskell', + Hyperledger: 'Hyperledger', + 'Apache Cordova': 'Apache Cordova', + 'Play Framework': 'Play Framework', + Zipkin: 'Zipkin', + Marklogic: 'MarkLogic', + Mysql: 'MySql', + Visualforce: 'Visualforce', + 'Data Architecture': 'IBM Rational Data Architect', + 'Windows Communication Foundation': 'Windows Communication Foundation', + 'Jboss Seam': 'JBoss Seam', + 'Java Stored Procedure (SQL)': 'Transact-SQL', + 'Component Object Model (COM)': 'COM', + 'Ubuntu (OPERATING SYSTEM)': 'ubuntu', + 'Cobol (PROGRAMMING LANGUAGE)': 'Cobol', + 'Continuous Integration': 'Continuous Integration', + 'Extensible Messaging And Presence Protocol (XMPP)': 'XMPP', + Microservices: 'Microservices', + 'Java Platform Micro Edition (J2ME)': 'J2ME', + 'Qt (SOFTWARE)': 'Qt', + 'R (PROGRAMMING LANGUAGE)': 'R', + 'Scala (PROGRAMMING LANGUAGE)': 'Scala', + 'Dynamic Programming': 'Dynamic Programming', + 'C (PROGRAMMING LANGUAGE)': 'C#', + Typescript: 'TypeScript', + Xamarin: 'Xamarin', + 'Sql Server Integration Services (SSIS)': 'SSIS', + Kubernetes: 'Kubernetes', + Inkscape: 'Inkscape', + 'Ibm Websphere Portal': 'IBM WebSphere Portal', + Matlab: 'Matlab', + Jekyll: 'Jekyll', + Cassandra: 'Cassandra', + 'Airplay Sdk (APPLE)': 'Apple HIG', + Jquery: 'jQuery Mobile', + 'Power Bi': 'Power BI', + Json: 'JSON', + 'Django (WEB FRAMEWORK)': 'Django', + 'Meteor.Js': 'Meteor.js', + Clojure: 'Clojure', + 'App Store (IOS)': 'iOS', + 'Amazon Alexa': 'Amazon Alexa', + 'Ibm Bluemix': 'IBM Bluemix', + 'Extensible Stylesheet Language (XSL)': 'XSL', + 'React.Js': 'React.js', + Gradle: 'Gradle', + Protractor: 'Protractor', + 'Java Platform Enterprise Edition (J2EE)': 'J2EE', + Drupal: 'Drupal', + 'Php (SCRIPTING LANGUAGE)': 'PHP', + 'Customer Experience': 'Customer Experience (Cx)', + Mariadb: 'MariaDB', + Grommet: 'Grommet', + Clickonce: 'ClickOnce', + 'Application Programming Interface (API)': 'API', + 'Unit Testing': 'Unit-Testing', + 'Ionic Framework': 'Ionic Framework', + Moodle: 'moodle', + Jbehave: 'JBehave', + Gremlin: 'Gremlin', + Office365: 'Office365', + 'Fortran (PROGRAMMING LANGUAGE)': 'Fortran', + 'Vue.Js': 'Vuejs', + 'Google Maps': 'Google-Maps', + 'Cloud Foundry': 'Cloud Foundry', + 'Robot Framework': 'Robot Framework', + Ethereum: 'Ethereum', + Neo4J: 'Neo4J', + 'Microsoft Dynamics': 'Microsoft Dynamics', + 'Geospatial Information Technology (GIT)': 'Git', + Predix: 'Predix', + Gitlab: 'Gitlab', + 'Windows Workflow Foundation': 'Windows Workflow Foundation', + 'Javascript (PROGRAMMING LANGUAGE)': 'JavaScript', + 'Backbone.Js': 'Backbone.js', + Jabber: 'Jabber', + Wordpress: 'Wordpress', + Devops: 'DevOps', + 'Apache Derby': 'Apache Derby', + 'Rexx (PROGRAMMING LANGUAGE)': 'IBM REXX', + 'Web Scraping': 'Web scraping', + Sorting: 'Sorting', + 'Message Broker': 'IBM Websphere Message Broker', + Openam: 'Openam', + Less: 'Less', + 'Equinox (OSGI)': 'OSGi', + 'Zend Framework': 'zend framework', + 'Sketch (DESIGN SOFTWARE)': 'Sketch', + Coffeescript: 'Coffeescript', + 'Gnu Image Manipulation Program (GIMP)': 'gimp', + 'Node.Js': 'Node.js', + Laravel: 'laravel', + 'Ruby (PROGRAMMING LANGUAGE)': 'Ruby', + Mongodb: 'MongoDB', + 'Graphic Design': 'Graphic Design', + 'Entity Framework': 'Entity-Framework', + 'Hibernate (JAVA)': 'Hibernate', + 'Data Visualization': 'Data Visualization', + 'Windows Phone': 'Windows Phone', + 'Bash (SCRIPTING LANGUAGE)': 'Bash', + 'Akka (TOOLKIT)': 'Akka', + 'Sencha Touch': 'Sencha Touch 2', + Multithreading: 'Multithreading', + Apigee: 'Apigee', + 'Iso/Iec 14882 (C++)': 'C++', + 'Ab Initio (SOFTWARE)': 'Ab Initio', + 'Python (PROGRAMMING LANGUAGE)': 'Python', + 'Big Data': 'Big data', + Vscode: 'VSCode', + Codeigniter: 'Codeigniter', + 'Grunt.Js': 'Grunt.js', + 'Swing (DANCE)': 'Swing', + 'Groovy (PROGRAMMING LANGUAGE)': 'Groovy', + Openshift: 'OpenShift', + Integration: 'IBM Integration Bus', + Compression: 'Compression', + 'Salesforce.Com': 'Salesforce.com', + 'Ibm Websphere Mq': 'IBM WebSphere MQ', + 'Information Architecture': 'Information Architecture (IA)', + 'Ember.Js': 'Ember.js', + 'Vim (TEXT EDITOR)': 'vim', + Html5: 'HTML5', + 'Custom Tag': 'Custom Tag', + 'Asp.Net': 'ASP.NET', + 'Responsive Web Design': 'Responsive Web Design', + 'Ibm Rational Software': 'IBM Rational Software Architect', + Corda: 'R3 Corda', + Phonegap: 'Phonegap', + Junit: 'Junit', + 'Graph Theory': 'Graph Theory', + 'Eclipse (SOFTWARE)': 'Eclipse', + Bigquery: 'BigQuery', + Requirejs: 'Require.js', + Flash: 'Flash', + Github: 'Github', + 'Cascading Style Sheets (CSS)': 'CSS', + 'Web Services': 'Web Services', + Phantomjs: 'Phantomjs', + Heroku: 'Heroku', + Geometry: 'Geometry', + 'Java Message Service (JMS)': 'JMS', + 'Aws Lambda': 'AWS Lambda', + Sass: 'SASS', + 'Artificial Intelligence': 'AI', + Talend: 'Talend', + Quorum: 'Quorum', + Kotlin: 'Kotlin', + 'Google Cloud': 'Google Cloud', + 'Interaction Design': 'Interaction Design (Ixd)', + Sqlite: 'Sqlite', + Postgresql: 'PostgreSQL', + 'User Experience': 'User Experience (Ux)', + Invision: 'InVision', + 'Vert.X': 'Vert.X', + Oauth: 'Oauth', + Smartsheet: 'Smartsheet', + Actionscript: 'ActionScript', + Drools: 'Drools', + 'Apache Kafka': 'Apache Kafka', + 'Perl (PROGRAMMING LANGUAGE)': 'Perl', + Parsing: 'String Parsing', + 'Product Design': 'Product Design', + Openstack: 'Openstack', + 'Android (OPERATING SYSTEM)': 'Android', + 'Google App Engines': 'Google App Engine', + 'Apache Camel': 'Apache Camel', + 'Java (PROGRAMMING LANGUAGE)': 'Java', + 'Application Servers': 'IBM Websphere Application Server', + 'Hypertext Markup Language (HTML)': 'HTML', + 'Sitemaps (XML)': 'XML', + Clojurescript: 'ClojureScript', + Blockchain: 'Blockchain', + Cartodb: 'CartoDB', + 'Oracle Databases': 'Oracle Database', + 'Ibm Lotus Domino': 'IBM Lotus Domino', + Indexeddb: 'IndexedDB', + 'Data Science': 'Data Science', + 'Ajax (PROGRAMMING LANGUAGE)': 'Ajax', + Twilio: 'Twilio', + Selenium: 'Selenium', + Trello: 'trello', + Appium: 'Appium', + Jruby: 'Jruby', + 'Ibm Db2': 'IBM DB2', + Branding: 'Branding', + '3D Reconstruction': '3D Reconstruction', + 'Ibm Aix': 'IBM AiX', + 'Active Directory': 'Active Directory' + }, + unMatchedSkills: [ + 'EJB', + 'Database', + 'Winforms', + 'Photoshop', + '.NET', + 'Leaflet.js', + 'Databasedotcom', + 'Maven', + 'Gaming', + 'Go', + 'Mobile', + 'IBM WebSphere DataStage', + 'Azure', + 'Om', + 'Lightning', + 'File', + 'Security', + 'Tableau', + 'Ibatis/Mybatis', + 'Integrator', + 'HAML', + 'SFDC Apex', + 'Responsive Design', + 'Castor', + 'Npm', + 'ipfs', + '.NET System.Addins', + 'TIBCO', + 'Boomi', + 'InDesign', + 'EC2', + 'Concept Design', + 'nodewebkit', + 'S3', + 'Mozilla', + 'sympfony', + 'Website Design', + 'Chatter', + 'Calabash', + 'Sinatra', + 'Algorithm', + 'OSx', + 'Open Source', + 'Frontend', + 'XAML', + 'VB', + 'Winforms Controls', + 'User Testing', + 'SFDC Lightening Components', + 'Forms', + 'Contentful', + 'bower', + 'Use Case Diagrams (TcUML)', + 'BizTalk', + 'Infographic', + 'Gulp', + 'Xcode', + 'Word/Rich Text', + 'Spring', + 'RMI', + 'OmniGraffle', + 'Linq', + 'Swift', + 'MESH01', + 'MSMQ', + 'yii', + 'IBM Rational Application Developer', + 'Illustrator', + 'QlikView', + 'MIDP 2.0', + 'Beanstalk', + 'JPA', + 'SWT', + 'Simulation', + 'Brute Force', + 'IBM Pl/1', + 'Cumulocity', + 'Windows', + 'IBM Cognitive', + 'Validation', + 'IDOL OnDemand', + 'Wpf', + 'Hadoop', + 'Search', + 'Actian Database', + 'Simple Math', + 'Box', + 'CSS3', + 'LoadRunner', + 'Sharepoint 3.0', + 'IBM COGNOS', + 'Dc.js', + 'Pl/Sql', + 'Cisco', + 'Web methods', + 'Aris', + 'Remoting', + 'Apex', + 'VB.NET', + 'PowerShell', + 'Q & Bluebird', + 'Microsoft Exchange', + 'Swagger', + 'Regex', + 'UML', + 'JSF', + 'WCF', + 'Zepto.js', + 'Flight.js', + 'Apache Flume', + 'IBM Cloud Private', + 'Activity Diagrams (Tcuml)', + 'Servlet', + 'Cocoa', + 'Greedy', + 'IBM Rational Team Concert', + 'DocuSign', + 'VBA', + 'AngularJS', + 'Mobile Design', + 'Actian Data', + 'doctrine', + 'JSP', + 'foundation', + 'Axure', + 'Knockout', + 'F#', + 'IBM Watson', + 'Excel', + 'Sockets', + 'Siebel', + 'QA', + 'UITableView', + 'Dynamodb', + 'Solidity', + 'Logo', + 'travis', + 'Visual-Studio', + 'Espruino', + 'REST', + 'Hashgraph', + 'tvOS', + 'atom', + 'Titanium', + 'Shell', + 'Tosca', + 'Ldap', + 'kraken.js', + 'Performance', + 'JDBC', + 'D3.JS', + 'Couchbase', + 'CloudFactor', + 'HTTP', + 'ADO.NET', + 'Dojo', + 'Applet', + 'Spark', + 'AWS', + 'Mainframe', + 'Facebook', + 'jetbrains', + 'Flex', + 'Ant', + 'SFDC Mobile', + 'HPE Haven OnDemand', + 'Oracle', + 'JavaBean', + 'Salesforce', + 'Struts', + 'Function', + 'Class', + 'IBM Lotus Notes', + 'SCSS', + 'Brivo Labs', + 'SAP', + 'Multichain', + 'List', + 'Express', + 'gulp', + 'JMeter', + 'Math', + 'Image', + 'Commerce Server 2009', + 'IBM Design', + 'Print', + 'Advanced Math', + 'SFDC REST APIs', + 'String Manipulation', + 'chrome', + 'String', + 'SFDC Design', + 'CA', + 'Oracle EBS', + 'Golang', + 'Simple Search', + 'Pega', + 'Cognitive', + 'redhat', + 'Marvel - Design' + ] +} diff --git a/scripts/emsi-mapping/index.js b/scripts/emsi-mapping/index.js new file mode 100644 index 00000000..58245fd4 --- /dev/null +++ b/scripts/emsi-mapping/index.js @@ -0,0 +1,52 @@ +/** + * mapping emsi skills to topcoder skills + */ + +const fs = require('fs') +const path = require('path') +const logger = require('../../src/common/logger') +const helper = require('../../src/common/helper') + +async function mappingSkill () { + const matchedSkills = {} + const unMatchedSkills = [] + const failedSkills = [] + let tcSkills + const startTime = Date.now() + try { + tcSkills = await helper.getAllTopcoderSkills() + } catch (e) { + logger.error({ component: 'getAllTopcoderSkills', context: 'emsi-mapping', message: JSON.stringify(e) }) + } + + for (let i = 0; i < tcSkills.length; i++) { + const tcSkill = tcSkills[i] + let emsiTags + try { + emsiTags = await helper.getTags(tcSkill.name) + } catch (e) { + failedSkills.push(tcSkill.name) + logger.error({ component: 'getTags', context: 'emsi-mapping', message: JSON.stringify(e) }) + } + if (emsiTags.length) { + matchedSkills[emsiTags[0].tag] = tcSkill.name + } else { + unMatchedSkills.push(tcSkill.name) + } + } + + const textString = `module.exports = { matchedSkills: ${JSON.stringify(matchedSkills, 2, 3)}, unMatchedSkills: ${JSON.stringify(unMatchedSkills, 2, 2)} }` + const filePath = path.join(__dirname, 'emsi-skills-mapping.js') + const result = { + totalTime: (Date.now() - startTime) / 60 / 1000 + ' min', + totalSkills: tcSkills.length, + matchedSkills: tcSkills.length - unMatchedSkills.length, + unMatchedSkills: unMatchedSkills.length, + filePath, + failSkills: failedSkills + } + + logger.info({ component: 'emsi-mapping', context: 'emsi-mapping', message: JSON.stringify(result) }) + fs.writeFileSync(filePath, textString) +} +mappingSkill() diff --git a/src/bootstrap.js b/src/bootstrap.js index 6e088046..a81e5dcc 100644 --- a/src/bootstrap.js +++ b/src/bootstrap.js @@ -15,7 +15,7 @@ Joi.rateType = () => Joi.string().valid('hourly', 'daily', 'weekly', 'monthly', Joi.jobStatus = () => Joi.string().valid('sourcing', 'in-review', 'assigned', 'closed', 'cancelled') Joi.resourceBookingStatus = () => Joi.string().valid('placed', 'closed', 'cancelled') Joi.workload = () => Joi.string().valid('full-time', 'fractional') -Joi.jobCandidateStatus = () => Joi.string().valid('open', 'placed', 'selected', 'client rejected - screening', 'client rejected - interview', 'rejected - other', 'cancelled', 'interview', 'topcoder-rejected', 'applied', 'rejected-pre-screen', 'skills-test', 'skills-test', 'phone-screen', 'job-closed', 'offered') +Joi.jobCandidateStatus = () => Joi.string().valid('open', 'placed', 'selected', 'client rejected - screening', 'client rejected - interview', 'rejected - other', 'cancelled', 'interview', 'topcoder-rejected', 'applied', 'rejected-pre-screen', 'skills-test', 'skills-test', 'phone-screen', 'job-closed', 'offered', 'withdrawn', 'withdrawn-prescreen') Joi.title = () => Joi.string().max(128) Joi.paymentStatus = () => Joi.string().valid(..._.values(AggregatePaymentStatus)) Joi.xaiTemplate = () => Joi.string().valid(...allowedXAITemplate) diff --git a/src/common/helper.js b/src/common/helper.js index 851f6907..7f9625be 100644 --- a/src/common/helper.js +++ b/src/common/helper.js @@ -177,7 +177,7 @@ esIndexPropertyMapping[config.get('esConfig.ES_INDEX_RESOURCE_BOOKING')] = { memberRate: { type: 'float' }, customerRate: { type: 'float' }, rateType: { type: 'keyword' }, - billingAccountId: { type: 'integer' }, + billingAccountId: { type: 'integer', null_value: 0 }, workPeriods: { type: 'nested', properties: { @@ -249,6 +249,9 @@ esIndexPropertyMapping[config.get('esConfig.ES_INDEX_ROLE')] = { global: { type: 'integer' }, inCountry: { type: 'integer' }, offShore: { type: 'integer' }, + niche: { type: 'integer' }, + rate20niche: { type: 'integer' }, + rate30niche: { type: 'integer' }, rate30Global: { type: 'integer' }, rate30InCountry: { type: 'integer' }, rate30OffShore: { type: 'integer' }, diff --git a/src/controllers/JobCandidateController.js b/src/controllers/JobCandidateController.js index 4f81c7ec..a6a31fbd 100644 --- a/src/controllers/JobCandidateController.js +++ b/src/controllers/JobCandidateController.js @@ -2,6 +2,7 @@ * Controller for JobCandidate endpoints */ const HttpStatus = require('http-status-codes') +const _ = require('lodash') const service = require('../services/JobCandidateService') const helper = require('../common/helper') @@ -57,7 +58,8 @@ async function deleteJobCandidate (req, res) { * @param res the response */ async function searchJobCandidates (req, res) { - const result = await service.searchJobCandidates(req.authUser, req.query) + const query = { ...req.query, statuses: _.get(req, 'body.statuses', []) } + const result = await service.searchJobCandidates(req.authUser, query) helper.setResHeaders(req, res, result) res.send(result.result) } diff --git a/src/controllers/ResourceBookingController.js b/src/controllers/ResourceBookingController.js index f8d3d566..24fafcd7 100644 --- a/src/controllers/ResourceBookingController.js +++ b/src/controllers/ResourceBookingController.js @@ -2,6 +2,7 @@ * Controller for ResourceBooking endpoints */ const HttpStatus = require('http-status-codes') +const _ = require('lodash') const service = require('../services/ResourceBookingService') const helper = require('../common/helper') @@ -57,7 +58,8 @@ async function deleteResourceBooking (req, res) { * @param res the response */ async function searchResourceBookings (req, res) { - const result = await service.searchResourceBookings(req.authUser, req.query) + const query = { ...req.query, jobIds: _.get(req, 'body.jobIds', []) } + const result = await service.searchResourceBookings(req.authUser, query) helper.setResHeaders(req, res, result) res.send(result.result) } diff --git a/src/eventHandlers/JobCandidateEventHandler.js b/src/eventHandlers/JobCandidateEventHandler.js index 8780ab08..2ad9005b 100644 --- a/src/eventHandlers/JobCandidateEventHandler.js +++ b/src/eventHandlers/JobCandidateEventHandler.js @@ -1,11 +1,14 @@ /* * Handle events for JobCandidate. */ - +const { Op } = require('sequelize') +const _ = require('lodash') +const config = require('config') const models = require('../models') const logger = require('../common/logger') const helper = require('../common/helper') const JobService = require('../services/JobService') +const JobCandidateService = require('../services/JobCandidateService') /** * Once we create at least one JobCandidate for a Job, the Job status should be changed to in-review. @@ -44,6 +47,84 @@ async function inReviewJob (payload) { } } +/** + * Actual Update Job Candidates + * + * @param {*} statuses the source status we'll update + * @param {*} userId the userID + */ +async function updateJobCandidates (statuses, userId) { + logger.info({ + component: 'JobCandidateEventHandler', + context: 'updateJobCandidates', + message: `Update jobCandidates for user ${userId}` + }) + const filter = { [Op.and]: [] } + filter[Op.and].push({ status: statuses }) + filter[Op.and].push({ userId: userId }) + const candidates = await models.JobCandidate.findAll({ + where: filter + }) + if (candidates && candidates.length > 0) { + _.each(candidates, async (candidate) => { + logger.info({ + component: 'JobCandidateEventHandler', + context: 'updateJobCandidates', + message: `Begin update id: ${candidate.id}' candidate with ${candidate.status} status into ${config.WITHDRAWN_STATUS_CHANGE_MAPPING[candidate.status]} for userId: ${userId}` + }) + await JobCandidateService.partiallyUpdateJobCandidate( + helper.getAuditM2Muser(), + candidate.id, + { status: config.WITHDRAWN_STATUS_CHANGE_MAPPING[candidate.status] } + ).then(result => { + logger.info({ + component: 'JobCandidateEventHandler', + context: 'updateJobCandidates', + message: `Finish update id: ${result.id}' candidate into ${result.status} status for userId: ${userId}` + }) + }) + }) + } else { + logger.info({ + component: 'JobCandidateEventHandler', + context: 'updateJobCandidates', + message: `There are not jobCandidates for user ${userId} that required to be updated.` + }) + } +} + +/** + * Update Job Candidates based on business rules + * + * @param {*} payload the updated jobCandidate info + */ +async function withDrawnJobCandidates (payload) { + const jobCandidate = payload.value + if (jobCandidate.status === 'placed') { + const job = await models.Job.findById(payload.value.jobId) + if (job.hoursPerWeek > config.JOBS_HOUR_PER_WEEK) { + // find all these user's open job Candidate and mark the status as withdrawn or withdrawn-prescreen + logger.info({ + component: 'JobCandidateEventHandler', + context: 'withDrawnJobCandidates', + message: `Begin update jobCandidates as ${payload.value.id} candidate's new gig is requiring more than 20 hrs per week` + }) + await updateJobCandidates(['applied', 'skills-test', 'phone-screen', 'open', 'interview', 'selected', 'offered'], payload.value.userId) + logger.info({ + component: 'JobCandidateEventHandler', + context: 'withDrawnJobCandidates', + message: `Finish update jobCandidates as ${payload.value.id} candidate` + }) + } else { + logger.debug({ + component: 'JobCandidateEventHandler', + context: 'withDrawnJobCandidates', + message: `id: ${payload.value.id} candidate is not placing on a gig requiring 20 hrs per week` + }) + } + } +} + /** * Process job candidate create event. * @@ -52,6 +133,9 @@ async function inReviewJob (payload) { */ async function processCreate (payload) { await inReviewJob(payload) + if (payload.value.status === 'placed') { + await withDrawnJobCandidates(payload) + } } /** @@ -62,6 +146,9 @@ async function processCreate (payload) { */ async function processUpdate (payload) { await inReviewJob(payload) + if (payload.value.status === 'placed' && payload.options.oldValue.status !== 'placed') { + await withDrawnJobCandidates(payload) + } } module.exports = { diff --git a/src/models/Role.js b/src/models/Role.js index ab8b4670..587fec38 100644 --- a/src/models/Role.js +++ b/src/models/Role.js @@ -55,18 +55,27 @@ module.exports = (sequelize) => { type: Sequelize.ARRAY({ type: Sequelize.JSONB({ global: { - type: Sequelize.SMALLINT, - allowNull: false + type: Sequelize.SMALLINT }, inCountry: { field: 'in_country', - type: Sequelize.SMALLINT, - allowNull: false + type: Sequelize.SMALLINT }, offShore: { field: 'off_shore', - type: Sequelize.SMALLINT, - allowNull: false + type: Sequelize.SMALLINT + }, + niche: { + field: 'niche', + type: Sequelize.SMALLINT + }, + rate20Niche: { + field: 'rate20_niche', + type: Sequelize.SMALLINT + }, + rate30Niche: { + field: 'rate30_niche', + type: Sequelize.SMALLINT }, rate30Global: { field: 'rate30_global', diff --git a/src/models/RoleSearchRequest.js b/src/models/RoleSearchRequest.js index c79ae84f..2e8c189f 100644 --- a/src/models/RoleSearchRequest.js +++ b/src/models/RoleSearchRequest.js @@ -55,7 +55,7 @@ module.exports = (sequelize) => { }, jobDescription: { field: 'job_description', - type: Sequelize.STRING() + type: Sequelize.STRING(100000) }, skills: { type: Sequelize.ARRAY({ diff --git a/src/services/JobCandidateService.js b/src/services/JobCandidateService.js index a46917aa..8bdd2b60 100644 --- a/src/services/JobCandidateService.js +++ b/src/services/JobCandidateService.js @@ -144,6 +144,7 @@ createJobCandidate.schema = Joi.object().keys({ */ async function updateJobCandidate (currentUser, id, data) { const jobCandidate = await JobCandidate.findById(id) + const oldValue = jobCandidate.toJSON() const userId = await helper.getUserId(currentUser.userId) // check user permission @@ -155,7 +156,7 @@ async function updateJobCandidate (currentUser, id, data) { data.updatedBy = userId const updated = await jobCandidate.update(data) - await helper.postEvent(config.TAAS_JOB_CANDIDATE_UPDATE_TOPIC, updated.toJSON()) + await helper.postEvent(config.TAAS_JOB_CANDIDATE_UPDATE_TOPIC, updated.toJSON(), { oldValue: oldValue }) const result = _.assign(jobCandidate.dataValues, data) return result } @@ -283,6 +284,15 @@ async function searchJobCandidates (currentUser, criteria) { } }) }) + + // if criteria contains statuses, filter statuses with this value + if (criteria.statuses && criteria.statuses.length > 0) { + esQuery.body.query.bool.filter.push({ + terms: { + status: criteria.statuses + } + }) + } logger.debug({ component: 'JobCandidateService', context: 'searchJobCandidates', message: `Query: ${JSON.stringify(esQuery)}` }) const { body } = await esClient.search(esQuery) @@ -301,10 +311,13 @@ async function searchJobCandidates (currentUser, criteria) { logger.logFullError(err, { component: 'JobCandidateService', context: 'searchJobCandidates' }) } logger.info({ component: 'JobCandidateService', context: 'searchJobCandidates', message: 'fallback to DB query' }) - const filter = {} + const filter = { [Op.and]: [] } _.each(_.pick(criteria, ['jobId', 'userId', 'status', 'externalId']), (value, key) => { filter[Op.and].push({ [key]: value }) }) + if (criteria.statuses && criteria.statuses.length > 0) { + filter[Op.and].push({ status: criteria.statuses }) + } // include interviews if user has permission const include = [] @@ -340,6 +353,7 @@ searchJobCandidates.schema = Joi.object().keys({ jobId: Joi.string().uuid(), userId: Joi.string().uuid(), status: Joi.jobCandidateStatus(), + statuses: Joi.array().items(Joi.jobCandidateStatus()), externalId: Joi.string() }).required() }).required() diff --git a/src/services/ResourceBookingService.js b/src/services/ResourceBookingService.js index cabadca0..46d2fe62 100644 --- a/src/services/ResourceBookingService.js +++ b/src/services/ResourceBookingService.js @@ -109,7 +109,7 @@ function _checkCriteriaAndGetFields (currentUser, criteria) { // Check if any WorkPeriodPayment field will be returned result.withWorkPeriodPayments = result.allWorkPeriodPayments || result.fieldsWPP.length > 0 // Extract the filters from criteria parameter - let filters = _.filter(Object.keys(criteria), key => _.indexOf(['fromDb', 'fields', 'page', 'perPage', 'sortBy', 'sortOrder'], key) === -1) + let filters = _.filter(Object.keys(criteria), key => _.indexOf(['fromDb', 'fields', 'page', 'perPage', 'sortBy', 'sortOrder', 'jobIds', 'workPeriods.isFirstWeek', 'workPeriods.isLastWeek'], key) === -1) filters = _.map(filters, f => { if (f === 'projectIds') { return 'projectId' @@ -556,7 +556,8 @@ async function searchResourceBookings (currentUser, criteria, options) { body: { query: { bool: { - must: [] + must: [], + filter: [] } }, from: (page - 1) * perPage, @@ -589,7 +590,7 @@ async function searchResourceBookings (currentUser, criteria, options) { } esQuery.body.sort.push(sort) // Apply ResourceBooking filters - _.each(_.pick(criteria, ['status', 'startDate', 'endDate', 'rateType', 'projectId', 'jobId', 'userId']), (value, key) => { + _.each(_.pick(criteria, ['status', 'startDate', 'endDate', 'rateType', 'projectId', 'jobId', 'userId', 'billingAccountId']), (value, key) => { esQuery.body.query.bool.must.push({ term: { [key]: { @@ -600,11 +601,29 @@ async function searchResourceBookings (currentUser, criteria, options) { }) // if criteria contains projectIds, filter projectId with this value if (criteria.projectIds) { - esQuery.body.query.bool.filter = [{ + esQuery.body.query.bool.filter.push({ terms: { projectId: criteria.projectIds } - }] + }) + } + // if criteria contains jobIds, filter jobIds with this value + if (criteria.jobIds && criteria.jobIds.length > 0) { + esQuery.body.query.bool.filter.push({ + terms: { + jobId: criteria.jobIds + } + }) + } + if (criteria['workPeriods.isFirstWeek']) { + esQuery.body.query.bool.must.push({ + range: { startDate: { gte: criteria['workPeriods.startDate'] } } + }) + } + if (criteria['workPeriods.isLastWeek']) { + esQuery.body.query.bool.must.push({ + range: { endDate: { lte: moment(criteria['workPeriods.startDate']).add(6, 'day').format('YYYY-MM-DD') } } + }) } // Apply WorkPeriod and WorkPeriodPayment filters const workPeriodFilters = _.pick(criteria, ['workPeriods.paymentStatus', 'workPeriods.startDate', 'workPeriods.endDate', 'workPeriods.userHandle']) @@ -707,9 +726,21 @@ async function searchResourceBookings (currentUser, criteria, options) { _.each(_.pick(criteria, ['status', 'startDate', 'endDate', 'rateType', 'projectId', 'jobId', 'userId']), (value, key) => { filter[Op.and].push({ [key]: value }) }) + if (!_.isUndefined(criteria.billingAccountId)) { + filter[Op.and].push({ billingAccountId: criteria.billingAccountId === 0 ? null : criteria.billingAccountId }) + } if (criteria.projectIds) { filter[Op.and].push({ projectId: criteria.projectIds }) } + if (criteria.jobIds && criteria.jobIds.length > 0) { + filter[Op.and].push({ id: criteria.jobIds }) + } + if (criteria['workPeriods.isFirstWeek']) { + filter[Op.and].push({ startDate: { [Op.gte]: criteria['workPeriods.startDate'] } }) + } + if (criteria['workPeriods.isLastWeek']) { + filter[Op.and].push({ endDate: { [Op.lte]: moment(criteria['workPeriods.startDate']).add(6, 'day').format('YYYY-MM-DD') } }) + } const queryCriteria = { where: filter, offset: ((page - 1) * perPage), @@ -831,19 +862,49 @@ searchResourceBookings.schema = Joi.object().keys({ endDate: Joi.date().format('YYYY-MM-DD'), rateType: Joi.rateType(), jobId: Joi.string().uuid(), + jobIds: Joi.array().items(Joi.string().uuid()), userId: Joi.string().uuid(), projectId: Joi.number().integer(), projectIds: Joi.alternatives( Joi.string(), Joi.array().items(Joi.number().integer()) ), + billingAccountId: Joi.number().integer(), 'workPeriods.paymentStatus': Joi.alternatives( Joi.string(), Joi.array().items(Joi.paymentStatus()) ), - 'workPeriods.startDate': Joi.date().format('YYYY-MM-DD'), - 'workPeriods.endDate': Joi.date().format('YYYY-MM-DD'), + 'workPeriods.startDate': Joi.date().format('YYYY-MM-DD').custom((value, helpers) => { + const date = new Date(value) + const weekDay = date.getDay() + if (weekDay !== 0) { + return helpers.message('workPeriods.startDate should be always Sunday') + } + return value + }), + 'workPeriods.endDate': Joi.date().format('YYYY-MM-DD').custom((value, helpers) => { + const date = new Date(value) + const weekDay = date.getDay() + if (weekDay !== 6) { + return helpers.message('workPeriods.endDate should be always Saturday') + } + return value + }), 'workPeriods.userHandle': Joi.string(), + 'workPeriods.isFirstWeek': Joi.when(Joi.ref('workPeriods.startDate', { separator: false }), { + is: Joi.exist(), + then: Joi.boolean().default(false), + otherwise: Joi.boolean().valid(false).messages({ + 'any.only': 'Cannot filter by "isFirstWeek" without "startDate"' + }) + }), + 'workPeriods.isLastWeek': Joi.boolean().when(Joi.ref('workPeriods.startDate', { separator: false }), { + is: Joi.exist(), + then: Joi.boolean().default(false), + otherwise: Joi.boolean().valid(false).messages({ + 'any.only': 'Cannot filter by "isLastWeek" without "startDate"' + }) + }), 'workPeriods.payments.status': Joi.workPeriodPaymentStatus(), 'workPeriods.payments.days': Joi.number().integer().min(0).max(5) }).required(), diff --git a/src/services/RoleService.js b/src/services/RoleService.js index 765d0f5c..7ff7de65 100644 --- a/src/services/RoleService.js +++ b/src/services/RoleService.js @@ -131,12 +131,15 @@ createRole.schema = Joi.object().keys({ description: Joi.string().max(1000), listOfSkills: Joi.array().items(Joi.string().max(50).required()), rates: Joi.array().items(Joi.object().keys({ - global: Joi.smallint().required(), - inCountry: Joi.smallint().required(), - offShore: Joi.smallint().required(), + global: Joi.smallint(), + inCountry: Joi.smallint(), + offShore: Joi.smallint(), + niche: Joi.smallint(), + rate30Niche: Joi.smallint(), rate30Global: Joi.smallint(), rate30InCountry: Joi.smallint(), rate30OffShore: Joi.smallint(), + rate20Niche: Joi.smallint(), rate20Global: Joi.smallint(), rate20InCountry: Joi.smallint(), rate20OffShore: Joi.smallint() @@ -189,10 +192,13 @@ updateRole.schema = Joi.object().keys({ global: Joi.smallint().required(), inCountry: Joi.smallint().required(), offShore: Joi.smallint().required(), + niche: Joi.smallint(), + rate30Niche: Joi.smallint(), rate30Global: Joi.smallint(), rate30InCountry: Joi.smallint(), rate30OffShore: Joi.smallint(), rate20Global: Joi.smallint(), + rate20Niche: Joi.smallint(), rate20InCountry: Joi.smallint(), rate20OffShore: Joi.smallint() }).required()), diff --git a/src/services/TeamService.js b/src/services/TeamService.js index ea1a5767..2ffd9c2d 100644 --- a/src/services/TeamService.js +++ b/src/services/TeamService.js @@ -17,9 +17,9 @@ const { Op } = require('sequelize') const models = require('../models') const stopWords = require('../../data/stopWords.json') const { getAuditM2Muser } = require('../common/helper') +const { matchedSkills, unMatchedSkills } = require('../../scripts/emsi-mapping/esmi-skills-mapping') const Role = models.Role const RoleSearchRequest = models.RoleSearchRequest -const topcoderSkills = {} const emailTemplates = _.mapValues(emailTemplateConfig, (template) => { return { @@ -64,29 +64,19 @@ async function _getJobsByProjectIds (currentUser, projectIds) { } /** - * Gets topcoder skills and stores their name and compiled - * regex patters according to Levenshtein distance <=1 + * compiled regex patters according to Levenshtein distance <=1 for unmatched skills from EMSI + * @returns {Array} the unMatched skills with regex pattern */ -async function _reloadCachedTopcoderSkills () { - // do not reload if cache time is not expired - if (!_.isUndefined(topcoderSkills.time)) { - const cacheTime = config.TOPCODER_SKILLS_CACHE_TIME * 60 * 1000 - if (new Date().getTime() - topcoderSkills.time < cacheTime) { - return - } - } - // collect all skills - const skills = await helper.getAllTopcoderSkills() - // set the last cached time - topcoderSkills.time = new Date().getTime() - topcoderSkills.skills = [] +function compileRegexPatternForNoEmsiSkills () { + const unMatched = [] // store skill names and compiled regex paterns - _.each(skills, skill => { - topcoderSkills.skills.push({ - name: skill.name, - pattern: _compileRegexPatternForSkillName(skill.name) + _.each(unMatchedSkills, skill => { + unMatched.push({ + name: skill.toLowerCase(), + pattern: _compileRegexPatternForSkillName(skill.toLowerCase()) }) }) + return unMatched } /** @@ -792,7 +782,7 @@ roleSearchRequest.schema = Joi.object() currentUser: Joi.object(), data: Joi.object().keys({ roleId: Joi.string().uuid(), - jobDescription: Joi.string().max(2000), + jobDescription: Joi.string().max(100000), skills: Joi.array().items(Joi.string().uuid().required()), jobTitle: Joi.string().max(100), previousRoleSearchRequestId: Joi.string().uuid() @@ -835,18 +825,17 @@ getRoleBySkills.schema = Joi.object() }).required() /** - * Return skills by job description. + * Return skills by job description from EMSI. * * @param {Object} currentUser the user who perform this operation. * @param {Object} data the search criteria * @returns {Object} the result */ async function getSkillsByJobDescription (data) { - // load topcoder skills if needed. Using cached skills helps to avoid - // unnecessary api calls which is extremely time comsuming. - await _reloadCachedTopcoderSkills() // replace markdown tags with spaces const description = helper.removeTextFormatting(data.description) + // get skill from emsi + const emsiTags = await helper.getTags(description) // extract words from description let words = _.split(description, ' ') // remove stopwords from description @@ -858,11 +847,20 @@ async function getSkillsByJobDescription (data) { } words = _.concat(words, twoWords) let foundSkills = [] + // add emsi parsed skills + _.each(emsiTags, (t) => { + if (matchedSkills[t.tag]) { + foundSkills.push(matchedSkills[t.tag]) + } + }) + + // unmatctched skill + const unMatchedTopcoderSkills = compileRegexPatternForNoEmsiSkills() const result = [] // try to match each word with skill names // using pre-compiled regex pattern _.each(words, word => { - _.each(topcoderSkills.skills, skill => { + _.each(unMatchedTopcoderSkills, skill => { // do not stop searching after a match in order to detect more lookalikes if (skill.pattern.test(word)) { foundSkills.push(skill.name) @@ -980,7 +978,7 @@ createRoleSearchRequest.schema = Joi.object() currentUser: Joi.object().required(), roleSearchRequest: Joi.object().keys({ roleId: Joi.string().uuid(), - jobDescription: Joi.string().max(2000), + jobDescription: Joi.string().max(100000), skills: Joi.array().items(Joi.string().uuid().required()) }).required().min(1) }).required() @@ -998,7 +996,7 @@ async function _cleanRoleDTO (currentUser, role) { role.isExternalMember = true if (role.rates) { role.rates = _.map(role.rates, rate => - _.omit(rate, ['inCountry', 'offShore', 'rate30InCountry', 'rate30OffShore', 'rate20InCountry', 'rate20OffShore'])) + _.omit(rate, ['inCountry', 'offShore', 'niche', 'rate30InCountry', 'rate30OffShore', 'rate30Niche', 'rate20InCountry', 'rate20OffShore', 'rate20Niche'])) } return role } @@ -1036,7 +1034,10 @@ async function createTeam (currentUser, data) { description: data.teamDescription, type: 'talent-as-a-service', details: { - positions: data.positions + positions: data.positions, + utm: { + code: data.refCode + } } } // create project with given data @@ -1072,6 +1073,7 @@ createTeam.schema = Joi.object() data: Joi.object().keys({ teamName: Joi.string().required(), teamDescription: Joi.string(), + refCode: Joi.string(), positions: Joi.array().items( Joi.object().keys({ roleName: Joi.string().required(), diff --git a/src/services/WorkPeriodPaymentService.js b/src/services/WorkPeriodPaymentService.js index 72b94d44..9a7a7780 100644 --- a/src/services/WorkPeriodPaymentService.js +++ b/src/services/WorkPeriodPaymentService.js @@ -75,6 +75,10 @@ async function _createSingleWorkPeriodPaymentWithWorkPeriodAndResourceBooking (w if (maxPossibleDays <= 0) { throw new errors.ConflictError(`There are no days to pay for WorkPeriod: ${correspondingWorkPeriod.id}`) } + const workPeriodStartTime = moment(`${correspondingWorkPeriod.startDate}T00:00:00.000+12`) + if (workPeriodStartTime.isAfter(moment())) { + throw new errors.BadRequestError(`Cannot process payments for the future WorkPeriods. You can process after ${workPeriodStartTime.diff(moment(), 'hours')} hours`) + } workPeriodPayment.days = _.defaultTo(workPeriodPayment.days, maxPossibleDays) workPeriodPayment.amount = _.round(workPeriodPayment.memberRate * workPeriodPayment.days / 5, 2) workPeriodPayment.customerRate = _.defaultTo(correspondingResourceBooking.customerRate, null) diff --git a/test/unit/common/ResourceBookingData.js b/test/unit/common/ResourceBookingData.js index 1f9535a1..0dd6aa74 100644 --- a/test/unit/common/ResourceBookingData.js +++ b/test/unit/common/ResourceBookingData.js @@ -1500,7 +1500,7 @@ const T30 = { } } const T31 = { - criteria: { 'workPeriods.startDate': '2021-05-10' }, + criteria: { 'workPeriods.startDate': '2021-05-09' }, error: { httpStatus: 400, message: 'Can not filter or sort by some field which is not included in fields'