diff --git a/README.md b/README.md index 14a5791..114b441 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ const SERVICE_PROVIDER = 'Gmail'; const RECIPIENT = 'mail1@gmail.com,mail2@gmail.com,mail3@gmail.com'; ``` -Finally, you can also alter the date range with which the application will fetch vaccination slots by customising **DATE_RANGE** value in [`src/configs/schedulerConfig.js`](https://github.com/sinhadotabhinav/covid-19-vaccine-alerts-cowin/blob/master/src/configs/schedulerConfig.js) file. By default it is set to **10** but, you can change it to 7 or 15 for example, based on your need. The config file also allows changes in the periodic schedule with which the application runs. By default, **SCHEDULE** value depicts a cron schedule **every 3 hours at minute 0**. To alter this schedule, you need to be familiar with the [cron scheduler](https://linuxhint.com/cron_jobs_complete_beginners_tutorial/#:~:text=The%20scheduled%20commands%20and%20scripts,Task%20Scheduler%20in%20Windows%20OS). I use [Crontab Guru](https://crontab.guru) website to test my cron schedules. +Finally, you can also alter the date range with which the application will fetch vaccination slots by customising **DATE_RANGE** value in [`src/configs/schedulerConfig.js`](https://github.com/sinhadotabhinav/covid-19-vaccine-alerts-cowin/blob/master/src/configs/schedulerConfig.js) file. By default it is set to **7** but, you can change it to 10 or 15 for example, based on your need. The config file also allows changes in the periodic schedule with which the application runs. By default, **SCHEDULE** value depicts a cron schedule **every 2 hours at minute 0**. To alter this schedule, you need to be familiar with the [cron scheduler](https://linuxhint.com/cron_jobs_complete_beginners_tutorial/#:~:text=The%20scheduled%20commands%20and%20scripts,Task%20Scheduler%20in%20Windows%20OS). I use [Crontab Guru](https://crontab.guru) website to test my cron schedules. ``` const SCHEDULE = '0 */3 * * *'; diff --git a/package.json b/package.json index 183296c..8ef4794 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "covid-19-vaccine-alerts-cowin", - "version": "1.0.0", + "version": "1.0.1", "description": "This is an alerting application that sends email notifications to beneficiaries in India using COWIN platform for vaccine availability", "main": "src/app.js", "scripts": { @@ -10,7 +10,7 @@ "license": "Self", "dependencies": { "axios": "^0.21.1", - "cron": "^1.8.2", + "cron-parser": "^3.5.0", "dotenv": "^8.2.0", "moment": "^2.29.1", "node-cron": "^3.0.0", diff --git a/src/api/routes.js b/src/api/routes.js index 3eab940..fa83bea 100644 --- a/src/api/routes.js +++ b/src/api/routes.js @@ -12,7 +12,7 @@ async function getStates() { if (result.status == apiConfig.STATUS_OK) { return result; } else { - throw 'Unable to get list of states, status code ' + result.status + ': ' + result.statusText; + throw 'Unable to get list of states, status code', result.status, ':', result.statusText; } }) } @@ -28,7 +28,7 @@ async function getDistricts(districtId) { if (result.status == apiConfig.STATUS_OK) { return result; } else { - throw 'Unable to get list of districts, status code ' + result.status + ': ' + result.statusText; + throw 'Unable to get list of districts, status code', result.status, ':', result.statusText; } }) } @@ -44,7 +44,7 @@ async function getVaccinationSlotsByPincode(pinCode, date) { if (result.status == apiConfig.STATUS_OK) { return result; } else { - throw 'Unable to get vaccine sessions by pincode, status code ' + result.status + ': ' + result.statusText; + throw 'Unable to get vaccine sessions by pincode, status code', result.status, ':', result.statusText; } }) } @@ -60,7 +60,7 @@ async function getVaccinationSlotsByDistrict(districtId, date) { if (result.status == apiConfig.STATUS_OK) { return result; } else { - throw 'Unable to get vaccine sessions by distict, status code ' + result.status + ': ' + result.statusText; + throw 'Unable to get vaccine sessions by distict, status code', result.status, ':', result.statusText; } }) } diff --git a/src/app.js b/src/app.js index 2b05076..171759f 100644 --- a/src/app.js +++ b/src/app.js @@ -1,25 +1,29 @@ require('dotenv').config() -const moment = require('moment'); const cron = require('node-cron'); -const axios = require('axios'); +const moment = require('moment'); +const parser = require('cron-parser'); +const routes = require('./api/routes') const appConfig = require('./configs/appConfig'); -const schedulerConfig = require('./configs/schedulerConfig'); -const alerts = require('./services/alerts'); -const routes = require('./api/routes'); -const locations = require('./utilities/locations'); +const mailConfig = require('./configs/mailConfig'); +const schedulerConfig = require('./configs/schedulerConfig');; +const alerts = require('./utilities/alerts'); const appointments = require('./utilities/appointments'); +const dailyDigest = require('./utilities/dailyDigest'); const htmlBuilder = require('./utilities/htmlBuilder'); +const locations = require('./utilities/locations'); +const logger = require('./utilities/logger'); +let runCounter = 0; +let firstRun = true; let vaccinationSlots = []; async function main() { try { - cron.schedule(schedulerConfig.SCHEDULE, async () => { + cron.schedule(schedulerConfig.SCHEDULE, async function() { await fetchVaccinationSlots(); }); - } catch (e) { - console.log('There was an error fetching vaccine slots: ' + JSON.stringify(e, null, 2)); - throw e; + } catch (error) { + console.log(logger.getLog('There was an error fetching vaccine slots: ' + JSON.stringify(error, null, 2))); } } @@ -32,26 +36,37 @@ async function fetchVaccinationSlots() { let pincodeArray = appConfig.PINCODE.split(','); for( counter = 0; counter < pincodeArray.length; counter++) { if (pincodeArray[counter].toString().length < 6) { - console.log('Invalid pincode ' + pincodeArray[counter] + ' provided in config file: (src/config/appConfig.js)'); + console.log(logger.getLog('Invalid pincode ' + pincodeArray[counter] + ' provided in config file: (src/config/appConfig.js).')); pincodeArray.splice(counter, 1); } } getAppointmentsByPincode(pincodeArray, dates); } else if (appConfig.PINCODE.toString().length < 6) { - throw 'Application is set to fetch vaccine slots by pincode but no pincode/ invalid pincode provided in config file: (src/config/appConfig.js)'; + throw 'Application is set to fetch vaccine slots by pincode but no pincode/ invalid pincode provided in config file: (src/config/appConfig.js).'; } else { pincodeArray.push(appConfig.PINCODE); getAppointmentsByPincode(pincodeArray, dates); } } else { if (appConfig.DISTRICT.length == 0) { - throw 'Application is set to fetch vaccine slots by district but no district name provided in config file: (src/config/appConfig.js)'; + throw 'Application is set to fetch vaccine slots by district but no district name provided in config file: (src/config/appConfig.js).'; } getAppointmentsByDistrict(dates); } - } catch (e) { - console.log(e); + } catch (error) { + console.log(logger.getLog(error)); + } +} + +async function getTwoWeekDateArray() { + let dateArray = []; + let currentDate = moment(); + for(let counter = 1; counter <= schedulerConfig.DATE_RANGE; counter++) { + let date = currentDate.format(schedulerConfig.DATE_FORMAT); + dateArray.push(date); + currentDate.add(1, 'day'); } + return dateArray; } async function getAppointmentsByPincode(pincodeArray, dates) { @@ -63,7 +78,8 @@ async function getAppointmentsByPincode(pincodeArray, dates) { return appointments.getFilteredSlots(date, result.data.sessions); }) .catch(function (error) { - console.log('Unable to get appointment slots at pincode: ' + pin + ' for the date: ' + date + ', ' + error.response.statusText); + console.log(logger.getLog('Unable to get appointment slots at pincode: ' + pin + ' for the date: ' + date + + ', ' + error.response.statusText)); }); slotsArray.push(slots); }; @@ -77,9 +93,9 @@ async function getAppointmentsByDistrict(dates) { let stateId = await locations.getStateId(appConfig.STATE); let districtId = await locations.getDistrictId(stateId, appConfig.DISTRICT); if (stateId == undefined) { - throw 'Unable to find state id. Please verify state name in config file: src/config/appConfig.js'; + throw 'Unable to find state id. Please verify state name in config file: src/config/appConfig.js.'; } else if (districtId == undefined) { - throw 'Unable to find district id. Please verify district name in config file: src/config/appConfig.js'; + throw 'Unable to find district id. Please verify district name in config file: src/config/appConfig.js.'; } for await (const date of dates) { let slots = await routes.getVaccinationSlotsByDistrict(districtId, date) @@ -87,53 +103,62 @@ async function getAppointmentsByDistrict(dates) { return appointments.getFilteredSlots(date, result.data.sessions); }) .catch(function (error) { - console.log('Unable to get appointment slots at district: ' + districtId + ' for the date: ' + date + ', ' + error.response.statusText); + console.log(logger.getLog('Unable to get appointment slots at district: ' + districtId + ' for the date: ' + date + + ', ' + error.response.statusText)); }); slotsArray.push(slots); }; sendEmailAlert(slotsArray); - } catch (e) { - console.log(e); - } -} - -async function getTwoWeekDateArray() { - let dateArray = []; - let currentDate = moment(); - for(let counter = 1; counter <= schedulerConfig.DATE_RANGE; counter++) { - let date = currentDate.format(schedulerConfig.DATE_FORMAT); - dateArray.push(date); - currentDate.add(1, 'day'); + } catch (error) { + console.log(logger.getLog(error)); } - return dateArray; } async function sendEmailAlert(slotsArray) { let outputArray = []; - if (slotsArray[0] == undefined) { - console.log('No sessions to process at this time'); - } else { - for(let counter1 = 0; counter1 < slotsArray.length; counter1++) { + for(let counter1 = 0; counter1 < slotsArray.length; counter1++) { + if (slotsArray[counter1] != undefined || slotsArray[counter1] != undefined && slotsArray[counter1].length > 0) { for(let counter2 = 0; counter2 < slotsArray[counter1].length; counter2++) { outputArray.push(slotsArray[counter1][counter2]); } } - if (Boolean(await appointments.compareVaccinationSlots(outputArray, vaccinationSlots))) { - console.log('No new sessions to process at this time'); - } else { - let htmlBody = await htmlBuilder.prepareHtmlBody(outputArray); - vaccinationSlots = outputArray; - alerts.sendEmailAlert(htmlBody, (err, result) => { - if(err) { - console.error({err}); + } + if (outputArray.length > 0 && !Boolean(await appointments.compareVaccinationSlots(outputArray, vaccinationSlots))) { + vaccinationSlots = outputArray; + alerts.sendEmailAlert(mailConfig.SUBJECT, await htmlBuilder.prepareHtmlBody(outputArray), (error, result) => { + if(error) { + console.log(logger.getLog(error)); + } else { + console.log(logger.getLog('New sessions have been processed and sent as an email alert to the recipient(s).')); + } + }); + } else { + console.log(logger.getLog('No new sessions to process at this time.')); + if (Boolean(firstRun)) { + alerts.sendEmailAlert(mailConfig.FIRST_EMAIL_SUBJECT, await htmlBuilder.prepareFirstEmail(), (error, result) => { + if(error) { + console.log(logger.getLog(error)); } else { - console.log('New sessions have been processed and sent as an email alert'); + console.log(logger.getLog('Welcome email alert has been sent to the recipient(s).')); } - }) + }); } } + firstRun = false; + runCounter = runCounter + 1; + await dailyDigest.updateRunCounter(runCounter, vaccinationSlots); + await resetDailyCounter(); +}; + +async function resetDailyCounter() { + const interval = parser.parseExpression(schedulerConfig.SCHEDULE); + let difference = moment(new Date(interval.next().toString())).diff(moment(), 'days', true); + if (difference.toFixed() > 0) { + await dailyDigest.prepareReport(); + runCounter = 0; + } }; main().then(() => { - console.log('The covid-19 vaccine alerting application has started!'); + console.log(logger.getLog('The covid-19 vaccination alerting application has started!')); }); diff --git a/src/configs/apiConfig.js b/src/configs/apiConfig.js index e677e81..21dec96 100644 --- a/src/configs/apiConfig.js +++ b/src/configs/apiConfig.js @@ -10,6 +10,7 @@ const STATES_URI = '/admin/location/states'; const DISTRICTS_URI = '/admin/location/districts/'; const APPOINTMENTS_PINCODE_URI = '/appointment/sessions/public/findByPin'; const APPOINTMENTS_DISTRICTS_URI = '/appointment/sessions/public/findByDistrict'; + let xApiKey = ''; if (typeof Buffer.from === "function") { xApiKey = Buffer.from(X_API_KEY, 'base64'); diff --git a/src/configs/appConfig.js b/src/configs/appConfig.js index c897808..68cf9a1 100644 --- a/src/configs/appConfig.js +++ b/src/configs/appConfig.js @@ -1,4 +1,4 @@ -const FINDBYPINCODE = true; +const FINDBYPINCODE = false; // location configs const PINCODE = '800001'; const STATE = 'BIHAR'; diff --git a/src/configs/mailConfig.js b/src/configs/mailConfig.js index bb93e90..80f4d30 100644 --- a/src/configs/mailConfig.js +++ b/src/configs/mailConfig.js @@ -2,6 +2,12 @@ const SERVICE_PROVIDER = 'Gmail'; const RECIPIENT = 'asinha093@gmail.com'; const SENDER = 'covid-19-vaccine-alerts-cowin'; const SUBJECT = 'New vaccination slots are available on COWIN. Book appointment now'; -const BODY = 'There are now new COVID-19 vaccination slots available in your requested location(s) in the next 10 days'; +const DAILY_DIGEST_SUBJECT = 'Daily digest: covid-19-vaccine-alerts-cowin'; +const FIRST_EMAIL_SUBJECT = 'Welcome to covid-19-vaccine-alerts-cowin application'; +const COWIN_URL = 'https://www.cowin.gov.in/home'; +const MOFHW_URL = 'https://www.mohfw.gov.in/covid_vaccination/vaccination/faqs.html'; +const COWIN_LOGO_URL = 'https://github.com/sinhadotabhinav/covid-19-vaccine-alerts-cowin/blob/master/src/assets/cowin-logo.png?raw=true'; +const WHO_VACCINE_LOGO_URL = 'https://github.com/sinhadotabhinav/covid-19-vaccine-alerts-cowin/blob/master/src/assets/vaccine.png?raw=true'; -module.exports = { SERVICE_PROVIDER, RECIPIENT, SENDER, SUBJECT, BODY }; +module.exports = { SERVICE_PROVIDER, RECIPIENT, SENDER, SUBJECT, DAILY_DIGEST_SUBJECT, FIRST_EMAIL_SUBJECT, COWIN_URL, MOFHW_URL, + COWIN_LOGO_URL, WHO_VACCINE_LOGO_URL }; diff --git a/src/configs/schedulerConfig.js b/src/configs/schedulerConfig.js index c33ce90..726a92a 100644 --- a/src/configs/schedulerConfig.js +++ b/src/configs/schedulerConfig.js @@ -1,5 +1,5 @@ -const SCHEDULE = '0 */3 * * *'; -const DATE_RANGE = 10; +const SCHEDULE = '0 */2 * * *'; +const DATE_RANGE = 7; const DATE_FORMAT = 'DD-MM-YYYY'; module.exports = { SCHEDULE, DATE_RANGE, DATE_FORMAT }; diff --git a/src/services/alerts.js b/src/utilities/alerts.js similarity index 85% rename from src/services/alerts.js rename to src/utilities/alerts.js index 1b4031f..9c4cfad 100644 --- a/src/services/alerts.js +++ b/src/utilities/alerts.js @@ -10,12 +10,12 @@ let mailerTransport = mailer.createTransport({ } }); -function sendEmailAlert (htmlBody, callback) { +function sendEmailAlert (subject, htmlBody, callback) { let options = { from: String(mailConfig.SENDER + `<${appConfig.EMAIL}>`), to: mailConfig.RECIPIENT, - subject: mailConfig.SUBJECT, - text: mailConfig.BODY, + subject: subject, + text: 'Email alert', html: htmlBody }; mailerTransport.sendMail(options, (error, info) => { diff --git a/src/utilities/appointments.js b/src/utilities/appointments.js index f17627c..46611c0 100644 --- a/src/utilities/appointments.js +++ b/src/utilities/appointments.js @@ -1,10 +1,10 @@ const appConfig = require('../configs/appConfig'); +const logger = require('../utilities/logger'); function getFilteredSlots (date, sessions) { let validSlots = sessions.filter(slot => slot.min_age_limit <= appConfig.AGE && slot.available_capacity > 0) if(validSlots.length > 0) { - console.log('Vaccination slots returned:'); - console.log({date:date, validSlots: validSlots.length}); + console.log(logger.getLog(`Vaccination slots returned for ${date}: ${validSlots.length}`)); } return validSlots; }; @@ -13,7 +13,7 @@ async function compareVaccinationSlots(outputArray, vaccinationSlots) { if (outputArray.length == vaccinationSlots.length) { let equalCount = 0; for(let counter = 0; counter < outputArray.length; counter++) { - if (JSON.stringify(outputArray[counter]) == JSON.stringify(vaccinationSlots[counter])) { + if (outputArray[counter].name == vaccinationSlots[counter].name) { equalCount = equalCount + 1; } } diff --git a/src/utilities/dailyDigest.js b/src/utilities/dailyDigest.js new file mode 100644 index 0000000..f512233 --- /dev/null +++ b/src/utilities/dailyDigest.js @@ -0,0 +1,28 @@ +const appConfig = require('../configs/appConfig'); +const mailConfig = require('../configs/mailConfig'); +const alerts = require('../utilities/alerts'); +const htmlBuilder = require('../utilities/htmlBuilder'); +const logger = require('../utilities/logger'); + +let runs = 0; +let slots = []; + +async function prepareReport () { + let regionType = Boolean(appConfig.FINDBYPINCODE) ? 'pincode' : 'district'; + let region = regionType == 'pincode' ? appConfig.PINCODE : appConfig.DISTRICT; + alerts.sendEmailAlert(mailConfig.DAILY_DIGEST_SUBJECT, + await htmlBuilder.prepareDailyDigestEmail(runs, slots.length, regionType, region), (error, result) => { + if(error) { + console.log(logger.getLog(error)); + } else { + console.log(logger.getLog('Daily digest email alert has been sent to the recipient(s).')); + } + }); +} + +async function updateRunCounter (runCounter, vaccinationSlots) { + runs = runCounter; + slots = vaccinationSlots; +} + +module.exports = { prepareReport, updateRunCounter }; diff --git a/src/utilities/htmlBuilder.js b/src/utilities/htmlBuilder.js index 155428e..5f94952 100644 --- a/src/utilities/htmlBuilder.js +++ b/src/utilities/htmlBuilder.js @@ -1,92 +1,148 @@ const mailConfig = require('../configs/mailConfig'); +const schedulerConfig = require('../configs/schedulerConfig'); async function prepareHtmlBody(outputArray) { let html = ` - - - - -
-

${mailConfig.BODY}. Login to COWIN portal to book your appointment now

-

Total number of slots: ${outputArray.length}. Find details below: \n<\p> - - - - - - - - - - - - - - \n`; - for (let counter = 0; counter < outputArray.length; counter++) { - let slots = outputArray[counter].slots.toString().replace(/,/g, '; '); - html = html + - ` - - - - - - - - - - - - \n`; - } + + + + +
+

There are now new COVID-19 vaccination slots available in your requested location(s) in the next ${schedulerConfig.DATE_RANGE} days. + Login to COWIN portal to book your appointment now

+

Total number of centers with slots available are: ${outputArray.length}. Find details below: \n<\p> +

#DateDistrictCenter NameCenter AddressPincodeVaccine NameAge LimitAvailabilityAvailable SlotsFee Type
${counter + 1}${outputArray[counter].date}${outputArray[counter].district_name}${outputArray[counter].name}${outputArray[counter].address}${outputArray[counter].pincode}${outputArray[counter].vaccine}${outputArray[counter].min_age_limit}${outputArray[counter].available_capacity}${slots}${outputArray[counter].fee_type}
+ + + + + + + + + + + + + \n`; + for (let counter = 0; counter < outputArray.length; counter++) { + let slots = outputArray[counter].slots.toString().replace(/,/g, '; '); html = html + - `
#DateDistrictCenter NameCenter AddressPincodeVaccine NameAge LimitAvailabilityAvailable SlotsFee Type
-

- \n -


-
-
- - COWIN platform - -
-
- - MOHFW India - + ` + ${counter + 1} + ${outputArray[counter].date} + ${outputArray[counter].district_name} + ${outputArray[counter].name} + ${outputArray[counter].address} + ${outputArray[counter].pincode} + ${outputArray[counter].vaccine} + ${outputArray[counter].min_age_limit} + ${outputArray[counter].available_capacity} + ${slots} + ${outputArray[counter].fee_type} + \n`; + } + html = html + ` +
-
\n`; + \n +

+ ${getHtmlFooter()}\n + `; return html; } -module.exports = { prepareHtmlBody }; +async function prepareFirstEmail() { + return ` + + + + +
+

There are no new COVID-19 vaccination slots available in your requested location(s) in the next ${schedulerConfig.DATE_RANGE} days.\n + The application will periodically fetch new slots if available and send email alerts.<\p> +

+ \n +

+ ${getHtmlFooter()}\n + `; +} + +async function prepareDailyDigestEmail(runs, centers, regionType, region) { + return ` + + + + +
+

Welcome to the ${mailConfig.SENDER} daily digest. A summary of results obtained today are gathered and presented below:<\p> +

The application periodically checked for new vaccination slots ${runs} times today.<\p> +

Total number of vaccination centers with available slots found for ${regionType} \(${region}\) were: ${centers}<\p> +

The application will continue to look for new slots in your requested region and send alerts tomorrow.<\p> +

+ \n +

+ ${getHtmlFooter()}\n + `; +} + +function getHtmlFooter() { + return ` +
+
+ + COWIN platform + +
+
+ + MOHFW India + +
+
`; +} + +function getImageStyles() { + return ` + img { + border: 1px solid #ddd; + border-radius: 4px; + padding: 5px; + width: 150px; + }`; +} + +function getTableStyles() { + return ` + table { + font-family: arial, sans-serif; + border-collapse: collapse; + width: 100%; + } + th { + background-color: #f1f1f1; + font-size: 9pt; + border: 1px solid #dddddd; + text-align: left; + padding: 8px; + } + td { + border: 1px solid #dddddd; + text-align: left; + font-size: 8pt; + padding: 8px; + }`; +} + +module.exports = { prepareHtmlBody, prepareFirstEmail, prepareDailyDigestEmail }; diff --git a/src/utilities/locations.js b/src/utilities/locations.js index d2fd504..3536ed3 100644 --- a/src/utilities/locations.js +++ b/src/utilities/locations.js @@ -1,5 +1,5 @@ -var stateJson = require('../models/states.json'); const routes = require('../api/routes'); +var stateJson = require('../models/states.json'); function getStateId (state) { for(var counter = 0; counter < stateJson['states'].length; counter++) { diff --git a/src/utilities/logger.js b/src/utilities/logger.js new file mode 100644 index 0000000..cb237bb --- /dev/null +++ b/src/utilities/logger.js @@ -0,0 +1,9 @@ +const moment = require('moment'); +const schedulerConfig = require('../configs/schedulerConfig'); + +function getLog(event) { + let timestamp = moment().format(schedulerConfig.DATE_FORMAT + ' HH:mm:ss').toString(); + return `${timestamp}, ${event}`; +}; + +module.exports = { getLog };