From 4999c54bf646154685fde8a92767eb645b48b55a Mon Sep 17 00:00:00 2001 From: Cagdas U Date: Mon, 10 May 2021 08:30:54 +0300 Subject: [PATCH] feat(interview-scheduler): add new endpoints & update the schema * Update Interview schema with new additions & changes. * Add `xaiId` column. * Add `rescheduleUrl` column. * Add `endTimestamp` column. * Add `duration` column. * Add `templateId` column. * Add `templateType` column. * Add `title` column. * Add `locationDetails` column. * Add `hostName` column. * Add `hostEmail` column. * Add `guestNames` column. * Rename column `attendeesList` to `guestEmails` * Rename column `googleCalendarId` to `calendarEventId` * Rename column `xaiTemplate` to `templateUrl` * Remove `customMessage` column * Update the interviews in demo data based on schema changes. * Add `migrations/2021-05-09-interviews-table-migration.js` migration for schema changes. * Add `GET /getInterview/:id` endpoint. * Add `PATCH /updateInterview/:id` endpoint. * Update the POSTMAN collection & environment for new endpoints & schema changes. * Update the Swagger for new endpoints & schema changes. --- data/demo-data.json | 20 +- ...coder-bookings-api.postman_collection.json | 1009 +++++++++++++++-- docs/swagger.yaml | 243 +++- ...topcoder-bookings.postman_environment.json | 46 +- .../2021-05-09-interviews-table-migration.js | 63 + src/common/helper.js | 18 +- src/controllers/InterviewController.js | 28 +- src/eventHandlers/InterviewEventHandler.js | 18 +- src/models/Interview.js | 60 +- src/routes/InterviewRoutes.js | 18 +- src/services/InterviewService.js | 265 ++++- src/services/TeamService.js | 2 +- 12 files changed, 1560 insertions(+), 230 deletions(-) create mode 100644 migrations/2021-05-09-interviews-table-migration.js diff --git a/data/demo-data.json b/data/demo-data.json index 5736c839..2c7a94a7 100644 --- a/data/demo-data.json +++ b/data/demo-data.json @@ -436,10 +436,14 @@ { "id": "81f03238-1ce2-4d3d-80c5-5ecd5e7e94a2", "jobCandidateId": "25787cb2-d876-4883-b533-d5e628d213ce", - "googleCalendarId": "dummyId", - "customMessage": "This is a custom message", - "xaiTemplate": "30-min-interview", + "calendarEventId": "dummyId", + "templateUrl": "interview-30", "round": 1, + "duration": 30, + "hostName": "John Doe", + "hostEmail": "testuserforemail@yopmail.com", + "guestNames": ["Customer Test"], + "guestEmails": ["testcustomer@yopmail.com"], "status": "Scheduling", "createdBy": "57646ff9-1cd3-4d3c-88ba-eb09a395366c", "updatedBy": null, @@ -449,10 +453,14 @@ { "id": "75363f1d-46c3-4261-9c21-70019f90a61a", "jobCandidateId": "25787cb2-d876-4883-b533-d5e628d213ce", - "googleCalendarId": "dummyId", - "customMessage": "This is a custom message", - "xaiTemplate": "30-min-interview", + "calendarEventId": "dummyId", + "templateUrl": "interview-30", "round": 2, + "duration": 30, + "hostName": "John Doe", + "hostEmail": "testuserforemail@yopmail.com", + "guestNames": ["Customer Test"], + "guestEmails": ["testcustomer@yopmail.com"], "status": "Scheduling", "createdBy": "57646ff9-1cd3-4d3c-88ba-eb09a395366c", "updatedBy": null, diff --git a/docs/Topcoder-bookings-api.postman_collection.json b/docs/Topcoder-bookings-api.postman_collection.json index 52000d57..a6afdc2b 100644 --- a/docs/Topcoder-bookings-api.postman_collection.json +++ b/docs/Topcoder-bookings-api.postman_collection.json @@ -1,6 +1,6 @@ { "info": { - "_postman_id": "059739c9-5726-44b6-876d-6d87940c9aff", + "_postman_id": "3f80d93d-6ca3-4645-970d-a9e533394e2e", "name": "Topcoder-bookings-api", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" }, @@ -3186,6 +3186,202 @@ }, "response": [] }, + { + "name": "create job candidate", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Status code is 200', function () {\r", + " pm.response.to.have.status(200)\r", + " if(pm.response.status === \"OK\"){\r", + " const response = pm.response.json()\r", + " pm.environment.set(\"jobCandidateId_2\", response.id);\r", + " }\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "type": "text", + "value": "Bearer {{token_bookingManager}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"jobId\": \"{{jobId}}\",\n \"userId\": \"95e7970f-12b4-43b7-ab35-38c34bf033c7\",\n \"externalId\": \"88774631\",\n \"resume\": \"http://example.com\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{URL}}/jobCandidates", + "host": [ + "{{URL}}" + ], + "path": [ + "jobCandidates" + ] + } + }, + "response": [] + }, + { + "name": "create job candidate", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Status code is 200', function () {\r", + " pm.response.to.have.status(200)\r", + " if(pm.response.status === \"OK\"){\r", + " const response = pm.response.json()\r", + " pm.environment.set(\"jobCandidateId_3\", response.id);\r", + " }\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "type": "text", + "value": "Bearer {{token_bookingManager}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"jobId\": \"{{jobId}}\",\n \"userId\": \"95e7970f-12b4-43b7-ab35-38c34bf033c7\",\n \"externalId\": \"88774631\",\n \"resume\": \"http://example.com\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{URL}}/jobCandidates", + "host": [ + "{{URL}}" + ], + "path": [ + "jobCandidates" + ] + } + }, + "response": [] + }, + { + "name": "create job candidate", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Status code is 200', function () {\r", + " pm.response.to.have.status(200)\r", + " if(pm.response.status === \"OK\"){\r", + " const response = pm.response.json()\r", + " pm.environment.set(\"jobCandidateId_4\", response.id);\r", + " }\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "type": "text", + "value": "Bearer {{token_bookingManager}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"jobId\": \"{{jobId}}\",\n \"userId\": \"95e7970f-12b4-43b7-ab35-38c34bf033c7\",\n \"externalId\": \"88774631\",\n \"resume\": \"http://example.com\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{URL}}/jobCandidates", + "host": [ + "{{URL}}" + ], + "path": [ + "jobCandidates" + ] + } + }, + "response": [] + }, + { + "name": "create job candidate", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Status code is 200', function () {\r", + " pm.response.to.have.status(200)\r", + " if(pm.response.status === \"OK\"){\r", + " const response = pm.response.json()\r", + " pm.environment.set(\"jobCandidateId_5\", response.id);\r", + " }\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "type": "text", + "value": "Bearer {{token_bookingManager}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"jobId\": \"{{jobId}}\",\n \"userId\": \"95e7970f-12b4-43b7-ab35-38c34bf033c7\",\n \"externalId\": \"88774631\",\n \"resume\": \"http://example.com\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{URL}}/jobCandidates", + "host": [ + "{{URL}}" + ], + "path": [ + "jobCandidates" + ] + } + }, + "response": [] + }, { "name": "Create completed interview", "event": [ @@ -3217,7 +3413,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"xaiTemplate\": \"interview-30\",\r\n \"status\": \"Completed\"\r\n}", + "raw": "{\r\n \"templateUrl\": \"interview-30\",\r\n \"hostEmail\": \"testcustomer@yopmail.com\",\r\n \"status\": \"Completed\"\r\n}", "options": { "raw": { "language": "json" @@ -3251,6 +3447,7 @@ " pm.response.to.have.status(200)\r", " if(pm.response.status === \"OK\"){\r", " const response = pm.response.json()\r", + " pm.environment.set(\"interviewId\", response.id);\r", " pm.environment.set(\"interviewRound\", response.round);\r", " }\r", "});" @@ -3270,7 +3467,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"xaiTemplate\": \"interview-30\",\r\n \"googleCalendarId\": \"dummyId\",\r\n \"customMessage\": \"This is a custom message\",\r\n \"attendeesList\": [\"attendee1@yopmail.com\", \"attendee2@yopmail.com\"],\r\n \"status\": \"Scheduling\"\r\n}", + "raw": "{\r\n \"templateUrl\": \"interview-30\",\r\n \"calendarEventId\": \"dummyId\",\r\n \"hostEmail\": \"testcustomer@yopmail.com\",\r\n \"guestEmails\": [\"attendee1@yopmail.com\", \"attendee2@yopmail.com\"],\r\n \"status\": \"Scheduling\"\r\n}", "options": { "raw": { "language": "json" @@ -3304,6 +3501,56 @@ " const lastRound = pm.environment.get(\"interviewRound\");\r", " pm.expect(response.round).to.eq(lastRound + 1);\r", " pm.environment.set(\"interviewRound\", response.round);\r", + " pm.environment.set(\"interviewId\", response.id);\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "PATCH", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{token_bookingManager}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"templateUrl\": \"interview-30\",\r\n \"calendarEventId\": \"dummyId\",\r\n \"hostEmail\": \"testcustomer@yopmail.com\",\r\n \"status\": \"Scheduling\"\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{URL}}/jobCandidates/{{jobCandidateId}}/requestInterview", + "host": [ + "{{URL}}" + ], + "path": [ + "jobCandidates", + "{{jobCandidateId}}", + "requestInterview" + ] + } + }, + "response": [] + }, + { + "name": "Request 4th interview - should not allow", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Status code is 200', function () {\r", + " pm.response.to.have.status(409);\r", + " const response = pm.response.json()\r", + " pm.expect(response.message).to.eq(\"You've reached the maximum allowed number (3) of interviews for this candidate.\")\r", "});" ], "type": "text/javascript" @@ -3321,7 +3568,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"xaiTemplate\": \"interview-30\",\r\n \"googleCalendarId\": \"dummyId\",\r\n \"customMessage\": \"This is a custom message\",\r\n \"status\": \"Scheduling\"\r\n}", + "raw": "{\r\n \"templateUrl\": \"interview-30\",\r\n \"hostEmail\": \"testcustomer@yopmail.com\"\r\n}", "options": { "raw": { "language": "json" @@ -3373,7 +3620,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"xaiTemplate\": \"interview-30\"\r\n}", + "raw": "{\r\n \"templateUrl\": \"interview-30\",\r\n \"hostEmail\": \"testcustomer@yopmail.com\"\r\n}", "options": { "raw": { "language": "json" @@ -3381,13 +3628,13 @@ } }, "url": { - "raw": "{{URL}}/jobCandidates/{{jobCandidateId}}/requestInterview", + "raw": "{{URL}}/jobCandidates/{{jobCandidateId_2}}/requestInterview", "host": [ "{{URL}}" ], "path": [ "jobCandidates", - "{{jobCandidateId}}", + "{{jobCandidateId_2}}", "requestInterview" ] } @@ -3422,7 +3669,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"xaiTemplate\": \"interview-30\",\r\n \"status\": \"xxxx\"\r\n}", + "raw": "{\r\n \"templateUrl\": \"interview-30\",\r\n \"status\": \"xxxx\",\r\n \"hostEmail\": \"testcustomer@yopmail.com\"\r\n}", "options": { "raw": { "language": "json" @@ -3444,7 +3691,7 @@ "response": [] }, { - "name": "Request interview with attendeesList", + "name": "Request interview with guestEmails", "event": [ { "listen": "test", @@ -3453,8 +3700,8 @@ "pm.test('Status code is 200', function () {\r", " pm.response.to.have.status(200);\r", " const response = pm.response.json()\r", - " pm.expect(response.attendeesList[0]).to.eq(\"attendee1@yopmail.com\")\r", - " pm.expect(response.attendeesList[1]).to.eq(\"attendee2@yopmail.com\")\r", + " pm.expect(response.guestEmails[0]).to.eq(\"attendee1@yopmail.com\")\r", + " pm.expect(response.guestEmails[1]).to.eq(\"attendee2@yopmail.com\")\r", "});" ], "type": "text/javascript" @@ -3472,7 +3719,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"xaiTemplate\": \"interview-30\",\r\n \"attendeesList\": [\"attendee1@yopmail.com\", \"attendee2@yopmail.com\"]\r\n}", + "raw": "{\r\n \"templateUrl\": \"interview-30\",\r\n \"hostEmail\": \"testcustomer@yopmail.com\",\r\n \"guestEmails\": [\"attendee1@yopmail.com\", \"attendee2@yopmail.com\"]\r\n}", "options": { "raw": { "language": "json" @@ -3480,13 +3727,13 @@ } }, "url": { - "raw": "{{URL}}/jobCandidates/{{jobCandidateId}}/requestInterview", + "raw": "{{URL}}/jobCandidates/{{jobCandidateId_2}}/requestInterview", "host": [ "{{URL}}" ], "path": [ "jobCandidates", - "{{jobCandidateId}}", + "{{jobCandidateId_2}}", "requestInterview" ] } @@ -3494,7 +3741,7 @@ "response": [] }, { - "name": "Request interview with invalid attendeesList", + "name": "Request interview with invalid guestEmails", "event": [ { "listen": "test", @@ -3503,7 +3750,7 @@ "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(\"\\\"interview.attendeesList\\\" must be an array\")\r", + " pm.expect(response.message).to.eq(\"\\\"interview.guestEmails\\\" must be an array\")\r", "});" ], "type": "text/javascript" @@ -3521,7 +3768,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"xaiTemplate\": \"interview-30\",\r\n \"attendeesList\": \"asddd\"\r\n}", + "raw": "{\r\n \"templateUrl\": \"interview-30\",\r\n \"hostEmail\": \"testcustomer@yopmail.com\",\r\n \"guestEmails\": \"asddd\"\r\n}", "options": { "raw": { "language": "json" @@ -3543,7 +3790,7 @@ "response": [] }, { - "name": "Request interview with invalid attendee email", + "name": "Request interview with invalid guest email", "event": [ { "listen": "test", @@ -3552,7 +3799,7 @@ "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(\"\\\"interview.attendeesList[0]\\\" must be a valid email\")\r", + " pm.expect(response.message).to.eq(\"\\\"interview.guestEmails[0]\\\" must be a valid email\")\r", "});" ], "type": "text/javascript" @@ -3570,7 +3817,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"xaiTemplate\": \"interview-30\",\r\n \"attendeesList\": [\"asdas\"]\r\n}", + "raw": "{\r\n \"templateUrl\": \"interview-30\",\r\n \"hostEmail\": \"testcustomer@yopmail.com\",\r\n \"guestEmails\": [\"asdas\"]\r\n}", "options": { "raw": { "language": "json" @@ -3619,7 +3866,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"xaiTemplate\": \"interview-30\",\r\n \"round\": 1\r\n}", + "raw": "{\r\n \"templateUrl\": \"interview-30\",\r\n \"hostEmail\": \"testcustomer@yopmail.com\",\r\n \"round\": 1\r\n}", "options": { "raw": { "language": "json" @@ -3668,7 +3915,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"xaiTemplate\": \"interview-30\",\r\n \"startTimestamp\": \"2021-04-17\"\r\n}", + "raw": "{\r\n \"templateUrl\": \"interview-30\",\r\n \"hostEmail\": \"testcustomer@yopmail.com\",\r\n \"startTimestamp\": \"2021-04-17\"\r\n}", "options": { "raw": { "language": "json" @@ -3690,7 +3937,7 @@ "response": [] }, { - "name": "Request interview without xaiTemplate - should fail", + "name": "Request interview without templateUrl - should fail", "event": [ { "listen": "test", @@ -3699,7 +3946,7 @@ "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(\"\\\"interview.xaiTemplate\\\" is required\")\r", + " pm.expect(response.message).to.eq(\"\\\"interview.templateUrl\\\" is required\")\r", "});" ], "type": "text/javascript" @@ -3717,7 +3964,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"status\": \"Requested\"\r\n}", + "raw": "{\r\n \"status\": \"Scheduling\",\r\n \"hostEmail\": \"testcustomer@yopmail.com\"\r\n}", "options": { "raw": { "language": "json" @@ -3739,7 +3986,7 @@ "response": [] }, { - "name": "Request interview with invalid xaiTemplate", + "name": "Request interview with invalid templateUrl", "event": [ { "listen": "test", @@ -3748,7 +3995,7 @@ "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(\"\\\"interview.xaiTemplate\\\" must be one of [interview-30, interview-60]\")\r", + " pm.expect(response.message).to.eq(\"\\\"interview.templateUrl\\\" must be one of [interview-30, interview-60]\")\r", "});" ], "type": "text/javascript" @@ -3766,7 +4013,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"xaiTemplate\": \"asdas\"\r\n}", + "raw": "{\r\n \"templateUrl\": \"asdas\",\r\n \"hostEmail\": \"testcustomer@yopmail.com\"\r\n}", "options": { "raw": { "language": "json" @@ -3815,7 +4062,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"xaiTemplate\": \"interview-30\"\r\n}", + "raw": "{\r\n \"templateUrl\": \"interview-30\",\r\n \"hostEmail\": \"testcustomer@yopmail.com\"\r\n}", "options": { "raw": { "language": "json" @@ -3864,7 +4111,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"xaiTemplate\": \"interview-30\"\r\n}", + "raw": "{\r\n \"templateUrl\": \"interview-30\",\r\n \"hostEmail\": \"testcustomer@yopmail.com\"\r\n}", "options": { "raw": { "language": "json" @@ -3872,13 +4119,13 @@ } }, "url": { - "raw": "{{URL}}/jobCandidates/{{jobCandidateId}}/requestInterview", + "raw": "{{URL}}/jobCandidates/{{jobCandidateId_2}}/requestInterview", "host": [ "{{URL}}" ], "path": [ "jobCandidates", - "{{jobCandidateId}}", + "{{jobCandidateId_2}}", "requestInterview" ] } @@ -3911,7 +4158,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"xaiTemplate\": \"interview-30\"\r\n}", + "raw": "{\r\n \"templateUrl\": \"interview-30\",\r\n \"hostEmail\": \"testcustomer@yopmail.com\"\r\n}", "options": { "raw": { "language": "json" @@ -3919,13 +4166,13 @@ } }, "url": { - "raw": "{{URL}}/jobCandidates/{{jobCandidateId}}/requestInterview", + "raw": "{{URL}}/jobCandidates/{{jobCandidateId_3}}/requestInterview", "host": [ "{{URL}}" ], "path": [ "jobCandidates", - "{{jobCandidateId}}", + "{{jobCandidateId_3}}", "requestInterview" ] } @@ -3958,7 +4205,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"xaiTemplate\": \"interview-30\"\r\n}", + "raw": "{\r\n \"templateUrl\": \"interview-30\",\r\n \"hostEmail\": \"testcustomer@yopmail.com\"\r\n}", "options": { "raw": { "language": "json" @@ -3966,13 +4213,13 @@ } }, "url": { - "raw": "{{URL}}/jobCandidates/{{jobCandidateId}}/requestInterview", + "raw": "{{URL}}/jobCandidates/{{jobCandidateId_3}}/requestInterview", "host": [ "{{URL}}" ], "path": [ "jobCandidates", - "{{jobCandidateId}}", + "{{jobCandidateId_3}}", "requestInterview" ] } @@ -4005,7 +4252,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"xaiTemplate\": \"interview-30\"\r\n}", + "raw": "{\r\n \"templateUrl\": \"interview-30\",\r\n \"hostEmail\": \"testcustomer@yopmail.com\"\r\n}", "options": { "raw": { "language": "json" @@ -4013,13 +4260,13 @@ } }, "url": { - "raw": "{{URL}}/jobCandidates/{{jobCandidateId}}/requestInterview", + "raw": "{{URL}}/jobCandidates/{{jobCandidateId_3}}/requestInterview", "host": [ "{{URL}}" ], "path": [ "jobCandidates", - "{{jobCandidateId}}", + "{{jobCandidateId_3}}", "requestInterview" ] } @@ -4052,7 +4299,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"xaiTemplate\": \"interview-30\"\r\n}", + "raw": "{\r\n \"templateUrl\": \"interview-30\",\r\n \"hostEmail\": \"testcustomer@yopmail.com\"\r\n}", "options": { "raw": { "language": "json" @@ -4060,13 +4307,13 @@ } }, "url": { - "raw": "{{URL}}/jobCandidates/{{jobCandidateId}}/requestInterview", + "raw": "{{URL}}/jobCandidates/{{jobCandidateId_4}}/requestInterview", "host": [ "{{URL}}" ], "path": [ "jobCandidates", - "{{jobCandidateId}}", + "{{jobCandidateId_4}}", "requestInterview" ] } @@ -4101,7 +4348,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"xaiTemplate\": \"interview-30\"\r\n}", + "raw": "{\r\n \"templateUrl\": \"interview-30\",\r\n \"hostEmail\": \"testcustomer@yopmail.com\"\r\n}", "options": { "raw": { "language": "json" @@ -4150,7 +4397,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"xaiTemplate\": \"interview-30\"\r\n}", + "raw": "{\r\n \"templateUrl\": \"interview-30\",\r\n \"hostEmail\": \"testcustomer@yopmail.com\"\r\n}", "options": { "raw": { "language": "json" @@ -4199,7 +4446,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"xaiTemplate\": \"interview-30\"\r\n}", + "raw": "{\r\n \"templateUrl\": \"interview-30\",\r\n \"hostEmail\": \"testcustomer@yopmail.com\"\r\n}", "options": { "raw": { "language": "json" @@ -4207,13 +4454,13 @@ } }, "url": { - "raw": "{{URL}}/jobCandidates/{{jobCandidateId}}/requestInterview", + "raw": "{{URL}}/jobCandidates/{{jobCandidateId_4}}/requestInterview", "host": [ "{{URL}}" ], "path": [ "jobCandidates", - "{{jobCandidateId}}", + "{{jobCandidateId_4}}", "requestInterview" ] } @@ -4248,7 +4495,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"xaiTemplate\": \"interview-30\"\r\n}", + "raw": "{\r\n \"templateUrl\": \"interview-30\",\r\n \"hostEmail\": \"testcustomer@yopmail.com\"\r\n}", "options": { "raw": { "language": "json" @@ -4256,13 +4503,13 @@ } }, "url": { - "raw": "{{URL}}/jobCandidates/{{jobCandidateId}}/requestInterview", + "raw": "{{URL}}/jobCandidates/{{jobCandidateId_4}}/requestInterview", "host": [ "{{URL}}" ], "path": [ "jobCandidates", - "{{jobCandidateId}}", + "{{jobCandidateId_4}}", "requestInterview" ] } @@ -4297,7 +4544,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"xaiTemplate\": \"interview-30\"\r\n}", + "raw": "{\r\n \"templateUrl\": \"interview-30\",\r\n \"hostEmail\": \"testcustomer@yopmail.com\"\r\n}", "options": { "raw": { "language": "json" @@ -4305,13 +4552,13 @@ } }, "url": { - "raw": "{{URL}}/jobCandidates/{{jobCandidateId}}/requestInterview", + "raw": "{{URL}}/jobCandidates/{{jobCandidateId_5}}/requestInterview", "host": [ "{{URL}}" ], "path": [ "jobCandidates", - "{{jobCandidateId}}", + "{{jobCandidateId_5}}", "requestInterview" ] } @@ -4340,7 +4587,7 @@ "header": [], "body": { "mode": "raw", - "raw": "{\r\n \"xaiTemplate\": \"interview-30\"\r\n}", + "raw": "{\r\n \"templateUrl\": \"interview-30\",\r\n \"hostEmail\": \"testcustomer@yopmail.com\"\r\n}", "options": { "raw": { "language": "json" @@ -4389,7 +4636,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"xaiTemplate\": \"interview-30\"\r\n}", + "raw": "{\r\n \"templateUrl\": \"interview-30\",\r\n \"hostEmail\": \"testcustomer@yopmail.com\"\r\n}", "options": { "raw": { "language": "json" @@ -4410,6 +4657,86 @@ }, "response": [] }, + { + "name": "Get interview by id", + "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", + "value": "Bearer {{token_bookingManager}}", + "type": "text" + } + ], + "url": { + "raw": "{{URL}}/getInterview/{{interviewId}}", + "host": [ + "{{URL}}" + ], + "path": [ + "getInterview", + "{{interviewId}}" + ] + } + }, + "response": [] + }, + { + "name": "Get interview by id fromDb", + "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", + "value": "Bearer {{token_bookingManager}}", + "type": "text" + } + ], + "url": { + "raw": "{{URL}}/getInterview/{{interviewId}}?fromDb=true", + "host": [ + "{{URL}}" + ], + "path": [ + "getInterview", + "{{interviewId}}" + ], + "query": [ + { + "key": "fromDb", + "value": "true" + } + ] + } + }, + "response": [] + }, { "name": "Get interview by round", "event": [ @@ -5080,7 +5407,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"googleCalendarId\": \"dummyIdXX\",\r\n \"customMessage\": \"This is the updated message\"\r\n}", + "raw": "{\r\n \"calendarEventId\": \"dummyIdXX\",\r\n \"xaiId\": \"0edc1751-f4ca-4e8e-908a-95f6560311ab\"\r\n}", "options": { "raw": { "language": "json" @@ -5088,13 +5415,13 @@ } }, "url": { - "raw": "{{URL}}/jobCandidates/{{jobCandidateId}}/updateInterview/{{interviewRound}}", + "raw": "{{URL}}/jobCandidates/{{jobCandidateId_4}}/updateInterview/{{interviewRound}}", "host": [ "{{URL}}" ], "path": [ "jobCandidates", - "{{jobCandidateId}}", + "{{jobCandidateId_4}}", "updateInterview", "{{interviewRound}}" ] @@ -5303,7 +5630,7 @@ "response": [] }, { - "name": "Update interview with invalid xaiTemplate", + "name": "Update interview with invalid templateUrl", "event": [ { "listen": "test", @@ -5312,7 +5639,7 @@ "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(\"\\\"data.xaiTemplate\\\" must be one of [interview-30, interview-60]\")\r", + " pm.expect(response.message).to.eq(\"\\\"data.templateUrl\\\" must be one of [interview-30, interview-60]\")\r", "});" ], "type": "text/javascript" @@ -5330,7 +5657,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"xaiTemplate\": \"xxx\"\r\n}", + "raw": "{\r\n \"templateUrl\": \"xxx\"\r\n}", "options": { "raw": { "language": "json" @@ -5353,7 +5680,7 @@ "response": [] }, { - "name": "Update interview status to Scheduled without googleCalendarId", + "name": "Update interview status to Scheduled without calendarEventId", "event": [ { "listen": "test", @@ -5362,7 +5689,7 @@ "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(\"\\\"data.googleCalendarId\\\" is required\")\r", + " pm.expect(response.message).to.eq(\"\\\"data.calendarEventId\\\" is required\")\r", "});" ], "type": "text/javascript" @@ -5430,7 +5757,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"googleCalendarId\": \"sdsds\",\r\n \"status\": \"Scheduled\"\r\n}", + "raw": "{\r\n \"calendarEventId\": \"sdsds\",\r\n \"status\": \"Scheduled\"\r\n}", "options": { "raw": { "language": "json" @@ -5453,7 +5780,7 @@ "response": [] }, { - "name": "Update interview status to Rescheduled without googleCalendarId", + "name": "Update interview status to Rescheduled without calendarEventId", "event": [ { "listen": "test", @@ -5462,7 +5789,7 @@ "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(\"\\\"data.googleCalendarId\\\" is required\")\r", + " pm.expect(response.message).to.eq(\"\\\"data.calendarEventId\\\" is required\")\r", "});" ], "type": "text/javascript" @@ -5530,7 +5857,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"googleCalendarId\": \"sdsds\",\r\n \"status\": \"Rescheduled\"\r\n}", + "raw": "{\r\n \"calendarEventId\": \"sdsds\",\r\n \"status\": \"Rescheduled\"\r\n}", "options": { "raw": { "language": "json" @@ -5580,7 +5907,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"googleCalendarId\": \"sdsds\"\r\n}", + "raw": "{\r\n \"calendarEventId\": \"sdsds\"\r\n}", "options": { "raw": { "language": "json" @@ -5630,7 +5957,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"googleCalendarId\": \"sdsds\",\r\n \"status\": \"Scheduling\"\r\n}", + "raw": "{\r\n \"calendarEventId\": \"sdsds\",\r\n \"status\": \"Scheduling\"\r\n}", "options": { "raw": { "language": "json" @@ -5728,7 +6055,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"customMessage\": \"sdsds\"\r\n}", + "raw": "{\r\n \"calendarEventId\": \"sdsds\"\r\n}", "options": { "raw": { "language": "json" @@ -5776,7 +6103,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"customMessage\": \"sdsds\"\r\n}", + "raw": "{\r\n \"calendarEventId\": \"sdsds\"\r\n}", "options": { "raw": { "language": "json" @@ -5824,7 +6151,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"customMessage\": \"sdsds\"\r\n}", + "raw": "{\r\n \"calendarEventId\": \"sdsds\"\r\n}", "options": { "raw": { "language": "json" @@ -5872,7 +6199,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"customMessage\": \"sdsds\"\r\n}", + "raw": "{\r\n \"calendarEventId\": \"sdsds\"\r\n}", "options": { "raw": { "language": "json" @@ -5922,7 +6249,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"customMessage\": \"sdsds\"\r\n}", + "raw": "{\r\n \"calendarEventId\": \"sdsds\"\r\n}", "options": { "raw": { "language": "json" @@ -5972,7 +6299,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"customMessage\": \"sdsds\"\r\n}", + "raw": "{\r\n \"calendarEventId\": \"sdsds\"\r\n}", "options": { "raw": { "language": "json" @@ -6022,7 +6349,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"customMessage\": \"sdsds\"\r\n}", + "raw": "{\r\n \"calendarEventId\": \"sdsds\"\r\n}", "options": { "raw": { "language": "json" @@ -6072,7 +6399,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"customMessage\": \"sdsds\"\r\n}", + "raw": "{\r\n \"calendarEventId\": \"sdsds\"\r\n}", "options": { "raw": { "language": "json" @@ -6122,7 +6449,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"customMessage\": \"sdsds\"\r\n}", + "raw": "{\r\n \"calendarEventId\": \"sdsds\"\r\n}", "options": { "raw": { "language": "json" @@ -6210,13 +6537,187 @@ "header": [ { "key": "Authorization", - "value": "Bearer invalid_key", + "value": "Bearer invalid_key", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"customMessage\": \"sdsds\"\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{URL}}/jobCandidates/{{jobCandidateId}}/updateInterview/{{interviewRound}}", + "host": [ + "{{URL}}" + ], + "path": [ + "jobCandidates", + "{{jobCandidateId}}", + "updateInterview", + "{{interviewRound}}" + ] + } + }, + "response": [] + }, + { + "name": "Get interview by xaiId", + "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", + "value": "Bearer {{token_bookingManager}}", + "type": "text" + } + ], + "url": { + "raw": "{{URL}}/getInterview/0edc1751-f4ca-4e8e-908a-95f6560311ab", + "host": [ + "{{URL}}" + ], + "path": [ + "getInterview", + "0edc1751-f4ca-4e8e-908a-95f6560311ab" + ] + } + }, + "response": [] + }, + { + "name": "Get interview by xaiId fromDb", + "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", + "value": "Bearer {{token_bookingManager}}", + "type": "text" + } + ], + "url": { + "raw": "{{URL}}/getInterview/0edc1751-f4ca-4e8e-908a-95f6560311ab?fromDb=true", + "host": [ + "{{URL}}" + ], + "path": [ + "getInterview", + "0edc1751-f4ca-4e8e-908a-95f6560311ab" + ], + "query": [ + { + "key": "fromDb", + "value": "true" + } + ] + } + }, + "response": [] + }, + { + "name": "Update interview by id", + "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": "PATCH", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{token_bookingManager}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"xaiId\": \"0edc1751-f4ca-4e8e-908a-95f6560311ab\"\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{URL}}/updateInterview/{{interviewId}}", + "host": [ + "{{URL}}" + ], + "path": [ + "updateInterview", + "{{interviewId}}" + ] + } + }, + "response": [] + }, + { + "name": "Update interview by xaiId", + "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": "PATCH", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{token_bookingManager}}", "type": "text" } ], "body": { "mode": "raw", - "raw": "{\r\n \"customMessage\": \"sdsds\"\r\n}", + "raw": "{\r\n \"xaiId\": \"0edc1751-f4ca-4e8e-908a-95f6560311ab\",\r\n \"calendarEventId\": \"test\"\r\n}", "options": { "raw": { "language": "json" @@ -6224,15 +6725,13 @@ } }, "url": { - "raw": "{{URL}}/jobCandidates/{{jobCandidateId}}/updateInterview/{{interviewRound}}", + "raw": "{{URL}}/updateInterview/0edc1751-f4ca-4e8e-908a-95f6560311ab", "host": [ "{{URL}}" ], "path": [ - "jobCandidates", - "{{jobCandidateId}}", "updateInterview", - "{{interviewRound}}" + "0edc1751-f4ca-4e8e-908a-95f6560311ab" ] } }, @@ -19431,6 +19930,7 @@ " if(pm.response.status === \"OK\"){\r", " const response = pm.response.json()\r", " pm.environment.set(\"interview_round_created_by_administrator\", response.round);\r", + " pm.environment.set(\"interview_id_created_by_administrator\", response.id);\r", " }\r", "});" ], @@ -19449,7 +19949,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"xaiTemplate\": \"interview-30\",\r\n \"googleCalendarId\": \"dummyId\",\r\n \"customMessage\": \"This is a custom message\",\r\n \"status\": \"Scheduling\"\r\n}", + "raw": "{\r\n \"templateUrl\": \"interview-30\",\r\n \"hostEmail\": \"testcustomer@yopmail.com\",\r\n \"calendarEventId\": \"dummyId\",\r\n \"status\": \"Scheduling\"\r\n}", "options": { "raw": { "language": "json" @@ -19471,7 +19971,7 @@ "response": [] }, { - "name": "✔ get interview with administrator", + "name": "✔ get interview by round with administrator", "event": [ { "listen": "test", @@ -19510,7 +20010,44 @@ "response": [] }, { - "name": "✔ update interview with administrator", + "name": "✔ get interview by id with administrator", + "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", + "value": "Bearer {{token_administrator}}", + "type": "text" + } + ], + "url": { + "raw": "{{URL}}/getInterview/{{interview_id_created_by_administrator}}", + "host": [ + "{{URL}}" + ], + "path": [ + "getInterview", + "{{interview_id_created_by_administrator}}" + ] + } + }, + "response": [] + }, + { + "name": "✔ update interview by round with administrator", "event": [ { "listen": "test", @@ -19535,7 +20072,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"customMessage\": \"updated\"\r\n}", + "raw": "{\r\n \"calendarEventId\": \"updated\"\r\n}", "options": { "raw": { "language": "json" @@ -19557,6 +20094,52 @@ }, "response": [] }, + { + "name": "✔ update interview by id with administrator", + "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": "PATCH", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{token_administrator}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"xaiId\": \"0edc1751-f4ca-4e8e-908a-95f6560311ab\",\r\n \"calendarEventId\": \"updated\"\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{URL}}/updateInterview/{{interview_id_created_by_administrator}}", + "host": [ + "{{URL}}" + ], + "path": [ + "updateInterview", + "{{interview_id_created_by_administrator}}" + ] + } + }, + "response": [] + }, { "name": "✔ search interviews with administrator", "event": [ @@ -21372,6 +21955,7 @@ " pm.response.to.have.status(200);\r", " const response = pm.response.json()\r", " pm.environment.set(\"interview_round_created_for_member\", response.round)\r", + " pm.environment.set(\"interview_id_created_for_member\", response.id)\r", "});" ], "type": "text/javascript" @@ -21389,7 +21973,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"xaiTemplate\": \"interview-30\",\r\n \"googleCalendarId\": \"dummyId\",\r\n \"customMessage\": \"This is a custom message\",\r\n \"status\": \"Scheduling\"\r\n}", + "raw": "{\r\n \"templateUrl\": \"interview-30\",\r\n \"calendarEventId\": \"dummyId\",\r\n \"hostEmail\": \"testcustomer@yopmail.com\",\r\n \"status\": \"Scheduling\"\r\n}", "options": { "raw": { "language": "json" @@ -21419,10 +22003,10 @@ "listen": "test", "script": { "exec": [ - "pm.test('Status code is 403', function () {\r", - " pm.response.to.have.status(403);\r", + "pm.test('Status code is 401', function () {\r", + " pm.response.to.have.status(401);\r", " const response = pm.response.json()\r", - " pm.expect(response.message).to.eq(\"You are not allowed to perform this action!\")\r", + " pm.expect(response.message).to.eq(\"userId: 8547899 the user is not a member of project 111\")\r", "});" ], "type": "text/javascript" @@ -21440,7 +22024,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"xaiTemplate\": \"interview-30\",\r\n \"googleCalendarId\": \"dummyId\",\r\n \"customMessage\": \"This is a custom message\",\r\n \"status\": \"Scheduling\"\r\n}", + "raw": "{\r\n \"templateUrl\": \"interview-30\",\r\n \"hostEmail\": \"testcustomer@yopmail.com\",\r\n \"calendarEventId\": \"dummyId\",\r\n \"status\": \"Scheduling\"\r\n}", "options": { "raw": { "language": "json" @@ -21462,16 +22046,16 @@ "response": [] }, { - "name": "✘ get interview with member", + "name": "✘ get interview by round with member", "event": [ { "listen": "test", "script": { "exec": [ - "pm.test('Status code is 403', function () {\r", - " pm.response.to.have.status(403);\r", + "pm.test('Status code is 401', function () {\r", + " pm.response.to.have.status(401);\r", " const response = pm.response.json()\r", - " pm.expect(response.message).to.eq(\"You are not allowed to perform this action!\")\r", + " pm.expect(response.message).to.eq(\"userId: 8547899 the user is not a member of project 111\")\r", "});" ], "type": "text/javascript" @@ -21503,16 +22087,55 @@ "response": [] }, { - "name": "✘ update interview with member", + "name": "✘ get interview by id with member", "event": [ { "listen": "test", "script": { "exec": [ - "pm.test('Status code is 403', function () {\r", - " pm.response.to.have.status(403);\r", + "pm.test('Status code is 401', function () {\r", + " pm.response.to.have.status(401);\r", " const response = pm.response.json()\r", - " pm.expect(response.message).to.eq(\"You are not allowed to perform this action!\")\r", + " pm.expect(response.message).to.eq(\"userId: 8547899 the user is not a member of project 111\")\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{token_member}}", + "type": "text" + } + ], + "url": { + "raw": "{{URL}}/getInterview/{{interview_id_created_for_member}}", + "host": [ + "{{URL}}" + ], + "path": [ + "getInterview", + "{{interview_id_created_for_member}}" + ] + } + }, + "response": [] + }, + { + "name": "✘ update interview by round with member", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Status code is 401', function () {\r", + " pm.response.to.have.status(401);\r", + " const response = pm.response.json()\r", + " pm.expect(response.message).to.eq(\"userId: 8547899 the user is not a member of project 111\")\r", "});" ], "type": "text/javascript" @@ -21530,7 +22153,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"customMessage\": \"updated\"\r\n}", + "raw": "{\r\n \"xaiId\": \"0edc1751-f4ca-4e8e-908a-95f6560311ab\"\r\n}", "options": { "raw": { "language": "json" @@ -21552,6 +22175,54 @@ }, "response": [] }, + { + "name": "✘ update interview by id with member", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Status code is 401', function () {\r", + " pm.response.to.have.status(401);\r", + " const response = pm.response.json()\r", + " pm.expect(response.message).to.eq(\"userId: 8547899 the user is not a member of project 111\")\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "PATCH", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{token_member}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"xaiId\": \"0edc1751-f4ca-4e8e-908a-95f6560311ab\"\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{URL}}/updateInterview/{{interview_id_created_for_member}}", + "host": [ + "{{URL}}" + ], + "path": [ + "updateInterview", + "{{interview_id_created_for_member}}" + ] + } + }, + "response": [] + }, { "name": "✘ search interviews with member", "event": [ @@ -21559,10 +22230,10 @@ "listen": "test", "script": { "exec": [ - "pm.test('Status code is 403', function () {\r", - " pm.response.to.have.status(403);\r", + "pm.test('Status code is 401', function () {\r", + " pm.response.to.have.status(401);\r", " const response = pm.response.json()\r", - " pm.expect(response.message).to.eq(\"You are not allowed to perform this action!\")\r", + " pm.expect(response.message).to.eq(\"userId: 8547899 the user is not a member of project 111\")\r", "});" ], "type": "text/javascript" @@ -23526,7 +24197,8 @@ "pm.test('Status code is 200', function () {\r", " pm.response.to.have.status(200);\r", " const response = pm.response.json()\r", - " pm.environment.set(\"interview_round_created_for_connect_manager\", response.round)\r", + " pm.environment.set(\"interview_round_created_for_connect_manager\", response.round);\r", + " pm.environment.set(\"interview_id_created_for_connect_manager\", response.id);\r", "});" ], "type": "text/javascript" @@ -23544,7 +24216,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"xaiTemplate\": \"interview-30\",\r\n \"googleCalendarId\": \"dummyId\",\r\n \"customMessage\": \"This is a custom message\",\r\n \"status\": \"Scheduling\"\r\n}", + "raw": "{\r\n \"templateUrl\": \"interview-30\",\r\n \"calendarEventId\": \"dummyId\",\r\n \"hostEmail\": \"testcustomer@yopmail.com\",\r\n \"status\": \"Scheduling\"\r\n}", "options": { "raw": { "language": "json" @@ -23574,10 +24246,10 @@ "listen": "test", "script": { "exec": [ - "pm.test('Status code is 403', function () {\r", - " pm.response.to.have.status(403);\r", + "pm.test('Status code is 401', function () {\r", + " pm.response.to.have.status(401);\r", " const response = pm.response.json()\r", - " pm.expect(response.message).to.eq(\"You are not allowed to perform this action!\")\r", + " pm.expect(response.message).to.eq(\"userId: 88774489 the user is not a member of project 111\")\r", "});" ], "type": "text/javascript" @@ -23595,7 +24267,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"xaiTemplate\": \"interview-30\",\r\n \"googleCalendarId\": \"dummyId\",\r\n \"customMessage\": \"This is a custom message\",\r\n \"status\": \"Scheduling\"\r\n}", + "raw": "{\r\n \"templateUrl\": \"interview-30\",\r\n \"calendarEventId\": \"dummyId\",\r\n \"hostEmail\": \"testcustomer@yopmail.com\",\r\n \"status\": \"Scheduling\"\r\n}", "options": { "raw": { "language": "json" @@ -23617,16 +24289,16 @@ "response": [] }, { - "name": "✘ get interview with connect manager", + "name": "✘ get interview by round with connect manager", "event": [ { "listen": "test", "script": { "exec": [ - "pm.test('Status code is 403', function () {\r", - " pm.response.to.have.status(403);\r", + "pm.test('Status code is 401', function () {\r", + " pm.response.to.have.status(401);\r", " const response = pm.response.json()\r", - " pm.expect(response.message).to.eq(\"You are not allowed to perform this action!\")\r", + " pm.expect(response.message).to.eq(\"userId: 88774489 the user is not a member of project 111\")\r", "});" ], "type": "text/javascript" @@ -23658,16 +24330,55 @@ "response": [] }, { - "name": "✘ update interview with connect manager", + "name": "✘ get interview by id with connect manager", "event": [ { "listen": "test", "script": { "exec": [ - "pm.test('Status code is 403', function () {\r", - " pm.response.to.have.status(403);\r", + "pm.test('Status code is 401', function () {\r", + " pm.response.to.have.status(401);\r", " const response = pm.response.json()\r", - " pm.expect(response.message).to.eq(\"You are not allowed to perform this action!\")\r", + " pm.expect(response.message).to.eq(\"userId: 88774489 the user is not a member of project 111\")\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{token_connect_manager_pshahcopmanag2}}", + "type": "text" + } + ], + "url": { + "raw": "{{URL}}/getInterview/{{interview_id_created_for_connect_manager}}", + "host": [ + "{{URL}}" + ], + "path": [ + "getInterview", + "{{interview_id_created_for_connect_manager}}" + ] + } + }, + "response": [] + }, + { + "name": "✘ update interview by round with connect manager", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Status code is 401', function () {\r", + " pm.response.to.have.status(401);\r", + " const response = pm.response.json()\r", + " pm.expect(response.message).to.eq(\"userId: 88774489 the user is not a member of project 111\")\r", "});" ], "type": "text/javascript" @@ -23685,7 +24396,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"customMessage\": \"updated\"\r\n}", + "raw": "{\r\n \"xaiId\": \"0edc1751-f4ca-4e8e-908a-95f6560311ab\"\r\n}", "options": { "raw": { "language": "json" @@ -23707,6 +24418,54 @@ }, "response": [] }, + { + "name": "✘ update interview by id with connect manager", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Status code is 401', function () {\r", + " pm.response.to.have.status(401);\r", + " const response = pm.response.json()\r", + " pm.expect(response.message).to.eq(\"userId: 88774489 the user is not a member of project 111\")\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "PATCH", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{token_connect_manager_pshahcopmanag2}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"xaiId\": \"0edc1751-f4ca-4e8e-908a-95f6560311ab\"\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{URL}}/updateInterview/{{interview_id_created_for_connect_manager}}", + "host": [ + "{{URL}}" + ], + "path": [ + "updateInterview", + "{{interview_id_created_for_connect_manager}}" + ] + } + }, + "response": [] + }, { "name": "✘ search interviews with member", "event": [ @@ -23714,10 +24473,10 @@ "listen": "test", "script": { "exec": [ - "pm.test('Status code is 403', function () {\r", - " pm.response.to.have.status(403);\r", + "pm.test('Status code is 401', function () {\r", + " pm.response.to.have.status(401);\r", " const response = pm.response.json()\r", - " pm.expect(response.message).to.eq(\"You are not allowed to perform this action!\")\r", + " pm.expect(response.message).to.eq(\"userId: 88774489 the user is not a member of project 111\")\r", "});" ], "type": "text/javascript" diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 00dab590..c28e4f08 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -1029,6 +1029,66 @@ paths: application/json: schema: $ref: "#/components/schemas/Error" + /updateInterview/{id}: + patch: + tags: + - Interviews + description: | + Partially update interview. + + **Authorization** Topcoder token with update interview scope is allowed + security: + - bearerAuth: [] + parameters: + - in: path + name: id + description: The interview or xai id. + required: true + schema: + type: string + format: uuid + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateInterviewRequestBody" + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/Interview" + "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}/interviews: get: tags: @@ -1203,6 +1263,67 @@ paths: application/json: schema: $ref: "#/components/schemas/Error" + /getInterview/{id}: + get: + tags: + - Interviews + description: | + Get interview by id. + + **Authorization** Topcoder token with read interview scope is allowed + security: + - bearerAuth: [] + parameters: + - in: path + name: id + description: The interview or xai id. + required: true + schema: + type: string + format: uuid + - in: query + name: fromDb + description: get data from db or not. + required: false + schema: + type: boolean + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/Interview" + "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" /resourceBookings: post: tags: @@ -3436,7 +3557,7 @@ components: required: - id - jobCandidateId - - xaiTemplate + - templateUrl - round - status - createdAt @@ -3446,41 +3567,79 @@ components: type: string format: uuid description: "The interview id." + xaiId: + type: string + format: uuid + description: "The x.ai id." jobCandidateId: type: string format: uuid description: "The job candidate id." - googleCalendarId: + calendarEventId: type: string example: "dummyId" - description: "The google calendar id." - xaiTemplate: + description: "The calendar event id." + templateUrl: type: string example: "interview-30" enum: ["interview-30", "interview-60"] description: "The x.ai template name" - customMessage: + templateId: + type: string + format: uuid + description: "The x.ai template id" + templateType: + type: string + description: "The x.ai template type" + title: type: string - example: "This is a custom message" - description: "The custom message." + description: "The x.ai template title" + locationDetails: + type: string + example: "Location TBD." + description: "The x.ai meeting location." round: type: integer example: 1 description: "The interview round." - attendeesList: + duration: + type: integer + example: 30 + description: "The interview duration (in minutes)." + hostEmail: + type: string + format: email + description: "The interview host email." + hostName: + type: string + description: "The interview host name." + guestEmails: type: array description: "Attendee list for this interview." items: type: string format: email + guestNames: + type: array + description: "Names of guests." + items: + type: string startTimestamp: type: string format: date-time description: "Interview start time." + endTimestamp: + type: string + format: date-time + description: "Interview end time." status: type: string enum: ["Scheduling", "Scheduled", "Requested for reschedule", "Rescheduled", "Completed", "Cancelled"] description: "The interview status." + rescheduleUrl: + type: string + format: url + description: "x.ai reschedule url." createdAt: type: string format: date-time @@ -3499,22 +3658,22 @@ components: description: "The user who updated the interview last time.(Will get the user info from the token)" RequestInterviewBody: required: - - xaiTemplate + - templateUrl + - hostEmail properties: - googleCalendarId: + calendarEventId: type: string example: "dummyId" - description: "The google calendar id." - customMessage: - type: string - example: "This is a custom message" - description: "The custom message." - xaiTemplate: + description: "The calendar event id." + templateUrl: type: string enum: ["interview-30", "interview-60"] example: "interview-30" description: "The x.ai template name" - attendeesList: + hostEmail: + type: string + format: email + guestEmails: type: array items: type: string @@ -3526,31 +3685,67 @@ components: description: "The interview status." UpdateInterviewRequestBody: properties: - googleCalendarId: + xaiId: + type: string + format: uuid + description: "The x.ai meeting id" + calendarEventId: type: string example: "dummyId" description: "The google calendar id." - customMessage: - type: string - example: "This is a custom message" - description: "The custom message." - xaiTemplate: + templateUrl: type: string enum: ["interview-30", "interview-60"] example: "interview-30" description: "The x.ai template name" - attendeesList: + templateId: + type: string + format: uuid + description: "The x.ai template id" + templateType: + type: string + description: "The x.ai template type" + title: + type: string + description: "The x.ai meeting title" + locationDetails: + type: string + example: "Location TBD." + description: "The x.ai location details" + hostName: + type: string + description: "The host name" + hostEmail: + type: string + format: email + description: "The host email" + guestNames: + type: array + items: + type: string + description: "The guest names" + guestEmails: type: array items: type: string format: email + description: "The guest emails" startTimestamp: type: string format: date-time + description: "Interview start time." + endTimestamp: + type: string + format: date-time + description: "Interview end time." status: type: string enum: ["Scheduling", "Scheduled", "Requested for reschedule", "Rescheduled", "Completed", "Cancelled"] description: "The interview status." + rescheduleUrl: + type: string + format: url + description: "The x.ai reschedule url" JobPatchRequestBody: properties: status: diff --git a/docs/topcoder-bookings.postman_environment.json b/docs/topcoder-bookings.postman_environment.json index 58304b6d..837b55db 100644 --- a/docs/topcoder-bookings.postman_environment.json +++ b/docs/topcoder-bookings.postman_environment.json @@ -1,5 +1,5 @@ { - "id": "cf10e077-1975-444a-b2d2-3e8e71080526", + "id": "228f4dcc-6914-462e-9b56-3285b643a2f8", "name": "topcoder-bookings", "values": [ { @@ -421,9 +421,49 @@ "key": "token_m2m_read_jobCandidates_all_interviews", "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL3RvcGNvZGVyLWRldi5hdXRoMC5jb20vIiwic3ViIjoiZW5qdzE4MTBlRHozWFR3U08yUm4yWTljUVRyc3BuM0JAY2xpZW50cyIsImF1ZCI6Imh0dHBzOi8vbTJtLnRvcGNvZGVyLWRldi5jb20vIiwiaWF0IjoxNTUwOTA2Mzg4LCJleHAiOjIxNDc0ODM2NDgsImF6cCI6ImVuancxODEwZUR6M1hUd1NPMlJuMlk5Y1FUcnNwbjNCIiwic2NvcGUiOiJyZWFkOnRhYXMtam9iQ2FuZGlkYXRlcyBhbGw6dGFhcy1pbnRlcnZpZXdzIiwiZ3R5IjoiY2xpZW50LWNyZWRlbnRpYWxzIn0.XQj74JSHp98XKxa1eZnMMpHxGpHeZAHVhLvFAN7gHBY", "enabled": true + }, + { + "key": "jobCandidateId_2", + "value": "", + "enabled": true + }, + { + "key": "jobCandidateId_3", + "value": "", + "enabled": true + }, + { + "key": "jobCandidateId_4", + "value": "", + "enabled": true + }, + { + "key": "jobCandidateId_5", + "value": "", + "enabled": true + }, + { + "key": "interviewId", + "value": "", + "enabled": true + }, + { + "key": "interview_id_created_by_administrator", + "value": "", + "enabled": true + }, + { + "key": "interview_id_created_for_member", + "value": "", + "enabled": true + }, + { + "key": "interview_id_created_for_connect_manager", + "value": "", + "enabled": true } ], "_postman_variable_scope": "environment", - "_postman_exported_at": "2021-04-22T11:06:21.725Z", - "_postman_exported_using": "Postman/8.2.3" + "_postman_exported_at": "2021-05-10T05:06:38.661Z", + "_postman_exported_using": "Postman/8.3.1" } \ No newline at end of file diff --git a/migrations/2021-05-09-interviews-table-migration.js b/migrations/2021-05-09-interviews-table-migration.js new file mode 100644 index 00000000..6fbbaba9 --- /dev/null +++ b/migrations/2021-05-09-interviews-table-migration.js @@ -0,0 +1,63 @@ +'use strict'; +const config = require('config') + +/** + * Add `xaiId` column. + * Add `rescheduleUrl` column. + * Add `endTimestamp` column. + * Add `duration` column. + * Add `templateId` column. + * Add `templateType` column. + * Add `title` column. + * Add `locationDetails` column. + * Add `hostName` column. + * Add `hostEmail` column. + * Add `guestNames` column. + * Rename column `attendeesList` to `guestEmails` + * Rename column `googleCalendarId` to `calendarEventId` + * Rename column `xaiTemplate` to `templateUrl` + * Remove `customMessage` column + */ +module.exports = { + up: async (queryInterface, Sequelize) => { + const table = { tableName: 'interviews', schema: config.DB_SCHEMA_NAME } + await Promise.all([ + queryInterface.addColumn(table, 'xai_id', { type: Sequelize.UUID }), + queryInterface.addColumn(table, 'duration', { type: Sequelize.INTEGER }), + queryInterface.addColumn(table, 'end_timestamp', { type: Sequelize.DATE }), + queryInterface.addColumn(table, 'template_id', { type: Sequelize.UUID }), + queryInterface.addColumn(table, 'template_type', { type: Sequelize.STRING(255) }), + queryInterface.addColumn(table, 'location_details', { type: Sequelize.STRING(255) }), + queryInterface.addColumn(table, 'title', { type: Sequelize.STRING(255) }), + queryInterface.addColumn(table, 'host_name', { type: Sequelize.STRING(255) }), + queryInterface.addColumn(table, 'host_email', { type: Sequelize.STRING(255) }), + queryInterface.addColumn(table, 'guest_names', { type: Sequelize.ARRAY(Sequelize.STRING) }), + queryInterface.addColumn(table, 'reschedule_url', { type: Sequelize.STRING(255) }), + queryInterface.renameColumn(table, 'attendees_list', 'guest_emails'), + queryInterface.renameColumn(table, 'google_calendar_id', 'calendar_event_id'), + queryInterface.renameColumn(table, 'xai_template', 'template_url'), + queryInterface.removeColumn(table, 'custom_message') + ]) + }, + + down: async (queryInterface, Sequelize) => { + const table = { tableName: 'interviews', schema: config.DB_SCHEMA_NAME } + await Promise.all([ + queryInterface.removeColumn(table, 'xai_id'), + queryInterface.removeColumn(table, 'duration'), + queryInterface.removeColumn(table, 'end_timestamp'), + queryInterface.removeColumn(table, 'template_id'), + queryInterface.removeColumn(table, 'template_type'), + queryInterface.removeColumn(table, 'location_details'), + queryInterface.removeColumn(table, 'title'), + queryInterface.removeColumn(table, 'host_name'), + queryInterface.removeColumn(table, 'host_email'), + queryInterface.removeColumn(table, 'guest_names'), + queryInterface.removeColumn(table, 'reschedule_url'), + queryInterface.renameColumn(table, 'guest_emails', 'attendees_list'), + queryInterface.renameColumn(table, 'calendar_event_id', 'google_calendar_id'), + queryInterface.renameColumn(table, 'template_url', 'xai_template'), + queryInterface.addColumn(table, 'custom_message', { type: Sequelize.TEXT }) + ]) + } +}; diff --git a/src/common/helper.js b/src/common/helper.js index 255565ba..2b619563 100644 --- a/src/common/helper.js +++ b/src/common/helper.js @@ -88,14 +88,24 @@ esIndexPropertyMapping[config.get('esConfig.ES_INDEX_JOB_CANDIDATE')] = { type: 'nested', properties: { id: { type: 'keyword' }, + xaiId: { type: 'keyword' }, jobCandidateId: { type: 'keyword' }, - googleCalendarId: { type: 'keyword' }, - customMessage: { type: 'text' }, - xaiTemplate: { type: 'keyword' }, + calendarEventId: { type: 'keyword' }, + templateUrl: { type: 'keyword' }, + templateId: { type: 'keyword' }, + templateType: { type: 'keyword' }, + title: { type: 'keyword' }, + locationDetails: { type: 'keyword' }, + duration: { type: 'integer' }, startTimestamp: { type: 'date' }, - attendeesList: [], + endTimestamp: { type: 'date' }, + hostName: { type: 'keyword' }, + hostEmail: { type: 'keyword' }, + guestNames: { type: 'keyword' }, + guestEmails: { type: 'keyword' }, round: { type: 'integer' }, status: { type: 'keyword' }, + rescheduleUrl: { type: 'keyword' }, createdAt: { type: 'date' }, createdBy: { type: 'keyword' }, updatedAt: { type: 'date' }, diff --git a/src/controllers/InterviewController.js b/src/controllers/InterviewController.js index d9ae8e44..21cceca1 100644 --- a/src/controllers/InterviewController.js +++ b/src/controllers/InterviewController.js @@ -14,6 +14,15 @@ async function getInterviewByRound (req, res) { res.send(await service.getInterviewByRound(req.authUser, jobCandidateId, round, req.query.fromDb)) } +/** + * Get interview by id + * @param req the request + * @param res the response + */ +async function getInterviewById (req, res) { + res.send(await service.getInterviewById(req.authUser, req.params.id, req.query.fromDb)) +} + /** * Request interview * @param req the request @@ -24,13 +33,22 @@ async function requestInterview (req, res) { } /** - * Patch (partially update) interview + * Patch (partially update) interview by round * @param req the request * @param res the response */ -async function partiallyUpdateInterview (req, res) { +async function partiallyUpdateInterviewByRound (req, res) { const { jobCandidateId, round } = req.params - res.send(await service.partiallyUpdateInterview(req.authUser, jobCandidateId, round, req.body)) + res.send(await service.partiallyUpdateInterviewByRound(req.authUser, jobCandidateId, round, req.body)) +} + +/** + * Patch (partially update) interview by id + * @param req the request + * @param res the response + */ +async function partiallyUpdateInterviewById (req, res) { + res.send(await service.partiallyUpdateInterviewById(req.authUser, req.params.id, req.body)) } /** @@ -46,7 +64,9 @@ async function searchInterviews (req, res) { module.exports = { getInterviewByRound, + getInterviewById, requestInterview, - partiallyUpdateInterview, + partiallyUpdateInterviewByRound, + partiallyUpdateInterviewById, searchInterviews } diff --git a/src/eventHandlers/InterviewEventHandler.js b/src/eventHandlers/InterviewEventHandler.js index 11d118b4..ab7bd0c1 100644 --- a/src/eventHandlers/InterviewEventHandler.js +++ b/src/eventHandlers/InterviewEventHandler.js @@ -5,9 +5,7 @@ const models = require('../models') // const logger = require('../common/logger') const helper = require('../common/helper') -const { Interviews } = require('../../app-constants') const teamService = require('../services/TeamService') -const _ = require('lodash') /** * Once we request Interview for a JobCandidate, the invitation emails to be sent out. @@ -17,9 +15,6 @@ const _ = require('lodash') */ async function sendInvitationEmail (payload) { const interview = payload.value - // get the Interviewer - const interviewerUsers = await helper.getMemberDetailsByEmails(interview.attendeesList) - .then((members) => _.map(members, (member) => ({ ...member, emailLowerCase: member.email.toLowerCase() }))) // get job candidate user details const jobCandidate = await models.JobCandidate.findById(interview.jobCandidateId) const jobCandidateUser = await helper.getUserById(jobCandidate.userId) @@ -29,15 +24,14 @@ async function sendInvitationEmail (payload) { teamService.sendEmail({}, { template: 'interview-invitation', - cc: [jobCandidateMember.email, ...interview.attendeesList], + cc: [jobCandidateMember.email, ...interview.guestEmails], data: { - job_candidate_id: interview.jobCandidateId, - interview_round: interview.round, + interview_id: interview.id, interviewee_name: `${jobCandidateMember.firstName} ${jobCandidateMember.lastName}`, - interviewer_name: `${interviewerUsers[0].firstName} ${interviewerUsers[0].lastName}`, - xai_template: '/' + interview.xaiTemplate, - additional_interviewers: (interview.attendeesList).join(','), - interview_length: Interviews.XaiTemplate[interview.xaiTemplate], + interviewer_name: interview.hostName, + xai_template: '/' + interview.templateUrl, + additional_interviewers: (interview.guestEmails).join(','), + interview_length: interview.duration, job_name: job.title, interviewee_handle: jobCandidateMember.handle } diff --git a/src/models/Interview.js b/src/models/Interview.js index 424251a3..c0688a90 100644 --- a/src/models/Interview.js +++ b/src/models/Interview.js @@ -42,24 +42,44 @@ module.exports = (sequelize) => { allowNull: false, defaultValue: Sequelize.UUIDV4 }, + xaiId: { + field: 'xai_id', + type: Sequelize.UUID + }, jobCandidateId: { field: 'job_candidate_id', type: Sequelize.UUID, allowNull: false }, - googleCalendarId: { - field: 'google_calendar_id', + calendarEventId: { + field: 'calendar_event_id', type: Sequelize.STRING(255) }, - customMessage: { - field: 'custom_message', - type: Sequelize.TEXT - }, - xaiTemplate: { - field: 'xai_template', + templateUrl: { + field: 'template_url', type: Sequelize.STRING(255), allowNull: false }, + templateId: { + field: 'template_id', + type: Sequelize.UUID + }, + templateType: { + field: 'template_type', + type: Sequelize.STRING(255) + }, + title: { + field: 'title', + type: Sequelize.STRING(255) + }, + locationDetails: { + field: 'location_details', + type: Sequelize.STRING(255) + }, + duration: { + field: 'duration', + type: Sequelize.INTEGER + }, round: { type: Sequelize.INTEGER, allowNull: false @@ -68,14 +88,34 @@ module.exports = (sequelize) => { field: 'start_timestamp', type: Sequelize.DATE }, - attendeesList: { - field: 'attendees_list', + endTimestamp: { + field: 'end_timestamp', + type: Sequelize.DATE + }, + hostName: { + field: 'host_name', + type: Sequelize.STRING(255) + }, + hostEmail: { + field: 'host_email', + type: Sequelize.STRING(255) + }, + guestNames: { + field: 'guest_names', + type: Sequelize.ARRAY(Sequelize.STRING) + }, + guestEmails: { + field: 'guest_emails', type: Sequelize.ARRAY(Sequelize.STRING) }, status: { type: Sequelize.ENUM(statuses), allowNull: false }, + rescheduleUrl: { + field: 'reschedule_url', + type: Sequelize.STRING(255) + }, createdBy: { field: 'created_by', type: Sequelize.UUID, diff --git a/src/routes/InterviewRoutes.js b/src/routes/InterviewRoutes.js index e8f8e960..e4a419ad 100644 --- a/src/routes/InterviewRoutes.js +++ b/src/routes/InterviewRoutes.js @@ -15,7 +15,15 @@ module.exports = { '/jobCandidates/:jobCandidateId/updateInterview/:round': { patch: { controller: 'InterviewController', - method: 'partiallyUpdateInterview', + method: 'partiallyUpdateInterviewByRound', + auth: 'jwt', + scopes: [constants.Scopes.UPDATE_INTERVIEW, constants.Scopes.ALL_INTERVIEW] + } + }, + '/updateInterview/:id': { + patch: { + controller: 'InterviewController', + method: 'partiallyUpdateInterviewById', auth: 'jwt', scopes: [constants.Scopes.UPDATE_INTERVIEW, constants.Scopes.ALL_INTERVIEW] } @@ -35,5 +43,13 @@ module.exports = { auth: 'jwt', scopes: [constants.Scopes.READ_INTERVIEW, constants.Scopes.ALL_INTERVIEW] } + }, + '/getInterview/:id': { + get: { + controller: 'InterviewController', + method: 'getInterviewById', + auth: 'jwt', + scopes: [constants.Scopes.READ_INTERVIEW, constants.Scopes.ALL_INTERVIEW] + } } } diff --git a/src/services/InterviewService.js b/src/services/InterviewService.js index 9d40cfeb..0d6e34c0 100644 --- a/src/services/InterviewService.js +++ b/src/services/InterviewService.js @@ -4,6 +4,7 @@ const _ = require('lodash') const Joi = require('joi') +const moment = require('moment') const config = require('config') const { Op, ForeignKeyConstraintError } = require('sequelize') const { v4: uuid } = require('uuid') @@ -86,7 +87,7 @@ async function getInterviewByRound (currentUser, jobCandidateId, round, fromDb = } } // either ES query failed or `fromDb` is set - fallback to DB - logger.info({ component: 'InterviewService', context: 'getInterview', message: 'try to query db for data' }) + logger.info({ component: 'InterviewService', context: 'getInterviewByRound', message: 'try to query db for data' }) const interview = await Interview.findOne({ where: { jobCandidateId, round } @@ -106,6 +107,94 @@ getInterviewByRound.schema = Joi.object().keys({ fromDb: Joi.boolean() }).required() +/** + * Get interview by id + * @param {Object} currentUser the user who perform this operation. + * @param {String} id the interview or xai id + * @param {Boolean} fromDb flag if query db for data or not + * @returns {Object} the interview + */ +async function getInterviewById (currentUser, id, fromDb = false) { + if (!fromDb) { + try { + // construct query for nested search + const esQueryBody = { + _source: false, + query: { + nested: { + path: 'interviews', + query: { + bool: { + should: [] + } + }, + inner_hits: {} + } + } + } + // add filtering terms + // interviewId + esQueryBody.query.nested.query.bool.should.push({ + term: { + 'interviews.id': { + value: id + } + } + }) + // xaiId + esQueryBody.query.nested.query.bool.should.push({ + term: { + 'interviews.xaiId': { + value: id + } + } + }) + // search + const { body } = await esClient.search({ + index: config.esConfig.ES_INDEX_JOB_CANDIDATE, + body: esQueryBody + }) + // extract inner interview hit from body - there's always one jobCandidate & interview hit as we search with IDs + const interview = _.get(body, 'hits.hits[0].inner_hits.interviews.hits.hits[0]._source') + if (interview) { + // check permission before returning + await ensureUserIsPermitted(currentUser, interview.jobCandidateId) + return interview + } + // if reaches here, the interview with this IDs is not found + throw new errors.NotFoundError(`Interview doesn't exist with id/xaiId: ${id}`) + } catch (err) { + logger.logFullError(err, { component: 'InterviewService', context: 'getInterviewById' }) + throw err + } + } + // either ES query failed or `fromDb` is set - fallback to DB + logger.info({ component: 'InterviewService', context: 'getInterviewById', message: 'try to query db for data' }) + + const interview = await Interview.findOne({ + where: { + [Op.or]: [ + { id }, + { xaiId: id } + ] + } + }) + // throw NotFound error if doesn't exist + if (!!interview !== true) { + throw new errors.NotFoundError(`Interview doesn't exist with id/xaiId: ${id}`) + } + // check permission before returning + await ensureUserIsPermitted(currentUser, interview.jobCandidateId) + + return interview.dataValues +} + +getInterviewById.schema = Joi.object().keys({ + currentUser: Joi.object().required(), + id: Joi.string().uuid().required(), + fromDb: Joi.boolean() +}).required() + /** * Request interview * @param {Object} currentUser the user who perform this operation @@ -117,10 +206,6 @@ async function requestInterview (currentUser, jobCandidateId, interview) { // check permission await ensureUserIsPermitted(currentUser, jobCandidateId) - interview.id = uuid() - interview.jobCandidateId = jobCandidateId - interview.createdBy = await helper.getUserId(currentUser.userId) - // find the round count const round = await Interview.count({ where: { jobCandidateId } @@ -130,7 +215,18 @@ async function requestInterview (currentUser, jobCandidateId, interview) { if (round >= InterviewConstants.MaxAllowedCount) { throw new errors.ConflictError(`You've reached the maximum allowed number (${InterviewConstants.MaxAllowedCount}) of interviews for this candidate.`) } + + // pre-populate fields + interview.id = uuid() + interview.jobCandidateId = jobCandidateId interview.round = round + 1 + interview.duration = InterviewConstants.XaiTemplate[interview.templateUrl] + interview.createdBy = await helper.getUserId(currentUser.userId) + // pre-populate hostName & guestNames + const hostMembers = await helper.getMemberDetailsByEmails([interview.hostEmail]) + const guestMembers = await helper.getMemberDetailsByEmails(interview.guestEmails) + interview.hostName = `${hostMembers[0].firstName} ${hostMembers[0].lastName}` + interview.guestNames = _.map(guestMembers, (guestMember) => `${guestMember.firstName} ${guestMember.lastName}`) try { // create the interview @@ -157,26 +253,58 @@ requestInterview.schema = Joi.object().keys({ currentUser: Joi.object().required(), jobCandidateId: Joi.string().uuid().required(), interview: Joi.object().keys({ - googleCalendarId: Joi.string().allow(null), - customMessage: Joi.string().allow(null), - xaiTemplate: Joi.xaiTemplate().required(), - attendeesList: Joi.array().items(Joi.string().email()).allow(null), + calendarEventId: Joi.string().allow(null), + templateUrl: Joi.xaiTemplate().required(), + hostEmail: Joi.string().email().required(), + guestEmails: Joi.array().items(Joi.string().email()).default([]), status: Joi.interviewStatus().default(InterviewConstants.Status.Scheduling) }).required() }).required() /** - * Patch (partially update) interview + * Updates interview + * + * @param {Object} currentUser user who performs the operation + * @param {Object} interview the existing interview object + * @param {Object} data object containing updated fields + * @returns {Object} updated interview + */ +async function partiallyUpdateInterview (currentUser, interview, data) { + // only status can be updated for Completed interviews + if (interview.status === InterviewConstants.Status.Completed) { + const updatedFields = _.keys(data) + if (updatedFields.length !== 1 || !_.includes(updatedFields, 'status')) { + throw new errors.BadRequestError('Only the "status" can be updated for Completed interviews.') + } + } + + // automatically set endTimestamp if startTimestamp is provided + if (data.startTimestamp && !!data.endTimestamp !== true) { + data.endTimestamp = moment(data.startTimestamp).add(interview.duration, 'minutes').toDate() + } + + data.updatedBy = await helper.getUserId(currentUser.userId) + try { + const updated = await interview.update(data) + await helper.postEvent(config.TAAS_INTERVIEW_UPDATE_TOPIC, updated.toJSON(), { oldValue: interview.toJSON() }) + return updated.dataValues + } catch (err) { + // gracefully handle if one of the common sequelize errors + handleSequelizeError(err, interview.jobCandidateId) + // if reaches here, it's not one of the common errors handled in `handleSequelizeError` + throw err + } +} + +/** + * Patch (partially update) interview by round * @param {Object} currentUser the user who perform this operation * @param {String} jobCandidateId the job candidate id * @param {Number} round the interview round * @param {Object} data object containing patched fields * @returns {Object} the patched interview object */ -async function partiallyUpdateInterview (currentUser, jobCandidateId, round, data) { - // check permission - await ensureUserIsPermitted(currentUser, jobCandidateId) - +async function partiallyUpdateInterviewByRound (currentUser, jobCandidateId, round, data) { const interview = await Interview.findOne({ where: { jobCandidateId, round @@ -186,49 +314,104 @@ async function partiallyUpdateInterview (currentUser, jobCandidateId, round, dat if (!!interview !== true) { throw new errors.NotFoundError(`Interview doesn't exist with jobCandidateId: ${jobCandidateId} and round: ${round}`) } + // check permission + await ensureUserIsPermitted(currentUser, jobCandidateId) - const oldValue = interview.toJSON() + return await partiallyUpdateInterview(currentUser, interview, data) +} - // only status can be updated for Completed interviews - if (oldValue.status === InterviewConstants.Status.Completed) { - const updatedFields = _.keys(data) - if (updatedFields.length !== 1 || !_.includes(updatedFields, 'status')) { - throw new errors.BadRequestError('Only the "status" can be updated for Completed interviews.') +partiallyUpdateInterviewByRound.schema = Joi.object().keys({ + currentUser: Joi.object().required(), + jobCandidateId: Joi.string().uuid().required(), + round: Joi.number().integer().positive().required(), + data: Joi.object().keys({ + xaiId: Joi.string().uuid().allow(null), + calendarEventId: Joi.string().when('status', { + is: [InterviewConstants.Status.Scheduled, InterviewConstants.Status.Rescheduled], + then: Joi.required(), + otherwise: Joi.allow(null) + }), + templateUrl: Joi.xaiTemplate(), + templateId: Joi.string().uuid().allow(null), + templateType: Joi.string().allow(null), + title: Joi.string().allow(null), + locationDetails: Joi.string().allow(null), + startTimestamp: Joi.date().greater('now').when('status', { + is: [InterviewConstants.Status.Scheduled, InterviewConstants.Status.Rescheduled], + then: Joi.required(), + otherwise: Joi.allow(null) + }), + endTimestamp: Joi.date().greater('now').when('startTimestamp', { + is: Joi.required(), + then: Joi.date().greater(Joi.ref('startTimestamp')), + otherwise: Joi.allow(null) + }), + hostName: Joi.string(), + hostEmail: Joi.string().email(), + guestNames: Joi.array().items(Joi.string()).allow(null), + guestEmails: Joi.array().items(Joi.string().email()).allow(null), + status: Joi.interviewStatus(), + rescheduleUrl: Joi.string().allow(null) + }).required().min(1) // at least one key - i.e. don't allow empty object +}).required() + +/** + * Patch (partially update) interview by id + * @param {Object} currentUser the user who perform this operation + * @param {String} id the interview or x.ai meeting id + * @param {Object} data object containing patched fields + * @returns {Object} the patched interview object + */ +async function partiallyUpdateInterviewById (currentUser, id, data) { + const interview = await Interview.findOne({ + where: { + [Op.or]: [ + { id }, + { xaiId: id } + ] } + }) + // throw NotFound error if doesn't exist + if (!!interview !== true) { + throw new errors.NotFoundError(`Interview doesn't exist with id/xaiId: ${id}`) } + // check permission + await ensureUserIsPermitted(currentUser, interview.jobCandidateId) - data.updatedBy = await helper.getUserId(currentUser.userId) - try { - const updated = await interview.update(data) - await helper.postEvent(config.TAAS_INTERVIEW_UPDATE_TOPIC, updated.toJSON(), { oldValue: oldValue }) - return updated.dataValues - } catch (err) { - // gracefully handle if one of the common sequelize errors - handleSequelizeError(err, jobCandidateId) - // if reaches here, it's not one of the common errors handled in `handleSequelizeError` - throw err - } + return await partiallyUpdateInterview(currentUser, interview, data) } -partiallyUpdateInterview.schema = Joi.object().keys({ +partiallyUpdateInterviewById.schema = Joi.object().keys({ currentUser: Joi.object().required(), - jobCandidateId: Joi.string().uuid().required(), - round: Joi.number().integer().positive().required(), + id: Joi.string().uuid().required(), data: Joi.object().keys({ - googleCalendarId: Joi.string().when('status', { + xaiId: Joi.string().uuid().required(), + calendarEventId: Joi.string().when('status', { is: [InterviewConstants.Status.Scheduled, InterviewConstants.Status.Rescheduled], then: Joi.required(), otherwise: Joi.allow(null) }), - customMessage: Joi.string().allow(null), - xaiTemplate: Joi.xaiTemplate(), + templateUrl: Joi.xaiTemplate(), + templateId: Joi.string().uuid().allow(null), + templateType: Joi.string().allow(null), + title: Joi.string().allow(null), + locationDetails: Joi.string().allow(null), startTimestamp: Joi.date().greater('now').when('status', { is: [InterviewConstants.Status.Scheduled, InterviewConstants.Status.Rescheduled], then: Joi.required(), otherwise: Joi.allow(null) }), - attendeesList: Joi.array().items(Joi.string().email()).allow(null), - status: Joi.interviewStatus() + endTimestamp: Joi.date().greater('now').when('startTimestamp', { + is: Joi.required(), + then: Joi.date().greater(Joi.ref('startTimestamp')), + otherwise: Joi.allow(null) + }), + hostName: Joi.string(), + hostEmail: Joi.string().email(), + guestNames: Joi.array().items(Joi.string()).allow(null), + guestEmails: Joi.array().items(Joi.string().email()).allow(null), + status: Joi.interviewStatus(), + rescheduleUrl: Joi.string().allow(null) }).required().min(1) // at least one key - i.e. don't allow empty object }).required() @@ -395,8 +578,10 @@ async function updateCompletedInterviews () { module.exports = { getInterviewByRound, + getInterviewById, requestInterview, - partiallyUpdateInterview, + partiallyUpdateInterviewByRound, + partiallyUpdateInterviewById, searchInterviews, updateCompletedInterviews } diff --git a/src/services/TeamService.js b/src/services/TeamService.js index d3ff606e..a1432fd1 100644 --- a/src/services/TeamService.js +++ b/src/services/TeamService.js @@ -325,7 +325,7 @@ async function sendEmail (currentUser, data) { subject: data.subject || template.subject, body: data.body || template.body } - for (var key in subjectBody) { + for (const key in subjectBody) { subjectBody[key] = await helper.substituteStringByObject(subjectBody[key], data.data) } const emailData = {