diff --git a/.gitignore b/.gitignore index ea148e00..bed42869 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,7 @@ build/Release # Dependency directories node_modules/ jspm_packages/ +scripts/withdrawn-migration/temp/ # Snowpack dependency directory (https://snowpack.dev/) web_modules/ diff --git a/README.md b/README.md index a61b99dd..a8215223 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,9 @@ ES_HOST=http://dockerhost:9200 DATABASE_URL=postgres://postgres:postgres@dockerhost:5432/postgres BUSAPI_URL=http://dockerhost:8002/v5 + # stripe + STRIPE_SECRET_KEY= + CURRENCY=usd ``` - Values from this file would be automatically used by many `npm` commands. @@ -215,6 +218,7 @@ To be able to change and test `taas-es-processor` locally you can follow the nex | `npm run services:up` | Start services via docker-compose for local development. | | `npm run services:down` | Stop services via docker-compose for local development. | | `npm run services:logs -- -f ` | View logs of some service inside docker-compose. | +| `npm run services:rebuild -- -f ` | Rebuild service container ignoring cache (useful when pushed something to the Git repository of service) | | `npm run local:init` | Recreate Database and Elasticsearch indexes and populate demo data for local development (removes any existent data). | | `npm run local:reset` | Recreate Database and Elasticsearch indexes (removes any existent data). | | `npm run cov` | Code Coverage Report. | @@ -337,6 +341,6 @@ When we add, update or delete models and/or endpoints we have to make sure that - Test, that when we migrate DB from the previous state using `npm run migrate`, we get exactly the same DB schema as if we create DB from scratch using command `npm run init-db force`. ## EMSI mapping -mapping EMSI tags to topcoder skills -Run `npm run emsi-mapping` to create the mapping file +mapping EMSI tags to topcoder skills +Run `npm run emsi-mapping` to create the mapping file It will take about 15 minutes to create the mapping file `script/emsi-mapping/emsi-skils-mapping.js` diff --git a/app-constants.js b/app-constants.js index 83dcbdb2..78bab1f3 100644 --- a/app-constants.js +++ b/app-constants.js @@ -152,6 +152,14 @@ const PaymentSchedulerStatus = { CLOSE_CHALLENGE: 'close-challenge' } +const JobStatus = { + OPEN: 'open' +} + +const JobCandidateStatus = { + INTERVIEW: 'interview' +} + module.exports = { UserRoles, FullManagePermissionRoles, @@ -164,5 +172,7 @@ module.exports = { PaymentSchedulerStatus, PaymentProcessingSwitch, PaymentStatusRules, - ActiveWorkPeriodPaymentStatuses + ActiveWorkPeriodPaymentStatuses, + JobStatus, + JobCandidateStatus } diff --git a/app.js b/app.js index e6d79c69..3e60e0e8 100644 --- a/app.js +++ b/app.js @@ -14,6 +14,8 @@ const logger = require('./src/common/logger') const eventHandlers = require('./src/eventHandlers') const interviewService = require('./src/services/InterviewService') const { processScheduler } = require('./src/services/PaymentSchedulerService') +const { sendSurveys } = require('./src/services/SurveyService') +const emailNotificationService = require('./src/services/EmailNotificationService') // setup express app const app = express() @@ -98,9 +100,16 @@ const server = app.listen(app.get('port'), () => { eventHandlers.init() // schedule updateCompletedInterviews to run every hour schedule.scheduleJob('0 0 * * * *', interviewService.updateCompletedInterviews) - + // schedule sendSurveys + schedule.scheduleJob(config.WEEKLY_SURVEY.CRON, sendSurveys) // schedule payment processing schedule.scheduleJob(config.PAYMENT_PROCESSING.CRON, processScheduler) + + schedule.scheduleJob(config.CRON_CANDIDATE_REVIEW, emailNotificationService.sendCandidatesAvailableEmails) + schedule.scheduleJob(config.CRON_INTERVIEW_COMING_UP, emailNotificationService.sendInterviewComingUpEmails) + schedule.scheduleJob(config.CRON_INTERVIEW_COMPLETED, emailNotificationService.sendInterviewCompletedEmails) + schedule.scheduleJob(config.CRON_POST_INTERVIEW, emailNotificationService.sendPostInterviewActionEmails) + schedule.scheduleJob(config.CRON_UPCOMING_RESOURCE_BOOKING, emailNotificationService.sendResourceBookingExpirationEmails) }) if (process.env.NODE_ENV === 'test') { diff --git a/config/default.js b/config/default.js index cb589290..1ddb0594 100644 --- a/config/default.js +++ b/config/default.js @@ -147,6 +147,8 @@ module.exports = { // the Kafka message topic for sending email EMAIL_TOPIC: process.env.EMAIL_TOPIC || 'external.action.email', + // the Kafka message topic for creating notifications + NOTIFICATIONS_CREATE_TOPIC: process.env.NOTIFICATIONS_CREATE_TOPIC || 'notifications.action.create', // the emails address for receiving the issue report // REPORT_ISSUE_EMAILS may contain comma-separated list of email which is converted to array REPORT_ISSUE_EMAILS: (process.env.REPORT_ISSUE_EMAILS || '').split(','), @@ -180,6 +182,17 @@ module.exports = { INTERNAL_MEMBER_GROUPS: process.env.INTERNAL_MEMBER_GROUPS || ['20000000', '20000001', '20000003', '20000010', '20000015'], // Topcoder skills cache time in minutes TOPCODER_SKILLS_CACHE_TIME: process.env.TOPCODER_SKILLS_CACHE_TIME || 60, + // weekly survey scheduler config + WEEKLY_SURVEY: { + CRON: process.env.WEEKLY_SURVEY_CRON || '0 1 * * 7', + BASE_URL: process.env.WEEKLY_SURVEY_BASE_URL || 'https://api.surveymonkey.net/v3/surveys', + JWT_TOKEN: process.env.WEEKLY_SURVEY_JWT_TOKEN || '', + SURVEY_ID: process.env.WEEKLY_SURVEY_SURVEY_ID || '', + SURVEY_COLLECTOR_PREFIX: process.env.WEEKLY_SURVEY_SURVEY_COLLECTOR_PREFIX || 'Week ending', + SURVEY_MASTER_COLLECTOR_ID: process.env.WEEKLY_SURVEY_SURVEY_MASTER_COLLECTOR_ID || '', + SURVEY_MASTER_MESSAGE_ID: process.env.WEEKLY_SURVEY_SURVEY_MASTER_MESSAGE_ID || '', + SURVEY_CONTACT_GROUP_ID: process.env.WEEKLY_SURVEY_SURVEY_CONTACT_GROUP_ID || '' + }, // payment scheduler config PAYMENT_PROCESSING: { // switch off actual API calls in Payment Scheduler @@ -226,5 +239,31 @@ module.exports = { interview: 'withdrawn', selected: 'withdrawn', offered: 'withdrawn' - } + }, + // the sender email + NOTIFICATION_SENDER_EMAIL: process.env.NOTIFICATION_SENDER_EMAIL, + // the email notification sendgrid template id + NOTIFICATION_SENDGRID_TEMPLATE_ID: process.env.NOTIFICATION_SENDGRID_TEMPLATE_ID, + // frequency of cron checking for available candidates for review + CRON_CANDIDATE_REVIEW: process.env.CRON_CANDIDATE_REVIEW || '00 00 13 * * 0-6', + // frequency of cron checking for coming up interviews + // when changing this to frequency other than 5 mins, please change the minutesRange in sendInterviewComingUpEmails correspondingly + CRON_INTERVIEW_COMING_UP: process.env.CRON_INTERVIEW_COMING_UP || '*/5 * * * *', + // frequency of cron checking for interview completed + // when changing this to frequency other than 5 mins, please change the minutesRange in sendInterviewCompletedEmails correspondingly + CRON_INTERVIEW_COMPLETED: process.env.CRON_INTERVIEW_COMPLETED || '*/5 * * * *', + // frequency of cron checking for post interview actions + CRON_POST_INTERVIEW: process.env.CRON_POST_INTERVIEW || '00 00 13 * * 0-6', + // frequency of cron checking for upcoming resource bookings + CRON_UPCOMING_RESOURCE_BOOKING: process.env.CRON_UPCOMING_RESOURCE_BOOKING || '00 00 13 * * 1', + // The match window for fetching interviews which are coming up + INTERVIEW_COMING_UP_MATCH_WINDOW: process.env.INTERVIEW_COMING_UP_MATCH_WINDOW || 'PT5M', + // The remind time for fetching interviews which are coming up + INTERVIEW_COMING_UP_REMIND_TIME: (process.env.INTERVIEW_COMING_UP_REMIND_TIME || 'PT1H,PT24H').split(','), + // The match window for fetching completed interviews + INTERVIEW_COMPLETED_MATCH_WINDOW: process.env.INTERVIEW_COMPLETED_MATCH_WINDOW || 'PT5M', + // The interview completed past time for fetching interviews + INTERVIEW_COMPLETED_PAST_TIME: process.env.INTERVIEW_COMPLETED_PAST_TIME || 'PT4H', + // The time before resource booking expiry when we should start sending notifications + RESOURCE_BOOKING_EXPIRY_TIME: process.env.RESOURCE_BOOKING_EXPIRY_TIME || 'P21D' } diff --git a/config/email_template.config.js b/config/email_template.config.js index e223ce95..c76bd482 100644 --- a/config/email_template.config.js +++ b/config/email_template.config.js @@ -6,99 +6,152 @@ const config = require('config') module.exports = { - /* Report a general issue for a team. - * - * - projectId: the project ID. Example: 123412 - * - projectName: the project name. Example: "TaaS API Misc Updates" - * - reportText: the body of reported issue. Example: "I have issue with ... \n ... Thank you in advance!" + /** + * List all the kind of emails which could be sent by the endpoint `POST /taas-teams/email` inside `teamTemplates`. */ - 'team-issue-report': { - subject: 'Issue Reported on TaaS Team {{projectName}} ({{projectId}}).', - body: 'Project Name: {{projectName}}' + '\n' + - 'Project ID: {{projectId}}' + '\n' + - `Project URL: ${config.TAAS_APP_URL}/{{projectId}}` + '\n' + - '\n' + - '{{reportText}}', - recipients: config.REPORT_ISSUE_EMAILS, - sendgridTemplateId: config.REPORT_ISSUE_SENDGRID_TEMPLATE_ID - }, + teamTemplates: { + /* Report a general issue for a team. + * + * - projectId: the project ID. Example: 123412 + * - projectName: the project name. Example: "TaaS API Misc Updates" + * - reportText: the body of reported issue. Example: "I have issue with ... \n ... Thank you in advance!" + */ + 'team-issue-report': { + subject: 'Issue Reported on TaaS Team {{projectName}} ({{projectId}}).', + body: 'Project Name: {{projectName}}' + '\n' + + 'Project ID: {{projectId}}' + '\n' + + `Project URL: ${config.TAAS_APP_URL}/{{projectId}}` + '\n' + + '\n' + + '{{reportText}}', + recipients: config.REPORT_ISSUE_EMAILS, + sendgridTemplateId: config.REPORT_ISSUE_SENDGRID_TEMPLATE_ID + }, - /* Report issue for a particular member - * - * - userHandle: the user handle. Example: "bili_2021" - * - projectId: the project ID. Example: 123412 - * - projectName: the project name. Example: "TaaS API Misc Updates" - * - reportText: the body of reported issue. Example: "I have issue with ... \n ... Thank you in advance!" - */ - 'member-issue-report': { - subject: 'Issue Reported for member {{userHandle}} on TaaS Team {{projectName}} ({{projectId}}).', - body: 'User Handle: {{userHandle}}' + '\n' + - 'Project Name: {{projectName}}' + '\n' + - 'Project ID: {{projectId}}' + '\n' + - `Project URL: ${config.TAAS_APP_URL}/{{projectId}}` + '\n' + - '\n' + - '{{reportText}}', - recipients: config.REPORT_ISSUE_EMAILS, - sendgridTemplateId: config.REPORT_ISSUE_SENDGRID_TEMPLATE_ID - }, + /* Report issue for a particular member + * + * - userHandle: the user handle. Example: "bili_2021" + * - projectId: the project ID. Example: 123412 + * - projectName: the project name. Example: "TaaS API Misc Updates" + * - reportText: the body of reported issue. Example: "I have issue with ... \n ... Thank you in advance!" + */ + 'member-issue-report': { + subject: 'Issue Reported for member {{userHandle}} on TaaS Team {{projectName}} ({{projectId}}).', + body: 'User Handle: {{userHandle}}' + '\n' + + 'Project Name: {{projectName}}' + '\n' + + 'Project ID: {{projectId}}' + '\n' + + `Project URL: ${config.TAAS_APP_URL}/{{projectId}}` + '\n' + + '\n' + + '{{reportText}}', + recipients: config.REPORT_ISSUE_EMAILS, + sendgridTemplateId: config.REPORT_ISSUE_SENDGRID_TEMPLATE_ID + }, - /* Request extension for a particular member - * - * - userHandle: the user handle. Example: "bili_2021" - * - projectId: the project ID. Example: 123412 - * - projectName: the project name. Example: "TaaS API Misc Updates" - * - text: comment for the request. Example: "I would like to keep working with this member for 2 months..." - */ - 'extension-request': { - subject: 'Extension Requested for member {{userHandle}} on TaaS Team {{projectName}} ({{projectId}}).', - body: 'User Handle: {{userHandle}}' + '\n' + - 'Project Name: {{projectName}}' + '\n' + - 'Project ID: {{projectId}}' + '\n' + - `Project URL: ${config.TAAS_APP_URL}/{{projectId}}` + '\n' + - '\n' + - '{{text}}', - recipients: config.REPORT_ISSUE_EMAILS, - sendgridTemplateId: config.REQUEST_EXTENSION_SENDGRID_TEMPLATE_ID + /* Request extension for a particular member + * + * - userHandle: the user handle. Example: "bili_2021" + * - projectId: the project ID. Example: 123412 + * - projectName: the project name. Example: "TaaS API Misc Updates" + * - text: comment for the request. Example: "I would like to keep working with this member for 2 months..." + */ + 'extension-request': { + subject: 'Extension Requested for member {{userHandle}} on TaaS Team {{projectName}} ({{projectId}}).', + body: 'User Handle: {{userHandle}}' + '\n' + + 'Project Name: {{projectName}}' + '\n' + + 'Project ID: {{projectId}}' + '\n' + + `Project URL: ${config.TAAS_APP_URL}/{{projectId}}` + '\n' + + '\n' + + '{{text}}', + recipients: config.REPORT_ISSUE_EMAILS, + sendgridTemplateId: config.REQUEST_EXTENSION_SENDGRID_TEMPLATE_ID + }, + + /* Request interview for a job candidate + * + * - interviewType: the x.ai interview type. Example: "interview-30" + * - interviewRound: the round of the interview. Example: 2 + * - interviewDuration: duration of the interview, in minutes. Example: 30 + * - interviewerList: The list of interviewer email addresses. Example: "first@attendee.com, second@attendee.com" + * - candidateId: the id of the jobCandidate. Example: "cc562545-7b75-48bf-87e7-50b3c57e41b1" + * - candidateName: Full name of candidate. Example: "John Doe" + * - jobName: The title of the job. Example: "TaaS API Misc Updates" + * + * Template (defined in SendGrid): + * Subject: '{{interviewType}} tech interview with {{candidateName}} for {{jobName}} is requested by the Customer' + * Body: + * 'Hello! + *

+ * Congratulations, you have been selected to participate in a Topcoder Gig Work Interview! + *

+ * Please monitor your email for a response to this where you can coordinate your availability. + *

+ * Interviewee: {{candidateName}}
+ * Interviewer(s): {{interviewerList}}
+ * Interview Length: {{interviewDuration}} minutes + *

+ * /{{interviewType}} + *

