├── frontend ├── .DS_Store ├── public │ ├── icon.png │ ├── .DS_Store │ ├── manifest.json │ └── index.html ├── src │ ├── TODO.txt │ ├── Components │ │ ├── MessageOptions │ │ │ ├── MessageOptions.js │ │ │ ├── LinkShortening │ │ │ │ └── LinkShortening.js │ │ │ └── MessageScheduler │ │ │ │ └── MessageScheduler.js │ │ ├── LogManager │ │ │ ├── TabPanel │ │ │ │ └── TabPanel.js │ │ │ ├── LogPanel │ │ │ │ └── LogPanel.js │ │ │ ├── ProgressBar │ │ │ │ └── ProgressBar.js │ │ │ ├── GraphPanel │ │ │ │ └── GraphPanel.js │ │ │ └── Logs.js │ │ ├── AccordionTemplate │ │ │ └── AccordionTemplate.js │ │ ├── ProTip │ │ │ └── ProTip.js │ │ ├── SenderBuilder │ │ │ ├── SenderTypeSelection │ │ │ │ └── SenderTypeSelection.js │ │ │ ├── SenderBuilder.js │ │ │ └── MessagingServiceSelection │ │ │ │ └── MessagingServiceSelection.js │ │ ├── Authentication │ │ │ ├── Authentication.js │ │ │ └── Login │ │ │ │ └── Login.js │ │ ├── ChannelSelection │ │ │ └── ChannelSelection.js │ │ ├── MessageBuilder │ │ │ ├── MessageTypeSelection │ │ │ │ └── MessageTypeSelection.js │ │ │ ├── ContentTemplateRenderer │ │ │ │ ├── TemplateVariablesInput.js │ │ │ │ └── ContentTemplateRenderer.js │ │ │ ├── MessageBuilder.js │ │ │ └── TemplateOptions │ │ │ │ └── TemplateOptions.js │ │ ├── NumberColumnSelection │ │ │ └── NumberColumnSelection.js │ │ ├── MainAppBar │ │ │ └── MainAppBar.js │ │ ├── MainBuilder │ │ │ └── MainBuilder.js │ │ ├── Settings │ │ │ └── Settings.js │ │ ├── ResultsExport │ │ │ └── ResultsExport.js │ │ ├── FileLoader │ │ │ └── FileLoader.js │ │ ├── CheckAndSend │ │ │ └── CheckAndSend.js │ │ └── CampaignTable │ │ │ └── CampaignTable.js │ ├── theme.js │ ├── index.js │ ├── Redux │ │ ├── slices │ │ │ ├── authSlice.js │ │ │ ├── csvDataSlice.js │ │ │ ├── actionSlice.js │ │ │ ├── settingsSlice.js │ │ │ └── messagingSlice.js │ │ ├── store.js │ │ ├── rootReducer.js │ │ └── localStorage.js │ ├── exponential-backoff.js │ ├── App.js │ └── Utils │ │ ├── variables.js │ │ └── functions.js ├── package.json └── .gitignore ├── setup-local-diff-port.sh ├── serverless ├── .env.example ├── functions │ ├── check-user-authenticated.js │ ├── remove-token.js │ ├── exponential-backoff.private.js │ ├── jwt.js │ ├── fetch-services.js │ ├── check-auth.private.js │ ├── get-message-status.js │ ├── fetch-templates.js │ ├── send.js │ ├── check-numbers.js │ ├── send-broadcast.js │ └── send-prepare.private.js ├── package.json └── .gitignore ├── test.csv ├── setup-local-same-port.sh ├── setup-remote.sh ├── LICENSE.md ├── .gitignore └── README.md /frontend/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/evanTheTerribleWarrior/Twilio-Outbound-Messaging/HEAD/frontend/.DS_Store -------------------------------------------------------------------------------- /frontend/public/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/evanTheTerribleWarrior/Twilio-Outbound-Messaging/HEAD/frontend/public/icon.png -------------------------------------------------------------------------------- /frontend/public/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/evanTheTerribleWarrior/Twilio-Outbound-Messaging/HEAD/frontend/public/.DS_Store -------------------------------------------------------------------------------- /setup-local-diff-port.sh: -------------------------------------------------------------------------------- 1 | # Run the frontend 2 | npm run --prefix frontend start & 3 | 4 | # Run the functions 5 | cd serverless && twilio serverless:start --port=3102 -------------------------------------------------------------------------------- /serverless/.env.example: -------------------------------------------------------------------------------- 1 | USERNAME= 2 | PASSWORD= 3 | JWT_SECRET= 4 | 5 | # Only needed if you deploy locally. Not needed if you deploy to your Twilio Account 6 | ACCOUNT_SID= 7 | AUTH_TOKEN= -------------------------------------------------------------------------------- /test.csv: -------------------------------------------------------------------------------- 1 | Name,Number,Age,Occupation,Link 2 | Jack Ripper,,20,IT,google.com 3 | Bob Ross,442072343111,20,IT,google.com 4 | Mickey Mouse,33109758351,20,IT,google.com 5 | The Rock,447123,20,IT,google.com -------------------------------------------------------------------------------- /frontend/src/TODO.txt: -------------------------------------------------------------------------------- 1 | Check and Send: 2 | - Handle error in promises 3 | - Put text under the Selected column if check or send are clicked and there is no column set 4 | - Add more template type rendering 5 | 6 | General: 7 | - Message Tagging? 8 | 9 | Future: 10 | - Add GBM? 11 | - Allow local files to be added as Media URL? 12 | - Bulk Lookup -------------------------------------------------------------------------------- /frontend/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Outbound Campaigns App", 3 | "name": "Outbound Campaigns App", 4 | "icons": [ 5 | { 6 | "src": "icon.png", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/png" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /frontend/src/Components/MessageOptions/MessageOptions.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import MessageScheduler from './MessageScheduler/MessageScheduler'; 3 | import LinkShortening from './LinkShortening/LinkShortening'; 4 | 5 | const MessageOptions = () => { 6 | 7 | return(<> 8 | 9 | 10 | ) 11 | } 12 | 13 | export default MessageOptions; -------------------------------------------------------------------------------- /setup-local-same-port.sh: -------------------------------------------------------------------------------- 1 | # Remove any existing frontend build and serverless deployed assets (if any) 2 | rm -r ./frontend/build && find "./serverless/assets" -mindepth 1 -delete 3 | 4 | # Create the new build 5 | npm run --prefix frontend build 6 | 7 | # Copy new build to serverless assets 8 | cp -a ./frontend/build/. ./serverless/assets/ 9 | 10 | # Deploy app and functions 11 | cd serverless && twilio serverless:start --port=3002 -------------------------------------------------------------------------------- /setup-remote.sh: -------------------------------------------------------------------------------- 1 | # Remove any existing frontend build and serverless deployed assets (if any) 2 | rm -r ./frontend/build && find "./serverless/assets" -mindepth 1 -delete 3 | 4 | # Create the new build 5 | npm run --prefix frontend build 6 | 7 | # Copy new build to serverless assets 8 | cp -a ./frontend/build/. ./serverless/assets/ 9 | 10 | # Deploy app and functions 11 | cd serverless && twilio serverless:deploy --override-existing-project -------------------------------------------------------------------------------- /frontend/src/theme.js: -------------------------------------------------------------------------------- 1 | import { red } from '@mui/material/colors'; 2 | import { createTheme } from '@mui/material/styles'; 3 | import { spacing } from '@mui/system'; 4 | 5 | // A custom theme for this app 6 | const theme = createTheme({ 7 | palette: { 8 | primary: { 9 | main: '#556cd6', 10 | }, 11 | secondary: { 12 | main: '#19857b', 13 | }, 14 | error: { 15 | main: red.A400, 16 | }, 17 | }, 18 | 19 | 20 | }); 21 | 22 | export default theme; 23 | -------------------------------------------------------------------------------- /serverless/functions/check-user-authenticated.js: -------------------------------------------------------------------------------- 1 | exports.handler = async function(context, event, callback) { 2 | const checkAuthPath = Runtime.getFunctions()['check-auth'].path; 3 | const checkAuth = require(checkAuthPath) 4 | let check = checkAuth.checkAuth(event.request.cookies, context.JWT_SECRET); 5 | if(!check.allowed)return callback(null,check.response); 6 | const response = check.response 7 | 8 | response.setBody({isAuthenticated: true}) 9 | return callback(null, response) 10 | } -------------------------------------------------------------------------------- /frontend/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import App from './App'; 4 | import { BrowserRouter, HashRouter } from "react-router-dom"; 5 | window.onbeforeunload = () => { return "" }; 6 | const root = ReactDOM.createRoot(document.getElementById('root')); 7 | 8 | root.render( 9 | 10 | 11 | 12 | 13 | ); 14 | /* 15 | ReactDOM.render( 16 | 17 | 18 | , 19 | document.getElementById('root') 20 | );*/ 21 | -------------------------------------------------------------------------------- /serverless/functions/remove-token.js: -------------------------------------------------------------------------------- 1 | exports.handler = async function(context, event, callback) { 2 | const checkAuthPath = Runtime.getFunctions()['check-auth'].path; 3 | const checkAuth = require(checkAuthPath) 4 | let check = checkAuth.checkAuth(event.request.cookies, context.JWT_SECRET); 5 | if(!check.allowed)return callback(null,check.response); 6 | const response = check.response 7 | 8 | response.removeCookie('outbound_messaging_jwt'); 9 | response.setBody({cookieRemoved: true}) 10 | return callback(null, response) 11 | } -------------------------------------------------------------------------------- /serverless/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "outbound-messaging-v2", 3 | "version": "2.0.0", 4 | "private": true, 5 | "scripts": { 6 | "test": "echo \"Error: no test specified\" && exit 1", 7 | "start": "twilio-run", 8 | "deploy": "twilio-run deploy" 9 | }, 10 | "dependencies": { 11 | "@twilio/runtime-handler": "1.2.2", 12 | "axios": "^0.27.2", 13 | "jsonwebtoken": "^8.5.1", 14 | "twilio": "^4.18.0" 15 | }, 16 | "devDependencies": { 17 | "twilio-run": "^3.4.1" 18 | }, 19 | "engines": { 20 | "node": "12" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /frontend/src/Redux/slices/authSlice.js: -------------------------------------------------------------------------------- 1 | import {createSlice} from '@reduxjs/toolkit' 2 | 3 | const initialState = { 4 | isAuthenticated: false 5 | }; 6 | 7 | const authSlice = createSlice({ 8 | name: 'auth', 9 | initialState, 10 | reducers: { 11 | logout: (state) => { 12 | state.isAuthenticated = false; 13 | }, 14 | setAuthenticated(state, action) { 15 | state.isAuthenticated = action.payload; 16 | } 17 | }, 18 | }); 19 | 20 | export const { logout, setAuthenticated } = authSlice.actions; 21 | export default authSlice.reducer; -------------------------------------------------------------------------------- /frontend/src/Components/LogManager/TabPanel/TabPanel.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Box } from '@mui/material'; 3 | 4 | const TabPanel = (props) => { 5 | const { children, value, index, ...other } = props; 6 | 7 | return ( 8 | 21 | ); 22 | } 23 | 24 | export default TabPanel; -------------------------------------------------------------------------------- /frontend/src/Redux/store.js: -------------------------------------------------------------------------------- 1 | import { configureStore } from '@reduxjs/toolkit'; 2 | import rootReducer from './rootReducer'; 3 | import throttle from 'lodash/throttle'; 4 | import { loadState, saveState } from './localStorage'; 5 | 6 | const persistedState = loadState(); 7 | 8 | const store = configureStore({ 9 | reducer: rootReducer, 10 | preloadedState: persistedState 11 | }); 12 | 13 | store.subscribe(throttle(() => { 14 | const state = store.getState(); 15 | saveState({ 16 | auth: { 17 | isAuthenticated: state.auth.isAuthenticated 18 | } 19 | }); 20 | }, 1000)); 21 | 22 | 23 | export default store; -------------------------------------------------------------------------------- /frontend/src/Redux/rootReducer.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | import authReducer from './slices/authSlice' 3 | import csvDataReducer from './slices/csvDataSlice'; 4 | import messagingReducer from './slices/messagingSlice' 5 | import settingsReducer from './slices/settingsSlice'; 6 | import actionReducer from './slices/actionSlice'; 7 | 8 | const rootReducer = combineReducers({ 9 | auth: authReducer, 10 | csvDataStructure: csvDataReducer, 11 | messagingStructure: messagingReducer, 12 | settingsStructure: settingsReducer, 13 | actionStructure: actionReducer 14 | }); 15 | 16 | export default rootReducer; 17 | -------------------------------------------------------------------------------- /frontend/src/Redux/localStorage.js: -------------------------------------------------------------------------------- 1 | export const loadState = () => { 2 | try { 3 | const serializedState = localStorage.getItem('outbound_messaging_app_state'); 4 | if (serializedState === null) { 5 | return undefined; 6 | } 7 | return JSON.parse(serializedState); 8 | } catch (err) { 9 | return undefined; 10 | } 11 | }; 12 | 13 | export const saveState = (state) => { 14 | try { 15 | const serializedState = JSON.stringify(state); 16 | localStorage.setItem('outbound_messaging_app_state', serializedState); 17 | } catch (err) { 18 | console.error("Could not save state", err); 19 | } 20 | }; 21 | -------------------------------------------------------------------------------- /serverless/functions/exponential-backoff.private.js: -------------------------------------------------------------------------------- 1 | const wait = (ms) => new Promise((res) => setTimeout(res, ms)); 2 | const limit = 10; 3 | const min = 10; 4 | const max = 1000; 5 | 6 | function randomNumber(min, max){ 7 | const r = Math.random()*(max-min) + min 8 | return Math.floor(r) 9 | } 10 | 11 | expbackoff = async (fn, depth = 0) => { 12 | try { 13 | return await fn(); 14 | }catch(e) { 15 | if(e.message.toLowerCase().indexOf("too many requests") !== -1){ 16 | if (depth > limit) { 17 | throw e; 18 | } 19 | 20 | let number = randomNumber(min, max) 21 | await wait((2 ** depth) + number); 22 | 23 | return expbackoff(fn, depth + 1); 24 | }else{ 25 | throw e; 26 | } 27 | 28 | } 29 | } 30 | 31 | module.exports = {expbackoff}; -------------------------------------------------------------------------------- /frontend/src/Components/AccordionTemplate/AccordionTemplate.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Accordion, AccordionSummary, AccordionDetails, Typography } from '@mui/material'; 3 | import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; 4 | 5 | const AccordionTemplate = ({ title, children }) => { 6 | return ( 7 | 8 | } aria-controls="panel-content" id="panel-header"> 9 | 10 | {title} 11 | 12 | 13 | 14 | {children} 15 | 16 | 17 | ); 18 | } 19 | 20 | export default AccordionTemplate; -------------------------------------------------------------------------------- /frontend/src/Components/ProTip/ProTip.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import SvgIcon from '@mui/material/SvgIcon'; 3 | import Typography from '@mui/material/Typography'; 4 | 5 | function LightBulbIcon(props) { 6 | return ( 7 | 8 | 9 | 10 | ); 11 | } 12 | 13 | export default function ProTip(props) { 14 | return ( 15 |
16 | 17 | 18 | {props.message} 19 | 20 |
21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /frontend/src/exponential-backoff.js: -------------------------------------------------------------------------------- 1 | const wait = (ms) => new Promise((res) => setTimeout(res, ms)); 2 | 3 | function randomNumber(min, max){ 4 | const r = Math.random()*(max-min) + min 5 | return Math.floor(r) 6 | } 7 | 8 | export const expbackoff = async (fn, depth = 0) => { 9 | try { 10 | return await fn(); 11 | }catch(e) { 12 | if(e.message.toLowerCase().indexOf("too many requests") !== -1){ 13 | console.log("Got error: " + e.message) 14 | if (depth > 7) { 15 | throw e; 16 | } 17 | 18 | let number = randomNumber(1, 1000) 19 | await wait((2 ** depth) + number); 20 | 21 | return expbackoff(fn, depth + 1); 22 | }else{ 23 | console.log("Got error: " + e.message) 24 | if (depth > 3) { 25 | throw e; 26 | } 27 | 28 | let number = randomNumber(1, 1000) 29 | await wait((2 ** depth) + number); 30 | 31 | return expbackoff(fn, depth + 1); 32 | } 33 | 34 | } 35 | } -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022 Twilio Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /frontend/src/Components/LogManager/LogPanel/LogPanel.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Box from '@mui/material/Box' 3 | import Button from '@mui/material/Button'; 4 | import TextareaAutosize from '@mui/material/TextareaAutosize'; 5 | 6 | const LogPanel = (props) => { 7 | 8 | const { logs } = props; 9 | 10 | const clearLogs = () => { 11 | props.clearLogs() 12 | } 13 | 14 | return ( 15 |
20 | 21 | 28 | 29 | 30 | 31 | 32 |
33 | ); 34 | } 35 | 36 | export default LogPanel; -------------------------------------------------------------------------------- /frontend/src/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Container } from '@mui/material'; 3 | import { Routes, Route, Navigate } from 'react-router-dom'; 4 | import { Provider } from 'react-redux'; 5 | import store from './Redux/store'; 6 | import MainAppBar from './Components/MainAppBar/MainAppBar'; 7 | import Login from './Components/Authentication/Login/Login'; 8 | import Auth from './Components/Authentication/Authentication'; 9 | import MainBuilder from './Components/MainBuilder/MainBuilder'; 10 | 11 | const App = () => { 12 | 13 | return( 14 | 15 | 16 | 18 | 19 | } /> 20 | } /> 21 | } /> 22 | } /> 23 | 24 | 25 | 26 | ); 27 | }; 28 | 29 | export default App; -------------------------------------------------------------------------------- /serverless/functions/jwt.js: -------------------------------------------------------------------------------- 1 | const jwt = require('jsonwebtoken'); 2 | 3 | exports.handler = (context, event, callback) => { 4 | 5 | console.log(JSON.stringify(event)) 6 | 7 | const { username, password } = event; 8 | 9 | const response = new Twilio.Response(); 10 | response.appendHeader('Access-Control-Allow-Methods', 'POST, OPTIONS'); 11 | response.appendHeader('Access-Control-Allow-Headers', 'Content-Type'); 12 | response.appendHeader('Content-Type', 'application/json'); 13 | 14 | if (username !== context.USERNAME || password !== context.PASSWORD) { 15 | response 16 | .setBody({success: false, message: 'Invalid Credentials'}) 17 | .setStatusCode(401); 18 | 19 | return callback(null, response); 20 | } 21 | 22 | const token = jwt.sign( 23 | { 24 | sub: 'OutboundMessagingApp', 25 | iss: 'twil.io', 26 | org: 'twilio', 27 | perms: ['read'], 28 | }, 29 | context.JWT_SECRET, 30 | { expiresIn: '5h' } 31 | ) 32 | 33 | response.setCookie('outbound_messaging_jwt', token, ['HttpOnly', 'Max-Age=86400', 'Path=/', 'SameSite=strict']) 34 | response.setBody({success: true}); 35 | console.log(response) 36 | return callback(null, response); 37 | }; -------------------------------------------------------------------------------- /frontend/src/Components/SenderBuilder/SenderTypeSelection/SenderTypeSelection.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useSelector, useDispatch } from 'react-redux'; 3 | import { Box, Radio, RadioGroup, FormControlLabel } from '@mui/material'; 4 | import { MESSAGING_TYPES } from '../../../Utils/variables'; 5 | import { updateMessagingState } from '../../../Redux/slices/messagingSlice'; 6 | 7 | const SenderTypeSelection = () => { 8 | 9 | const senderTypeSelection = useSelector((state) => state.messagingStructure.senderTypeSelection) 10 | const dispatch = useDispatch() 11 | 12 | const onRadioChange = (event) => { 13 | dispatch(updateMessagingState({ 14 | type: MESSAGING_TYPES.SENDER_TYPE_SELECTION, 15 | value: event.target.value 16 | })) 17 | } 18 | 19 | return ( 20 | 21 | 22 | } label="Single" style={{ marginRight: '50px' }} /> 23 | } label="Messaging Service" /> 24 | 25 | 26 | ) 27 | 28 | } 29 | 30 | export default SenderTypeSelection; -------------------------------------------------------------------------------- /frontend/src/Components/Authentication/Authentication.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { useSelector, useDispatch } from 'react-redux'; 3 | import { Navigate } from "react-router-dom"; 4 | import { checkAuthentication } from '../../Utils/functions'; 5 | import { setAuthenticated, logout } from '../../Redux/slices/authSlice'; 6 | 7 | 8 | const Auth = ({ children }) => { 9 | const dispatch = useDispatch(); 10 | const currentIsAuthenticated = useSelector((state) => state.auth.isAuthenticated) 11 | const [isAuthenticated, setIsAuthenticated] = useState(currentIsAuthenticated) 12 | 13 | useEffect(() => { 14 | async function checkAuth() { 15 | const checkAuthenticated = await checkAuthentication(); 16 | console.log(checkAuthenticated) 17 | if (checkAuthenticated.isAuthenticated) dispatch(setAuthenticated(true)); 18 | else dispatch(logout()); 19 | } 20 | checkAuth() 21 | },[dispatch]) 22 | 23 | 24 | 25 | return ( 26 | <> 27 | { 28 | currentIsAuthenticated ? ( 29 |
{ children }
30 | ) : 31 | ( 32 | 33 | ) 34 | } 35 | 36 | ); 37 | }; 38 | 39 | export default Auth; -------------------------------------------------------------------------------- /serverless/functions/fetch-services.js: -------------------------------------------------------------------------------- 1 | exports.handler = async function(context, event, callback) { 2 | 3 | const response = new Twilio.Response(); 4 | response.appendHeader('Content-Type', 'application/json'); 5 | 6 | const checkAuthPath = Runtime.getFunctions()['check-auth'].path; 7 | const checkAuth = require(checkAuthPath) 8 | let check = checkAuth.checkAuth(event.request.cookies, context.JWT_SECRET); 9 | if(!check.allowed)return callback(null,check.response); 10 | 11 | try { 12 | const twilioClient = context.getTwilioClient(); 13 | let services_array = []; 14 | let services = await twilioClient.messaging.v1.services.list() 15 | services.forEach(s => { 16 | 17 | services_array.push( 18 | { 19 | 'name': s.friendlyName + " - MG..." + s.sid.slice(-4), 20 | 'sid': s.sid 21 | } 22 | ) 23 | }); 24 | response.setBody({ 25 | status: true, 26 | message: "Getting Services done", 27 | data: { 28 | services_array: services_array 29 | }, 30 | }) 31 | callback(null, response); 32 | } catch (err) { 33 | console.log("error:" + err); 34 | response.setStatusCode(500); 35 | response.setBody(err); 36 | callback(null, response); 37 | } 38 | 39 | } -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "create-react-app", 3 | "version": "5.0.0", 4 | "private": true, 5 | "scripts": { 6 | "start": "react-scripts start", 7 | "start-https": "sudo PORT=443 HTTPS=true react-scripts start", 8 | "build": "react-scripts build", 9 | "test": "react-scripts test", 10 | "eject": "react-scripts eject" 11 | }, 12 | "dependencies": { 13 | "@emotion/react": "latest", 14 | "@emotion/styled": "latest", 15 | "@mui/icons-material": "^5.6.1", 16 | "@mui/material": "latest", 17 | "@mui/x-data-grid": "^5.17.26", 18 | "@reduxjs/toolkit": "^1.9.7", 19 | "@webscopeio/react-textarea-autocomplete": "^4.9.1", 20 | "date-fns": "^2.30.0", 21 | "react": "^18.2.0", 22 | "react-dom": "^18.2.0", 23 | "react-json-to-table": "^0.1.7", 24 | "react-papaparse": "^4.0.2", 25 | "react-redux": "^8.1.3", 26 | "react-router-dom": "^6.20.0", 27 | "react-scripts": "^5.0.1", 28 | "recharts": "^2.1.9", 29 | "ts-react-json-table": "^0.1.2", 30 | "uuid": "^8.3.2" 31 | }, 32 | "browserslist": { 33 | "production": [ 34 | ">0.2%", 35 | "not dead", 36 | "not op_mini all" 37 | ], 38 | "development": [ 39 | "last 1 chrome version", 40 | "last 1 firefox version", 41 | "last 1 safari version" 42 | ] 43 | }, 44 | "proxy": "http://localhost:3102/" 45 | } 46 | -------------------------------------------------------------------------------- /frontend/src/Components/ChannelSelection/ChannelSelection.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useSelector, useDispatch } from 'react-redux'; 3 | import RadioGroup from '@mui/material/RadioGroup'; 4 | import Radio from '@mui/material/Radio'; 5 | import FormControlLabel from '@mui/material/FormControlLabel'; 6 | import { MESSAGING_TYPES } from '../../Utils/variables'; 7 | import { updateMessagingState } from '../../Redux/slices/messagingSlice'; 8 | 9 | const ChannelSelection = () => { 10 | 11 | const channelSelection = useSelector((state) => state.messagingStructure.channelSelection) 12 | const dispatch = useDispatch() 13 | 14 | const onRadioChange = (event) => { 15 | dispatch(updateMessagingState({ 16 | type: MESSAGING_TYPES.CHANNEL_SELECTION, 17 | value: event.target.value 18 | })) 19 | } 20 | 21 | return ( 22 | 23 | <> 24 | 25 | } label="SMS" style={{ marginRight: '50px' }} /> 26 | } label="Whatsapp" style={{ marginRight: '50px' }} /> 27 | } label="Facebook Messenger" /> 28 | 29 | 30 | 31 | ) 32 | 33 | } 34 | 35 | export default ChannelSelection -------------------------------------------------------------------------------- /serverless/functions/check-auth.private.js: -------------------------------------------------------------------------------- 1 | /* Here we set the Twilio response headers - which can be expanded/changed. 2 | Also we check if the Auth Header is valid and return appropriate response (401 if not) */ 3 | 4 | const jwt = require('jsonwebtoken'); 5 | exports.checkAuth = (cookies, secret) => { 6 | if (!cookies.outbound_messaging_jwt) return setResponse(false, setTwilioResponseHeaders()) 7 | 8 | try{ 9 | jwt.verify(cookies.outbound_messaging_jwt, secret); 10 | } catch (error) { 11 | return setResponse(false, setTwilioResponseHeaders()) 12 | } 13 | 14 | return setResponse(true, setTwilioResponseHeaders()) 15 | }; 16 | 17 | const setResponse = (allowed, response) => { 18 | 19 | if(!allowed) { 20 | response.setBody('Unauthorized').setStatusCode(401) 21 | .appendHeader( 22 | 'WWW-Authenticate', 23 | 'Bearer realm="Access to the app"' 24 | ) 25 | } 26 | return ( { 27 | allowed: allowed, 28 | response: response 29 | }) 30 | 31 | } 32 | 33 | const setTwilioResponseHeaders = () => { 34 | const response = new Twilio.Response(); 35 | response.appendHeader('Access-Control-Allow-Methods', 'POST'); 36 | response.appendHeader('Access-Control-Allow-Headers', 'Content-Type'); 37 | response.appendHeader('Content-Type', 'application/json'); 38 | response.appendHeader('Access-Control-Allow-Credentials', true) 39 | return response 40 | } -------------------------------------------------------------------------------- /frontend/src/Components/MessageBuilder/MessageTypeSelection/MessageTypeSelection.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useSelector, useDispatch } from 'react-redux'; 3 | import { Box, Radio, RadioGroup, FormControlLabel } from '@mui/material'; 4 | import { MESSAGING_TYPES } from '../../../Utils/variables'; 5 | import { updateMessagingState } from '../../../Redux/slices/messagingSlice'; 6 | 7 | const MessageTypeSelection = () => { 8 | 9 | const messageTypeSelection = useSelector((state) => state.messagingStructure.messageTypeSelection) 10 | const dispatch = useDispatch() 11 | 12 | const onRadioChange = (event) => { 13 | if (event.target.value === "Custom"){ 14 | dispatch(updateMessagingState({ 15 | type: MESSAGING_TYPES.SELECTED_TEMPLATE, 16 | value: null 17 | })) 18 | } 19 | dispatch(updateMessagingState({ 20 | type: MESSAGING_TYPES.MESSAGE_TYPE_SELECTION, 21 | value: event.target.value 22 | })) 23 | } 24 | 25 | return ( 26 | 27 | 28 | } label="Custom" style={{ marginRight: '50px' }} /> 29 | } label="Template" /> 30 | 31 | 32 | ) 33 | 34 | } 35 | 36 | export default MessageTypeSelection; -------------------------------------------------------------------------------- /frontend/src/Components/LogManager/ProgressBar/ProgressBar.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useCallback } from 'react'; 2 | import { useSelector } from 'react-redux'; 3 | import { LinearProgress, Box, Typography } from '@mui/material'; 4 | import throttle from 'lodash/throttle'; 5 | 6 | const ProgressBar = () => { 7 | 8 | const csvDataLength = useSelector((state) => state.csvDataStructure.csvData.length); 9 | const progressBarCount = useSelector((state) => state.actionStructure.progressBarCount); 10 | const [completionPercentage, setCompletionPercentage] = useState(0); 11 | 12 | const throttledSetCompletionPercentage = useCallback(throttle((percentage) => { 13 | setCompletionPercentage(percentage); 14 | }, 100), []); 15 | 16 | useEffect(() => { 17 | const newPercentage = csvDataLength > 0 18 | ? (progressBarCount / csvDataLength) * 100 19 | : 0; 20 | throttledSetCompletionPercentage(newPercentage); 21 | }, [progressBarCount, csvDataLength, throttledSetCompletionPercentage]); 22 | 23 | return ( 24 | 25 | 26 | 27 | 28 | 29 | {`${Math.round(completionPercentage)}%`} 30 | 31 | 32 | ); 33 | } 34 | 35 | export default ProgressBar; 36 | -------------------------------------------------------------------------------- /frontend/src/Components/MessageOptions/LinkShortening/LinkShortening.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { Box, FormControlLabel, Switch, Alert, IconButton } from '@mui/material'; 3 | import HelpOutlineIcon from '@mui/icons-material/HelpOutline'; 4 | import { useSelector, useDispatch } from 'react-redux'; 5 | import { updateSettingsState } from '../../../Redux/slices/settingsSlice'; 6 | import { SETTINGS_TYPES } from '../../../Utils/variables'; 7 | 8 | const LinkShortening = () => { 9 | 10 | const [linkShorteningAlert, setLinkShorteningAlert] = useState(false); 11 | const checkLinkShortening = useSelector(state => state.settingsStructure.checkLinkShortening) 12 | const dispatch = useDispatch() 13 | 14 | const handleLinkShorteningSwitchChange = (e) => { 15 | dispatch(updateSettingsState({ 16 | type: SETTINGS_TYPES.LINK_SHORTENING, 17 | value: e.target.checked 18 | })) 19 | } 20 | 21 | return ( 22 | 23 | } label="Link Shortening" /> 24 | setLinkShorteningAlert(linkShorteningAlert ? false : true)}> 25 | 26 | 27 | { linkShorteningAlert && 28 | ( 29 | If you enable this, any long URLs in the message will be shortened using your Twilio-registered shortened domain. Learn more here 30 | ) 31 | } 32 | 33 | ); 34 | }; 35 | 36 | export default LinkShortening; 37 | -------------------------------------------------------------------------------- /frontend/src/Components/NumberColumnSelection/NumberColumnSelection.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useSelector, useDispatch } from 'react-redux'; 3 | import FormControl from '@mui/material/FormControl'; 4 | import Select from '@mui/material/Select'; 5 | import MenuItem from "@mui/material/MenuItem"; 6 | import InputLabel from "@mui/material/InputLabel"; 7 | import Box from "@mui/material/Box"; 8 | 9 | import { updateCSVState } from '../../Redux/slices/csvDataSlice'; 10 | import { CSVDATA_TYPES } from '../../Utils/variables'; 11 | 12 | const NumberColumnSelection = () => { 13 | 14 | const columnFields = useSelector(state => state.csvDataStructure.csvColumnFields) 15 | const selectedColumn = useSelector(state => state.csvDataStructure.csvSelectedColumn) 16 | const dispatch = useDispatch() 17 | 18 | function handleSelectColumn(event){ 19 | dispatch(updateCSVState({ 20 | type: CSVDATA_TYPES.CSV_SELECTED_COLUMN, 21 | value: event.target.value 22 | })) 23 | } 24 | 25 | return ( 26 | 27 | 28 | Select Phone Numbers Column 29 | 37 | 38 | 39 | ) 40 | 41 | } 42 | 43 | 44 | export default NumberColumnSelection; -------------------------------------------------------------------------------- /frontend/src/Components/SenderBuilder/SenderBuilder.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { FormControl, TextField } from '@mui/material'; 3 | import SenderTypeSelection from './SenderTypeSelection/SenderTypeSelection'; 4 | import MessagingServiceSelection from './MessagingServiceSelection/MessagingServiceSelection'; 5 | import { useSelector, useDispatch } from 'react-redux'; 6 | import { MESSAGING_TYPES } from '../../Utils/variables'; 7 | import { updateMessagingState } from '../../Redux/slices/messagingSlice'; 8 | const SenderBuilder = () => { 9 | 10 | const dispatch = useDispatch() 11 | const senderTypeSelection = useSelector(state => state.messagingStructure.senderTypeSelection) 12 | const sender = useSelector(state => state.messagingStructure.selectedSingleSender) 13 | 14 | const handleSetSender = (event) => { 15 | dispatch(updateMessagingState({ 16 | type: MESSAGING_TYPES.SELECTED_SINGLE_SENDER, 17 | value: event.target.value 18 | })) 19 | } 20 | return ( 21 | <> 22 | 23 | 24 | { 25 | senderTypeSelection === "Single" && ( 26 | 34 | ) 35 | } 36 | { 37 | senderTypeSelection === "Messaging Service" && 38 | } 39 | 40 | 41 | ) 42 | 43 | } 44 | 45 | export default SenderBuilder; -------------------------------------------------------------------------------- /frontend/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 22 | Outbound Campaigns App 23 | 24 | 25 | 26 | 27 | 28 |
29 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /frontend/src/Redux/slices/csvDataSlice.js: -------------------------------------------------------------------------------- 1 | import {createSlice} from '@reduxjs/toolkit' 2 | import { CSVDATA_TYPES, COMMON } from '../../Utils/variables'; 3 | 4 | const initialState = { 5 | csvData: [], 6 | csvColumnFields: [], 7 | csvSelectedColumn: "" 8 | }; 9 | 10 | const csvDataSlice = createSlice({ 11 | name: 'csvDataStructure', 12 | initialState, 13 | reducers: { 14 | updateCSVState: (state, action) => { 15 | switch(action.payload.type){ 16 | 17 | case CSVDATA_TYPES.ALL_CSV_DATA: 18 | state.csvData = action.payload.value; 19 | break; 20 | case CSVDATA_TYPES.UPDATE_DATA_CHUNK: 21 | state.csvData = state.csvData.concat(action.payload.value); 22 | break; 23 | case CSVDATA_TYPES.CSV_COLUMN_FIELDS: 24 | state.csvColumnFields = action.payload.value 25 | break; 26 | case CSVDATA_TYPES.CSV_SELECTED_COLUMN: 27 | state.csvSelectedColumn = action.payload.value 28 | break; 29 | case CSVDATA_TYPES.ADD_ROW: 30 | state.csvData.unshift(action.payload.value) 31 | break; 32 | case CSVDATA_TYPES.DELETE_ROW: 33 | state.csvData = state.csvData.filter((row, j) => j !== action.payload.value) 34 | break; 35 | case CSVDATA_TYPES.UPDATE_ROW: 36 | const index = state.csvData.findIndex(row => row.UniqueID === action.payload.value.UniqueID); 37 | console.log(index) 38 | if (index !== -1) { 39 | state.csvData[index] = { ...state.csvData[index], ...action.payload.value }; 40 | } 41 | break; 42 | case COMMON.RESET_STATE: 43 | return initialState; 44 | } 45 | } 46 | 47 | } 48 | }); 49 | 50 | export const { updateCSVState} = csvDataSlice.actions; 51 | export default csvDataSlice.reducer; -------------------------------------------------------------------------------- /frontend/src/Components/MainAppBar/MainAppBar.js: -------------------------------------------------------------------------------- 1 | import React, {useState} from 'react'; 2 | import { useDispatch, useSelector } from 'react-redux'; 3 | import { useNavigate } from 'react-router-dom'; 4 | import { logout } from '../../Redux/slices/authSlice'; 5 | import { removeJWT } from '../../Utils/functions'; 6 | import AppBar from '@mui/material/AppBar'; 7 | import Toolbar from '@mui/material/Toolbar'; 8 | import Typography from '@mui/material/Typography'; 9 | import Button from '@mui/material/Button'; 10 | import { updateCSVState } from '../../Redux/slices/csvDataSlice'; 11 | import { updateMessagingState } from '../../Redux/slices/messagingSlice'; 12 | import { updateActionState } from '../../Redux/slices/actionSlice'; 13 | import { updateSettingsState } from '../../Redux/slices/settingsSlice'; 14 | import { COMMON } from '../../Utils/variables'; 15 | 16 | const MainAppBar = () => { 17 | 18 | const dispatch = useDispatch(); 19 | const isAuthenticated = useSelector((state) => state.auth.isAuthenticated); 20 | const navigate = useNavigate(); 21 | 22 | const handleLogout = async () => { 23 | if(isAuthenticated){ 24 | dispatch(updateMessagingState({ 25 | type: COMMON.RESET_STATE, 26 | value: "" 27 | })) 28 | 29 | dispatch(updateActionState({ 30 | type: COMMON.RESET_STATE, 31 | value: "" 32 | })) 33 | 34 | dispatch(updateCSVState({ 35 | type: COMMON.RESET_STATE, 36 | value: "" 37 | })) 38 | 39 | dispatch(updateSettingsState({ 40 | type: COMMON.RESET_STATE, 41 | value: "" 42 | })) 43 | dispatch(logout(isAuthenticated)) 44 | const res = await removeJWT(); 45 | console.log(res) 46 | navigate("/login"); 47 | } 48 | } 49 | 50 | return ( 51 | 52 | 53 | 54 | Outbound Messaging App 55 | 56 | { isAuthenticated && ()} 57 | 58 | 59 | 60 | ); 61 | } 62 | 63 | export default MainAppBar; -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | 106 | # production 107 | /build -------------------------------------------------------------------------------- /serverless/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | 106 | # created assets 107 | assets/ -------------------------------------------------------------------------------- /frontend/src/Components/MessageBuilder/ContentTemplateRenderer/TemplateVariablesInput.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useDispatch, useSelector } from 'react-redux'; 3 | import { TextField, Grid, InputLabel, Select, MenuItem, FormControl } from '@mui/material'; 4 | import { updateMessagingState } from '../../../Redux/slices/messagingSlice'; 5 | import { MESSAGING_TYPES } from '../../../Utils/variables'; 6 | 7 | const TemplateVariablesInput = ({data}) => { 8 | 9 | const columnFields = useSelector(state => state.csvDataStructure.csvColumnFields) 10 | const values = useSelector(state => state.messagingStructure.templateVariables) 11 | const dispatch = useDispatch(); 12 | 13 | const handleSelectChange = (key, value) => { 14 | const newValues = { ...values, [key]: value }; 15 | dispatch(updateMessagingState({ 16 | type: MESSAGING_TYPES.TEMPLATE_VARIABLES, 17 | value: newValues 18 | })); 19 | }; 20 | 21 | return ( 22 | <> 23 | {Object.keys(data).map((key) => ( 24 | 25 | 26 | 35 | 36 | 37 | 38 | Select Column 39 | 47 | 48 | 49 | 50 | 51 | ))} 52 | 53 | ) 54 | 55 | } 56 | 57 | export default TemplateVariablesInput; -------------------------------------------------------------------------------- /frontend/src/Redux/slices/actionSlice.js: -------------------------------------------------------------------------------- 1 | import {createSlice} from '@reduxjs/toolkit'; 2 | import { ACTION_TYPES, COMMON } from '../../Utils/variables'; 3 | 4 | const initialState = { 5 | lookupDataForLogs: {}, 6 | totalLogs: "", 7 | sendDataForLogs: {}, 8 | getStatusDataForLogs: {}, 9 | emptyNumbersForLogs: {}, 10 | duplicateNumberDataForLogs: {}, 11 | progressBarCount: 0 12 | }; 13 | 14 | const actionSlice = createSlice({ 15 | name: 'actionStructure', 16 | initialState, 17 | reducers: { 18 | updateActionState: (state, action) => { 19 | switch(action.payload.type){ 20 | case ACTION_TYPES.LOOKUP_DATA_FOR_LOGS: 21 | state.lookupDataForLogs = action.payload.value 22 | break; 23 | case ACTION_TYPES.SEND_DATA_FOR_LOGS: 24 | state.sendDataForLogs = action.payload.value 25 | break; 26 | case ACTION_TYPES.GET_STATUS_DATA_FOR_LOGS: 27 | state.getStatusDataForLogs = action.payload.value 28 | break; 29 | case ACTION_TYPES.EMPTY_NUMBERS_FOR_LOGS: 30 | state.emptyNumbersForLogs = action.payload.value 31 | break; 32 | case ACTION_TYPES.DUPLICATE_NUMBERS_FOR_LOGS: 33 | state.duplicateNumberDataForLogs = action.payload.value 34 | break; 35 | case ACTION_TYPES.PROGRESS_BAR_COUNT: 36 | let currentBarCount = state.progressBarCount; 37 | const newValue = action.payload.value.newValue; 38 | const totalData = action.payload.value.totalData; 39 | if(newValue === 0){ 40 | state.progressBarCount = 0 41 | } 42 | else { 43 | const newBarCount = currentBarCount + newValue; 44 | state.progressBarCount = newBarCount > totalData ? totalData : newBarCount; 45 | } 46 | break; 47 | case ACTION_TYPES.TOTAL_LOGS: 48 | state.totalLogs = action.payload.value 49 | break; 50 | case COMMON.RESET_STATE: 51 | return initialState 52 | } 53 | } 54 | }, 55 | }); 56 | 57 | export const { updateActionState} = actionSlice.actions; 58 | export default actionSlice.reducer; -------------------------------------------------------------------------------- /frontend/src/Redux/slices/settingsSlice.js: -------------------------------------------------------------------------------- 1 | import {createSlice} from '@reduxjs/toolkit' 2 | import { SETTINGS_TYPES, COMMON } from '../../Utils/variables'; 3 | 4 | const initialState = { 5 | checkLineType: false, 6 | checkMediaURL: false, 7 | checkOptOutURL: false, 8 | checkMessagingService: false, 9 | checkScheduleMessages: false, 10 | checkBroadcastAPI: false, 11 | checkLinkShortening: false, 12 | enableGraph: false, 13 | limits: { 14 | lookupChunkSize: 50, 15 | broadcastChunkSize: 100, 16 | standardAPIChunkSize: 50, 17 | browserConcurrency: 5, 18 | getStatusChunkSize: 50 19 | } 20 | }; 21 | 22 | const settingsSlice = createSlice({ 23 | name: 'settingsStructure', 24 | initialState, 25 | reducers: { 26 | updateSettingsState: (state, action) => { 27 | switch(action.payload.type){ 28 | case SETTINGS_TYPES.LINE_TYPE_SWITCH: 29 | state.checkLineType = action.payload.value 30 | break; 31 | case SETTINGS_TYPES.MESSAGING_SERVICE_SWITCH: 32 | state.checkMessagingService = action.payload.value 33 | break; 34 | case SETTINGS_TYPES.MEDIA_URL_SWITCH: 35 | state.checkMediaURL = action.payload.value 36 | break; 37 | case SETTINGS_TYPES.OPT_OUT_SWITCH: 38 | state.checkOptOutURL = action.payload.value 39 | break; 40 | case SETTINGS_TYPES.SCHEDULE_SWITCH: 41 | state.checkScheduleMessages = action.payload.value; 42 | break; 43 | case SETTINGS_TYPES.BROADCAST_SWITCH: 44 | state.checkBroadcastAPI = action.payload.value 45 | break; 46 | case SETTINGS_TYPES.LINK_SHORTENING: 47 | state.checkLinkShortening = action.payload.value 48 | break; 49 | case SETTINGS_TYPES.ENABLE_GRAPH: 50 | state.enableGraph = action.payload.value 51 | break; 52 | case SETTINGS_TYPES.LIMITS: 53 | state.limits = action.payload.value 54 | break; 55 | case COMMON.RESET_STATE: 56 | return initialState 57 | } 58 | } 59 | }, 60 | }); 61 | 62 | export const { updateSettingsState } = settingsSlice.actions; 63 | export default settingsSlice.reducer; -------------------------------------------------------------------------------- /serverless/functions/get-message-status.js: -------------------------------------------------------------------------------- 1 | exports.handler = function(context, event, callback) { 2 | 3 | const twilioClient = context.getTwilioClient(); 4 | 5 | const response = new Twilio.Response(); 6 | 7 | response.appendHeader('Content-Type', 'application/json'); 8 | 9 | const checkAuthPath = Runtime.getFunctions()['check-auth'].path; 10 | const checkAuth = require(checkAuthPath) 11 | let check = checkAuth.checkAuth(event.request.cookies, context.JWT_SECRET); 12 | if(!check.allowed)return callback(null,check.response); 13 | 14 | try { 15 | 16 | const { sendResultsArray, startIndex } = event; 17 | 18 | let promises = []; 19 | const expbackoffPath = Runtime.getFunctions()['exponential-backoff'].path; 20 | const expbackoff = require(expbackoffPath) 21 | 22 | sendResultsArray.map((row) => { 23 | if(row.messageSid.length > 0){ 24 | console.log(row) 25 | promises.push( 26 | expbackoff.expbackoff(async () => { 27 | {return twilioClient.messages(row.messageSid).fetch()} 28 | }) 29 | ); 30 | return; 31 | } 32 | return; 33 | }) 34 | 35 | let getStatusArray = [] 36 | 37 | Promise.allSettled(promises).then((result) => { 38 | result.forEach((r,index) => { 39 | let getStatusObj = { 40 | error: {} 41 | } 42 | if(promises[index] === "")return; 43 | if (r.status === "fulfilled") { 44 | 45 | getStatusObj["csvRowID"] = sendResultsArray[index].csvRowID 46 | getStatusObj["status"] = r.value.status 47 | getStatusObj["error"]["errorMessage"] = r.value.errorMessage 48 | getStatusObj["error"]["errorCode"] = r.value.errorCode 49 | } 50 | else if (r.status === "rejected"){ 51 | getStatusObj["csvRowID"] = sendResultsArray[index].csvRowID 52 | getStatusObj["status"] = "failed" 53 | getStatusObj["error"]["errorMessage"] = r.reason.moreInfo 54 | getStatusObj["error"]["errorCode"] = r.reason.code 55 | } 56 | getStatusArray.push(getStatusObj) 57 | 58 | }) 59 | 60 | response.setBody({ 61 | status: true, 62 | message: "Getting Statuses done", 63 | data: { 64 | getStatusArray: getStatusArray 65 | }, 66 | }) 67 | callback(null, response); 68 | }); 69 | } catch (err) { 70 | console.log("error:" + err); 71 | response.setStatusCode(500); 72 | response.setBody(err); 73 | callback(null, response); 74 | } 75 | 76 | } -------------------------------------------------------------------------------- /serverless/functions/fetch-templates.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios').default; 2 | const URL = "https://content.twilio.com/v1/Content" 3 | 4 | exports.handler = async function(context, event, callback) { 5 | 6 | const response = new Twilio.Response(); 7 | response.appendHeader('Content-Type', 'application/json'); 8 | 9 | const checkAuthPath = Runtime.getFunctions()['check-auth'].path; 10 | const checkAuth = require(checkAuthPath) 11 | let check = checkAuth.checkAuth(event.request.cookies, context.JWT_SECRET); 12 | if(!check.allowed)return callback(null,check.response); 13 | 14 | const fetchTemplates = async (url, axios_auth) => { 15 | let templates = []; 16 | 17 | if(!url || url.length === 0) url = URL; 18 | 19 | try { 20 | const response = await axios.get(url, axios_auth); 21 | const data = response.data; 22 | templates = [...data.contents] 23 | nextPageUrl = data.meta.next_page_url ? data.meta.next_page_url : null; 24 | 25 | } catch (error) { 26 | console.error("Error fetching templates:", error); 27 | } 28 | 29 | return {templates: templates, nextPageUrl: nextPageUrl}; 30 | }; 31 | 32 | try { 33 | 34 | let templates_array = []; 35 | 36 | let axios_auth = { 37 | auth: { 38 | username: context.ACCOUNT_SID, 39 | password: context.AUTH_TOKEN 40 | } 41 | } 42 | 43 | 44 | const {templates, nextPageUrl} = await fetchTemplates(event.nextPageUrl, axios_auth); 45 | 46 | console.log(templates.length) 47 | console.log(nextPageUrl) 48 | 49 | templates.forEach(t => { 50 | 51 | const templateData = { 52 | 'name': t.friendly_name + " - HX..." + t.sid.slice(-4), 53 | 'language': t.language, 54 | 'content': t.types, 55 | 'sid': t.sid, 56 | 'variables': t.variables 57 | } 58 | 59 | if(event.channelSelection === "SMS"){ 60 | if ('twilio/text' in t.types || 'twilio/media' in t.types){ 61 | templates_array.push(templateData) 62 | } 63 | } 64 | else { 65 | templates_array.push(templateData) 66 | } 67 | }); 68 | 69 | response.setBody({ 70 | status: true, 71 | message: "Getting Templates done", 72 | data: { 73 | templates_array: templates_array, 74 | nextPageUrl: nextPageUrl 75 | }, 76 | }) 77 | callback(null, response); 78 | } catch (err) { 79 | console.log("error:" + err); 80 | response.setStatusCode(500); 81 | response.setBody(err); 82 | callback(null, response); 83 | } 84 | 85 | } -------------------------------------------------------------------------------- /frontend/src/Components/Authentication/Login/Login.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { useDispatch, useSelector } from 'react-redux'; 3 | import { useNavigate, Navigate } from "react-router-dom"; 4 | import { setAuthenticated } from '../../../Redux/slices/authSlice'; 5 | import { authenticateUser } from '../../../Utils/functions'; 6 | import { Typography, TextField, Button, Container } from '@mui/material'; 7 | 8 | const Login = () => { 9 | const dispatch = useDispatch(); 10 | const isAuthenticated = useSelector(state => state.auth.isAuthenticated); 11 | const navigate = useNavigate(); 12 | const [username, setUsername] = useState(''); 13 | const [password, setPassword] = useState(''); 14 | const [authFailMessage, setAuthFailMessage] = useState(null); 15 | 16 | const handleLogin = async (username, password) => { 17 | const data = await authenticateUser( 18 | { 19 | username: username, 20 | password: password 21 | } 22 | ) 23 | if(data.success){ 24 | dispatch(setAuthenticated(true)) 25 | setAuthFailMessage(null) 26 | navigate("/build", { replace: true }) 27 | } 28 | else { 29 | setAuthFailMessage(data.message) 30 | } 31 | } 32 | 33 | const handleFormSubmit = (e) => { 34 | e.preventDefault(); 35 | handleLogin(username, password); 36 | }; 37 | 38 | const containerStyles = { 39 | display: 'flex', 40 | justifyContent: 'center', 41 | alignItems: 'center', 42 | minHeight: '50vh', 43 | }; 44 | 45 | const centerStyles = { 46 | display: 'flex', 47 | justifyContent: 'center', 48 | alignItems: 'center', 49 | }; 50 | 51 | if(isAuthenticated) return ; 52 | return ( 53 |
54 |
55 | 56 |
57 | setUsername(e.target.value)} 61 | fullWidth 62 | margin="normal" 63 | /> 64 | setPassword(e.target.value)} 68 | type="password" 69 | fullWidth 70 | margin="normal" 71 | /> 72 | 75 | 76 | { 77 | authFailMessage && ( 78 | 79 | {authFailMessage} 80 | 81 | ) 82 | } 83 |
84 |
85 |
86 | ); 87 | }; 88 | 89 | export default Login; 90 | -------------------------------------------------------------------------------- /frontend/src/Components/MainBuilder/MainBuilder.js: -------------------------------------------------------------------------------- 1 | import { FormControl, Grid } from '@mui/material'; 2 | import React from 'react'; 3 | import { useSelector } from 'react-redux'; 4 | import AccordionTemplate from '../AccordionTemplate/AccordionTemplate'; 5 | import CampaignTable from '../CampaignTable/CampaignTable'; 6 | import ChannelSelection from '../ChannelSelection/ChannelSelection'; 7 | import CheckAndSend from '../CheckAndSend/CheckAndSend'; 8 | import FileLoader from '../FileLoader/FileLoader'; 9 | import Logs from '../LogManager/Logs'; 10 | import MessageBuilder from '../MessageBuilder/MessageBuilder'; 11 | import MessageOptions from '../MessageOptions/MessageOptions'; 12 | import NumberColumnSelection from '../NumberColumnSelection/NumberColumnSelection'; 13 | import ResultsExports from '../ResultsExport/ResultsExport'; 14 | import SenderBuilder from '../SenderBuilder/SenderBuilder'; 15 | import Settings from '../Settings/Settings'; 16 | 17 | const MainBuilder = () => { 18 | const csvColumnFields = useSelector((state) => state.csvDataStructure.csvColumnFields); 19 | const csvData = useSelector((state) => state.csvDataStructure.csvData); 20 | 21 | //useSelector((state) => console.log(state)) 22 | 23 | return ( 24 | 25 | 26 | 27 | 28 | { csvColumnFields && csvColumnFields.length > 0 && } 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | {csvData.length > 0 && } 64 | 65 | 66 | 67 | ); 68 | } 69 | 70 | export default MainBuilder; -------------------------------------------------------------------------------- /frontend/src/Utils/variables.js: -------------------------------------------------------------------------------- 1 | // API URLs 2 | export const API_URLS = { 3 | PROTOCOL: '', 4 | BASE_URL: '', 5 | AUTHENTICATE: 'jwt', 6 | CHECK_USER_AUTHENTICATED: 'check-user-authenticated', 7 | REMOVE_TOKEN: 'remove-token', 8 | FETCH_TEMPLATES: 'fetch-templates', 9 | FETCH_SERVICES: 'fetch-services', 10 | CHECK_NUMBERS: 'check-numbers', 11 | SEND_SMS_API: 'send', 12 | SEND_BROADCAST_API: 'send-broadcast', 13 | GET_MESSAGE_STATUS: 'get-message-status' 14 | }; 15 | 16 | export const SETTINGS_TYPES = { 17 | LINE_TYPE_SWITCH: 'lineTypeSwitch', 18 | MESSAGING_SERVICE_SWITCH: 'messagingServiceSwitch', 19 | MEDIA_URL_SWITCH: 'mediaUrlSwitch', 20 | OPT_OUT_SWITCH: 'optOutSwitch', 21 | SCHEDULE_SWITCH: 'scheduleSwitch', 22 | BROADCAST_SWITCH: 'broadcastSwitch', 23 | ENABLE_GRAPH: 'enableGraph', 24 | LIMITS: 'limits', 25 | LINK_SHORTENING: 'linkShorteningSwitch' 26 | } 27 | 28 | export const CSVDATA_TYPES = { 29 | ALL_CSV_DATA : 'allCSVData', 30 | CSV_COLUMN_FIELDS: 'csvColumnFields', 31 | CSV_SELECTED_COLUMN: 'csvSelectedColumn', 32 | DELETE_ROW: 'deleteRow', 33 | UPDATE_DATA_CHUNK: 'updateDataChunk', 34 | UPDATE_ROW: 'updateRow', 35 | ADD_ROW: 'addRow' 36 | } 37 | 38 | export const MESSAGING_TYPES = { 39 | MESSAGE_TYPE_SELECTION: 'messageTypeSelection', 40 | SENDER_TYPE_SELECTION: 'senderTypeSelection', 41 | CHANNEL_SELECTION: 'channelSelection', 42 | SELECTED_SERVICE: 'selectedService', 43 | SELECTED_SINGLE_SENDER: 'selectedSingleSender', 44 | SELECTED_TEMPLATE: 'selectedTemplate', 45 | TEMPLATE_VARIABLES: 'templateVariables', 46 | CUSTOM_MESSAGE: 'customMessage', 47 | CUSTOM_MEDIA_URL: 'customMediaURL', 48 | SCHEDULED_DATE: 'scheduledDate', 49 | SEND_RESULTS_ARRAY: 'sendResultsArray', 50 | UPDATE_SEND_RESULTS_ARRAY_AFTER_SEND: 'sendResultsArrayUpdateAfterSend', 51 | UPDATE_DATA_CHUNK: 'updateDataChunk', 52 | DELETE_ROW: 'deleteRow', 53 | UPDATE_ROW: 'updateRow', 54 | ADD_ROW: 'addRow', 55 | UPDATE_STATUS_CHUNK: 'updateStatusChunk' 56 | 57 | } 58 | 59 | export const ACTION_TYPES = { 60 | LOOKUP_DATA_FOR_LOGS: 'lookupDataForLogs', 61 | TOTAL_LOGS: 'totalLogs', 62 | SEND_DATA_FOR_LOGS: 'sendDataForLogs', 63 | GET_STATUS_DATA_FOR_LOGS: 'getStatusDataForLogs', 64 | EMPTY_NUMBERS_FOR_LOGS: 'emptyNumbersForLogs', 65 | DUPLICATE_NUMBERS_FOR_LOGS: 'duplicateNumberDataForLogs', 66 | PROGRESS_BAR_COUNT: 'progressBarCount' 67 | } 68 | 69 | export const COMMON = { 70 | RESET_STATE: 'resetState' 71 | } 72 | 73 | export const CONTENT_TYPES = { 74 | 'twilio/text': 'Text', 75 | 'twilio/media': 'Media', 76 | 'twilio/call-to-action': 'Call To Action', 77 | 'twilio/quick-reply': 'Quick Reply', 78 | 'twilio/card': 'Card', 79 | 'twilio/list-picker': 'List Picker', 80 | 'whatsapp/authentication': 'Whatsapp Authentication' 81 | }; -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Twilio Serverless 2 | .twiliodeployinfo 3 | .twilioserverlessrc 4 | # Logs 5 | logs 6 | *.log 7 | npm-debug.log* 8 | yarn-debug.log* 9 | yarn-error.log* 10 | lerna-debug.log* 11 | .pnpm-debug.log* 12 | 13 | # Diagnostic reports (https://nodejs.org/api/report.html) 14 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 15 | 16 | # Runtime data 17 | pids 18 | *.pid 19 | *.seed 20 | *.pid.lock 21 | 22 | # Directory for instrumented libs generated by jscoverage/JSCover 23 | lib-cov 24 | 25 | # Coverage directory used by tools like istanbul 26 | coverage 27 | *.lcov 28 | 29 | # nyc test coverage 30 | .nyc_output 31 | 32 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 33 | .grunt 34 | 35 | # Bower dependency directory (https://bower.io/) 36 | bower_components 37 | 38 | # node-waf configuration 39 | .lock-wscript 40 | 41 | # Compiled binary addons (https://nodejs.org/api/addons.html) 42 | build/Release 43 | 44 | # Dependency directories 45 | node_modules/ 46 | jspm_packages/ 47 | 48 | # Snowpack dependency directory (https://snowpack.dev/) 49 | web_modules/ 50 | 51 | # TypeScript cache 52 | *.tsbuildinfo 53 | 54 | # Optional npm cache directory 55 | .npm 56 | 57 | # Optional eslint cache 58 | .eslintcache 59 | 60 | # Optional stylelint cache 61 | .stylelintcache 62 | 63 | # Microbundle cache 64 | .rpt2_cache/ 65 | .rts2_cache_cjs/ 66 | .rts2_cache_es/ 67 | .rts2_cache_umd/ 68 | 69 | # Optional REPL history 70 | .node_repl_history 71 | 72 | # Output of 'npm pack' 73 | *.tgz 74 | 75 | # Yarn Integrity file 76 | .yarn-integrity 77 | 78 | # dotenv environment variable files 79 | .env 80 | .env.development.local 81 | .env.test.local 82 | .env.production.local 83 | .env.local 84 | 85 | # parcel-bundler cache (https://parceljs.org/) 86 | .cache 87 | .parcel-cache 88 | 89 | # Next.js build output 90 | .next 91 | out 92 | 93 | # Nuxt.js build / generate output 94 | .nuxt 95 | dist 96 | 97 | # Gatsby files 98 | .cache/ 99 | # Comment in the public line in if your project uses Gatsby and not Next.js 100 | # https://nextjs.org/blog/next-9-1#public-directory-support 101 | # public 102 | 103 | # vuepress build output 104 | .vuepress/dist 105 | 106 | # vuepress v2.x temp and cache directory 107 | .temp 108 | .cache 109 | 110 | # Docusaurus cache and generated files 111 | .docusaurus 112 | 113 | # Serverless directories 114 | .serverless/ 115 | 116 | # FuseBox cache 117 | .fusebox/ 118 | 119 | # DynamoDB Local files 120 | .dynamodb/ 121 | 122 | # TernJS port file 123 | .tern-port 124 | 125 | # Stores VSCode versions used for testing VSCode extensions 126 | .vscode-test 127 | 128 | # yarn v2 129 | .yarn/cache 130 | .yarn/unplugged 131 | .yarn/build-state.yml 132 | .yarn/install-state.gz 133 | .pnp.* 134 | -------------------------------------------------------------------------------- /serverless/functions/send.js: -------------------------------------------------------------------------------- 1 | exports.handler = function(context, event, callback) { 2 | 3 | const response = new Twilio.Response(); 4 | response.appendHeader('Content-Type', 'application/json'); 5 | 6 | const checkAuthPath = Runtime.getFunctions()['check-auth'].path; 7 | const checkAuth = require(checkAuthPath) 8 | let check = checkAuth.checkAuth(event.request.cookies, context.JWT_SECRET); 9 | if(!check.allowed)return callback(null,check.response); 10 | 11 | try { 12 | 13 | const expbackoffPath = Runtime.getFunctions()['exponential-backoff'].path; 14 | const expbackoff = require(expbackoffPath) 15 | const sendPreparePath = Runtime.getFunctions()['send-prepare'].path; 16 | const sendPrepare = require(sendPreparePath) 17 | let preparedData = sendPrepare.prepareData(event, "simple"); 18 | 19 | const twilioClient = context.getTwilioClient(); 20 | 21 | const { Messages, ...messageData } = preparedData; 22 | 23 | const dataToSend = Messages.map(userObj => ({ 24 | ...userObj, 25 | ...messageData 26 | })); 27 | 28 | let promises = [] 29 | dataToSend.map(requestObj => { 30 | promises.push( 31 | expbackoff.expbackoff(async () => { 32 | return twilioClient.messages.create(requestObj) 33 | }) 34 | ); 35 | }) 36 | 37 | let sentSuccess = 0; 38 | let sentErrors = 0; 39 | let messageReceiptsArray = [] 40 | let failedReceiptsArray = [] 41 | const { csvData } = event; 42 | 43 | Promise.allSettled(promises).then((result) => { 44 | result.forEach((r,index) => { 45 | 46 | if (r.status === "fulfilled"){ 47 | sentSuccess++; 48 | messageReceiptsArray.push({ 49 | csvRowID: csvData[index].UniqueID, 50 | messageSid: r.value.sid 51 | }) 52 | } 53 | else { 54 | sentErrors++; 55 | failedReceiptsArray.push({ 56 | csvRowID: csvData[index].UniqueID, 57 | errorCode: r.reason.code, 58 | errorMessage: r.reason.moreInfo, 59 | status: "failed" 60 | }) 61 | } 62 | 63 | }); 64 | 65 | response.setBody({ 66 | status: true, 67 | message: "Messages Sent", 68 | data: { 69 | sentSuccess: sentSuccess, 70 | sentErrors: sentErrors, 71 | messageReceiptsArray: messageReceiptsArray, 72 | failedReceiptsArray: failedReceiptsArray 73 | }, 74 | }) 75 | return callback(null, response); 76 | 77 | }); 78 | 79 | } 80 | catch (err) { 81 | console.log("error:" + err); 82 | response.setStatusCode(500); 83 | response.setBody(err); 84 | return callback(null, response); 85 | } 86 | } -------------------------------------------------------------------------------- /frontend/src/Components/Settings/Settings.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useDispatch, useSelector } from 'react-redux'; 3 | import { TextField, Box, Tooltip } from '@mui/material'; 4 | import { updateSettingsState } from '../../Redux/slices/settingsSlice'; 5 | import { SETTINGS_TYPES } from '../../Utils/variables'; 6 | 7 | const Settings = () => { 8 | 9 | const dispatch = useDispatch(); 10 | const limits = useSelector(state => state.settingsStructure.limits) 11 | 12 | const handleSettingChange = (type) => event => { 13 | let newLimits = { 14 | ...limits, 15 | [type]: Number(event.target.value) 16 | } 17 | dispatch(updateSettingsState({ 18 | type: SETTINGS_TYPES.LIMITS, 19 | value: newLimits 20 | })) 21 | } 22 | 23 | return ( 24 | 25 | 26 | 33 | 34 | 35 | 42 | 43 | 44 | 51 | 52 | 53 | 60 | 61 | 62 | 69 | 70 | 71 | 72 | ) 73 | 74 | } 75 | 76 | export default Settings; -------------------------------------------------------------------------------- /frontend/src/Components/LogManager/GraphPanel/GraphPanel.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Stack from '@mui/material/Stack'; 3 | import { BarChart, CartesianGrid, XAxis, YAxis , Bar, PieChart, Pie, Cell, Tooltip } from 'recharts'; 4 | import { useSelector } from 'react-redux'; 5 | 6 | const GraphPanel = (props) => { 7 | 8 | const { index } = props; 9 | const sendResultsArray = useSelector(state => state.messagingStructure.sendResultsArray) 10 | 11 | const statuses = ["accepted", "queued", "sent", "sending","undelivered", "failed", "delivered", "read"] 12 | const colors = ['#0088FE', '#00C49F', 'grey', '#FF8042', '#8B0000', 'red', 'green', '#00ff00']; 13 | 14 | let error_array = [] 15 | if(sendResultsArray){ 16 | for (let i = 0; i < sendResultsArray.length; i++){ 17 | if(sendResultsArray[i].error && sendResultsArray[i].error.errorCode){ 18 | let error_index = error_array.findIndex((element) => { 19 | return parseInt(element.name) === sendResultsArray[i].error.errorCode 20 | }) 21 | if(error_index !== -1){ 22 | error_array[error_index].count++ 23 | } 24 | else{ 25 | error_array.push({"name": sendResultsArray[i].error.errorCode, "count": 1}) 26 | } 27 | } 28 | } 29 | } 30 | 31 | let statuses_array = [] 32 | if(sendResultsArray){ 33 | sendResultsArray.map((item) => { 34 | let status_index = statuses_array.findIndex((element) => { 35 | return element.name === item.status 36 | }) 37 | if(status_index !== -1){ 38 | statuses_array[status_index].count++ 39 | } 40 | else{ 41 | statuses_array.push({"name": item.status, "count": 1}) 42 | } 43 | }) 44 | } 45 | 46 | let colors_final = [] 47 | statuses_array.map((status) =>{ 48 | let color_index = statuses.indexOf(status.name) 49 | colors_final.push(color_index) 50 | }) 51 | 52 | 53 | return ( 54 |
59 | 60 | 61 | 62 | 71 | {colors_final.map((entry) => 72 | ( 73 | 74 | ) 75 | )} 76 | 77 | 78 | 79 | 80 | { 81 | error_array ? 82 | 83 | ( 84 | 85 | 86 | 87 | 88 | 89 | ): 90 | <> 91 | } 92 | 93 | 94 | 95 |
96 | ); 97 | } 98 | 99 | export default GraphPanel; -------------------------------------------------------------------------------- /serverless/functions/check-numbers.js: -------------------------------------------------------------------------------- 1 | exports.handler = async function(context, event, callback) { 2 | 3 | const response = new Twilio.Response(); 4 | response.appendHeader('Content-Type', 'application/json'); 5 | 6 | const checkAuthPath = Runtime.getFunctions()['check-auth'].path; 7 | const checkAuth = require(checkAuthPath) 8 | let check = checkAuth.checkAuth(event.request.cookies, context.JWT_SECRET); 9 | if(!check.allowed)return callback(null,check.response); 10 | 11 | try { 12 | sentSuccess = 0; 13 | sentErrors = 0; 14 | nonmobileNumbers = []; 15 | invalidNumbers = []; 16 | nonmobileNumbers_ID = []; 17 | invalidNumbers_ID = []; 18 | 19 | const twilioClient = context.getTwilioClient(); 20 | 21 | const {csvData, phoneNumberColumn, startIndex, checkLineType} = event 22 | if (typeof csvData === 'undefined' || csvData === null || csvData.length === 0) { 23 | throw("csvData can not be empty"); 24 | } else { 25 | 26 | 27 | const expbackoffPath = Runtime.getFunctions()['exponential-backoff'].path; 28 | const expbackoff = require(expbackoffPath) 29 | 30 | let promises = [] 31 | 32 | csvData.map((row) => { 33 | let phoneNumber = row[phoneNumberColumn] 34 | if(!phoneNumber.startsWith('+'))phoneNumber = `+${phoneNumber}` 35 | promises.push( 36 | expbackoff.expbackoff(async () => { 37 | return twilioClient.lookups.v2.phoneNumbers(phoneNumber) 38 | .fetch(checkLineType ? {fields: 'line_type_intelligence'} : "") 39 | }) 40 | ); 41 | }) 42 | 43 | Promise.allSettled(promises).then(result => { 44 | result.forEach((r,index) => { 45 | console.log(r) 46 | if (r.status === "rejected") { 47 | sentErrors++; 48 | } 49 | else if (r.status === "fulfilled") { 50 | sentSuccess++ 51 | if(!r.value.valid){ 52 | invalidNumbers_ID.push(startIndex + index) 53 | invalidNumbers.push(csvData[index].UniqueID) 54 | } 55 | if (checkLineType){ 56 | if (r.value.lineTypeIntelligence) { 57 | if(r.value.lineTypeIntelligence.type !== "mobile"){ 58 | nonmobileNumbers_ID.push(startIndex + index) 59 | nonmobileNumbers.push(csvData[index].UniqueID) 60 | } 61 | } 62 | } 63 | } 64 | }); 65 | response.setBody({ 66 | status: true, 67 | message: "Lookup done", 68 | data: { 69 | checkedSuccess: sentSuccess, 70 | checkedErrors: sentErrors, 71 | nonmobileNumbers: nonmobileNumbers, 72 | invalidNumbers: invalidNumbers, 73 | nonmobileNumbers_ID: nonmobileNumbers_ID, 74 | invalidNumbers_ID: invalidNumbers_ID 75 | }, 76 | }) 77 | return callback(null, response); 78 | }) 79 | } 80 | } 81 | catch (err) { 82 | console.log("error:" + err); 83 | response.setStatusCode(500); 84 | response.setBody(err); 85 | return callback(null, response); 86 | } 87 | 88 | } 89 | -------------------------------------------------------------------------------- /frontend/src/Components/ResultsExport/ResultsExport.js: -------------------------------------------------------------------------------- 1 | import React, {useState} from 'react'; 2 | import { Button, Box, TextField } from '@mui/material'; 3 | import { convertToCSV, downloadCSV } from '../../Utils/functions'; 4 | import { useSelector } from 'react-redux'; 5 | 6 | const ResultsExports = () => { 7 | 8 | const [filename, setFilename] = useState('results.csv'); 9 | const [webhookUrl, setWebhookUrl] = useState(''); 10 | 11 | const csvData = useSelector(state => state.csvDataStructure.csvData) 12 | const lookupData = useSelector(state => state.actionStructure.lookupDataForLogs) 13 | const checkLineType = useSelector(state => state.settingsStructure.checkLineType) 14 | const sendResultsArray = useSelector(state => state.messagingStructure.sendResultsArray) 15 | 16 | const capitalizeFirstLetter = (str) => { 17 | if (!str) return str; 18 | return str.charAt(0).toUpperCase() + str.slice(1); 19 | } 20 | 21 | const mergedData = csvData.map((row, index) => { 22 | let lookupValidityStatus = ""; 23 | let lookupLineType = ""; 24 | if (lookupData.checkedSuccess > 0 || lookupData.checkedErrors > 0) { 25 | lookupValidityStatus = lookupData.invalidNumbers_ID.includes(index) ? 'Invalid' : 'Valid'; 26 | if(checkLineType) { 27 | lookupLineType = lookupData.nonmobileNumbers_ID.includes(index) ? 'Non mobile' : 'Mobile'; 28 | } 29 | else { 30 | lookupLineType = "Line Type not checked" 31 | } 32 | } 33 | else { 34 | lookupValidityStatus = "Lookup not done" 35 | } 36 | 37 | const sendResultRowStatus = sendResultsArray.find(result => result.csvRowID === row.UniqueID); 38 | const messageStatus = sendResultRowStatus ? capitalizeFirstLetter(sendResultRowStatus.status) : 'Status not available'; 39 | 40 | return { 41 | ...row, 42 | "Is the number valid?" : lookupValidityStatus, 43 | "Is the number mobile?" : lookupLineType, 44 | "Message Status": messageStatus 45 | }; 46 | }); 47 | 48 | const handleDownload = () => { 49 | const csvContent = convertToCSV(mergedData); 50 | downloadCSV(csvContent, filename); 51 | }; 52 | 53 | const handleSendData = () => { 54 | fetch(webhookUrl, { 55 | method: 'POST', 56 | headers: { 57 | 'Content-Type': 'text/plain', 58 | }, 59 | body: JSON.stringify(mergedData), 60 | }) 61 | .then(response => response.json()) 62 | .then(data => console.log('Success:', data)) 63 | .catch((error) => console.error('Error:', error)); 64 | }; 65 | 66 | return( 67 | 68 | setFilename(e.target.value)} 73 | helperText="Enter the filename for the CSV download." 74 | /> 75 | 78 | 79 | setWebhookUrl(e.target.value)} 84 | helperText="Enter the Webhook URL to send data." 85 | /> 86 | 89 | 90 | ) 91 | } 92 | 93 | export default ResultsExports; -------------------------------------------------------------------------------- /frontend/src/Components/MessageOptions/MessageScheduler/MessageScheduler.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { Box, TextField, FormControlLabel, Switch, Alert, IconButton } from '@mui/material'; 3 | import { addMinutes, addDays, addHours, parse, format, formatISO } from 'date-fns'; 4 | import HelpOutlineIcon from '@mui/icons-material/HelpOutline'; 5 | import { useSelector, useDispatch } from 'react-redux'; 6 | import { updateSettingsState } from '../../../Redux/slices/settingsSlice'; 7 | import { updateMessagingState } from '../../../Redux/slices/messagingSlice'; 8 | import { SETTINGS_TYPES, MESSAGING_TYPES } from '../../../Utils/variables'; 9 | 10 | const MessageScheduler = () => { 11 | const [days, setDays] = useState(0); 12 | const [minutes, setMinutes] = useState(0); 13 | const [hours, setHours] = useState(0); 14 | const [formattedDate, setFormattedDate] = useState(''); 15 | const [scheduleAlert, setScheduleAlert] = useState(false); 16 | 17 | const checkScheduleMessages = useSelector(state => state.settingsStructure.checkScheduleMessages) 18 | const scheduledDate = useSelector(state => state.messagingStructure.scheduledDate) 19 | 20 | const dispatch = useDispatch() 21 | 22 | useEffect(() => { 23 | const targetDate = addMinutes(addHours(addDays(new Date(), days), hours), minutes); 24 | const minDate = addMinutes(new Date(), 15); 25 | const maxDate = addDays(new Date(), 7); 26 | const newFormattedDate = format(targetDate, "yyyy-MM-dd HH:mm:ss"); 27 | const form = "yyyy-MM-dd HH:mm:ss" 28 | const parsedDate = parse(newFormattedDate, form, new Date()); 29 | const isoFormattedDate = formatISO(parsedDate); 30 | dispatch(updateMessagingState({ 31 | type: MESSAGING_TYPES.SCHEDULED_DATE, 32 | value: isoFormattedDate 33 | })) 34 | }, [days, minutes, hours ]); 35 | 36 | const handleScheduleSwitchChange = (e) => { 37 | dispatch(updateSettingsState({ 38 | type: SETTINGS_TYPES.SCHEDULE_SWITCH, 39 | value: e.target.checked 40 | })) 41 | 42 | if (!e.target.checked){ 43 | dispatch(updateMessagingState({ 44 | type: MESSAGING_TYPES.SCHEDULED_DATE, 45 | value: "" 46 | })) 47 | } 48 | } 49 | 50 | return ( 51 | 52 | } label="Schedule Message" /> 53 | setScheduleAlert(scheduleAlert ? false : true)}> 54 | 55 | 56 | { scheduleAlert && 57 | ( 58 | You can schedule the time to send the messages within specific time ranges. Learn more here 59 | ) 60 | } 61 | {checkScheduleMessages && ( 62 |
63 | setDays(parseInt(e.target.value))} 69 | /> 70 | setHours(parseInt(e.target.value))} 76 | /> 77 | setMinutes(parseInt(e.target.value))} 83 | /> 84 | 91 |
92 | )} 93 |
94 | ); 95 | }; 96 | 97 | export default MessageScheduler; 98 | -------------------------------------------------------------------------------- /serverless/functions/send-broadcast.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios'); 2 | 3 | exports.handler = async function(context, event, callback) { 4 | 5 | const response = new Twilio.Response(); 6 | response.appendHeader('Content-Type', 'application/json'); 7 | 8 | const checkAuthPath = Runtime.getFunctions()['check-auth'].path; 9 | const checkAuth = require(checkAuthPath) 10 | let check = checkAuth.checkAuth(event.request.cookies, context.JWT_SECRET); 11 | if(!check.allowed)return callback(null,check.response); 12 | 13 | try { 14 | const expbackoffPath = Runtime.getFunctions()['exponential-backoff'].path; 15 | const expbackoff = require(expbackoffPath) 16 | const sendPreparePath = Runtime.getFunctions()['send-prepare'].path; 17 | const sendPrepare = require(sendPreparePath) 18 | let preparedData = sendPrepare.prepareData(event, "broadcast"); 19 | 20 | const url = 'https://preview.messaging.twilio.com/v1/Messages'; 21 | const config = { 22 | headers: { 23 | 'Content-Type': 'application/json', 24 | 'Authorization': 'Basic ' + Buffer.from(context.ACCOUNT_SID + ':' + context.AUTH_TOKEN).toString('base64') 25 | } 26 | }; 27 | 28 | let promises = []; 29 | promises.push(expbackoff.expbackoff(async () => { 30 | return axios.post(url, preparedData, config) 31 | })) 32 | 33 | let messageReceiptsArray = [] 34 | let failedReceiptsArray = [] 35 | let failedToSend = {} 36 | let sentSuccess = 0; 37 | let sentErrors = 0; 38 | 39 | const {csvData, phoneNumberColumn} = event; 40 | 41 | Promise.allSettled(promises).then((result) => { 42 | result.forEach((r,index) => { 43 | if (r.status === "fulfilled"){ 44 | sentSuccess += r.value.data.success_count; 45 | sentErrors += r.value.data.error_count; 46 | if(r.value.data.message_receipts && r.value.data.message_receipts.length > 0){ 47 | r.value.data.message_receipts.map(receipt => { 48 | let match = sendPrepare.getCorrectIndex(csvData, phoneNumberColumn, receipt.to) 49 | if(match >= 0){ 50 | messageReceiptsArray.push({ 51 | csvRowID: csvData[match].UniqueID, 52 | messageSid: receipt.sid, 53 | }) 54 | } 55 | 56 | 57 | }) 58 | } 59 | if(r.value.data.failed_message_receipts && r.value.data.failed_message_receipts.length > 0){ 60 | r.value.data.failed_message_receipts.map(receipt => { 61 | let match = sendPrepare.getCorrectIndex(csvData, phoneNumberColumn, receipt.to) 62 | if(match >= 0){ 63 | failedReceiptsArray.push({ 64 | csvRowID: csvData[match].UniqueID, 65 | errorCode: receipt.error_code, 66 | errorMessage: receipt.error_message, 67 | status: "failed" 68 | }) 69 | } 70 | }) 71 | } 72 | } 73 | else { 74 | failedToSend = { 75 | ...r.reason.response.data 76 | } 77 | } 78 | 79 | }); 80 | 81 | response.setBody({ 82 | status: true, 83 | message: "Messages Sent", 84 | data: { 85 | sentSuccess: sentSuccess, 86 | sentErrors: sentErrors, 87 | messageReceiptsArray: messageReceiptsArray, 88 | failedReceiptsArray: failedReceiptsArray, 89 | failedToSend: failedToSend 90 | } 91 | }) 92 | return callback(null, response); 93 | 94 | }); 95 | 96 | } 97 | catch (err) { 98 | console.log("error:" + err); 99 | response.setStatusCode(500); 100 | response.setBody(err); 101 | return callback(null, response); 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /frontend/src/Components/SenderBuilder/MessagingServiceSelection/MessagingServiceSelection.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import FormControl from '@mui/material/FormControl'; 3 | import InputLabel from '@mui/material/InputLabel'; 4 | import Select from '@mui/material/Select'; 5 | import Typography from '@mui/material/Typography'; 6 | import {CircularProgress} from '@mui/material'; 7 | import { MenuItem } from '@mui/material'; 8 | import { useSelector, useDispatch } from 'react-redux'; 9 | import { fetchServices } from '../../../Utils/functions'; 10 | import { updateMessagingState } from '../../../Redux/slices/messagingSlice'; 11 | import { MESSAGING_TYPES } from '../../../Utils/variables'; 12 | 13 | const MessagingServiceSelection = () => { 14 | 15 | const senderTypeSelection = useSelector(state => state.messagingStructure.senderTypeSelection) 16 | const dispatch = useDispatch() 17 | 18 | const [selectedService, setSelectedService] = useState("") 19 | const [services_array, setServicesArray] = useState([]); 20 | const [stateUpdateValues, setStateUpdateValues] = useState({submitting: false, getServicesCompleted: false, error: false, showServicesLoadingIcon: false}) 21 | const [showLoadingIcon, setLoadingIcon] = useState(false) 22 | const [sender, setSender] = useState("") 23 | 24 | const listAllServices = async () => { 25 | 26 | setLoadingIcon(true) 27 | setStateUpdateValues({ submitting: true, getServicesCompleted: false, error: false, showServicesLoadingIcon: true }); 28 | try { 29 | const data = await fetchServices(); 30 | setStateUpdateValues({ 31 | submitting: false, 32 | showLoadingIcon: false, 33 | getServicesCompleted: true 34 | }); 35 | setServicesArray(data.data.services_array) 36 | setLoadingIcon(false) 37 | } catch (error) { 38 | console.error('Error fetching templates:', error); 39 | setStateUpdateValues({ 40 | submitting: false, 41 | showLoadingIcon: false, 42 | getServicesCompleted: true 43 | }); 44 | } 45 | 46 | } 47 | 48 | useEffect(() => { 49 | if(senderTypeSelection === "Messaging Service"){ 50 | listAllServices(); 51 | } 52 | else { 53 | cleanValues() 54 | } 55 | }, [senderTypeSelection]); 56 | 57 | const cleanValues = () => { 58 | setServicesArray([]) 59 | setSelectedService("") 60 | setLoadingIcon(false) 61 | setStateUpdateValues({getServicesCompleted: false}) 62 | dispatch(updateMessagingState({ 63 | type: MESSAGING_TYPES.SELECTED_SERVICE, 64 | value: null 65 | })) 66 | } 67 | 68 | 69 | function handleSelectedService(event) { 70 | setSelectedService(event.target.value) 71 | dispatch(updateMessagingState({ 72 | type: MESSAGING_TYPES.SELECTED_SERVICE, 73 | value: event.target.value 74 | })) 75 | } 76 | 77 | function handleSetSender(event) { 78 | setSender(event.target.value) 79 | } 80 | 81 | return (<> 82 | { 83 | showLoadingIcon ? 84 | 85 | (<> 86 | 87 | 88 | Loading Services... 89 | 90 | 91 | ) : "" 92 | } 93 | {services_array.length > 0 && 94 | 95 | 96 | Select Messaging Service 97 | 105 | 106 | } 107 | 108 | ) 109 | } 110 | 111 | export default MessagingServiceSelection; -------------------------------------------------------------------------------- /serverless/functions/send-prepare.private.js: -------------------------------------------------------------------------------- 1 | /* This function prepares the data depending on the selections of the user to be passed to SMS API or Broadcast API */ 2 | const normalizePhoneNumber = (phoneNumber) => { 3 | let cleanedNumber = phoneNumber.replace(/[^\d+]/g, ''); 4 | if (!cleanedNumber.startsWith('+')) { 5 | cleanedNumber = '+' + cleanedNumber; 6 | } 7 | return cleanedNumber; 8 | } 9 | 10 | exports.prepareData = (event, sendType) => { 11 | 12 | const { 13 | channelSelection, 14 | messageTypeSelection, 15 | senderTypeSelection, 16 | csvData, 17 | phoneNumberColumn, 18 | isSchedulingEnabled, 19 | isLinkShorteningEnabled 20 | } = event; 21 | 22 | let sender; 23 | senderTypeSelection === "Messaging Service" ? sender = event.selectedService : sender = event.selectedSingleSender; 24 | 25 | let messageData = { 26 | from: sender 27 | }; 28 | if (messageTypeSelection === "Template"){ 29 | messageData['contentSid'] = event.selectedTemplate.sid 30 | } 31 | else if (messageTypeSelection === "Custom") { 32 | if (event.customMediaURL) messageData['mediaUrl'] = [event.customMediaURL] 33 | } 34 | 35 | let Messages = []; 36 | 37 | csvData.map((row) => { 38 | 39 | let userObj = {} 40 | 41 | let phoneNumber = row[phoneNumberColumn] 42 | if(channelSelection === "SMS" || channelSelection === "Whatsapp"){ 43 | phoneNumber = normalizePhoneNumber(phoneNumber) 44 | } 45 | userObj['to'] = "" 46 | switch (channelSelection){ 47 | case "Whatsapp": 48 | userObj['to'] = `whatsapp:${phoneNumber}` 49 | break; 50 | case "FBM": 51 | userObj['to'] = `messenger:${phoneNumber}` 52 | break; 53 | case "SMS": 54 | userObj['to'] = `${phoneNumber}` 55 | break; 56 | } 57 | 58 | if(messageTypeSelection === "Custom") { 59 | let body = event.customMessage; 60 | Object.keys(row).forEach((col) => { 61 | body = body.replace(/{{\s*(\w+)\s*}}/g, (match, key) => { 62 | return row[key] || match; 63 | }); 64 | }); 65 | userObj['body'] = body 66 | } 67 | else { 68 | const contentVariables = buildContentVariables(event.templateVariables, row); 69 | userObj['contentVariables'] = contentVariables; 70 | } 71 | 72 | if (isSchedulingEnabled && sendType === "simple") { 73 | const scheduledDate = event.scheduledDate 74 | userObj['scheduleType'] = 'fixed'; 75 | userObj['sendAt'] = scheduledDate; 76 | } 77 | 78 | if (isLinkShorteningEnabled && sendType === "simple") { 79 | userObj['shortenUrls'] = true; 80 | } 81 | Messages.push(userObj); 82 | }) 83 | 84 | if (isSchedulingEnabled && sendType === "broadcast") { 85 | const scheduledDate = event.scheduledDate 86 | messageData['scheduleType'] = 'fixed'; 87 | messageData['sendAt'] = scheduledDate 88 | } 89 | 90 | if (isLinkShorteningEnabled && sendType === "broadcast") { 91 | messageData['shortenUrls'] = true; 92 | } 93 | 94 | return { 95 | ...messageData, 96 | Messages 97 | } 98 | 99 | } 100 | 101 | exports.getCorrectIndex = (csvData, phoneNumberColumn, userNumber) => { 102 | 103 | if(userNumber.startsWith("whatsapp:") || userNumber.startsWith("messenger:")){ 104 | userNumber = userNumber.split(":")[1] 105 | } 106 | userNumber = normalizePhoneNumber(userNumber) 107 | const match = csvData.findIndex(r => { 108 | let csvDataUserNumber = normalizePhoneNumber(r[phoneNumberColumn]) 109 | return csvDataUserNumber === userNumber 110 | }); 111 | return match; 112 | } 113 | 114 | 115 | const buildContentVariables = (templateVariables, csvRow) => { 116 | let contentVariables = {}; 117 | 118 | for (let key in templateVariables) { 119 | if (templateVariables.hasOwnProperty(key)) { 120 | const csvColumnName = templateVariables[key]; 121 | contentVariables[key] = csvRow[csvColumnName] || ''; 122 | } 123 | } 124 | 125 | return JSON.stringify(contentVariables); 126 | }; -------------------------------------------------------------------------------- /frontend/src/Components/MessageBuilder/ContentTemplateRenderer/ContentTemplateRenderer.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Box from '@mui/material/Box'; 3 | import { Card, CardContent, Typography, Button, CardMedia, CardActions } from '@mui/material'; 4 | import TemplateVariablesInput from './TemplateVariablesInput'; 5 | 6 | const ContentTemplateRenderer = ({ template }) => { 7 | 8 | const renderCard = () => { 9 | 10 | const { actions, media, title, subtitle } = template.content['twilio/card'] ; 11 | return ( 12 | 13 | 14 | {media[0] && ( 15 | 21 | )} 22 | 23 | 24 | {title} 25 | 26 | 27 | {actions && actions.length > 0 && ( 28 | 29 | {actions.map((button, index) => ( 30 | 33 | ))} 34 | 35 | )} 36 | 37 | 38 | ); 39 | } 40 | 41 | const renderText = () => { 42 | const { body } = template.content['twilio/text'] 43 | return ( 44 | 45 | 46 | 47 | 48 | {body} 49 | 50 | 51 | 52 | 53 | ) 54 | 55 | } 56 | 57 | const renderButtons = (type) => { 58 | 59 | const { actions, body } = template.content[type] ; 60 | return ( 61 | 62 | 63 | 64 | 65 | {body} 66 | 67 | 68 | {actions && actions.length > 0 && ( 69 | 70 | {actions.map((button, index) => ( 71 | 74 | ))} 75 | 76 | )} 77 | 78 | 79 | ); 80 | } 81 | 82 | const renderList = () => { 83 | 84 | const { button, body } = template.content['twilio/list-picker'] ; 85 | return ( 86 | 87 | 88 | 89 | 90 | {body} 91 | 92 | 93 | 94 | 97 | 98 | 99 | 100 | 101 | ); 102 | } 103 | 104 | const renderMedia = () => { 105 | const { body, media } = template.content['twilio/media'] 106 | return ( 107 | 108 | 109 | 110 | 111 | {body} 112 | 113 | 114 | 115 | 116 | Media URL: {media} 117 | 118 | 119 | 120 | 121 | ) 122 | 123 | } 124 | 125 | const renderContent = () => { 126 | if ('twilio/card' in template.content) return (<>{renderCard("card")}); 127 | else if ('twilio/call-to-action' in template.content) return (<>{renderButtons("twilio/call-to-action")}); 128 | else if ('twilio/quick-reply' in template.content) return (<>{renderButtons("twilio/quick-reply")}); 129 | else if ('twilio/list-picker' in template.content) return (<>{renderList()}); 130 | else if ('twilio/text' in template.content)return (<>{renderText()}); 131 | else if ('twilio/media' in template.content)return (<>{renderMedia()}); 132 | }; 133 | 134 | return ( 135 | 136 | {renderContent()} 137 | {Object.keys(template.variables).length > 0 && } 138 | 139 | ); 140 | } 141 | 142 | export default ContentTemplateRenderer; 143 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | **Note:** This is the documentation for the updated version of the project that includes the latest Twilio APIs, like Content API and Broadcast API. If you are looking for the old version, please check the [old_version branch](https://github.com/evanTheTerribleWarrior/Twilio-Outbound-Messaging/tree/old_version). 2 | 3 | **Note 2:** The repo is still work in progress. This note will be removed once the repo is in a stable state across all functionalities. Currently working on Logs, CampaignTable mostly 4 | 5 | --- 6 | 7 | # Outbound Messaging App 8 | 9 | Easily set up and send out messaging campaigns using your Twilio account. 10 | 11 | ![Application screenshot](https://github.com/evanTheTerribleWarrior/Twilio-Outbound-Messaging/assets/111442118/c436f41e-15b7-45d7-9eff-da0c53ff9911) 12 | 13 | ## Key features 14 | 15 | ### Messaging features 16 | * Set custom messages or pre-created templates (via Twilio Content API) 17 | * Send messages via the standard Messaging API or Broadcast API 18 | * Messaging Features: Scheduling, Link Shortening 19 | * Check numbers before sending to identify malformed numbers or non-mobile numbers (via Twilio Lookup API) 20 | * Supports multiple messaging channels (SMS, Whatsapp, Facebook Messenger) 21 | * Get Updated Message statuses to understand the final state of messages 22 | 23 | ### App features 24 | * Load data from a CSV file 25 | * Add/Edit/Delete/Search data directly on the browser 26 | * Logs 27 | * Basic Graphs for visualising the total message statuses and errors 28 | * JWT authentication 29 | * Exponential Backoff for error 429 "Too Many Requests" 30 | * Redux store used to save important data across the application locally 31 | * Download the results in csv format or send them to your webhook endpoint 32 | 33 | ## Pre-requisites 34 | 1. Install the [Twilio CLI](https://www.twilio.com/docs/twilio-cli/quickstart#install-twilio-cli) 35 | 2. Install the [serverless toolkit](https://www.twilio.com/docs/labs/serverless-toolkit/getting-started) 36 | 37 | ## Setup 38 | 39 | - Clone the repository and `cd` into it: 40 | ```shell 41 | git clone https://github.com/evanTheTerribleWarrior/Twilio-Outbound-Messaging.git 42 | 43 | cd Twilio-Outbound-Messaging 44 | ``` 45 | 46 | - Go to `frontend` directory and run `npm install`: 47 | ```shell 48 | cd frontend 49 | npm install 50 | ``` 51 | 52 | - Go to `serverless` directory. Run `npm install` and then create .env file and add USERNAME, PASSWORD, JWT_SECRET that are used for authentication. Make them hard to guess if you deploy this! If you deploy locally, you need also to add the AUTH_TOKEN and ACCOUNT_SID with your Twilio credentials, but if you deploy remotely you DO NOT need them 53 | ```shell 54 | cd serverless 55 | npm install 56 | cp .env.example .env 57 | ``` 58 | 59 | ### Option 1: Build remote (Twilio account) 60 | - Run the `setup-remote.sh` script (if you use other shell, use the equivalent command): 61 | ```shell 62 | zsh setup-remote.sh 63 | # View your app at https://[my-runtime-url].twil.io/index.html 64 | ``` 65 | 66 | ### Option 2: Build local - same ports for backend and frontend 67 | - Run the `setup-local-same-port.sh` script (if you use other shell, use the equivalent command): 68 | ```shell 69 | zsh setup-local-same-port.sh 70 | # View your app at http://localhost:3002/index.html (or set the port you want) 71 | ``` 72 | 73 | ### Option 3: Run local - different ports for backend and frontend (easier for changing/testing code) 74 | - Run the `setup-local-diff-port.sh` script (if you use other shell, use the equivalent command): 75 | ```shell 76 | zsh setup-local-diff-port.sh 77 | ``` 78 | This will effectively use `npm run start` to start the frontend on the standard 3000 port and have it runninng in the background. 79 | But in `package.json` we added `proxy: http://localhost:3002/` so that all requests are proxied to the same 80 | port as the functions backend. This way we can use `credentials: 'same-origin'` when authenticating 81 | 82 | ## Test It! 83 | 84 | A sample .csv is included that you can load and play around. It has certain elements like malformed numbers, empty number fields etc in order for you to check and validate the app behaviour 85 | 86 | ## Considerations 87 | 88 | - This is not an official Twilio repository. 89 | - Currently there is no process that saves any data on your twilio account, other than creating logs and the local redux store. So this repository as it stands is for one-off campaigns/sending 90 | - As this is a personal work, updates will be published at non-standard intervals. You are of course free to take the code and shape as you wish 91 | 92 | ## TODO 93 | 94 | - Messenger via Content API templates gets stuck (works fine with normal custom message) 95 | - Scalability: since most of the updates of the rows happen UI-end, it gets more laggy as the csv files grow. 96 | 97 | ## Credits 98 | This repository is built upon the following: 99 | https://github.com/r-lego/CSV-to-SMS -------------------------------------------------------------------------------- /frontend/src/Components/FileLoader/FileLoader.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { useSelector, useDispatch } from 'react-redux'; 3 | import Button from '@mui/material/Button'; 4 | import FormControl from '@mui/material/FormControl'; 5 | import FormLabel from '@mui/material/FormLabel'; 6 | import Box from '@mui/material/Box'; 7 | import Papa from "papaparse"; 8 | import { v4 as uuidv4 } from 'uuid'; 9 | import { updateCSVState } from '../../Redux/slices/csvDataSlice'; 10 | import { CSVDATA_TYPES, COMMON } from '../../Utils/variables'; 11 | import { updateMessagingState } from '../../Redux/slices/messagingSlice'; 12 | import { updateActionState } from '../../Redux/slices/actionSlice'; 13 | import { updateSettingsState } from '../../Redux/slices/settingsSlice'; 14 | 15 | const FileLoader = (props) => { 16 | 17 | const [isSelected, setIsSelected] = useState(false); 18 | const [selectedFile, setSelectedFile] = useState(undefined); 19 | const [csvParsed, setCSVParsed] = useState(false); 20 | const [csvErrors, setCSVErrors] = useState(false); 21 | const [columnCount, setColumnCount] = useState(0); 22 | const [columnFields, setColumnFields] = useState(null); 23 | const [sendCompleted, setSendCompleted] = useState(false) 24 | const [checkCompleted, setCheckCompleted] = useState(false) 25 | const [error, setError] = useState(false) 26 | 27 | const csvData = useSelector((state) => state.csvDataStructure.csvData); 28 | const dispatch = useDispatch() 29 | 30 | const resetState = () => { 31 | dispatch(updateMessagingState({ 32 | type: COMMON.RESET_STATE, 33 | value: "" 34 | })) 35 | 36 | dispatch(updateActionState({ 37 | type: COMMON.RESET_STATE, 38 | value: "" 39 | })) 40 | 41 | 42 | dispatch(updateSettingsState({ 43 | type: COMMON.RESET_STATE, 44 | value: "" 45 | })) 46 | 47 | dispatch(updateCSVState({ 48 | type: COMMON.RESET_STATE, 49 | value: "" 50 | })) 51 | } 52 | 53 | useEffect(() => { 54 | 55 | if(selectedFile){ 56 | 57 | resetState() 58 | Papa.parse(selectedFile, { 59 | header: true, 60 | worker: true, 61 | skipEmptyLines: true, 62 | dynamicTyping: true, 63 | chunk: (results) => { 64 | let newCSVData = [] 65 | setColumnCount(results.meta.fields.length) 66 | setColumnFields(results.meta.fields) 67 | 68 | dispatch(updateCSVState({ 69 | type: CSVDATA_TYPES.CSV_COLUMN_FIELDS, 70 | value: results.meta.fields 71 | })) 72 | 73 | if (results.errors.length > 0) { 74 | setCSVParsed(false); 75 | setCSVErrors(true); 76 | setIsSelected(false); 77 | console.error('Errors:', results.errors); 78 | parser.abort(); 79 | return; 80 | } 81 | 82 | 83 | 84 | results.data.forEach(row => { 85 | const UniqueID = uuidv4(); 86 | 87 | let csvObj = {} 88 | csvObj["UniqueID"] = UniqueID 89 | for (const key in row) { 90 | csvObj[key] = String(row[key]); 91 | } 92 | 93 | newCSVData.push(csvObj); 94 | 95 | }); 96 | 97 | dispatch(updateCSVState({ 98 | type: CSVDATA_TYPES.UPDATE_DATA_CHUNK, 99 | value: newCSVData 100 | })) 101 | 102 | }, 103 | complete: () => { 104 | 105 | }, 106 | }); 107 | 108 | } 109 | 110 | }, [selectedFile]) 111 | 112 | function onFileChange(event) { 113 | if (event.target.files.length > 0) { 114 | 115 | setIsSelected(true) 116 | setSelectedFile(event.target.files[0]) 117 | setSendCompleted(false) 118 | setCheckCompleted(false) 119 | setError(false) 120 | } 121 | } 122 | 123 | function truncateString(str, num) { 124 | if (str.length > num) { 125 | return str.slice(0, num) + '...'; 126 | } else { 127 | return str; 128 | } 129 | } 130 | 131 | 132 | return( 133 | <> 134 | 135 | 145 | 146 | 147 | {csvParsed && csvData ? ( 148 | 149 | 150 | Total Rows Count: {csvData.length}
151 | Total Columns Count: {columnCount} 152 |
{" "} 153 |
154 | ) : ( 155 | "" 156 | )} 157 | 158 | {csvErrors ? ( 159 | 160 | 161 | Errors parsing the CSV file {selectedFile.name}.{" "} 162 | {" "} 163 | 164 | ) : ( 165 | "" 166 | )} 167 | 168 | ) 169 | } 170 | 171 | export default FileLoader; -------------------------------------------------------------------------------- /frontend/src/Components/MessageBuilder/MessageBuilder.js: -------------------------------------------------------------------------------- 1 | import React, {useState} from 'react'; 2 | import { TextField, Box, FormControl, Switch, FormControlLabel, IconButton, Alert, FormHelperText } from '@mui/material'; 3 | import TemplateOptions from './TemplateOptions/TemplateOptions'; 4 | import ContentTemplateRenderer from './ContentTemplateRenderer/ContentTemplateRenderer'; 5 | import ProTip from '../ProTip/ProTip'; 6 | import MessageTypeSelection from './MessageTypeSelection/MessageTypeSelection'; 7 | import { useSelector, useDispatch } from 'react-redux'; 8 | import HelpOutlineIcon from '@mui/icons-material/HelpOutline'; 9 | import { updateSettingsState } from '../../Redux/slices/settingsSlice'; 10 | import { updateMessagingState } from '../../Redux/slices/messagingSlice'; 11 | import { SETTINGS_TYPES, MESSAGING_TYPES } from '../../Utils/variables'; 12 | 13 | const MessageBuilder = () => { 14 | 15 | const messageTypeSelection = useSelector(state => state.messagingStructure.messageTypeSelection) 16 | const template = useSelector(state => state.messagingStructure.selectedTemplate) 17 | const msg = useSelector(state => state.messagingStructure.customMessage) 18 | const mediaSwitch = useSelector(state => state.settingsStructure.checkMediaURL) 19 | 20 | const dispatch = useDispatch() 21 | 22 | const [mediaAlertClicked, setMediaAlert] = useState(false); 23 | const [mediaURL, setMediaURL] = useState("") 24 | const [isValidUrl, setIsValidUrl] = useState(true); 25 | 26 | const handleMediaSwitch = (event) => { 27 | dispatch(updateSettingsState({ 28 | type: SETTINGS_TYPES.MEDIA_URL_SWITCH, 29 | value: event.target.checked 30 | })) 31 | } 32 | 33 | const handleSetMsg = (event) => { 34 | dispatch(updateMessagingState({ 35 | type: MESSAGING_TYPES.CUSTOM_MESSAGE, 36 | value: event.target.value 37 | })) 38 | } 39 | 40 | const handleSetMediaURL = (event) => { 41 | if (validateMediaURL(event.target.value)){ 42 | dispatch(updateMessagingState({ 43 | type: MESSAGING_TYPES.CUSTOM_MEDIA_URL, 44 | value: event.target.value 45 | })) 46 | setMediaURL(event.target.value) 47 | setIsValidUrl(true) 48 | } 49 | else{ 50 | setMediaURL(event.target.value) 51 | setIsValidUrl(false) 52 | } 53 | 54 | 55 | } 56 | 57 | const validateMediaURL = (url) => { 58 | const urlPattern = new RegExp('^(https?:\\/\\/)?' + 59 | '((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|' + 60 | '((\\d{1,3}\\.){3}\\d{1,3}))' + 61 | '(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*' + 62 | '(\\?[;&a-z\\d%_.~+=-]*)?' + 63 | '(\\#[-a-z\\d_]*)?$', 'i'); 64 | return !!urlPattern.test(url); 65 | }; 66 | 67 | 68 | 69 | return ( 70 | <> 71 | 72 | 73 | { 74 | messageTypeSelection === "Custom" && (<> 75 | 85 | 86 | 87 | 88 | 89 | } label="Send Media / MMS" /> 90 | setMediaAlert(mediaAlertClicked ? false : true)}> 91 | 92 | 93 | 94 | { mediaAlertClicked && 95 | ( 96 | If selected, you can set a publicly available Media URL in order to send a media file. You can read more about sending media files in SMS here and Whatsapp here 97 | ) 98 | } 99 | { 100 | mediaSwitch && ( 101 | <> 102 | 110 | 111 | {!isValidUrl && ( 112 | 113 | Please enter a valid and publicly accessible URL 114 | 115 | )} 116 | 117 | ) 118 | } 119 | ) 120 | } 121 | { 122 | messageTypeSelection === "Template" && 123 | } 124 | { template && } 125 | 126 | 127 | ) 128 | } 129 | 130 | export default MessageBuilder; -------------------------------------------------------------------------------- /frontend/src/Redux/slices/messagingSlice.js: -------------------------------------------------------------------------------- 1 | import {createSlice} from '@reduxjs/toolkit'; 2 | import { MESSAGING_TYPES, COMMON } from '../../Utils/variables'; 3 | 4 | const initialState = { 5 | channelSelection: "SMS", 6 | messageTypeSelection: "Custom", 7 | senderTypeSelection: "Single", 8 | selectedService: null, 9 | selectedSingleSender: null, 10 | selectedTemplate: null, 11 | templateVariables: {}, 12 | customMessage: "", 13 | customMediaURL: "", 14 | scheduledDate: "", 15 | sendResultsArray: [] 16 | }; 17 | 18 | const messagingSlice = createSlice({ 19 | name: 'messagingStructure', 20 | initialState, 21 | reducers: { 22 | updateMessagingState: (state, action) => { 23 | switch(action.payload.type){ 24 | case MESSAGING_TYPES.CHANNEL_SELECTION: 25 | state.channelSelection = action.payload.value 26 | break; 27 | case MESSAGING_TYPES.SELECTED_TEMPLATE: 28 | state.selectedTemplate = action.payload.value 29 | break; 30 | case MESSAGING_TYPES.SELECTED_SERVICE: 31 | state.selectedService = action.payload.value 32 | break; 33 | case MESSAGING_TYPES.SELECTED_SINGLE_SENDER: 34 | state.selectedSingleSender = action.payload.value 35 | break; 36 | case MESSAGING_TYPES.MESSAGE_TYPE_SELECTION: 37 | state.messageTypeSelection = action.payload.value 38 | break; 39 | case MESSAGING_TYPES.SENDER_TYPE_SELECTION: 40 | state.senderTypeSelection = action.payload.value 41 | break; 42 | case MESSAGING_TYPES.TEMPLATE_VARIABLES: 43 | state.templateVariables = action.payload.value 44 | break; 45 | case MESSAGING_TYPES.CUSTOM_MESSAGE: 46 | state.customMessage = action.payload.value 47 | break; 48 | case MESSAGING_TYPES.CUSTOM_MEDIA_URL: 49 | state.customMediaURL = action.payload.value 50 | break; 51 | case MESSAGING_TYPES.SCHEDULED_DATE: 52 | state.scheduledDate = action.payload.value 53 | break; 54 | case MESSAGING_TYPES.SEND_RESULTS_ARRAY: 55 | state.sendResultsArray = action.payload.value 56 | break; 57 | 58 | case MESSAGING_TYPES.UPDATE_DATA_CHUNK: 59 | state.sendResultsArray = state.sendResultsArray.concat(action.payload.value); 60 | break; 61 | 62 | case MESSAGING_TYPES.ADD_ROW: 63 | state.sendResultsArray.unshift(action.payload.value) 64 | break; 65 | 66 | case MESSAGING_TYPES.DELETE_ROW: 67 | state.sendResultsArray = state.sendResultsArray.filter((row, j) => j !== action.payload.value) 68 | break; 69 | 70 | case MESSAGING_TYPES.UPDATE_SEND_RESULTS_ARRAY_AFTER_SEND: 71 | const { messageReceiptsArray, failedReceiptsArray } = action.payload.value; 72 | 73 | if(messageReceiptsArray.length > 0) { 74 | messageReceiptsArray.map(row => { 75 | let result = state.sendResultsArray.findIndex(r => r.csvRowID === row.csvRowID); 76 | if(result !== -1){ 77 | state.sendResultsArray[result].messageSid = row.messageSid 78 | return; 79 | } 80 | else { 81 | let sendResultsObj = {} 82 | sendResultsObj["messageSid"] = row.messageSid 83 | sendResultsObj["error"] = {} 84 | sendResultsObj["error"]["errorCode"] = "" 85 | sendResultsObj["error"]["errorMessage"] = "" 86 | sendResultsObj["error"]["errorLink"] = "" 87 | sendResultsObj["status"] = "" 88 | sendResultsObj["csvRowID"] = row.csvRowID 89 | 90 | state.sendResultsArray.push(sendResultsObj) 91 | sendResultsObj = {} 92 | return; 93 | 94 | } 95 | }) 96 | } 97 | 98 | if(failedReceiptsArray.length > 0) { 99 | failedReceiptsArray.map(row => { 100 | let result = state.sendResultsArray.findIndex(r => r.csvRowID === row.csvRowID); 101 | if(result !== -1){ 102 | state.sendResultsArray[result]["error"].errorCode = row.errorCode 103 | state.sendResultsArray[result]["error"].errorMessage = row.errorMessage 104 | return; 105 | } 106 | else { 107 | let sendResultsObj = {} 108 | sendResultsObj["messageSid"] = "" 109 | sendResultsObj["error"] = {} 110 | sendResultsObj["error"]["errorCode"] = row.errorCode 111 | sendResultsObj["error"]["errorMessage"] = row.errorMessage 112 | sendResultsObj["error"]["errorLink"] = "" 113 | sendResultsObj["status"] = row.status 114 | sendResultsObj["csvRowID"] = row.csvRowID 115 | 116 | state.sendResultsArray.push(sendResultsObj) 117 | sendResultsObj = {} 118 | return; 119 | } 120 | }) 121 | } 122 | 123 | break; 124 | 125 | case MESSAGING_TYPES.UPDATE_STATUS_CHUNK: 126 | if(action.payload.value.length > 0) { 127 | action.payload.value.map(row => { 128 | let result = state.sendResultsArray.findIndex(r => r.csvRowID === row.csvRowID); 129 | if(result !== -1){ 130 | state.sendResultsArray[result].status = row.status; 131 | state.sendResultsArray[result].error.errorCode = row.error.errorCode 132 | state.sendResultsArray[result].error.errorMessage = row.error.errorMessage 133 | return; 134 | } 135 | return; 136 | }) 137 | } 138 | break; 139 | case COMMON.RESET_STATE: 140 | return initialState 141 | } 142 | } 143 | }, 144 | }); 145 | 146 | export const { updateMessagingState} = messagingSlice.actions; 147 | export default messagingSlice.reducer; -------------------------------------------------------------------------------- /frontend/src/Components/MessageBuilder/TemplateOptions/TemplateOptions.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { useSelector, useDispatch } from 'react-redux'; 3 | import { updateMessagingState } from '../../../Redux/slices/messagingSlice'; 4 | import { MESSAGING_TYPES } from '../../../Utils/variables'; 5 | import Box from "@mui/material/Box"; 6 | import FormControl from "@mui/material/FormControl"; 7 | import FormGroup from '@mui/material/FormGroup'; 8 | import CircularProgress from '@mui/material/CircularProgress'; 9 | import Typography from '@mui/material/Typography'; 10 | import Select from '@mui/material/Select'; 11 | import MenuItem from '@mui/material/MenuItem'; 12 | import InputLabel from '@mui/material/InputLabel'; 13 | import Grid from '@mui/material/Grid'; 14 | import Divider from '@mui/material/Divider'; 15 | import Menu from '@mui/material/Menu'; 16 | import { fetchTemplates } from '../../../Utils/functions'; 17 | import { styled } from '@mui/system'; 18 | import AddIcon from '@mui/icons-material/Add'; 19 | import { CONTENT_TYPES } from '../../../Utils/variables'; 20 | 21 | const StyledMenuItem = styled(MenuItem)({ 22 | whiteSpace: 'normal', 23 | }); 24 | 25 | const LoadMoreMenuItem = styled(MenuItem)({ 26 | display: 'flex', 27 | justifyContent: 'center', 28 | color: '#007BFF', 29 | }); 30 | 31 | const TemplateOptions = (props) => { 32 | const dispatch = useDispatch(); 33 | const [templatesArray, setTemplatesArray] = useState([]); 34 | const [nextPageUrl, setNextPageUrl] = useState(""); 35 | const [selectedTemplate, setSelectedTemplate] = useState(""); 36 | const [showLoadingIcon, setShowLoadingIcon] = useState(false); 37 | const [showLoadingMoreIcon, setShowLoadingMoreIcon] = useState(false); 38 | const [anchorEl, setAnchorEl] = useState(null); 39 | 40 | 41 | const messageTypeSelection = useSelector(state => state.messagingStructure.messageTypeSelection); 42 | const channelSelection = useSelector(state => state.messagingStructure.channelSelection); 43 | 44 | useEffect(() => { 45 | if (messageTypeSelection === "Template") { 46 | fetchPaginatedTemplates(nextPageUrl, true); 47 | } else { 48 | cleanValues(); 49 | } 50 | }, [messageTypeSelection, channelSelection]); 51 | 52 | const handleSelectedTemplate = (templateSid) => { 53 | setSelectedTemplate(templateSid); 54 | const template = templatesArray.find(template => template.sid === templateSid); 55 | dispatch(updateMessagingState({ 56 | type: MESSAGING_TYPES.SELECTED_TEMPLATE, 57 | value: template 58 | })); 59 | handleClose(); 60 | }; 61 | 62 | const fetchPaginatedTemplates = async (url, isInitialFetch = false, keepOpen = false, isLoadMore = false) => { 63 | if (!url && !isInitialFetch) return; 64 | if (isLoadMore) { 65 | setShowLoadingMoreIcon(true); 66 | } else { 67 | setShowLoadingIcon(true); 68 | } 69 | try { 70 | const data = await fetchTemplates(channelSelection, url); 71 | setTemplatesArray((prevTemplates) => [...prevTemplates, ...data.data.templates_array]); 72 | setNextPageUrl(data.data.nextPageUrl); 73 | if (keepOpen) { 74 | setAnchorEl(anchorEl); 75 | } 76 | } catch (error) { 77 | console.error('Error fetching templates:', error); 78 | } finally { 79 | setShowLoadingIcon(false); 80 | setShowLoadingMoreIcon(false); 81 | } 82 | }; 83 | 84 | const handleLoadMore = (event) => { 85 | event.stopPropagation(); 86 | fetchPaginatedTemplates(nextPageUrl, false, true, true); 87 | }; 88 | 89 | const handleOpen = (event) => { 90 | setAnchorEl(event.currentTarget); 91 | }; 92 | 93 | const handleClose = () => { 94 | setAnchorEl(null); 95 | }; 96 | 97 | const cleanValues = () => { 98 | setTemplatesArray([]); 99 | setNextPageUrl(""); 100 | setSelectedTemplate(""); 101 | setShowLoadingIcon(false); 102 | dispatch(updateMessagingState({ 103 | type: MESSAGING_TYPES.SELECTED_TEMPLATE, 104 | value: null 105 | })); 106 | }; 107 | 108 | const menuItems = templatesArray.flatMap((template, index) => [ 109 | handleSelectedTemplate(template.sid)}> 110 | 111 | 112 | Name: {template.name} 113 | 114 | 115 | Language: {template.language} 116 | 117 | 118 | Type: {CONTENT_TYPES[Object.keys(template.content)[0]]} 119 | 120 | 121 | , 122 | index < templatesArray.length - 1 && 123 | ]).filter(Boolean); 124 | 125 | return ( 126 | 127 | 128 | {showLoadingIcon && ( 129 | <> 130 | 131 | 132 | Loading Templates... 133 | 134 | 135 | )} 136 | 137 | Select Template 138 | 153 | 161 | {menuItems} 162 | {nextPageUrl && ( 163 | 164 | {showLoadingMoreIcon ? 'Loading...' : ( 165 | <> 166 | Load More 167 | 168 | )} 169 | 170 | )} 171 | 172 | 173 | 174 | 175 | ); 176 | }; 177 | 178 | export default TemplateOptions; 179 | -------------------------------------------------------------------------------- /frontend/src/Components/LogManager/Logs.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { useSelector, useDispatch } from 'react-redux' 3 | import Box from "@mui/material/Box"; 4 | import Typography from '@mui/material/Typography'; 5 | import Tabs from '@mui/material/Tabs'; 6 | import Tab from '@mui/material/Tab'; 7 | import { updateActionState } from '../../Redux/slices/actionSlice'; 8 | import { ACTION_TYPES } from '../../Utils/variables'; 9 | import TabPanel from './TabPanel/TabPanel'; 10 | import LogPanel from './LogPanel/LogPanel'; 11 | import GraphPanel from './GraphPanel/GraphPanel'; 12 | import ProgressBar from './ProgressBar/ProgressBar'; 13 | 14 | const Logs = () => { 15 | const dispatch = useDispatch() 16 | const currentTotalLogs = useSelector(state => state.actionStructure.totalLogs) 17 | const lookupDataForLogs = useSelector(state => state.actionStructure.lookupDataForLogs) 18 | const getStatusDataForLogs = useSelector(state => state.actionStructure.getStatusDataForLogs) 19 | const emptyNumbersForLogs = useSelector(state => state.actionStructure.emptyNumbersForLogs) 20 | const duplicateNumberDataForLogs = useSelector(state => state.actionStructure.duplicateNumberDataForLogs) 21 | const sendDataForLogs = useSelector(state => state.actionStructure.sendDataForLogs) 22 | const enableGraph = useSelector(state => state.settingsStructure.enableGraph) 23 | const sendResultsArray = useSelector(state => state.messagingStructure.sendResultsArray) 24 | const channelSelection = useSelector(state => state.messagingStructure.channelSelection) 25 | const checkLineType = useSelector(state => state.settingsStructure.checkLineType) 26 | const csvData = useSelector(state => state.csvDataStructure.csvData) 27 | 28 | useEffect(() => { 29 | updateLogs(lookupDataForLogs) 30 | }, [lookupDataForLogs]) 31 | 32 | useEffect(() => { 33 | updateLogs(sendDataForLogs) 34 | }, [sendDataForLogs]) 35 | 36 | useEffect(() => { 37 | updateLogs(getStatusDataForLogs) 38 | }, [getStatusDataForLogs]) 39 | 40 | useEffect(() => { 41 | updateLogs(emptyNumbersForLogs) 42 | }, [emptyNumbersForLogs]) 43 | 44 | useEffect(() => { 45 | updateLogs(duplicateNumberDataForLogs) 46 | }, [duplicateNumberDataForLogs]) 47 | 48 | const [tabValue, setTabValue] = useState(0) 49 | 50 | function handleChange(e, newValue) { 51 | setTabValue(newValue) 52 | } 53 | 54 | const clearLogs = () => { 55 | dispatch(updateActionState({ 56 | type: ACTION_TYPES.TOTAL_LOGS, 57 | value: "" 58 | })) 59 | } 60 | 61 | const updateLogs = (data) => { 62 | 63 | const { source } = data; 64 | 65 | let date = new Date(Date.now()); 66 | date.setUTCSeconds(0); 67 | let checkLog = "" 68 | 69 | if(source === "lookup"){ 70 | 71 | checkLog = `Date: ${date.toString()}` 72 | const { checkedSuccess, checkedErrors, invalidNumbers_ID, nonmobileNumbers_ID} = data; 73 | 74 | checkLog += `\nAction: Checked Numbers` 75 | checkLog += "\nTotal numbers checked: " + checkedSuccess 76 | if(checkedErrors > 0) checkLog += "\nTotal numbers failed to be checked: " + checkedErrors 77 | invalidNumbers_ID.length > 0 ? checkLog += "\n!!! Total invalid numbers found: " + invalidNumbers_ID.length + "\n==> Check rows: " + JSON.stringify(invalidNumbers_ID) + "\n==> Please fix these errors to be able to send to this number" : checkLog += "\nNo invalid numbers found" 78 | if(checkLineType){ 79 | nonmobileNumbers_ID.length > 0 ? checkLog += "\n!!! Total non-mobile numbers found: " + nonmobileNumbers_ID.length + "\n==> Check rows: " + JSON.stringify(nonmobileNumbers_ID) : checkLog += "\nNo non-mobile numbers found" 80 | } 81 | 82 | } 83 | 84 | else if(source === "send"){ 85 | checkLog = `Date: ${date.toString()}` 86 | checkLog += `\nAction: Send Messages` 87 | 88 | const { sentSuccess, sentErrors, messageReceiptsArray, failedReceiptsArray} = data; 89 | checkLog += `\nNumber of messages created successfully: ${sentSuccess}` 90 | checkLog += `\nNumber of messages created with errors: ${sentErrors}` 91 | 92 | let showErrorsArray = failedReceiptsArray.map((element) => { 93 | let csvDataIndex = csvData.findIndex(r => element.csvRowID === r.UniqueID) 94 | return csvDataIndex 95 | }) 96 | checkLog += `\nCheck rows: ${JSON.stringify(showErrorsArray.sort((a, b) => a - b))}` 97 | } 98 | 99 | else if(source=== "emptyNumbersError"){ 100 | checkLog = `Date: ${date.toString()}` 101 | checkLog += `\n!!! Error: Numbers can't be empty ==> Check rows: ${JSON.stringify(emptyNumbersForLogs.emptyNumbersArray)}` 102 | } 103 | 104 | else if(source=== "duplicateNumbersError"){ 105 | checkLog = `Date: ${date.toString()}` 106 | checkLog += `\n!!! Error: You have duplicate phone numbers ==> Check rows: ${JSON.stringify(duplicateNumberDataForLogs.duplicateNumbers)}` 107 | } 108 | 109 | else if(source=== "getMessageStatus"){ 110 | checkLog = `Date: ${date.toString()}` 111 | checkLog += '\nCurrent Messages Status (row numbers shown):'; 112 | let delivered = [] 113 | let failed = [] 114 | let in_progress = [] 115 | let read = [] 116 | for(let i = 0; i < sendResultsArray.length; i++){ 117 | if(sendResultsArray[i].status === "delivered")delivered.push(i) 118 | else if (sendResultsArray[i].status === "read")read.push(i) 119 | else if(sendResultsArray[i].status === "undelivered")failed.push(i) 120 | else if(sendResultsArray[i].status === "failed")failed.push(i) 121 | else in_progress.push(i) 122 | } 123 | checkLog += "\n===> Delivered: " + JSON.stringify(delivered) 124 | if(channelSelection === "Whatsapp")checkLog += "\n===> Read: " + JSON.stringify(read) 125 | checkLog += "\n===> Failed/Undelivered: " + JSON.stringify(failed) 126 | checkLog += "\n===> In-Progress: " + JSON.stringify(in_progress) 127 | } 128 | 129 | const newTotalLogs = checkLog.length > 0 ? checkLog + "\n\n" + currentTotalLogs: currentTotalLogs 130 | 131 | dispatch(updateActionState({ 132 | type: ACTION_TYPES.TOTAL_LOGS, 133 | value: newTotalLogs 134 | })) 135 | return; 136 | } 137 | 138 | 139 | 140 | return( 141 | <> 142 | 143 | Logs 144 | 145 | 146 | 147 | 148 | 149 | { 150 | enableGraph ? 151 | () 152 | : 153 | () 154 | } 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | ) 168 | 169 | } 170 | 171 | 172 | export default Logs -------------------------------------------------------------------------------- /frontend/src/Utils/functions.js: -------------------------------------------------------------------------------- 1 | import { API_URLS } from "./variables"; 2 | 3 | export async function fetchTemplates (channelSelection, nextPageUrl) { 4 | try { 5 | const url = API_URLS.PROTOCOL + API_URLS.BASE_URL + API_URLS.FETCH_TEMPLATES 6 | const response = await fetch(url, { 7 | method: 'POST', 8 | body: JSON.stringify({channelSelection: channelSelection, nextPageUrl: nextPageUrl}), 9 | headers: { 10 | 'Content-Type': 'application/json' 11 | }, 12 | credentials: 'same-origin' 13 | }); 14 | const data = await response.json(); 15 | return data; 16 | } catch (error) { 17 | throw new Error(`Failed to fetch templates: ${error}`); 18 | } 19 | }; 20 | 21 | export async function fetchServices () { 22 | try { 23 | const url = API_URLS.PROTOCOL + API_URLS.BASE_URL + API_URLS.FETCH_SERVICES 24 | const response = await fetch(url, { 25 | method: 'POST', 26 | headers: { 27 | 'Content-Type': 'application/json' 28 | }, 29 | credentials: 'same-origin' 30 | }); 31 | const data = await response.json(); 32 | return data; 33 | } catch (error) { 34 | throw new Error(`Failed to fetch services: ${error}`); 35 | } 36 | }; 37 | 38 | 39 | export async function authenticateUser (credentials) { 40 | try { 41 | const url = API_URLS.PROTOCOL + API_URLS.BASE_URL + API_URLS.AUTHENTICATE 42 | const response = await fetch(url, { 43 | method: 'POST', 44 | body: JSON.stringify(credentials), 45 | headers: { 46 | 'Content-Type': 'application/json' 47 | } 48 | }); 49 | const data = await response.json(); 50 | return data; 51 | } catch (error) { 52 | throw new Error(`Failed to get token: ${error}`); 53 | } 54 | } 55 | 56 | export async function checkAuthentication () { 57 | try { 58 | const url = API_URLS.PROTOCOL + API_URLS.BASE_URL + API_URLS.CHECK_USER_AUTHENTICATED 59 | const response = await fetch(url, { 60 | method: 'POST', 61 | headers: { 62 | 'Content-Type': 'application/json' 63 | }, 64 | credentials: 'same-origin' 65 | }); 66 | const data = await response.json(); 67 | return data; 68 | } catch (error) { 69 | throw new Error(`Failed to check authentication: ${error}`); 70 | } 71 | } 72 | 73 | export async function removeJWT () { 74 | try { 75 | const url = API_URLS.PROTOCOL + API_URLS.BASE_URL + API_URLS.REMOVE_TOKEN 76 | const response = await fetch(url, { 77 | method: 'POST', 78 | headers: { 79 | 'Content-Type': 'application/json' 80 | }, 81 | credentials: 'same-origin' 82 | }); 83 | const data = await response.json(); 84 | return data; 85 | } catch (error) { 86 | throw new Error(`Failed to remove cookie: ${error}`); 87 | } 88 | } 89 | 90 | export async function sendMessages(sendData, sendType) { 91 | 92 | try { 93 | const endpoint = sendType === "broadcast" ? API_URLS.SEND_BROADCAST_API : API_URLS.SEND_SMS_API 94 | const url = API_URLS.PROTOCOL + API_URLS.BASE_URL + endpoint 95 | const response = await fetch(url, { 96 | method: 'POST', 97 | body: JSON.stringify(sendData), 98 | headers: { 99 | 'Content-Type': 'application/json' 100 | }, 101 | credentials: 'same-origin' 102 | }); 103 | const data = await response.json(); 104 | return data.data; 105 | } catch (error) { 106 | throw new Error(`sendMessages failed with error message: ${error}`); 107 | } 108 | } 109 | 110 | export async function checkNumbers(checkData) { 111 | try { 112 | const url = API_URLS.PROTOCOL + API_URLS.BASE_URL + API_URLS.CHECK_NUMBERS 113 | const response = await fetch(url, { 114 | method: 'POST', 115 | body: JSON.stringify(checkData), 116 | headers: { 117 | 'Content-Type': 'application/json' 118 | }, 119 | credentials: 'same-origin' 120 | }); 121 | const data = await response.json(); 122 | return data.data; 123 | } catch (error) { 124 | throw new Error(`checkNumbers failed with error message: ${error}`); 125 | } 126 | } 127 | 128 | export async function getMessageStatus(getData) { 129 | try { 130 | const url = API_URLS.PROTOCOL + API_URLS.BASE_URL + API_URLS.GET_MESSAGE_STATUS 131 | const response = await fetch(url, { 132 | method: 'POST', 133 | body: JSON.stringify(getData), 134 | headers: { 135 | 'Content-Type': 'application/json' 136 | }, 137 | credentials: 'same-origin' 138 | }); 139 | const data = await response.json(); 140 | return data.data; 141 | } catch (error) { 142 | throw new Error(`getMessageStatus failed with error message: ${error}`); 143 | } 144 | } 145 | 146 | export const chunkArray = (array, size) => { 147 | const chunkedArr = []; 148 | for (let i = 0; i < array.length; i += size) { 149 | const chunkData = array.slice(i, i + size); 150 | chunkedArr.push({ startIndex: i, chunkData }); 151 | } 152 | return chunkedArr; 153 | } 154 | 155 | export const processChunksInBatches = async (chunks, processChunk, limit) => { 156 | let results = []; 157 | let activeRequests = []; 158 | 159 | for (let chunk of chunks) { 160 | const request = processChunk(chunk).finally(() => { 161 | activeRequests = activeRequests.filter(req => req !== request); 162 | }); 163 | 164 | activeRequests.push(request); 165 | 166 | if (activeRequests.length >= limit) { 167 | await Promise.race(activeRequests); 168 | } 169 | results.push(request); 170 | } 171 | 172 | return Promise.allSettled(results); 173 | } 174 | 175 | export const normalizePhoneNumber = (phoneNumber) => { 176 | let cleanedNumber = phoneNumber.replace(/[^\d+]/g, ''); 177 | if (!cleanedNumber.startsWith('+')) { 178 | cleanedNumber = '+' + cleanedNumber; 179 | } 180 | return cleanedNumber; 181 | } 182 | 183 | export const findDuplicatePhoneIndices = (csvData, phoneNumberColumn) => { 184 | const phoneIndices = new Map(); 185 | const duplicates = []; 186 | 187 | csvData.forEach((item, index) => { 188 | const normalizedPhone = normalizePhoneNumber(item[phoneNumberColumn]); 189 | if (phoneIndices.has(normalizedPhone)) { 190 | duplicates.push(index); 191 | duplicates.push(...phoneIndices.get(normalizedPhone)); 192 | } else { 193 | phoneIndices.set(normalizedPhone, [index]); 194 | } 195 | }); 196 | 197 | return Array.from(new Set(duplicates)).sort((a, b) => a - b); 198 | } 199 | 200 | export const convertToCSV = (arr) => { 201 | const array = [Object.keys(arr[0])].concat(arr); 202 | return array.map(it => { 203 | return Object.values(it).toString(); 204 | }).join('\n'); 205 | } 206 | 207 | export const downloadCSV = (csvContent, filename) => { 208 | const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); 209 | const link = document.createElement('a'); 210 | link.href = URL.createObjectURL(blob); 211 | link.download = filename; 212 | link.click(); 213 | } 214 | 215 | export const isVadidObject = (obj) => { 216 | return obj !== null && typeof obj === 'object' && Object.keys(obj).length > 0; 217 | } -------------------------------------------------------------------------------- /frontend/src/Components/CheckAndSend/CheckAndSend.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { useSelector, useDispatch} from 'react-redux' 3 | import { Button, Stack, Box, FormControlLabel, Switch, Alert, IconButton, AlertTitle } from '@mui/material'; 4 | import HelpOutlineIcon from '@mui/icons-material/HelpOutline'; 5 | import ProTip from '../ProTip/ProTip'; 6 | import { ACTION_TYPES, SETTINGS_TYPES, MESSAGING_TYPES } from '../../Utils/variables'; 7 | import { checkNumbers, chunkArray, processChunksInBatches, sendMessages, findDuplicatePhoneIndices } from '../../Utils/functions'; 8 | import { updateActionState } from '../../Redux/slices/actionSlice'; 9 | import { updateSettingsState } from '../../Redux/slices/settingsSlice'; 10 | import { updateMessagingState } from '../../Redux/slices/messagingSlice'; 11 | import { expbackoff } from '../../exponential-backoff'; 12 | 13 | 14 | const CheckAndSend = () => { 15 | 16 | const protipmessage = "It is advisable to check the numbers first to ensure they have a valid structure" 17 | 18 | const dispatch = useDispatch() 19 | const csvData = useSelector(state => state.csvDataStructure.csvData); 20 | const phoneNumberColumn = useSelector(state => state.csvDataStructure.csvSelectedColumn) 21 | const messagingStructure = useSelector(state => state.messagingStructure) 22 | const broadcastSwitch = useSelector(state => state.settingsStructure.checkBroadcastAPI) 23 | const lineTypeSwitch = useSelector(state => state.settingsStructure.checkLineType) 24 | const limits = useSelector(state => state.settingsStructure.limits) 25 | const checkScheduleMessages = useSelector(state => state.settingsStructure.checkScheduleMessages) 26 | const scheduledDate = useSelector(state => state.messagingStructure.scheduledDate) 27 | const sendResultsArray = useSelector(state => state.messagingStructure.sendResultsArray) 28 | const checkLinkShortening = useSelector(state => state.settingsStructure.checkLinkShortening) 29 | 30 | const [broadcastAlertClicked, setBroadcastAlert] = useState(false); 31 | const [lineTypeAlertClicked, setLineTypeAlert] = useState(false); 32 | const [emptyNumbersArray, setEmptyNumbersArray] = useState([]); 33 | 34 | const hasEmptyNumbers = () => { 35 | let empty = [] 36 | for(let i = 0; i < csvData.length; i++){ 37 | if(csvData[i][phoneNumberColumn].length === 0){ 38 | empty.push(i) 39 | setEmptyNumbersArray(empty) 40 | } 41 | } 42 | if(empty.length > 0){ 43 | const emptyNumberDataForLogs = { 44 | emptyNumbersArray: empty, 45 | source: "emptyNumbersError" 46 | } 47 | dispatch(updateActionState({ 48 | type: ACTION_TYPES.EMPTY_NUMBERS_FOR_LOGS, 49 | value: emptyNumberDataForLogs 50 | })) 51 | return true; 52 | } 53 | else { 54 | dispatch(updateActionState({ 55 | type: ACTION_TYPES.EMPTY_NUMBERS_FOR_LOGS, 56 | value: {} 57 | })) 58 | return false; 59 | } 60 | } 61 | 62 | const handleBroadcastSwitch = (event) => { 63 | dispatch(updateSettingsState({ 64 | type: SETTINGS_TYPES.BROADCAST_SWITCH, 65 | value: event.target.checked 66 | })) 67 | } 68 | 69 | const handleLineTypeSwitch = (event) => { 70 | dispatch(updateSettingsState({ 71 | type: SETTINGS_TYPES.LINE_TYPE_SWITCH, 72 | value: event.target.checked 73 | })) 74 | } 75 | 76 | const hasDuplicates = () => { 77 | let duplicateNumbers = findDuplicatePhoneIndices(csvData, phoneNumberColumn) 78 | if (duplicateNumbers.length > 0) { 79 | const duplicateNumberDataForLogs = { 80 | duplicateNumbers: duplicateNumbers, 81 | source: "duplicateNumbersError" 82 | } 83 | dispatch(updateActionState({ 84 | type: ACTION_TYPES.DUPLICATE_NUMBERS_FOR_LOGS, 85 | value: duplicateNumberDataForLogs 86 | })) 87 | return true; 88 | } 89 | else{ 90 | dispatch(updateActionState({ 91 | type: ACTION_TYPES.DUPLICATE_NUMBERS_FOR_LOGS, 92 | value: {} 93 | })) 94 | return false; 95 | } 96 | 97 | } 98 | 99 | const updateProgressBar = (newValue, totalData) => { 100 | return dispatch(updateActionState({ 101 | type: ACTION_TYPES.PROGRESS_BAR_COUNT, 102 | value: { 103 | newValue: newValue, 104 | totalData: totalData 105 | } 106 | })) 107 | } 108 | 109 | 110 | 111 | const handleCheckNumbers = async () => { 112 | 113 | if (hasEmptyNumbers()) return; 114 | if (hasDuplicates()) return; 115 | 116 | updateProgressBar(0, csvData.length); 117 | 118 | const startTime = new Date(); 119 | const chunkSize = limits.lookupChunkSize; 120 | const chunks = chunkArray(csvData, chunkSize); 121 | 122 | const processChunk = async (chunk) => { 123 | 124 | const data = { 125 | csvData: chunk.chunkData, 126 | phoneNumberColumn: phoneNumberColumn, 127 | startIndex: chunk.startIndex, 128 | checkLineType: lineTypeSwitch 129 | } 130 | updateProgressBar(chunkSize, csvData.length) 131 | return expbackoff(async () => { 132 | return checkNumbers(data); 133 | }) 134 | } 135 | 136 | const results = await processChunksInBatches(chunks, processChunk, limits.browserConcurrency); 137 | 138 | let checkedSuccess = 0; 139 | let checkedErrors = 0; 140 | let nonmobileNumbers_ID = [] 141 | let invalidNumbers_ID = [] 142 | let nonmobileNumbers = [] 143 | let invalidNumbers = [] 144 | 145 | results.forEach((r,index) => { 146 | if (r.status === 'fulfilled') { 147 | checkedSuccess += r.value.checkedSuccess; 148 | checkedErrors += r.value.checkedErrors; 149 | invalidNumbers_ID = invalidNumbers_ID.concat(r.value.invalidNumbers_ID) 150 | nonmobileNumbers_ID = nonmobileNumbers_ID.concat(r.value.nonmobileNumbers_ID) 151 | invalidNumbers = invalidNumbers.concat(r.value.invalidNumbers) 152 | nonmobileNumbers = nonmobileNumbers.concat(r.value.nonmobileNumbers) 153 | 154 | } else { 155 | console.log(`Promise no ${index} rejected with reason: ${r.reason}`) 156 | } 157 | }); 158 | 159 | const lookupDataForLogs = { 160 | checkedSuccess, 161 | checkedErrors, 162 | invalidNumbers_ID, 163 | nonmobileNumbers_ID, 164 | invalidNumbers, 165 | nonmobileNumbers, 166 | source: "lookup" 167 | } 168 | 169 | dispatch(updateActionState({ 170 | type: ACTION_TYPES.LOOKUP_DATA_FOR_LOGS, 171 | value: lookupDataForLogs 172 | })) 173 | 174 | const endTime = new Date(); 175 | const timeTaken = (endTime - startTime) / 1000; 176 | 177 | console.log(`The whole thing for ${csvData.length} rows took ${timeTaken}`) 178 | 179 | } 180 | 181 | const handleSendMessages = async () => { 182 | if (hasEmptyNumbers()) return; 183 | if (hasDuplicates()) return; 184 | updateProgressBar(0, csvData.length) 185 | 186 | const startTime = new Date(); 187 | const chunkSize = broadcastSwitch ? limits.broadcastChunkSize : limits.standardAPIChunkSize; 188 | const chunks = chunkArray(csvData, chunkSize); 189 | 190 | const processChunk = async (chunk) => { 191 | 192 | let filteredChunkData = [] 193 | if(sendResultsArray.length > 0){ 194 | filteredChunkData = chunk.chunkData.filter(chunkItem => { 195 | const found = sendResultsArray.find(r => r.csvRowID === chunkItem.UniqueID); 196 | return !(found && found.status === 'delivered'); 197 | }); 198 | } 199 | 200 | const data = { 201 | csvData: filteredChunkData.length > 0 ? filteredChunkData: chunk.chunkData, 202 | startIndex: chunk.startIndex, 203 | phoneNumberColumn: phoneNumberColumn, 204 | ...(checkScheduleMessages ? { scheduledDate : scheduledDate, isSchedulingEnabled: true } : { isSchedulingEnabled: false}), 205 | ...messagingStructure, 206 | isLinkShorteningEnabled: checkLinkShortening 207 | } 208 | 209 | updateProgressBar(chunkSize, csvData.length) 210 | return expbackoff(async () => { 211 | return sendMessages(data, broadcastSwitch ? "broadcast" : "standard"); 212 | }) 213 | } 214 | 215 | const results = await processChunksInBatches(chunks, processChunk, limits.browserConcurrency); 216 | 217 | let sentSuccess = 0; 218 | let sentErrors = 0; 219 | let messageReceiptsArray = []; 220 | let failedReceiptsArray = []; 221 | 222 | results.forEach((r,index) => { 223 | if (r.status === 'fulfilled') { 224 | sentSuccess += r.value.sentSuccess; 225 | sentErrors += r.value.sentErrors; 226 | messageReceiptsArray = messageReceiptsArray.concat(r.value.messageReceiptsArray) 227 | failedReceiptsArray = failedReceiptsArray.concat(r.value.failedReceiptsArray) 228 | 229 | } else { 230 | console.log(`Promise no ${index} rejected with reason: ${r.reason}`) 231 | } 232 | }); 233 | 234 | const resultsForState = { 235 | messageReceiptsArray, 236 | failedReceiptsArray 237 | } 238 | 239 | dispatch(updateMessagingState({ 240 | type: MESSAGING_TYPES.UPDATE_SEND_RESULTS_ARRAY_AFTER_SEND, 241 | value: resultsForState 242 | })) 243 | 244 | const sendDataForLogs = { 245 | sentSuccess, 246 | sentErrors, 247 | messageReceiptsArray, 248 | failedReceiptsArray, 249 | source: "send" 250 | } 251 | 252 | dispatch(updateActionState({ 253 | type: ACTION_TYPES.SEND_DATA_FOR_LOGS, 254 | value: sendDataForLogs 255 | })) 256 | 257 | const endTime = new Date(); 258 | const timeTaken = (endTime - startTime) / 1000; 259 | 260 | console.log(`The whole thing for ${messageReceiptsArray.length + failedReceiptsArray.length} rows took ${timeTaken}`) 261 | 262 | } 263 | 264 | 265 | return ( 266 | <> 267 | 268 | } label="Line Type Intelligence" /> 269 | setLineTypeAlert(lineTypeAlertClicked ? false : true)}> 270 | 271 | 272 | } label="Broadcast Messages" /> 273 | setBroadcastAlert(broadcastAlertClicked ? false : true)}> 274 | 275 | 276 | 277 | { broadcastAlertClicked && 278 | ( 279 | If selected, we will use the Broadcast API to send messages, which sends 1 request to many recipients, so it can be more efficient 280 | for high volume messaging. As of December 2023, this API is in pilot so your account needs to be manually enabled and full stability is not guaranteed. 281 | ) 282 | } 283 | { lineTypeAlertClicked && 284 | ( 285 | About Line Type Intelligence 286 | Looking for line type can provide additional insights on the number itself. For example you can exclude landlines or VOIP numbers from your SMS campaigns. Not needed for Whatsapp campaigns. 287 | Additional charges apply. More information here 288 | ) 289 | } 290 | 291 | 292 | 303 | 314 | 315 | 316 | ); 317 | }; 318 | 319 | export default CheckAndSend; 320 | -------------------------------------------------------------------------------- /frontend/src/Components/CampaignTable/CampaignTable.js: -------------------------------------------------------------------------------- 1 | import React, {useState, useEffect} from 'react'; 2 | import Table from '@mui/material/Table'; 3 | import TableBody from '@mui/material/TableBody'; 4 | import TableCell from '@mui/material/TableCell'; 5 | import TableContainer from '@mui/material/TableContainer'; 6 | import TableHead from '@mui/material/TableHead'; 7 | import TableRow from '@mui/material/TableRow'; 8 | import TablePagination from '@mui/material/TablePagination'; 9 | import TableFooter from '@mui/material/TableFooter'; 10 | import Paper from '@mui/material/Paper'; 11 | import Typography from '@mui/material/Typography'; 12 | 13 | import AddIcon from '@mui/icons-material/Add'; 14 | import DeleteIcon from '@mui/icons-material/Delete'; 15 | import EditIcon from '@mui/icons-material/Edit'; 16 | import CheckIcon from '@mui/icons-material/Check'; 17 | import IconButton from '@mui/material/IconButton'; 18 | import ErrorIcon from '@mui/icons-material/Error'; 19 | import WarningIcon from '@mui/icons-material/Warning'; 20 | import CheckCircleIcon from '@mui/icons-material/CheckCircle'; 21 | import SearchIcon from '@mui/icons-material/Search'; 22 | import HourglassEmptyIcon from '@mui/icons-material/HourglassEmpty'; 23 | import Tooltip from '@mui/material/Tooltip'; 24 | import TextField from '@mui/material/TextField'; 25 | import Button from "@mui/material/Button"; 26 | import OutlinedInput from "@mui/material/OutlinedInput"; 27 | import InputAdornment from "@mui/material/InputAdornment"; 28 | import FormControl from "@mui/material/FormControl"; 29 | import InputLabel from "@mui/material/InputLabel"; 30 | import Stack from '@mui/material/Stack'; 31 | import { v4 as uuidv4 } from 'uuid'; 32 | import { useSelector, useDispatch} from 'react-redux'; 33 | import { updateCSVState } from '../../Redux/slices/csvDataSlice'; 34 | import { updateMessagingState } from '../../Redux/slices/messagingSlice'; 35 | import { updateSettingsState } from '../../Redux/slices/settingsSlice'; 36 | import { updateActionState } from '../../Redux/slices/actionSlice'; 37 | import { CSVDATA_TYPES, MESSAGING_TYPES, SETTINGS_TYPES, ACTION_TYPES } from '../../Utils/variables'; 38 | import { chunkArray, processChunksInBatches, getMessageStatus } from '../../Utils/functions'; 39 | import { expbackoff } from '../../exponential-backoff'; 40 | 41 | const in_progress_statuses = ["accepted", "queued", "sent", "sending", "scheduled"] 42 | const failed_statuses = ["undelivered", "failed"] 43 | const PlaceholderIcon = () => ; 44 | 45 | const CampaignTable = () => { 46 | 47 | const csvData = useSelector(state => state.csvDataStructure.csvData) 48 | const csvColumnFields = useSelector(state => state.csvDataStructure.csvColumnFields) 49 | const phoneNumberColumn = useSelector(state => state.csvDataStructure.csvSelectedColumn) 50 | const sendResultsArray = useSelector(state => state.messagingStructure.sendResultsArray) 51 | const customMessage = useSelector(state => state.messagingStructure.customMessage) 52 | const lookupDataForLogs = useSelector(state => state.actionStructure.lookupDataForLogs) 53 | const limits = useSelector(state => state.settingsStructure.limits) 54 | const dispatch = useDispatch() 55 | 56 | const [editIdx, setEditIdx] = useState(-1); 57 | const [editRow, setEditRow] = useState(null); 58 | const [page, setPage] = useState(0); 59 | const [rowsPerPage, setRowsPerPage] = useState(25); 60 | const [findRowNumber, setFindRowNumber] = useState(0); 61 | const [current, setCurrent] = useState(0); 62 | 63 | useEffect(() => { 64 | setCurrent(page * rowsPerPage); 65 | }, [page, rowsPerPage]); 66 | 67 | const updateProgressBar = (newValue, totalData) => { 68 | return dispatch(updateActionState({ 69 | type: ACTION_TYPES.PROGRESS_BAR_COUNT, 70 | value: { 71 | newValue: newValue, 72 | totalData: totalData 73 | } 74 | })) 75 | } 76 | 77 | const handleGetStatus = async () => { 78 | 79 | updateProgressBar(0, csvData.length) 80 | 81 | const startTime = new Date(); 82 | const chunkSize = limits.getStatusChunkSize; 83 | const chunks = chunkArray(sendResultsArray, chunkSize); 84 | 85 | const processChunk = async (chunk) => { 86 | let shouldCheckStatus = false; 87 | 88 | for (let i = 0; i < chunk.chunkData.length; i++) { 89 | if(chunk.chunkData[i].messageSid.length > 0){ 90 | shouldCheckStatus = true; 91 | break; 92 | } 93 | } 94 | 95 | if(shouldCheckStatus){ 96 | const data = { 97 | sendResultsArray: chunk.chunkData, 98 | startIndex: chunk.startIndex 99 | } 100 | updateProgressBar(limits.getStatusChunkSize, csvData.length) 101 | return expbackoff(async () => { 102 | return getMessageStatus(data); 103 | }) 104 | } 105 | else { 106 | updateProgressBar(limits.getStatusChunkSize, csvData.length) 107 | return ; 108 | } 109 | 110 | } 111 | 112 | const results = await processChunksInBatches(chunks, processChunk, limits.browserConcurrency); 113 | 114 | results.forEach((r,index) => { 115 | if (r.status === 'fulfilled' && r.value) { 116 | dispatch(updateMessagingState({ 117 | type: MESSAGING_TYPES.UPDATE_STATUS_CHUNK, 118 | value: r.value.getStatusArray 119 | })) 120 | } else { 121 | console.log(`Promise no ${index} rejected with reason: ${r.reason}`) 122 | } 123 | }); 124 | 125 | dispatch(updateSettingsState({ 126 | type: SETTINGS_TYPES.ENABLE_GRAPH, 127 | value: true 128 | })) 129 | 130 | const endTime = new Date(); 131 | const timeTaken = (endTime - startTime) / 1000; 132 | 133 | const getStatusDataForLogs = { 134 | source: "getMessageStatus" 135 | } 136 | 137 | dispatch(updateActionState({ 138 | type: ACTION_TYPES.GET_STATUS_DATA_FOR_LOGS, 139 | value: getStatusDataForLogs 140 | })) 141 | 142 | console.log(`Get status for ${sendResultsArray.length} rows took ${timeTaken}`) 143 | 144 | } 145 | 146 | const addRowClick = () => { 147 | let cols = Object.keys(csvData[0]) 148 | let new_row = {} 149 | for (let i = 0; i < cols.length; i++){ 150 | cols[i] === "UniqueID" ? new_row[cols[i]] = uuidv4() : new_row[cols[i]] = "" 151 | } 152 | dispatch(updateCSVState({ 153 | type: CSVDATA_TYPES.ADD_ROW, 154 | value: new_row 155 | })); 156 | 157 | if(sendResultsArray.length > 0){ 158 | let obj = {} 159 | obj["messageSid"] = "" 160 | obj["error"] = {} 161 | obj["error"]["errorCode"] = "" 162 | obj["error"]["errorMessage"] = "" 163 | obj["error"]["errorLink"] = "" 164 | obj["status"] = "" 165 | obj["csvRowID"] = new_row.UniqueID 166 | dispatch(updateMessagingState({ 167 | type: MESSAGING_TYPES.ADD_ROW, 168 | value: obj 169 | })); 170 | obj = {} 171 | } 172 | 173 | } 174 | 175 | const handleColumnNameClick = (col) => { 176 | console.log(col) 177 | dispatch(updateMessagingState({ 178 | type: MESSAGING_TYPES.CUSTOM_MESSAGE, 179 | value: customMessage + `{{${col}}}` 180 | })) 181 | } 182 | 183 | const startEditing = (i, row) => { 184 | setEditIdx(i); 185 | setEditRow(row) 186 | }; 187 | 188 | const stopEditing = () => { 189 | dispatch(updateCSVState({ 190 | type: CSVDATA_TYPES.UPDATE_ROW, 191 | value: editRow 192 | })); 193 | setEditIdx(-1); 194 | }; 195 | 196 | const handleEditing = (e, name) => { 197 | const value = name === phoneNumberColumn 198 | ? e.target.value.trim().replace(/\s+/g, '') 199 | : e.target.value; 200 | 201 | setEditRow(prevRow => ({ 202 | ...prevRow, 203 | [name]: value 204 | })); 205 | }; 206 | 207 | 208 | const handleRemove = (i) => { 209 | dispatch(updateCSVState({ 210 | type: CSVDATA_TYPES.DELETE_ROW, 211 | value: i 212 | })); 213 | 214 | dispatch(updateMessagingState({ 215 | type: MESSAGING_TYPES.DELETE_ROW, 216 | value: i 217 | })); 218 | }; 219 | 220 | const shouldShowIconLookup = (UniqueID) => { 221 | 222 | let show = false; 223 | if ((lookupDataForLogs.nonmobileNumbers && lookupDataForLogs.nonmobileNumbers.includes(UniqueID)) || 224 | (lookupDataForLogs.invalidNumbers && lookupDataForLogs.invalidNumbers.includes(UniqueID)) || (lookupDataForLogs.checkedSuccess > 0 || lookupDataForLogs.checkedErrors >0) 225 | ){return show = true;} 226 | 227 | return show; 228 | } 229 | 230 | const showRelevantIconLookup = (UniqueID) =>{ 231 | let title, color; 232 | let icon; 233 | let interactive = false; 234 | let renderTitleJSX = false; 235 | 236 | if(lookupDataForLogs.checkedSuccess > 0 || lookupDataForLogs.checkedErrors >0){ 237 | if (lookupDataForLogs.nonmobileNumbers && lookupDataForLogs.nonmobileNumbers.includes(UniqueID)){ 238 | title = "We could not identify this number as a valid mobile number. Ensure you don't send SMS to non-mobile numbers" 239 | color = "orange" 240 | icon = "WarningIcon" 241 | interactive = false 242 | 243 | } 244 | else if (lookupDataForLogs.invalidNumbers && lookupDataForLogs.invalidNumbers.includes(UniqueID)){ 245 | title = "This number was marked as invalid, does it have the correct number of digits?" 246 | color = "red" 247 | icon = "ErrorIcon" 248 | interactive = false 249 | } 250 | else { 251 | title = "This number passed the check" 252 | color = "green" 253 | icon = "CheckCircleIcon" 254 | interactive = false 255 | } 256 | } 257 | 258 | 259 | 260 | return {title, color, icon, interactive, renderTitleJSX} 261 | 262 | } 263 | 264 | const shouldShowIcon = (UniqueID) => { 265 | let show = false; 266 | if (sendResultsArray.length > 0){ 267 | 268 | let index = sendResultsArray.findIndex((element) => { return element.csvRowID === UniqueID}) 269 | if (index !== -1){ 270 | if (sendResultsArray[index]["status"].length > 0) 271 | return show = true; 272 | } 273 | } 274 | return show; 275 | } 276 | 277 | const showRelevantIcon = (UniqueID) =>{ 278 | 279 | let title, color; 280 | let icon; 281 | let interactive = false; 282 | let renderTitleJSX = false; 283 | let index; 284 | 285 | if (sendResultsArray.length > 0){ 286 | index = sendResultsArray.findIndex((element) => {return element.csvRowID === UniqueID}) 287 | } 288 | 289 | if (sendResultsArray && sendResultsArray.length > 0 && index !== -1 && (sendResultsArray[index]["status"] === "delivered" || sendResultsArray[index]["status"] === "read")){ 290 | title = "Message marked as Delivered - It will be excluded from further 'Send Messages' actions" 291 | color = "green" 292 | icon = "CheckCircleIcon" 293 | interactive = false 294 | } 295 | 296 | else if (sendResultsArray && sendResultsArray.length > 0 && index !== -1 && failed_statuses.includes(sendResultsArray[index]["status"])){ 297 | 298 | color = "red" 299 | icon = "ErrorIcon" 300 | interactive = true 301 | renderTitleJSX = true 302 | } 303 | 304 | else if (sendResultsArray && sendResultsArray.length > 0 && index !== -1 && (in_progress_statuses.includes(sendResultsArray[index]["status"]) || sendResultsArray[index]["status"] === "")){ 305 | 306 | if(sendResultsArray[index]["status"] === "scheduled"){ 307 | title = "Message Successfully Scheduled - Check Status once the schedule time has elapsed" 308 | } 309 | else{ 310 | title = "Message Successfuly Created - Check Status periodically for final message status. If Status does not change in a few minutes, check Twilio Logs" 311 | } 312 | color = "grey" 313 | icon = "CheckCircleIcon" 314 | interactive = false 315 | } 316 | 317 | return {title, color, icon, interactive, renderTitleJSX} 318 | 319 | } 320 | 321 | const handleChangePage = (event, page) => { 322 | setPage(parseInt(page, 10)) 323 | setCurrent(page * rowsPerPage) 324 | }; 325 | 326 | const handleChangeRowsPerPage = (event) => { 327 | setRowsPerPage(parseInt(event.target.value, 10)) 328 | setPage(0) 329 | }; 330 | 331 | const handleFindRowChange = (event) => { 332 | setFindRowNumber(event.target.value) 333 | } 334 | 335 | const handleMouseDownFindRow = (event) => { 336 | event.preventDefault(); 337 | } 338 | 339 | const handleClickFindRow = (rowNumber) => { 340 | if(rowNumber < 0){ 341 | rowNumber = 0; 342 | } 343 | else if (rowNumber > csvData.length){ 344 | rowNumber = csvData.length 345 | } 346 | setPage(Math.floor(rowNumber/rowsPerPage)) 347 | } 348 | 349 | 350 | const renderRow = (row, index) => { 351 | 352 | const currentRowIndex = current + index; 353 | 354 | return ( 355 | 359 | 360 | {currentRowIndex} 361 | 362 | 363 | 364 | {Object.keys(row).map((col) => ( 365 | <> 366 | 367 | { 368 | 369 | col === "UniqueID" ? "" : 370 | ( 371 | 372 | { editIdx === currentRowIndex ? 373 | ( 374 |
375 | handleEditing(e, col, currentRowIndex, row)} 378 | value={editRow[col] || row[col]} 379 | /> 380 |
381 | ) 382 | : 383 | ( 384 | row[col] 385 | ) 386 | } 387 |
) 388 | } 389 | 390 | ))} 391 | 392 | 393 | { editIdx === currentRowIndex ? 394 | 395 | ( stopEditing()} style={{ width: '100px' }}> 396 | 397 | 398 | 399 | 400 | 401 | ) 402 | 403 | : 404 | 405 | ( startEditing(currentRowIndex, row)} style={{ width: '100px' }}> 406 | 407 | 408 | 409 | 410 | 411 | ) 412 | } 413 | 414 | handleRemove(currentRowIndex)} style={{ width: '100px' }}> 415 | 416 | 417 | 418 | 419 | 420 | 421 | 422 | 423 | {shouldShowIconLookup(row.UniqueID) ? 424 | ( 427 | 428 | {showRelevantIconLookup(row.UniqueID).icon === "WarningIcon"? : "" } 429 | {showRelevantIconLookup(row.UniqueID).icon === "ErrorIcon"? : "" } 430 | {showRelevantIconLookup(row.UniqueID).icon === "CheckCircleIcon"? : "" } 431 | 432 | ): } 433 | 434 | 435 | 436 | {shouldShowIcon(row.UniqueID) ? 437 | ( 440 | Error code: {sendResultsArray[currentRowIndex]["error"].errorCode}
441 | {sendResultsArray[currentRowIndex]["error"].errorLink ? (<>For more information please click HERE) : (<>For more information please click HERE)} 442 | ) 443 | : showRelevantIcon(row.UniqueID).title} 444 | interactive={showRelevantIcon(currentRowIndex).interactive ? 1 : 0}> 445 | 446 | {showRelevantIcon(row.UniqueID).icon === "WarningIcon"? : "" } 447 | {showRelevantIcon(row.UniqueID).icon === "ErrorIcon"? : "" } 448 | {showRelevantIcon(row.UniqueID).icon === "CheckCircleIcon"? : "" } 449 | 450 |
) : 451 | } 452 |
453 | 454 | 455 | 456 |
457 | ) 458 | } 459 | 460 | 461 | const renderHeader = () => { 462 | 463 | return ( 464 | 465 | 466 | ID 467 | {Object.keys(csvData[0]).map((col) => ( 468 | <> 469 | { 470 | col === "UniqueID" ? "" : 471 | ( handleColumnNameClick(col)}>{col} 475 | ) 476 | } 477 | 478 | 479 | ))} 480 | Edit 481 | Delete 482 | Lookup Status 483 | 484 | 485 | 486 | ) 487 | 488 | } 489 | 490 | return ( 491 | <> 492 | 493 | 494 | User Data 495 | 496 | 497 | 498 | 499 | 500 | 501 | Find Row 502 | handleFindRowChange(e)} 507 | endAdornment={ 508 | 509 | handleClickFindRow(findRowNumber)} 512 | onMouseDown={(e) => handleMouseDownFindRow(e)} 513 | edge="start" 514 | > 515 | 516 | 517 | 518 | } 519 | label="Find Row" 520 | /> 521 | 522 | 523 | 524 | 525 | 526 | {renderHeader()} 527 | 528 | 529 | {(csvData.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage)).map((row, index) => 530 | <>{renderRow(row,index)} 531 | )} 532 | 533 | 534 | 535 | handleChangePage(e,p)} 540 | onRowsPerPageChange={(e) => handleChangeRowsPerPage(e)} 541 | rowsPerPageOptions={[10, 25, 50, 100]} 542 | /> 543 | 544 | 545 |
546 |
547 | 548 | 549 | ) 550 | 551 | } 552 | 553 | 554 | export default CampaignTable; --------------------------------------------------------------------------------