diff --git a/.env.sample b/.env.sample index 29ec50d..dc7d944 100644 --- a/.env.sample +++ b/.env.sample @@ -6,4 +6,5 @@ SERVER_PORT=3001 TWILIO_ACCOUNT_SID= TWILIO_AUTH_TOKEN= TWILIO_NUMBER= -TEST_RECEPIENT_NUMBER= \ No newline at end of file +TEST_RECEPIENT_NUMBER= +STRAPI_TOURISM_TOKEN= \ No newline at end of file diff --git a/.github/workflows/api_tests.yml b/.github/workflows/api_tests.yml index 6f41203..c57dad4 100644 --- a/.github/workflows/api_tests.yml +++ b/.github/workflows/api_tests.yml @@ -23,6 +23,7 @@ jobs: echo "TWILIO_AUTH_TOKEN=${{secrets.TWILIO_AUTH_TOKEN}}" >> .env echo "TWILIO_NUMBER=${{secrets.TWILIO_NUMBER}}" >> .env echo "TEST_RECEPIENT_NUMBER=${{secrets.TEST_RECEPIENT_NUMBER}}" >> .env + echo "STRAPI_TOURISM_TOKEN=${{secrets.STRAPI_TOURISM_TOKEN}}" >> .env - name: Set up Node.js uses: actions/setup-node@v2 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index c7db779..70bdba2 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -24,7 +24,7 @@ jobs: echo "TWILIO_AUTH_TOKEN=${{secrets.TWILIO_AUTH_TOKEN}}" >> .env echo "TWILIO_NUMBER=${{secrets.TWILIO_NUMBER}}" >> .env echo "TEST_RECEPIENT_NUMBER=${{secrets.TEST_RECEPIENT_NUMBER}}" >> .env - + echo "STRAPI_TOURISM_TOKEN=${{secrets.STRAPI_TOURISM_TOKEN}}" >> .env - name: Create SSH key file run: echo -e "${{ secrets.EC2_SSH_KEY }}" > ~/ec2_key env: diff --git a/.github/workflows/lint_checks.yml b/.github/workflows/lint_checks.yml index cdba9ff..212b69e 100644 --- a/.github/workflows/lint_checks.yml +++ b/.github/workflows/lint_checks.yml @@ -23,7 +23,7 @@ jobs: echo "TWILIO_AUTH_TOKEN=${{secrets.TWILIO_AUTH_TOKEN}}" >> .env echo "TWILIO_NUMBER=${{secrets.TWILIO_NUMBER}}" >> .env echo "TEST_RECEPIENT_NUMBER=${{secrets.TEST_RECEPIENT_NUMBER}}" >> .env - + echo "STRAPI_TOURISM_TOKEN=${{secrets.STRAPI_TOURISM_TOKEN}}" >> .env - name: Set up Node.js uses: actions/setup-node@v2 with: diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index adc4635..27f1df7 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -23,7 +23,7 @@ jobs: echo "TWILIO_AUTH_TOKEN=${{secrets.TWILIO_AUTH_TOKEN}}" >> .env echo "TWILIO_NUMBER=${{secrets.TWILIO_NUMBER}}" >> .env echo "TEST_RECEPIENT_NUMBER=${{secrets.TEST_RECEPIENT_NUMBER}}" >> .env - + echo "STRAPI_TOURISM_TOKEN=${{secrets.STRAPI_TOURISM_TOKEN}}" >> .env - name: Set up Node.js uses: actions/setup-node@v2 with: diff --git a/controllers/ControlCenter.js b/controllers/ControlCenter.js new file mode 100644 index 0000000..173f69d --- /dev/null +++ b/controllers/ControlCenter.js @@ -0,0 +1,97 @@ +import Actions from '../services/Actions.js' +import logger from '../utils/logger.js' +import { + ITEM_ID, + ITEM_NAME, + CAT_ATTR_TAG_RELATIONS, + NEW_CATALOG_AVAILABLE, + TRIGGER_BLIZZARD_MESSAGE, + CANCEL_BOOKING_MESSAGE, + TOURISM_STRAPI_URL +} from '../utils/constants.js' + +const action = new Actions() + +const TWILIO_RECEPIENT_NUMBER = process.env.TEST_RECEPIENT_NUMBER +export const cancelBooking = async (req, res) => { + try { + const { orderId } = req.body + if(!orderId){ + return res.status(400).json({message:"Order Id is Required", status:false}) + } + + const validOrderId = await action.call_api(`${TOURISM_STRAPI_URL}/orders/${orderId}`,'GET',{},{ Authorization: `Bearer ${process.env.STRAPI_TOURISM_TOKEN}`}) + logger.info(`OrderDetails: ${JSON.stringify(validOrderId)}`) + if(!validOrderId.status){ + return res.status(400).send({ message: `Invalid Order Id`, status:false }) + } + const messageBody = CANCEL_BOOKING_MESSAGE; + const getOrderAddressDetails = await action.call_api(`${TOURISM_STRAPI_URL}/order-addresses?order_id=${orderId}`,'GET',{},{ Authorization: `Bearer ${process.env.STRAPI_TOURISM_TOKEN}`}) + + const getOrderFulfillmentDetails = await action.call_api(`${TOURISM_STRAPI_URL}/order-fulfillments?order_id=${orderId}`,'GET',{},{ Authorization: `Bearer ${process.env.STRAPI_TOURISM_TOKEN}`}) + if (getOrderFulfillmentDetails.data.data.length) { + await action.call_api(`${TOURISM_STRAPI_URL}/order-fulfillments/${getOrderFulfillmentDetails.data.data[0].id}`,'PUT',{ + data: { + state_code: 'CANCELLED', + state_value: 'CANCELLED BY HOTEL', + }, + },{ Authorization: `Bearer ${process.env.STRAPI_TOURISM_TOKEN}`}) + let statusMessage = ""; + + if(getOrderAddressDetails.data.data[0].attributes.phone){ + statusMessage = (await action.send_message(`+91${getOrderAddressDetails.data.data[0].attributes.phone}`, messageBody)).deliveryStatus + } + else{ + statusMessage = (await action.send_message(TWILIO_RECEPIENT_NUMBER, messageBody)).deliveryStatus + } + return res.status(200).send({ message: `Notification ${statusMessage}`, status:true }) + } + + return res.status(200).send({ message: 'Cancel Booking Failed', status:false }) + } catch (error) { + logger.error(error.message) + return res.status(400).send({ message: error.message, status:false }) + } +} + +export const updateCatalog = async (req, res) => { + try { + const { userNo = TWILIO_RECEPIENT_NUMBER } = req.body; + const messageBody = NEW_CATALOG_AVAILABLE; + await action.call_api(`${TOURISM_STRAPI_URL}/items/${ITEM_ID}`,'PUT',{ + data: { + name: ITEM_NAME, + cat_attr_tag_relations: CAT_ATTR_TAG_RELATIONS, + }, + },{ Authorization: `Bearer ${process.env.STRAPI_TOURISM_TOKEN}`}) + const notifyResponse = await action.send_message(userNo, messageBody) + + if(!notifyResponse || notifyResponse.deliveryStatus === "failed"){ + throw new Error('Notification Failed') + } + return res.status(200).send({ message: 'Catalog Updated', status:true }) + } catch (error) { + logger.error(error.message) + return res.status(400).send({ message: error.message, status:false }) + } +} + + +export const notify = async (req, res) => { + try { + const { userNo = TWILIO_RECEPIENT_NUMBER } = req.body; + const messageBody = TRIGGER_BLIZZARD_MESSAGE; + const sendWhatsappNotificationResponse = await action.send_message( + userNo, + messageBody + ) + if(sendWhatsappNotificationResponse.deliveryStatus === "failed"){ + return res.status(400).json({...sendWhatsappNotificationResponse, status:false}) + } + sendWhatsappNotificationResponse.deliveryStatus = 'delivered' + return res.status(200).json({...sendWhatsappNotificationResponse, status:true}) + } catch (error) { + logger.error(error.message) + return res.status(400).send({ message: error.message, status:false }) + } +} \ No newline at end of file diff --git a/package.json b/package.json index 91c36c4..35b4aa5 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "body-parser": "^1.20.2", "chai": "^5.0.0", "config": "^3.3.11", + "cors": "^2.8.5", "dotenv": "^16.3.1", "express": "^4.18.2", "js-yaml": "^4.1.0", diff --git a/server.js b/server.js index b034754..e3f73a4 100644 --- a/server.js +++ b/server.js @@ -1,13 +1,18 @@ import dotenv from 'dotenv' +import cors from 'cors' dotenv.config() import express from 'express' import bodyParser from 'body-parser' import logger from './utils/logger.js' import messageController from './controllers/Bot.js' import DBService from './services/DBService.js' - +import { + cancelBooking, + updateCatalog, + notify +} from './controllers/ControlCenter.js' const app = express() - +app.use(cors()) // parse application/x-www-form-urlencoded app.use(bodyParser.urlencoded({ extended: false })) @@ -16,11 +21,13 @@ app.use(bodyParser.json()) // Define endpoints here // app.post('/act', actions.act) -app.post('/webhook', messageController.process_text) - +app.post('/webhook', messageController.process_wa_webhook) +app.post('/notify', notify) +app.post('/cancel-booking', cancelBooking) +app.post('/update-catalog', updateCatalog) // Reset all sessions -const db = new DBService(); -await db.clear_all_sessions(); +const db = new DBService() +await db.clear_all_sessions() // Start the Express server app.listen(process.env.SERVER_PORT, () => { diff --git a/services/Actions.js b/services/Actions.js index ea4a4fb..e7b06c2 100644 --- a/services/Actions.js +++ b/services/Actions.js @@ -33,7 +33,7 @@ class Actions { // optimise search results. // This code will ensure that for search resylts, only the responses with catalog providers are returned and out of them we only take the first resopnse to further reduce the token size. // This should be imlemented by different baps based on their requirements. - if(request.data.context.action==='search'){ + if(request.data.context && request.data.context.action==='search'){ response.data.responses = response.data.responses.filter(res => res.message && res.message.catalog && res.message.catalog.providers && res.message.catalog.providers.length > 0) if(response.data.responses.length > 0) response.data.responses = response.data.responses.slice(0, 1); @@ -106,14 +106,13 @@ class Actions { async send_message(recipient, message) { try { - - const response = await client.messages.create({ + const data = await client.messages.create({ body: message, from: `whatsapp:${twilioNumber}`, to: recipient.includes('whatsapp:') ? recipient : `whatsapp:${recipient}`, }) - logger.info(`Message sent: ${JSON.stringify(response)}`) - return true; + const status = await client.messages(data.sid).fetch() + return { deliveryStatus: status.status } } catch (error) { logger.error(`Error sending message: ${error.message}`) return false; diff --git a/tests/unit/controllers/controlCenter.test.js b/tests/unit/controllers/controlCenter.test.js new file mode 100644 index 0000000..e45c314 --- /dev/null +++ b/tests/unit/controllers/controlCenter.test.js @@ -0,0 +1,95 @@ +import { describe, it} from 'mocha' +import app from '../../../server.js' +import request from 'supertest' +import * as chai from 'chai' +const expect = chai.expect + + +describe('API tests for /notify endpoint for an end to end Notify Request', () => { + it('Should test unsuccess response for invalid whatsapp number.', async () => { + const response = await request(app).post('/notify').send({ + "userNo":"INVALID_NUMBER" + }) + expect(response.status).to.equal(400) + }) + + it('Should test success response for no whatsapp number provided in the payload and will sent to TEST_RECEPIENT_NUMBER', async () => { + const response = await request(app).post('/notify').send({}) + + expect(response.status).to.equal(200) + expect(response._body.status).to.equal(true) + expect(response._body.deliveryStatus).to.not.equal('failed') + }) + + it('Should test success response for valid whatsapp number', async () => { + const response = await request(app).post('/notify').send({ + "userNo":process.env.TEST_RECEPIENT_NUMBER + }) + expect(response.status).to.equal(200) + expect(response._body.status).to.equal(true) + expect(response._body.deliveryStatus).to.not.equal('failed') + }) + + +}) + + + +describe('API tests for /cancel-booking endpoint for an end to end Notify Message', () => { + it('Should test unsuccess response for invalid order Id.', async () => { + const response = await request(app).post('/cancel-booking').send({ + "orderId":"Abcd" + }) + expect(response.status).equal(400) + expect(response._body.status).equal(false) + expect(response._body.status).equal(false) + }) + + it('Should test unsuccess response for no order Id.', async () => { + const response = await request(app).post('/cancel-booking').send({}) + expect(response.status).equal(400) + expect(response._body.status).equal(false) + expect(response._body.status).equal(false) + }) + + + it('Should test success response for valid order Id.', async () => { + const response = await request(app).post('/cancel-booking').send({ + "orderId":"1" + }) + + expect(response.status).equal(200) + expect(response._body.status).equal(true) + expect(response._body.message).to.not.equal('Notification failed') + }) + + +}) + +describe('API tests for /update-catalog endpoint for an end to end Notify Message', () => { + it('Should test success response for invalid whatsapp No.', async () => { + const response = await request(app).post('/update-catalog').send({ + "userNo":"INVALID_NUMBER" + }) + + expect(response.status).equal(400) + expect(response._body.status).equal(false) + expect(response._body.message).equal('Notification Failed') + }) + + it('Should test success response for no whatsapp number provided in the payload and will sent to TEST_RECEPIENT_NUMBER', async () => { + const response = await request(app).post('/update-catalog').send({}) + expect(response.status).equal(200) + expect(response._body.status).equal(true) + expect(response._body.message).equal('Catalog Updated') + }) + + it('Should test success response for valid whatsapp number', async () => { + const response = await request(app).post('/update-catalog').send({ + "userNo":process.env.TEST_RECEPIENT_NUMBER + }) + expect(response.status).equal(200) + expect(response._body.status).equal(true) + expect(response._body.message).equal('Catalog Updated') + }) +}) \ No newline at end of file diff --git a/tests/unit/services/actions.test.js b/tests/unit/services/actions.test.js index 3a85463..45994c5 100644 --- a/tests/unit/services/actions.test.js +++ b/tests/unit/services/actions.test.js @@ -52,7 +52,8 @@ describe('should test send_message()', () => { const message = "hi, this is a test message"; let status = await actionsService.send_message(recipient, message); - expect(status).to.be.true; + + expect(status.deliveryStatus).to.not.equal('failed') }); it('should test send a message via Twilio with a whatsapp prefix', async () => { @@ -60,19 +61,17 @@ describe('should test send_message()', () => { const message = "hi, this is a test message"; let status = await actionsService.send_message(recipient, message); - expect(status).to.be.true; + expect(status.deliveryStatus).to.not.equal('failed') }); it('should throw an error for invalid recipient', async () => { const recipient = ''; const message = 'Test message'; - try { await actionsService.send_message(recipient, message); throw new Error('Expected an error to be thrown'); } catch (error) { - expect(error).to.be.an.instanceOf(Error); } }); @@ -80,12 +79,10 @@ describe('should test send_message()', () => { it('should throw an error for empty message', async () => { const recipient = process.env.TEST_RECEPIENT_NUMBER; const message = ''; - try { await actionsService.send_message(recipient, message); throw new Error('Expected an error to be thrown'); } catch (error) { - expect(error).to.be.an.instanceOf(Error); } }); diff --git a/tests/unit/services/ai.test.js b/tests/unit/services/ai.test.js index f55a51c..02e8efc 100644 --- a/tests/unit/services/ai.test.js +++ b/tests/unit/services/ai.test.js @@ -148,7 +148,7 @@ describe('Test cases for get_context_by_instruction()', async () => { expect(config.bap_url).to.equal(registry_config[0].bpp_subscriber_uri); }) - it('Should return right config for search action in retail contect', async () => { + it('Should return right config for search action in retail context', async () => { ai.action = {action: 'search'}; const config = await ai.get_context_by_instruction("I'm looking for some pet food");; expect(config).to.have.property('action') diff --git a/utils/constants.js b/utils/constants.js new file mode 100644 index 0000000..9d4f394 --- /dev/null +++ b/utils/constants.js @@ -0,0 +1,8 @@ +export const ITEM_ID = '4' +export const ITEM_NAME = 'Ticket Pass-Mueseum' + +export const CAT_ATTR_TAG_RELATIONS = [2, 3, 4, 5] +export const TRIGGER_BLIZZARD_MESSAGE = "Hey, Triggering a Blizzard"; +export const CANCEL_BOOKING_MESSAGE = `Dear Guest,\n\nApologies, but your hotel booking with us has been canceled due to unforeseen circumstances. \nWe understand the inconvenience and are here to assist you with any alternative arrangements needed. \n\nPlease contact us for further assistance.`; +export const NEW_CATALOG_AVAILABLE = `Dear Guest,\n\n Checkout this new place to visit.` +export const TOURISM_STRAPI_URL = "https://mit-bpp-tourism.becknprotocol.io/api"