├── 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 | 
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 |
--------------------------------------------------------------------------------