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
}