├── docs └── connect_listener_architecture.png ├── .vscode └── launch.json ├── LICENSE ├── package.json ├── .gitignore ├── ds_configuration.js ├── README.md ├── runTest.js ├── lib ├── dsJwtAuth.js └── processNotification.js └── index.js /docs/connect_listener_architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/docusign/connect-node-worker-aws/master/docs/connect_listener_architecture.png -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "Launch Program", 11 | "program": "${workspaceFolder}/index.js", 12 | "envFile": "${workspaceFolder}/.env", // See https://code.visualstudio.com/docs/nodejs/nodejs-debugging#_load-environment-variables-from-external-file-node 13 | } 14 | ] 15 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2018 DocuSign, Inc. (https://www.docusign.com) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "connect_node_worker_azure", 3 | "version": "1.0.0", 4 | "description": "Work off DS Connect msgs via an Azure Service Message Bus queue", 5 | "main": "index.js", 6 | "scripts": { 7 | "debug": "node --require dotenv/config --inspect-brk index.js", 8 | "start": "node --require dotenv/config index.js", 9 | "test": "mocha" 10 | }, 11 | "directories": { 12 | "test": "test" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "https://github.com/docusign/connect-node-worker-bee-queue" 17 | }, 18 | "author": "DocuSign, Inc", 19 | "license": "MIT", 20 | "bugs": { 21 | "url": "https://github.com/docusign/connect-node-worker-bee-queue/issues" 22 | }, 23 | "homepage": "https://github.com/docusign/connect-node-worker-bee-queue#readme", 24 | "engines": { 25 | "node": ">=8.10" 26 | }, 27 | "dependencies": { 28 | "aws-sdk": "^2.502.0", 29 | "docusign-esign": "^4.3.0", 30 | "dotenv": "^7.0.0", 31 | "fs-extra": "^7.0.1", 32 | "moment": "^2.24.0", 33 | "request": "^2.88.0", 34 | "request-promise-native": "^1.0.7", 35 | "xml2js": "^0.4.19" 36 | }, 37 | "devDependencies": {} 38 | } 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | output/* 2 | test_messages/* 3 | .DS_Store 4 | # Logs 5 | logs 6 | *.log 7 | npm-debug.log* 8 | yarn-debug.log* 9 | yarn-error.log* 10 | lerna-debug.log* 11 | 12 | # Diagnostic reports (https://nodejs.org/api/report.html) 13 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 14 | 15 | # Runtime data 16 | pids 17 | *.pid 18 | *.seed 19 | *.pid.lock 20 | 21 | # Directory for instrumented libs generated by jscoverage/JSCover 22 | lib-cov 23 | 24 | # Coverage directory used by tools like istanbul 25 | coverage 26 | 27 | # nyc test coverage 28 | .nyc_output 29 | 30 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 31 | .grunt 32 | 33 | # Bower dependency directory (https://bower.io/) 34 | bower_components 35 | 36 | # node-waf configuration 37 | .lock-wscript 38 | 39 | # Compiled binary addons (https://nodejs.org/api/addons.html) 40 | build/Release 41 | 42 | # Dependency directories 43 | node_modules/ 44 | jspm_packages/ 45 | 46 | # TypeScript v1 declaration files 47 | typings/ 48 | 49 | # Optional npm cache directory 50 | .npm 51 | 52 | # Optional eslint cache 53 | .eslintcache 54 | 55 | # Optional REPL history 56 | .node_repl_history 57 | 58 | # Output of 'npm pack' 59 | *.tgz 60 | 61 | # Yarn Integrity file 62 | .yarn-integrity 63 | 64 | # dotenv environment variables file 65 | .env 66 | .env.test 67 | 68 | # parcel-bundler cache (https://parceljs.org/) 69 | .cache 70 | 71 | # next.js build output 72 | .next 73 | 74 | # nuxt.js build output 75 | .nuxt 76 | 77 | # vuepress build output 78 | .vuepress/dist 79 | 80 | # Serverless directories 81 | .serverless/ 82 | 83 | # FuseBox cache 84 | .fusebox/ 85 | 86 | # DynamoDB Local files 87 | .dynamodb/ 88 | -------------------------------------------------------------------------------- /ds_configuration.js: -------------------------------------------------------------------------------- 1 | // ds_configuration.js -- configuration information 2 | // Either fill in the data below or set the environment variables 3 | // 4 | const env = process.env; 5 | 6 | exports.config = { 7 | basicAuthName: env.BASIC_AUTH_NAME || '{BASIC_AUTH_NAME}' // The required Basic Auth Name (From Connect) 8 | , basicAuthPW: env.BASIC_AUTH_PW || '{BASIC_AUTH_PW}' // The required Basic Auth Password (From Connect) 9 | , queueUrl: env.QUEUE_URL || '{QUEUE_URL}' 10 | , queueRegion: env.QUEUE_REGION || '{QUEUE_REGION}' 11 | , outputDir: "output" // relative to this app's root dir 12 | , outputFilePefix: "order_" 13 | , envelopeCustomField: "Sales order" // The value of this field is used in the output file name 14 | , envelopeColorCustomField: "Light color" // The value of this field is used for the color bulb 15 | , lifxAccessToken: env.LIFX_ACCESS_TOKEN || '{LIFX_ACCESS_TOKEN}' // optional 16 | , lifxSelector: 'all' 17 | , clientId: env.DS_CLIENT_ID || '{CLIENT_ID}' 18 | /** The guid for the user who will be impersonated. 19 | * An email address can't be used. 20 | * This is the user (or 'service account') 21 | * that the JWT will represent. */ 22 | , impersonatedUserGuid: env.DS_IMPERSONATED_USER_GUID || '{IMPERSONATED_GUID}' 23 | /** The private key */ 24 | /** Enter the key as a multiline string value. No leading spaces! */ 25 | , privateKey: env.DS_PRIVATE_KEY || `{RSA_PRIVATE_KEY}` 26 | /** The account_id that will be used. 27 | * If set to false, then the user's default account will be used. 28 | * If an account_id is provided then it must be the guid 29 | * version of the account number. 30 | * Default: false */ 31 | , targetAccountId: false 32 | // The authentication server. DO NOT INCLUDE https:// prefix! 33 | , authServer: env.DS_AUTH_SERVER || 'account-d.docusign.com' 34 | /** The same value must be set as a redirect URI in the 35 | * DocuSign admin tool. This setting is only used for individually granting 36 | * permission to the clientId if organizational-level permissions 37 | * are not used. 38 | *
Default: https://www.docusign.com */ 39 | , oAuthConsentRedirectURI: 'https://www.docusign.com' 40 | // To provide accurate reporting, the next setting must be the same as the value 41 | // in the listener configuration file 42 | , bqRetries: 10 // when a job fails, how many times should it be retried? 43 | 44 | // settings for development 45 | , debug: true // Send debugging statements to console 46 | , testOutputDirName: 'test_messages' 47 | , enableBreakTest: true // should the worker break tests be enabled? Disable to clear the queue 48 | // These settings are only needed for using the tests/test.js file 49 | , testEnqueueUrl: env.ENQ_URL || '' // URL for enquing a test. Same as the listener's url 50 | 51 | 52 | } 53 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Connect Node Worker for AWS 2 | 3 | This is an example worker application for 4 | Connect webhook notification messages sent 5 | via the 6 | [AWS SQS (Simple Queueing System)](https://aws.amazon.com/sqs/). 7 | 8 | This application receives DocuSign Connect 9 | messages from the queue and then processes them: 10 | 11 | 1. If the envelope is complete, the application 12 | uses a DocuSign JWT Grant token to retrieve 13 | the envelope's combined set of documents, 14 | and stores them in the `output` directory. 15 | 16 | For this example, the envelope **must** 17 | include an Envelope Custom Field 18 | named `Sales order.` The Sales order field is used 19 | to name the output file. 20 | 1. Optionally, this worker app can be configured to 21 | also change the color of an 22 | [LIFX](https://www.lifx.com/) 23 | bulb (or set of bulbs) 24 | to the color set in the envelope's 25 | Custom Field `Light color` 26 | 27 | ## Architecture 28 | ![Connect listener architecture](docs/connect_listener_architecture.png) 29 | 30 | This figure shows the solution's architecture. 31 | This worker application is written in Node.js. 32 | But it 33 | could be written in a different language. 34 | 35 | AWS has 36 | [SQS](https://aws.amazon.com/tools/) 37 | SDK libraries for C#, Java, Node.js, Python, Ruby, C++, and Go. 38 | 39 | ## Installation 40 | 41 | 1. Install the example 42 | [Connect listener for AWS](https://github.com/docusign/connect-node-listener-aws) 43 | on AWS. 44 | At the end of this step, you will have the 45 | `Queue URL`, and `Queue Region`. 46 | 47 | 1. Using AWS IAM, create an IAM `User` with 48 | access to your SQS queue. 49 | 50 | Record the IAM user's AWS Access Key and Secret. 51 | 52 | Configure environment variables 53 | `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` with the 54 | IAM user credentials. 55 | 56 | 1. Install the latest Long Term Support version of 57 | Node v8.x or v10.x on your system, with the 58 | npm package manager. 59 | 60 | 1. Configure a DocuSign Integration Key for the application. 61 | The application uses the OAuth JWT Grant flow. 62 | 63 | If consent has not been granted to the application by 64 | the user, then the application provides a url 65 | that can be used to grant individual consent. 66 | 67 | **To enable individual consent:** either 68 | add the URL `https://www.docusign.com` as a redirect URI 69 | for the Integration Key, or add a different URL and 70 | update the `oAuthConsentRedirectURI` setting 71 | in the ds_configuration.js file. 72 | 73 | 1. Download this repo to a directory. 74 | 75 | 1. In the directory: 76 | 77 | `npm install` 78 | 1. Configure `ds_configuration.js` or set the 79 | environment variables as indicated in that file. 80 | 81 | 1. Start the listener: 82 | 83 | `npm start` 84 | 85 | ## Testing 86 | Configure a DocuSign Connect subscription to send notifications to 87 | the Cloud Function. Create / complete a DocuSign envelope. 88 | The envelope **must include an Envelope Custom Field named "Sales order".** 89 | 90 | * Check the Connect logs for feedback. 91 | * Check the console output of this app for log output. 92 | * Check the `output` directory to see if the envelope's 93 | combined documents and CoC were downloaded. 94 | 95 | For this code example, the 96 | envelope's documents will only be downloaded if 97 | the envelope is `complete` and includes a 98 | `Sales order` custom field. 99 | 100 | ## Integration testing 101 | This repository includes a `runTest.js` file. It conducts an 102 | end-to-end integration test of enqueuing and dequeuing 103 | test messages. See the file for more information. 104 | 105 | ## License and Pull Requests 106 | 107 | ### License 108 | This repository uses the MIT License. See the LICENSE file for more information. 109 | 110 | ### Pull Requests 111 | Pull requests are welcomed. Pull requests will only be considered if their content 112 | uses the MIT License. 113 | 114 | -------------------------------------------------------------------------------- /runTest.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node --require dotenv/config 2 | 3 | /** 4 | * See settings in ds_configuration.js 5 | */ 6 | 7 | const dsConfig = require('./ds_configuration.js').config 8 | , fse = require('fs-extra') 9 | , path = require('path') 10 | , rp = require('request-promise-native') 11 | , testOutputDirName = dsConfig.testOutputDirName 12 | , testOutputDir = path.join(path.normalize("."), testOutputDirName) 13 | , moment = require('moment') 14 | , sleep = (seconds) => { 15 | return new Promise(resolve => setTimeout(resolve, 1000 * seconds))} 16 | , log = msg => {console.log(`${new Date().toUTCString()} ${msg}`)} 17 | ; 18 | 19 | let timeStart 20 | , timeChecks = [] 21 | , timeCheckNumber = 0 // 0..6 22 | , enqueueErrors = 0 23 | , dequeueErrors = 0 24 | , successes = 0 25 | , mode // help, many or few 26 | , testsSent = [] // test values sent that should also be receieved 27 | , foundAll = false 28 | ; 29 | 30 | async function startTest() { 31 | timeStart = moment() 32 | for (let i = 0; i <= 7; i++) { 33 | timeChecks[i] = moment(timeStart).add(i + 1, 'h') 34 | } 35 | log("Starting"); 36 | await doTests(); 37 | log("Done.\n"); 38 | } 39 | 40 | async function doTests() { 41 | while (timeCheckNumber <= 7) { 42 | while (moment().isBefore(timeChecks[timeCheckNumber])) { 43 | await doTest(); 44 | if (mode == "few") { 45 | await sleep(moment.duration(moment().diff(timeChecks[timeCheckNumber])).asSeconds() + 2) 46 | } 47 | } 48 | showStats(); 49 | timeCheckNumber ++; 50 | } 51 | showStats(); 52 | } 53 | 54 | function showStats() { 55 | const rate = Math.round((100.0 * successes) / (enqueueErrors + dequeueErrors + successes)); 56 | log (`##### Test statistics: ${successes} (${rate}%) successes, ${enqueueErrors} enqueue errors, ${dequeueErrors} dequeue errors.`) 57 | } 58 | 59 | async function doTest() { 60 | await send(); // sets testsSent 61 | const endTime = moment().add(3, 'minutes'); 62 | foundAll = false; 63 | const tests = testsSent.length, 64 | successesStart = successes; 65 | while (!foundAll && moment().isBefore(endTime)){ 66 | await sleep(1); 67 | await checkResults(); // sets foundAll and updates testsSent 68 | } 69 | if (!foundAll) { 70 | dequeueErrors += testsSent.length; 71 | } 72 | log (`Test: ${tests} sent. ${successes - successesStart } successes, ${testsSent.length} failures.`) 73 | } 74 | 75 | /** 76 | * Look for the reception of the testsSent values 77 | */ 78 | async function checkResults(){ 79 | let testsReceived = []; 80 | for (let i = 1; i <= 20; i++) { 81 | let fileData = null; 82 | try {fileData = await fse.readFile(path.join(testOutputDir, `test${i}.txt`))} catch(e){} 83 | if (fileData) {testsReceived.push(fileData.toString())} 84 | } 85 | // Create a private copy of testsSent (testsSentOrig) and reset testsSent 86 | // Then, for each element in testsSentOrig not found, add back to testsSent. 87 | let testsSentOrig = testsSent; 88 | testsSent = []; 89 | testsSentOrig.forEach(testValue => { 90 | const found = testsReceived.includes(testValue); 91 | if (found) {successes ++} 92 | else {testsSent.push(testValue)} 93 | }) 94 | // Update foundAll 95 | foundAll = testsSent.length == 0 96 | } 97 | 98 | async function send() { 99 | testsSent = []; 100 | for (let i = 0 ; i < 5; i++) { 101 | try { 102 | const testValue = Date.now().toString(); 103 | await send1(testValue); 104 | testsSent.push(testValue) 105 | } catch (e) { 106 | enqueueErrors ++; 107 | log (`Enqueue error: ${e}`); 108 | await sleep(30); 109 | } 110 | } 111 | } 112 | 113 | /** 114 | * Send one enqueue request. Errors will be caught by caller 115 | * @param {string} test The test value 116 | */ 117 | async function send1(test){ 118 | let options = {url: `${dsConfig.testEnqueueUrl}?test=${test}`, method: 'POST', body: ''} 119 | , auth = authObject(); 120 | if (auth) {options.auth = auth}; 121 | return await rp(options) 122 | } 123 | 124 | /** 125 | * Returns an auth object for the request library or false 126 | * if Basic Auth is not being used 127 | */ 128 | function authObject() { 129 | if (dsConfig.basicAuthName && dsConfig.basicAuthName != '{BASIC_AUTH_NAME}') { 130 | return {user: dsConfig.basicAuthName, pass: dsConfig.basicAuthPW} 131 | } else { 132 | return false 133 | } 134 | } 135 | 136 | 137 | 138 | //////////////////////////////////// 139 | // 140 | // Mainline 141 | 142 | if (process.argv.length < 3) {mode = 'help'} 143 | else {mode = process.argv[2]} 144 | 145 | if (mode === 'help') { 146 | console.log(` 147 | ./runTest.js many # send many tests 148 | ./runTest.js few # send five tests, wait an hour, repeat 149 | 150 | Tests run for 8 hours, with interim reports every hour.\n`) 151 | } else if (mode === 'many' || mode === 'few') {startTest() 152 | } else {console.log(`\nProblem: unrecogpnized mode '${mode}'\n`)} 153 | 154 | 155 | 156 | 157 | 158 | -------------------------------------------------------------------------------- /lib/dsJwtAuth.js: -------------------------------------------------------------------------------- 1 | // dsJwtAuth.js 2 | 3 | /** 4 | * @file 5 | * This file handles the JWT authentication with DocuSign. 6 | * It also looks up the user's account and base_url 7 | * via the OAuth::userInfo method. 8 | * See https://developers.docusign.com/esign-rest-api/guides/authentication/user-info-endpoints userInfo method. 9 | * @author DocuSign 10 | */ 11 | 12 | 13 | 'use strict'; 14 | let DsJwtAuth = {}; 15 | module.exports = DsJwtAuth; // SET EXPORTS for the module. 16 | 17 | const moment = require('moment') 18 | , docusign = require('docusign-esign') 19 | , dsConfig = require('../ds_configuration.js').config 20 | ; 21 | 22 | // private constants and globals 23 | const tokenReplaceMin = 10; // The accessToken must expire at least this number of 24 | // minutes later or it will be replaced 25 | 26 | let tokenExpirationTimestamp = null; // when does the accessToken expire? 27 | 28 | // Exported variables 29 | DsJwtAuth.accessToken = null; // The bearer accessToken 30 | DsJwtAuth.accountId = null; // current account 31 | DsJwtAuth.accountName = null; // current account name 32 | DsJwtAuth.basePath = null; // eg https://na2.docusign.net/restapi 33 | DsJwtAuth.userName = null; 34 | DsJwtAuth.userEmail = null; 35 | 36 | 37 | /** 38 | * This is the key method for the object. 39 | * It should be called before any API call to DocuSign. 40 | * It checks that the existing access accessToken can be used. 41 | * If the existing accessToken is expired or doesn't exist, then 42 | * a new accessToken will be obtained from DocuSign by using 43 | * the JWT flow. 44 | * 45 | * This is an async function so call it with await. 46 | * 47 | * SIDE EFFECT: Sets the access accessToken that the SDK will use. 48 | * SIDE EFFECT: If the accountId et al is not set, then this method will 49 | * also get the user's information 50 | * @function 51 | * @returns {promise} a promise with null result. 52 | */ 53 | DsJwtAuth.checkToken = async function _checkToken() { 54 | let noToken = !DsJwtAuth.accessToken || !tokenExpirationTimestamp 55 | , now = moment() 56 | , needToken = noToken || tokenExpirationTimestamp.subtract( 57 | tokenReplaceMin, 'm').isBefore(now) 58 | ; 59 | if (noToken) {console.log('checkToken: Starting up--need an accessToken')} 60 | if (needToken && !noToken) {console.log('checkToken: Replacing old accessToken')} 61 | //if (!needToken) {console.log('checkToken: Using current accessToken')} 62 | 63 | if (needToken) { 64 | let results = await DsJwtAuth.getToken(); 65 | DsJwtAuth.accessToken = results.accessToken; 66 | tokenExpirationTimestamp = results.tokenExpirationTimestamp; 67 | console.log ("Obtained an access token. Continuing..."); 68 | 69 | if (!DsJwtAuth.accountId) { 70 | await DsJwtAuth.getUserInfo() 71 | } 72 | } 73 | } 74 | 75 | /** 76 | * Async function to obtain a accessToken via JWT grant 77 | * 78 | * RETURNS {accessToken, tokenExpirationTimestamp} 79 | * 80 | * We need a new accessToken. We will use the DocuSign SDK's function. 81 | */ 82 | DsJwtAuth.getToken = async function _getToken() { 83 | // Data used 84 | // dsConfig.clientId 85 | // dsConfig.impersonatedUserGuid 86 | // dsConfig.privateKey 87 | // dsConfig.authServer 88 | const jwtLifeSec = 10 * 60, // requested lifetime for the JWT is 10 min 89 | scopes = "signature", // impersonation scope is implied due to use of JWT grant 90 | dsApi = new docusign.ApiClient(); 91 | 92 | dsApi.setOAuthBasePath(dsConfig.authServer); 93 | const results = await dsApi.requestJWTUserToken(dsConfig.clientId, 94 | dsConfig.impersonatedUserGuid, scopes, dsConfig.privateKey, 95 | jwtLifeSec); 96 | const expiresAt = moment().add(results.body.expires_in, 's'); 97 | return {accessToken: results.body.access_token, tokenExpirationTimestamp: expiresAt}; 98 | } 99 | 100 | /** 101 | * Sets the following variables: 102 | * DsJwtAuth.accountId 103 | * DsJwtAuth.accountName 104 | * DsJwtAuth.basePath 105 | * DsJwtAuth.userName 106 | * DsJwtAuth.userEmail 107 | * @function _getAccount 108 | * @returns {promise} 109 | * @promise 110 | */ 111 | DsJwtAuth.getUserInfo = async function _getUserInfo(){ 112 | // Data used: 113 | // dsConfig.targetAccountId 114 | // dsConfig.authServer 115 | // DsJwtAuth.accessToken 116 | 117 | const dsApi = new docusign.ApiClient() 118 | , targetAccountId = dsConfig.targetAccountId 119 | , baseUriSuffix = '/restapi'; 120 | 121 | dsApi.setOAuthBasePath(dsConfig.authServer); 122 | const results = await dsApi.getUserInfo(DsJwtAuth.accessToken); 123 | 124 | let accountInfo; 125 | if (targetAccountId === "false" || targetAccountId === "FALSE" || 126 | targetAccountId === false) { 127 | // find the default account 128 | accountInfo = results.accounts.find(account => 129 | account.isDefault === "true"); 130 | } else { 131 | // find the matching account 132 | accountInfo = results.accounts.find(account => account.accountId == targetAccountId); 133 | } 134 | if (typeof accountInfo === 'undefined') { 135 | let err = new Error (`Target account ${targetAccountId} not found!`); 136 | throw err; 137 | } 138 | 139 | ({accountId: DsJwtAuth.accountId, 140 | accountName: DsJwtAuth.accountName, 141 | baseUri: DsJwtAuth.basePath} = accountInfo); 142 | DsJwtAuth.basePath += baseUriSuffix; 143 | } 144 | 145 | 146 | /** 147 | * Clears the accessToken. Same as logging out 148 | * @function 149 | */ 150 | DsJwtAuth.clearToken = function(){ // "logout" function 151 | tokenExpirationTimestamp = false; 152 | DsJwtAuth.accessToken = false; 153 | }; -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * See settings in ds_configuration.js 5 | */ 6 | 7 | const dsConfig = require('./ds_configuration.js').config 8 | , AWS = require('aws-sdk') 9 | , processNotification = require('./lib/processNotification.js') 10 | , dsJwtAuth = require('./lib/dsJwtAuth') 11 | , queueUrl = dsConfig.queueUrl 12 | , queueRegion = dsConfig.queueRegion 13 | ; 14 | 15 | const sleep = (seconds) => { 16 | return new Promise(resolve => setTimeout(resolve, 1000 * seconds)) 17 | } 18 | 19 | /** 20 | * Process a message 21 | * See https://github.com/Azure/azure-sdk-for-js/tree/master/sdk/servicebus/service-bus#register-message-handler 22 | * @param {string} message 23 | */ 24 | const messageHandler = async function _messageHandler (message, queue) { 25 | if (dsConfig.debug) { 26 | let m = `Processing message id ${message.MessageId}`; 27 | console.log(`${new Date().toUTCString()} ${m}`); 28 | } 29 | 30 | let body; 31 | try {body = JSON.parse(message.Body)} catch(e) {body = false} 32 | 33 | if (body) { 34 | await processNotification.process(body.test, body.xml); 35 | } else { 36 | let m = `Null or bad body in message id ${message.messageId}. Ignoring.`; 37 | console.log(`${new Date().toUTCString()} ${m}`); 38 | } 39 | await queue.deleteMessage({QueueUrl: queueUrl, ReceiptHandle: message.ReceiptHandle}).promise(); 40 | } 41 | 42 | /** 43 | * The function will listen forever, dispatching incoming notifications 44 | * to the processNotification library. 45 | * See https://docs.aws.amazon.com/sdk-for-javascript/v2/developer-guide/sqs-examples-send-receive-messages.html#sqs-examples-send-receive-messages-receiving 46 | */ 47 | async function listenForever() { 48 | // Check that we can get a DocuSign token 49 | await testToken(); 50 | 51 | let queue = null 52 | , restart = true 53 | ; 54 | 55 | const startQueue = async () => { 56 | let checkLogQ = []; // Last four queue checking log messages. 57 | 58 | /** 59 | * Maintain the array checkLogQ as a FIFO buffer with length 4. 60 | * When a new entry is added, remove oldest entry and shuffle. 61 | * @param {string} msg 62 | */ 63 | function addCheckLogQ(msg) { 64 | const len = 4; 65 | if (checkLogQ.length < len) {checkLogQ.push(msg)} 66 | else { 67 | for (let i = 0; i < len - 1; i++) { 68 | checkLogQ[i] = checkLogQ [i+1] 69 | } 70 | checkLogQ[len - 1] = msg; 71 | } 72 | } 73 | 74 | /** 75 | * Dump checkLogQ to the console 76 | */ 77 | function printCheckLogQ() { 78 | checkLogQ.forEach(m =>{console.log(m)}); 79 | checkLogQ = []; // reset 80 | } 81 | 82 | try { 83 | AWS.config.update({region: queueRegion}); 84 | queue = new AWS.SQS({apiVersion: '2012-11-05'}); 85 | const params = { 86 | MaxNumberOfMessages: 10, 87 | MessageAttributeNames: ["All"], 88 | QueueUrl: queueUrl, 89 | MaxNumberOfMessages: 10, 90 | WaitTimeSeconds: 20 91 | }; 92 | 93 | while (true) { 94 | addCheckLogQ(`${new Date().toUTCString()} Awaiting a message...`); 95 | let data = await queue.receiveMessage(params).promise() 96 | let msgCount = data.Messages ? data.Messages.length : 0; 97 | addCheckLogQ(`${new Date().toUTCString()} Found ${msgCount} message(s)`); 98 | if (msgCount) { 99 | printCheckLogQ() 100 | for (const message of data.Messages) { 101 | await messageHandler(message, queue); 102 | } 103 | } 104 | } 105 | } catch (e) { 106 | printCheckLogQ(); 107 | console.error(`\n${new Date().toUTCString()} Queue receive error:`); 108 | console.error(e); 109 | await sleep(5); 110 | restart = true; 111 | } 112 | } 113 | 114 | while (true) { 115 | if (restart) { 116 | console.log(`${new Date().toUTCString()} Starting queue worker`); 117 | restart = false; 118 | await startQueue(); 119 | } 120 | await sleep(5); 121 | } 122 | } 123 | 124 | 125 | /** 126 | * Check that we can get a DocuSign token and handle common error 127 | * cases: ds_configuration not configured, need consent. 128 | */ 129 | async function testToken() { 130 | try { 131 | if (! dsConfig.clientId || dsConfig.clientId == '{CLIENT_ID}') { 132 | console.log (` 133 | Problem: you need to configure this example, either via environment variables (recommended) 134 | or via the ds_configuration.js file. 135 | See the README file for more information\n\n`); 136 | process.exit(); 137 | } 138 | 139 | await dsJwtAuth.checkToken(); 140 | } catch (e) { 141 | let body = e.response && e.response.body; 142 | if (body) { 143 | // DocuSign API problem 144 | if (body.error && body.error == 'consent_required') { 145 | // Consent problem 146 | let consent_scopes = "signature%20impersonation", 147 | consent_url = `https://${dsConfig.authServer}/oauth/auth?response_type=code&` + 148 | `scope=${consent_scopes}&client_id=${dsConfig.clientId}&` + 149 | `redirect_uri=${dsConfig.oAuthConsentRedirectURI}`; 150 | console.log(`\nProblem: C O N S E N T R E Q U I R E D 151 | Ask the user who will be impersonated to run the following url: 152 | ${consent_url} 153 | 154 | It will ask the user to login and to approve access by your application. 155 | 156 | Alternatively, an Administrator can use Organization Administration to 157 | pre-approve one or more users.\n\n`) 158 | process.exit(); 159 | } else { 160 | // Some other DocuSign API problem 161 | console.log (`\nAPI problem: Status code ${e.response.status}, message body: 162 | ${JSON.stringify(body, null, 4)}\n\n`); 163 | process.exit(); 164 | } 165 | } else { 166 | // Not an API problem 167 | throw e; 168 | } 169 | } 170 | } 171 | 172 | /* The mainline... */ 173 | /* Start listening for jobs */ 174 | listenForever() 175 | -------------------------------------------------------------------------------- /lib/processNotification.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @file 3 | * processNotification -- Handle an event notification 4 | * 5 | * Strategy for this worker example: 6 | * 1. Notifications are ignored unless the notification's envelope: 7 | * a. Envelope status is completed 8 | * b. The envelope has an Envelope Custom Field "Sales order" with content 9 | * 2. The software will then download and store the "combined" document on the 10 | * configured "outputDir" directory. The format will be "Order_xyz.pdf" 11 | * where xyz is the value from the "Sales order" Envelope Custom Field. 12 | * 13 | * The combined document download will include the certificate of completion 14 | * if the Admin Tool is set: screen Signing Settings, section Envelope Delivery 15 | * option Attach certificate of completion to envelope is checked. 16 | * 17 | * To manually test: curl -X POST localhost:5000/docusign-listener?test=123 18 | * To manually test a broken worker: curl -X POST localhost:5000/docusign-listener?test=/break 19 | * 20 | * @author DocuSign 21 | * 22 | */ 23 | 24 | const dsConfig = require('../ds_configuration.js').config 25 | , parseString = require('xml2js').parseString 26 | , {promisify} = require('util') 27 | , fse = require('fs-extra') 28 | , path = require('path') 29 | , dsJwtAuth = require('./dsJwtAuth') 30 | , docusign = require('docusign-esign') 31 | , rp = require('request-promise-native') 32 | , parseStringAsync = promisify(parseString) 33 | , testOutputDirName = dsConfig.testOutputDirName 34 | , testOutputDir = path.join(path.normalize("."), testOutputDirName) 35 | , outputDir = path.join(process.cwd(), dsConfig.outputDir) 36 | , sleep = (seconds) => { 37 | return new Promise(resolve => setTimeout(resolve, 1000 * seconds))} 38 | ; 39 | 40 | const processNotification = exports; 41 | 42 | /** 43 | * Process the notification message 44 | * @param {string} test '' (false) indicates: real data, not a test. 45 | * Other values are used as the test data value. 46 | * If a test value includes /break then the worker will immediately exit. 47 | * This is for testing job recovery when the worker crashes. 48 | * @param {string} xml if !test 49 | */ 50 | processNotification.process = async (test, xml) => { 51 | if (test) { 52 | return await processTest(test); 53 | } 54 | 55 | // Step 1. parse the xml 56 | const msg = await parseStringAsync(xml) 57 | , envelopeStatus = msg.DocuSignEnvelopeInformation.EnvelopeStatus[0] 58 | , envelopeId = envelopeStatus.EnvelopeID[0] 59 | , created = envelopeStatus.Created[0] // date/time the envelope was created 60 | , status = envelopeStatus.Status[0] 61 | , completed = status == 'Completed' ? envelopeStatus.Completed[0] : false // when env was completed 62 | , subject = envelopeStatus.Subject[0] 63 | , senderName = envelopeStatus.UserName[0] 64 | , senderEmail = envelopeStatus.Email[0] 65 | , completedMsg = completed ? `Completed ${completed}.` : "" 66 | , orderNumber = getOrderNumber(envelopeStatus) 67 | , lightColor = getLightColor(envelopeStatus) 68 | ; 69 | 70 | // For debugging, you can print the entire notification 71 | //console.log(`received notification!\n${JSON.stringify(msg, null, " ")}`); 72 | 73 | console.log (`${new Date().toUTCString()} EnvelopeId ${envelopeId} Status: ${status}. 74 | Order number: ${orderNumber}. Subject: ${subject} 75 | Sender: ${senderName} <${senderEmail}>. Light color: ${lightColor} 76 | Sent ${created}. ${completedMsg}`); 77 | 78 | // Step 2. Filter the notifications 79 | // Connect sends notifications about all envelopes from the account, sent by anyone. 80 | // So in many use cases, we will need to filter out some notifications. 81 | // Notifications that we don't want should be simply ignored. 82 | // DO NOT reject them by sending a 400 response to DocuSign--that would only 83 | // cause them to be resent. 84 | // 85 | // For this example, we'll filter out any notifications unless the 86 | // envelope status is complete and has an "Order number" envelope custom field 87 | if (status != "Completed") { 88 | if (dsConfig.debug) {console.log(`IGNORED: envelope status is ${status}.`)} 89 | return 90 | } 91 | if (!orderNumber) { 92 | if (dsConfig.debug) {console.log(`IGNORED: envelope does not have a ${dsConfig.envelopeCustomField} envelope custom field.`)} 93 | return 94 | } 95 | 96 | // Step 3. Check that this is not a duplicate notification 97 | // The queuing system delivers on an "at least once" basis. So there is a 98 | // chance that we have already processes this notification. 99 | // 100 | // For this example, we'll just repeat the document fetch if it is duplicate notification 101 | 102 | // Step 3 Download and save the "combined" document 103 | try { 104 | await saveDoc(envelopeId, orderNumber) 105 | } catch (e) { 106 | // Returning a promise rejection tells the queuing system that the 107 | // failied. It will be retried by the queuing system. 108 | return Promise.reject(new Error("job process#saveDoc error")) 109 | } 110 | 111 | // Step 4 Set the light's color 112 | // This is an optional step: it changes the LIFX light color to the value 113 | // set in the envelope's custom field. 114 | if (lightColor && dsConfig.lifxAccessToken && dsConfig.lifxAccessToken != '{LIFX_ACCESS_TOKEN}') { 115 | const lifxToken = dsConfig.lifxAccessToken 116 | , lifxSelector = dsConfig.lifxSelector 117 | , options = {url: `https://api.lifx.com/v1/lights/${lifxSelector}/state`, 118 | method: 'PUT', auth: {bearer: dsConfig.lifxAccessToken}, 119 | form:{power: 'on', color: lightColor, duration: 0.0}} 120 | ; 121 | try {await rp(options)} catch(e) {} 122 | } 123 | } 124 | 125 | /** 126 | * Search through the Envelope Custom Fields to see if an order number 127 | * field is present 128 | * @param {object} envelopeStatus 129 | */ 130 | function getOrderNumber(envelopeStatus){ 131 | const customFields = envelopeStatus.CustomFields[0].CustomField 132 | , orderField = customFields.find( field => field.Name[0] == dsConfig.envelopeCustomField) 133 | , result = orderField ? orderField.Value[0] : null; 134 | return result 135 | } 136 | 137 | /** 138 | * Search through the Envelope Custom Fields to see if a Light color 139 | * field is present 140 | * @param {object} envelopeStatus 141 | */ 142 | function getLightColor(envelopeStatus){ 143 | const customFields = envelopeStatus.CustomFields[0].CustomField 144 | , colorField = customFields.find( field => field.Name[0] == dsConfig.envelopeColorCustomField) 145 | , result = colorField ? colorField.Value[0] : null; 146 | return result 147 | } 148 | 149 | /** 150 | * Downloads and saves the combined documents from the envelope. 151 | * 152 | * @param {string} envelopeId 153 | * @param {string} orderNumber 154 | */ 155 | async function saveDoc(envelopeId, orderNumber) { 156 | try { 157 | await dsJwtAuth.checkToken(); 158 | let dsApiClient = new docusign.ApiClient(); 159 | dsApiClient.setBasePath(dsJwtAuth.basePath); 160 | dsApiClient.addDefaultHeader('Authorization', 'Bearer ' + dsJwtAuth.accessToken); 161 | let envelopesApi = new docusign.EnvelopesApi(dsApiClient); 162 | 163 | // Call EnvelopeDocuments::get. 164 | const docResult = await envelopesApi.getDocument( 165 | dsJwtAuth.accountId, envelopeId, "combined", null) 166 | , sanitizedOrderNumber = orderNumber.replace(/\W/g, '_') 167 | , fileName = dsConfig.outputFilePefix + sanitizedOrderNumber + '.pdf'; 168 | 169 | // create the output dir if need be 170 | const dirExists = await fse.exists(outputDir); 171 | if (!dirExists) {await fse.mkdir(outputDir)} 172 | 173 | // Create the output file 174 | await fse.writeFile(path.join(outputDir, fileName), docResult, 'binary'); 175 | 176 | if (dsConfig.enableBreakTest && ("" + orderNumber).includes("/break")) { 177 | throw new Error('Break test') 178 | } 179 | 180 | } catch (e) { 181 | console.error(`\n${new Date().toUTCString()} Error while fetching and saving docs for envelope ${envelopeId}, order ${orderNumber}.`); 182 | console.error(e); 183 | throw new Error("saveDoc error"); 184 | } 185 | } 186 | 187 | /** 188 | * 189 | * @param {string} test -- what value was sent as a test. 190 | * It will be stored in one of testOutputDir/test1.txt, test2.txt, test3.txt, test4.txt, test5.txt 191 | * 192 | * If a test value includes /break then the worker will immediately exit. 193 | * This is for testing job recovery when the worker crashes. 194 | * 195 | */ 196 | async function processTest (test) { 197 | // Are we being asked to crash? 198 | if (dsConfig.enableBreakTest && ("" + test).includes("/break")) { 199 | console.error(`${new Date().toUTCString()} BREAKING worker test!`); 200 | process.exit(2); 201 | } 202 | 203 | console.log(`Processing test value ${test}`); 204 | 205 | // Create testOutputDir if need be 206 | const dirExists = await fse.exists(testOutputDir); 207 | if (!dirExists) { 208 | await fse.mkdir(testOutputDir) 209 | } 210 | 211 | // The new test message will be placed in test1. 212 | // So first shuffle test4 to test5 (if it exists); and so on. 213 | for (const i of [9, 8, 7, 6, 5, 4, 3, 2, 1]) { 214 | const oldFile = `test${i}.txt` 215 | , newFile = `test${i + 1}.txt` 216 | , oldExists = await fse.exists(path.join(testOutputDir, oldFile)) 217 | ; 218 | if (oldExists) { 219 | await fse.rename (path.join(testOutputDir, oldFile), path.join(testOutputDir, newFile)) 220 | } 221 | } 222 | 223 | // Now write the test message into test1.txt 224 | await fse.writeFile(path.join(testOutputDir, "test1.txt"), test); 225 | } 226 | --------------------------------------------------------------------------------