diff --git a/app-constants.js b/app-constants.js index 78bab1f3..67bc5a1a 100644 --- a/app-constants.js +++ b/app-constants.js @@ -5,7 +5,8 @@ const UserRoles = { BookingManager: 'bookingmanager', Administrator: 'administrator', - ConnectManager: 'Connect Manager' + ConnectManager: 'Connect Manager', + TopcoderUser: 'Topcoder User' } const FullManagePermissionRoles = [ diff --git a/config/email_template.config.js b/config/email_template.config.js index 06ea4fb0..a61cd355 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 54bd1823..df1032a5 100644 --- a/data/notifications-email-template.html +++ b/data/notifications-email-template.html @@ -240,6 +240,13 @@ {{/each}} {{/if}} + + {{#if notificationType.jobCandidateResumeViewed}} + Hi {{jobCandidateUserHandle}}.

+ + Your resume for the job "{{jobName}}" has been viewed by the client.
+ {{/if}} + {{#if notificationType.newTeamCreated}} Team: {{teamName}} diff --git a/docs/Topcoder-bookings-api.postman_collection.json b/docs/Topcoder-bookings-api.postman_collection.json index e20d62bf..9cfa9240 100644 --- a/docs/Topcoder-bookings-api.postman_collection.json +++ b/docs/Topcoder-bookings-api.postman_collection.json @@ -1,6 +1,6 @@ { "info": { - "_postman_id": "99905d78-e7c8-42ee-8aca-2c7fe3f8df4a", + "_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 c0743098..b0a9bd21 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' }, diff --git a/src/controllers/JobCandidateController.js b/src/controllers/JobCandidateController.js index a6a31fbd..844c9eab 100644 --- a/src/controllers/JobCandidateController.js +++ b/src/controllers/JobCandidateController.js @@ -64,11 +64,22 @@ async function searchJobCandidates (req, res) { res.send(result.result) } +/** + * Download jobCandidate resume + * @param req the request + * @param res the response + */ +async function downloadJobCandidateResume (req, res) { + const resumeUrl = await service.downloadJobCandidateResume(req.authUser, req.params.id) + res.redirect(resumeUrl) +} + module.exports = { getJobCandidate, createJobCandidate, partiallyUpdateJobCandidate, fullyUpdateJobCandidate, deleteJobCandidate, - searchJobCandidates + searchJobCandidates, + downloadJobCandidateResume } 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..197a7bcd 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: 'downloadJobCandidateResume', + 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..af31106b 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, UserRoles } = 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,55 @@ 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 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] === 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 }) + } catch (err) { + logger.logFullError(err, { component: 'JobCandidateService', context: 'downloadJobCandidateResume' }) + } + } + + return jobCandidate.resume +} + +downloadJobCandidateResume.schema = Joi.object().keys({ + currentUser: Joi.object().required(), + id: Joi.string().uuid().required() +}).required() + module.exports = { getJobCandidate, createJobCandidate, partiallyUpdateJobCandidate, fullyUpdateJobCandidate, deleteJobCandidate, - searchJobCandidates + searchJobCandidates, + downloadJobCandidateResume }