From 198d276e316b060c33c05b6235623c28f064867a Mon Sep 17 00:00:00 2001 From: yoution Date: Fri, 13 Aug 2021 08:11:29 +0800 Subject: [PATCH 1/2] fix: issue #475 --- app-constants.js | 6 +- config/email_template.config.js | 7 + data/notifications-email-template.html | 5 + ...coder-bookings-api.postman_collection.json | 270 +++++++++++++++++- docs/swagger.yaml | 58 ++++ ...-candidate-add-viewed-by-customer-field.js | 19 ++ src/common/helper.js | 11 + src/controllers/JobCandidateController.js | 16 +- src/models/JobCandidate.js | 6 + src/routes/JobCandidateRoutes.js | 8 + src/services/EmailNotificationService.js | 1 + src/services/JobCandidateService.js | 47 ++- 12 files changed, 448 insertions(+), 6 deletions(-) create mode 100644 migrations/2021-08-13-job-candidate-add-viewed-by-customer-field.js diff --git a/app-constants.js b/app-constants.js index 78bab1f3..ad64ed56 100644 --- a/app-constants.js +++ b/app-constants.js @@ -5,9 +5,12 @@ const UserRoles = { BookingManager: 'bookingmanager', Administrator: 'administrator', - ConnectManager: 'Connect Manager' + ConnectManager: 'Connect Manager', + TopcoderUser: 'Topcoder User' } +const TopCoderUserPermissionRole = UserRoles.TopcoderUser + const FullManagePermissionRoles = [ UserRoles.BookingManager, UserRoles.Administrator @@ -162,6 +165,7 @@ const JobCandidateStatus = { module.exports = { UserRoles, + TopCoderUserPermissionRole, FullManagePermissionRoles, Scopes, Interviews, diff --git a/config/email_template.config.js b/config/email_template.config.js index c76bd482..1ad16c23 100644 --- a/config/email_template.config.js +++ b/config/email_template.config.js @@ -111,6 +111,13 @@ module.exports = { * List all kind of emails which could be send as Email Notifications by scheduler, API endpoints or anything else. */ notificationEmailTemplates: { + 'taas.notification.job-candidate-resume-viewed': { + subject: 'Topcoder - job candidate resume viewed', + body: '', + recipients: [], + from: config.NOTIFICATION_SENDER_EMAIL, + sendgridTemplateId: config.NOTIFICATION_SENDGRID_TEMPLATE_ID + }, 'taas.notification.candidates-available-for-review': { subject: 'Topcoder - {{teamName}} has job candidates available for review', body: '', diff --git a/data/notifications-email-template.html b/data/notifications-email-template.html index 7dcb8f30..f7172577 100644 --- a/data/notifications-email-template.html +++ b/data/notifications-email-template.html @@ -240,6 +240,11 @@ {{/each}} {{/if}} + {{#if notificationType.jobCandidateResumeViewed}} + Hi {{jobCandidateUserHandle}}.