+ * Topcoder Info:
+ * Note: "id: {{candidateId}}, round: {{interviewRound}}"' + * + * Note, that the template should be defined in SendGrid. + * The subject & body above (identical to actual SendGrid template) is for reference purposes. + * We won't pass subject & body but only substitutions (replacements in template subject/body). + */ + 'interview-invitation': { + subject: '', + body: '', + from: config.INTERVIEW_INVITATION_SENDER_EMAIL, + cc: config.INTERVIEW_INVITATION_CC_LIST, + recipients: config.INTERVIEW_INVITATION_RECIPIENTS_LIST, + sendgridTemplateId: config.INTERVIEW_INVITATION_SENDGRID_TEMPLATE_ID + } }, - /* Request interview for a job candidate - * - * - interviewType: the x.ai interview type. Example: "interview-30" - * - interviewRound: the round of the interview. Example: 2 - * - interviewDuration: duration of the interview, in minutes. Example: 30 - * - interviewerList: The list of interviewer email addresses. Example: "first@attendee.com, second@attendee.com" - * - candidateId: the id of the jobCandidate. Example: "cc562545-7b75-48bf-87e7-50b3c57e41b1" - * - candidateName: Full name of candidate. Example: "John Doe" - * - jobName: The title of the job. Example: "TaaS API Misc Updates" - * - * Template (defined in SendGrid): - * Subject: '{{interviewType}} tech interview with {{candidateName}} for {{jobName}} is requested by the Customer' - * Body: - * 'Hello! - *

- * Congratulations, you have been selected to participate in a Topcoder Gig Work Interview! - *

- * Please monitor your email for a response to this where you can coordinate your availability. - *

- * Interviewee: {{candidateName}}
- * Interviewer(s): {{interviewerList}}
- * Interview Length: {{interviewDuration}} minutes - *

- * /{{interviewType}} - *

- * Topcoder Info:
- * Note: "id: {{candidateId}}, round: {{interviewRound}}"' - * - * Note, that the template should be defined in SendGrid. - * The subject & body above (identical to actual SendGrid template) is for reference purposes. - * We won't pass subject & body but only substitutions (replacements in template subject/body). + /** + * List all kind of emails which could be send as Email Notifications by scheduler, API endpoints or anything else. */ - 'interview-invitation': { - subject: '', - body: '', - from: config.INTERVIEW_INVITATION_SENDER_EMAIL, - cc: config.INTERVIEW_INVITATION_CC_LIST, - recipients: config.INTERVIEW_INVITATION_RECIPIENTS_LIST, - sendgridTemplateId: config.INTERVIEW_INVITATION_SENDGRID_TEMPLATE_ID + notificationEmailTemplates: { + 'taas.notification.candidates-available-for-review': { + subject: 'Topcoder - {{teamName}} has job candidates available for review', + body: '', + recipients: [], + from: config.NOTIFICATION_SENDER_EMAIL, + sendgridTemplateId: config.NOTIFICATION_SENDGRID_TEMPLATE_ID + }, + 'taas.notification.interview-coming-up-host': { + subject: 'Topcoder - Interview Coming Up: {{jobTitle}} with {{guestFullName}}', + body: '', + recipients: [], + from: config.NOTIFICATION_SENDER_EMAIL, + sendgridTemplateId: config.NOTIFICATION_SENDGRID_TEMPLATE_ID + }, + 'taas.notification.interview-coming-up-guest': { + subject: 'Topcoder - Interview Coming Up: {{jobTitle}} with {{hostFullName}}', + body: '', + recipients: [], + from: config.NOTIFICATION_SENDER_EMAIL, + sendgridTemplateId: config.NOTIFICATION_SENDGRID_TEMPLATE_ID + }, + 'taas.notification.interview-awaits-resolution': { + subject: 'Topcoder - Interview Awaits Resolution: {{jobTitle}} for {{guestFullName}}', + body: '', + recipients: [], + from: config.NOTIFICATION_SENDER_EMAIL, + sendgridTemplateId: config.NOTIFICATION_SENDGRID_TEMPLATE_ID + }, + 'taas.notification.post-interview-action-required': { + subject: 'Topcoder - Candidate Action Required in {{teamName}} for {{numCandidates}} candidates', + body: '', + recipients: [], + from: config.NOTIFICATION_SENDER_EMAIL, + sendgridTemplateId: config.NOTIFICATION_SENDGRID_TEMPLATE_ID + }, + 'taas.notification.resource-booking-expiration': { + subject: 'Topcoder - Resource Booking Expiring in {{teamName}} for {{numResourceBookings}} resource bookings', + body: '', + recipients: [], + from: config.NOTIFICATION_SENDER_EMAIL, + sendgridTemplateId: config.NOTIFICATION_SENDGRID_TEMPLATE_ID + } } } diff --git a/data/demo-data.json b/data/demo-data.json index 9d939c4c..0e67b097 100644 --- a/data/demo-data.json +++ b/data/demo-data.json @@ -54,7 +54,7 @@ "isApplicationPageActive": false, "minSalary": 100, "maxSalary": 200, - "hoursPerWeek": 20, + "hoursPerWeek": 80, "jobLocation": "Any location", "jobTimezone": "GMT", "currency": "USD", @@ -86,7 +86,7 @@ "isApplicationPageActive": false, "minSalary": 100, "maxSalary": 200, - "hoursPerWeek": 20, + "hoursPerWeek": 90, "jobLocation": "Any location", "jobTimezone": "GMT", "currency": "USD", @@ -765,7 +765,7 @@ "id": "b0fc417b-3f41-4c06-9f2b-8e680c3a03c6", "jobId": "728ff056-63f6-4730-8a9f-3074acad8479", "userId": "a55fe1bc-1754-45fa-9adc-cf3d6d7c377a", - "status": "open", + "status": "placed", "externalId": "300234321", "resume": "http://example.com", "remark": "excellent", @@ -793,7 +793,7 @@ "id": "02a622f4-7894-4ac0-a823-a952ffa1b3f3", "jobId": "728ff056-63f6-4730-8a9f-3074acad8479", "userId": "a55fe1bc-1754-45fa-9adc-cf3d6d7c377a", - "status": "open", + "status": "selected", "externalId": "300234321", "resume": "http://example.com", "remark": "excellent", @@ -807,7 +807,7 @@ "id": "b32b4819-7bfa-49a8-851e-69cdddff8149", "jobId": "728ff056-63f6-4730-8a9f-3074acad8479", "userId": "a55fe1bc-1754-45fa-9adc-cf3d6d7c377a", - "status": "open", + "status": "skills-test", "externalId": "300234321", "resume": "http://example.com", "remark": "excellent", @@ -833,9 +833,9 @@ }, { "id": "881a19de-2b0c-4bb9-b36a-4cb5e223bdb5", - "jobId": "728ff056-63f6-4730-8a9f-3074acad8479", + "jobId": "a8adb1f8-a6ee-48b1-8661-33bd851b726e", "userId": "a55fe1bc-1754-45fa-9adc-cf3d6d7c377a", - "status": "open", + "status": "placed", "externalId": "300234321", "resume": "http://example.com", "remark": "excellent", @@ -859,9 +859,9 @@ "startTimestamp": null, "endTimestamp": null, "hostName": null, - "hostEmail": null, - "guestNames": null, - "guestEmails": null, + "hostEmail": "interviewhost@tc.com", + "guestNames": ["guest name1", "guest name2"], + "guestEmails": ["guest1@tc.com", "guest2@tc.com"], "status": "Completed", "rescheduleUrl": null, "createdBy": "57646ff9-1cd3-4d3c-88ba-eb09a395366c", @@ -900,9 +900,9 @@ "startTimestamp": null, "endTimestamp": null, "hostName": null, - "hostEmail": null, - "guestNames": null, - "guestEmails": null, + "hostEmail": "interviewhost@tc.com", + "guestNames": ["guest name1", "guest name2"], + "guestEmails": ["guest1@tc.com", "guest2@tc.com"], "status": "Scheduling", "rescheduleUrl": null, "createdBy": "57646ff9-1cd3-4d3c-88ba-eb09a395366c", @@ -926,9 +926,9 @@ "startTimestamp": null, "endTimestamp": null, "hostName": null, - "hostEmail": null, - "guestNames": null, - "guestEmails": null, + "hostEmail": "interviewhost@tc.com", + "guestNames": ["guest name1", "guest name2"], + "guestEmails": ["guest1@tc.com", "guest2@tc.com"], "status": "Completed", "rescheduleUrl": null, "createdBy": "57646ff9-1cd3-4d3c-88ba-eb09a395366c", @@ -967,9 +967,9 @@ "startTimestamp": null, "endTimestamp": null, "hostName": null, - "hostEmail": null, - "guestNames": null, - "guestEmails": null, + "hostEmail": "interviewhost@tc.com", + "guestNames": ["guest name1", "guest name2"], + "guestEmails": ["guest1@tc.com", "guest2@tc.com"], "status": "Scheduling", "rescheduleUrl": null, "createdBy": "57646ff9-1cd3-4d3c-88ba-eb09a395366c", @@ -993,9 +993,9 @@ "startTimestamp": null, "endTimestamp": null, "hostName": null, - "hostEmail": null, - "guestNames": null, - "guestEmails": null, + "hostEmail": "interviewhost@tc.com", + "guestNames": ["guest name1", "guest name2"], + "guestEmails": ["guest1@tc.com", "guest2@tc.com"], "status": "Scheduling", "rescheduleUrl": null, "createdBy": "57646ff9-1cd3-4d3c-88ba-eb09a395366c", @@ -1019,9 +1019,9 @@ "startTimestamp": null, "endTimestamp": null, "hostName": null, - "hostEmail": null, - "guestNames": null, - "guestEmails": null, + "hostEmail": "interviewhost@tc.com", + "guestNames": ["guest name1", "guest name2"], + "guestEmails": ["guest1@tc.com", "guest2@tc.com"], "status": "Completed", "rescheduleUrl": null, "createdBy": "57646ff9-1cd3-4d3c-88ba-eb09a395366c", @@ -1359,11 +1359,11 @@ { "id": "d6103727-6615-4168-8169-0485577bfb3f", "projectId": 111, - "userId": "bef43122-426b-4b2b-acdd-9b5b3bd1c0bf", - "jobId": "a8adb1f8-a6ee-48b1-8661-33bd851b726e", + "userId": "a55fe1bc-1754-45fa-9adc-cf3d6d7c377a", + "jobId": "728ff056-63f6-4730-8a9f-3074acad8479", "status": "placed", "startDate": "2021-03-27", - "endDate": "2021-04-27", + "endDate": "2021-08-23", "memberRate": 13.23, "customerRate": 13, "rateType": "hourly", @@ -1645,11 +1645,11 @@ { "id": "c0a12936-77ef-46fa-8c75-6996339e79f6", "projectId": 111, - "userId": "05e988b7-7d54-4c10-ada1-1a04870a88a8", "jobId": "a8adb1f8-a6ee-48b1-8661-33bd851b726e", + "userId": "a55fe1bc-1754-45fa-9adc-cf3d6d7c377a", "status": "placed", "startDate": "2020-09-27", - "endDate": "2020-10-27", + "endDate": "2021-10-27", "memberRate": 13.23, "customerRate": 13, "rateType": "hourly", diff --git a/data/notifications-email-demo-data.json b/data/notifications-email-demo-data.json new file mode 100644 index 00000000..98c19b5f --- /dev/null +++ b/data/notifications-email-demo-data.json @@ -0,0 +1,232 @@ +{ + "candidatesAvailableForReview": { + "teamName": "the thing three", + "teamJobs": [ + { + "title": "Dummy title - at most 64 characters", + "nResourceBookings": 0, + "jobCandidates": [ + { + "handle": "testfordevemail", + "name": "John Doe", + "status": "open" + } + ], + "reviewLink": "https://platform.topcoder-dev.com/taas/myteams/111/positions/36dad9f2-98ed-4d3a-9ea7-2cd3d0f8a51a/candidates/to-review" + }, + { + "title": "Dummy title - at most 64 characters", + "nResourceBookings": 0, + "jobCandidates": [ + { + "handle": "pshah_manager", + "name": "pshah manager", + "status": "open" + }, + { + "handle": "pshah_manager", + "name": "pshah manager", + "status": "open" + }, + { + "handle": "pshah_manager", + "name": "pshah manager", + "status": "open" + }, + { + "handle": "pshah_manager", + "name": "pshah manager", + "status": "open" + }, + { + "handle": "pshah_manager", + "name": "pshah manager", + "status": "open" + }, + { + "handle": "pshah_manager", + "name": "pshah manager", + "status": "open" + }, + { + "handle": "testfordevemail", + "name": "John Doe", + "status": "open" + } + ], + "reviewLink": "https://platform.topcoder-dev.com/taas/myteams/111/positions/728ff056-63f6-4730-8a9f-3074acad8479/candidates/to-review" + } + ], + "notificationType": { + "candidatesAvailableForReview": true + }, + "description": "Candidates are available for review", + "subject": "Topcoder - the thing three has job candidates available for review", + "body": "" + }, + "interviewComingUpForHost": { + "jobTitle": "Dummy title - at most 64 characters", + "guestFullName": "name1", + "hostFullName": "host name", + "candidateName": "John Doe", + "handle": "testfordevemail", + "attendees": [ + "name1", + "name2", + "name3" + ], + "startTime": "Tue, 27 Jul 2021 21:21:17 GMT", + "duration": 120, + "interviewLink": "https://platform.topcoder-dev.com/taas/myteams/111/positions/36dad9f2-98ed-4d3a-9ea7-2cd3d0f8a51a/candidates/interviews", + "notificationType": { + "interviewComingUpForHost": true + }, + "description": "Interview Coming Up", + "subject": "Topcoder - Interview Coming Up: Dummy title - at most 64 characters with name1", + "body": "" + }, + + "interviewComingUpForGuest": { + "jobTitle": "Dummy title - at most 64 characters", + "guestFullName": "name1", + "hostFullName": "host name", + "candidateName": "John Doe", + "handle": "testfordevemail", + "attendees": [ + "name1", + "name2", + "name3" + ], + "startTime": "Tue, 27 Jul 2021 21:21:17 GMT", + "duration": 120, + "interviewLink": "https://platform.topcoder-dev.com/taas/myteams/111/positions/36dad9f2-98ed-4d3a-9ea7-2cd3d0f8a51a/candidates/interviews", + "notificationType": { + "interviewComingUpForGuest": true + }, + "description": "Interview Coming Up", + "subject": "Topcoder - Interview Coming Up: Dummy title - at most 64 characters with host name", + "body": "" + }, + + "interviewCompleted": { + "jobTitle": "Dummy title - at most 64 characters", + "guestFullName": "name1", + "hostFullName": null, + "candidateName": "John Doe", + "handle": "testfordevemail", + "attendees": [ + "name1", + "name2", + "name3" + ], + "startTime": "Tue, 27 Jul 2021 21:21:17 GMT", + "duration": 120, + "interviewLink": "https://platform.topcoder-dev.com/taas/myteams/111/positions/36dad9f2-98ed-4d3a-9ea7-2cd3d0f8a51a/candidates/interviews", + "notificationType": { + "interviewCompleted": true + }, + "description": "Interview Completed", + "subject": "Topcoder - Interview Awaits Resolution: Dummy title - at most 64 characters for name1", + "body": "" + }, + + "postInterviewCandidateAction": { + "teamName": "the thing three", + "numCandidates": 3, + "teamInterviews": [ + { + "jobTitle": "Dummy title - at most 64 characters", + "guestFullName": "name1", + "hostFullName": "host name", + "candidateName": "John Doe", + "handle": "testfordevemail", + "attendees": [ + "name1", + "name2", + "name3" + ], + "startTime": "Tue, 27 Jul 2021 21:21:17 GMT", + "duration": 120, + "interviewLink": "https://platform.topcoder-dev.com/taas/myteams/111/positions/36dad9f2-98ed-4d3a-9ea7-2cd3d0f8a51a/candidates/interviews" + }, + { + "jobTitle": "Dummy title - at most 64 characters", + "guestFullName": "guest1", + "hostFullName": null, + "candidateName": "pshah manager", + "handle": "pshah_manager", + "attendees": [ + "guest1", + "guest2", + "guest3" + ], + "startTime": "Tue, 27 Jul 2021 21:21:17 GMT", + "duration": 30, + "interviewLink": "https://platform.topcoder-dev.com/taas/myteams/111/positions/728ff056-63f6-4730-8a9f-3074acad8479/candidates/interviews" + }, + { + "jobTitle": "Dummy title - at most 64 characters", + "guestFullName": "g name1", + "hostFullName": null, + "candidateName": "John Doe", + "handle": "testfordevemail", + "attendees": [ + "g name1", + "g name2" + ], + "startTime": "Tue, 27 Jul 2021 21:21:17 GMT", + "duration": 60, + "interviewLink": "https://platform.topcoder-dev.com/taas/myteams/111/positions/728ff056-63f6-4730-8a9f-3074acad8479/candidates/interviews" + } + ], + "notificationType": { + "postInterviewCandidateAction": true + }, + "description": "Post Interview Candidate Action Reminder", + "subject": "Topcoder - Candidate Action Required in the thing three for 3 candidates", + "body": "" + }, + + "upcomingResourceBookingExpiration": { + "teamName": "the thing three", + "numResourceBookings": 6, + "teamResourceBookings": [ + { + "jobTitle": "Dummy title - at most 64 characters", + "handle": "testcat", + "endDate": "2021-04-27" + }, + { + "jobTitle": "Dummy title - at most 64 characters", + "handle": "sachin-wipro", + "endDate": "2020-10-27" + }, + { + "jobTitle": "Dummy title - at most 64 characters", + "handle": "aaaa", + "endDate": "2021-04-27" + }, + { + "jobTitle": "Dummy title - at most 64 characters", + "handle": "pshah_manager", + "endDate": "2021-01-27" + }, + { + "jobTitle": "Dummy title - at most 64 characters", + "handle": "amy_admin", + "endDate": "2021-06-15" + }, + { + "jobTitle": "Dummy title - at most 64 characters", + "handle": "lakshmiaconnmgr", + "endDate": "2021-06-15" + } + ], + "notificationType": { + "upcomingResourceBookingExpiration": true + }, + "description": "Upcoming Resource Booking Expiration", + "subject": "Topcoder - Resource Booking Expiring in the thing three for 6 resource bookings", + "body": "" + } +} diff --git a/data/notifications-email-template.html b/data/notifications-email-template.html new file mode 100644 index 00000000..7dcb8f30 --- /dev/null +++ b/data/notifications-email-template.html @@ -0,0 +1,385 @@ +
+ + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + +
+ + + + + + + + +
IMG +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + +
+ Topcoder Gig Work
+
+ + + + + + + + + +
IMG + {{description}} +
+
+ + + + + + +
+ + + + + + + + +
+ + {{#if notificationType.candidatesAvailableForReview}} + + + + + + + + {{#each teamJobs}} + + + + + + + {{/each}} +
Job titleNo of resource bookingsCandidatesStatus
{{this.title}}{{this.nResourceBookings}} + + +
    + {{#each this.jobCandidates}} +
  • {{this.status}}
  • + {{/each}} +
+
+ {{/if}} + + {{#if notificationType.interviewComingUpForHost}} + + + + + + + + + + + + + + + +
Job titleCandidate HandleInterviewsAttendeesDate and Time
{{this.jobTitle}}{{this.handle}} + Link +
    + {{#each this.attendees}} +
  • {{this}}
  • + {{/each}} +
+
{{this.startTime}}
+ {{/if}} + + {{#if notificationType.interviewComingUpForGuest}} + + + + + + + + + + + +
Job titleDate and TimeInterviews
{{this.jobTitle}}{{this.startTime}} + Link
+ {{/if}} + + {{#if notificationType.interviewCompleted}} + + + + + + + + + + + + + + + +
Job titleCandidate HandleDate and TimeAttendeesInterviews
{{this.jobTitle}}{{this.handle}}{{this.startTime}} +
    + {{#each this.attendees}} +
  • {{this}}
  • + {{/each}} +
+
+ Link
+ {{/if}} + + {{#if notificationType.postInterviewCandidateAction}} + + + + + + + + + {{#each teamInterviews}} + + + + + + + + {{/each}} +
Job titleHandleDate and TimeAttendeesInterviews
{{this.jobTitle}}{{this.handle}}{{this.startTime}} +
    + {{#each this.attendees}} +
  • {{this}}
  • + {{/each}} +
+
+ Link
+ {{/if}} + + {{#if notificationType.upcomingResourceBookingExpiration}} + Team Name: + {{teamName}} + + + + + + + {{#each teamResourceBookings}} + + + + + + {{/each}} +
Job titleResource Bookings HandleEnd Date
{{this.jobTitle}}{{this.handle}}{{this.endDate}}
+ {{/if}} + +
+ If you have any questions about this process or if you encounter any issues coordinating your availability, you may reply to this email or send a separate email to our Gig Work operations team. + +

Thanks!
+ The Topcoder Team +

+
+
+
+ + + + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+
+ + + + + + + + + + + + + + + + + +
+ + + + + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ 201 S Capitol Ave #1100
+ Indianapolis, IN 46225 United States
+
+ ●●●
+
+ Topcoder System Information: +
+ InterviewType: {{xai_template}}
+
+ + + + + + +
+
+
diff --git a/docs/Topcoder-bookings-api.postman_collection.json b/docs/Topcoder-bookings-api.postman_collection.json index 8ee3ef14..9aec690c 100644 --- a/docs/Topcoder-bookings-api.postman_collection.json +++ b/docs/Topcoder-bookings-api.postman_collection.json @@ -1,6 +1,6 @@ { "info": { - "_postman_id": "0bd597ba-4bc2-4ea1-be33-45776b80c1ce", + "_postman_id": "7954a27f-3833-404f-9e55-6016a938c86e", "name": "Topcoder-bookings-api", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" }, @@ -15486,7 +15486,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.daysWorked\\\" must be less than or equal to 5\")\r", + " pm.expect(response.message).to.eq(\"\\\"data.daysWorked\\\" must be less than or equal to 10\")\r", "});" ], "type": "text/javascript" @@ -15504,55 +15504,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"daysWorked\": 6\r\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{URL}}/work-periods/{{workPeriodId-1}}", - "host": [ - "{{URL}}" - ], - "path": [ - "work-periods", - "{{workPeriodId-1}}" - ] - } - }, - "response": [] - }, - { - "name": "patch work period with invalid parameter 4", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test('Status code is 400', function () {\r", - " pm.response.to.have.status(400);\r", - " const response = pm.response.json()\r", - " pm.expect(response.message).to.eq(\"Maximum allowed daysWorked is (4)\")\r", - "});" - ], - "type": "text/javascript" - } - } - ], - "request": { - "method": "PATCH", - "header": [ - { - "key": "Authorization", - "type": "text", - "value": "Bearer {{token_bookingManager}}" - } - ], - "body": { - "mode": "raw", - "raw": "{\r\n \"daysWorked\": 5\r\n}", + "raw": "{\r\n \"daysWorked\": 11\r\n}", "options": { "raw": { "language": "json" @@ -15988,8 +15940,12 @@ " pm.response.to.have.status(200);\r", " if(pm.response.status === \"OK\"){\r", " const response = pm.response.json()\r", - " pm.environment.set(\"workPeriodPaymentId-2\", response[0].id);\r", - " pm.environment.set(\"workPeriodPaymentId-3\", response[1].id);\r", + " if (response[0].id) {\r", + " pm.environment.set(\"workPeriodPaymentId-2\", response[0].id);\r", + " }\r", + " if (response[1].id) {\r", + " pm.environment.set(\"workPeriodPaymentId-3\", response[1].id);\r", + " }\r", " }\r", "});" ], @@ -16016,12 +15972,13 @@ } }, "url": { - "raw": "{{URL}}/work-period-payments", + "raw": "{{URL}}/work-period-payments/bulk", "host": [ "{{URL}}" ], "path": [ - "work-period-payments" + "work-period-payments", + "bulk" ] } }, @@ -16508,7 +16465,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(\"\\\"workPeriodPayment.days\\\" must be less than or equal to 5\")\r", + " pm.expect(response.message).to.eq(\"\\\"workPeriodPayment.days\\\" must be less than or equal to 10\")\r", "});" ], "type": "text/javascript" @@ -16526,7 +16483,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"workPeriodId\": \"{{workPeriodId-3}}\",\r\n \"days\": 6\r\n}", + "raw": "{\r\n \"workPeriodId\": \"{{workPeriodId-3}}\",\r\n \"days\": 11\r\n}", "options": { "raw": { "language": "json" @@ -16546,7 +16503,7 @@ "response": [] }, { - "name": "create work period payment with invalid days 2 Copy", + "name": "create work period payment with invalid days 3", "event": [ { "listen": "test", @@ -17279,7 +17236,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"status\": \"cancelled\"\r\n}", + "raw": "{\r\n \"status\": \"cancelled\",\r\n \"days\":1,\r\n \"amount\":2,\r\n \"memberRate\":1,\r\n \"customerRate\":3,\r\n \"billingAccountId\": 23\r\n}", "options": { "raw": { "language": "json" @@ -17325,7 +17282,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"status\": \"cancelled\"\r\n}", + "raw": "{\r\n \"status\": \"cancelled\",\r\n \"days\":1,\r\n \"amount\":2,\r\n \"memberRate\":1,\r\n \"customerRate\":3,\r\n \"billingAccountId\": 23\r\n}", "options": { "raw": { "language": "json" @@ -17373,7 +17330,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"status\": \"cancelled\"\r\n}", + "raw": "{\r\n \"status\": \"cancelled\",\r\n \"days\":1,\r\n \"amount\":2,\r\n \"memberRate\":1,\r\n \"customerRate\":3,\r\n \"billingAccountId\": 23\r\n}", "options": { "raw": { "language": "json" @@ -17421,7 +17378,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"status\": \"cancelled\"\r\n}", + "raw": "{\r\n \"status\": \"cancelled\",\r\n \"days\":1,\r\n \"amount\":2,\r\n \"memberRate\":1,\r\n \"customerRate\":3,\r\n \"billingAccountId\": 23\r\n}", "options": { "raw": { "language": "json" @@ -17469,7 +17426,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"status\": \"cancelled\"\r\n}", + "raw": "{\r\n \"status\": \"cancelled\",\r\n \"days\":1,\r\n \"amount\":2,\r\n \"memberRate\":1,\r\n \"customerRate\":3,\r\n \"billingAccountId\": 23\r\n}", "options": { "raw": { "language": "json" @@ -17517,7 +17474,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"status\": \"cancelled\"\r\n}", + "raw": "{\r\n \"status\": \"cancelled\",\r\n \"days\":1,\r\n \"amount\":2,\r\n \"memberRate\":1,\r\n \"customerRate\":3,\r\n \"billingAccountId\": 23\r\n}", "options": { "raw": { "language": "json" @@ -17728,6 +17685,196 @@ } }, "response": [] + }, + { + "name": "patch work period payment in bulk", + "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", + "type": "text", + "value": "Bearer {{token_bookingManager}}" + } + ], + "body": { + "mode": "raw", + "raw": "[\r\n {\r\n \"id\": \"{{workPeriodPaymentId}}\",\r\n \"status\": \"cancelled\",\r\n \"days\": 5,\r\n \"amount\": 10,\r\n \"memberRate\": 2,\r\n \"customerRate\": null,\r\n \"billingAccountId\": 44\r\n },\r\n {\r\n \"id\": \"{{workPeriodPaymentId-2}}\",\r\n \"status\": \"scheduled\"\r\n },\r\n {\r\n \"id\": \"{{workPeriodPaymentId-3}}\",\r\n \"days\": 5,\r\n \"amount\": 10,\r\n \"memberRate\": 2,\r\n \"customerRate\": 5,\r\n \"billingAccountId\": 44\r\n },\r\n {\r\n \"id\": \"{{workPeriodPaymentIdCreatedByM2M}}\",\r\n \"days\": 3\r\n }\r\n]", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{URL}}/work-period-payments/bulk", + "host": [ + "{{URL}}" + ], + "path": [ + "work-period-payments", + "bulk" + ] + } + }, + "response": [] + }, + { + "name": "patch work period payment in bulk invalid parameters - 1", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Status code is 400', function () {\r", + " pm.response.to.have.status(400);\r", + " const response = pm.response.json()\r", + " pm.expect(response.message).to.eq(\"\\\"workPeriodPayments[0].status\\\" must be one of [scheduled, cancelled]\")\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "PATCH", + "header": [ + { + "key": "Authorization", + "type": "text", + "value": "Bearer {{token_bookingManager}}" + } + ], + "body": { + "mode": "raw", + "raw": "[\r\n {\r\n \"id\": \"{{workPeriodPaymentId}}\",\r\n \"status\": \"completed\"\r\n }\r\n]", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{URL}}/work-period-payments/bulk", + "host": [ + "{{URL}}" + ], + "path": [ + "work-period-payments", + "bulk" + ] + } + }, + "response": [] + }, + { + "name": "patch work period payment in bulk invalid parameters - 2", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Status code is 400', function () {\r", + " pm.response.to.have.status(400);\r", + " const response = pm.response.json()\r", + " pm.expect(response.message).to.eq(\"\\\"workPeriodPayments[0].days\\\" must be less than or equal to 10\")\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "PATCH", + "header": [ + { + "key": "Authorization", + "type": "text", + "value": "Bearer {{token_bookingManager}}" + } + ], + "body": { + "mode": "raw", + "raw": "[\r\n {\r\n \"id\": \"{{workPeriodPaymentId}}\",\r\n \"days\": 11\r\n }\r\n]", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{URL}}/work-period-payments/bulk", + "host": [ + "{{URL}}" + ], + "path": [ + "work-period-payments", + "bulk" + ] + } + }, + "response": [] + }, + { + "name": "patch work period payment in bulk invalid parameters - 3", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Status code is 400', function () {\r", + " pm.response.to.have.status(400);\r", + " const response = pm.response.json()\r", + " pm.expect(response.message).to.eq(\"\\\"workPeriodPayments[0].amount\\\" must be greater than 0\")\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "PATCH", + "header": [ + { + "key": "Authorization", + "type": "text", + "value": "Bearer {{token_bookingManager}}" + } + ], + "body": { + "mode": "raw", + "raw": "[\r\n {\r\n \"id\": \"{{workPeriodPaymentId}}\",\r\n \"amount\": 0\r\n }\r\n]", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{URL}}/work-period-payments/bulk", + "host": [ + "{{URL}}" + ], + "path": [ + "work-period-payments", + "bulk" + ] + } + }, + "response": [] } ] }, diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 6eb3ef5d..bfcf3abd 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -2573,6 +2573,109 @@ paths: application/json: schema: $ref: "#/components/schemas/Error" + /work-period-payments/bulk: + post: + tags: + - WorkPeriodPayments + description: | + Create Work Period Payments in Bulk. + + **Authorization** Topcoder token with write Work period payment scope is allowed + security: + - bearerAuth: [] + requestBody: + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/WorkPeriodPaymentCreateRequestBody" + responses: + "200": + description: OK + content: + application/json: + schema: + type: array + items: + oneOf: + - $ref: "#/components/schemas/WorkPeriodPayment" + - $ref: "#/components/schemas/WorkPeriodPaymentCreatedError" + "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" + "500": + description: Internal Server Error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + patch: + tags: + - WorkPeriodPayments + description: | + Partial Update work period payments in bulk. + + **Authorization** Topcoder token with update work period payment scope is allowed + security: + - bearerAuth: [] + requestBody: + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/WorkPeriodPaymentPatchRequestBodyInBulk" + responses: + "200": + description: OK + content: + application/json: + schema: + type: array + items: + oneOf: + - $ref: "#/components/schemas/WorkPeriodPayment" + - $ref: "#/components/schemas/WorkPeriodPaymentUpdatedError" + "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" + "500": + description: Internal Server Error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" /taas-teams: get: tags: @@ -3332,6 +3435,110 @@ paths: application/json: schema: $ref: "#/components/schemas/Error" + /taas-teams/calculateAmount: + post: + tags: + - Teams + description: | + Calculates total amount for the team. + + **Authorization** Any Topcoder user with valid token is allowed. For not logged users Topcoder m2m token with create:taas-teams scope is allowed. + security: + - bearerAuth: [] + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/CalculateAmountRequestBody" + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/CalculateAmountResponse" + "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" + "409": + description: Conflict + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "500": + description: Internal Server Error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /taas-teams/createPayment: + post: + tags: + - Teams + description: | + Calculates total amount for the team. + + **Authorization** Any Topcoder user with valid token is allowed. For not logged users Topcoder m2m token with create:taas-teams scope is allowed. + security: + - bearerAuth: [] + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/CreatePaymentRequestBody" + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/CreatePaymentResponse" + "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" + "409": + description: Conflict + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "500": + description: Internal Server Error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" /taas-teams/members-suggest/{fragment}: get: tags: @@ -3655,6 +3862,9 @@ components: schemas: SkillItem: properties: + id: + type: string + format: uuid tag: type: string example: "Java" @@ -3944,7 +4154,7 @@ components: "job-closed", "offered", "withdrawn", - "withdrawn-prescreen" + "withdrawn-prescreen", ] description: "The job candidate status." externalId: @@ -4046,7 +4256,26 @@ components: type: array items: type: string - enum: ["open", "placed", "selected", "client rejected - screening", "client rejected - interview", "rejected - other", "cancelled", "interview", "topcoder-rejected", "applied", "rejected-pre-screen", "skills-test", "phone-screen", "job-closed", "offered", "withdrawn", "withdrawn-prescreen"] + enum: + [ + "open", + "placed", + "selected", + "client rejected - screening", + "client rejected - interview", + "rejected - other", + "cancelled", + "interview", + "topcoder-rejected", + "applied", + "rejected-pre-screen", + "skills-test", + "phone-screen", + "job-closed", + "offered", + "withdrawn", + "withdrawn-prescreen", + ] description: "The array of job Candidates status" JobCandidateRequestBody: required: @@ -4082,7 +4311,7 @@ components: "job-closed", "offered", "withdrawn", - "withdrawn-prescreen" + "withdrawn-prescreen", ] description: "The job candidate status." default: open @@ -4120,7 +4349,7 @@ components: "job-closed", "offered", "withdrawn", - "withdrawn-prescreen" + "withdrawn-prescreen", ] externalId: type: string @@ -4470,6 +4699,10 @@ components: format: float example: 13 description: "The member rate." + sendWeeklySurvey: + type: boolean + example: true, + description: "whether we should send weekly survey to this ResourceBooking or no" customerRate: type: integer format: float @@ -4527,6 +4760,10 @@ components: format: uuid example: "a55fe1bc-1754-45fa-9adc-cf3d6d7c377a" description: "The external id." + sendWeeklySurvey: + type: boolean + example: true, + description: "whether we should send weekly survey to this ResourceBooking or no" jobId: type: string format: uuid @@ -4584,6 +4821,10 @@ components: format: float example: 13.23 description: "The member rate." + sendWeeklySurvey: + type: boolean + example: true, + description: "whether we should send weekly survey to this ResourceBooking or no" customerRate: type: number format: float @@ -4620,6 +4861,22 @@ components: type: string format: uuid description: "The resource booking id." + sentSurvey: + type: boolean + example: true + description: "whether we've already sent a survey for this WorkPeriod of no" + sentSurveyError: + description: "error details if error happened during sending survey" + type: object + properties: + errorMessage: + type: string + example: "error message" + description: "The error message" + errorCode: + type: integer + example: 429 + description: "HTTP code of error" userHandle: type: string example: "eisbilir" @@ -4641,7 +4898,7 @@ components: daysWorked: type: integer minimum: 0 - maximum: 5 + maximum: 10 example: 2 description: "The count of the days worked for that work period." daysPaid: @@ -4693,8 +4950,26 @@ components: properties: daysWorked: type: integer + minimum: 0 + maximum: 10 example: 2 description: "The count of the days worked for that work period." + sentSurvey: + type: boolean + example: true + description: "whether we've already sent a survey for this WorkPeriod of no" + sentSurveyError: + description: "error details if error happened during sending survey" + type: object + properties: + errorMessage: + type: string + example: "error message" + description: "The error message" + errorCode: + type: integer + example: 429 + description: "HTTP code of error" WorkPeriodPayment: required: - id @@ -4809,10 +5084,15 @@ components: description: "The work period id." days: type: integer - minimum: 1 - maximum: 5 + minimum: 0 + maximum: 10 example: 2 description: "The workDays to be paid." + amount: + type: integer + minimum: 1 + example: 200 + description: "The amount to be paid. Required only if days value is 0, otherwise forbidden." WorkPeriodPaymentQueryCreateRequestBody: properties: status: @@ -4884,6 +5164,56 @@ components: type: string enum: ["scheduled", "cancelled"] description: "The payment status." + memberRate: + type: integer + format: float + example: 13 + description: "The member rate." + customerRate: + type: integer + format: float + example: 13 + description: "The customer rate." + billingAccountId: + type: integer + example: 80000071 + description: "the billing account id for payments" + days: + type: integer + minimum: 0 + example: 3 + description: "The workdays to pay" + amount: + type: integer + format: float + example: 2 + description: "The amount to be paid." + WorkPeriodPaymentPatchRequestBodyInBulk: + allOf: + - type: object + required: + - id + properties: + id: + type: string + format: uuid + description: "The work period payment id." + - $ref: "#/components/schemas/WorkPeriodPaymentPatchRequestBody" + WorkPeriodPaymentUpdatedError: + allOf: + - $ref: "#/components/schemas/WorkPeriodPaymentPatchRequestBodyInBulk" + - type: object + properties: + error: + type: object + properties: + message: + type: string + description: "The error message" + code: + type: integer + example: 429 + description: "HTTP code of error" CheckRun: type: object properties: @@ -5272,7 +5602,7 @@ components: "job-closed", "offered", "withdrawn", - "withdrawn-prescreen" + "withdrawn-prescreen", ] description: "The job candidate status." skills: @@ -5398,6 +5728,18 @@ components: isExternalMember: type: boolean description: "Is the user external member" + matchedSkills: + type: array + items: + type: string + example: "java" + description: "skills match with the role" + unMatchedSkills: + type: array + items: + type: string + example: "javascript" + description: "skills unmatch with the role" skillsMatch: type: number format: float @@ -5407,6 +5749,32 @@ components: type: string description: "Optional job title." example: "Lead Application Developer" + CalculateAmountRequestBody: + properties: + numberOfResources: + type: number + description: "No. of resources required." + rates: + type: number + description: "Weekly rates" + durationWeeks: + type: number + description: "No. of weeks" + CalculateAmountResponse: + properties: + totalAmount: + type: number + description: "Total amount calculated" + CreatePaymentRequestBody: + properties: + totalAmount: + type: number + description: "Total amount charged to user via stripe" + CreatePaymentResponse: + properties: + paymentIntentToken: + type: string + description: " Token required by stripe for completing payment." SubmitTeamRequestBody: properties: teamName: @@ -5440,6 +5808,11 @@ components: example: 10 minimum: 1 description: "The number of needed resources" + hoursPerWeek: + type: integer + example: 40 + minimum: 1 + description: "The amount of working hours per week" durationWeeks: type: integer example: 5 @@ -5864,4 +6237,3 @@ components: properties: message: type: string - diff --git a/local/kafka-client/topics.txt b/local/kafka-client/topics.txt index 2611220b..405b2510 100644 --- a/local/kafka-client/topics.txt +++ b/local/kafka-client/topics.txt @@ -21,3 +21,4 @@ taas.interview.update taas.interview.bulkUpdate external.action.email taas.action.retry +notifications.action.create \ No newline at end of file diff --git a/migrations/2021-07-26-add-send-weekly-survery-fields.js b/migrations/2021-07-26-add-send-weekly-survery-fields.js new file mode 100644 index 00000000..06a45672 --- /dev/null +++ b/migrations/2021-07-26-add-send-weekly-survery-fields.js @@ -0,0 +1,46 @@ +const config = require('config') +const moment = require('moment') + +module.exports = { + up: async (queryInterface, Sequelize) => { + const transaction = await queryInterface.sequelize.transaction() + try { + await queryInterface.addColumn({ tableName: 'resource_bookings', schema: config.DB_SCHEMA_NAME }, 'send_weekly_survey', + { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: true }, + { transaction }) + await queryInterface.addColumn({ tableName: 'work_periods', schema: config.DB_SCHEMA_NAME }, 'sent_survey', + { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: false }, + { transaction }) + await queryInterface.addColumn({ tableName: 'work_periods', schema: config.DB_SCHEMA_NAME }, 'sent_survey_error', + { + type: Sequelize.JSONB({ + errorCode: { + field: 'error_code', + type: Sequelize.INTEGER, + }, + errorMessage: { + field: 'error_message', + type: Sequelize.STRING(255) + }, + }), allowNull: true }, { transaction }) + await queryInterface.sequelize.query(`UPDATE ${config.DB_SCHEMA_NAME}.work_periods SET sent_survey = true where payment_status = 'completed' and end_date <= '${moment().subtract(7, 'days').format('YYYY-MM-DD')}'`, + { transaction }) + await transaction.commit() + } catch (err) { + await transaction.rollback() + throw err + } + }, + down: async (queryInterface, Sequelize) => { + const transaction = await queryInterface.sequelize.transaction() + try { + await queryInterface.removeColumn({ tableName: 'resource_bookings', schema: config.DB_SCHEMA_NAME }, 'send_weekly_survey', { transaction }) + await queryInterface.removeColumn({ tableName: 'work_periods', schema: config.DB_SCHEMA_NAME }, 'sent_survey', { transaction }) + await queryInterface.removeColumn({ tableName: 'work_periods', schema: config.DB_SCHEMA_NAME }, 'sent_survey_error', { transaction } ) + await transaction.commit() + } catch (err) { + await transaction.rollback() + throw err + } + }, +} diff --git a/package-lock.json b/package-lock.json index 3d174e12..16ed9890 100644 --- a/package-lock.json +++ b/package-lock.json @@ -585,6 +585,12 @@ } } }, + "@types/bluebird": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/@types/bluebird/-/bluebird-3.5.0.tgz", + "integrity": "sha1-JjNHCk6r6aR82aRf2yDtX5NAe8o=", + "dev": true + }, "@types/body-parser": { "version": "1.19.0", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.0.tgz", @@ -645,6 +651,12 @@ "@types/express": "*" } }, + "@types/lodash": { + "version": "4.14.172", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.172.tgz", + "integrity": "sha512-/BHF5HAx3em7/KkzVKm3LrsD6HZAXuXO1AJZQ3cRRBZj4oHZDviWPYu0aEplAqDFNHZPW6d3G7KN+ONcCCC7pw==", + "dev": true + }, "@types/mime": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz", @@ -1297,6 +1309,17 @@ "tweetnacl": "^0.14.3" } }, + "bin-protocol": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/bin-protocol/-/bin-protocol-3.1.1.tgz", + "integrity": "sha512-9vCGfaHC2GBHZwGQdG+DpyXfmLvx9uKtf570wMLwIc9wmTIDgsdCBXQxTZu5X2GyogkfBks2Ode4N0sUVxJ2qQ==", + "dev": true, + "requires": { + "lodash": "^4.17.11", + "long": "^4.0.0", + "protocol-buffers-schema": "^3.0.0" + } + }, "binary-extensions": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.1.0.tgz", @@ -1433,6 +1456,12 @@ "isarray": "^1.0.0" } }, + "buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI=", + "dev": true + }, "buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -1836,6 +1865,12 @@ "xdg-basedir": "^4.0.0" } }, + "connection-parse": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/connection-parse/-/connection-parse-0.0.7.tgz", + "integrity": "sha1-GOcxiqsGppkmc3KxDFIm0locmmk=", + "dev": true + }, "contains-path": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/contains-path/-/contains-path-0.1.0.tgz", @@ -3232,6 +3267,27 @@ "integrity": "sha512-qBr4OuELkhPenW6goKVXiv47US3clb3/IbuWF9KNKEijAy9oeHxU9IgzjvJhHkUzhaj7rOUD7+YGWqUjLp5oSA==", "dev": true }, + "handlebars": { + "version": "4.7.7", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.7.tgz", + "integrity": "sha512-aAcXm5OAfE/8IXkcZvCepKU3VzW1/39Fb5ZuqMtgI/hT8X2YgoMvBY5dLhq/cpOvw7Lk1nK/UF71aLG/ZnVYRA==", + "dev": true, + "requires": { + "minimist": "^1.2.5", + "neo-async": "^2.6.0", + "source-map": "^0.6.1", + "uglify-js": "^3.1.4", + "wordwrap": "^1.0.0" + }, + "dependencies": { + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, "har-schema": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", @@ -3280,6 +3336,16 @@ "type-fest": "^0.8.0" } }, + "hashring": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/hashring/-/hashring-3.2.0.tgz", + "integrity": "sha1-/aTv3oqiLNuX+x0qZeiEAeHBRM4=", + "dev": true, + "requires": { + "connection-parse": "0.0.x", + "simple-lru-cache": "0.0.x" + } + }, "he": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", @@ -4608,6 +4674,12 @@ } } }, + "long": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", + "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==", + "dev": true + }, "long-timeout": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/long-timeout/-/long-timeout-0.1.1.tgz", @@ -4935,9 +5007,9 @@ } }, "moment": { - "version": "2.29.0", - "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.0.tgz", - "integrity": "sha512-z6IJ5HXYiuxvFTI6eiQ9dm77uE0gyy1yXNApVHqTcnIKfY9tIwEjlzsZ6u1LQXvVgKeTnv9Xm7NDvJ7lso3MtA==" + "version": "2.29.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.1.tgz", + "integrity": "sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ==" }, "moment-timezone": { "version": "0.5.33", @@ -4952,6 +5024,12 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" }, + "murmur-hash-js": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/murmur-hash-js/-/murmur-hash-js-1.0.0.tgz", + "integrity": "sha1-UEEEkmnJZjPIZjhpYLL0KJ515bA=", + "dev": true + }, "mute-stream": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", @@ -5014,12 +5092,27 @@ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.2.tgz", "integrity": "sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==" }, + "neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true + }, "next-tick": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.0.0.tgz", "integrity": "sha1-yobR/ogoFpsBICCOPchCS524NCw=", "dev": true }, + "nice-simple-logger": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/nice-simple-logger/-/nice-simple-logger-1.0.1.tgz", + "integrity": "sha1-D55khSe+e+PkmrdvqMjAmK+VG/Y=", + "dev": true, + "requires": { + "lodash": "^4.3.0" + } + }, "nice-try": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", @@ -5055,6 +5148,32 @@ } } }, + "no-kafka": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/no-kafka/-/no-kafka-3.4.3.tgz", + "integrity": "sha512-hYnkg1OWVdaxORdzVvdQ4ueWYpf7IICObPzd24BBiDyVG5219VkUnRxSH9wZmisFb6NpgABzlSIL1pIZaCKmXg==", + "dev": true, + "requires": { + "@types/bluebird": "3.5.0", + "@types/lodash": "^4.14.55", + "bin-protocol": "^3.1.1", + "bluebird": "^3.3.3", + "buffer-crc32": "^0.2.5", + "hashring": "^3.2.0", + "lodash": "=4.17.11", + "murmur-hash-js": "^1.0.0", + "nice-simple-logger": "^1.0.1", + "wrr-pool": "^1.0.3" + }, + "dependencies": { + "lodash": { + "version": "4.17.11", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.11.tgz", + "integrity": "sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg==", + "dev": true + } + } + }, "node-preload": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/node-preload/-/node-preload-0.2.1.tgz", @@ -6106,6 +6225,12 @@ "integrity": "sha1-IS1b/hMYMGpCD2QCuOJv85ZHqEk=", "dev": true }, + "protocol-buffers-schema": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.5.1.tgz", + "integrity": "sha512-YVCvdhxWNDP8/nJDyXLuM+UFsuPk4+1PB7WGPVDzm3HTHbzFLxQYeW2iZpS4mmnXrQJGBzt230t/BbEb7PrQaw==", + "dev": true + }, "proxy-addr": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.6.tgz", @@ -6782,6 +6907,12 @@ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.3.tgz", "integrity": "sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==" }, + "simple-lru-cache": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/simple-lru-cache/-/simple-lru-cache-0.0.2.tgz", + "integrity": "sha1-1ZzDoZPBpdAyD4Tucy9uRxPlEd0=", + "dev": true + }, "simple-swizzle": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", @@ -7155,6 +7286,15 @@ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==" }, + "stripe": { + "version": "8.168.0", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-8.168.0.tgz", + "integrity": "sha512-MQXTarijIOagtLajGe1zBFc9KMbB7jIoFv/kr1WsDPJO/S+/hhZjsXCgBkNvnlwK7Yl0VUn+YrgXl9/9wU6WCw==", + "requires": { + "@types/node": ">=8.1.0", + "qs": "^6.6.0" + } + }, "success-symbol": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/success-symbol/-/success-symbol-0.1.0.tgz", @@ -7489,6 +7629,13 @@ "is-typedarray": "^1.0.0" } }, + "uglify-js": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.14.0.tgz", + "integrity": "sha512-R/tiGB1ZXp2BC+TkRGLwj8xUZgdfT2f4UZEgX6aVjJ5uttPrr4fYmwTWDGqVnBCLbOXRMY6nr/BTbwCtVfps0g==", + "dev": true, + "optional": true + }, "umzug": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/umzug/-/umzug-2.3.0.tgz", @@ -7886,6 +8033,12 @@ "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==" }, + "wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha1-J1hIEIkUVqQXHI0CJkQa3pDLyus=", + "dev": true + }, "workerpool": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.0.0.tgz", @@ -7953,6 +8106,15 @@ "typedarray-to-buffer": "^3.1.5" } }, + "wrr-pool": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/wrr-pool/-/wrr-pool-1.1.4.tgz", + "integrity": "sha512-+lEdj42HlYqmzhvkZrx6xEymj0wzPBxqr7U1Xh9IWikMzOge03JSQT9YzTGq54SkOh/noViq32UejADZVzrgAg==", + "dev": true, + "requires": { + "lodash": "^4.17.11" + } + }, "xdg-basedir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-4.0.0.tgz", diff --git a/package.json b/package.json index 31009f5f..9fd07981 100644 --- a/package.json +++ b/package.json @@ -25,11 +25,15 @@ "services:up": "docker-compose -f ./local/docker-compose.yml --env-file .env up -d", "services:down": "docker-compose -f ./local/docker-compose.yml down", "services:logs": "docker-compose -f ./local/docker-compose.yml logs", + "services:rebuild": "docker-compose -f ./local/docker-compose.yml build --no-cache", "local:init": "npm run local:reset && npm run data:import -- --force", "local:reset": "npm run delete-index -- --force || true && npm run create-index -- --force && npm run init-db force", "cov": "nyc --reporter=html --reporter=text npm run test", "demo-payment-scheduler": "node scripts/demo-payment-scheduler/index.js && npm run index:all -- --force", - "demo-payment": "node scripts/demo-payment" + "demo-payment": "node scripts/demo-payment", + "migrate:backup-withdrawn": "node scripts/withdrawn-migration/backup.js", + "migrate:migration-withdrawn": "node scripts/withdrawn-migration/migration.js", + "migrate:restore-withdrawn": "node scripts/withdrawn-migration/restore.js" }, "keywords": [], "author": "", @@ -51,12 +55,14 @@ "http-status-codes": "^2.1.4", "joi": "^17.2.1", "lodash": "^4.17.20", + "moment": "^2.29.1", "node-schedule": "^2.0.0", "pg": "^8.4.0", "pg-hstore": "^2.3.3", "prompt-confirm": "^2.0.4", "rewire": "^5.0.0", "sequelize": "^6.3.5", + "stripe": "^8.168.0", "superagent": "^6.1.0", "tc-core-library-js": "github:appirio-tech/tc-core-library-js#v2.6", "util": "^0.12.3", @@ -66,7 +72,9 @@ "devDependencies": { "chai": "^4.2.0", "csv-parser": "^3.0.0", + "handlebars": "^4.7.7", "mocha": "^8.1.3", + "no-kafka": "^3.4.3", "nodemon": "^2.0.4", "nyc": "^15.1.0", "sequelize-cli": "^6.2.0", diff --git a/scripts/demo-email-notifications/README.md b/scripts/demo-email-notifications/README.md new file mode 100644 index 00000000..3a5dbd47 --- /dev/null +++ b/scripts/demo-email-notifications/README.md @@ -0,0 +1,39 @@ +# Trigger and render demo Email Notifications. + +This script does 2 things: + +- update demo data created by `npm run local:init` inside the DB in such a way that it would create situation for Email Notifications which would be triggered by the scheduler to demonstrate all possible cases. +- start Kafka Consumer that would listen to the Kafka Topic `config.NOTIFICATIONS_CREATE_TOPIC` and if there is email notification created, it would render it using provided email template `data/notifications-email-template.html` into `out` folder. + +## Usage + +1. Config scheduler to run more often so we don't have to wait to long for triggering notification, like every minute: + + ```sh + CRON_CANDIDATE_REVIEW=0 */1 * * * * + CRON_INTERVIEW_COMING_UP=0 */1 * * * * + CRON_INTERVIEW_COMPLETED=0 */1 * * * * + CRON_POST_INTERVIEW=0 */1 * * * * + CRON_UPCOMING_RESOURCE_BOOKING=0 */1 * * * * + INTERVIEW_COMING_UP_MATCH_WINDOW=PT1M + INTERVIEW_COMPLETED_MATCH_WINDOW=PT1M + ``` + +2. Recreate demo data by: + + ```sh + npm run local:init` + +3. Run TaaS API by: + + ```sh + npm run dev + ``` + +4. Run this demo script: + + ```sh + node scripts/demo-email-notifications + ``` + +Check the rendered emails inside `out` folder. diff --git a/scripts/demo-email-notifications/index.js b/scripts/demo-email-notifications/index.js new file mode 100644 index 00000000..23f3ac9f --- /dev/null +++ b/scripts/demo-email-notifications/index.js @@ -0,0 +1,92 @@ +const Kafka = require('no-kafka') +const fs = require('fs') +const config = require('config') +const moment = require('moment') +const handlebars = require('handlebars') +const logger = require('../../src/common/logger') +const { Interview, JobCandidate, ResourceBooking } = require('../../src/models') +const { Interviews } = require('../../app-constants') + +const consumer = new Kafka.GroupConsumer({ connectionString: process.env.KAFKA_URL, groupId: 'test-render-email' }) + +const localLogger = { + debug: message => logger.debug({ component: 'render email content', context: 'test', message }), + info: message => logger.info({ component: 'render email content', context: 'test', message }) +} + +const template = handlebars.compile(fs.readFileSync('./data/notifications-email-template.html', 'utf8')) + +/** + * Reset notification records + */ +async function resetNotificationRecords () { + // reset coming up interview records + localLogger.info('reset coming up interview records') + const interview = await Interview.findById('976d23a9-5710-453f-99d9-f57a588bb610') + const startTimestamp = moment().add(moment.duration(config.INTERVIEW_COMING_UP_REMIND_TIME[0])).add(config.INTERVIEW_COMING_UP_MATCH_WINDOW).toDate() + await interview.update({ startTimestamp, duration: 30, status: Interviews.Status.Scheduled, guestNames: ['test1', 'test2'], hostName: 'hostName' }) + + // reset completed interview records + localLogger.info('reset completed interview records') + const pastTime = moment.duration(config.INTERVIEW_COMPLETED_PAST_TIME) + const endTimestamp = moment().subtract(pastTime).add(config.INTERVIEW_COMPLETED_MATCH_WINDOW).toDate() + const completedInterview = await Interview.findById('9efd72c3-1dc7-4ce2-9869-8cca81d0adeb') + const duration = 30 + const completedStartTimestamp = moment().subtract(pastTime).subtract(30, 'm').toDate() + await completedInterview.update({ startTimestamp: completedStartTimestamp, duration, endTimestamp, status: Interviews.Status.Scheduled, guestNames: ['guest1', 'guest2'], hostName: 'hostName' }) + + // reset post interview candidate action reminder records + localLogger.info('reset post interview candidate action reminder records') + const jobCandidate = await JobCandidate.findById('881a19de-2b0c-4bb9-b36a-4cb5e223bdb5') + await jobCandidate.update({ status: 'interview' }) + const c2Interview = await Interview.findById('077aa2ca-5b60-4ad9-a965-1b37e08a5046') + await c2Interview.update({ startTimestamp: completedStartTimestamp, duration, endTimestamp, guestNames: ['guest1', 'guest2'], hostName: 'hostName' }) + + // reset upcoming resource booking expiration records + localLogger.info('reset upcoming resource booking expiration records') + const resourceBooking = await ResourceBooking.findById('62c3f0c9-2bf0-4f24-8647-2c802a39cbcb') + const testEnd = moment().add(moment.duration(config.RESOURCE_BOOKING_EXPIRY_TIME)).toDate() + await resourceBooking.update({ endDate: testEnd }) +} + +/** + * Init consumer. + */ +async function initConsumer () { + await consumer + .init([{ + subscriptions: [config.NOTIFICATIONS_CREATE_TOPIC], + handler: async (messageSet, topic, partition) => { + localLogger.debug(`Consumer handler. Topic: ${topic}, partition: ${partition}, message set length: ${messageSet.length}`) + for (const m of messageSet) { + const message = JSON.parse(m.message.value.toString('utf8')) + if (!fs.existsSync('out')) { + fs.mkdirSync('out') + } + if (message.payload.notifications) { + message.payload.notifications.forEach((notification) => { + const email = template(notification.details.data) + fs.writeFileSync(`./out/${notification.details.data.subject}-${Date.now()}.html`, email) + }) + } + } + } + }]) + .then(() => { + localLogger.info('Initialized.......') + localLogger.info([config.NOTIFICATIONS_CREATE_TOPIC]) + localLogger.info('Kick Start.......') + }).catch(err => { + logger.logFullError(err, { component: 'app' }) + }) +} + +/** + * Main function + */ +async function main () { + await resetNotificationRecords() + await initConsumer() +} + +main() diff --git a/scripts/notification-email-template-renderer/README.md b/scripts/notification-email-template-renderer/README.md new file mode 100644 index 00000000..19819690 --- /dev/null +++ b/scripts/notification-email-template-renderer/README.md @@ -0,0 +1,22 @@ +# Render Email Notification Template with some data + +This script can render SendGrid Email Template (handlebars) `data/notifications-email-template.html` using some data from `data/notifications-email-demo-data.json` into `out/notifications-email-template-with-data.html`. + +## Usage + +Please run + +``` +node scripts/notification-email-template-renderer +``` + +where `` can be one of the keys in `data/notifications-email-demo-data.json` i.e: + +- `candidatesAvailableForReview` +- `interviewComingUpForHost` +- `interviewComingUpForGuest` +- `interviewCompleted` +- `postInterviewCandidateAction` +- `upcomingResourceBookingExpiration` + +The resulting file would be placed into `out/notifications-email-template-with-data.html` \ No newline at end of file diff --git a/scripts/notification-email-template-renderer/index.js b/scripts/notification-email-template-renderer/index.js new file mode 100644 index 00000000..8a95b772 --- /dev/null +++ b/scripts/notification-email-template-renderer/index.js @@ -0,0 +1,32 @@ +/** + * Script for rendering email template + */ +const fs = require('fs') +const Handlebars = require('handlebars') +const path = require('path') + +function render (filename, data) { + const source = fs.readFileSync(filename, 'utf8').toString() + const template = Handlebars.compile(source) + const output = template(data) + return output +} + +const data = JSON.parse(fs.readFileSync(path.join(__dirname, '../../data/notifications-email-demo-data.json'), 'utf8')) + +const key = process.argv.length >= 3 ? process.argv[2] : 'candidatesAvailableForReview' + +if (!data[key]) { + console.error('Please provide a proper key which is present in notifications.json') + process.exit(1) +} + +const outputDir = path.join(__dirname, '../../out') +const outputFile = path.join(__dirname, '../../out/notifications-email-template-with-data.html') +const result = render(path.join(__dirname, '../../data/notifications-email-template.html'), data[key]) +if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir) +} +fs.writeFileSync(outputFile, result) + +console.log(`Template has been rendered to: ${outputFile}`) diff --git a/scripts/recruit-crm-job-import/README.md b/scripts/recruit-crm-job-import/README.md index fdd8e01a..5653794c 100644 --- a/scripts/recruit-crm-job-import/README.md +++ b/scripts/recruit-crm-job-import/README.md @@ -16,6 +16,7 @@ By default the script creates jobs and resource bookings via `TC_API`. Follow the README for Taas API to deploy Taas API locally and then point the script to the local API by running: ``` bash +export RCRM_IMPORT_CONFIG_NAMESAPCE=RCRM_IMPORT_ export RCRM_IMPORT_TAAS_API_URL=http://localhost:3000/api/v5 node scripts/recruit-crm-job-import scripts/recruit-crm-job-import/example_data.csv | tee /tmp/report.txt ``` @@ -27,38 +28,42 @@ DEBUG: processing line #1 - {"directProjectId":"24568","projectId":"(dynamic loa WARN: #1 - externalId is missing DEBUG: processed line #1 DEBUG: processing line #2 - {"directProjectId":"24568","projectId":"(dynamic load)","externalId":"0","title":"taas-demo-job5","startDate":"10/26/2020","endDate":"01/29/2021","numPositions":"2","userHandle":"not_found_handle","jobid":"(dynamic load)","customerRate":"20","memberRate":"10","_lnum":2} -ERROR: #2 - handle: not_found_handle user not found +ERROR: #2 - id: 51ce2216-0dee-4dcf-bf7d-79f862e8d63c job created; handle: not_found_handle user not found DEBUG: processed line #2 DEBUG: processing line #3 - {"directProjectId":"24568","projectId":"(dynamic load)","externalId":"0","title":"taas-demo-job5","startDate":"10/26/2020","endDate":"01/29/2021","numPositions":"2","userHandle":"nkumartest","jobid":"(dynamic load)","customerRate":"20","memberRate":"10","_lnum":3} DEBUG: userHandle: nkumartest userId: 57646ff9-1cd3-4d3c-88ba-eb09a395366c -DEBUG: resourceBookingId: dc8b23d4-9987-4a7d-a587-2056283223de status: assigned -INFO: #3 - id: 7c8ed989-35bf-4899-9c93-708630a7c63b job already exists; id: dc8b23d4-9987-4a7d-a587-2056283223de resource booking created; id: dc8b23d4-9987-4a7d-a587-2056283223de status: assigned resource booking updated +INFO: #3 - id: 51ce2216-0dee-4dcf-bf7d-79f862e8d63c externalId: 0 job already exists; id: d49d2fbd-ba11-49dc-8eaa-5afafa7e993f resource booking created DEBUG: processed line #3 DEBUG: processing line #4 - {"directProjectId":"24567","projectId":"(dynamic load)","externalId":"1212","title":"Dummy Description","startDate":"10/20/2020","endDate":"01/29/2021","numPositions":"2","userHandle":"pshah_manager","jobid":"(dynamic load)","customerRate":"150","memberRate":"100","_lnum":4} DEBUG: userHandle: pshah_manager userId: a55fe1bc-1754-45fa-9adc-cf3d6d7c377a -DEBUG: resourceBookingId: 708469fb-ead0-4fc3-bef7-1ef4dd041428 status: assigned -INFO: #4 - id: f61da880-5295-40c2-b6db-21e6cdef93f9 job created; id: 708469fb-ead0-4fc3-bef7-1ef4dd041428 resource booking created; id: 708469fb-ead0-4fc3-bef7-1ef4dd041428 status: assigned resource booking updated +INFO: #4 - id: e0267551-24fe-48b5-9605-719852901de2 job created; id: f6285f03-056d-446f-a69b-6d275a97d68a resource booking created DEBUG: processed line #4 DEBUG: processing line #5 - {"directProjectId":"24566","projectId":"(dynamic load)","externalId":"23850272","title":"33fromzaps330","startDate":"02/21/2021","endDate":"03/15/2021","numPositions":"7","userHandle":"nkumar2","jobid":"(dynamic load)","customerRate":"50","memberRate":"30","_lnum":5} DEBUG: userHandle: nkumar2 userId: 4b00d029-c87b-47b2-bfe2-0ab80d8b5774 -DEBUG: resourceBookingId: 7870c30b-e511-48f2-8687-499ab116174f status: assigned -INFO: #5 - id: 72dc0399-5e4b-4783-9a27-ea07a4ce99a7 job created; id: 7870c30b-e511-48f2-8687-499ab116174f resource booking created; id: 7870c30b-e511-48f2-8687-499ab116174f status: assigned resource booking updated +INFO: #5 - id: cd94784c-432d-4c46-b860-04a89e7b1099 job created; id: 98604c13-c6f3-4203-b74f-db376e9f02e4 resource booking created DEBUG: processed line #5 DEBUG: processing line #6 - {"directProjectId":"24565","projectId":"(dynamic load)","externalId":"23843365","title":"Designer","startDate":"02/24/2021","endDate":"03/30/2021","numPositions":"1","userHandle":"GunaK-TopCoder","jobid":"(dynamic load)","customerRate":"70","memberRate":"70","_lnum":6} DEBUG: userHandle: GunaK-TopCoder userId: 2bba34d5-20e4-46d6-bfc1-05736b17afbb -DEBUG: resourceBookingId: b2e705d3-6864-4697-96bb-dc2a288755bc status: assigned -INFO: #6 - id: 7ff0737e-958c-494e-8a5a-592ac1c5d4ff job created; id: b2e705d3-6864-4697-96bb-dc2a288755bc resource booking created; id: b2e705d3-6864-4697-96bb-dc2a288755bc status: assigned resource booking updated +INFO: #6 - id: 49883150-59c2-4e5b-b5c3-aaf6d11d0da2 job created; id: 5505b6b5-050c-421c-893f-b862b1a08092 resource booking created DEBUG: processed line #6 DEBUG: processing line #7 - {"directProjectId":"24564","projectId":"(dynamic load)","externalId":"23836459","title":"demo-dev-19janV4","startDate":"01/20/2021","endDate":"01/30/2021","numPositions":"1","userHandle":"nkumar1","jobid":"(dynamic load)","customerRate":"400","memberRate":"200","_lnum":7} DEBUG: userHandle: nkumar1 userId: ab19a53b-0607-4a99-8bdd-f3b0cb552293 -DEBUG: resourceBookingId: 04299b4c-3f6e-4b3e-ae57-bf8232408cf9 status: assigned -INFO: #7 - id: 73301ade-40ff-4103-bd50-37b8d2a98183 job created; id: 04299b4c-3f6e-4b3e-ae57-bf8232408cf9 resource booking created; id: 04299b4c-3f6e-4b3e-ae57-bf8232408cf9 status: assigned resource booking updated +INFO: #7 - id: b03dc641-d6be-4a15-9c86-ef38f0e20c28 job created; id: 8e332107-453b-4ec5-b934-902c829e73a2 resource booking created DEBUG: processed line #7 INFO: === summary === INFO: total: 7 INFO: success: 5 INFO: failure: 1 INFO: skips: 1 +INFO: jobs created: 5 +INFO: resource bookings created: 5 +INFO: jobs already exist: 1 +INFO: resource bookings already exist: 0 +INFO: validation errors: 0 +INFO: user not found: 1 +INFO: external id missing: 1 +INFO: request error: 0 +INFO: internal error: 0 INFO: === summary === INFO: done! ``` diff --git a/scripts/recruit-crm-job-import/helper.js b/scripts/recruit-crm-job-import/helper.js index 43591ff3..b3096866 100644 --- a/scripts/recruit-crm-job-import/helper.js +++ b/scripts/recruit-crm-job-import/helper.js @@ -54,15 +54,15 @@ async function getJobByExternalId (externalId) { * Update the status of a resource booking. * * @param {String} resourceBookingId the resource booking id - * @param {String} status the status for the resource booking + * @param {Object} data the data to update * @returns {Object} the result */ -async function updateResourceBookingStatus (resourceBookingId, status) { +async function updateResourceBooking (resourceBookingId, data) { const token = await getM2MToken() const { body: resourceBooking } = await request.patch(`${config.TAAS_API_URL}/resourceBookings/${resourceBookingId}`) .set('Authorization', `Bearer ${token}`) .set('Content-Type', 'application/json') - .send({ status }) + .send(data) return resourceBooking } @@ -139,7 +139,7 @@ module.exports = { getPathnameFromCommandline: commonHelper.getPathnameFromCommandline, createJob, getJobByExternalId, - updateResourceBookingStatus, + updateResourceBooking, getResourceBookingByJobIdAndUserId, createResourceBooking, getUserByHandle, diff --git a/scripts/recruit-crm-job-import/index.js b/scripts/recruit-crm-job-import/index.js index 596ab680..100ad315 100644 --- a/scripts/recruit-crm-job-import/index.js +++ b/scripts/recruit-crm-job-import/index.js @@ -5,7 +5,7 @@ const Joi = require('joi') .extend(require('@joi/date')) const _ = require('lodash') -const dateFNS = require('date-fns') +const moment = require('moment') const Report = require('./report') const config = require('./config') const helper = require('./helper') @@ -18,12 +18,12 @@ const jobSchema = Joi.object({ title: Joi.string().required(), startDate: Joi.date().format('MM/DD/YYYY').required(), endDate: Joi.date().format('MM/DD/YYYY').required(), - numPositions: Joi.number().integer().min(1), + numPositions: Joi.number().integer().min(1).required(), userHandle: Joi.string(), customerRate: Joi.number(), memberRate: Joi.number(), skills: Joi.array().default([]), - rateType: Joi.string().default('weekly') + rateType: Joi.string().default('weekly').valid('hourly', 'daily', 'weekly', 'monthly', 'annual') }).unknown(true) /** @@ -67,14 +67,28 @@ async function processJob (job, info = []) { data.jobId = result.id } catch (err) { if (!(err.message && err.message.includes('job not found'))) { + err.info = info throw err } - const result = await helper.createJob(_.pick(data, ['projectId', 'externalId', 'title', 'numPositions', 'skills'])) + const jobData = _.pick(data, ['projectId', 'externalId', 'title', 'numPositions', 'skills']) + if (data.numPositions === 1) { + jobData.status = 'assigned' + } + const result = await helper.createJob(jobData) info.push({ text: `id: ${result.id} job created`, tag: 'job_created' }) data.jobId = result.id } - data.userId = (await helper.getUserByHandle(data.userHandle)).id - logger.debug(`userHandle: ${data.userHandle} userId: ${data.userId}`) + try { + data.userId = (await helper.getUserByHandle(data.userHandle)).id + logger.debug(`userHandle: ${data.userHandle} userId: ${data.userId}`) + } catch (err) { + if (!(err.message && err.message.includes('user not found'))) { + err.info = info + throw err + } + info.push({ text: err.message, tag: 'user_not_found' }) + return { status: constants.ProcessingStatus.Failed, info } + } // create a resource booking if it does not already exist try { const result = await helper.getResourceBookingByJobIdAndUserId(data.jobId, data.userId) @@ -82,18 +96,22 @@ async function processJob (job, info = []) { return { status: constants.ProcessingStatus.Successful, info } } catch (err) { if (!(err.message && err.message.includes('resource booking not found'))) { + err.info = info + throw err + } + try { + const resourceBookingData = _.pick(data, ['projectId', 'jobId', 'userId', 'memberRate', 'customerRate', 'rateType']) + resourceBookingData.startDate = moment(data.startDate).format('YYYY-MM-DD') + resourceBookingData.endDate = moment(data.endDate).format('YYYY-MM-DD') + resourceBookingData.status = moment(data.endDate).isBefore(moment()) ? 'closed' : 'placed' + const result = await helper.createResourceBooking(resourceBookingData) + info.push({ text: `id: ${result.id} resource booking created`, tag: 'resource_booking_created' }) + return { status: constants.ProcessingStatus.Successful, info } + } catch (err) { + err.info = info throw err } - const result = await helper.createResourceBooking(_.pick(data, ['projectId', 'jobId', 'userId', 'startDate', 'endDate', 'memberRate', 'customerRate', 'rateType'])) - info.push({ text: `id: ${result.id} resource booking created`, tag: 'resource_booking_created' }) - data.resourceBookingId = result.id } - // update the resourceBooking based on startDate and endDate - const resourceBookingStatus = dateFNS.isBefore(data.endDate, dateFNS.startOfToday()) ? 'closed' : 'placed' - logger.debug(`resourceBookingId: ${data.resourceBookingId} status: ${resourceBookingStatus}`) - await helper.updateResourceBookingStatus(data.resourceBookingId, resourceBookingStatus) - info.push({ text: `id: ${data.resourceBookingId} status: ${resourceBookingStatus} resource booking updated`, tag: 'resource_booking_status_updated' }) - return { status: constants.ProcessingStatus.Successful, info } } /** @@ -111,10 +129,11 @@ async function main () { const result = await processJob(job) report.add({ lnum: job._lnum, ...result }) } catch (err) { + const info = err.info || [] if (err.response) { - report.add({ lnum: job._lnum, status: constants.ProcessingStatus.Failed, info: [{ text: err.response.error.toString().split('\n')[0], tag: 'request_error' }] }) + report.add({ lnum: job._lnum, status: constants.ProcessingStatus.Failed, info: [{ text: err.response.error.toString().split('\n')[0], tag: 'request_error' }, ...info] }) } else { - report.add({ lnum: job._lnum, status: constants.ProcessingStatus.Failed, info: [{ text: err.message, tag: 'internal_error' }] }) + report.add({ lnum: job._lnum, status: constants.ProcessingStatus.Failed, info: [{ text: err.message, tag: 'internal_error' }, ...info] }) } } report.print() diff --git a/scripts/recruit-crm-job-import/report.js b/scripts/recruit-crm-job-import/report.js index ef31a4c8..b1b53bb2 100644 --- a/scripts/recruit-crm-job-import/report.js +++ b/scripts/recruit-crm-job-import/report.js @@ -44,6 +44,11 @@ class Report { const resourceBookingsCreated = groupsByTag.resource_booking_created || [] const jobsAlreadyExist = groupsByTag.job_already_exists || [] const resourceBookingsAlreadyExist = groupsByTag.resource_booking_already_exists || [] + const validationErrors = groupsByTag.validation_error || [] + const userNotFound = groupsByTag.user_not_found || [] + const externalIdMissing = groupsByTag.external_id_missing || [] + const requestError = groupsByTag.request_error || [] + const internalError = groupsByTag.internal_error || [] logger.info('=== summary ===') logger.info(`total: ${this.messages.length}`) logger.info(`success: ${success.length}`) @@ -53,6 +58,11 @@ class Report { logger.info(`resource bookings created: ${resourceBookingsCreated.length}`) logger.info(`jobs already exist: ${jobsAlreadyExist.length}`) logger.info(`resource bookings already exist: ${resourceBookingsAlreadyExist.length}`) + logger.info(`validation errors: ${validationErrors.length}`) + logger.info(`user not found: ${userNotFound.length}`) + logger.info(`external id missing: ${externalIdMissing.length}`) + logger.info(`request error: ${requestError.length}`) + logger.info(`internal error: ${internalError.length}`) logger.info('=== summary ===') } } diff --git a/scripts/withdrawn-migration/backup.js b/scripts/withdrawn-migration/backup.js new file mode 100644 index 00000000..33cedc7f --- /dev/null +++ b/scripts/withdrawn-migration/backup.js @@ -0,0 +1,89 @@ +/** + * Back up the jobCandidates that we will update it's status + */ +const config = require('config') +const Sequelize = require('sequelize') +const fs = require('fs') +const path = require('path') +const { JobCandidate, ResourceBooking, Job } = require('../../src/models') +const logger = require('../../src/common/logger') + +const currentStep = 'Backup' + +async function backup () { + logger.info({ component: currentStep, message: '*************************** Backup process started ***************************' }) + const filePath = path.join(__dirname, '/temp/') + if (fs.existsSync(filePath)) { + fs.rmdirSync(filePath, { recursive: true }) + } + fs.mkdirSync(filePath) + const Op = Sequelize.Op + const jobCandidates = await JobCandidate.findAll({ + where: { + status: 'placed' + } + }) + let summary = 0 + const processMapping = {} + for (let i = 0; i < jobCandidates.length; i++) { + const jc = jobCandidates[i] + if (processMapping[jc.userId]) continue + let job = null + try { + job = await Job.findById(jc.jobId) + } catch (error) { + // log the error + logger.info({ component: currentStep, message: `==> Data integrity issue: Can't find the Job with Id ${jc.jobId}` }) + } + if (!job) continue + let rb = null + try { + rb = await ResourceBooking.findOne({ + where: { + userId: jc.userId, + jobId: jc.jobId + } + }) + } catch (error) { + // log the error + logger.info({ component: currentStep, message: `==> Data integrity issue: Can't find the ResourceBooking whose userId is ${jc.userId} and jobId is ${jc.jobId}` }) + } + if (!rb) continue + let completed = false + if (rb && rb.endDate) { + completed = new Date(rb.endDate) < new Date() && new Date(rb.endDate).toDateString() !== new Date().toDateString() + } + if (job.hoursPerWeek > config.JOBS_HOUR_PER_WEEK && !completed) { + const statuses = ['applied', 'skills-test', 'phone-screen', 'open', 'interview', 'selected', 'offered'] + const filter = { [Op.and]: [] } + filter[Op.and].push({ status: statuses }) + filter[Op.and].push({ userId: jc.userId }) + const candidates = await JobCandidate.findAll({ + where: filter + }) + if (candidates && candidates.length > 0) { + try { + fs.writeFileSync(filePath + `jobcandidate-backup-${i + 1}.json`, JSON.stringify( + candidates + )) + logger.info({ component: `${currentStep} Sub`, message: `There are ${candidates.length} jobCandidates that need to be updated for userId: ${jc.userId}` }) + summary += candidates.length + processMapping[jc.userId] = true + } catch (err) { + logger.error({ component: currentStep, message: err.message }) + process.exit(1) + } + } + } + } + logger.info({ component: `${currentStep}`, message: `Report: there are ${summary} jobCandidates in total` }) + logger.info({ component: currentStep, message: '*************************** Backup process finished ***************************' }) +} + +backup().then(() => { + logger.info({ component: currentStep, message: 'Execution Finished!' }) + process.exit() +}).catch(err => { + logger.error(err.message) + process.exit(1) +}) diff --git a/scripts/withdrawn-migration/migration.js b/scripts/withdrawn-migration/migration.js new file mode 100644 index 00000000..b9c90ed0 --- /dev/null +++ b/scripts/withdrawn-migration/migration.js @@ -0,0 +1,46 @@ +/** + * Migration the jobCandidate status into expected status + */ +const config = require('config') +const fs = require('fs') +const path = require('path') +const { JobCandidate } = require('../../src/models') +const logger = require('../../src/common/logger') + +const currentStep = 'Migration' + +async function migration () { + logger.info({ component: currentStep, message: '*************************** Migration process started ***************************' }) + const filePath = path.join(__dirname, '/temp/') + const files = [] + fs.readdirSync(filePath).forEach(async (file) => { + files.push(`${filePath}${file}`) + }) + let totalSum = 0 + for (let j = 0; j < files.length; j++) { + const data = fs.readFileSync(files[j], 'utf-8') + const jobCandidates = JSON.parse(data) + let summary = 0 + for (let i = 0; i < jobCandidates.length; i++) { + const jc = await JobCandidate.findById(jobCandidates[i].id) + if (jc) { + const oldStatus = jc.status + const updated = await jc.update({ status: config.WITHDRAWN_STATUS_CHANGE_MAPPING[jobCandidates[i].status] }) + summary++ + totalSum++ + logger.info({ component: currentStep, message: `jobCandidate with ${jc.id} status changed from ${oldStatus} to ${updated.status}` }) + } + }; + logger.info({ component: `${currentStep} Sub`, message: `Updated ${summary} jobCandidates from ${files[j]}` }) + } + logger.info({ component: currentStep, message: `Report: Totally Updated ${totalSum} jobCandidates` }) + logger.info({ component: currentStep, message: '*************************** Migration process finished ***************************' }) +} + +migration().then(() => { + logger.info({ component: currentStep, message: 'Execution Finished!' }) + process.exit() +}).catch(err => { + logger.error(err.message) + process.exit(1) +}) diff --git a/scripts/withdrawn-migration/restore.js b/scripts/withdrawn-migration/restore.js new file mode 100644 index 00000000..0f842a9f --- /dev/null +++ b/scripts/withdrawn-migration/restore.js @@ -0,0 +1,45 @@ +/** + * Resotre the changed jobCandidates into its original state. + */ +const fs = require('fs') +const path = require('path') +const { JobCandidate } = require('../../src/models') +const logger = require('../../src/common/logger') + +const currentStep = 'Restore' + +async function restore () { + logger.info({ component: currentStep, message: '*************************** Restore process started ***************************' }) + const filePath = path.join(__dirname, '/temp/') + const files = [] + fs.readdirSync(filePath).forEach(async (file) => { + files.push(`${filePath}${file}`) + }) + let totalSum = 0 + for (let j = 0; j < files.length; j++) { + const data = fs.readFileSync(files[j], 'utf-8') + const jobCandidates = JSON.parse(data) + let summary = 0 + for (var i = 0; i < jobCandidates.length; i++) { + const jc = await JobCandidate.findById(jobCandidates[i].id) + if (jc) { + const oldStatus = jc.status + const updated = await jc.update({ status: jobCandidates[i].status }) + summary++ + totalSum++ + logger.info({ component: currentStep, message: `jobCandidate with ${jc.id} status restored from ${oldStatus} to ${updated.status}` }) + } + }; + logger.info({ component: `${currentStep} Sub`, message: `Restored ${summary} jobCandidates from ${files[j]}` }) + } + logger.info({ component: currentStep, message: `Report: Totally restored ${totalSum} jobCandidates` }) + logger.info({ component: currentStep, message: '*************************** Restore process finished ***************************' }) +} + +restore().then(() => { + logger.info({ component: currentStep, message: 'Execution Finished!' }) + process.exit() +}).catch(err => { + logger.error(err.message) + process.exit(1) +}) diff --git a/src/bootstrap.js b/src/bootstrap.js index a81e5dcc..896b14f7 100644 --- a/src/bootstrap.js +++ b/src/bootstrap.js @@ -26,7 +26,7 @@ Joi.workPeriodPaymentUpdateStatus = () => Joi.string().valid(..._.values(WorkPer // See https://joi.dev/api/?v=17.3.0#string fro details why it's like this. // In many cases we would like to allow empty string to make it easier to create UI for editing data. Joi.stringAllowEmpty = () => Joi.string().allow('') -Joi.smallint = () => Joi.number().min(-32768).max(32767) +Joi.smallint = () => Joi.number().integer().min(-32768).max(32767) function buildServices (dir) { const files = fs.readdirSync(dir) diff --git a/src/common/helper.js b/src/common/helper.js index 7f9625be..1fd28f60 100644 --- a/src/common/helper.js +++ b/src/common/helper.js @@ -22,6 +22,7 @@ const eventDispatcher = require('./eventDispatcher') const busApi = require('@topcoder-platform/topcoder-bus-api-wrapper') const moment = require('moment') const { PaymentStatusRules } = require('../../app-constants') +const emailTemplateConfig = require('../../config/email_template.config') const localLogger = { debug: (message) => @@ -176,6 +177,7 @@ esIndexPropertyMapping[config.get('esConfig.ES_INDEX_RESOURCE_BOOKING')] = { endDate: { type: 'date', format: 'yyyy-MM-dd' }, memberRate: { type: 'float' }, customerRate: { type: 'float' }, + sendWeeklySurvey: { type: 'boolean' }, rateType: { type: 'keyword' }, billingAccountId: { type: 'integer', null_value: 0 }, workPeriods: { @@ -189,6 +191,14 @@ esIndexPropertyMapping[config.get('esConfig.ES_INDEX_RESOURCE_BOOKING')] = { }, projectId: { type: 'integer' }, userId: { type: 'keyword' }, + sentSurvey: { type: 'boolean' }, + sentSurveyError: { + type: 'nested', + properties: { + errorCode: { type: 'integer' }, + errorMessage: { type: 'keyword' } + } + }, startDate: { type: 'date', format: 'yyyy-MM-dd' }, endDate: { type: 'date', format: 'yyyy-MM-dd' }, daysWorked: { type: 'integer' }, @@ -2011,7 +2021,28 @@ async function getMembersSuggest (fragment) { return res.body } +/** + * Returns the email templates for given key + * @param key the type of email template ex: teamTemplates + * @returns the list of templates for the given key + */ +function getEmailTemplatesForKey (key) { + if (!_.has(emailTemplateConfig, key)) { return [] } + + return _.mapValues(emailTemplateConfig[key], (template) => { + return { + subject: template.subject, + body: template.body, + from: template.from, + recipients: template.recipients, + cc: template.cc, + sendgridTemplateId: template.sendgridTemplateId + } + }) +} + module.exports = { + encodeQueryString, getParamFromCliArgs, promptUser, sleep, @@ -2072,5 +2103,6 @@ module.exports = { createProject, getMemberGroups, removeTextFormatting, - getMembersSuggest + getMembersSuggest, + getEmailTemplatesForKey } diff --git a/src/common/surveyMonkey.js b/src/common/surveyMonkey.js new file mode 100644 index 00000000..20762c3f --- /dev/null +++ b/src/common/surveyMonkey.js @@ -0,0 +1,242 @@ +/* + * surveymonkey api + * + */ + +const logger = require('./logger') +const config = require('config') +const _ = require('lodash') +const request = require('superagent') +const moment = require('moment') +const { encodeQueryString } = require('./helper') +/** + * This code uses several environment variables + * + * WEEKLY_SURVEY_SURVEY_CONTACT_GROUP_ID - the ID of contacts list which would be used to store all the contacts, + * see https://developer.surveymonkey.com/api/v3/#contact_lists-id + * WEEKLY_SURVEY_SURVEY_MASTER_COLLECTOR_ID - the ID of master collector - this collector should be created manually, + * and all other collectors would be created by copying this master collector. + * This is needed so we can make some config inside master collector which would + * be applied to all collectors. + * WEEKLY_SURVEY_SURVEY_MASTER_MESSAGE_ID - the ID of master message - similar to collector, this message would be created manually + * and then script would create copies of this message to use the same config. + */ + +const localLogger = { + debug: (message, context) => logger.debug({ component: 'SurveyMonkeyAPI', context, message }), + error: (message, context) => logger.error({ component: 'SurveyMonkeyAPI', context, message }), + info: (message, context) => logger.info({ component: 'SurveyMonkeyAPI', context, message }) +} + +function getRemainingRequestCountMessage (response) { + return `today has sent ${response.header['x-ratelimit-app-global-day-limit'] - response.header['x-ratelimit-app-global-day-remaining']} requests` +} + +function enrichErrorMessage (e) { + e.code = _.get(e, 'response.body.error.http_status_code') + e.message = _.get(e, 'response.body.error.message', e.toString()) + + return e +} + +function getSingleItem (lst, errorMessage) { + if (lst.length === 0) { + return null + } + + if (lst.length > 1) { + throw new Error(errorMessage) + } + + return lst[0].id +} + +/* + * get collector name + * + * format `Week Ending yyyy-nth(weeks)` + */ +function getCollectorName (dt) { + return config.WEEKLY_SURVEY.SURVEY_COLLECTOR_PREFIX + ' ' + moment(dt).format('M/D/YYYY') +} + +/* + * search collector by name + */ +async function searchCollector (collectorName) { + const url = `${config.WEEKLY_SURVEY.BASE_URL}/surveys/${config.WEEKLY_SURVEY.SURVEY_ID}/collectors?${encodeQueryString({ name: collectorName })}` + try { + const response = await request + .get(url) + .set('Authorization', `Bearer ${config.WEEKLY_SURVEY.JWT_TOKEN}`) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json') + + localLogger.info(`URL ${url}, ${getRemainingRequestCountMessage(response)}`, 'searchCollector') + + return getSingleItem(response.body.data, 'More than 1 collector found by name ' + collectorName) + } catch (e) { + const enrichedError = enrichErrorMessage(e) + localLogger.error(`URL ${url} ERROR ${enrichedError}, ${getRemainingRequestCountMessage(e.response)}`, 'searchCollector') + throw enrichedError + } +} + +/* + * create a named collector if not created + * else return the collectId of the named collector + */ +async function createCollector (collectorName) { + let collectorID = await searchCollector(collectorName) + if (collectorID) { + return collectorID + } + + collectorID = await cloneCollector() + await renameCollector(collectorID, collectorName) + + return collectorID +} + +/* + * clone collector from MASTER_COLLECTOR + */ +async function cloneCollector () { + const body = { from_collector_id: `${config.WEEKLY_SURVEY.SURVEY_MASTER_COLLECTOR_ID}` } + const url = `${config.WEEKLY_SURVEY.BASE_URL}/surveys/${config.WEEKLY_SURVEY.SURVEY_ID}/collectors` + try { + const response = await request + .post(url) + .set('Authorization', `Bearer ${config.WEEKLY_SURVEY.JWT_TOKEN}`) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json') + .send(body) + localLogger.info(`URL ${url}, ${getRemainingRequestCountMessage(response)}`, 'cloneCollector') + return response.body.id + } catch (e) { + const enrichedError = enrichErrorMessage(e) + localLogger.error(`URL ${url} ERROR ${enrichedError}, ${getRemainingRequestCountMessage(e.response)}`, 'cloneCollector') + throw enrichedError + } +} + +/* + * rename collector + */ +async function renameCollector (collectorId, name) { + const body = { name: name } + const url = `${config.WEEKLY_SURVEY.BASE_URL}/collectors/${collectorId}` + try { + const response = await request + .patch(url) + .set('Authorization', `Bearer ${config.WEEKLY_SURVEY.JWT_TOKEN}`) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json') + .send(body) + localLogger.info(`URL ${url}, ${getRemainingRequestCountMessage(response)}`, 'renameCollector') + } catch (e) { + const enrichedError = enrichErrorMessage(e) + localLogger.error(`URL ${url} ERROR ${enrichedError}, ${getRemainingRequestCountMessage(e.response)}`, 'renameCollector') + throw enrichedError + } +} + +/* + * create message + */ +async function createMessage (collectorId) { + const body = { + from_collector_id: `${config.WEEKLY_SURVEY.SURVEY_MASTER_COLLECTOR_ID}`, + from_message_id: `${config.WEEKLY_SURVEY.SURVEY_MASTER_MESSAGE_ID}` + } + const url = `${config.WEEKLY_SURVEY.BASE_URL}/collectors/${collectorId}/messages` + try { + const response = await request + .post(url) + .set('Authorization', `Bearer ${config.WEEKLY_SURVEY.JWT_TOKEN}`) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json') + .send(body) + localLogger.info(`URL ${url}, ${getRemainingRequestCountMessage(response)}`, 'createMessage') + return response.body.id + } catch (e) { + const enrichedError = enrichErrorMessage(e) + localLogger.error(`URL ${url} ERROR ${enrichedError}, ${getRemainingRequestCountMessage(e.response)}`, 'createMessage') + throw enrichedError + } +} + +/** + * Add Contact Email to List for sending a survey + */ +async function upsertContactInSurveyMonkey (list) { + list = _.filter(list, p => p.email) + if (!list.length) { + return [] + } + const body = { + contacts: list + } + const url = `${config.WEEKLY_SURVEY.BASE_URL}/contact_lists/${config.WEEKLY_SURVEY.SURVEY_CONTACT_GROUP_ID}/contacts/bulk` + try { + const response = await request + .post(url) + .set('Authorization', `Bearer ${config.WEEKLY_SURVEY.JWT_TOKEN}`) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json') + .send(body) + + localLogger.info(`URL ${url}, ${getRemainingRequestCountMessage(response)}`, 'upsertContactInSurveyMonkey') + return _.concat(response.body.existing, response.body.succeeded) + } catch (e) { + const enrichedError = enrichErrorMessage(e) + localLogger.error(`URL ${url} ERROR ${enrichedError}, ${getRemainingRequestCountMessage(e.response)}`, 'createMessage') + throw enrichedError + } +} + +async function addContactsToSurvey (collectorId, messageId, contactIds) { + const url = `${config.WEEKLY_SURVEY.BASE_URL}/collectors/${collectorId}/messages/${messageId}/recipients/bulk` + const body = { contact_ids: _.map(contactIds, 'id') } + try { + const response = await request + .post(url) + .set('Authorization', `Bearer ${config.WEEKLY_SURVEY.JWT_TOKEN}`) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json') + .send(body) + localLogger.info(`URL ${url}, ${getRemainingRequestCountMessage(response)}`, 'addContactsToSurvey') + return response.body.id + } catch (e) { + const enrichedError = enrichErrorMessage(e) + localLogger.error(`URL ${url} ERROR ${enrichedError}, ${getRemainingRequestCountMessage(e.response)}`, 'addContactsToSurvey') + throw enrichedError + } +} + +async function sendSurveyAPI (collectorId, messageId) { + const url = `${config.WEEKLY_SURVEY.BASE_URL}/collectors/${collectorId}/messages/${messageId}/send` + try { + const response = await request + .post(url) + .set('Authorization', `Bearer ${config.WEEKLY_SURVEY.JWT_TOKEN}`) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json') + .send({}) + localLogger.info(`URL ${url}, ${getRemainingRequestCountMessage(response)}`, 'sendSurveyAPI') + return response.body.id + } catch (e) { + const enrichedError = enrichErrorMessage(e) + localLogger.error(`URL ${url} ${enrichedError}, ${getRemainingRequestCountMessage(e.response)}`, 'sendSurveyAPI') + throw enrichedError + } +} + +module.exports = { + getCollectorName, + createCollector, + createMessage, + upsertContactInSurveyMonkey, + addContactsToSurvey, + sendSurveyAPI +} diff --git a/src/controllers/TeamController.js b/src/controllers/TeamController.js index 65e5262f..e34fa943 100644 --- a/src/controllers/TeamController.js +++ b/src/controllers/TeamController.js @@ -155,6 +155,24 @@ async function suggestMembers (req, res) { res.send(await service.suggestMembers(req.authUser, req.params.fragment)) } +/** + * + * @param req the request + * @param res the response + */ + async function calculateAmount(req, res) { + res.send(await service.calculateAmount(req.body)); +} + +/** + * + * @param req the request + * @param res the response + */ +async function createPayment(req, res) { + res.send(await service.createPayment(req.body.totalAmount)); +} + module.exports = { searchTeams, getTeam, @@ -169,5 +187,7 @@ module.exports = { roleSearchRequest, createTeam, searchSkills, - suggestMembers + suggestMembers, + createPayment, + calculateAmount } diff --git a/src/controllers/WorkPeriodPaymentController.js b/src/controllers/WorkPeriodPaymentController.js index 3ac5c2fe..1fc44f26 100644 --- a/src/controllers/WorkPeriodPaymentController.js +++ b/src/controllers/WorkPeriodPaymentController.js @@ -22,6 +22,24 @@ async function createWorkPeriodPayment (req, res) { res.send(await service.createWorkPeriodPayment(req.authUser, req.body)) } +/** + * Create workPeriodPayments in bulk + * @param req the request + * @param res the response + */ +async function createBulkOfWorkPeriodPayments (req, res) { + res.send(await service.createBulkOfWorkPeriodPayments(req.authUser, req.body)) +} + +/** + * Update workPeriodPayments in bulk + * @param req the request + * @param res the response + */ +async function updateBulkOfWorkPeriodPayments (req, res) { + res.send(await service.updateBulkOfWorkPeriodPayments(req.authUser, req.body)) +} + /** * Partially update workPeriodPayment by id * @param req the request @@ -54,6 +72,8 @@ async function createQueryWorkPeriodPayments (req, res) { module.exports = { getWorkPeriodPayment, createWorkPeriodPayment, + createBulkOfWorkPeriodPayments, + updateBulkOfWorkPeriodPayments, createQueryWorkPeriodPayments, partiallyUpdateWorkPeriodPayment, searchWorkPeriodPayments diff --git a/src/eventHandlers/ResourceBookingEventHandler.js b/src/eventHandlers/ResourceBookingEventHandler.js index 1134674a..067deb75 100644 --- a/src/eventHandlers/ResourceBookingEventHandler.js +++ b/src/eventHandlers/ResourceBookingEventHandler.js @@ -102,11 +102,11 @@ async function assignJob (payload) { return } const job = await models.Job.findById(resourceBooking.jobId) - if (job.status === 'placed') { + if (job.status === 'placed' || job.status === 'assigned') { logger.debug({ component: 'ResourceBookingEventHandler', context: 'assignJob', - message: `job with projectId ${job.projectId} is already placed` + message: `job with projectId ${job.projectId} is already ${job.status}` }) return } @@ -188,11 +188,13 @@ async function updateWorkPeriods (payload) { const originalFirstWeek = _.find(workPeriods, ['startDate', firstWeek.startDate]) const existentFirstWeek = _.minBy(workPeriods, 'startDate') // recalculate daysWorked for the first week of existent workPeriods and daysWorked have changed - if (firstWeek.startDate === existentFirstWeek.startDate && firstWeek.daysWorked !== existentFirstWeek.daysWorked) { + if (firstWeek.startDate === existentFirstWeek.startDate && firstWeek.daysWorked !== existentFirstWeek.daysWorked && + existentFirstWeek.daysPaid <= firstWeek.daysWorked) { workPeriodsToUpdate.push(_.assign(firstWeek, { id: originalFirstWeek.id })) // if first of intersected workPeriods is not the first one of existent workPeriods // we only check if it's daysWorked exceeds the possible maximum - } else if (originalFirstWeek.daysWorked > firstWeek.daysWorked) { + } else if (originalFirstWeek.daysWorked > firstWeek.daysWorked && + originalFirstWeek.daysPaid <= firstWeek.daysWorked) { workPeriodsToUpdate.push(_.assign(firstWeek, { id: originalFirstWeek.id })) } } @@ -201,11 +203,13 @@ async function updateWorkPeriods (payload) { const originalLastWeek = _.find(workPeriods, ['startDate', lastWeek.startDate]) const existentLastWeek = _.maxBy(workPeriods, 'startDate') // recalculate daysWorked for the last week of existent workPeriods and daysWorked have changed - if (lastWeek.startDate === existentLastWeek.startDate && lastWeek.daysWorked !== existentLastWeek.daysWorked) { + if (lastWeek.startDate === existentLastWeek.startDate && lastWeek.daysWorked !== existentLastWeek.daysWorked && + existentLastWeek.daysPaid <= lastWeek.daysWorked) { workPeriodsToUpdate.push(_.assign(lastWeek, { id: originalLastWeek.id })) // if last of intersected workPeriods is not the last one of existent workPeriods // we only check if it's daysWorked exceeds the possible maximum - } else if (originalLastWeek.daysWorked > lastWeek.daysWorked) { + } else if (originalLastWeek.daysWorked > lastWeek.daysWorked && + originalLastWeek.daysPaid <= lastWeek.daysWorked) { workPeriodsToUpdate.push(_.assign(lastWeek, { id: originalLastWeek.id })) } } diff --git a/src/models/ResourceBooking.js b/src/models/ResourceBooking.js index 580e6e96..21a222f1 100644 --- a/src/models/ResourceBooking.js +++ b/src/models/ResourceBooking.js @@ -122,6 +122,12 @@ module.exports = (sequelize) => { type: Sequelize.STRING(255), allowNull: false }, + sendWeeklySurvey: { + field: 'send_weekly_survey', + type: Sequelize.BOOLEAN, + defaultValue: true, + allowNull: false + }, billingAccountId: { field: 'billing_account_id', type: Sequelize.BIGINT diff --git a/src/models/WorkPeriod.js b/src/models/WorkPeriod.js index 720e4870..d2a3b12c 100644 --- a/src/models/WorkPeriod.js +++ b/src/models/WorkPeriod.js @@ -56,6 +56,26 @@ module.exports = (sequelize) => { type: Sequelize.UUID, allowNull: false }, + sentSurvey: { + field: 'sent_survey', + type: Sequelize.BOOLEAN, + defaultValue: false, + allowNull: false + }, + sentSurveyError: { + field: 'sent_survey_error', + allowNull: true, + type: Sequelize.JSONB({ + errorCode: { + field: 'error_code', + type: Sequelize.INTEGER + }, + errorMessage: { + field: 'error_message', + type: Sequelize.STRING(255) + } + }) + }, userHandle: { field: 'user_handle', type: Sequelize.STRING(50), diff --git a/src/routes/TeamRoutes.js b/src/routes/TeamRoutes.js index a4c1ca5e..3c83d5f2 100644 --- a/src/routes/TeamRoutes.js +++ b/src/routes/TeamRoutes.js @@ -107,5 +107,17 @@ module.exports = { auth: 'jwt', scopes: [] } - } + }, + "/taas-teams/calculateAmount": { + post: { + controller: "TeamController", + method: "calculateAmount", + }, + }, + "/taas-teams/createPayment": { + post: { + controller: "TeamController", + method: "createPayment", + }, +} } diff --git a/src/routes/WorkPeriodPaymentRoutes.js b/src/routes/WorkPeriodPaymentRoutes.js index 7ddd2bc5..06377a4d 100644 --- a/src/routes/WorkPeriodPaymentRoutes.js +++ b/src/routes/WorkPeriodPaymentRoutes.js @@ -18,6 +18,20 @@ module.exports = { scopes: [constants.Scopes.READ_WORK_PERIOD_PAYMENT, constants.Scopes.ALL_WORK_PERIOD_PAYMENT] } }, + '/work-period-payments/bulk': { + post: { + controller: 'WorkPeriodPaymentController', + method: 'createBulkOfWorkPeriodPayments', + auth: 'jwt', + scopes: [constants.Scopes.CREATE_WORK_PERIOD_PAYMENT, constants.Scopes.ALL_WORK_PERIOD_PAYMENT] + }, + patch: { + controller: 'WorkPeriodPaymentController', + method: 'updateBulkOfWorkPeriodPayments', + auth: 'jwt', + scopes: [constants.Scopes.UPDATE_WORK_PERIOD_PAYMENT, constants.Scopes.ALL_WORK_PERIOD_PAYMENT] + } + }, '/work-period-payments/query': { post: { controller: 'WorkPeriodPaymentController', diff --git a/src/services/EmailNotificationService.js b/src/services/EmailNotificationService.js new file mode 100644 index 00000000..5572933c --- /dev/null +++ b/src/services/EmailNotificationService.js @@ -0,0 +1,498 @@ +/** + * Email notification service - has the cron handlers for sending different types of email notifications + */ +const _ = require('lodash') +const { Op } = require('sequelize') +const moment = require('moment') +const config = require('config') +const models = require('../models') +const Job = models.Job +const JobCandidate = models.JobCandidate +const Interview = models.Interview +const ResourceBooking = models.ResourceBooking +const helper = require('../common/helper') +const constants = require('../../app-constants') +const logger = require('../common/logger') + +const localLogger = { + debug: (message, context) => logger.debug({ component: 'EmailNotificationService', context, message }), + error: (message, context) => logger.error({ component: 'EmailNotificationService', context, message }), + info: (message, context) => logger.info({ component: 'EmailNotificationService', context, message }) +} + +const emailTemplates = helper.getEmailTemplatesForKey('notificationEmailTemplates') + +/** + * Returns the project with the given id + * @param projectId the project id + * @returns the project + */ +async function getProjectWithId (projectId) { + let project = null + try { + project = await helper.getProjectById(helper.getAuditM2Muser(), projectId) + } catch (err) { + localLogger.error( + `exception fetching project with id: ${projectId} Status Code: ${err.status} message: ${err.response.text}`, 'getProjectWithId') + } + + return project +} + +/** + * extract the members emails from the given project + * @param project the project + * @returns {string[]} array of emails + */ +function getProjectMembersEmails (project) { + let recipientEmails = _.map(_.get(project, 'members', []), member => member.email) + recipientEmails = _.filter(recipientEmails, email => email) + if (_.isEmpty(recipientEmails)) { + localLogger.error(`No recipients for projectId:${project.id}`, 'getProjectMembersEmails') + } + + return recipientEmails +} + +/** + * Gets the user with the given id + * @param userId the user id + * @returns the user + */ +async function getUserWithId (userId) { + let user = null + try { + user = await helper.ensureUserById(userId) + } catch (err) { + localLogger.error( + `exception fetching user with id: ${userId} Status Code: ${err.status} message: ${err.response.text}`, 'getUserWithId') + } + + return user +} + +/** + * returns the data for the interview + * @param interview the interview + * @param jobCandidate optional jobCandidate corresponding to interview + * @param job option job corresponding to interview + * @returns the interview details in format used by client + */ +async function getDataForInterview (interview, jobCandidate, job) { + jobCandidate = jobCandidate || await JobCandidate.findById(interview.jobCandidateId) + + job = job || await Job.findById(jobCandidate.jobId) + + const user = await getUserWithId(jobCandidate.userId) + if (!user) { return null } + + const interviewLink = `${config.TAAS_APP_URL}/${job.projectId}/positions/${job.id}/candidates/interviews` + const guestName = _.isEmpty(interview.guestNames) ? '' : interview.guestNames[0] + const startTime = interview.startTimestamp ? interview.startTimestamp.toUTCString() : '' + + return { + jobTitle: job.title, + guestFullName: guestName, + hostFullName: interview.hostName, + candidateName: `${user.firstName} ${user.lastName}`, + handle: user.handle, + attendees: interview.guestNames, + startTime: startTime, + duration: interview.duration, + interviewLink + } +} + +/** + * Sends email notifications to all the teams which have candidates available for review + */ +async function sendCandidatesAvailableEmails () { + const jobsDao = await Job.findAll({ + include: [{ + model: JobCandidate, + as: 'candidates', + required: true, + where: { + status: constants.JobStatus.OPEN + } + }] + }) + const jobs = _.map(jobsDao, dao => dao.dataValues) + + const projectIds = _.uniq(_.map(jobs, job => job.projectId)) + // for each unique project id, send an email + for (const projectId of projectIds) { + const project = await getProjectWithId(projectId) + if (!project) { continue } + + const recipientEmails = getProjectMembersEmails(project) + const projectJobs = _.filter(jobs, job => job.projectId === projectId) + + const teamJobs = [] + for (const projectJob of projectJobs) { + // get candidate list + const jobCandidates = [] + for (const jobCandidate of projectJob.candidates) { + const user = await getUserWithId(jobCandidate.userId) + if (!user) { continue } + + jobCandidates.push({ + handle: user.handle, + status: jobCandidate.status + }) + } + + // review link + const reviewLink = `${config.TAAS_APP_URL}/${projectId}/positions/${projectJob.id}/candidates/to-review` + + // get # of resource bookings + const nResourceBookings = await ResourceBooking.count({ + where: { + jobId: projectJob.id + } + }) + + teamJobs.push({ + title: projectJob.title, + nResourceBookings, + jobCandidates, + reviewLink + }) + } + + sendEmail({}, { + template: 'taas.notification.candidates-available-for-review', + recipients: recipientEmails, + data: { + teamName: project.name, + teamJobs, + notificationType: { + candidatesAvailableForReview: true + }, + description: 'Candidates are available for review' + } + }) + } +} + +/** + * Sends email reminders to the hosts and guests about their upcoming interview(s) + */ +async function sendInterviewComingUpEmails () { + const currentTime = moment.utc() + const timestampFilter = { + [Op.or]: [] + } + const window = moment.duration(config.INTERVIEW_COMING_UP_MATCH_WINDOW) + for (const remindTime of config.INTERVIEW_COMING_UP_REMIND_TIME) { + const rangeStart = currentTime.clone().add(moment.duration(remindTime)) + const rangeEnd = rangeStart.clone().add(window) + + timestampFilter[Op.or].push({ + [Op.and]: [ + { + [Op.gt]: rangeStart + }, + { + [Op.lte]: rangeEnd + } + ] + }) + } + + const filter = { + [Op.and]: [ + { + status: { [Op.eq]: constants.Interviews.Status.Scheduled } + }, + { + startTimestamp: timestampFilter + } + ] + } + + const interviews = await Interview.findAll({ + where: filter, + raw: true + }) + + for (const interview of interviews) { + // send host email + const data = await getDataForInterview(interview) + if (!data) { continue } + + if (!_.isEmpty(interview.hostEmail)) { + sendEmail({}, { + template: 'taas.notification.interview-coming-up-host', + recipients: [interview.hostEmail], + data: { + ...data, + notificationType: { + interviewComingUpForHost: true + }, + description: 'Interview Coming Up' + } + }) + } else { + localLogger.error(`Interview id: ${interview.id} host email not present`, 'sendInterviewComingUpEmails') + } + + if (!_.isEmpty(interview.guestEmails)) { + // send guest emails + sendEmail({}, { + template: 'taas.notification.interview-coming-up-guest', + recipients: interview.guestEmails, + data: { + ...data, + notificationType: { + interviewComingUpForGuest: true + }, + description: 'Interview Coming Up' + } + }) + } else { + localLogger.error(`Interview id: ${interview.id} guest emails not present`, 'sendInterviewComingUpEmails') + } + } +} + +/** + * Sends email reminder to the interview host after it ends to change the interview status + */ +async function sendInterviewCompletedEmails () { + const window = moment.duration(config.INTERVIEW_COMPLETED_MATCH_WINDOW) + const rangeStart = moment.utc().subtract(moment.duration(config.INTERVIEW_COMPLETED_PAST_TIME)) + const rangeEnd = rangeStart.clone().add(window) + const filter = { + [Op.and]: [ + { + status: { [Op.eq]: constants.Interviews.Status.Scheduled } + }, + { + endTimestamp: { + [Op.and]: [ + { + [Op.gte]: rangeStart + }, + { + [Op.lt]: rangeEnd + } + ] + } + } + ] + } + + const interviews = await Interview.findAll({ + where: filter, + raw: true + }) + + for (const interview of interviews) { + if (_.isEmpty(interview.hostEmail)) { + localLogger.error(`Interview id: ${interview.id} host email not present`) + continue + } + + const data = await getDataForInterview(interview) + if (!data) { continue } + + sendEmail({}, { + template: 'taas.notification.interview-awaits-resolution', + recipients: [interview.hostEmail], + data: { + ...data, + notificationType: { + interviewCompleted: true + }, + description: 'Interview Completed' + } + }) + } +} + +/** + * Sends email reminder to the all members of teams which have interview completed to take action + * to update the job candidate status + */ +async function sendPostInterviewActionEmails () { + const completedJobCandidates = await JobCandidate.findAll({ + where: { + status: constants.JobCandidateStatus.INTERVIEW + }, + include: [{ + model: Interview, + as: 'interviews', + required: true, + where: { + status: constants.Interviews.Status.Completed + } + }] + }) + + // get all project ids for this job candidates + const jobs = await Job.findAll({ + where: { + id: { + [Op.in]: completedJobCandidates.map(jc => jc.jobId) + } + }, + raw: true + }) + + const projectIds = _.uniq(_.map(jobs, job => job.projectId)) + for (const projectId of projectIds) { + const project = await getProjectWithId(projectId) + if (!project) { continue } + + const recipientEmails = getProjectMembersEmails(project) + const projectJobs = _.filter(jobs, job => job.projectId === projectId) + const teamInterviews = [] + let numCandidates = 0 + for (const projectJob of projectJobs) { + const projectJcs = _.filter(completedJobCandidates, jc => jc.jobId === projectJob.id) + numCandidates += projectJcs.length + for (const projectJc of projectJcs) { + for (const interview of projectJc.interviews) { + const d = await getDataForInterview(interview, projectJc, projectJob) + if (!d) { continue } + teamInterviews.push(d) + } + } + } + + sendEmail({}, { + template: 'taas.notification.post-interview-action-required', + recipients: recipientEmails, + data: { + teamName: project.name, + numCandidates, + teamInterviews, + notificationType: { + postInterviewCandidateAction: true + }, + description: 'Post Interview Candidate Action Reminder' + } + }) + } +} + +/** + * Sends reminder emails to all members of teams which have atleast one upcoming resource booking expiration + */ +async function sendResourceBookingExpirationEmails () { + const currentTime = moment.utc() + const maxEndDate = currentTime.clone().add(moment.duration(config.RESOURCE_BOOKING_EXPIRY_TIME)) + + const expiringResourceBookings = await ResourceBooking.findAll({ + where: { + endDate: { + [Op.and]: [ + { + [Op.gt]: currentTime + }, + { + [Op.lte]: maxEndDate + } + ] + } + }, + raw: true + }) + + const jobs = await Job.findAll({ + where: { + id: { + [Op.in]: _.map(expiringResourceBookings, rb => rb.jobId) + } + }, + raw: true + }) + const projectIds = _.uniq(_.map(expiringResourceBookings, rb => rb.projectId)) + + for (const projectId of projectIds) { + const project = await getProjectWithId(projectId) + if (!project) { continue } + const recipientEmails = getProjectMembersEmails(project) + const projectJobs = _.filter(jobs, job => job.projectId === projectId) + + let numResourceBookings = 0 + const teamResourceBookings = [] + for (const projectJob of projectJobs) { + const resBookings = _.filter(expiringResourceBookings, rb => rb.jobId === projectJob.id) + numResourceBookings += resBookings.length + + for (const booking of resBookings) { + const user = await getUserWithId(booking.userId) + if (!user) { continue } + + teamResourceBookings.push({ + jobTitle: projectJob.title, + handle: user.handle, + endDate: booking.endDate + }) + } + } + + sendEmail({}, { + template: 'taas.notification.resource-booking-expiration', + recipients: recipientEmails, + data: { + teamName: project.name, + numResourceBookings, + teamResourceBookings, + notificationType: { + upcomingResourceBookingExpiration: true + }, + description: 'Upcoming Resource Booking Expiration' + } + }) + } +} + +/** + * Send email through a particular template + * @param {Object} currentUser the user who perform this operation + * @param {Object} data the email object + * @returns {undefined} + */ +async function sendEmail (currentUser, data) { + const template = emailTemplates[data.template] + const dataCC = data.cc || [] + const templateCC = template.cc || [] + const dataRecipients = data.recipients || [] + const templateRecipients = template.recipients || [] + const subjectBody = { + subject: data.subject || template.subject, + body: data.body || template.body + } + for (const key in subjectBody) { + subjectBody[key] = await helper.substituteStringByObject( + subjectBody[key], + data.data + ) + } + const emailData = { + serviceId: 'email', + type: data.template, + details: { + from: data.from || template.from, + recipients: _.map(_.uniq([...dataRecipients, ...templateRecipients]), function (r) { return { email: r } }), + cc: _.map(_.uniq([...dataCC, ...templateCC]), function (r) { return { email: r } }), + data: { ...data.data, ...subjectBody }, + sendgridTemplateId: template.sendgridTemplateId, + version: 'v3' + } + } + await helper.postEvent(config.NOTIFICATIONS_CREATE_TOPIC, { + notifications: [emailData] + }) +} + +module.exports = { + sendCandidatesAvailableEmails, + sendInterviewComingUpEmails, + sendInterviewCompletedEmails, + sendPostInterviewActionEmails, + sendResourceBookingExpirationEmails +} diff --git a/src/services/ResourceBookingService.js b/src/services/ResourceBookingService.js index 46d2fe62..6b155ce6 100644 --- a/src/services/ResourceBookingService.js +++ b/src/services/ResourceBookingService.js @@ -250,16 +250,6 @@ async function _ensurePaidWorkPeriodsNotDeleted (resourceBookingId, oldValue, ne // we can't delete workperiods with paymentStatus 'partially-completed', 'completed' or 'in-progress', // or any of it's WorkPeriodsPayment has status 'completed' or 'in-progress'. _checkForPaidWorkPeriods(workPeriodsToRemove) - // check if this update makes maximum possible daysWorked value less than daysPaid - _.each(newWorkPeriods, newWP => { - const wp = _.find(workPeriods, ['startDate', newWP.startDate]) - if (!wp) { - return - } - if (wp.daysPaid > newWP.daysWorked) { - throw new errors.ConflictError(`Cannot make maximum daysWorked (${newWP.daysWorked}) to the value less than daysPaid (${wp.daysPaid}) for WorkPeriod: ${wp.id}`) - } - }) } /** @@ -354,6 +344,7 @@ createResourceBooking.schema = Joi.object().keys({ projectId: Joi.number().integer().required(), userId: Joi.string().uuid().required(), jobId: Joi.string().uuid().allow(null), + sendWeeklySurvey: Joi.boolean().default(true), startDate: Joi.date().format('YYYY-MM-DD').allow(null), endDate: Joi.date().format('YYYY-MM-DD').when('startDate', { is: Joi.exist(), @@ -427,6 +418,7 @@ partiallyUpdateResourceBooking.schema = Joi.object().keys({ memberRate: Joi.number().allow(null), customerRate: Joi.number().allow(null), rateType: Joi.rateType(), + sendWeeklySurvey: Joi.boolean(), billingAccountId: Joi.number().allow(null) }).required() }).required() @@ -466,6 +458,7 @@ fullyUpdateResourceBooking.schema = Joi.object().keys({ customerRate: Joi.number().allow(null).default(null), rateType: Joi.rateType().required(), status: Joi.resourceBookingStatus().required(), + sendWeeklySurvey: Joi.boolean().default(true), billingAccountId: Joi.number().allow(null).default(null) }).required() }).required() @@ -546,6 +539,10 @@ async function searchResourceBookings (currentUser, criteria, options) { if (!criteria.sortOrder) { criteria.sortOrder = 'desc' } + + if (_.has(criteria, 'workPeriods.sentSurveyError') && !criteria['workPeriods.sentSurveyError']) { + criteria['workPeriods.sentSurveyError'] = null + } // this option to return data from DB is only for internal usage, and it cannot be passed from the endpoint if (!options.returnFromDB) { try { @@ -590,7 +587,7 @@ async function searchResourceBookings (currentUser, criteria, options) { } esQuery.body.sort.push(sort) // Apply ResourceBooking filters - _.each(_.pick(criteria, ['status', 'startDate', 'endDate', 'rateType', 'projectId', 'jobId', 'userId', 'billingAccountId']), (value, key) => { + _.each(_.pick(criteria, ['sendWeeklySurvey', 'status', 'startDate', 'endDate', 'rateType', 'projectId', 'jobId', 'userId', 'billingAccountId']), (value, key) => { esQuery.body.query.bool.must.push({ term: { [key]: { @@ -626,7 +623,7 @@ async function searchResourceBookings (currentUser, criteria, options) { }) } // Apply WorkPeriod and WorkPeriodPayment filters - const workPeriodFilters = _.pick(criteria, ['workPeriods.paymentStatus', 'workPeriods.startDate', 'workPeriods.endDate', 'workPeriods.userHandle']) + const workPeriodFilters = _.pick(criteria, ['workPeriods.sentSurveyError', 'workPeriods.sentSurvey', 'workPeriods.paymentStatus', 'workPeriods.startDate', 'workPeriods.endDate', 'workPeriods.userHandle']) const workPeriodPaymentFilters = _.pick(criteria, ['workPeriods.payments.status', 'workPeriods.payments.days']) if (!_.isEmpty(workPeriodFilters) || !_.isEmpty(workPeriodPaymentFilters)) { const workPeriodsMust = [] @@ -637,7 +634,7 @@ async function searchResourceBookings (currentUser, criteria, options) { [key]: value } }) - } else { + } else if (key !== 'workPeriods.sentSurveyError') { workPeriodsMust.push({ term: { [key]: { @@ -666,6 +663,7 @@ async function searchResourceBookings (currentUser, criteria, options) { } }) } + esQuery.body.query.bool.must.push({ nested: { path: 'workPeriods', @@ -688,7 +686,9 @@ async function searchResourceBookings (currentUser, criteria, options) { r.workPeriods = _.filter(r.workPeriods, wp => { return _.every(_.omit(workPeriodFilters, 'workPeriods.userHandle'), (value, key) => { key = key.split('.')[1] - if (key === 'paymentStatus') { + if (key === 'sentSurveyError' && !workPeriodFilters['workPeriods.sentSurveyError']) { + return !wp[key] + } else if (key === 'paymentStatus') { return _.includes(value, wp[key]) } else { return wp[key] === value @@ -723,7 +723,7 @@ async function searchResourceBookings (currentUser, criteria, options) { logger.info({ component: 'ResourceBookingService', context: 'searchResourceBookings', message: 'fallback to DB query' }) const filter = { [Op.and]: [] } // Apply ResourceBooking filters - _.each(_.pick(criteria, ['status', 'startDate', 'endDate', 'rateType', 'projectId', 'jobId', 'userId']), (value, key) => { + _.each(_.pick(criteria, ['sendWeeklySurvey', 'status', 'startDate', 'endDate', 'rateType', 'projectId', 'jobId', 'userId']), (value, key) => { filter[Op.and].push({ [key]: value }) }) if (!_.isUndefined(criteria.billingAccountId)) { @@ -773,7 +773,7 @@ async function searchResourceBookings (currentUser, criteria, options) { queryCriteria.include[0].attributes = { exclude: _.map(queryOpt.excludeWP, f => _.split(f, '.')[1]) } } // Apply WorkPeriod filters - _.each(_.pick(criteria, ['workPeriods.startDate', 'workPeriods.endDate', 'workPeriods.paymentStatus']), (value, key) => { + _.each(_.pick(criteria, ['workPeriods.sentSurveyError', 'workPeriods.sentSurvey', 'workPeriods.startDate', 'workPeriods.endDate', 'workPeriods.paymentStatus']), (value, key) => { key = key.split('.')[1] queryCriteria.include[0].where[Op.and].push({ [key]: value }) }) @@ -869,6 +869,7 @@ searchResourceBookings.schema = Joi.object().keys({ Joi.string(), Joi.array().items(Joi.number().integer()) ), + sendWeeklySurvey: Joi.boolean(), billingAccountId: Joi.number().integer(), 'workPeriods.paymentStatus': Joi.alternatives( Joi.string(), @@ -891,6 +892,11 @@ searchResourceBookings.schema = Joi.object().keys({ return value }), 'workPeriods.userHandle': Joi.string(), + 'workPeriods.sentSurvey': Joi.boolean(), + 'workPeriods.sentSurveyError': Joi.object().keys({ + errorCode: Joi.number().integer().min(0), + errorMessage: Joi.string() + }).allow('').optional(), 'workPeriods.isFirstWeek': Joi.when(Joi.ref('workPeriods.startDate', { separator: false }), { is: Joi.exist(), then: Joi.boolean().default(false), @@ -906,7 +912,7 @@ searchResourceBookings.schema = Joi.object().keys({ }) }), 'workPeriods.payments.status': Joi.workPeriodPaymentStatus(), - 'workPeriods.payments.days': Joi.number().integer().min(0).max(5) + 'workPeriods.payments.days': Joi.number().integer().min(0).max(10) }).required(), options: Joi.object().keys({ returnAll: Joi.boolean().default(false), diff --git a/src/services/RoleService.js b/src/services/RoleService.js index 7ff7de65..ba128170 100644 --- a/src/services/RoleService.js +++ b/src/services/RoleService.js @@ -131,24 +131,24 @@ createRole.schema = Joi.object().keys({ description: Joi.string().max(1000), listOfSkills: Joi.array().items(Joi.string().max(50).required()), rates: Joi.array().items(Joi.object().keys({ - global: Joi.smallint(), - inCountry: Joi.smallint(), - offShore: Joi.smallint(), - niche: Joi.smallint(), - rate30Niche: Joi.smallint(), - rate30Global: Joi.smallint(), - rate30InCountry: Joi.smallint(), - rate30OffShore: Joi.smallint(), - rate20Niche: Joi.smallint(), - rate20Global: Joi.smallint(), - rate20InCountry: Joi.smallint(), - rate20OffShore: Joi.smallint() + global: Joi.smallint().min(1), + inCountry: Joi.smallint().min(1), + offShore: Joi.smallint().min(1), + niche: Joi.smallint().min(1), + rate30Niche: Joi.smallint().min(1), + rate30Global: Joi.smallint().min(1), + rate30InCountry: Joi.smallint().min(1), + rate30OffShore: Joi.smallint().min(1), + rate20Niche: Joi.smallint().min(1), + rate20Global: Joi.smallint().min(1), + rate20InCountry: Joi.smallint().min(1), + rate20OffShore: Joi.smallint().min(1) }).required()).required(), - numberOfMembers: Joi.number(), - numberOfMembersAvailable: Joi.smallint(), + numberOfMembers: Joi.number().integer().min(1), + numberOfMembersAvailable: Joi.smallint().min(1), imageUrl: Joi.string().uri().max(255), - timeToCandidate: Joi.smallint(), - timeToInterview: Joi.smallint() + timeToCandidate: Joi.smallint().min(1), + timeToInterview: Joi.smallint().min(1) }).required() }).required() @@ -189,24 +189,24 @@ updateRole.schema = Joi.object().keys({ description: Joi.string().max(1000).allow(null), listOfSkills: Joi.array().items(Joi.string().max(50).required()).allow(null), rates: Joi.array().items(Joi.object().keys({ - global: Joi.smallint().required(), - inCountry: Joi.smallint().required(), - offShore: Joi.smallint().required(), - niche: Joi.smallint(), - rate30Niche: Joi.smallint(), - rate30Global: Joi.smallint(), - rate30InCountry: Joi.smallint(), - rate30OffShore: Joi.smallint(), - rate20Global: Joi.smallint(), - rate20Niche: Joi.smallint(), - rate20InCountry: Joi.smallint(), - rate20OffShore: Joi.smallint() + global: Joi.smallint().min(1).required(), + inCountry: Joi.smallint().min(1).required(), + offShore: Joi.smallint().min(1).required(), + niche: Joi.smallint().min(1), + rate30Niche: Joi.smallint().min(1), + rate30Global: Joi.smallint().min(1), + rate30InCountry: Joi.smallint().min(1), + rate30OffShore: Joi.smallint().min(1), + rate20Global: Joi.smallint().min(1), + rate20Niche: Joi.smallint().min(1), + rate20InCountry: Joi.smallint().min(1), + rate20OffShore: Joi.smallint().min(1) }).required()), - numberOfMembers: Joi.number().allow(null), - numberOfMembersAvailable: Joi.smallint().allow(null), + numberOfMembers: Joi.number().integer().min(1).allow(null), + numberOfMembersAvailable: Joi.smallint().min(1).allow(null), imageUrl: Joi.string().uri().max(255).allow(null), - timeToCandidate: Joi.smallint().allow(null), - timeToInterview: Joi.smallint().allow(null) + timeToCandidate: Joi.smallint().min(1).allow(null), + timeToInterview: Joi.smallint().min(1).allow(null) }).required() }).required() diff --git a/src/services/SurveyService.js b/src/services/SurveyService.js new file mode 100644 index 00000000..33641062 --- /dev/null +++ b/src/services/SurveyService.js @@ -0,0 +1,159 @@ +const _ = require('lodash') +const logger = require('../common/logger') +const { searchResourceBookings } = require('./ResourceBookingService') +const { partiallyUpdateWorkPeriod } = require('./WorkPeriodService') +const { Scopes } = require('../../app-constants') +const { getUserById, getMemberDetailsByHandle } = require('../common/helper') +const { getCollectorName, createCollector, createMessage, upsertContactInSurveyMonkey, addContactsToSurvey, sendSurveyAPI } = require('../common/surveyMonkey') + +const resourceBookingCache = {} +const contactIdToWorkPeriodIdMap = {} +const emailToWorkPeriodIdMap = {} + +function buildSentSurveyError (e) { + return { + errorCode: _.get(e, 'code'), + errorMessage: _.get(e, 'message', e.toString()) + } +} + +/** + * Scheduler process entrance + */ +async function sendSurveys () { + const currentUser = { + isMachine: true, + scopes: [Scopes.ALL_WORK_PERIOD, Scopes.ALL_WORK_PERIOD_PAYMENT] + } + + const criteria = { + fields: 'workPeriods,userId,id,sendWeeklySurvey', + sendWeeklySurvey: true, + 'workPeriods.paymentStatus': 'completed', + 'workPeriods.sentSurvey': false, + 'workPeriods.sentSurveyError': '', + jobIds: [], + page: 1 + } + + const options = { + returnAll: true, + returnFromDB: true + } + try { + let resourceBookings = await searchResourceBookings(currentUser, criteria, options) + resourceBookings = resourceBookings.result + + logger.info({ component: 'SurveyService', context: 'sendSurvey', message: 'load workPeriod successfully' }) + + const workPeriods = _.flatten(_.map(resourceBookings, 'workPeriods')) + + const collectors = {} + + // for each WorkPeriod make sure to creat a collector (one per week) + // so several WorkPeriods for the same week would be included into on collector + // and gather contacts (members) from each WorkPeriods + for (const workPeriod of workPeriods) { + try { + const collectorName = getCollectorName(workPeriod.endDate) + + // create collector and message for each week if not yet + if (!collectors[collectorName]) { + const collectorId = await createCollector(collectorName) + const messageId = await createMessage(collectorId) + // create map + contactIdToWorkPeriodIdMap[collectorName] = {} + emailToWorkPeriodIdMap[collectorName] = {} + collectors[collectorName] = { + collectorId, + messageId, + contacts: [] + } + } + + const resourceBooking = _.find(resourceBookings, (r) => r.id === workPeriod.resourceBookingId) + const userInfo = {} + if (!resourceBookingCache[resourceBooking.userId]) { + let user = await getUserById(resourceBooking.userId) + if (!user.email && user.handle) { + user = await getMemberDetailsByHandle(user.handle) + } + if (user.email) { + userInfo.email = user.email + if (user.firstName) { + userInfo.first_name = user.firstName + } + if (user.lastName) { + userInfo.last_name = user.lastName + } + resourceBookingCache[resourceBooking.userId] = userInfo + } + } + emailToWorkPeriodIdMap[collectorName][resourceBookingCache[resourceBooking.userId].email] = workPeriod.id + collectors[collectorName].contacts.push(resourceBookingCache[resourceBooking.userId]) + } catch (e) { + try { + await partiallyUpdateWorkPeriod( + currentUser, + workPeriod.id, + { sentSurveyError: buildSentSurveyError(e) } + ) + } catch (e) { + logger.error({ component: 'SurveyService', context: 'sendSurvey', message: `Error updating survey as failed for Work Period "${workPeriod.id}": ` + e.message }) + } + } + } + + // add contacts + for (const collectorName in collectors) { + const collector = collectors[collectorName] + collectors[collectorName].contacts = await upsertContactInSurveyMonkey(collector.contacts) + + for (const contact of collectors[collectorName].contacts) { + contactIdToWorkPeriodIdMap[collectorName][contact.id] = emailToWorkPeriodIdMap[collectorName][contact.email] + } + } + + // send surveys + for (const collectorName in collectors) { + const collector = collectors[collectorName] + if (collector.contacts.length) { + try { + await addContactsToSurvey( + collector.collectorId, + collector.messageId, + collector.contacts + ) + await sendSurveyAPI(collector.collectorId, collector.messageId) + for (const contactId in contactIdToWorkPeriodIdMap[collectorName]) { + try { + await partiallyUpdateWorkPeriod(currentUser, contactIdToWorkPeriodIdMap[collectorName][contactId], { sentSurvey: true }) + } catch (e) { + logger.error({ component: 'SurveyService', context: 'sendSurvey', message: `Error updating survey as sent for Work Period "${contactIdToWorkPeriodIdMap[collectorName][contactId]}": ` + e.message }) + } + } + } catch (e) { + for (const contactId in contactIdToWorkPeriodIdMap[collectorName]) { + try { + await partiallyUpdateWorkPeriod( + currentUser, + contactIdToWorkPeriodIdMap[collectorName][contactId], + { sentSurveyError: buildSentSurveyError(e) } + ) + } catch (e) { + logger.error({ component: 'SurveyService', context: 'sendSurvey', message: `Error updating survey as failed for Work Period "${contactIdToWorkPeriodIdMap[collectorName][contactId]}": ` + e.message }) + } + } + } + } + } + + logger.info({ component: 'SurveyService', context: 'sendSurvey', message: 'Processing weekly surveys is completed' }) + } catch (e) { + logger.error({ component: 'SurveyService', context: 'sendSurvey', message: 'Error sending surveys: ' + e.message }) + } +} + +module.exports = { + sendSurveys +} diff --git a/src/services/TeamService.js b/src/services/TeamService.js index 2ffd9c2d..cfac33db 100644 --- a/src/services/TeamService.js +++ b/src/services/TeamService.js @@ -6,7 +6,6 @@ const _ = require('lodash') const Joi = require('joi') const dateFNS = require('date-fns') const config = require('config') -const emailTemplateConfig = require('../../config/email_template.config') const helper = require('../common/helper') const logger = require('../common/logger') const errors = require('../common/errors') @@ -20,17 +19,9 @@ const { getAuditM2Muser } = require('../common/helper') const { matchedSkills, unMatchedSkills } = require('../../scripts/emsi-mapping/esmi-skills-mapping') const Role = models.Role const RoleSearchRequest = models.RoleSearchRequest +const stripe = require("stripe")(process.env.STRIPE_SECRET_KEY); -const emailTemplates = _.mapValues(emailTemplateConfig, (template) => { - return { - subject: template.subject, - body: template.body, - from: template.from, - recipients: template.recipients, - cc: template.cc, - sendgridTemplateId: template.sendgridTemplateId - } -}) +const emailTemplates = helper.getEmailTemplatesForKey('teamTemplates') /** * Function to get placed resource bookings with specific projectIds @@ -803,8 +794,11 @@ async function getRoleBySkills (skills) { const roles = await Role.findAll(queryCriteria) if (roles.length > 0) { let result = _.each(roles, role => { + // role matched skills list + role.matchedSkills = _.intersection(role.listOfSkills, skills) + role.unMatchedSkills = _.difference(skills, role.matchedSkills) // calculate each found roles matching rate - role.skillsMatch = _.intersection(role.listOfSkills, skills).length / skills.length + role.skillsMatch = role.matchedSkills.length / skills.length // each role can have multiple rates, get the maximum of global rates role.maxGlobal = _.maxBy(role.rates, 'global').global }) @@ -874,10 +868,12 @@ async function getSkillsByJobDescription (data) { }) }) foundSkills = _.uniq(foundSkills) + const skillIds = await getSkillIdsByNames(foundSkills) // apply desired template - _.each(foundSkills, skill => { + _.each(foundSkills, (skillTag, idx) => { result.push({ - tag: skill, + id: skillIds[idx], + tag: skillTag, type: 'taas_skill', source: 'taas-jd-parser' }) @@ -933,12 +929,12 @@ getSkillNamesByIds.schema = Joi.object() * @returns {Array} the array of skill ids */ async function getSkillIdsByNames (skills) { - const result = await helper.getAllTopcoderSkills({ name: _.join(skills, ',') }) + const tcSkills = await helper.getAllTopcoderSkills({ name: _.join(skills, ',') }) // endpoint returns the partial matched skills // we need to filter by exact match case insensitive - const filteredSkills = _.filter(result, tcSkill => _.some(skills, skill => _.toLower(skill) === _.toLower(tcSkill.name))) - const skillIds = _.map(filteredSkills, 'id') - return skillIds + // const filteredSkills = _.filter(result, tcSkill => _.some(skills, skill => _.toLower(skill) === _.toLower(tcSkill.name))) + const matchedSkills = _.map(skills, skillTag => tcSkills.find(tcSkill => _.toLower(skillTag) === _.toLower(tcSkill.name))) + return _.map(matchedSkills, 'id') } getSkillIdsByNames.schema = Joi.object() @@ -1051,6 +1047,7 @@ async function createTeam (currentUser, data) { numPositions: position.numberOfResources, rateType: position.rateType, workload: position.workload, + hoursPerWeek: position.hoursPerWeek, skills: roleSearchRequest.skills, description: roleSearchRequest.jobDescription, roleIds: [roleSearchRequest.roleId], @@ -1083,6 +1080,7 @@ createTeam.schema = Joi.object() startMonth: Joi.date(), rateType: Joi.rateType().default('weekly'), workload: Joi.workload().default('full-time'), + hoursPerWeek: Joi.number().integer().positive(), resourceType: Joi.string() }).required() ).required() @@ -1158,6 +1156,30 @@ suggestMembers.schema = Joi.object().keys({ fragment: Joi.string().required() }).required() +/** + * Calculates total amount + * @param {Object} body + * @returns {int} totalAmount + */ + async function calculateAmount(body) { + const totalAmount = body.numberOfResources * body.rates * body.durationWeeks; + return { totalAmount }; +} + +/** + * Creates token for stripe + * @param {int} totalAmount + * @returns {string} paymentIntentToken + */ +async function createPayment(totalAmount) { + const paymentIntent = await stripe.paymentIntents.create({ + amount: totalAmount, + currency: process.env.CURRENCY, + }); + return { paymentIntentToken: paymentIntent.client_secret }; +} + + module.exports = { searchTeams, getTeam, @@ -1176,6 +1198,8 @@ module.exports = { createRoleSearchRequest, isExternalMember, createTeam, + calculateAmount, + createPayment, searchSkills, suggestMembers } diff --git a/src/services/WorkPeriodPaymentService.js b/src/services/WorkPeriodPaymentService.js index 9a7a7780..ac0fbe19 100644 --- a/src/services/WorkPeriodPaymentService.js +++ b/src/services/WorkPeriodPaymentService.js @@ -13,7 +13,7 @@ const helper = require('../common/helper') const logger = require('../common/logger') const errors = require('../common/errors') const models = require('../models') -const { WorkPeriodPaymentStatus } = require('../../app-constants') +const { WorkPeriodPaymentStatus, ActiveWorkPeriodPaymentStatuses } = require('../../app-constants') const { searchResourceBookings } = require('./ResourceBookingService') const WorkPeriodPayment = models.WorkPeriodPayment @@ -48,6 +48,33 @@ async function _createSingleWorkPeriodPayment (workPeriodPayment, createdBy) { return _createSingleWorkPeriodPaymentWithWorkPeriodAndResourceBooking(workPeriodPayment, createdBy, correspondingWorkPeriod.toJSON(), correspondingResourceBooking.toJSON()) } +/** + * update challenge + * @param {String} challengeId the challenge id + * @param {Object} data the challenge update data + * @returns {undefined} + */ +async function _updateChallenge (challengeId, data) { + const body = {} + + if (data.billingAccountId) { + body.billing = { + billingAccountId: _.toString(data.billingAccountId), + markup: 0 // for TaaS payments we always use 0 markup + } + } + + if (data.billingAccountId) { + try { + await helper.updateChallenge(challengeId, body) + logger.debug({ component: 'WorkPeriodPaymentService', context: 'updateChallenge', message: `Challenge with id ${challengeId} is updated` }) + } catch (err) { + logger.error({ component: 'WorkPeriodPaymentService', context: 'updateChallenge', message: err.response.text }) + throw new errors.BadRequestError(`Cannot update the the challenge: ${err.response.text}`) + } + } +} + /** * Create single workPeriodPayment * @param {Object} workPeriodPayment the workPeriodPayment to be created @@ -61,27 +88,33 @@ async function _createSingleWorkPeriodPaymentWithWorkPeriodAndResourceBooking (w throw new errors.ConflictError(`id: ${correspondingResourceBooking.id} "ResourceBooking" Billing account is not assigned to the resource booking`) } workPeriodPayment.billingAccountId = correspondingResourceBooking.billingAccountId - if (_.isNil(correspondingResourceBooking.memberRate)) { - throw new errors.ConflictError(`Can't find a member rate in ResourceBooking: ${correspondingResourceBooking.id} to calculate the amount`) - } - if (correspondingResourceBooking.memberRate <= 0) { - throw new errors.ConflictError(`Can't process payment with member rate: ${correspondingResourceBooking.memberRate}. It must be higher than 0`) - } - workPeriodPayment.memberRate = correspondingResourceBooking.memberRate - const maxPossibleDays = correspondingWorkPeriod.daysWorked - correspondingWorkPeriod.daysPaid - if (workPeriodPayment.days > maxPossibleDays) { - throw new errors.BadRequestError(`Days cannot be more than not paid days which is ${maxPossibleDays}`) - } - if (maxPossibleDays <= 0) { - throw new errors.ConflictError(`There are no days to pay for WorkPeriod: ${correspondingWorkPeriod.id}`) - } - const workPeriodStartTime = moment(`${correspondingWorkPeriod.startDate}T00:00:00.000+12`) - if (workPeriodStartTime.isAfter(moment())) { - throw new errors.BadRequestError(`Cannot process payments for the future WorkPeriods. You can process after ${workPeriodStartTime.diff(moment(), 'hours')} hours`) - } - workPeriodPayment.days = _.defaultTo(workPeriodPayment.days, maxPossibleDays) - workPeriodPayment.amount = _.round(workPeriodPayment.memberRate * workPeriodPayment.days / 5, 2) + // TODO: we should allow `memberRate` to be `null` as it's not required for additional payments + workPeriodPayment.memberRate = _.defaultTo(correspondingResourceBooking.memberRate, 0) workPeriodPayment.customerRate = _.defaultTo(correspondingResourceBooking.customerRate, null) + + if (!_.has(workPeriodPayment, 'days') || workPeriodPayment.days > 0) { + if (_.isNil(correspondingResourceBooking.memberRate)) { + throw new errors.ConflictError(`Can't find a member rate in ResourceBooking: ${correspondingResourceBooking.id} to calculate the amount`) + } + if (correspondingResourceBooking.memberRate <= 0) { + throw new errors.ConflictError(`Can't process payment with member rate: ${correspondingResourceBooking.memberRate}. It must be higher than 0`) + } + + const maxPossibleDays = correspondingWorkPeriod.daysWorked - correspondingWorkPeriod.daysPaid + if (workPeriodPayment.days > maxPossibleDays) { + throw new errors.BadRequestError(`Days cannot be more than not paid days which is ${maxPossibleDays}`) + } + if (maxPossibleDays <= 0) { + throw new errors.ConflictError(`There are no days to pay for WorkPeriod: ${correspondingWorkPeriod.id}`) + } + const workPeriodStartTime = moment(`${correspondingWorkPeriod.startDate}T00:00:00.000+12`) + if (workPeriodStartTime.isAfter(moment())) { + throw new errors.BadRequestError(`Cannot process payments for the future WorkPeriods. You can process after ${workPeriodStartTime.diff(moment(), 'hours')} hours`) + } + workPeriodPayment.days = _.defaultTo(workPeriodPayment.days, maxPossibleDays) + workPeriodPayment.amount = _.round(workPeriodPayment.memberRate * workPeriodPayment.days / 5, 2) + } + workPeriodPayment.id = uuid.v4() workPeriodPayment.status = WorkPeriodPaymentStatus.SCHEDULED workPeriodPayment.createdBy = createdBy @@ -167,47 +200,72 @@ async function createWorkPeriodPayment (currentUser, workPeriodPayment) { _checkUserPermissionForCRUWorkPeriodPayment(currentUser) const createdBy = await helper.getUserId(currentUser.userId) - if (_.isArray(workPeriodPayment)) { - const result = [] - for (const wp of workPeriodPayment) { - try { - const successResult = await _createSingleWorkPeriodPayment(wp, createdBy) - result.push(successResult) - } catch (e) { - result.push(_.extend(_.pick(wp, 'workPeriodId'), { error: { message: e.message, code: e.httpStatus } })) - } - } - return result - } else { - return await _createSingleWorkPeriodPayment(workPeriodPayment, createdBy) - } + return await _createSingleWorkPeriodPayment(workPeriodPayment, createdBy) } const singleCreateWorkPeriodPaymentSchema = Joi.object().keys({ workPeriodId: Joi.string().uuid().required(), - days: Joi.number().integer().min(1).max(5) -}) + days: Joi.number().integer().min(0).max(10), + amount: Joi.when('days', { + is: Joi.number().integer().valid(0).exist(), + then: Joi.number().greater(0).required().messages({ + 'any.required': '"amount" has to be provided when processing additional payment for 0 days' + }), + otherwise: Joi.forbidden() + }) +}).required() + createWorkPeriodPayment.schema = Joi.object().keys({ currentUser: Joi.object().required(), - workPeriodPayment: Joi.alternatives().try( - singleCreateWorkPeriodPaymentSchema.required(), - Joi.array().min(1).items(singleCreateWorkPeriodPaymentSchema).required() - ).required() + workPeriodPayment: singleCreateWorkPeriodPaymentSchema +}) + +/** + * Create workPeriodPayments in bulk + * @param {Object} currentUser the user who perform this operation + * @param {Array} workPeriodPayments the workPeriodPayment to be created + * @returns {Array} the created workPeriodPayments + */ +async function createBulkOfWorkPeriodPayments (currentUser, workPeriodPayments) { + // check permission + _checkUserPermissionForCRUWorkPeriodPayment(currentUser) + const createdBy = await helper.getUserId(currentUser.userId) + + const result = [] + for (const wp of workPeriodPayments) { + try { + const successResult = await _createSingleWorkPeriodPayment(wp, createdBy) + result.push(successResult) + } catch (e) { + result.push(_.extend(_.pick(wp, 'workPeriodId'), { error: { message: e.message, code: e.httpStatus } })) + } + } + return result +} + +createBulkOfWorkPeriodPayments.schema = Joi.object().keys({ + currentUser: Joi.object().required(), + workPeriodPayments: Joi.array().min(1).items(singleCreateWorkPeriodPaymentSchema).required() }).required() /** * Update workPeriodPayment - * @param {Object} currentUser the user who perform this operation * @param {String} id the workPeriod id * @param {Object} data the data to be updated * @returns {Object} the updated workPeriodPayment */ -async function updateWorkPeriodPayment (currentUser, id, data) { - // check permission - _checkUserPermissionForCRUWorkPeriodPayment(currentUser) - +async function updateWorkPeriodPayment (id, data) { const workPeriodPayment = await WorkPeriodPayment.findById(id) + const oldValue = workPeriodPayment.toJSON() + + if (oldValue.status === 'in-progress') { + const keys = _.keys(_.pick(data, ['amount', 'days', 'memberRate', 'customerRate', 'billingAccountId'])) + if (keys.length) { + throw new errors.BadRequestError(`${JSON.stringify(keys)} cannot be updated when workPeriodPayment status is in-progress`) + } + } + if (data.status === 'cancelled' && oldValue.status === 'in-progress') { throw new errors.BadRequestError('You cannot cancel a WorkPeriodPayment which is in-progress') } @@ -222,7 +280,22 @@ async function updateWorkPeriodPayment (currentUser, id, data) { throw new errors.BadRequestError('There is no available daysWorked to schedule a payment') } } - data.updatedBy = await helper.getUserId(currentUser.userId) + + if (data.days) { + const correspondingWorkPeriod = await helper.ensureWorkPeriodById(workPeriodPayment.workPeriodId) // ensure work period exists + const maxPossibleDays = correspondingWorkPeriod.daysWorked - (correspondingWorkPeriod.daysPaid - + (_.includes(ActiveWorkPeriodPaymentStatuses, oldValue.status) ? oldValue.days : 0)) + if (data.days > maxPossibleDays) { + throw new errors.BadRequestError(`Cannot update days paid to more than ${maxPossibleDays}, otherwise total paid days (${correspondingWorkPeriod.daysPaid - + (_.includes(ActiveWorkPeriodPaymentStatuses, oldValue.status) ? oldValue.days : 0)}) would be more that total worked days (${correspondingWorkPeriod.daysWorked}) for the week.`) + } + } + + // challengeId exist and skip dummy challenge + if (oldValue.challengeId && oldValue.challengeId !== '00000000-0000-0000-0000-000000000000') { + await _updateChallenge(workPeriodPayment.challengeId, data) + } + const updated = await workPeriodPayment.update(data) await helper.postEvent(config.TAAS_WORK_PERIOD_PAYMENT_UPDATE_TOPIC, updated.toJSON(), { oldValue: oldValue, key: `workPeriodPayment.billingAccountId:${updated.billingAccountId}` }) return updated.dataValues @@ -236,15 +309,56 @@ async function updateWorkPeriodPayment (currentUser, id, data) { * @returns {Object} the updated workPeriodPayment */ async function partiallyUpdateWorkPeriodPayment (currentUser, id, data) { - return updateWorkPeriodPayment(currentUser, id, data) + // check permission + _checkUserPermissionForCRUWorkPeriodPayment(currentUser) + data.updatedBy = await helper.getUserId(currentUser.userId) + return updateWorkPeriodPayment(id, data) } +const updateWorkPeriodPaymentSchema = Joi.object().keys({ + status: Joi.workPeriodPaymentUpdateStatus(), + amount: Joi.number().greater(0), + days: Joi.number().integer().min(0).max(10), + memberRate: Joi.number().positive(), + customerRate: Joi.number().positive().allow(null), + billingAccountId: Joi.number().positive().integer() +}).min(1).required() + partiallyUpdateWorkPeriodPayment.schema = Joi.object().keys({ currentUser: Joi.object().required(), id: Joi.string().uuid().required(), - data: Joi.object().keys({ - status: Joi.workPeriodPaymentUpdateStatus() - }).min(1).required() + data: updateWorkPeriodPaymentSchema +}).required() + +/** + * Partially update workPeriodPayment in bulk + * @param {Object} currentUser the user who perform this operation + * @param {Array} workPeriodPayments the workPeriodPayments data to be updated + * @returns {Array} the updated workPeriodPayment + */ +async function updateBulkOfWorkPeriodPayments (currentUser, workPeriodPayments) { + // check permission + _checkUserPermissionForCRUWorkPeriodPayment(currentUser) + const updatedBy = await helper.getUserId(currentUser.userId) + const result = [] + for (const wpp of workPeriodPayments) { + try { + const successResult = await updateWorkPeriodPayment(wpp.id, _.assign(_.omit(wpp, 'id'), { updatedBy })) + result.push(successResult) + } catch (e) { + result.push(_.assign(wpp, { error: { message: e.message, code: e.httpStatus } })) + } + } + return result +} + +updateBulkOfWorkPeriodPayments.schema = Joi.object().keys({ + currentUser: Joi.object().required(), + workPeriodPayments: Joi.array().min(1).items( + updateWorkPeriodPaymentSchema.keys({ + id: Joi.string().uuid().required() + }).min(2).required() + ).required() }).required() /** @@ -456,7 +570,9 @@ createQueryWorkPeriodPayments.schema = Joi.object().keys({ module.exports = { getWorkPeriodPayment, createWorkPeriodPayment, + createBulkOfWorkPeriodPayments, createQueryWorkPeriodPayments, partiallyUpdateWorkPeriodPayment, + updateBulkOfWorkPeriodPayments, searchWorkPeriodPayments } diff --git a/src/services/WorkPeriodService.js b/src/services/WorkPeriodService.js index 8d018fb0..e48c6cb1 100644 --- a/src/services/WorkPeriodService.js +++ b/src/services/WorkPeriodService.js @@ -241,6 +241,7 @@ createWorkPeriod.schema = Joi.object().keys({ resourceBookingId: Joi.string().uuid().required(), startDate: Joi.workPeriodStartDate(), endDate: Joi.workPeriodEndDate(), + sentSurvey: Joi.boolean().default(false), daysWorked: Joi.number().integer().min(0).max(5).required(), daysPaid: Joi.number().default(0).forbidden(), paymentTotal: Joi.number().default(0).forbidden(), @@ -273,11 +274,10 @@ async function updateWorkPeriod (currentUser, id, data) { if (_.isNil(thisWeek)) { throw new errors.ConflictError('Work Period dates are not compatible with Resource Booking dates') } - if (thisWeek.daysWorked < data.daysWorked) { - throw new errors.BadRequestError(`Maximum allowed daysWorked is (${thisWeek.daysWorked})`) - } data.paymentStatus = helper.calculateWorkPeriodPaymentStatus(_.assign({}, oldValue, data)) - data.updatedBy = await helper.getUserId(currentUser.userId) + if (!currentUser.isMachine) { + data.updatedBy = await helper.getUserId(currentUser.userId) + } const updated = await workPeriod.update(data) const updatedDataWithoutPayments = _.omit(updated.toJSON(), ['payments']) const oldValueWithoutPayments = _.omit(oldValue, ['payments']) @@ -300,7 +300,12 @@ partiallyUpdateWorkPeriod.schema = Joi.object().keys({ currentUser: Joi.object().required(), id: Joi.string().uuid().required(), data: Joi.object().keys({ - daysWorked: Joi.number().integer().min(0).max(5) + daysWorked: Joi.number().integer().min(0).max(10), + sentSurvey: Joi.boolean(), + sentSurveyError: Joi.object().keys({ + errorCode: Joi.number().integer().min(0), + errorMessage: Joi.string() + }) }).required().min(1) }).required() @@ -499,6 +504,11 @@ searchWorkPeriods.schema = Joi.object().keys({ userHandle: Joi.string(), projectId: Joi.number().integer(), resourceBookingId: Joi.string().uuid(), + sentSurvey: Joi.boolean(), + sentSurveyError: Joi.object().keys({ + errorCode: Joi.number().integer().min(0), + errorMessage: Joi.string() + }), resourceBookingIds: Joi.alternatives( Joi.string(), Joi.array().items(Joi.string().uuid()) diff --git a/test/unit/WorkPeriodPaymentService.test.js b/test/unit/WorkPeriodPaymentService.test.js index 0b5a8aca..b860bd22 100644 --- a/test/unit/WorkPeriodPaymentService.test.js +++ b/test/unit/WorkPeriodPaymentService.test.js @@ -7,7 +7,7 @@ const commonData = require('./common/CommonData') const testData = require('./common/WorkPeriodPaymentData') const helper = require('../../src/common/helper') const busApiClient = helper.getBusApiClient() -describe('workPeriod service test', () => { +describe('workPeriodPayment service test', () => { beforeEach(() => { sinon.stub(busApiClient, 'postEvent').callsFake(async () => {}) })