+ + Your resume for the job "{{jobName}}" has been viewed by the client.
+ {{/if}}
If you have any questions about this process or if you encounter any issues coordinating your availability, you may reply to this email or send a separate email to our Gig Work operations team. diff --git a/docs/Topcoder-bookings-api.postman_collection.json b/docs/Topcoder-bookings-api.postman_collection.json index 47e81133..8e57b5cd 100644 --- a/docs/Topcoder-bookings-api.postman_collection.json +++ b/docs/Topcoder-bookings-api.postman_collection.json @@ -1,6 +1,6 @@ { "info": { - "_postman_id": "709e8fdf-f5f4-4053-a679-b89504637cc8", + "_postman_id": "e3bc87e1-261d-493b-b8f7-09e0e2102c5f", "name": "Topcoder-bookings-api", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" }, @@ -2658,6 +2658,274 @@ }, "response": [] }, + { + "name": "get job candidate candidate resume with booking manager", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "type": "text", + "value": "Bearer {{token_bookingManager}}" + } + ], + "url": { + "raw": "{{URL}}/jobCandidates/{{jobCandidateId}}/resume", + "host": [ + "{{URL}}" + ], + "path": [ + "jobCandidates", + "{{jobCandidateId}}", + "resume" + ], + "query": [ + { + "key": "page", + "value": "1", + "disabled": true + }, + { + "key": "perPage", + "value": "1", + "disabled": true + }, + { + "key": "sortBy", + "value": "id", + "disabled": true + }, + { + "key": "sortOrder", + "value": "asc", + "disabled": true + }, + { + "key": "jobId", + "value": "46225f4c-c2a3-4603-a141-0277e96fabfa", + "disabled": true + }, + { + "key": "userId", + "value": "a55fe1bc-1754-45fa-9adc-cf3d6d7c377a", + "disabled": true + }, + { + "key": "status", + "value": "selected", + "disabled": true + }, + { + "key": "externalId", + "value": "300234321", + "disabled": true + } + ] + } + }, + "response": [] + }, + { + "name": "get job candidate candidate resume with customer user", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "type": "text", + "value": "Bearer {{token_member}}" + } + ], + "url": { + "raw": "{{URL}}/jobCandidates/{{jobCandidateId}}/resume", + "host": [ + "{{URL}}" + ], + "path": [ + "jobCandidates", + "{{jobCandidateId}}", + "resume" + ], + "query": [ + { + "key": "page", + "value": "1", + "disabled": true + }, + { + "key": "perPage", + "value": "1", + "disabled": true + }, + { + "key": "sortBy", + "value": "id", + "disabled": true + }, + { + "key": "sortOrder", + "value": "asc", + "disabled": true + }, + { + "key": "jobId", + "value": "46225f4c-c2a3-4603-a141-0277e96fabfa", + "disabled": true + }, + { + "key": "userId", + "value": "a55fe1bc-1754-45fa-9adc-cf3d6d7c377a", + "disabled": true + }, + { + "key": "status", + "value": "selected", + "disabled": true + }, + { + "key": "externalId", + "value": "300234321", + "disabled": true + } + ] + } + }, + "response": [] + }, + { + "name": "get job candidate candidate resume with connect user", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "type": "text", + "value": "Bearer {{token_connectUser}}" + } + ], + "url": { + "raw": "{{URL}}/jobCandidates/{{jobCandidateId}}/resume", + "host": [ + "{{URL}}" + ], + "path": [ + "jobCandidates", + "{{jobCandidateId}}", + "resume" + ], + "query": [ + { + "key": "page", + "value": "1", + "disabled": true + }, + { + "key": "perPage", + "value": "1", + "disabled": true + }, + { + "key": "sortBy", + "value": "id", + "disabled": true + }, + { + "key": "sortOrder", + "value": "asc", + "disabled": true + }, + { + "key": "jobId", + "value": "46225f4c-c2a3-4603-a141-0277e96fabfa", + "disabled": true + }, + { + "key": "userId", + "value": "a55fe1bc-1754-45fa-9adc-cf3d6d7c377a", + "disabled": true + }, + { + "key": "status", + "value": "selected", + "disabled": true + }, + { + "key": "externalId", + "value": "300234321", + "disabled": true + } + ] + } + }, + "response": [] + }, + { + "name": "get job candidate candidate resume with m2m all", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "type": "text", + "value": "Bearer {{token_m2m_all_job_candidate}}" + } + ], + "url": { + "raw": "{{URL}}/jobCandidates/{{jobCandidateId}}/resume", + "host": [ + "{{URL}}" + ], + "path": [ + "jobCandidates", + "{{jobCandidateId}}", + "resume" + ], + "query": [ + { + "key": "page", + "value": "1", + "disabled": true + }, + { + "key": "perPage", + "value": "1", + "disabled": true + }, + { + "key": "sortBy", + "value": "id", + "disabled": true + }, + { + "key": "sortOrder", + "value": "asc", + "disabled": true + }, + { + "key": "jobId", + "value": "46225f4c-c2a3-4603-a141-0277e96fabfa", + "disabled": true + }, + { + "key": "userId", + "value": "a55fe1bc-1754-45fa-9adc-cf3d6d7c377a", + "disabled": true + }, + { + "key": "status", + "value": "selected", + "disabled": true + }, + { + "key": "externalId", + "value": "300234321", + "disabled": true + } + ] + } + }, + "response": [] + }, { "name": "put job candidate with booking manager", "request": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 6f67d13b..684cc260 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -922,6 +922,61 @@ paths: application/json: schema: $ref: "#/components/schemas/Error" + /jobCandidates/{id}/resume: + get: + tags: + - JobCandidates + description: | + Get job candidate resume by id. + + **Authorization** Topcoder token with read job candidate scope is allowed + security: + - bearerAuth: [] + parameters: + - in: path + name: id + description: The job candidate id. + required: true + schema: + type: string + format: uuid + responses: + "200": + description: OK + content: + application/msword: + schema: + $ref: "#/components/schemas/JobCandidateResume" + "400": + description: Bad request + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "401": + description: Not authenticated + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "403": + description: Forbidden + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "404": + description: Not Found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "500": + description: Internal Server Error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" /jobCandidates/{jobCandidateId}/requestInterview: patch: tags: @@ -4277,6 +4332,9 @@ components: "withdrawn-prescreen", ] description: "The array of job Candidates status" + JobCandidateResume: + type: string + format: binary JobCandidateRequestBody: required: - jobId diff --git a/migrations/2021-08-13-job-candidate-add-viewed-by-customer-field.js b/migrations/2021-08-13-job-candidate-add-viewed-by-customer-field.js new file mode 100644 index 00000000..a9a9a82f --- /dev/null +++ b/migrations/2021-08-13-job-candidate-add-viewed-by-customer-field.js @@ -0,0 +1,19 @@ +const config = require('config') + +/* + * Add viewedByCustomer field to the JobCandidata model. + */ + +module.exports = { + up: async (queryInterface, Sequelize) => { + await queryInterface.addColumn({ tableName: 'job_candidates', schema: config.DB_SCHEMA_NAME }, 'viewed_by_customer', + { + type: Sequelize.BOOLEAN, + defaultValue: false, + allowNull: false + }) + }, + down: async (queryInterface, Sequelize) => { + await queryInterface.removeColumn({ tableName: 'job_candidates', schema: config.DB_SCHEMA_NAME }, 'viewed_by_customer') + } +} diff --git a/src/common/helper.js b/src/common/helper.js index 1fd28f60..848e6ae1 100644 --- a/src/common/helper.js +++ b/src/common/helper.js @@ -131,6 +131,7 @@ esIndexPropertyMapping[config.get('esConfig.ES_INDEX_JOB_CANDIDATE')] = { jobId: { type: 'keyword' }, userId: { type: 'keyword' }, status: { type: 'keyword' }, + viewedByCustomer: { type: 'boolean' }, externalId: { type: 'keyword' }, resume: { type: 'text' }, remark: { type: 'keyword' }, @@ -1072,6 +1073,15 @@ async function getTopcoderUserById (userId) { return user } +/** + * Function to download resume + * @param {String} resumeUrl the resume id + * @returns the request result + */ +async function downloadResume (resumeUrl) { + return request.get(resumeUrl) +} + /** * Function to get users * @param {String} userId the user id @@ -2063,6 +2073,7 @@ module.exports = { } return ensureUbahnUserId({ userId }) }, + downloadResume, getUserByExternalId, getM2MToken, getM2MUbahnToken, diff --git a/src/controllers/JobCandidateController.js b/src/controllers/JobCandidateController.js index a6a31fbd..83342596 100644 --- a/src/controllers/JobCandidateController.js +++ b/src/controllers/JobCandidateController.js @@ -64,11 +64,25 @@ async function searchJobCandidates (req, res) { res.send(result.result) } +/** + * Download jobCandidate resume + * @param req the request + * @param res the response + */ +async function downlaodJobCandidateResume (req, res) { + const { body, res: { headers } } = await service.downlaodJobCandidateResume(req.authUser, req.params.id) + for (const h in headers) { + res.setHeader(h, headers[h]) + } + res.send(body) +} + module.exports = { getJobCandidate, createJobCandidate, partiallyUpdateJobCandidate, fullyUpdateJobCandidate, deleteJobCandidate, - searchJobCandidates + searchJobCandidates, + downlaodJobCandidateResume } diff --git a/src/models/JobCandidate.js b/src/models/JobCandidate.js index fc54c0aa..481359fb 100644 --- a/src/models/JobCandidate.js +++ b/src/models/JobCandidate.js @@ -51,6 +51,12 @@ module.exports = (sequelize) => { type: Sequelize.UUID, allowNull: false }, + viewedByCustomer: { + field: 'viewed_by_customer', + type: Sequelize.BOOLEAN, + defaultValue: false, + allowNull: false + }, status: { type: Sequelize.STRING(255), allowNull: false diff --git a/src/routes/JobCandidateRoutes.js b/src/routes/JobCandidateRoutes.js index 7c1dac24..a6b91d8c 100644 --- a/src/routes/JobCandidateRoutes.js +++ b/src/routes/JobCandidateRoutes.js @@ -43,5 +43,13 @@ module.exports = { auth: 'jwt', scopes: [constants.Scopes.DELETE_JOB_CANDIDATE, constants.Scopes.ALL_JOB_CANDIDATE] } + }, + '/jobCandidates/:id/resume': { + get: { + controller: 'JobCandidateController', + method: 'downlaodJobCandidateResume', + auth: 'jwt', + scopes: [constants.Scopes.READ_JOB_CANDIDATE, constants.Scopes.ALL_JOB_CANDIDATE] + } } } diff --git a/src/services/EmailNotificationService.js b/src/services/EmailNotificationService.js index a561445e..10921668 100644 --- a/src/services/EmailNotificationService.js +++ b/src/services/EmailNotificationService.js @@ -529,6 +529,7 @@ async function sendEmail (currentUser, data) { } module.exports = { + sendEmail, sendCandidatesAvailableEmails, sendInterviewComingUpEmails, sendInterviewCompletedEmails, diff --git a/src/services/JobCandidateService.js b/src/services/JobCandidateService.js index 8bdd2b60..3c92570a 100644 --- a/src/services/JobCandidateService.js +++ b/src/services/JobCandidateService.js @@ -8,13 +8,13 @@ const config = require('config') const HttpStatus = require('http-status-codes') const { Op } = require('sequelize') const { v4: uuid } = require('uuid') -const { Scopes } = require('../../app-constants') +const { Scopes, TopCoderUserPermissionRole } = require('../../app-constants') const helper = require('../common/helper') const logger = require('../common/logger') const errors = require('../common/errors') const models = require('../models') const JobService = require('./JobService') - +const EmailNotificationService = require('./EmailNotificationService') const JobCandidate = models.JobCandidate const esClient = helper.getESClient() @@ -178,6 +178,7 @@ partiallyUpdateJobCandidate.schema = Joi.object().keys({ data: Joi.object().keys({ status: Joi.jobCandidateStatus(), externalId: Joi.string().allow(null), + viewedByCustomer: Joi.boolean().allow(null), resume: Joi.string().uri().allow(null), remark: Joi.stringAllowEmpty().allow(null) }).required() @@ -205,6 +206,7 @@ fullyUpdateJobCandidate.schema = Joi.object() jobId: Joi.string().uuid().required(), userId: Joi.string().uuid().required(), status: Joi.jobCandidateStatus().default('open'), + viewedByCustomer: Joi.boolean().allow(null), externalId: Joi.string().allow(null).default(null), resume: Joi.string().uri().allow('').allow(null).default(null), remark: Joi.stringAllowEmpty().allow(null) @@ -358,11 +360,50 @@ searchJobCandidates.schema = Joi.object().keys({ }).required() }).required() +/** + * Download jobCandidate resume + * @params {Object} currentUser the user who perform this operation + * @params {String} id the jobCandidate id + */ +async function downlaodJobCandidateResume (currentUser, id) { + const jobCandidate = await JobCandidate.findById(id) + const { id: currentUserUserId } = await helper.getUserByExternalId(currentUser.userId) + + // customer role + if (!jobCandidate.viewedByCustomer && currentUserUserId !== jobCandidate.userId && currentUser.roles.length === 1 && currentUser.roles[0] === TopCoderUserPermissionRole) { + const job = await models.Job.findById(jobCandidate.jobId) + const { handle } = await helper.getUserById(jobCandidate.userId, true) + const { email } = await helper.getMemberDetailsByHandle(handle) + + await EmailNotificationService.sendEmail(currentUser, { + template: 'taas.notification.job-candidate-resume-viewed', + recipients: [email], + data: { + jobCandidateUserHandle: handle, + jobName: job.title, + notificationType: { + jobCandidateResumeViewed: true + } + } + }) + + await updateJobCandidate(currentUser, jobCandidate.id, { viewedByCustomer: true }) + } + + return helper.downloadResume(jobCandidate.resume) +} + +downlaodJobCandidateResume.schema = Joi.object().keys({ + currentUser: Joi.object().required(), + id: Joi.string().uuid().required() +}).required() + module.exports = { getJobCandidate, createJobCandidate, partiallyUpdateJobCandidate, fullyUpdateJobCandidate, deleteJobCandidate, - searchJobCandidates + searchJobCandidates, + downlaodJobCandidateResume } From ad4c66aa567bc3582ddc84b6c324327ff85a1587 Mon Sep 17 00:00:00 2001 From: yoution Date: Fri, 13 Aug 2021 21:17:03 +0800 Subject: [PATCH 2/2] fix: issue #475 --- app-constants.js | 3 -- src/common/helper.js | 10 ----- src/controllers/JobCandidateController.js | 11 ++---- src/routes/JobCandidateRoutes.js | 2 +- src/services/JobCandidateService.js | 47 +++++++++++++---------- 5 files changed, 31 insertions(+), 42 deletions(-) diff --git a/app-constants.js b/app-constants.js index ad64ed56..67bc5a1a 100644 --- a/app-constants.js +++ b/app-constants.js @@ -9,8 +9,6 @@ const UserRoles = { TopcoderUser: 'Topcoder User' } -const TopCoderUserPermissionRole = UserRoles.TopcoderUser - const FullManagePermissionRoles = [ UserRoles.BookingManager, UserRoles.Administrator @@ -165,7 +163,6 @@ const JobCandidateStatus = { module.exports = { UserRoles, - TopCoderUserPermissionRole, FullManagePermissionRoles, Scopes, Interviews, diff --git a/src/common/helper.js b/src/common/helper.js index 848e6ae1..53e1f32d 100644 --- a/src/common/helper.js +++ b/src/common/helper.js @@ -1073,15 +1073,6 @@ async function getTopcoderUserById (userId) { return user } -/** - * Function to download resume - * @param {String} resumeUrl the resume id - * @returns the request result - */ -async function downloadResume (resumeUrl) { - return request.get(resumeUrl) -} - /** * Function to get users * @param {String} userId the user id @@ -2073,7 +2064,6 @@ module.exports = { } return ensureUbahnUserId({ userId }) }, - downloadResume, getUserByExternalId, getM2MToken, getM2MUbahnToken, diff --git a/src/controllers/JobCandidateController.js b/src/controllers/JobCandidateController.js index 83342596..844c9eab 100644 --- a/src/controllers/JobCandidateController.js +++ b/src/controllers/JobCandidateController.js @@ -69,12 +69,9 @@ async function searchJobCandidates (req, res) { * @param req the request * @param res the response */ -async function downlaodJobCandidateResume (req, res) { - const { body, res: { headers } } = await service.downlaodJobCandidateResume(req.authUser, req.params.id) - for (const h in headers) { - res.setHeader(h, headers[h]) - } - res.send(body) +async function downloadJobCandidateResume (req, res) { + const resumeUrl = await service.downloadJobCandidateResume(req.authUser, req.params.id) + res.redirect(resumeUrl) } module.exports = { @@ -84,5 +81,5 @@ module.exports = { fullyUpdateJobCandidate, deleteJobCandidate, searchJobCandidates, - downlaodJobCandidateResume + downloadJobCandidateResume } diff --git a/src/routes/JobCandidateRoutes.js b/src/routes/JobCandidateRoutes.js index a6b91d8c..197a7bcd 100644 --- a/src/routes/JobCandidateRoutes.js +++ b/src/routes/JobCandidateRoutes.js @@ -47,7 +47,7 @@ module.exports = { '/jobCandidates/:id/resume': { get: { controller: 'JobCandidateController', - method: 'downlaodJobCandidateResume', + method: 'downloadJobCandidateResume', auth: 'jwt', scopes: [constants.Scopes.READ_JOB_CANDIDATE, constants.Scopes.ALL_JOB_CANDIDATE] } diff --git a/src/services/JobCandidateService.js b/src/services/JobCandidateService.js index 3c92570a..af31106b 100644 --- a/src/services/JobCandidateService.js +++ b/src/services/JobCandidateService.js @@ -8,7 +8,7 @@ const config = require('config') const HttpStatus = require('http-status-codes') const { Op } = require('sequelize') const { v4: uuid } = require('uuid') -const { Scopes, TopCoderUserPermissionRole } = require('../../app-constants') +const { Scopes, UserRoles } = require('../../app-constants') const helper = require('../common/helper') const logger = require('../common/logger') const errors = require('../common/errors') @@ -365,35 +365,40 @@ searchJobCandidates.schema = Joi.object().keys({ * @params {Object} currentUser the user who perform this operation * @params {String} id the jobCandidate id */ -async function downlaodJobCandidateResume (currentUser, id) { +async function downloadJobCandidateResume (currentUser, id) { const jobCandidate = await JobCandidate.findById(id) const { id: currentUserUserId } = await helper.getUserByExternalId(currentUser.userId) // customer role - if (!jobCandidate.viewedByCustomer && currentUserUserId !== jobCandidate.userId && currentUser.roles.length === 1 && currentUser.roles[0] === TopCoderUserPermissionRole) { - const job = await models.Job.findById(jobCandidate.jobId) - const { handle } = await helper.getUserById(jobCandidate.userId, true) - const { email } = await helper.getMemberDetailsByHandle(handle) - - await EmailNotificationService.sendEmail(currentUser, { - template: 'taas.notification.job-candidate-resume-viewed', - recipients: [email], - data: { - jobCandidateUserHandle: handle, - jobName: job.title, - notificationType: { - jobCandidateResumeViewed: true + if (!jobCandidate.viewedByCustomer && currentUserUserId !== jobCandidate.userId && currentUser.roles.length === 1 && currentUser.roles[0] === UserRoles.TopcoderUser) { + try { + const job = await models.Job.findById(jobCandidate.jobId) + const { handle } = await helper.getUserById(jobCandidate.userId, true) + const { email } = await helper.getMemberDetailsByHandle(handle) + + await EmailNotificationService.sendEmail(currentUser, { + template: 'taas.notification.job-candidate-resume-viewed', + recipients: [email], + data: { + jobCandidateUserHandle: handle, + jobName: job.title, + description: 'Client Viewed Resume', + notificationType: { + jobCandidateResumeViewed: true + } } - } - }) + }) - await updateJobCandidate(currentUser, jobCandidate.id, { viewedByCustomer: true }) + await updateJobCandidate(currentUser, jobCandidate.id, { viewedByCustomer: true }) + } catch (err) { + logger.logFullError(err, { component: 'JobCandidateService', context: 'downloadJobCandidateResume' }) + } } - return helper.downloadResume(jobCandidate.resume) + return jobCandidate.resume } -downlaodJobCandidateResume.schema = Joi.object().keys({ +downloadJobCandidateResume.schema = Joi.object().keys({ currentUser: Joi.object().required(), id: Joi.string().uuid().required() }).required() @@ -405,5 +410,5 @@ module.exports = { fullyUpdateJobCandidate, deleteJobCandidate, searchJobCandidates, - downlaodJobCandidateResume + downloadJobCandidateResume }