├── .editorconfig ├── .github └── CODEOWNERS ├── .gitignore ├── LICENSE ├── README.md ├── companies-app ├── .env.template ├── Dockerfile ├── README.md ├── css │ ├── main.css │ └── milligram.css ├── docker-compose.yml ├── index.js ├── package-lock.json ├── package.json └── views │ ├── companies.pug │ ├── company.pug │ ├── contacts.pug │ ├── error.pug │ ├── includes │ ├── footer.pug │ ├── head.pug │ ├── header.pug │ └── layout.pug │ └── login.pug ├── contacts-app ├── .env.template ├── Dockerfile ├── README.md ├── docker-compose.yml ├── index.js ├── package-lock.json ├── package.json ├── public │ ├── css │ │ └── main.css │ └── favicon-32x32.webp └── views │ ├── contacts.pug │ ├── engagements.pug │ ├── error.pug │ ├── includes │ ├── footer.pug │ ├── head.pug │ ├── header.pug │ └── layout.pug │ ├── list.pug │ └── properties.pug ├── form-file-submission-access-app ├── .editorconfig ├── Dockerfile ├── docker-compose.yml ├── src │ ├── .env.template │ ├── .eslintrc.js │ ├── .prettierrc │ ├── README.md │ ├── index.js │ ├── js │ │ ├── contacts-controller.js │ │ ├── files-handler.js │ │ ├── oauth-controller.js │ │ ├── setup-sample-middleware.js │ │ ├── utils.js │ │ ├── webhooks-controller.js │ │ └── websocket-controller.js │ ├── package-lock.json │ ├── package.json │ ├── public │ │ ├── css │ │ │ └── main.css │ │ ├── favicon-32x32.webp │ │ └── js │ │ │ └── main.js │ └── views │ │ ├── contacts.pug │ │ ├── error.pug │ │ ├── includes │ │ ├── footer.pug │ │ ├── head.pug │ │ ├── header.pug │ │ └── layout.pug │ │ └── login.pug └── storage │ └── .gitignore ├── oauth-app ├── .env.template ├── Dockerfile ├── README.md ├── docker-compose.yml ├── index.js ├── package-lock.json ├── package.json ├── public │ ├── css │ │ └── main.css │ └── favicon-32x32.webp └── views │ ├── contacts.pug │ ├── error.pug │ ├── includes │ ├── footer.pug │ ├── head.pug │ ├── header.pug │ └── layout.pug │ └── login.pug ├── webhooks-app ├── Dockerfile ├── docker-compose.yml ├── src │ ├── .env.template │ ├── README.md │ ├── index.js │ ├── js │ │ ├── contacts-controller.js │ │ ├── db-connector.js │ │ ├── db-helper.js │ │ ├── events-service.js │ │ ├── kafka-helper.js │ │ ├── oauth-controller.js │ │ ├── utils.js │ │ └── webhooks-controller.js │ ├── package-lock.json │ ├── package.json │ ├── public │ │ ├── css │ │ │ └── main.css │ │ ├── favicon-32x32.webp │ │ └── js │ │ │ └── main.js │ └── views │ │ ├── contacts.pug │ │ ├── error.pug │ │ ├── includes │ │ ├── footer.pug │ │ ├── head.pug │ │ ├── header.pug │ │ └── layout.pug │ │ └── login.pug └── tools │ └── wait-for-it.sh └── wrapper-file-upload-app ├── .editorconfig ├── Dockerfile ├── README.md ├── docker-compose.yml └── src ├── .env.template ├── .eslintrc.js ├── .prettierrc ├── index.js ├── package.json ├── public ├── css │ └── main.css └── favicon-32x32.webp └── views ├── dashboard.pug ├── error.pug ├── includes ├── footer.pug ├── head.pug ├── header.pug └── layout.pug └── login.pug /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: http://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # Unix-style newlines with a newline ending every file 7 | [*] 8 | end_of_line = lf 9 | insert_final_newline = true 10 | indent_style = space 11 | indent_size = 2 12 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # This file is managed by GitHubManager 2 | # Changes will be overwritten, please go to https://private.hubteam.com/githubmanager to update this. 3 | 4 | * @HubSpot/devex-sample-apps-developers 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # hubspot-integration-samples-js 2 | Sample JavaScript Applications using HubSpot Public API 3 | This repository contains a number of sample applications 4 | - [CRM Contacts API demo](https://github.com/HubSpot/integration-examples-nodejs/tree/master/contacts-app) 5 | - [CRM Companies API demo application](https://github.com/HubSpot/integration-examples-nodejs/tree/master/companies-app) 6 | - [OAuth 2.0 API demo application](https://github.com/HubSpot/integration-examples-nodejs/tree/master/oauth-app) 7 | - [Webhooks API demo application](https://github.com/HubSpot/integration-examples-nodejs/tree/master/webhooks-app) 8 | - [File Upload demo application](https://github.com/HubSpot/integration-examples-nodejs/tree/master/wrapper-file-upload-app) 9 | - [Form submission and File Download application](https://github.com/HubSpot/integration-examples-nodejs/tree/master/form-file-submission-access-app) 10 | -------------------------------------------------------------------------------- /companies-app/.env.template: -------------------------------------------------------------------------------- 1 | HUBSPOT_CLIENT_ID= 2 | HUBSPOT_CLIENT_SECRET= 3 | -------------------------------------------------------------------------------- /companies-app/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:10 2 | 3 | WORKDIR /companies-app 4 | 5 | COPY ./index.js ./ 6 | COPY ./package.json ./ 7 | COPY ./css ./css 8 | COPY ./views ./views 9 | RUN npm install 10 | 11 | 12 | ENTRYPOINT [ "node", "index.js" ] 13 | EXPOSE 3000 14 | -------------------------------------------------------------------------------- /companies-app/README.md: -------------------------------------------------------------------------------- 1 | # HubSpot-nodejs companies sample app 2 | 3 | This is a sample app for the [node-hubspot wrapper](https://www.npmjs.com/package/hubspot). Currently, this app focuses on demonstrating the functionality of [OAuth API](https://developers.hubspot.com/docs/methods/oauth2/oauth2-overview) endpoints and their related actions. 4 | 5 | Please see the documentation on [How do I create an app in HubSpot?](https://developers.hubspot.com/docs/faq/how-do-i-create-an-app-in-hubspot) 6 | 7 | ### HubSpot Public API links used in this application 8 | 9 | - [Create a Company]( https://developers.hubspot.com/docs/methods/companies/create_company) 10 | - [Update a Company]( https://developers.hubspot.com/docs/methods/companies/update_company) 11 | - [Search for companies by domain](https://developers.hubspot.com/docs/methods/companies/search_companies_by_domain) 12 | - [Get all companies](https://developers.hubspot.com/docs/methods/companies/get-all-companies) 13 | - [Get all Company Properties](https://developers.hubspot.com/docs/methods/companies/get_company_properties) 14 | - [Get a Company](https://developers.hubspot.com/docs/methods/companies/get_company) 15 | - [Get Contacts at a Company]( https://developers.hubspot.com/docs/methods/companies/get_company_contacts) 16 | - [Get all contacts](https://developers.hubspot.com/docs/methods/contacts/get_contacts) 17 | - [Search for contacts by email, name, or company name](https://developers.hubspot.com/docs/methods/contacts/search_contacts) 18 | - [Create multiple associations between CRM objects](https://developers.hubspot.com/docs/methods/crm-associations/batch-associate-objects) 19 | - [Delete multiple associations between CRM objects](https://developers.hubspot.com/docs/methods/crm-associations/batch-delete-associations) 20 | 21 | ### Setup App 22 | 23 | Make sure you have [Docker](https://www.docker.com/) installed. 24 | Make sure you have [Docker Compose](https://docs.docker.com/compose/) installed. 25 | 26 | ### Configure 27 | 28 | 1. Copy .env.template to .env 29 | 2. Paste your HubSpot Client Id and HubSpot Client Secret as the value for HUBSPOT_CLIENT_ID and HUBSPOT_CLIENT_SECRET in .env 30 | 31 | ### Running 32 | 33 | The best way to run this project (with the least configuration), is using docker cli. 34 | 35 | ```bash 36 | docker-compose up 37 | ``` 38 | You should now be able to navigate to [http://localhost:3000](http://localhost:3000) and use the application. 39 | -------------------------------------------------------------------------------- /companies-app/css/main.css: -------------------------------------------------------------------------------- 1 | .wrapper .container { 2 | padding-bottom: 2rem; 3 | padding-top: 2rem; 4 | } 5 | 6 | .navigation { 7 | background: #f4f5f6; 8 | border-bottom: .1rem solid #d1d1d1; 9 | display: block; 10 | height: 5.2rem; 11 | left: 0; 12 | max-width: 100%; 13 | width: 100%; 14 | } 15 | 16 | .navigation .container { 17 | padding-bottom: 0; 18 | padding-top: 0 19 | } 20 | 21 | .navigation .navigation-list { 22 | list-style: none; 23 | margin-bottom: 0; 24 | } 25 | 26 | .navigation .navigation-item { 27 | float: left; 28 | margin-bottom: 0; 29 | margin-left: 2.5rem; 30 | position: relative 31 | } 32 | 33 | .navigation .navigation-title, .navigation .title { 34 | color: #606c76; 35 | position: relative 36 | } 37 | 38 | .navigation .navigation-link, .navigation .navigation-title, .navigation .title { 39 | display: inline; 40 | font-size: 1.6rem; 41 | line-height: 5.2rem; 42 | padding: 0; 43 | text-decoration: none 44 | } 45 | 46 | .navigation .navigation-link.active { 47 | color: #606c76 48 | } 49 | 50 | .row.authorize-button { 51 | justify-content: center; 52 | } 53 | 54 | .alert-error { 55 | color: white; 56 | background-color: red; 57 | } 58 | 59 | .alert-success { 60 | color: #3c763d; 61 | background-color: #dff0d8; 62 | } 63 | -------------------------------------------------------------------------------- /companies-app/css/milligram.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * Milligram v1.3.0 3 | * https://milligram.github.io 4 | * 5 | * Copyright (c) 2017 CJ Patoilo 6 | * Licensed under the MIT license 7 | */ 8 | 9 | *, 10 | *:after, 11 | *:before { 12 | box-sizing: inherit; 13 | } 14 | 15 | html { 16 | box-sizing: border-box; 17 | font-size: 62.5%; 18 | } 19 | 20 | body { 21 | color: #606c76; 22 | font-family: 'Roboto', 'Helvetica Neue', 'Helvetica', 'Arial', sans-serif; 23 | font-size: 1.6em; 24 | font-weight: 300; 25 | letter-spacing: .01em; 26 | line-height: 1.6; 27 | } 28 | 29 | blockquote { 30 | border-left: 0.3rem solid #d1d1d1; 31 | margin-left: 0; 32 | margin-right: 0; 33 | padding: 1rem 1.5rem; 34 | } 35 | 36 | blockquote *:last-child { 37 | margin-bottom: 0; 38 | } 39 | 40 | .button, 41 | button, 42 | input[type='button'], 43 | input[type='reset'], 44 | input[type='submit'] { 45 | background-color: #9b4dca; 46 | border: 0.1rem solid #9b4dca; 47 | border-radius: .4rem; 48 | color: #fff; 49 | cursor: pointer; 50 | display: inline-block; 51 | font-size: 1.1rem; 52 | font-weight: 700; 53 | height: 3.8rem; 54 | letter-spacing: .1rem; 55 | line-height: 3.8rem; 56 | padding: 0 3.0rem; 57 | text-align: center; 58 | text-decoration: none; 59 | text-transform: uppercase; 60 | white-space: nowrap; 61 | } 62 | 63 | .button:focus, .button:hover, 64 | button:focus, 65 | button:hover, 66 | input[type='button']:focus, 67 | input[type='button']:hover, 68 | input[type='reset']:focus, 69 | input[type='reset']:hover, 70 | input[type='submit']:focus, 71 | input[type='submit']:hover { 72 | background-color: #606c76; 73 | border-color: #606c76; 74 | color: #fff; 75 | outline: 0; 76 | } 77 | 78 | .button[disabled], 79 | button[disabled], 80 | input[type='button'][disabled], 81 | input[type='reset'][disabled], 82 | input[type='submit'][disabled] { 83 | cursor: default; 84 | opacity: .5; 85 | } 86 | 87 | .button[disabled]:focus, .button[disabled]:hover, 88 | button[disabled]:focus, 89 | button[disabled]:hover, 90 | input[type='button'][disabled]:focus, 91 | input[type='button'][disabled]:hover, 92 | input[type='reset'][disabled]:focus, 93 | input[type='reset'][disabled]:hover, 94 | input[type='submit'][disabled]:focus, 95 | input[type='submit'][disabled]:hover { 96 | background-color: #9b4dca; 97 | border-color: #9b4dca; 98 | } 99 | 100 | .button.button-outline, 101 | button.button-outline, 102 | input[type='button'].button-outline, 103 | input[type='reset'].button-outline, 104 | input[type='submit'].button-outline { 105 | background-color: transparent; 106 | color: #9b4dca; 107 | } 108 | 109 | .button.button-outline:focus, .button.button-outline:hover, 110 | button.button-outline:focus, 111 | button.button-outline:hover, 112 | input[type='button'].button-outline:focus, 113 | input[type='button'].button-outline:hover, 114 | input[type='reset'].button-outline:focus, 115 | input[type='reset'].button-outline:hover, 116 | input[type='submit'].button-outline:focus, 117 | input[type='submit'].button-outline:hover { 118 | background-color: transparent; 119 | border-color: #606c76; 120 | color: #606c76; 121 | } 122 | 123 | .button.button-outline[disabled]:focus, .button.button-outline[disabled]:hover, 124 | button.button-outline[disabled]:focus, 125 | button.button-outline[disabled]:hover, 126 | input[type='button'].button-outline[disabled]:focus, 127 | input[type='button'].button-outline[disabled]:hover, 128 | input[type='reset'].button-outline[disabled]:focus, 129 | input[type='reset'].button-outline[disabled]:hover, 130 | input[type='submit'].button-outline[disabled]:focus, 131 | input[type='submit'].button-outline[disabled]:hover { 132 | border-color: inherit; 133 | color: #9b4dca; 134 | } 135 | 136 | .button.button-clear, 137 | button.button-clear, 138 | input[type='button'].button-clear, 139 | input[type='reset'].button-clear, 140 | input[type='submit'].button-clear { 141 | background-color: transparent; 142 | border-color: transparent; 143 | color: #9b4dca; 144 | } 145 | 146 | .button.button-clear:focus, .button.button-clear:hover, 147 | button.button-clear:focus, 148 | button.button-clear:hover, 149 | input[type='button'].button-clear:focus, 150 | input[type='button'].button-clear:hover, 151 | input[type='reset'].button-clear:focus, 152 | input[type='reset'].button-clear:hover, 153 | input[type='submit'].button-clear:focus, 154 | input[type='submit'].button-clear:hover { 155 | background-color: transparent; 156 | border-color: transparent; 157 | color: #606c76; 158 | } 159 | 160 | .button.button-clear[disabled]:focus, .button.button-clear[disabled]:hover, 161 | button.button-clear[disabled]:focus, 162 | button.button-clear[disabled]:hover, 163 | input[type='button'].button-clear[disabled]:focus, 164 | input[type='button'].button-clear[disabled]:hover, 165 | input[type='reset'].button-clear[disabled]:focus, 166 | input[type='reset'].button-clear[disabled]:hover, 167 | input[type='submit'].button-clear[disabled]:focus, 168 | input[type='submit'].button-clear[disabled]:hover { 169 | color: #9b4dca; 170 | } 171 | 172 | code { 173 | background: #f4f5f6; 174 | border-radius: .4rem; 175 | font-size: 86%; 176 | margin: 0 .2rem; 177 | padding: .2rem .5rem; 178 | white-space: nowrap; 179 | } 180 | 181 | pre { 182 | background: #f4f5f6; 183 | border-left: 0.3rem solid #9b4dca; 184 | overflow-y: hidden; 185 | } 186 | 187 | pre > code { 188 | border-radius: 0; 189 | display: block; 190 | padding: 1rem 1.5rem; 191 | white-space: pre; 192 | } 193 | 194 | hr { 195 | border: 0; 196 | border-top: 0.1rem solid #f4f5f6; 197 | margin: 3.0rem 0; 198 | } 199 | 200 | input[type='email'], 201 | input[type='number'], 202 | input[type='password'], 203 | input[type='search'], 204 | input[type='tel'], 205 | input[type='text'], 206 | input[type='url'], 207 | textarea, 208 | select { 209 | -webkit-appearance: none; 210 | -moz-appearance: none; 211 | appearance: none; 212 | background-color: transparent; 213 | border: 0.1rem solid #d1d1d1; 214 | border-radius: .4rem; 215 | box-shadow: none; 216 | box-sizing: inherit; 217 | height: 3.8rem; 218 | padding: .6rem 1.0rem; 219 | width: 100%; 220 | } 221 | 222 | input[type='email']:focus, 223 | input[type='number']:focus, 224 | input[type='password']:focus, 225 | input[type='search']:focus, 226 | input[type='tel']:focus, 227 | input[type='text']:focus, 228 | input[type='url']:focus, 229 | textarea:focus, 230 | select:focus { 231 | border-color: #9b4dca; 232 | outline: 0; 233 | } 234 | 235 | select { 236 | background: url('data:image/svg+xml;utf8,') center right no-repeat; 237 | padding-right: 3.0rem; 238 | } 239 | 240 | select:focus { 241 | background-image: url('data:image/svg+xml;utf8,'); 242 | } 243 | 244 | textarea { 245 | min-height: 6.5rem; 246 | } 247 | 248 | label, 249 | legend { 250 | display: block; 251 | font-size: 1.6rem; 252 | font-weight: 700; 253 | margin-bottom: .5rem; 254 | } 255 | 256 | fieldset { 257 | border-width: 0; 258 | padding: 0; 259 | } 260 | 261 | input[type='checkbox'], 262 | input[type='radio'] { 263 | display: inline; 264 | } 265 | 266 | .label-inline { 267 | display: inline-block; 268 | font-weight: normal; 269 | margin-left: .5rem; 270 | } 271 | 272 | .container { 273 | margin: 0 auto; 274 | max-width: 112.0rem; 275 | padding: 0 2.0rem; 276 | position: relative; 277 | width: 100%; 278 | } 279 | 280 | .row { 281 | display: flex; 282 | flex-direction: column; 283 | padding: 0; 284 | width: 100%; 285 | } 286 | 287 | .row.row-no-padding { 288 | padding: 0; 289 | } 290 | 291 | .row.row-no-padding > .column { 292 | padding: 0; 293 | } 294 | 295 | .row.row-wrap { 296 | flex-wrap: wrap; 297 | } 298 | 299 | .row.row-top { 300 | align-items: flex-start; 301 | } 302 | 303 | .row.row-bottom { 304 | align-items: flex-end; 305 | } 306 | 307 | .row.row-center { 308 | align-items: center; 309 | } 310 | 311 | .row.row-stretch { 312 | align-items: stretch; 313 | } 314 | 315 | .row.row-baseline { 316 | align-items: baseline; 317 | } 318 | 319 | .row .column { 320 | display: block; 321 | flex: 1 1 auto; 322 | margin-left: 0; 323 | max-width: 100%; 324 | width: 100%; 325 | } 326 | 327 | .row .column.column-offset-10 { 328 | margin-left: 10%; 329 | } 330 | 331 | .row .column.column-offset-20 { 332 | margin-left: 20%; 333 | } 334 | 335 | .row .column.column-offset-25 { 336 | margin-left: 25%; 337 | } 338 | 339 | .row .column.column-offset-33, .row .column.column-offset-34 { 340 | margin-left: 33.3333%; 341 | } 342 | 343 | .row .column.column-offset-50 { 344 | margin-left: 50%; 345 | } 346 | 347 | .row .column.column-offset-66, .row .column.column-offset-67 { 348 | margin-left: 66.6666%; 349 | } 350 | 351 | .row .column.column-offset-75 { 352 | margin-left: 75%; 353 | } 354 | 355 | .row .column.column-offset-80 { 356 | margin-left: 80%; 357 | } 358 | 359 | .row .column.column-offset-90 { 360 | margin-left: 90%; 361 | } 362 | 363 | .row .column.column-10 { 364 | flex: 0 0 10%; 365 | max-width: 10%; 366 | } 367 | 368 | .row .column.column-20 { 369 | flex: 0 0 20%; 370 | max-width: 20%; 371 | } 372 | 373 | .row .column.column-25 { 374 | flex: 0 0 25%; 375 | max-width: 25%; 376 | } 377 | 378 | .row .column.column-33, .row .column.column-34 { 379 | flex: 0 0 33.3333%; 380 | max-width: 33.3333%; 381 | } 382 | 383 | .row .column.column-40 { 384 | flex: 0 0 40%; 385 | max-width: 40%; 386 | } 387 | 388 | .row .column.column-50 { 389 | flex: 0 0 50%; 390 | max-width: 50%; 391 | } 392 | 393 | .row .column.column-60 { 394 | flex: 0 0 60%; 395 | max-width: 60%; 396 | } 397 | 398 | .row .column.column-66, .row .column.column-67 { 399 | flex: 0 0 66.6666%; 400 | max-width: 66.6666%; 401 | } 402 | 403 | .row .column.column-75 { 404 | flex: 0 0 75%; 405 | max-width: 75%; 406 | } 407 | 408 | .row .column.column-80 { 409 | flex: 0 0 80%; 410 | max-width: 80%; 411 | } 412 | 413 | .row .column.column-90 { 414 | flex: 0 0 90%; 415 | max-width: 90%; 416 | } 417 | 418 | .row .column .column-top { 419 | align-self: flex-start; 420 | } 421 | 422 | .row .column .column-bottom { 423 | align-self: flex-end; 424 | } 425 | 426 | .row .column .column-center { 427 | -ms-grid-row-align: center; 428 | align-self: center; 429 | } 430 | 431 | @media (min-width: 40rem) { 432 | .row { 433 | flex-direction: row; 434 | margin-left: -1.0rem; 435 | width: calc(100% + 2.0rem); 436 | } 437 | .row .column { 438 | margin-bottom: inherit; 439 | padding: 0 1.0rem; 440 | } 441 | } 442 | 443 | a { 444 | color: #9b4dca; 445 | text-decoration: none; 446 | } 447 | 448 | a:focus, a:hover { 449 | color: #606c76; 450 | } 451 | 452 | dl, 453 | ol, 454 | ul { 455 | list-style: none; 456 | margin-top: 0; 457 | padding-left: 0; 458 | } 459 | 460 | dl dl, 461 | dl ol, 462 | dl ul, 463 | ol dl, 464 | ol ol, 465 | ol ul, 466 | ul dl, 467 | ul ol, 468 | ul ul { 469 | font-size: 90%; 470 | margin: 1.5rem 0 1.5rem 3.0rem; 471 | } 472 | 473 | ol { 474 | list-style: decimal inside; 475 | } 476 | 477 | ul { 478 | list-style: circle inside; 479 | } 480 | 481 | .button, 482 | button, 483 | dd, 484 | dt, 485 | li { 486 | margin-bottom: 1.0rem; 487 | } 488 | 489 | fieldset, 490 | input, 491 | select, 492 | textarea { 493 | margin-bottom: 1.5rem; 494 | } 495 | 496 | blockquote, 497 | dl, 498 | figure, 499 | form, 500 | ol, 501 | p, 502 | pre, 503 | table, 504 | ul { 505 | margin-bottom: 2.5rem; 506 | } 507 | 508 | table { 509 | border-spacing: 0; 510 | width: 100%; 511 | } 512 | 513 | td, 514 | th { 515 | border-bottom: 0.1rem solid #e1e1e1; 516 | padding: 1.2rem 1.5rem; 517 | text-align: left; 518 | } 519 | 520 | td:first-child, 521 | th:first-child { 522 | padding-left: 0; 523 | } 524 | 525 | td:last-child, 526 | th:last-child { 527 | padding-right: 0; 528 | } 529 | 530 | b, 531 | strong { 532 | font-weight: bold; 533 | } 534 | 535 | p { 536 | margin-top: 0; 537 | } 538 | 539 | h1, 540 | h2, 541 | h3, 542 | h4, 543 | h5, 544 | h6 { 545 | font-weight: 300; 546 | letter-spacing: -.1rem; 547 | margin-bottom: 2.0rem; 548 | margin-top: 0; 549 | } 550 | 551 | h1 { 552 | font-size: 4.6rem; 553 | line-height: 1.2; 554 | } 555 | 556 | h2 { 557 | font-size: 3.6rem; 558 | line-height: 1.25; 559 | } 560 | 561 | h3 { 562 | font-size: 2.8rem; 563 | line-height: 1.3; 564 | } 565 | 566 | h4 { 567 | font-size: 2.2rem; 568 | letter-spacing: -.08rem; 569 | line-height: 1.35; 570 | } 571 | 572 | h5 { 573 | font-size: 1.8rem; 574 | letter-spacing: -.05rem; 575 | line-height: 1.5; 576 | } 577 | 578 | h6 { 579 | font-size: 1.6rem; 580 | letter-spacing: 0; 581 | line-height: 1.4; 582 | } 583 | 584 | img { 585 | max-width: 100%; 586 | } 587 | 588 | .clearfix:after { 589 | clear: both; 590 | content: ' '; 591 | display: table; 592 | } 593 | 594 | .float-left { 595 | float: left; 596 | } 597 | 598 | .float-right { 599 | float: right; 600 | } 601 | 602 | /*# sourceMappingURL=milligram.css.map */ -------------------------------------------------------------------------------- /companies-app/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | 3 | services: 4 | companies_web: 5 | env_file: 6 | - .env 7 | build: . 8 | ports: 9 | - 3000:3000 10 | -------------------------------------------------------------------------------- /companies-app/index.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config({path: '.env'}); 2 | 3 | const _ = require('lodash'); 4 | const path = require('path'); 5 | const express = require('express'); 6 | const Hubspot = require('hubspot'); 7 | const bodyParser = require('body-parser'); 8 | 9 | 10 | const PORT = 3000; 11 | 12 | const CONTACT_TO_COMPANY_DEFINITION_ID = 1; 13 | const CLIENT_ID = process.env.HUBSPOT_CLIENT_ID; 14 | const CLIENT_SECRET = process.env.HUBSPOT_CLIENT_SECRET; 15 | const HUBSPOT_ASSOCIATION_DEFINITION = 'HUBSPOT_DEFINED'; 16 | const SCOPES = 'contacts'; 17 | const CONTACTS_COUNT = 10; 18 | const ADD_ACTION = 'Add selected to company'; 19 | const DELETE_ACTION = 'Delete selected from Company'; 20 | const REDIRECT_URI = `http://localhost:${PORT}/oauth-callback`; 21 | 22 | let tokenStore = {}; 23 | 24 | const checkEnv = (req, res, next) => { 25 | if (_.startsWith(req.url, '/error')) return next(); 26 | 27 | if (_.isNil(CLIENT_ID)) return res.redirect('/error?msg=Please set HUBSPOT_CLIENT_ID env variable to proceed'); 28 | if (_.isNil(CLIENT_SECRET)) return res.redirect('/error?msg=Please set HUBSPOT_CLIENT_SECRET env variable to proceed'); 29 | 30 | next(); 31 | }; 32 | 33 | const isAuthorized = () => { 34 | return !_.isEmpty(tokenStore.refresh_token); 35 | }; 36 | 37 | const checkAuthorization = (req, res, next) => { 38 | if (_.startsWith(req.url, '/error')) return next(); 39 | if (_.startsWith(req.url, '/login')) return next(); 40 | if (!isAuthorized()) return res.redirect('/login'); 41 | 42 | next(); 43 | }; 44 | 45 | const preparePropertiesForView = (companyProperties, allProperties) => { 46 | return _ 47 | .chain(allProperties) 48 | .filter((property) => { 49 | return !property.readOnlyValue && !property.calculated 50 | }) 51 | .map((property) => { 52 | return { 53 | name: property.name, 54 | label: property.label, 55 | value: _.get(companyProperties, `${property.name}.value`) 56 | } 57 | }) 58 | .value(); 59 | }; 60 | 61 | const preparePropertiesForRequest = (properties = {}) => { 62 | return _.map(properties, (value, name) => { 63 | return {name, value} 64 | }) 65 | }; 66 | 67 | const prepareContactsForView = (contacts) => { 68 | return _.map(contacts, (contact) => { 69 | const id = _.get(contact, 'vid'); 70 | const firstNameProperty = _.find(contact.properties, {name: 'firstname'}); 71 | const lastNameProperty = _.find(contact.properties, {name: 'lastname'}); 72 | const name = `${_.get(firstNameProperty, 'value') || ''} ${_.get(lastNameProperty, 'value') || ''}`; 73 | return {id, name} 74 | }) 75 | }; 76 | 77 | const prepareAllContactsForView = (contacts) => { 78 | return _.map(contacts, (contact) => { 79 | const id = _.get(contact, 'vid'); 80 | const firstName = _.get(contact, 'properties.firstname.value') || ''; 81 | const lastName = _.get(contact, 'properties.lastname.value') || ''; 82 | const name = `${firstName} ${lastName}`; 83 | return {id, name} 84 | }) 85 | }; 86 | 87 | const prepareCompaniesForView = (companies) => { 88 | return _.map(companies, (company) => { 89 | const id = _.get(company, 'companyId'); 90 | const name = _.get(company, 'properties.name.value'); 91 | const domain = _.get(company, 'properties.domain.value'); 92 | return {id, name, domain} 93 | }) 94 | }; 95 | 96 | const logResponse = (response) => { 97 | console.log('Response from API', JSON.stringify(response, null, 2)); 98 | }; 99 | 100 | 101 | const app = express(); 102 | 103 | const hubspot = new Hubspot({ 104 | clientId: CLIENT_ID, 105 | clientSecret: CLIENT_SECRET, 106 | redirectUri: REDIRECT_URI, 107 | scopes: SCOPES, 108 | }); 109 | 110 | const getAllCompanies = async () => { 111 | 112 | // Get all companies 113 | // GET /companies/v2/companies/paged 114 | // https://developers.hubspot.com/docs/methods/companies/get-all-companies 115 | console.log('Calling hubspot.companies.get API method. Retrieve all companies.'); 116 | const companiesResponse = await hubspot.companies.get({properties: ['name', 'domain']}); 117 | logResponse(companiesResponse); 118 | 119 | return companiesResponse.companies; 120 | }; 121 | 122 | const getCompaniesByDomain = async (domain) => { 123 | 124 | // Search for companies by domain 125 | // POST /companies/v2/domains/:domain/companies 126 | // https://developers.hubspot.com/docs/methods/companies/search_companies_by_domain 127 | console.log('Calling hubspot.companies.getByDomain API method. Retrieve companies by domain.'); 128 | 129 | const data = { 130 | requestOptions: { 131 | properties: [ 132 | 'domain', 133 | 'name', 134 | ] 135 | }, 136 | offset: { 137 | isPrimary: true, 138 | companyId: 0 139 | } 140 | }; 141 | 142 | const companiesResponse = await hubspot.companies.getByDomain(domain, data); 143 | logResponse(companiesResponse); 144 | 145 | return companiesResponse.results; 146 | }; 147 | 148 | const createCompany = async (properties) => { 149 | 150 | // Create a Company 151 | // POST /companies/v2/companies/ 152 | // https://developers.hubspot.com/docs/methods/companies/create_company 153 | console.log('Calling hubspot.companies.create API method. Create company.'); 154 | return hubspot.companies.create({properties}); 155 | }; 156 | 157 | const updateCompany = (id, properties) => { 158 | 159 | // Update a Company 160 | // PUT /companies/v2/companies/:companyId 161 | // https://developers.hubspot.com/docs/methods/companies/update_company 162 | console.log('Calling hubspot.companies.update API method. Updating company properties.'); 163 | return hubspot.companies.update(id, {properties}); 164 | }; 165 | 166 | app.use(express.static('css')); 167 | app.set('view engine', 'pug'); 168 | app.set('views', path.join(__dirname, 'views')); 169 | 170 | app.use(bodyParser.urlencoded({ 171 | limit: '50mb', 172 | extended: true, 173 | })); 174 | 175 | app.use(bodyParser.json({ 176 | limit: '50mb', 177 | extended: true, 178 | })); 179 | 180 | app.use(checkEnv); 181 | 182 | app.get('/', checkAuthorization, (req, res) => { 183 | res.redirect('/companies'); 184 | }); 185 | 186 | app.get('/companies', checkAuthorization, async (req, res) => { 187 | try { 188 | 189 | const search = _.get(req, 'query.search'); 190 | const companiesResponse = _.isNil(search) 191 | ? await getAllCompanies() 192 | : await getCompaniesByDomain(search); 193 | const companies = prepareCompaniesForView(companiesResponse); 194 | res.render('companies', {companies}); 195 | } catch (e) { 196 | console.error(e); 197 | res.redirect(`/error?msg=${e.message}`); 198 | } 199 | }); 200 | 201 | app.get('/companies/new', checkAuthorization, async (req, res) => { 202 | try { 203 | 204 | // Get all Company Properties 205 | // GET /properties/v1/companies/properties/ 206 | // https://developers.hubspot.com/docs/methods/companies/get_company_properties 207 | console.log('Calling hubspot.companies.properties.get API method. Retrieve company properties.'); 208 | const allProperties = await hubspot.companies.properties.get(); 209 | logResponse(allProperties); 210 | const properties = preparePropertiesForView({}, allProperties); 211 | 212 | res.render('company', {companyId: '', properties, contacts: null}); 213 | } catch (e) { 214 | console.error(e); 215 | res.redirect(`/error?msg=${e.message}`); 216 | } 217 | }); 218 | 219 | app.get('/companies/:companyId', checkAuthorization, async (req, res) => { 220 | try { 221 | const companyId = _.get(req, 'params.companyId'); 222 | 223 | // Get a Company 224 | // GET /companies/v2/companies/:companyId 225 | // https://developers.hubspot.com/docs/methods/companies/get_company 226 | console.log('Calling hubspot.companies.getById API method. Retrieve company by id.'); 227 | const company = await hubspot.companies.getById(companyId); 228 | logResponse(company); 229 | 230 | // Get Contacts at a Company 231 | // GET /companies/v2/companies/:companyId/contacts 232 | // https://developers.hubspot.com/docs/methods/companies/get_company_contacts 233 | console.log('Calling hubspot.companies.getContacts API method. Retrieve company contacts by id.'); 234 | const contactsResponse = await hubspot.companies.getContacts(companyId); 235 | logResponse(contactsResponse); 236 | 237 | // Get all Company Properties 238 | // GET /properties/v1/companies/properties/ 239 | // https://developers.hubspot.com/docs/methods/companies/get_company_properties 240 | console.log('Calling hubspot.companies.properties.get API method. Retrieve company properties.'); 241 | const allProperties = await hubspot.companies.properties.get(); 242 | logResponse(allProperties); 243 | 244 | const contacts = prepareContactsForView(contactsResponse.contacts); 245 | const properties = preparePropertiesForView(company.properties, allProperties); 246 | res.render('company', {companyId, properties, contacts}); 247 | } catch (e) { 248 | console.error(e); 249 | res.redirect(`/error?msg=${e.message}`); 250 | } 251 | }); 252 | 253 | app.get('/companies/:companyId/contacts', checkAuthorization, async (req, res) => { 254 | try { 255 | const search = _.get(req, 'query.search') || ''; 256 | const companyId = _.get(req, 'params.companyId'); 257 | let contactsResponse = {contacts: []}; 258 | if (_.isNil(search)) { 259 | 260 | // Get all contacts 261 | // GET /contacts/v1/lists/all/contacts/all 262 | // https://developers.hubspot.com/docs/methods/contacts/get_contacts 263 | console.log('Calling contacts.get API method. Retrieve all contacts.'); 264 | contactsResponse = await hubspot.contacts.get({count: CONTACTS_COUNT}); 265 | 266 | } else { 267 | 268 | // Search for contacts by email, name, or company name 269 | // GET /contacts/v1/search/query 270 | // https://developers.hubspot.com/docs/methods/contacts/search_contacts 271 | console.log('Calling contacts.search API method. Retrieve contacts with search query:', search); 272 | contactsResponse = await hubspot.contacts.search(search); 273 | 274 | } 275 | logResponse(contactsResponse); 276 | 277 | const contacts = prepareAllContactsForView(contactsResponse.contacts); 278 | res.render('contacts', {companyId, contacts}); 279 | } catch (e) { 280 | console.error(e); 281 | res.redirect(`/error?msg=${e.message}`); 282 | } 283 | }); 284 | 285 | app.post('/companies/:companyId/contacts', checkAuthorization, async (req, res) => { 286 | try { 287 | const companyId = _.get(req, 'params.companyId'); 288 | const action = _.get(req, 'body.action'); 289 | const contactsIds = _.chain(req) 290 | .get('body.contactsIds') 291 | .values() 292 | .value(); 293 | 294 | if (!_.includes([ADD_ACTION, DELETE_ACTION], action)) { 295 | return res.redirect(`/error?msg=Unknown contacts action: ${action}`); 296 | } 297 | 298 | const data = _.map(contactsIds, (id) => { 299 | return { 300 | fromObjectId: id, 301 | toObjectId: companyId, 302 | category: HUBSPOT_ASSOCIATION_DEFINITION, 303 | definitionId: CONTACT_TO_COMPANY_DEFINITION_ID 304 | }; 305 | }); 306 | 307 | if (action === DELETE_ACTION) { 308 | 309 | // Delete multiple associations between CRM objects 310 | // PUT /crm-associations/v1/associations/delete-batch 311 | // https://developers.hubspot.com/docs/methods/crm-associations/batch-delete-associations 312 | console.log('Calling hubspot.crm.associations.deleteBatch API method. Delete contacts associations.'); 313 | const companyUpdateResponse = await hubspot.crm.associations.deleteBatch(data); 314 | logResponse(companyUpdateResponse); 315 | 316 | } else { 317 | 318 | // Create multiple associations between CRM objects 319 | // PUT /crm-associations/v1/associations/create-batch 320 | // https://developers.hubspot.com/docs/methods/crm-associations/batch-associate-objects 321 | console.log('Calling hubspot.crm.associations.createBatch API method. Add contacts associations.'); 322 | const companyUpdateResponse = await hubspot.crm.associations.createBatch(data); 323 | logResponse(companyUpdateResponse); 324 | 325 | } 326 | res.redirect(`/companies/${companyId}`); 327 | } catch (e) { 328 | console.error(e); 329 | res.redirect(`/error?msg=${e.message}`); 330 | } 331 | }); 332 | 333 | app.post('/companies/:companyId*?', checkAuthorization, async (req, res) => { 334 | try { 335 | const companyId = _.get(req, 'params.companyId'); 336 | const properties = preparePropertiesForRequest(_.get(req, 'body')); 337 | 338 | const result = _.isNil(companyId) 339 | ? await createCompany(properties) 340 | : await updateCompany(companyId, properties); 341 | 342 | logResponse(result); 343 | 344 | const id = _.get(result, 'companyId'); 345 | res.redirect(`/companies/${id}`); 346 | } catch (e) { 347 | console.error(e); 348 | const error_message_truncated = e.message.substring(0, 2000); 349 | console.error("eror message original and truncated", e.message, error_message_truncated); 350 | res.redirect(`/error?msg=${error_message_truncated}`); 351 | } 352 | }); 353 | 354 | app.get('/login', async (req, res) => { 355 | console.log('Is logged-in', isAuthorized()); 356 | if (isAuthorized()) return res.redirect('/'); 357 | res.render('login'); 358 | }); 359 | 360 | app.get('/oauth', async (req, res) => { 361 | 362 | const authorizationUrlParams = { 363 | client_id: CLIENT_ID, 364 | redirect_uri: REDIRECT_URI, 365 | scopes: SCOPES, 366 | }; 367 | 368 | // Use the client to get authorization Url 369 | // https://www.npmjs.com/package/hubspot 370 | console.log('Creating authorization Url'); 371 | const authorizationUrl = hubspot.oauth.getAuthorizationUrl(authorizationUrlParams); 372 | console.log('Authorization Url', authorizationUrl); 373 | 374 | res.redirect(authorizationUrl); 375 | }); 376 | 377 | app.get('/oauth-callback', async (req, res) => { 378 | const code = _.get(req, 'query.code'); 379 | 380 | // Get OAuth 2.0 Access Token and Refresh Tokens 381 | // POST /oauth/v1/token 382 | // https://developers.hubspot.com/docs/methods/oauth2/get-access-and-refresh-tokens 383 | console.log('Retrieving access token by code:', code); 384 | tokenStore = await hubspot.oauth.getAccessToken({code}); 385 | logResponse(tokenStore); 386 | 387 | // Set token for the 388 | // https://www.npmjs.com/package/hubspot 389 | hubspot.setAccessToken((tokenStore.access_token)); 390 | res.redirect('/'); 391 | }); 392 | 393 | app.get('/error', (req, res) => { 394 | res.render('error', {error: req.query.msg}); 395 | }); 396 | 397 | app.use((error, req, res, next) => { 398 | res.render('error', {error: error.message}); 399 | }); 400 | 401 | app.listen(PORT, () => console.log(`Listening on http://localhost:${PORT}`)); 402 | -------------------------------------------------------------------------------- /companies-app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hubspot-companies", 3 | "version": "1.0.0", 4 | "engines": { 5 | "node": ">= 10.13.0" 6 | }, 7 | "description": "hubspot-node client sample applications", 8 | "main": "index.js", 9 | "scripts": { 10 | "start": "node index.js" 11 | }, 12 | "keywords": [ 13 | "hubspot", 14 | "companies", 15 | "sample", 16 | "example" 17 | ], 18 | "author": "hubspot", 19 | "license": "Apache-2.0", 20 | "dependencies": { 21 | "body-parser": "^1.19.0", 22 | "dotenv": "^8.1.0", 23 | "express": "^4.16.3", 24 | "hubspot": "^2.3.2", 25 | "lodash": "^4.17.15", 26 | "pug": "^2.0.4" 27 | }, 28 | "devDependencies": { 29 | "nodemon": "^1.19.3" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /companies-app/views/companies.pug: -------------------------------------------------------------------------------- 1 | extends includes/layout 2 | block content 3 | - const getCompaniesApiDetails = ' Get all companies\n GET /companies/v2/companies/paged\n hubspot.companies.get(opts)'; 4 | - const searchCompaniesApiDetails = ' Search for companies by domain\n POST /companies/v2/domains/:domain/companies\n hubspot.companies.getByDomain(domain)'; 5 | 6 | section(class='container') 7 | 8 | pre #{getCompaniesApiDetails} 9 | form(action='/companies') 10 | fieldset 11 | input(type='text' name='search' placeholder='Search by domain..' id='search' value='') 12 | 13 | pre #{getCompaniesApiDetails} 14 | table 15 | thead 16 | tr 17 | th ID 18 | th Name 19 | th Domain 20 | tbody 21 | each company in companies 22 | tr 23 | td 24 | a(class='navigation-link' href=`/companies/${company.id}`)= company.id 25 | td= company.name 26 | td= company.domain 27 | a(href='/companies/new') 28 | input(class='button-primary' type='button' value='New Company') 29 | -------------------------------------------------------------------------------- /companies-app/views/company.pug: -------------------------------------------------------------------------------- 1 | extends includes/layout 2 | block content 3 | - const companiesApiDetails = ' Get a Company\n GET /companies/v2/companies/:companyId\n hubspot.companies.getById(companyId)'; 4 | - const companyPropertiesApiDetails = ' Get all Company Properties\n GET /properties/v1/companies/properties/\n hubspot.companies.properties.get(query) // query is optional'; 5 | - const companyApiSaveDetails = ' Update a Company\n PUT /companies/v2/companies/:companyId\n hubspot.companies.update(id, data)'; 6 | - const companyApiCreateDetails = ' Create a Company\n POST /companies/v2/companies/\n hubspot.companies.create(data)'; 7 | - const companyContactsApiDetails = ' Get Contacts at a Company\n GET /companies/v2/companies/:companyId/contacts\n hubspot.companies.getContacts(id, options)'; 8 | 9 | section(class='container') 10 | div(class='row') 11 | div(class='column column-50') 12 | h3 Company Properties 13 | pre #{`${companiesApiDetails}\n\n${companyPropertiesApiDetails}`} 14 | form(method='post' action=`/companies/${companyId}`) 15 | fieldset 16 | each property in properties 17 | label(for=property.name) #{property.label} 18 | input(name=property.name id=property.name type='text' value=property.value) 19 | 20 | pre #{`${companyApiCreateDetails}\n\n${companyApiSaveDetails}`} 21 | input(class='button-primary' type='submit' value='Save') 22 | 23 | -if (contacts) 24 | div(class='column column-50') 25 | h3 Contacts 26 | pre #{companyContactsApiDetails} 27 | table 28 | thead 29 | tr 30 | th ID 31 | th Name 32 | tbody 33 | each contact in contacts 34 | tr 35 | td #{contact.id} 36 | td #{contact.name} 37 | 38 | a(href=`/companies/${companyId}/contacts`) 39 | input(class='button-primary' type='button' value='Manage Contacts') 40 | -------------------------------------------------------------------------------- /companies-app/views/contacts.pug: -------------------------------------------------------------------------------- 1 | extends includes/layout 2 | block content 3 | -const searchDetails = ' Search for contacts by email, name, or company name\n GET /contacts/v1/search/query\n hubspot.contacts.search(search)' 4 | -const createCRMAssociateAPIDetails = ' Create multiple associations between CRM objects\n PUT /crm-associations/v1/associations/create-batch\n hubspot.crm.associations.createBatch(data)' 5 | -const deleteCRMAssociateAPIDetails = ' Delete multiple associations between CRM objects\n PUT /crm-associations/v1/associations/delete-batch\n hubspot.crm.associations.deleteBatch(data)' 6 | .container 7 | pre #{searchDetails} 8 | form 9 | fieldset 10 | input(type='text' name='search' placeholder='Search..' id='search') 11 | input(type='hidden' name='companyId') 12 | 13 | form(method='post') 14 | fieldset 15 | table 16 | thead 17 | tr 18 | th ID 19 | th Name 20 | th Selected 21 | tbody 22 | each contact in contacts 23 | tr 24 | td #{contact.id} 25 | td #{contact.name} 26 | td 27 | input(type='checkbox' name=`contactsIds[${contact.id}]` value=`${contact.id}`) 28 | 29 | .row 30 | .column 31 | pre #{createCRMAssociateAPIDetails} 32 | input(type='submit' name='action' value='Add selected to company') 33 | .column-offset-50 34 | pre #{deleteCRMAssociateAPIDetails} 35 | input(type='submit' name='action' value='Delete selected from Company') 36 | -------------------------------------------------------------------------------- /companies-app/views/error.pug: -------------------------------------------------------------------------------- 1 | extends includes/layout 2 | block content 3 | section(class='container') 4 | h4 Error 5 | p #{error} 6 | -------------------------------------------------------------------------------- /companies-app/views/includes/footer.pug: -------------------------------------------------------------------------------- 1 | footer(class='footer') 2 | section(class='container') 3 | -------------------------------------------------------------------------------- /companies-app/views/includes/head.pug: -------------------------------------------------------------------------------- 1 | head 2 | meta(charset='UTF-8') 3 | meta(name='description' content='HubSpot JavaScript Sample Companies') 4 | title HubSpot JavaScript Sample Companies 5 | link(rel='stylesheet' href='//fonts.googleapis.com/css?family=Roboto:300,300italic,700,700italic') 6 | link(rel='stylesheet' href='//cdnjs.cloudflare.com/ajax/libs/normalize/5.0.0/normalize.css') 7 | link(rel='stylesheet' href='//cdnjs.cloudflare.com/ajax/libs/milligram/1.3.0/milligram.css') 8 | link(rel='stylesheet' href='/main.css') 9 | -------------------------------------------------------------------------------- /companies-app/views/includes/header.pug: -------------------------------------------------------------------------------- 1 | header 2 | nav(class='navigation') 3 | div(class='container') 4 | a(class='navigation-title' href='/') 5 | h3(class='title') HubSpot JavaScript Sample Companies 6 | 7 | ul(class='navigation-list float-right') 8 | li(class='navigation-item') 9 | a(class='navigation-link' href='/companies') Companies 10 | -------------------------------------------------------------------------------- /companies-app/views/includes/layout.pug: -------------------------------------------------------------------------------- 1 | doctype html 2 | html(lang='en') 3 | include head 4 | body 5 | main(class='wrapper') 6 | include header 7 | block content 8 | include footer 9 | -------------------------------------------------------------------------------- /companies-app/views/login.pug: -------------------------------------------------------------------------------- 1 | extends includes/layout 2 | block content 3 | section(class='container') 4 | div(class='row authorize-button') 5 | a(class='button' href='/oauth') Authorize 6 | -------------------------------------------------------------------------------- /contacts-app/.env.template: -------------------------------------------------------------------------------- 1 | HUBSPOT_API_KEY=demo 2 | -------------------------------------------------------------------------------- /contacts-app/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:10 2 | 3 | WORKDIR /contacts-app 4 | 5 | COPY ./index.js ./ 6 | COPY ./package.json ./ 7 | COPY ./public ./public 8 | COPY ./views ./views 9 | RUN npm install 10 | 11 | 12 | ENTRYPOINT [ "node", "index.js" ] 13 | EXPOSE 3000 14 | -------------------------------------------------------------------------------- /contacts-app/README.md: -------------------------------------------------------------------------------- 1 | # HubSpot-nodejs contacts sample app 2 | 3 | This is a sample app for the [node-hubspot wrapper](https://www.npmjs.com/package/hubspot). Currently, this app focuses on demonstrating the functionality of [Contacts API](https://developers.hubspot.com/docs/methods/contacts/contacts-overview) endpoints and their related actions. 4 | 5 | Please see the documentation on [How do I create an app in HubSpot?](https://developers.hubspot.com/docs/faq/how-do-i-create-an-app-in-hubspot) 6 | 7 | ### HubSpot Public API links used in this application 8 | 9 | - [Create or update a contact](https://developers.hubspot.com/docs/methods/contacts/create_or_update) 10 | - [Get a contact record by its vid](https://developers.hubspot.com/docs/methods/contacts/get_contact) 11 | - [Get all contacts](https://developers.hubspot.com/docs/methods/contacts/get_contacts) 12 | - [Get All Contacts Properties](https://developers.hubspot.com/docs/methods/contacts/v2/get_contacts_properties) 13 | - [Get List of Owners](https://developers.hubspot.com/docs/methods/owners/get_owners) 14 | - [Update a contact property](https://developers.hubspot.com/docs/methods/contacts/v2/update_contact_property) 15 | - [Create a contact property](https://developers.hubspot.com/docs/methods/contacts/v2/create_contacts_property) 16 | - [Search for contacts by email, name, or company name](https://developers.hubspot.com/docs/methods/contacts/search_contacts) 17 | 18 | Application also demonstrates [contacts pagination](https://git.hubteam.com/HubSpot/hubspot-integration-samples-js/blob/master/contacts-app/index.js) by saving first 10 pages of contacts into a csv file 19 | 20 | ### Setup App 21 | 22 | Make sure you have [Docker](https://www.docker.com/) installed. 23 | Make sure you have [Docker Compose](https://docs.docker.com/compose/) installed. 24 | 25 | ### Configure 26 | 27 | 1. Copy .env.template to .env 28 | 2. Paste your HubSpot API Key as the value for HUBSPOT_API_KEY in .env 29 | 30 | ### Running 31 | 32 | The best way to run this project (with the least configuration), is using docker cli. 33 | 34 | ```bash 35 | docker-compose up 36 | ``` 37 | You should now be able to navigate to [http://localhost:3000](http://localhost:3000) and use the application. 38 | -------------------------------------------------------------------------------- /contacts-app/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | 3 | services: 4 | nodejs_web: 5 | env_file: 6 | - .env 7 | build: . 8 | ports: 9 | - 3000:3000 10 | -------------------------------------------------------------------------------- /contacts-app/index.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config({path: '.env'}); 2 | require('csv-express'); 3 | 4 | const _ = require('lodash'); 5 | const path = require('path'); 6 | const express = require('express'); 7 | const Hubspot = require('hubspot'); 8 | const bodyParser = require('body-parser'); 9 | 10 | 11 | const PORT = 3000; 12 | const CONTACTS_COUNT = 10; 13 | const CONTACT_OBJECT_TYPE = 'CONTACT'; 14 | 15 | 16 | const checkEnv = (req, res, next) => { 17 | if (_.startsWith(req.url, '/error')) return next(); 18 | if (_.isNil(process.env.HUBSPOT_API_KEY)) return res.redirect('/error?msg=Please set HUBSPOT_API_KEY env variable to proceed'); 19 | 20 | next(); 21 | }; 22 | 23 | const prepareContactsContent = (contacts) => { 24 | return _.map(contacts, (contact) => { 25 | const companyName = _.get(contact, 'properties.company.value') || ''; 26 | return {vid: contact.vid, name: getFullName(contact.properties), companyName} 27 | }); 28 | }; 29 | 30 | const toDate = (value) => { 31 | return _.isNil(value) ? null : (new Date(value)).getTime(); 32 | }; 33 | 34 | const prepareEngagements = (engagements) => { 35 | return _.map(engagements, (engagementDetails) => { 36 | const details = _.pick(engagementDetails.engagement, ['id', 'type']); 37 | details.title = _.get(engagementDetails, 'metadata.title') || ''; 38 | console.log(details); 39 | return details; 40 | }); 41 | }; 42 | 43 | const getEditableProperties = (properties) => { 44 | return _.reduce(properties, (editableProps, property) => { 45 | if (!isReadOnly(property)) editableProps[property.name] = {name: property.name, label: property.label}; 46 | return editableProps 47 | }, {}) 48 | }; 49 | 50 | const getMutableProperties = (properties) => { 51 | return _.reduce(properties, (mutableProps, property) => { 52 | if (!isMutable(property)) mutableProps[property.name] = property; 53 | return mutableProps 54 | }, {}) 55 | }; 56 | 57 | const getContactEditableProperties = (contactProperties, editableProperties) => { 58 | return _.reduce(editableProperties, (contactEditableProperties, property, propertyName) => { 59 | contactEditableProperties[propertyName] = property; 60 | const contactProperty = contactProperties[propertyName]; 61 | if (contactProperty) contactEditableProperties[propertyName].value = contactProperty.value; 62 | 63 | return contactEditableProperties; 64 | }, {}) 65 | }; 66 | 67 | const getFullName = (contactProperties) => { 68 | const firstName = _.get(contactProperties, 'firstname.value') || ''; 69 | const lastName = _.get(contactProperties, 'lastname.value') || ''; 70 | return `${firstName} ${lastName}` 71 | }; 72 | 73 | const isReadOnly = (property) => { 74 | return property.readOnlyValue || property.calculated 75 | }; 76 | 77 | const isMutable = (property) => { 78 | return property.readOnlyDefinition 79 | }; 80 | 81 | const getPropertyDetails = (property = {}) => { 82 | return { 83 | name: {label: 'Name', value: property.name}, 84 | label: {label: 'Label', value: property.label}, 85 | description: {label: 'Description', value: property.description}, 86 | groupName: {label: 'Group Name', value: property.groupName}, 87 | type: {label: 'Type', value: property.type}, 88 | } 89 | }; 90 | 91 | const toCsv = (contacts, properties) => { 92 | return _.map(contacts, (contact) => { 93 | 94 | const csvContact = _.reduce(properties, (csvContact, property) => { 95 | csvContact[property.label] = _.get(contact, `properties.${property.name}.value`) || ''; 96 | return csvContact; 97 | }, {}); 98 | 99 | console.log(csvContact); 100 | return csvContact 101 | }); 102 | }; 103 | 104 | 105 | const app = express(); 106 | const hubspot = new Hubspot({apiKey: process.env.HUBSPOT_API_KEY}); 107 | 108 | app.use(express.static('css')); 109 | app.use(express.static('html')); 110 | 111 | app.use(bodyParser.urlencoded({ 112 | limit: '50mb', 113 | extended: true, 114 | })); 115 | 116 | app.use(bodyParser.json({ 117 | limit: '50mb', 118 | extended: true, 119 | })); 120 | 121 | app.use(express.static('public')); 122 | app.set('view engine', 'pug'); 123 | app.set('views', path.join(__dirname, 'views')); 124 | 125 | app.use(checkEnv); 126 | 127 | app.get('/', async (req, res) => { 128 | res.redirect('/contacts') 129 | }); 130 | 131 | app.post('/contacts', async (req, res) => { 132 | try { 133 | const email = _.get(req, 'body.email'); 134 | if (!_.isNil(email)) { 135 | const properties = _.map(req.body, (value, property) => { 136 | return {property, value} 137 | }); 138 | 139 | // Create or update a contact 140 | // POST /contacts/v1/contact/createOrUpdate/email/:contact_email 141 | // https://developers.hubspot.com/docs/methods/contacts/create_or_update 142 | console.log('Calling contacts.create_or_update API method. Create new contact with email:', email); 143 | const result = await hubspot.contacts.createOrUpdate(email, {properties}); 144 | console.log('Response from API', result); 145 | 146 | res.redirect('/contacts'); 147 | } 148 | } catch (e) { 149 | console.error(e); 150 | res.redirect(`/error?msg=${e.message}`) 151 | } 152 | }); 153 | 154 | app.post('/contacts/:vid', async (req, res) => { 155 | try { 156 | const email = _.get(req, 'body.email'); 157 | if (!_.isNil(email)) { 158 | const properties = _.map(req.body, (value, property) => { 159 | return {property, value} 160 | }); 161 | 162 | // Create or update a contact 163 | // POST /contacts/v1/contact/createOrUpdate/email/:contact_email 164 | // https://developers.hubspot.com/docs/methods/contacts/create_or_update 165 | console.log('Calling contacts.create_or_update API method. Update contact with email:', email); 166 | const result = await hubspot.contacts.createOrUpdate(email, {properties}); 167 | console.log('Response from API', result); 168 | 169 | res.redirect('/contacts'); 170 | } 171 | } catch (e) { 172 | console.error(e); 173 | res.redirect(`/error?msg=${e.message}`); 174 | } 175 | }); 176 | 177 | app.get('/contacts', async (req, res) => { 178 | try { 179 | const search = _.get(req, 'query.search'); 180 | let contactsResponse = {contacts: []}; 181 | if (_.isNil(search)) { 182 | 183 | // Get all contacts 184 | // GET /contacts/v1/lists/all/contacts/all 185 | // https://developers.hubspot.com/docs/methods/contacts/get_contacts 186 | console.log('Calling contacts.get API method. Retrieve all contacts.'); 187 | contactsResponse = await hubspot.contacts.get({count: CONTACTS_COUNT}); 188 | console.log('Response from API', contactsResponse); 189 | 190 | } else { 191 | 192 | // Search for contacts by email, name, or company name 193 | // GET /contacts/v1/search/query 194 | // https://developers.hubspot.com/docs/methods/contacts/search_contacts 195 | console.log('Calling contacts.search API method. Retrieve contacts with search query:', search); 196 | contactsResponse = await hubspot.contacts.search(search); 197 | console.log('Response from API', contactsResponse); 198 | 199 | } 200 | 201 | res.render('contacts', { contacts: prepareContactsContent(contactsResponse.contacts), search}); 202 | } catch (e) { 203 | console.error(e); 204 | res.redirect(`/error?msg=${e.message}`); 205 | } 206 | }); 207 | 208 | app.get('/contacts/new', async (req, res) => { 209 | try { 210 | // Get All Contacts Properties 211 | // GET /properties/v1/contacts/properties 212 | // https://developers.hubspot.com/docs/methods/contacts/v2/get_contacts_properties 213 | console.log('Calling contacts.properties.get API method. Retrieve all contacts properties'); 214 | const hubspotProperties = await hubspot.contacts.properties.get(); 215 | console.log('Response from API', hubspotProperties); 216 | 217 | // Get List of Owners 218 | // GET /owners/v2/owners/ 219 | // https://developers.hubspot.com/docs/methods/owners/get_owners 220 | console.log('Calling hubspot.owners.get API method. Retrieve all contacts owners'); 221 | const owners = await hubspot.owners.get(); 222 | console.log('Response from API', owners); 223 | 224 | const editableProperties = getEditableProperties(hubspotProperties); 225 | const properties = getContactEditableProperties({}, editableProperties); 226 | 227 | res.render('list', {items: properties, owners, action: '/contacts'}); 228 | } catch (e) { 229 | console.error(e); 230 | res.redirect(`/error?msg=${e.message}`); 231 | } 232 | }); 233 | 234 | app.get('/contacts/:vid', async (req, res) => { 235 | try { 236 | const vid = _.get(req, 'params.vid'); 237 | if (_.isNil(vid)) return res.redirect('/error?msg=Missed contact'); 238 | 239 | // Get a contact record by its vid 240 | // GET /contacts/v1/contact/vid/:vid/profile 241 | // https://developers.hubspot.com/docs/methods/contacts/get_contact 242 | console.log('Calling contacts.getById API method. Retrieve a contacts by vid:', vid); 243 | const contact = await hubspot.contacts.getById(vid); 244 | console.log('Response from API', contact); 245 | 246 | // Get All Contacts Properties 247 | // GET /properties/v1/contacts/properties 248 | // https://developers.hubspot.com/docs/methods/contacts/v2/get_contacts_properties 249 | console.log('Calling contacts.properties.get API method. Retrieve all contacts properties'); 250 | const hubspotProperties = await hubspot.contacts.properties.get(); 251 | console.log('Response from API', hubspotProperties); 252 | 253 | // Get List of Owners 254 | // GET /owners/v2/owners/ 255 | // https://developers.hubspot.com/docs/methods/owners/get_owners 256 | console.log('Calling hubspot.owners.get API method. Retrieve all contacts owners'); 257 | const owners = await hubspot.owners.get(); 258 | console.log('Response from API', owners); 259 | 260 | // Get Associated Engagements 261 | // GET /engagements/v1/engagements/associated/:objectType/:objectId/paged 262 | // https://developers.hubspot.com/docs/methods/engagements/get_associated_engagements 263 | console.log('Calling hubspot.engagements.getAssociated API method. Retrieve all contacts engagements'); 264 | const hubspotEngagements = await hubspot.engagements.getAssociated(CONTACT_OBJECT_TYPE, vid); 265 | console.log('Response from API', hubspotEngagements); 266 | 267 | const editableProperties = getEditableProperties(hubspotProperties); 268 | const properties = getContactEditableProperties(contact.properties, editableProperties); 269 | const engagements = prepareEngagements(hubspotEngagements.results); 270 | 271 | res.render('list', {items: properties, engagements, owners, action: `/contacts/${vid}`, engagementAction: `/contacts/${vid}/engagement`}); 272 | } catch (e) { 273 | console.error(e); 274 | res.redirect(`/error?msg=${e.message}`); 275 | } 276 | }); 277 | 278 | app.get('/contacts/:vid/engagement', async (req, res) => { 279 | try { 280 | const vid = _.get(req, 'params.vid'); 281 | if (_.isNil(vid)) return res.redirect('/error?msg=Missed contact'); 282 | res.render('engagements', {vid}); 283 | } catch (e) { 284 | console.error(e); 285 | res.redirect(`/error?msg=${e.message}`); 286 | } 287 | }); 288 | 289 | app.post('/contacts/:vid/engagement', async (req, res) => { 290 | try { 291 | const vid = _.get(req, 'params.vid'); 292 | let payload = _.clone(req.body); 293 | payload = _.set(payload, 'metadata.startTime', toDate(_.get(payload, 'metadata.startTime'))); 294 | payload = _.set(payload, 'metadata.endTime', toDate(_.get(payload, 'metadata.endTime'))); 295 | 296 | // Create an Engagement 297 | // POST /engagements/v1/engagements 298 | // https://developers.hubspot.com/docs/methods/engagements/create_engagement 299 | console.log('Calling hubspot.engagements.create API method. Create contact engagement'); 300 | const result = await hubspot.engagements.create(payload); 301 | console.log('Response from API', result); 302 | 303 | res.redirect(`/contacts/${vid}`); 304 | } catch (e) { 305 | console.error(e); 306 | res.redirect(`/error?msg=${e.message}`); 307 | } 308 | }); 309 | 310 | app.get('/properties', async (req, res) => { 311 | try { 312 | // Get All Contacts Properties 313 | // GET /properties/v1/contacts/properties 314 | // https://developers.hubspot.com/docs/methods/contacts/v2/get_contacts_properties 315 | console.log('Calling contacts.properties.get API method. Retrieve all contacts properties'); 316 | const properties = await hubspot.contacts.properties.get(); 317 | console.log('Response from API', properties); 318 | 319 | const mutableProperties = getMutableProperties(properties); 320 | 321 | res.render('properties', {properties: mutableProperties}); 322 | } catch (e) { 323 | console.error(e); 324 | res.redirect(`/error?msg=${e.message}`); 325 | } 326 | }); 327 | 328 | app.post('/properties', async (req, res) => { 329 | try { 330 | 331 | // Create a contact property 332 | // POST /properties/v1/contacts/properties 333 | // https://developers.hubspot.com/docs/methods/contacts/v2/create_contacts_property 334 | console.log('Calling contacts.properties.create API method. Create contact property'); 335 | const result = await hubspot.contacts.properties.create(req.body); 336 | console.log('Response from API', result); 337 | 338 | res.redirect('/properties'); 339 | } catch (e) { 340 | console.error(e); 341 | res.redirect(`/error?msg=${e.message}`); 342 | } 343 | }); 344 | 345 | app.post('/properties/:name', async (req, res) => { 346 | try { 347 | const name = _.get(req, 'params.name'); 348 | 349 | // Update a contact property 350 | // PUT /properties/v1/contacts/properties/named/:property_name 351 | // https://developers.hubspot.com/docs/methods/contacts/v2/update_contact_property 352 | console.log('Calling contacts.properties.update API method. Update contact property, with name:', name); 353 | const result = await hubspot.contacts.properties.update(name, req.body); 354 | console.log('Response from API', result); 355 | 356 | res.redirect('/properties') 357 | } catch (e) { 358 | console.error(e); 359 | res.redirect(`/error?msg=${e.message}`); 360 | } 361 | }); 362 | 363 | app.get('/properties/new', async (req, res) => { 364 | try { 365 | 366 | // Get Contact Property Groups 367 | // GET /properties/v1/contacts/groups 368 | // https://developers.hubspot.com/docs/methods/contacts/v2/get_contact_property_groups 369 | console.log('Calling hubspot.contacts.properties.getGroups API method. Retrieve all contact property groups'); 370 | const groups = await hubspot.contacts.properties.getGroups(); 371 | console.log('Response from API', groups); 372 | 373 | res.render('list', {items: getPropertyDetails(), action: '/properties', groups}); 374 | } catch (e) { 375 | console.error(e); 376 | res.redirect(`/error?msg=${e.message}`); 377 | } 378 | }); 379 | 380 | app.get('/properties/:name', async (req, res) => { 381 | try { 382 | const name = _.get(req, 'params.name'); 383 | if (_.isNil(name)) return res.redirect('/error?msg=Missed property'); 384 | 385 | // Get All Contacts Properties 386 | // GET /properties/v1/contacts/properties 387 | // https://developers.hubspot.com/docs/methods/contacts/v2/get_contacts_properties 388 | console.log('Calling contacts.properties.get API method. Retrieve all contacts properties'); 389 | const hubspotProperties = await hubspot.contacts.properties.get(); 390 | console.log('Response from API', hubspotProperties); 391 | 392 | // Get Contact Property Groups 393 | // GET /properties/v1/contacts/groups 394 | // https://developers.hubspot.com/docs/methods/contacts/v2/get_contact_property_groups 395 | console.log('Calling hubspot.contacts.properties.getGroups API method. Retrieve all contact property groups'); 396 | const groups = await hubspot.contacts.properties.getGroups(); 397 | console.log('Response from API', groups); 398 | 399 | const property = _.find(hubspotProperties, {name}); 400 | const properties = getPropertyDetails(property); 401 | res.render('list', {items: properties, action: `/properties/${name}`, groups}); 402 | } catch (e) { 403 | console.error(e); 404 | res.redirect(`/error?msg=${e.message}`); 405 | } 406 | }); 407 | 408 | app.get('/export', async (req, res) => { 409 | 410 | try { 411 | 412 | // Get All Contacts Properties 413 | // GET /properties/v1/contacts/properties 414 | // https://developers.hubspot.com/docs/methods/contacts/v2/get_contacts_properties 415 | console.log('Calling contacts.properties.get API method. Retrieve all contacts properties'); 416 | const properties = await hubspot.contacts.properties.get(); 417 | console.log('Response from API', properties); 418 | 419 | // Get all contacts 420 | // GET /contacts/v1/lists/all/contacts/all 421 | // https://developers.hubspot.com/docs/methods/contacts/get_contacts 422 | console.log('Calling contacts.get API method. Retrieve all contacts.'); 423 | const contactsResponse = await hubspot.contacts.get({count: CONTACTS_COUNT}); 424 | console.log('Response from API', contactsResponse); 425 | const csvContent = toCsv(contactsResponse.contacts, properties); 426 | 427 | res.csv(csvContent, true, {'Content-disposition': 'attachment; filename=contacts.csv'}); 428 | } catch (e) { 429 | console.error(e); 430 | res.redirect(`/error?msg=${e.message}`); 431 | } 432 | }); 433 | 434 | app.get('/error', (req, res) => { 435 | res.render('error', {error: req.query.msg}); 436 | }); 437 | 438 | app.use((error, req, res, next) => { 439 | res.render('error', {error: error.message}); 440 | }); 441 | 442 | app.listen(PORT, () => console.log(`Listening on http://localhost:${PORT}`)); 443 | -------------------------------------------------------------------------------- /contacts-app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hubspot-contacts", 3 | "version": "1.0.0", 4 | "engines": { 5 | "node": ">= 10.13.0" 6 | }, 7 | "description": "hubspot-node client sample applications", 8 | "main": "index.js", 9 | "scripts": { 10 | "start": "node index.js" 11 | }, 12 | "keywords": [ 13 | "hubspot", 14 | "contacts", 15 | "sample", 16 | "example" 17 | ], 18 | "author": "hubspot", 19 | "license": "Apache-2.0", 20 | "dependencies": { 21 | "body-parser": "^1.19.0", 22 | "csv-express": "^1.2.2", 23 | "dotenv": "^8.1.0", 24 | "express": "^4.16.3", 25 | "hubspot": "^2.3.2", 26 | "lodash": "^4.17.15", 27 | "pug": "^2.0.4" 28 | }, 29 | "devDependencies": { 30 | "nodemon": "^1.19.2" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /contacts-app/public/css/main.css: -------------------------------------------------------------------------------- 1 | .wrapper .container { 2 | padding-bottom: 2rem; 3 | padding-top: 2rem; 4 | } 5 | 6 | .navigation { 7 | background: #f4f5f6; 8 | border-bottom: .1rem solid #d1d1d1; 9 | display: block; 10 | height: 5.2rem; 11 | left: 0; 12 | max-width: 100%; 13 | width: 100%; 14 | } 15 | 16 | .navigation .container { 17 | padding-bottom: 0; 18 | padding-top: 0 19 | } 20 | 21 | .navigation .navigation-list { 22 | list-style: none; 23 | margin-bottom: 0; 24 | } 25 | 26 | .navigation .navigation-item { 27 | float: left; 28 | margin-bottom: 0; 29 | margin-left: 2.5rem; 30 | position: relative 31 | } 32 | 33 | .navigation .navigation-title, .navigation .title { 34 | color: #606c76; 35 | position: relative 36 | } 37 | 38 | .navigation .navigation-link, .navigation .navigation-title, .navigation .title { 39 | display: inline; 40 | font-size: 1.6rem; 41 | line-height: 5.2rem; 42 | padding: 0; 43 | text-decoration: none 44 | } 45 | 46 | .navigation .navigation-link.active { 47 | color: #606c76 48 | } 49 | -------------------------------------------------------------------------------- /contacts-app/public/favicon-32x32.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HubSpot/integration-examples-nodejs/118db05d4770745a60e5ad89031000ff09ea160e/contacts-app/public/favicon-32x32.webp -------------------------------------------------------------------------------- /contacts-app/views/contacts.pug: -------------------------------------------------------------------------------- 1 | extends includes/layout 2 | block content 3 | .container 4 | table 5 | thead 6 | tr 7 | th ID 8 | th Name 9 | th Company 10 | tbody 11 | form(action='/contacts') 12 | fieldset 13 | input(type='text' name='search' placeholder='Search..' id='search' value=search) 14 | each contact in contacts 15 | tr 16 | td 17 | a(href=`/contacts/${contact.vid}`) #{contact.vid} 18 | td #{contact.name} 19 | td #{contact.companyName} 20 | .row 21 | a(href='/contacts/new') 22 | input(class='button-primary' type='button' value='New Contact') 23 | span 24 | a(href='/export') 25 | input(class='button-primary' type='button' value='Export To CSV') 26 | -------------------------------------------------------------------------------- /contacts-app/views/engagements.pug: -------------------------------------------------------------------------------- 1 | extends includes/layout 2 | block content 3 | .container 4 | .row 5 | .column(class='column-50') 6 | form(method='post' action=`/contacts/${vid}/engagement`) 7 | fieldset 8 | label(for='type') Type 9 | select(name='engagement[type]' id='type') 10 | option(value='MEETING') Meeting 11 | 12 | label(for='title') Title 13 | input(type='text' name='metadata[title]' id='title') 14 | label(for='body') Body 15 | textarea(name='metadata[body]' id='body') 16 | label(for='startTime') Start time 17 | input(type='datetime-local' id='startTime' name='metadata[startTime]') 18 | label(for='endTime') End time 19 | input(type='datetime-local' id='endTime' name='metadata[endTime]') 20 | input(type='hidden' name='associations[contactIds][]' value=vid) 21 | div 22 | input(class='button-primary' type='submit' value='Save') 23 | -------------------------------------------------------------------------------- /contacts-app/views/error.pug: -------------------------------------------------------------------------------- 1 | extends includes/layout 2 | block content 3 | .container 4 | h4 Error 5 | p #{error} 6 | -------------------------------------------------------------------------------- /contacts-app/views/includes/footer.pug: -------------------------------------------------------------------------------- 1 | .footer 2 | .container 3 | -------------------------------------------------------------------------------- /contacts-app/views/includes/head.pug: -------------------------------------------------------------------------------- 1 | head 2 | meta(charset='UTF-8') 3 | meta(name='description' content='HubSpot JavaScript Sample Webhooks') 4 | title HubSpot JavaScript Sample Webhooks 5 | link(rel='stylesheet' href='//fonts.googleapis.com/css?family=Roboto:300,300italic,700,700italic') 6 | link(rel='stylesheet' href='//cdnjs.cloudflare.com/ajax/libs/normalize/5.0.0/normalize.css') 7 | link(rel='stylesheet' href='//cdnjs.cloudflare.com/ajax/libs/milligram/1.3.0/milligram.css') 8 | link(rel='stylesheet' href='/css/main.css') 9 | // Fav Icon 10 | link(href='/favicon-32x32.webp' rel='shortcut icon') 11 | link(href='/favicon-32x32.webp' rel='apple-touch-icon') 12 | 13 | script(type='application/javascript' src='https://cdnjs.cloudflare.com/ajax/libs/jquery/3.4.1/jquery.min.js') 14 | -------------------------------------------------------------------------------- /contacts-app/views/includes/header.pug: -------------------------------------------------------------------------------- 1 | header 2 | .navigation 3 | .container 4 | a(class='navigation-title' href='/') 5 | h3(class='title') HubSpot JavaScript Sample Contacts App 6 | 7 | ul(class='navigation-list float-right') 8 | li(class='navigation-item') 9 | a(class='navigation-link' href='/contacts') Contacts 10 | li(class='navigation-item') 11 | a(class='navigation-link' href='/properties') Mutable Properties 12 | -------------------------------------------------------------------------------- /contacts-app/views/includes/layout.pug: -------------------------------------------------------------------------------- 1 | doctype html 2 | html(lang='en') 3 | include head 4 | body 5 | main(class='wrapper') 6 | include header 7 | block content 8 | include footer 9 | -------------------------------------------------------------------------------- /contacts-app/views/list.pug: -------------------------------------------------------------------------------- 1 | extends includes/layout 2 | block content 3 | .container 4 | .row 5 | .column 6 | h3 Properties 7 | form(method='post' action=action) 8 | fieldset 9 | each item, key in items 10 | - var isRequired = key === 'email'; 11 | 12 | label(for=key) #{item.label} #{isRequired ? '*' : ''} 13 | 14 | if (key === 'hubspot_owner_id') 15 | select(name=key id=key) 16 | option(value='') Not assigned 17 | each owner in owners 18 | -let selected = '' + owner.ownerId === '' + item.value 19 | option(value=owner.ownerId selected=selected) #{owner.firstName} #{owner.lastName} 20 | else if (key === 'groupName') 21 | select(name=key id=key) 22 | option(value='') Not assigned 23 | each group in groups 24 | -let selected = '' + group.name === '' + item.value 25 | option(value=group.name selected=selected) #{group.displayName} 26 | else 27 | input(name=key id=key type='text' value=`${item.value || ''}` required=isRequired) 28 | 29 | 30 | input(class='button-primary' type='submit' value='Save') 31 | 32 | if (engagements) 33 | .column 34 | h3 Engagements 35 | table 36 | thead 37 | tr 38 | th ID 39 | th Type 40 | th Title 41 | tbody 42 | each engagement in engagements 43 | tr 44 | td #{engagement.id} 45 | td #{engagement.type} 46 | td #{engagement.title} 47 | 48 | div 49 | a(href=engagementAction) 50 | input(class='button-primary' type='button' value='Add Engagement') 51 | -------------------------------------------------------------------------------- /contacts-app/views/properties.pug: -------------------------------------------------------------------------------- 1 | extends includes/layout 2 | block content 3 | .container 4 | table 5 | thead 6 | tr 7 | th Name 8 | th Label 9 | th Description 10 | th Type 11 | tbody 12 | each property in properties 13 | tr 14 | td 15 | a(href=`/properties/${property.name}`) #{property.name} 16 | td #{property.label} 17 | td #{property.description} 18 | td #{property.type} 19 | div 20 | a(href='/properties/new') 21 | input(class='button-primary' type='button' value='New Property') 22 | -------------------------------------------------------------------------------- /form-file-submission-access-app/.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: http://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # Unix-style newlines with a newline ending every file 7 | [*] 8 | end_of_line = lf 9 | insert_final_newline = true 10 | indent_style = space 11 | indent_size = 2 12 | charset = utf-8 13 | trim_trailing_whitespace = true 14 | 15 | [*.md] 16 | trim_trailing_whitespace = false -------------------------------------------------------------------------------- /form-file-submission-access-app/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:10 2 | 3 | RUN apt-get update 4 | 5 | WORKDIR /app/src 6 | 7 | COPY src/package.json ./ 8 | RUN npm install 9 | 10 | EXPOSE 3000 11 | -------------------------------------------------------------------------------- /form-file-submission-access-app/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | 3 | services: 4 | filesubmit_web: 5 | container_name: js-form-file-submission-access-app-web 6 | env_file: 7 | - src/.env 8 | build: . 9 | volumes: 10 | - ./src:/app/src 11 | - /app/src/node_modules 12 | - ./storage:/app/storage 13 | ports: 14 | - 3000:3000 15 | command: npm run start 16 | 17 | ngrok: 18 | image: gtriggiano/ngrok-tunnel 19 | ports: 20 | - 4040:4040 21 | environment: 22 | TARGET_HOST: filesubmit_web 23 | TARGET_PORT: 3000 24 | depends_on: 25 | - filesubmit_web 26 | -------------------------------------------------------------------------------- /form-file-submission-access-app/src/.env.template: -------------------------------------------------------------------------------- 1 | HUBSPOT_CLIENT_ID= 2 | HUBSPOT_CLIENT_SECRET= 3 | 4 | PROTECTED_FILE_LINK_PROPERTY=file_protected_link 5 | PUBLIC_FILE_LINK_PROPERTY=file_public_link 6 | 7 | DEBUG=filesubmit:* 8 | -------------------------------------------------------------------------------- /form-file-submission-access-app/src/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | 'eslint:recommended', 4 | 'standard', 5 | 'plugin:import/errors', 6 | 'plugin:import/warnings', 7 | 'plugin:promise/recommended', 8 | 'plugin:node/recommended', 9 | 'plugin:prettier/recommended', 10 | ], 11 | plugins: [ 12 | 'prefer-arrow' 13 | ], 14 | env: { 15 | es6: true, 16 | node: true, 17 | }, 18 | rules: { 19 | 'prefer-arrow-callback': 2, 20 | 'prefer-template': 2, 21 | 'no-template-curly-in-string': 2, 22 | }, 23 | } 24 | -------------------------------------------------------------------------------- /form-file-submission-access-app/src/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "quoteProps": "as-needed", 4 | "bracketSpacing": true, 5 | "arrowParens": "always", 6 | "semi": false, 7 | "printWidth": 120, 8 | "trailingComma": "es5", 9 | "endOfLine": "lf" 10 | } 11 | -------------------------------------------------------------------------------- /form-file-submission-access-app/src/README.md: -------------------------------------------------------------------------------- 1 | # HubSpot-nodejs form file submission sample app 2 | 3 | This is a sample app for the [hubspot-node SDK](https://github.com/MadKudu/node-hubspot). 4 | 5 | Please see the documentation on [How do I create an app in HubSpot?](https://developers.hubspot.com/docs/faq/how-do-i-create-an-app-in-hubspot) 6 | 7 | This Application demonstrates the recommended approach to working with file uploads via HubSpot form submission. For security reasons HubSpot makes uploaded files available to the Users only if they are logged in. If you want not logged in Users to access the file you may do the following: 8 | 9 | 1. Listen for a webhook for the file upload field (customer-defined, but similar to this https://github.com/HubSpot/integration-examples-nodejs/tree/master/webhooks-app) 10 | 2. Grab the file by hitting the URL stored by the form in Contact property 11 | - This URL can only be accessed by authenticated caller - see [OAuth 2.0 example](https://github.com/HubSpot/integration-examples-nodejs/tree/master/oauth-app) for an example of OAuth 2.0 authentication JS code 12 | 3. Upload the file to the file-manager via https://developers.hubspot.com/docs/methods/files/post_files 13 | 4. Put the resulting file URL to a new contact property, something like "file public link" as an example 14 | 15 | This design is implemented in this Application 16 | 17 | 1. There is an initialization code [setup-sample-middleware.js](https://github.com/HubSpot/integration-examples-nodejs/tree/master/form-file-submission-access-app/src/js/setup-sample-middleware.js) invoked automatically on the initial Application page. It is designed to create a form for file upload and custom properties for uploaded protected file link and public file link storage 18 | 2. After the initialization is done the form is created using JavaScript script provided by HubSpot to embed forms on your website. (src="//js.hsforms.net/forms/shell.js) - this is done in [contacts.pug](https://github.com/HubSpot/integration-examples-nodejs/tree/master/form-file-submission-access-app/src/views/contacts.pug) 19 | 3. When User uploads the file via the form webhook event is posted to [webhooks-controller.js](https://github.com/HubSpot/integration-examples-nodejs/tree/master/form-file-submission-access-app/src/js/webhooks-controller.js) that use [files-handler.js](https://github.com/HubSpot/integration-examples-nodejs/tree/master/form-file-submission-access-app/src/js/files-handler.js) that does three things: 20 | - calls [hubspot-node SDK](https://github.com/MadKudu/node-hubspot) method to get the file link from Protected Property https://github.com/HubSpot/integration-examples-nodejs/tree/master/form-file-submission-access-app/src/js/files-handler.js#L28 21 | - calls [hubspot-node SDK](https://github.com/MadKudu/node-hubspot) method to upload file to the public storage https://github.com/HubSpot/integration-examples-nodejs/tree/master/form-file-submission-access-app/src/js/files-handler.js#L37 22 | - calls [hubspot-node SDK](https://github.com/MadKudu/node-hubspot) method to update Public Property with publicly viewable location of the file https://github.com/HubSpot/integration-examples-nodejs/tree/master/form-file-submission-access-app/src/js/files-handler.js#L52 23 | 4. [contacts.pug](https://github.com/HubSpot/integration-examples-nodejs/tree/master/form-file-submission-access-app/src/views/contacts.pug) displays the list of contacts with protected and public links 24 | 25 | ### Setup App 26 | 27 | Make sure you have [Docker Compose](https://docs.docker.com/compose/) installed. 28 | 29 | ### Configure 30 | 31 | 1. Copy .env.template to .env 32 | 2. Paste your HUBSPOT_CLIENT_ID and HUBSPOT_CLIENT_SECRET 33 | 34 | ### Running 35 | 36 | The best way to run this project (with the least configuration), is using docker compose. Change to the webroot and start it 37 | 38 | ```bash 39 | docker-compose up --build 40 | ``` 41 | 42 | Copy Ngrok url from console. Now you should now be able to navigate to that url and use the application. 43 | 44 | ### NOTE about Ngrok Too Many Connections error 45 | 46 | If you are using Ngrok free plan and testing the application with large amount of import/deletions of Contacts you are likely to see Ngrok "Too Many Connections" error. 47 | This is caused by a large amount of weebhooks events being sent to Ngrok tunnel. To avoid it you can deploy sample applications on your server w/o Ngrok or upgrade to Ngrok Enterprise version 48 | 49 | ### Configure webhooks 50 | 51 | Required webhooks url should look like https://***.ngrok.io/webhooks 52 | 53 | Following [Webhooks Setup](https://developers.hubspot.com/docs/methods/webhooks/webhooks-overview) guide please note: 54 | - Every time the app is restarted you should update the webhooks url 55 | - The app requires `contact.propertyChange` subscription type 56 | - Subscription is paused by default. You need to activate it manually after creating 57 | -------------------------------------------------------------------------------- /form-file-submission-access-app/src/index.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config({ path: '.env' }) 2 | const debug = require('debug')('filesubmit:index') 3 | 4 | const url = require('url') 5 | const _ = require('lodash') 6 | const path = require('path') 7 | const Hubspot = require('hubspot') 8 | const express = require('express') 9 | const storage = require('node-persist') 10 | const bodyParser = require('body-parser') 11 | const oauthController = require('./js/oauth-controller') 12 | const contactsController = require('./js/contacts-controller') 13 | const webhooksController = require('./js/webhooks-controller') 14 | const webSocketController = require('./js/websocket-controller') 15 | const setupSampleMiddleware = require('./js/setup-sample-middleware') 16 | 17 | const PORT = 3000 18 | const TOKENS_ITEM = 'tokens' 19 | const STORAGE_PATH = '../storage' 20 | const CLIENT_ID = process.env.HUBSPOT_CLIENT_ID 21 | const CLIENT_SECRET = process.env.HUBSPOT_CLIENT_SECRET 22 | const PUBLIC_FILE_LINK_PROPERTY = process.env.PUBLIC_FILE_LINK_PROPERTY 23 | const PROTECTED_FILE_LINK_PROPERTY = process.env.PROTECTED_FILE_LINK_PROPERTY 24 | 25 | const HUBSPOT_AUTH_CONFIG = { 26 | clientId: CLIENT_ID, 27 | clientSecret: CLIENT_SECRET, 28 | } 29 | 30 | let hubspot 31 | let tokens = {} 32 | let tokensInitialized = false 33 | 34 | const updateTokens = async (newTokens) => { 35 | debug('updating tokens %O', newTokens) 36 | tokens = _.extend(tokens, newTokens) 37 | tokens.updated_at = Date.now() 38 | await storage.setItem(TOKENS_ITEM, tokens) 39 | } 40 | 41 | const checkEnv = (req, res, next) => { 42 | if (_.startsWith(req.url, '/error')) return next() 43 | 44 | if (_.isNil(CLIENT_ID)) return res.redirect('/error?msg=Please set HUBSPOT_CLIENT_ID env variable to proceed') 45 | if (_.isNil(CLIENT_SECRET)) return res.redirect('/error?msg=Please set HUBSPOT_CLIENT_SECRET env variable to proceed') 46 | if (_.isNil(PUBLIC_FILE_LINK_PROPERTY)) 47 | return res.redirect('/error?msg=Please set PUBLIC_FILE_LINK_PROPERTY env variable to proceed') 48 | if (_.isNil(PROTECTED_FILE_LINK_PROPERTY)) 49 | return res.redirect('/error?msg=Please set PROTECTED_FILE_LINK_PROPERTY env variable to proceed') 50 | next() 51 | } 52 | 53 | const isTokenExpired = () => { 54 | return Date.now() >= tokens.updated_at + tokens.expires_in * 1000 55 | } 56 | 57 | const setupHostUrl = (req, res, next) => { 58 | req.hostUrl = url.format({ 59 | protocol: 'https', 60 | hostname: req.get('host'), 61 | }) 62 | next() 63 | } 64 | 65 | const setupHubspot = async (req, res, next) => { 66 | if (_.startsWith(req.url, '/error')) return next() 67 | if (_.startsWith(req.url, '/login')) return next() 68 | 69 | if (tokensInitialized && hubspot && !isTokenExpired()) { 70 | req.hubspot = hubspot 71 | next() 72 | return 73 | } 74 | 75 | const refreshToken = _.get(tokens, 'refresh_token') 76 | 77 | if (_.isNil(hubspot)) { 78 | const redirectUri = `${req.hostUrl}/auth/oauth-callback` 79 | 80 | debug('create client instance') 81 | hubspot = new Hubspot(_.extend({}, HUBSPOT_AUTH_CONFIG, { redirectUri, refreshToken })) 82 | } 83 | req.hubspot = hubspot 84 | 85 | if (!tokensInitialized && !_.isNil(refreshToken)) { 86 | debug('need to initialize tokens') 87 | 88 | if (isTokenExpired()) { 89 | debug('need to refresh access token') 90 | const hubspotTokens = await req.hubspot.refreshAccessToken() 91 | await updateTokens(hubspotTokens) 92 | } else { 93 | debug('set access token') 94 | req.hubspot.setAccessToken(tokens.access_token) 95 | } 96 | 97 | tokensInitialized = true 98 | 99 | debug('tokens initialized') 100 | } else if (!_.startsWith(req.url, '/auth')) { 101 | debug('need to initialize tokens') 102 | return res.redirect('/login') 103 | } 104 | 105 | next() 106 | } 107 | 108 | const app = express() 109 | 110 | app.use(express.static('public')) 111 | app.set('view engine', 'pug') 112 | app.set('views', path.join(__dirname, 'views')) 113 | 114 | app.use( 115 | bodyParser.urlencoded({ 116 | limit: '50mb', 117 | extended: true, 118 | }) 119 | ) 120 | 121 | app.use( 122 | bodyParser.json({ 123 | limit: '50mb', 124 | extended: true, 125 | verify: webhooksController.getWebhookVerification(), 126 | }) 127 | ) 128 | 129 | app.use((req, res, next) => { 130 | debug(req.method, req.url) 131 | next() 132 | }) 133 | 134 | app.use(checkEnv) 135 | app.use(setupHostUrl) 136 | app.use(setupHubspot) 137 | app.use(setupSampleMiddleware) 138 | 139 | app.get('/', (req, res) => { 140 | res.redirect('/contacts') 141 | }) 142 | 143 | app.get('/logout', async (req, res) => { 144 | tokens = {} 145 | tokensInitialized = false 146 | hubspot = null 147 | await storage.setItem(TOKENS_ITEM, {}) 148 | res.redirect('/login') 149 | }) 150 | 151 | app.get('/login', async (req, res) => { 152 | if (tokensInitialized) return res.redirect('/') 153 | res.render('login') 154 | }) 155 | 156 | app.use('/auth', oauthController.getRouter(updateTokens)) 157 | app.use('/contacts', contactsController.getRouter()) 158 | app.use('/webhooks', webhooksController.getRouter()) 159 | 160 | app.get('/error', (req, res) => { 161 | res.render('error', { error: req.query.msg }) 162 | }) 163 | 164 | app.use((error, req, res, next) => { 165 | res.render('error', { error: error.message }) 166 | }) 167 | 168 | try { 169 | storage 170 | .init({ 171 | dir: STORAGE_PATH, 172 | expiredInterval: 4 * 60 * 1000, 173 | }) 174 | .then(() => storage.getItem(TOKENS_ITEM)) 175 | .then((storageTokens) => { 176 | return (tokens = storageTokens) 177 | }) 178 | .catch((e) => debug(e)) 179 | 180 | const server = app.listen(PORT, () => { 181 | debug(`listening on port : ${PORT}`) 182 | }) 183 | 184 | webSocketController.init(server) 185 | process.on('SIGTERM', async () => { 186 | server.close(() => { 187 | debug('process terminated') 188 | }) 189 | }) 190 | } catch (e) { 191 | debug('Error during the app start. ', e) 192 | } 193 | -------------------------------------------------------------------------------- /form-file-submission-access-app/src/js/contacts-controller.js: -------------------------------------------------------------------------------- 1 | const debug = require('debug')('filesubmit:contacts') 2 | 3 | const _ = require('lodash') 4 | const express = require('express') 5 | const router = new express.Router() 6 | 7 | const PUBLIC_FILE_LINK_PROPERTY = process.env.PUBLIC_FILE_LINK_PROPERTY 8 | const PROTECTED_FILE_LINK_PROPERTY = process.env.PROTECTED_FILE_LINK_PROPERTY 9 | 10 | const REQUESTED_PROPERTIES = { 11 | property: ['email', 'firstname', 'lastname', PUBLIC_FILE_LINK_PROPERTY, PROTECTED_FILE_LINK_PROPERTY], 12 | } 13 | 14 | let formIds = {} 15 | 16 | const getFullName = (contact) => { 17 | const firstName = _.get(contact, 'firstname.value') || '' 18 | const lastName = _.get(contact, 'lastname.value') || '' 19 | return `${firstName} ${lastName}` 20 | } 21 | 22 | const prepareContactsContent = (contacts) => { 23 | return _.map(contacts, (contact) => { 24 | const email = _.get(contact, `properties.email.value`) || '' 25 | const protectedLink = _.get(contact, `properties.${PROTECTED_FILE_LINK_PROPERTY}.value`) || '' 26 | const publicLink = _.get(contact, `properties.${PUBLIC_FILE_LINK_PROPERTY}.value`) || '' 27 | return { vid: contact.vid, email, name: getFullName(contact.properties), protectedLink, publicLink } 28 | }) 29 | } 30 | 31 | exports.setFormIds = (ids) => { 32 | formIds = ids 33 | } 34 | 35 | exports.getRouter = () => { 36 | router.get('/', async (req, res) => { 37 | try { 38 | const search = _.get(req, 'query.search') 39 | let contactsResponse = { contacts: [] } 40 | if (_.isNil(search)) { 41 | // Get recently updated and created contacts 42 | // GET /contacts/v1/lists/recently_updated/contacts/recent 43 | // https://developers.hubspot.com/docs/methods/contacts/get_recently_updated_contacts 44 | contactsResponse = await req.hubspot.contacts.getRecentlyModified(REQUESTED_PROPERTIES) 45 | } else { 46 | // Search for contacts by email, name, or company name 47 | // GET /contacts/v1/search/query 48 | // https://developers.hubspot.com/docs/methods/contacts/search_contacts 49 | contactsResponse = await req.hubspot.contacts.search(search, REQUESTED_PROPERTIES) 50 | } 51 | 52 | const contacts = prepareContactsContent(contactsResponse.contacts) 53 | res.render('contacts', { contacts: contacts, search, portalId: formIds.portalId, formId: formIds.formId }) 54 | } catch (e) { 55 | debug(e) 56 | res.redirect(`/error?msg=${e.message}`) 57 | } 58 | }) 59 | 60 | return router 61 | } 62 | -------------------------------------------------------------------------------- /form-file-submission-access-app/src/js/files-handler.js: -------------------------------------------------------------------------------- 1 | const debug = require('debug')('filesubmit:files') 2 | 3 | const _ = require('lodash') 4 | 5 | const OBJECT_ID = 'objectId' 6 | const PROPERTY_NAME = 'propertyName' 7 | const PROPERTY_VALUE = 'propertyValue' 8 | const SUBSCRIPTION_TYPE = 'subscriptionType' 9 | const PROPERTY_CHANGE_EVENT = 'contact.propertyChange' 10 | 11 | const UPLOAD_RESULT_URL_PROPERTY = 'friendly_url' 12 | const PUBLIC_FILE_LINK_PROPERTY = process.env.PUBLIC_FILE_LINK_PROPERTY 13 | const PROTECTED_FILE_LINK_PROPERTY = process.env.PROTECTED_FILE_LINK_PROPERTY 14 | 15 | module.exports = async (hubspot, webhooksEvents) => { 16 | try { 17 | // File Upload Flow: 18 | 19 | // Step 1: Iterate over events 20 | for (const webhooksEvent of webhooksEvents) { 21 | const subscriptionType = _.get(webhooksEvent, SUBSCRIPTION_TYPE) 22 | const propertyName = _.get(webhooksEvent, PROPERTY_NAME) 23 | 24 | // Step 2: Check if event triggered by the file submission 25 | if (subscriptionType === PROPERTY_CHANGE_EVENT && propertyName === PROTECTED_FILE_LINK_PROPERTY) { 26 | const contactId = _.get(webhooksEvent, OBJECT_ID) 27 | 28 | const fileData = { 29 | url: _.get(webhooksEvent, PROPERTY_VALUE) 30 | } 31 | // Step 3: Upload file to public file storage 32 | 33 | // Upload a new file 34 | // POST /filemanager/api/v3/files/upload 35 | // https://legacydocs.hubspot.com/docs/methods/files/v3/upload_new_file 36 | const publicFile = await hubspot.files.uploadByUrl(fileData) 37 | const publicUrl = _.get(publicFile, `objects[0].${UPLOAD_RESULT_URL_PROPERTY}`) 38 | 39 | const updatePayload = { 40 | properties: [{ property: PUBLIC_FILE_LINK_PROPERTY, value: publicUrl }], 41 | } 42 | 43 | debug('contact ID: %s', contactId, updatePayload) 44 | 45 | // Step 4: Update contact with public file link 46 | 47 | // Update an existing contact 48 | // POST /contacts/v1/contact/vid/:vid/profile 49 | // https://developers.hubspot.com/docs/methods/contacts/update_contact 50 | await hubspot.contacts.update(contactId, updatePayload) 51 | 52 | return true 53 | } 54 | } 55 | } catch (e) { 56 | debug(e) 57 | } 58 | return false 59 | } 60 | -------------------------------------------------------------------------------- /form-file-submission-access-app/src/js/oauth-controller.js: -------------------------------------------------------------------------------- 1 | const debug = require('debug')('filesubmit:auth') 2 | 3 | const _ = require('lodash') 4 | const express = require('express') 5 | const router = new express.Router() 6 | 7 | const SCOPE = _.join(['contacts', 'files', 'forms', 'forms-uploaded-files'], ' ') 8 | 9 | exports.getRouter = (updateTokens) => { 10 | router.get('/oauth', async (req, res) => { 11 | // Use the client to get authorization Url 12 | // https://www.npmjs.com/package/hubspot#obtain-your-authorization-url 13 | const authorizationUrl = req.hubspot.oauth.getAuthorizationUrl({ 14 | scope: SCOPE, 15 | }) 16 | debug('authorization Url: %s', authorizationUrl) 17 | 18 | res.redirect(authorizationUrl) 19 | }) 20 | 21 | router.get('/oauth-callback', async (req, res) => { 22 | const code = _.get(req, 'query.code') 23 | 24 | // Get OAuth 2.0 Access Token and Refresh Tokens 25 | // POST /oauth/v1/token 26 | // https://developers.hubspot.com/docs/methods/oauth2/get-access-and-refresh-tokens 27 | // 28 | // https://www.npmjs.com/package/hubspot#obtain-an-access-token-from-an-authorization_code 29 | debug('get tokens by code:', code) 30 | const tokens = await req.hubspot.oauth.getAccessToken({ code }) 31 | updateTokens(tokens) 32 | res.redirect('/') 33 | }) 34 | 35 | return router 36 | } 37 | -------------------------------------------------------------------------------- /form-file-submission-access-app/src/js/setup-sample-middleware.js: -------------------------------------------------------------------------------- 1 | const debug = require('debug')('filesubmit:setup') 2 | 3 | const _ = require('lodash') 4 | const contactsController = require('./contacts-controller') 5 | 6 | const SAMPLE_FILE_SUBMIT_FORM_NAME = 'sample_file_submit_form' 7 | const PUBLIC_FILE_LINK_PROPERTY = process.env.PUBLIC_FILE_LINK_PROPERTY 8 | const PROTECTED_FILE_LINK_PROPERTY = process.env.PROTECTED_FILE_LINK_PROPERTY 9 | 10 | const propertyProto = { 11 | description: 'HubSpot sample Form Submission and File Download app use this field for uploading picture', 12 | groupName: 'contactinformation', 13 | type: 'string', 14 | formField: true, 15 | fieldType: 'file', 16 | } 17 | 18 | let initialized = false 19 | 20 | const initProperty = (hubspot, propertyName) => { 21 | debug('init property %s', propertyName) 22 | const propertyPayload = _.assign({}, propertyProto, { 23 | name: propertyName, 24 | label: propertyName, 25 | }) 26 | 27 | // Update a contact property 28 | // PUT /properties/v1/contacts/properties/named/:property_name 29 | // https://developers.hubspot.com/docs/methods/contacts/v2/update_contact_property 30 | return hubspot.contacts.properties.upsert(propertyPayload) 31 | } 32 | 33 | const initForm = async (req, protectedPropertyName) => { 34 | // Get all forms from a portal 35 | // GET /forms/v2/forms 36 | // https://developers.hubspot.com/docs/methods/forms/v2/get_forms 37 | const formsResponse = await req.hubspot.forms.getAll() 38 | 39 | const form = _.find(formsResponse, { name: SAMPLE_FILE_SUBMIT_FORM_NAME }) 40 | if (form) { 41 | await req.hubspot.forms.delete(form.guid) 42 | } 43 | 44 | const formPayload = { 45 | name: SAMPLE_FILE_SUBMIT_FORM_NAME, 46 | submitText: 'SUBMIT', 47 | inlineMessage: `
Thanks for submitting the form.
48 | Please wait for page refresh. 49 | If page not refreshed automatically please refresh it manually`, 50 | formFieldGroups: [ 51 | { 52 | fields: [ 53 | { 54 | name: 'email', 55 | label: 'Contact Email', 56 | type: 'string', 57 | fieldType: 'text', 58 | required: true, 59 | enabled: true, 60 | hidden: false, 61 | placeholder: 'Email', 62 | }, 63 | ], 64 | default: true, 65 | isSmartGroup: false, 66 | }, 67 | { 68 | fields: [ 69 | { 70 | name: protectedPropertyName, 71 | label: 'Protected File', 72 | type: 'string', 73 | fieldType: 'file', 74 | required: true, 75 | enabled: true, 76 | hidden: false, 77 | placeholder: protectedPropertyName, 78 | }, 79 | ], 80 | default: true, 81 | isSmartGroup: false, 82 | }, 83 | ], 84 | } 85 | 86 | // Create a new form 87 | // POST /forms/v2/forms 88 | // https://developers.hubspot.com/docs/methods/forms/v2/create_form 89 | return req.hubspot.forms.create(formPayload) 90 | } 91 | 92 | module.exports = async (req, res, next) => { 93 | if (_.startsWith(req.url, '/error')) return next() 94 | if (_.startsWith(req.url, '/login')) return next() 95 | if (_.startsWith(req.url, '/auth')) return next() 96 | 97 | if (!initialized) { 98 | try { 99 | debug('setup app') 100 | 101 | debug('setup properties') 102 | await initProperty(req.hubspot, PROTECTED_FILE_LINK_PROPERTY) 103 | await initProperty(req.hubspot, PUBLIC_FILE_LINK_PROPERTY) 104 | 105 | debug('setup form') 106 | const formResponse = await initForm(req, PROTECTED_FILE_LINK_PROPERTY) 107 | contactsController.setFormIds({ 108 | portalId: formResponse.portalId, 109 | formId: formResponse.guid, 110 | }) 111 | initialized = true 112 | } catch (e) { 113 | debug(e) 114 | } 115 | } 116 | return next() 117 | } 118 | -------------------------------------------------------------------------------- /form-file-submission-access-app/src/js/utils.js: -------------------------------------------------------------------------------- 1 | exports.uuidv4 = () => { 2 | return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { 3 | const r = (Math.random() * 16) | 0 4 | const v = c === 'x' ? r : (r & 0x3) | 0x8 5 | return v.toString(16) 6 | }) 7 | } 8 | -------------------------------------------------------------------------------- /form-file-submission-access-app/src/js/webhooks-controller.js: -------------------------------------------------------------------------------- 1 | const debug = require('debug')('filesubmit:webhooks') 2 | 3 | const crypto = require('crypto') 4 | const express = require('express') 5 | 6 | const filesHandler = require('./files-handler') 7 | const websocketController = require('./websocket-controller') 8 | const router = new express.Router() 9 | 10 | const SIGNATURE_HEADER = 'X-HubSpot-Signature' 11 | 12 | exports.getRouter = () => { 13 | router.post('/', async (req, res) => { 14 | const webhooksEvents = req.body 15 | debug('receive events: %O', webhooksEvents) 16 | const result = await filesHandler(req.hubspot, webhooksEvents) 17 | debug('updated contact: ', result) 18 | result && websocketController.update() 19 | res.sendStatus(200) 20 | }) 21 | return router 22 | } 23 | 24 | exports.getWebhookVerification = () => { 25 | return (req, res, buf, encoding) => { 26 | try { 27 | if (req.originalUrl === '/webhooks') { 28 | const rawBody = buf.toString(encoding) 29 | const signature = req.header(SIGNATURE_HEADER) 30 | 31 | const secret = process.env.HUBSPOT_CLIENT_SECRET 32 | const hash = crypto 33 | .createHash('sha256') 34 | .update(secret + rawBody) 35 | .digest('hex') 36 | 37 | if (signature === hash) return 38 | } 39 | } catch (e) { 40 | debug(e) 41 | } 42 | 43 | throw new Error('Unauthorized webhook event or error with request processing!') 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /form-file-submission-access-app/src/js/websocket-controller.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | const WebSocketServer = require('websocket').server 3 | const debug = require('debug')('filesubmit:sockets') 4 | 5 | let wsServer = null 6 | let connections = [] 7 | 8 | exports.init = (server) => { 9 | wsServer = new WebSocketServer({ 10 | httpServer: server, 11 | }) 12 | 13 | wsServer.on('request', (request) => { 14 | debug('received request') 15 | const connection = request.accept(null, request.origin) 16 | connection.on('message', (message) => { 17 | if (message.type === 'utf8') { 18 | debug(message) 19 | } 20 | }) 21 | 22 | connection.on('close', (e) => { 23 | debug('closed', e) 24 | connections = _.without(connections, connection) 25 | }) 26 | connections.push(connection) 27 | }) 28 | } 29 | 30 | exports.update = () => { 31 | debug('trigger update') 32 | debug('connections: ', connections.length) 33 | _.each(connections, (connection) => { 34 | try { 35 | connection && connection.sendUTF('update') 36 | } catch (e) { 37 | debug(e) 38 | } 39 | }) 40 | } 41 | -------------------------------------------------------------------------------- /form-file-submission-access-app/src/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hubspot-form-file-submission-access", 3 | "version": "1.0.1", 4 | "engines": { 5 | "node": ">= 10.13.0" 6 | }, 7 | "description": "hubspot-node client sample applications", 8 | "main": "index.js", 9 | "scripts": { 10 | "start": "./node_modules/.bin/if-env NODE_ENV=development && nodemon --delay 3 index.js || node index.js", 11 | "lint": "npm run prettier && npm run eslint", 12 | "eslint": "./node_modules/.bin/eslint . --fix", 13 | "prettier": "./node_modules/.bin/prettier --write ./index.js ./js/**/*.js" 14 | }, 15 | "keywords": [ 16 | "hubspot", 17 | "oauth", 18 | "files", 19 | "forms", 20 | "contacts", 21 | "sample", 22 | "example" 23 | ], 24 | "author": "hubspot", 25 | "license": "Apache-2.0", 26 | "dependencies": { 27 | "bluebird": "^3.7.1", 28 | "body-parser": "^1.19.0", 29 | "debug": "^4.1.1", 30 | "dotenv": "^8.2.0", 31 | "express": "^4.16.3", 32 | "hubspot": "^2.3.14", 33 | "if-env": "^1.0.4", 34 | "lodash": "^4.17.15", 35 | "node-persist": "^3.0.5", 36 | "pug": "^2.0.4", 37 | "websocket": "^1.0.30" 38 | }, 39 | "devDependencies": { 40 | "eslint": "^6.5.1", 41 | "eslint-config-prettier": "^6.4.0", 42 | "eslint-config-standard": "14.1.0", 43 | "eslint-plugin-import": "^2.18.2", 44 | "eslint-plugin-node": "^10.0.0", 45 | "eslint-plugin-prefer-arrow": "^1.1.6", 46 | "eslint-plugin-prettier": "^3.1.1", 47 | "eslint-plugin-promise": "^4.2.1", 48 | "eslint-plugin-standard": "4.0.1", 49 | "nodemon": "^1.19.4", 50 | "prettier": "^1.18.2" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /form-file-submission-access-app/src/public/css/main.css: -------------------------------------------------------------------------------- 1 | .wrapper .container { 2 | padding-bottom: 2rem; 3 | padding-top: 2rem; 4 | } 5 | 6 | .navigation { 7 | background: #f4f5f6; 8 | border-bottom: .1rem solid #d1d1d1; 9 | display: block; 10 | height: 5.2rem; 11 | left: 0; 12 | max-width: 100%; 13 | width: 100%; 14 | } 15 | 16 | .navigation .container { 17 | padding-bottom: 0; 18 | padding-top: 0 19 | } 20 | 21 | .navigation .navigation-list { 22 | list-style: none; 23 | margin-bottom: 0; 24 | } 25 | 26 | .navigation .navigation-item { 27 | float: left; 28 | margin-bottom: 0; 29 | margin-left: 2.5rem; 30 | position: relative 31 | } 32 | 33 | .navigation .navigation-title, .navigation .title { 34 | color: #606c76; 35 | position: relative 36 | } 37 | 38 | .navigation .navigation-link, .navigation .navigation-title, .navigation .title { 39 | display: inline; 40 | font-size: 1.6rem; 41 | line-height: 5.2rem; 42 | padding: 0; 43 | text-decoration: none 44 | } 45 | 46 | .navigation .navigation-link.active { 47 | color: #606c76 48 | } 49 | 50 | .authorize-button { 51 | text-align: center; 52 | } 53 | 54 | form { 55 | display: flex; 56 | flex-direction: row; 57 | } 58 | -------------------------------------------------------------------------------- /form-file-submission-access-app/src/public/favicon-32x32.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HubSpot/integration-examples-nodejs/118db05d4770745a60e5ad89031000ff09ea160e/form-file-submission-access-app/src/public/favicon-32x32.webp -------------------------------------------------------------------------------- /form-file-submission-access-app/src/public/js/main.js: -------------------------------------------------------------------------------- 1 | ;(() => { 2 | const connection = new WebSocket(`wss://${location.host}`) 3 | 4 | connection.onopen = () => { 5 | console.log('connection opened', location.href) 6 | } 7 | 8 | connection.onerror = (error) => { 9 | console.log('connection error', error) 10 | connection.close() 11 | } 12 | 13 | connection.onmessage = (message) => { 14 | console.log(message) 15 | connection.close() 16 | history.go() 17 | } 18 | })() 19 | -------------------------------------------------------------------------------- /form-file-submission-access-app/src/views/contacts.pug: -------------------------------------------------------------------------------- 1 | extends includes/layout 2 | block content 3 | .container 4 | if (portalId && formId) 5 | .row 6 | .column 7 | h3 File Upload Form 8 | script. 9 | hbspt.forms.create({portalId: '#{portalId}', formId: '#{formId}'}) 10 | else 11 | h3 Issues With Form Initialization 12 | hr 13 | .row 14 | .column 15 | h3 Contacts 16 | .column 17 | form(action='/contacts') 18 | input(type='text' name='search' placeholder='Search..' id='search' value=search) 19 | span 20 | input(class='button-primary' type='submit' value='Search') 21 | .row 22 | .column 23 | table 24 | thead 25 | tr 26 | th ID 27 | th Name 28 | th Email 29 | th Protected File 30 | th Public File 31 | tbody 32 | each contact in contacts 33 | tr 34 | td #{contact.vid} 35 | td #{contact.name} 36 | td #{contact.email} 37 | td 38 | if (contact.protectedLink) 39 | a(href=contact.protectedLink target='blank') File 40 | td 41 | if (contact.publicLink) 42 | a(href=contact.publicLink target='blank') File 43 | -------------------------------------------------------------------------------- /form-file-submission-access-app/src/views/error.pug: -------------------------------------------------------------------------------- 1 | extends includes/layout 2 | block content 3 | .container 4 | h4 Error 5 | p #{error} 6 | -------------------------------------------------------------------------------- /form-file-submission-access-app/src/views/includes/footer.pug: -------------------------------------------------------------------------------- 1 | .footer 2 | .container 3 | -------------------------------------------------------------------------------- /form-file-submission-access-app/src/views/includes/head.pug: -------------------------------------------------------------------------------- 1 | head 2 | meta(charset='UTF-8') 3 | meta(name='description' content='HubSpot JavaScript Sample Webhooks') 4 | title HubSpot JavaScript Sample Webhooks 5 | link(rel='stylesheet' href='//fonts.googleapis.com/css?family=Roboto:300,300italic,700,700italic') 6 | link(rel='stylesheet' href='//cdnjs.cloudflare.com/ajax/libs/normalize/5.0.0/normalize.css') 7 | link(rel='stylesheet' href='//cdnjs.cloudflare.com/ajax/libs/milligram/1.3.0/milligram.css') 8 | link(rel='stylesheet' href='/css/main.css') 9 | // Fav Icon 10 | link(href='/favicon-32x32.webp' rel='shortcut icon') 11 | link(href='/favicon-32x32.webp' rel='apple-touch-icon') 12 | 13 | script(type='application/javascript' src='//js.hsforms.net/forms/v2.js') 14 | script(type='application/javascript' src='/js/main.js') 15 | -------------------------------------------------------------------------------- /form-file-submission-access-app/src/views/includes/header.pug: -------------------------------------------------------------------------------- 1 | header 2 | .navigation 3 | .container 4 | a(class='navigation-title' href='/') 5 | h3(class='title') HubSpot JavaScript Form File Submission Sample 6 | 7 | ul(class='navigation-list float-right') 8 | li(class='navigation-item') 9 | a(class='navigation-link' href='/logout') Logout (OAuth2) 10 | -------------------------------------------------------------------------------- /form-file-submission-access-app/src/views/includes/layout.pug: -------------------------------------------------------------------------------- 1 | doctype html 2 | html(lang='en') 3 | include head 4 | body 5 | main(class='wrapper') 6 | include header 7 | block content 8 | include footer 9 | -------------------------------------------------------------------------------- /form-file-submission-access-app/src/views/login.pug: -------------------------------------------------------------------------------- 1 | extends includes/layout 2 | block content 3 | .container 4 | .authorize-button 5 | h3 In order to continue please authorize via OAuth 6 | a(class='button' href='/auth/oauth') Authorize 7 | -------------------------------------------------------------------------------- /form-file-submission-access-app/storage/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /oauth-app/.env.template: -------------------------------------------------------------------------------- 1 | HUBSPOT_CLIENT_ID= 2 | HUBSPOT_CLIENT_SECRET= 3 | -------------------------------------------------------------------------------- /oauth-app/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:10 2 | 3 | WORKDIR /oauth-app 4 | 5 | COPY ./index.js ./ 6 | COPY ./package.json ./ 7 | COPY ./public ./public 8 | COPY ./views ./views 9 | RUN npm install 10 | 11 | 12 | ENTRYPOINT [ "node", "index.js" ] 13 | EXPOSE 3000 14 | -------------------------------------------------------------------------------- /oauth-app/README.md: -------------------------------------------------------------------------------- 1 | # HubSpot-nodejs oauth sample app 2 | 3 | This is a sample app for the [node-hubspot wrapper](https://www.npmjs.com/package/hubspot). Currently, this app focuses on demonstrating the functionality of [OAuth API](https://developers.hubspot.com/docs/methods/oauth2/oauth2-overview) endpoints and their related actions. 4 | 5 | - [Using OAuth 2.0 access tokens](https://developers.hubspot.com/docs/methods/oauth2/get-access-and-refresh-tokens) 6 | - [Get all contacts](https://developers.hubspot.com/docs/methods/contacts/get_contacts) 7 | 8 | Please see the documentation on [How do I create an app in HubSpot?](https://developers.hubspot.com/docs/faq/how-do-i-create-an-app-in-hubspot) 9 | 10 | ### Setup App 11 | 12 | Make sure you have [Docker](https://www.docker.com/) installed. 13 | Make sure you have [Docker Compose](https://docs.docker.com/compose/) installed. 14 | 15 | ### Note on Application Scopes 16 | HubSpot provides a way to restrict application users access to the system to certain scopes. In order to do that it is a good practice to make a set of scopes required by your applicatuion. 17 | Please refer to [Initiate an Integration with OAuth 2.0](https://developers.hubspot.com/docs/methods/oauth2/initiate-oauth-integration) for documentation on the scope parameter passed to https://app.hubspot.com/oauth/authorize to make a set of scopes required. [Scopes](https://developers.hubspot.com/docs/methods/oauth2/initiate-oauth-integration#scopes) explains how to make optional scopes and talks about scopes available in HubSpot system 18 | 19 | ### Configure 20 | 21 | 1. Copy .env.template to .env 22 | 2. Paste your HubSpot Client Id and HubSpot Client Secret as the value for HUBSPOT_CLIENT_ID and HUBSPOT_CLIENT_SECRET in .env 23 | 24 | ### Running 25 | 26 | The best way to run this project (with the least configuration), is using docker cli. 27 | 28 | ```bash 29 | docker-compose up 30 | ``` 31 | You should now be able to navigate to [http://localhost:3000](http://localhost:3000) and use the application. 32 | -------------------------------------------------------------------------------- /oauth-app/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | 3 | services: 4 | oauth_web: 5 | env_file: 6 | - .env 7 | build: . 8 | ports: 9 | - 3000:3000 10 | -------------------------------------------------------------------------------- /oauth-app/index.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config({path: '.env'}); 2 | 3 | const _ = require('lodash'); 4 | const path = require('path'); 5 | const express = require('express'); 6 | const Hubspot = require('hubspot'); 7 | const bodyParser = require('body-parser'); 8 | 9 | 10 | const PORT = 3000; 11 | const CONTACTS_COUNT = 10; 12 | 13 | const CLIENT_ID = process.env.HUBSPOT_CLIENT_ID; 14 | const CLIENT_SECRET = process.env.HUBSPOT_CLIENT_SECRET; 15 | const SCOPES = 'contacts'; 16 | const REDIRECT_URI = `http://localhost:${PORT}/oauth-callback`; 17 | 18 | let tokenStore = {}; 19 | 20 | const checkEnv = (req, res, next) => { 21 | if (_.startsWith(req.url, '/error')) return next(); 22 | 23 | if (_.isNil(CLIENT_ID)) return res.redirect('/error?msg=Please set HUBSPOT_CLIENT_ID env variable to proceed'); 24 | if (_.isNil(CLIENT_SECRET)) return res.redirect('/error?msg=Please set HUBSPOT_CLIENT_SECRET env variable to proceed'); 25 | 26 | next(); 27 | }; 28 | 29 | const isAuthorized = () => { 30 | return !_.isEmpty(tokenStore.refresh_token); 31 | }; 32 | 33 | const isTokenExpired = () => { 34 | return Date.now() >= tokenStore.updated_at + tokenStore.expires_in * 1000; 35 | }; 36 | 37 | const prepareContactsContent = (contacts) => { 38 | return _.map(contacts, (contact) => { 39 | const companyName = _.get(contact, 'properties.company.value') || ''; 40 | const name = getFullName(contact.properties); 41 | return {vid: contact.vid, name, companyName}; 42 | }); 43 | }; 44 | 45 | const getFullName = (contactProperties) => { 46 | const firstName = _.get(contactProperties, 'firstname.value') || ''; 47 | const lastName = _.get(contactProperties, 'lastname.value') || ''; 48 | return `${firstName} ${lastName}` 49 | }; 50 | 51 | const refreshToken = async () => { 52 | hubspot = new Hubspot({ 53 | clientId: CLIENT_ID, 54 | clientSecret: CLIENT_SECRET, 55 | redirectUri: REDIRECT_URI, 56 | scopes: SCOPES, 57 | refreshToken: tokenStore.refresh_token 58 | }); 59 | 60 | tokenStore = await hubspot.refreshAccessToken(); 61 | tokenStore.updated_at = Date.now(); 62 | console.log('Updated tokens', tokenStore); 63 | }; 64 | 65 | 66 | const app = express(); 67 | 68 | let hubspot = new Hubspot({ 69 | clientId: CLIENT_ID, 70 | clientSecret: CLIENT_SECRET, 71 | redirectUri: REDIRECT_URI, 72 | scopes: SCOPES, 73 | }); 74 | 75 | app.use(express.static('public')); 76 | app.set('view engine', 'pug'); 77 | app.set('views', path.join(__dirname, 'views')); 78 | 79 | app.use(bodyParser.urlencoded({ 80 | limit: '50mb', 81 | extended: true, 82 | })); 83 | 84 | app.use(bodyParser.json({ 85 | limit: '50mb', 86 | extended: true, 87 | })); 88 | 89 | app.use(checkEnv); 90 | 91 | app.get('/', async (req, res) => { 92 | try { 93 | if (!isAuthorized()) return res.render('login'); 94 | if (isTokenExpired()) await refreshToken(); 95 | 96 | // Get all contacts 97 | // GET /contacts/v1/lists/all/contacts/all 98 | // https://developers.hubspot.com/docs/methods/contacts/get_contacts 99 | console.log('Calling contacts.get API method. Retrieve all contacts.'); 100 | const contactsResponse = await hubspot.contacts.get({count: CONTACTS_COUNT}); 101 | console.log('Response from API', contactsResponse); 102 | 103 | res.render('contacts', { tokenStore, contacts: prepareContactsContent(contactsResponse.contacts) }); 104 | } catch (e) { 105 | console.error(e); 106 | res.redirect(`/error?msg=${e.message}`); 107 | } 108 | }); 109 | 110 | app.use('/oauth', async (req, res) => { 111 | 112 | const authorizationUrlParams = { 113 | client_id: CLIENT_ID, 114 | redirect_uri: REDIRECT_URI, 115 | scopes: SCOPES 116 | }; 117 | 118 | // Use the client to get authorization Url 119 | // https://www.npmjs.com/package/hubspot 120 | console.log('Creating authorization Url'); 121 | const authorizationUrl = hubspot.oauth.getAuthorizationUrl(authorizationUrlParams); 122 | console.log('Authorization Url', authorizationUrl); 123 | 124 | res.redirect(authorizationUrl); 125 | }); 126 | 127 | app.use('/oauth-callback', async (req, res) => { 128 | const code = _.get(req, 'query.code'); 129 | 130 | // Get OAuth 2.0 Access Token and Refresh Tokens 131 | // POST /oauth/v1/token 132 | // https://developers.hubspot.com/docs/methods/oauth2/get-access-and-refresh-tokens 133 | console.log('Retrieving access token by code:', code); 134 | tokenStore = await hubspot.oauth.getAccessToken({code}); 135 | console.log('Retrieving access token result:', tokenStore); 136 | tokenStore.updated_at = Date.now(); 137 | 138 | // Set token for the 139 | // https://www.npmjs.com/package/hubspot 140 | hubspot.setAccessToken((tokenStore.access_token)); 141 | res.redirect('/'); 142 | }); 143 | 144 | app.get('/login', (req, res) => { 145 | tokenStore = {}; 146 | res.redirect('/'); 147 | }); 148 | 149 | app.get('/refresh', async (req, res) => { 150 | if (isAuthorized()) await refreshToken(); 151 | res.redirect('/'); 152 | }); 153 | 154 | app.get('/error', (req, res) => { 155 | res.render('error', {error: req.query.msg}); 156 | }); 157 | 158 | app.use((error, req, res, next) => { 159 | res.render('error', {error: error.message}); 160 | }); 161 | 162 | app.listen(PORT, () => console.log(`Listening on http://localhost:${PORT}`)); 163 | -------------------------------------------------------------------------------- /oauth-app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hubspot-oauth", 3 | "version": "1.0.0", 4 | "engines": { 5 | "node": ">= 10.13.0" 6 | }, 7 | "description": "hubspot-node client sample applications", 8 | "main": "index.js", 9 | "scripts": { 10 | "start": "node index.js" 11 | }, 12 | "keywords": [ 13 | "hubspot", 14 | "oauth", 15 | "contacts", 16 | "sample", 17 | "example" 18 | ], 19 | "author": "hubspot", 20 | "license": "Apache-2.0", 21 | "dependencies": { 22 | "body-parser": "^1.19.0", 23 | "dotenv": "^8.1.0", 24 | "express": "^4.16.3", 25 | "hubspot": "^2.3.2", 26 | "lodash": "^4.17.15", 27 | "pug": "^2.0.4" 28 | }, 29 | "devDependencies": { 30 | "nodemon": "^1.19.2" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /oauth-app/public/css/main.css: -------------------------------------------------------------------------------- 1 | .wrapper .container { 2 | padding-bottom: 2rem; 3 | padding-top: 2rem; 4 | } 5 | 6 | .navigation { 7 | background: #f4f5f6; 8 | border-bottom: .1rem solid #d1d1d1; 9 | display: block; 10 | height: 5.2rem; 11 | left: 0; 12 | max-width: 100%; 13 | width: 100%; 14 | } 15 | 16 | .navigation .container { 17 | padding-bottom: 0; 18 | padding-top: 0 19 | } 20 | 21 | .navigation .navigation-list { 22 | list-style: none; 23 | margin-bottom: 0; 24 | } 25 | 26 | .navigation .navigation-item { 27 | float: left; 28 | margin-bottom: 0; 29 | margin-left: 2.5rem; 30 | position: relative 31 | } 32 | 33 | .navigation .navigation-title, .navigation .title { 34 | color: #606c76; 35 | position: relative 36 | } 37 | 38 | .navigation .navigation-link, .navigation .navigation-title, .navigation .title { 39 | display: inline; 40 | font-size: 1.6rem; 41 | line-height: 5.2rem; 42 | padding: 0; 43 | text-decoration: none 44 | } 45 | 46 | .navigation .navigation-link.active { 47 | color: #606c76 48 | } 49 | 50 | .authorize-button { 51 | text-align: center; 52 | } 53 | 54 | .tokens-table { 55 | max-width: 600px; 56 | } 57 | 58 | .tokens-table td { 59 | white-space: nowrap; 60 | overflow: hidden; 61 | text-overflow: ellipsis; 62 | font-size: small; 63 | } 64 | -------------------------------------------------------------------------------- /oauth-app/public/favicon-32x32.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HubSpot/integration-examples-nodejs/118db05d4770745a60e5ad89031000ff09ea160e/oauth-app/public/favicon-32x32.webp -------------------------------------------------------------------------------- /oauth-app/views/contacts.pug: -------------------------------------------------------------------------------- 1 | extends includes/layout 2 | block content 3 | .container 4 | h3 Tokens 5 | table(class='tokens-table') 6 | thead 7 | tr 8 | th Name 9 | th Value 10 | tbody 11 | tr 12 | td Access Token 13 | td #{tokenStore.access_token || ''} 14 | tr 15 | td Refresh Token 16 | td #{tokenStore.refresh_token || ''} 17 | tr 18 | td Expires In 19 | td #{tokenStore.expires_in || ''} 20 | tr 21 | td Updated At 22 | td #{tokenStore.updated_at ? new Date(tokenStore.updated_at) : ''} 23 | 24 | h3 Contacts 25 | table 26 | thead 27 | tr 28 | th ID 29 | th Name 30 | th Company 31 | tbody 32 | each contact in contacts 33 | tr 34 | td #{contact.vid} 35 | td #{contact.name} 36 | td #{contact.companyName} 37 | -------------------------------------------------------------------------------- /oauth-app/views/error.pug: -------------------------------------------------------------------------------- 1 | extends includes/layout 2 | block content 3 | .container 4 | h4 Error 5 | p #{error} 6 | -------------------------------------------------------------------------------- /oauth-app/views/includes/footer.pug: -------------------------------------------------------------------------------- 1 | .footer 2 | .container 3 | -------------------------------------------------------------------------------- /oauth-app/views/includes/head.pug: -------------------------------------------------------------------------------- 1 | head 2 | meta(charset='UTF-8') 3 | meta(name='description' content='HubSpot JavaScript Sample Webhooks') 4 | title HubSpot JavaScript Sample Webhooks 5 | link(rel='stylesheet' href='//fonts.googleapis.com/css?family=Roboto:300,300italic,700,700italic') 6 | link(rel='stylesheet' href='//cdnjs.cloudflare.com/ajax/libs/normalize/5.0.0/normalize.css') 7 | link(rel='stylesheet' href='//cdnjs.cloudflare.com/ajax/libs/milligram/1.3.0/milligram.css') 8 | link(rel='stylesheet' href='/css/main.css') 9 | // Fav Icon 10 | link(href='/favicon-32x32.webp' rel='shortcut icon') 11 | link(href='/favicon-32x32.webp' rel='apple-touch-icon') 12 | 13 | script(type='application/javascript' src='https://cdnjs.cloudflare.com/ajax/libs/jquery/3.4.1/jquery.min.js') 14 | -------------------------------------------------------------------------------- /oauth-app/views/includes/header.pug: -------------------------------------------------------------------------------- 1 | header 2 | .navigation 3 | .container 4 | a(class='navigation-title' href='/') 5 | h3(class='title') HubSpot JavaScript Sample OAuth2 6 | 7 | ul(class='navigation-list float-right') 8 | li(class='navigation-item') 9 | a(class='navigation-link' href='/refresh') Refresh Access Token 10 | li(class='navigation-item') 11 | a(class='navigation-link' href='/login') OAuth2 12 | -------------------------------------------------------------------------------- /oauth-app/views/includes/layout.pug: -------------------------------------------------------------------------------- 1 | doctype html 2 | html(lang='en') 3 | include head 4 | body 5 | main(class='wrapper') 6 | include header 7 | block content 8 | include footer 9 | -------------------------------------------------------------------------------- /oauth-app/views/login.pug: -------------------------------------------------------------------------------- 1 | extends includes/layout 2 | block content 3 | .container 4 | .authorize-button 5 | a(class='button' href='/oauth') Authorize 6 | -------------------------------------------------------------------------------- /webhooks-app/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:10 2 | 3 | WORKDIR /webhook-app/src 4 | 5 | COPY src/package.json ./ 6 | RUN npm install 7 | 8 | EXPOSE 3000 9 | -------------------------------------------------------------------------------- /webhooks-app/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | 3 | services: 4 | zookeeper: 5 | image: zookeeper:3.5 6 | ports: 7 | - 2181:2181 8 | logging: 9 | driver: none 10 | tmpfs: "/datalog" 11 | kafka: 12 | image: wurstmeister/kafka 13 | ports: 14 | - 9092:9092 15 | environment: 16 | KAFKA_ADVERTISED_HOST_NAME: kafka 17 | KAFKA_CREATE_TOPICS: "events:1:1" 18 | KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181 19 | KAFKA_BROKER_LIST: kafka:9092 20 | KAFKA_REFRESH_INTERVAL_MS: 1000 21 | KAFKA_BROKER_VERSION: 1.0.0 22 | KAFKA_PRODUCE_INTERVAL: 500 23 | KAFKA_GROUP_ID: events 24 | EVENT_TOPIC: events 25 | volumes: 26 | - /var/run/docker.sock:/var/run/docker.sock 27 | depends_on: 28 | - zookeeper 29 | logging: 30 | driver: none 31 | 32 | db: 33 | image: mysql:8.0 34 | command: mysqld --default-authentication-plugin=mysql_native_password 35 | volumes: 36 | - ./db/mysql:/var/lib/mysql 37 | ports: 38 | - 3306:3306 39 | environment: 40 | MYSQL_ROOT_PASSWORD: root 41 | MYSQL_DATABASE: events 42 | MYSQL_USER: events 43 | MYSQL_PASSWORD: events 44 | logging: 45 | driver: none 46 | 47 | webhooks_web: 48 | env_file: 49 | - src/.env 50 | environment: 51 | KAFKA_BROKER_LIST: kafka:9092 52 | KAFKA_GROUP_ID: events 53 | EVENT_TOPIC: events 54 | 55 | MYSQL_HOST: db 56 | MYSQL_DATABASE: events 57 | MYSQL_USER: events 58 | MYSQL_PASSWORD: events 59 | build: . 60 | volumes: 61 | - ./src:/webhook-app/src 62 | - /webhook-app/src/node_modules 63 | - ./db:/webhook-app/db 64 | - ./tools:/webhook-app/tools 65 | ports: 66 | - 3000:3000 67 | command: ../tools/wait-for-it.sh db:3306 -t 30 --strict -- npm run start 68 | depends_on: 69 | - kafka 70 | - db 71 | -------------------------------------------------------------------------------- /webhooks-app/src/.env.template: -------------------------------------------------------------------------------- 1 | NGROK_AUTHTOKEN= 2 | HUBSPOT_CLIENT_ID= 3 | HUBSPOT_CLIENT_SECRET= 4 | -------------------------------------------------------------------------------- /webhooks-app/src/README.md: -------------------------------------------------------------------------------- 1 | # HubSpot-nodejs webhooks sample app 2 | 3 | This is a sample app for the [hubspot-node SDK](https://github.com/MadKudu/node-hubspot). 4 | Currently, this app focuses on demonstrating the functionality of [Webhooks API](https://developers.hubspot.com/docs/methods/webhooks/webhooks-overview), contact creation/deletion in particular. 5 | 6 | Please note that the Webhooks events are not sent in chronological order with respect to the creation time. Events might be sent in large numbers, for example when the user imports large number of contacts or deletes a large list of contacts. 7 | The application demonstrates the use of Queues (Kafka in case of this application - see [kafka-helper.js](https://github.com/HubSpot/integration-examples-nodejs/tree/master/webhooks-app/src/js/kafka-helper.js)) to process webhooks events. 8 | 9 | Common webhook processing practice consists of few steps: 10 | 1. Handle methods receive the request sent by the webook and immediately place payload on the queue [webhooks-controller.js](https://github.com/HubSpot/integration-examples-nodejs/tree/master/webhooks-app/src/js/webhooks-controller.js) 11 | 2. Message consumer instance(s) is running in a separate process, typically on multiple nodes in a cloud, such as AWS [events-service.js](https://github.com/HubSpot/integration-examples-nodejs/tree/master/webhooks-app/src/js/events-service.js) 12 | 3. Consumer stores webhook events in the database potentially calling an API to get full record of the object that triggered the event 13 | - This application uses MySQL, the methods working with the database can be seen in [db-helper.js](https://github.com/HubSpot/integration-examples-nodejs/tree/master/webhooks-app/src/js/db-helper.js) 14 | 4. Other services/objects fetch the events data from the database sorted by timestamp of the event [db-helper.js](https://github.com/HubSpot/integration-examples-nodejs/tree/master/webhooks-app/src/js/db-helper.js#L41) 15 | 16 | 17 | ### Note on the Data Base 18 | This application uses MySQL database to store the events coming from Webhooks. There is a single events table: 19 | ``` 20 | create table if not exists events ( 21 | id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, 22 | event_type VARCHAR(255), 23 | property_name VARCHAR(255), 24 | property_value VARCHAR(255), 25 | object_id bigint default null, 26 | event_id bigint default null, 27 | occurred_at bigint default null, 28 | shown tinyint(1) default 0, 29 | created_at datetime default CURRENT_TIMESTAMP 30 | );` 31 | ``` 32 | Please note that event_id sent by HubSpot needs to be stored as int 33 | 34 | ### Setup App 35 | 36 | Make sure you have [Docker Compose](https://docs.docker.com/compose/) and [Ngrok](https://ngrok.com/) installed. 37 | 38 | ### Configure 39 | 40 | 1. Copy .env.template to .env 41 | 2. Paste your HUBSPOT_CLIENT_ID and HUBSPOT_CLIENT_SECRET 42 | 43 | ### Running 44 | 45 | The best way to run this project (with the least configuration), is using docker compose. Change to the webroot and start it 46 | 47 | ```bash 48 | docker-compose up --build 49 | ``` 50 | 51 | Copy Ngrok url from console. Now you should now be able to navigate to that url and use the application. 52 | 53 | ### NOTE about Ngrok Too Many Connections error 54 | 55 | If you are using Ngrok free plan and testing the application with large amount of import/deletions of Contacts you are likely to see Ngrok "Too Many Connections" error. 56 | This is caused by a large amount of weebhooks events being sent to Ngrok tunnel. To avoid it you can deploy sample applications on your server w/o Ngrok or upgrade to Ngrok Enterprise version 57 | 58 | ### Configure webhooks 59 | 60 | Required webhooks url should look like https://***.ngrok.io/webhooks 61 | 62 | Following [Webhooks Setup](https://developers.hubspot.com/docs/methods/webhooks/webhooks-overview) guide please note: 63 | - Every time the app is restarted you should update the webhooks url 64 | - The app supports `contact.creation` and `contact.deletion` subscription types only 65 | - Subscription are paused by default. You need to activate them manually after creating 66 | -------------------------------------------------------------------------------- /webhooks-app/src/index.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config({path: '.env'}); 2 | 3 | const url = require('url'); 4 | const _ = require('lodash'); 5 | const path = require('path'); 6 | const ngrok = require('ngrok'); 7 | const Hubspot = require('hubspot'); 8 | const express = require('express'); 9 | const Promise = require('bluebird'); 10 | const bodyParser = require('body-parser'); 11 | const dbHelper = require('./js/db-helper'); 12 | const dbConnector = require('./js/db-connector'); 13 | const kafkaHelper = require('./js/kafka-helper'); 14 | const eventsService = require('./js/events-service'); 15 | const oauthController = require('./js/oauth-controller'); 16 | const contactsController = require('./js/contacts-controller'); 17 | const webhooksController = require('./js/webhooks-controller'); 18 | 19 | const PORT = 3000; 20 | const CLIENT_ID = process.env.HUBSPOT_CLIENT_ID; 21 | const CLIENT_SECRET = process.env.HUBSPOT_CLIENT_SECRET; 22 | 23 | const HUBSPOT_AUTH_CONFIG = { 24 | clientId: CLIENT_ID, 25 | clientSecret: CLIENT_SECRET, 26 | }; 27 | 28 | let hubspot; 29 | let tokens = {}; 30 | 31 | const checkEnv = (req, res, next) => { 32 | if (_.startsWith(req.url, '/error')) return next(); 33 | 34 | if (_.isNil(CLIENT_ID)) return res.redirect('/error?msg=Please set HUBSPOT_CLIENT_ID env variable to proceed'); 35 | if (_.isNil(CLIENT_SECRET)) return res.redirect('/error?msg=Please set HUBSPOT_CLIENT_SECRET env variable to proceed'); 36 | 37 | next(); 38 | }; 39 | 40 | const getHostUrl = (req) => { 41 | return url.format({ 42 | protocol: 'https', 43 | hostname: req.get('host') 44 | }); 45 | }; 46 | 47 | const isTokenExpired = () => { 48 | return Date.now() >= Date.parse(tokens.updated_at) + tokens.expires_in * 1000; 49 | }; 50 | 51 | const setupHubspot = async (req, res, next) => { 52 | if (_.startsWith(req.url, '/error')) return next(); 53 | if (_.startsWith(req.url, '/login')) return next(); 54 | 55 | if (tokens.initialized && hubspot) { 56 | req.hubspot = hubspot; 57 | next(); 58 | return; 59 | } 60 | 61 | if (_.isNil(tokens.refresh_token)) { 62 | console.log('Missed tokens, check DB'); 63 | tokens = await dbHelper.getTokens() || {}; 64 | console.log('Tokens from DB:', tokens); 65 | } 66 | 67 | if (_.isNil(hubspot)) { 68 | const redirectUri = `${getHostUrl(req)}/auth/oauth-callback`; 69 | const refreshToken = tokens.refresh_token; 70 | console.log('Creating HubSpot api wrapper instance'); 71 | hubspot = new Hubspot(_.extend({}, HUBSPOT_AUTH_CONFIG, {redirectUri, refreshToken})); 72 | } 73 | req.hubspot = hubspot; 74 | 75 | 76 | if (!tokens.initialized && !_.isNil(tokens.refresh_token)) { 77 | console.log('Need to initialized tokens!'); 78 | 79 | if (isTokenExpired()) { 80 | console.log('HubSpot: need to refresh token'); 81 | const hubspotTokens = await req.hubspot.refreshAccessToken(); 82 | tokens = await dbHelper.updateTokens(hubspotTokens); 83 | console.log('Updated tokens', tokens); 84 | } else { 85 | console.log('HubSpot: set access token'); 86 | req.hubspot.setAccessToken(tokens.access_token); 87 | } 88 | tokens.initialized = true; 89 | console.log('Tokens are initialized'); 90 | } else if (!_.startsWith(req.url, '/auth')) { 91 | console.log('Not initialized tokens!'); 92 | return res.redirect('/login'); 93 | } 94 | 95 | next(); 96 | }; 97 | 98 | const app = express(); 99 | 100 | app.use(express.static('public')); 101 | app.set('view engine', 'pug'); 102 | app.set('views', path.join(__dirname, 'views')); 103 | 104 | app.use(bodyParser.urlencoded({ 105 | limit: '50mb', 106 | extended: true, 107 | })); 108 | 109 | app.use(bodyParser.json({ 110 | limit: '50mb', 111 | extended: true, 112 | verify: webhooksController.getWebhookVerification() 113 | })); 114 | 115 | app.use((req, res, next) => { 116 | console.log(req.method, req.url); 117 | next(); 118 | }); 119 | 120 | app.use(checkEnv); 121 | app.use(setupHubspot); 122 | 123 | app.get('/', (req, res) => { 124 | res.redirect('/contacts'); 125 | }); 126 | 127 | app.get('/login', async (req, res) => { 128 | if (tokens.initialized) return res.redirect('/'); 129 | res.render('login'); 130 | }); 131 | 132 | app.use('/auth', oauthController.getRouter()); 133 | app.use('/contacts', contactsController.getRouter()); 134 | app.use('/webhooks', webhooksController.getRouter()); 135 | 136 | app.get('/error', (req, res) => { 137 | res.render('error', {error: req.query.msg}); 138 | }); 139 | 140 | app.use((error, req, res, next) => { 141 | res.render('error', {error: error.message}); 142 | }); 143 | 144 | (async () => { 145 | try { 146 | await dbConnector.init(); 147 | await kafkaHelper.init(eventsService.getHandler()); 148 | 149 | const server = app.listen(PORT, () => { 150 | console.log(`Listening on port: ${PORT}`); 151 | Promise 152 | .delay(100) 153 | .then(() => ngrok.connect(PORT)) 154 | .then((url) => console.log('Please use:', url)); 155 | }); 156 | 157 | process.on('SIGTERM', async () => { 158 | await dbConnector.close(); 159 | 160 | server.close(() => { 161 | console.log('Process terminated') 162 | }); 163 | }); 164 | } catch (e) { 165 | console.log('Error during app start. ', e); 166 | } 167 | 168 | })(); 169 | -------------------------------------------------------------------------------- /webhooks-app/src/js/contacts-controller.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const express = require('express'); 3 | const router = new express.Router(); 4 | const dbHelper = require('./db-helper'); 5 | 6 | const EVENTS_COUNT_PER_PAGE = 25; 7 | 8 | const getEventForView = (event) => { 9 | const type = _ 10 | .chain(event) 11 | .get('event_type') 12 | .split('.') 13 | .last() 14 | .value(); 15 | const name = _.get(event, 'property_name'); 16 | const value = _.get(event, 'property_value'); 17 | 18 | return {type, name, value}; 19 | }; 20 | 21 | const getFullName = (contact) => { 22 | const firstName = _.get(contact, 'properties.firstname.value') || ''; 23 | const lastName = _.get(contact, 'properties.lastname.value') || ''; 24 | return `${firstName} ${lastName}` 25 | }; 26 | 27 | const prepareContactsForView = (events, contacts) => { 28 | return _.reduce(events, (eventsForView, event) => { 29 | const contactId = _.get(event, 'object_id'); 30 | 31 | if (_.isNil(eventsForView[contactId])) { 32 | const contact = contacts[contactId]; 33 | const name = contact ? getFullName(contact) : 'Deleted'; 34 | eventsForView[contactId] = {name, events: []} 35 | } 36 | 37 | const eventForView = getEventForView(event); 38 | eventsForView[contactId].events.push(eventForView); 39 | return eventsForView; 40 | }, {}); 41 | }; 42 | 43 | const getPaginationConfig = async (offset) => { 44 | const totalCount = await dbHelper.getEventsCount(); 45 | const pagesCount = Math.ceil(totalCount / EVENTS_COUNT_PER_PAGE); 46 | 47 | let rawPaginationConfig = _.map(Array(pagesCount), (v, index) => { 48 | const link = `/contacts/?offset=${index * EVENTS_COUNT_PER_PAGE}`; 49 | const aClass = index * EVENTS_COUNT_PER_PAGE === offset ? 'active' : ''; 50 | return {label: index + 1, link, aClass}; 51 | }); 52 | 53 | if (rawPaginationConfig.length < 2) return []; 54 | 55 | return rawPaginationConfig.length === 2 56 | ? rawPaginationConfig 57 | : _.concat([{label: '<<', link: '/contacts'}], rawPaginationConfig, 58 | [{label: '>>', link: `/contacts?offset=${(pagesCount - 1) * EVENTS_COUNT_PER_PAGE}`}]); 59 | }; 60 | 61 | 62 | exports.getRouter = () => { 63 | router.get('/', async (req, res) => { 64 | try { 65 | 66 | const offset = req.query.offset ? parseInt(req.query.offset) : 0; 67 | const limit = req.query.limit ? parseInt(req.query.limit) : EVENTS_COUNT_PER_PAGE; 68 | 69 | const contactIds = await dbHelper.getContactIds(offset, limit); 70 | const paginationConfig = await getPaginationConfig(offset); 71 | 72 | console.log('Calling contacts.getByIdBatch API method. Retrieve contacts.'); 73 | // Get a batch of contacts by vid 74 | // GET /contacts/v1/contact/vids/batch/ 75 | // https://developers.hubspot.com/docs/methods/contacts/get_batch_by_vid 76 | const contactsResponse = await req.hubspot.contacts.getByIdBatch(contactIds); 77 | console.log(contactsResponse); 78 | 79 | const events = await dbHelper.getEvents(contactIds); 80 | const contacts = prepareContactsForView(events, contactsResponse); 81 | await dbHelper.setAllWebhooksEventsShown(); 82 | 83 | res.render('contacts', {contacts, paginationConfig}); 84 | } catch (e) { 85 | console.error(e); 86 | res.redirect(`/error?msg=${e.message}`); 87 | } 88 | }); 89 | 90 | return router; 91 | }; 92 | -------------------------------------------------------------------------------- /webhooks-app/src/js/db-connector.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const mysql = require('mysql'); 3 | const Promise = require('bluebird'); 4 | 5 | let connection = null; 6 | 7 | const MYSQL_HOST = process.env.MYSQL_HOST; 8 | const MYSQL_USER = process.env.MYSQL_USER; 9 | const MYSQL_DATABASE = process.env.MYSQL_DATABASE; 10 | const MYSQL_PASSWORD = process.env.MYSQL_PASSWORD; 11 | 12 | const EVENTS_TABLE_INIT = 13 | `create table if not exists events ( 14 | id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, 15 | event_type VARCHAR(255) default "N/A", 16 | property_name VARCHAR(255) default null, 17 | property_value VARCHAR(255) default null, 18 | object_id bigint default null, 19 | event_id bigint default null, 20 | occurred_at bigint default null, 21 | shown tinyint(1) default 0, 22 | created_at datetime default CURRENT_TIMESTAMP 23 | );`; 24 | 25 | const TOKENS_TABLE_INIT = 26 | `create table if not exists tokens ( 27 | id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, 28 | refresh_token VARCHAR(255) default null, 29 | access_token VARCHAR(255) default null, 30 | expires_in bigint default null, 31 | created_at datetime default CURRENT_TIMESTAMP, 32 | updated_at datetime default CURRENT_TIMESTAMP 33 | );`; 34 | 35 | 36 | exports.init = async () => { 37 | try { 38 | connection = new mysql.createConnection({ 39 | host: MYSQL_HOST, 40 | user: MYSQL_USER, 41 | password: MYSQL_PASSWORD, 42 | database: MYSQL_DATABASE 43 | }); 44 | 45 | connection.connectAsync = Promise.promisify(connection.connect); 46 | connection.queryAsync = Promise.promisify(connection.query); 47 | 48 | console.log('connecting to DB'); 49 | await connection.connectAsync(); 50 | 51 | console.log('init tables'); 52 | await connection.queryAsync(EVENTS_TABLE_INIT); 53 | await connection.queryAsync(TOKENS_TABLE_INIT); 54 | } catch (e) { 55 | console.error('DB is not available'); 56 | console.error(e); 57 | } 58 | }; 59 | 60 | exports.close = async () => { 61 | if (connection) connection.end(); 62 | }; 63 | 64 | exports.run = (sql) => { 65 | console.log(sql); 66 | return _.isNull(connection) 67 | ? Promise.reject('DB not initialized!') 68 | : connection.queryAsync(sql); 69 | }; 70 | -------------------------------------------------------------------------------- /webhooks-app/src/js/db-helper.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const Promise = require('bluebird'); 3 | const dbConnector = require('./db-connector'); 4 | 5 | const GET_EVENTS_COUNT = 'select count(distinct object_id) as result from events'; 6 | const GET_NEW_EVENTS_COUNT = 'select count(*) from events where shown = 0'; 7 | const SET_EVENTS_SHOWN = 'update events set shown = 1 where shown = 0'; 8 | const GET_TOKENS = `select * from tokens order by 'updated_at' desc limit 1`; 9 | 10 | 11 | const getStringValueForSQL = (value) => { 12 | return _.isNil(value) ? null : `"${value}"`; 13 | }; 14 | 15 | module.exports = { 16 | getTokens: async () => { 17 | const result = await dbConnector.run(GET_TOKENS); 18 | return result[0]; 19 | }, 20 | 21 | saveTokens: (tokens) => { 22 | const SAVE_TOKENS = `insert into tokens (refresh_token, access_token, expires_in) values ("${tokens.refresh_token}", "${tokens.access_token}", ${tokens.expires_in})`; 23 | return dbConnector.run(SAVE_TOKENS); 24 | }, 25 | 26 | updateTokens: async (tokens) => { 27 | const UPDATE_TOKENS = `update tokens set access_token = '${tokens.access_token}', updated_at = CURRENT_TIMESTAMP where refresh_token = "${tokens.refresh_token}"`; 28 | const GET_TOKENS = `select * from tokens where refresh_token = "${tokens.refresh_token}"`; 29 | 30 | await dbConnector.run(UPDATE_TOKENS); 31 | const result = await dbConnector.run(GET_TOKENS); 32 | return result[0]; 33 | }, 34 | 35 | addEvents: (events) => { 36 | console.log(events.length); 37 | const valuesToInsert = _ 38 | .chain(events) 39 | .map((event) => { 40 | const propertyName = getStringValueForSQL(event.propertyName); 41 | const propertyValue = getStringValueForSQL(event.propertyValue); 42 | return `(${event.eventId}, "${event.subscriptionType}", ${propertyName}, ${propertyValue}, ${event.objectId}, ${event.occurredAt})` 43 | }) 44 | .join(',') 45 | .value(); 46 | 47 | const INSERT_EVENT_SQL = `insert into events (event_id, event_type, property_name, property_value, object_id, occurred_at) values ${valuesToInsert}`; 48 | return dbConnector.run(INSERT_EVENT_SQL); 49 | }, 50 | 51 | getContactIds: async (offset, limit) => { 52 | const GET_CONTACT_IDS = `select distinct object_id from events limit ${limit} offset ${offset}`; 53 | const result = await dbConnector.run(GET_CONTACT_IDS); 54 | return _.map(result, 'object_id'); 55 | }, 56 | 57 | getEvents: (contactIds) => { 58 | if (_.isEmpty(contactIds)) return Promise.resolve(); 59 | 60 | const GET_ALL_EVENTS = `select * from events where object_id in (${_.toString(contactIds)})`; 61 | return dbConnector.run(GET_ALL_EVENTS); 62 | }, 63 | 64 | getEventsCount: async () => { 65 | const result = await dbConnector.run(GET_EVENTS_COUNT); 66 | return _.get(result, '0.result') || 0; 67 | }, 68 | 69 | setAllWebhooksEventsShown: () => dbConnector.run(SET_EVENTS_SHOWN), 70 | 71 | getNewEventsCount: async () => { 72 | const QUERY_KEY = 'count(*)'; 73 | const eventsCountResponse = await dbConnector.run(GET_NEW_EVENTS_COUNT); 74 | return eventsCountResponse[0][QUERY_KEY]; 75 | } 76 | }; 77 | -------------------------------------------------------------------------------- /webhooks-app/src/js/events-service.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const dbHelper = require('./db-helper'); 3 | 4 | 5 | exports.getHandler = () => { 6 | return (message) => { 7 | const events = JSON.parse(message.value); 8 | return dbHelper.addEvents(events); 9 | }; 10 | }; 11 | -------------------------------------------------------------------------------- /webhooks-app/src/js/kafka-helper.js: -------------------------------------------------------------------------------- 1 | const kafka = require('kafka-node'); 2 | const Promise = require('bluebird'); 3 | 4 | const KAFKA_EVENT_TOPIC = process.env.EVENT_TOPIC || 'events'; 5 | const KAFKA_GROUP_ID = process.env.KAFKA_GROUP_ID || 'events'; 6 | const KAFKA_HOST = process.env.KAFKA_BROKER_LIST; 7 | 8 | let producer; 9 | let consumer; 10 | 11 | 12 | const initProducer = () => { 13 | return new Promise((resolve, reject) => { 14 | const Producer = kafka.Producer; 15 | const producerClient = new kafka.KafkaClient({kafkaHost: KAFKA_HOST}); 16 | const producer = new Producer(producerClient); 17 | 18 | producer.on('ready', () => { 19 | console.log('Producer ready. Refresh metadata'); 20 | producerClient.refreshMetadata([KAFKA_EVENT_TOPIC], (error) => { 21 | if (error) { 22 | console.error('Producer refresh metadata error:', error); 23 | reject(error); 24 | } 25 | resolve(producer); 26 | }); 27 | }); 28 | 29 | producer.on('error', (err) => { 30 | console.log('Producer error'); 31 | console.error(err); 32 | }); 33 | }) 34 | }; 35 | 36 | const initConsumer = (eventsHandler) => { 37 | return new Promise((resolve, reject) => { 38 | const Consumer = kafka.Consumer; 39 | const consumerClient = new kafka.KafkaClient({kafkaHost: KAFKA_HOST}); 40 | 41 | const consumer = new Consumer(consumerClient, 42 | [{topic: KAFKA_EVENT_TOPIC}], 43 | { 44 | groupId: KAFKA_GROUP_ID, 45 | autoCommit: true 46 | } 47 | ); 48 | 49 | consumer.on('error', (err) => { 50 | console.log('Consumer error'); 51 | console.error(err); 52 | }); 53 | 54 | console.log('Consumer ready. Refresh metadata'); 55 | consumerClient.refreshMetadata([KAFKA_EVENT_TOPIC], (error, data) => { 56 | if (error) { 57 | console.error('Consumer refresh metadata error', error); 58 | reject(error); 59 | } else { 60 | consumer.on('message', (message) => { 61 | console.log('Received', message); 62 | eventsHandler(message); 63 | }); 64 | resolve(consumer); 65 | } 66 | }); 67 | }); 68 | }; 69 | 70 | 71 | exports.init = async (eventsHandler) => { 72 | if (!producer) { 73 | producer = await initProducer(); 74 | } 75 | if (!consumer) { 76 | consumer = await initConsumer(eventsHandler); 77 | } 78 | }; 79 | 80 | exports.send = (event) => { 81 | return new Promise((resolve, reject) => { 82 | console.log('Sending', event); 83 | producer.send([{ 84 | topic: KAFKA_EVENT_TOPIC, 85 | messages: JSON.stringify(event), 86 | key: '' 87 | }], (error, data) => { 88 | if (error) { 89 | console.error(error); 90 | reject(error); 91 | } else resolve(data); 92 | }); 93 | }); 94 | }; 95 | -------------------------------------------------------------------------------- /webhooks-app/src/js/oauth-controller.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const express = require('express'); 3 | const router = new express.Router(); 4 | const dbHelper = require('./db-helper'); 5 | 6 | const SCOPE = 'contacts'; 7 | 8 | 9 | exports.getRouter = () => { 10 | router.get('/oauth', async (req, res) => { 11 | 12 | // Use the client to get authorization Url 13 | // https://www.npmjs.com/package/hubspot#obtain-your-authorization-url 14 | const authorizationUrl = req.hubspot.oauth.getAuthorizationUrl({scope: SCOPE}); 15 | console.log('Authorization Url:', authorizationUrl); 16 | 17 | res.redirect(authorizationUrl); 18 | }); 19 | 20 | router.get('/oauth-callback', async (req, res) => { 21 | const code = _.get(req, 'query.code'); 22 | 23 | // Get OAuth 2.0 Access Token and Refresh Tokens 24 | // POST /oauth/v1/token 25 | // https://developers.hubspot.com/docs/methods/oauth2/get-access-and-refresh-tokens 26 | // 27 | // https://www.npmjs.com/package/hubspot#obtain-an-access-token-from-an-authorization_code 28 | console.log('Retrieving access token by code:', code); 29 | const tokens = await req.hubspot.oauth.getAccessToken({code}); 30 | await dbHelper.saveTokens(tokens); 31 | res.redirect('/'); 32 | }); 33 | 34 | return router; 35 | }; 36 | -------------------------------------------------------------------------------- /webhooks-app/src/js/utils.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | 3 | exports.logJson = (data) => { 4 | console.log('Response', JSON.stringify(data, null, 2)); 5 | }; 6 | -------------------------------------------------------------------------------- /webhooks-app/src/js/webhooks-controller.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const crypto = require('crypto'); 3 | const express = require('express'); 4 | const router = new express.Router(); 5 | const dbHelper = require('./db-helper'); 6 | 7 | const utils = require('./utils'); 8 | const kafkaHelper = require('./kafka-helper'); 9 | 10 | const SIGNATURE_HEADER = 'X-HubSpot-Signature'; 11 | 12 | 13 | exports.getRouter = () => { 14 | router.post('/', async (req, res) => { 15 | const events = req.body; 16 | 17 | console.log('Received hook events:'); 18 | utils.logJson(events); 19 | await kafkaHelper.send(events); 20 | res.sendStatus(200); 21 | }); 22 | 23 | router.get('/new', async (req, res) => { 24 | const notShownEventsCount = await dbHelper.getNewEventsCount(); 25 | res.status(200).jsonp({notShownEventsCount}); 26 | }); 27 | 28 | return router; 29 | }; 30 | 31 | exports.getWebhookVerification = () => { 32 | return (req, res, buf, encoding) => { 33 | try { 34 | if (req.originalUrl === '/webhooks') { 35 | const rawBody = buf.toString(encoding); 36 | const signature = req.header(SIGNATURE_HEADER); 37 | 38 | const secret = process.env.HUBSPOT_CLIENT_SECRET; 39 | const hash = crypto.createHash('sha256').update(secret + rawBody).digest('hex'); 40 | 41 | if (signature === hash) return; 42 | } 43 | } catch (e) { 44 | console.log(e); 45 | } 46 | 47 | throw new Error('Unauthorized webhook or error with request processing!'); 48 | }; 49 | }; 50 | -------------------------------------------------------------------------------- /webhooks-app/src/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hubspot-webhooks", 3 | "version": "1.0.0", 4 | "engines": { 5 | "node": ">= 10.13.0" 6 | }, 7 | "description": "hubspot-node client sample applications", 8 | "main": "index.js", 9 | "scripts": { 10 | "start": "./node_modules/.bin/if-env NODE_ENV=development && nodemon --delay 3 index.js || node index.js" 11 | }, 12 | "keywords": [ 13 | "hubspot", 14 | "contacts", 15 | "webhooks", 16 | "sample", 17 | "example" 18 | ], 19 | "author": "hubspot", 20 | "license": "Apache-2.0", 21 | "dependencies": { 22 | "bluebird": "^3.7.0", 23 | "body-parser": "^1.19.0", 24 | "dotenv": "^8.1.0", 25 | "express": "^4.16.3", 26 | "hubspot": "^2.3.2", 27 | "if-env": "^1.0.4", 28 | "kafka-node": "^4.1.3", 29 | "lodash": "^4.17.15", 30 | "mysql": "^2.17.1", 31 | "ngrok": "^3.2.5", 32 | "pug": "^2.0.4" 33 | }, 34 | "devDependencies": { 35 | "nodemon": "^1.19.2" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /webhooks-app/src/public/css/main.css: -------------------------------------------------------------------------------- 1 | .container { 2 | padding-bottom: 2rem; 3 | padding-top: 2rem; 4 | } 5 | 6 | .navigation { 7 | background: #f4f5f6; 8 | border-bottom: .1rem solid #d1d1d1; 9 | display: block; 10 | height: 5.2rem; 11 | left: 0; 12 | max-width: 100%; 13 | width: 100%; 14 | } 15 | 16 | .navigation .container { 17 | padding-bottom: 0; 18 | padding-top: 0 19 | } 20 | 21 | .navigation .navigation-list { 22 | list-style: none; 23 | margin-bottom: 0; 24 | } 25 | 26 | .navigation .navigation-item { 27 | float: left; 28 | margin-bottom: 0; 29 | margin-left: 2.5rem; 30 | position: relative 31 | } 32 | 33 | .navigation .navigation-title, .navigation .title { 34 | color: #606c76; 35 | position: relative 36 | } 37 | 38 | .navigation .navigation-link, .navigation .navigation-title, .navigation .title { 39 | display: inline; 40 | font-size: 1.6rem; 41 | line-height: 5.2rem; 42 | padding: 0; 43 | text-decoration: none 44 | } 45 | 46 | .navigation .navigation-link.active { 47 | color: #606c76 48 | } 49 | 50 | .authorize-button { 51 | text-align: center; 52 | } 53 | 54 | .event.deletion { 55 | color: white; 56 | background-color: red; 57 | } 58 | 59 | .event.creation { 60 | color: #3c763d; 61 | background-color: #dff0d8; 62 | } 63 | 64 | .event.propertyChange { 65 | background-color: #d8aa65; 66 | color: #ffffff; 67 | } 68 | 69 | .event.propertyChange span:first-child { 70 | background-color: #08080885; 71 | } 72 | 73 | .alert-not-shown-events { 74 | display: none; 75 | } 76 | 77 | .pagination { 78 | flex-wrap: wrap; 79 | align-items: center; 80 | justify-content: center; 81 | padding-bottom: 1rem; 82 | font-size: 1.5rem; 83 | font-weight: bold; 84 | } 85 | .pagination a { 86 | border: 0.1rem solid #9b4dca; 87 | padding: 0.5rem 1.5rem; 88 | border-radius: 0.4rem; 89 | } 90 | .pagination a.active { 91 | background-color: #9b4dca; 92 | color: #fff; 93 | } 94 | -------------------------------------------------------------------------------- /webhooks-app/src/public/favicon-32x32.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HubSpot/integration-examples-nodejs/118db05d4770745a60e5ad89031000ff09ea160e/webhooks-app/src/public/favicon-32x32.webp -------------------------------------------------------------------------------- /webhooks-app/src/public/js/main.js: -------------------------------------------------------------------------------- 1 | 2 | function requestNotShownEventsCount() { 3 | return new Promise((resolve) => { 4 | $.getJSON("/webhooks/new", (data) => { 5 | const { notShownEventsCount } = data; 6 | resolve(notShownEventsCount); 7 | }); 8 | }); 9 | } 10 | 11 | async function displayNotShownEventsAlertIfNeed() { 12 | const notShownEventsCount = await requestNotShownEventsCount(); 13 | if (notShownEventsCount > 0) { 14 | $('.alert-not-shown-events').show(); 15 | } 16 | } 17 | 18 | $(document).ready(() => { 19 | $('.alert-not-shown-events').click(() => { 20 | document.location.reload(); 21 | return false; 22 | }); 23 | 24 | setInterval(displayNotShownEventsAlertIfNeed, 10000); 25 | }); 26 | -------------------------------------------------------------------------------- /webhooks-app/src/views/contacts.pug: -------------------------------------------------------------------------------- 1 | extends includes/layout 2 | block content 3 | .container 4 | h3(class='alert-not-shown-events') New webhooks are received. 5 | =' ' 6 | a(href='#') Reload the page to see updates 7 | table 8 | thead 9 | tr 10 | th ContactID 11 | th Name 12 | th Events 13 | 14 | tbody 15 | each contact, contactId in contacts 16 | tr 17 | td #{contactId} 18 | td #{contact.name} 19 | td 20 | each hookEvent in contact.events 21 | .row 22 | span(class=`event ${hookEvent.type}`) #{hookEvent.type} 23 | = ' ' 24 | span #{hookEvent.name} 25 | = ' ' 26 | span #{hookEvent.value} 27 | = ' ' 28 | if Object.keys(contacts).length === 0 29 | span There are no HubSpot webhooks events yet 30 | .row(class='pagination') 31 | each page in paginationConfig 32 | a(href=page.link class=page.aClass) #{page.label} 33 | -------------------------------------------------------------------------------- /webhooks-app/src/views/error.pug: -------------------------------------------------------------------------------- 1 | extends includes/layout 2 | block content 3 | .container 4 | h4 Error 5 | p #{error} 6 | -------------------------------------------------------------------------------- /webhooks-app/src/views/includes/footer.pug: -------------------------------------------------------------------------------- 1 | .footer 2 | .container 3 | -------------------------------------------------------------------------------- /webhooks-app/src/views/includes/head.pug: -------------------------------------------------------------------------------- 1 | head 2 | meta(charset='UTF-8') 3 | meta(name='description' content='HubSpot JavaScript Sample Webhooks') 4 | title HubSpot JavaScript Sample Webhooks 5 | link(rel='stylesheet' href='//fonts.googleapis.com/css?family=Roboto:300,300italic,700,700italic') 6 | link(rel='stylesheet' href='//cdnjs.cloudflare.com/ajax/libs/normalize/5.0.0/normalize.css') 7 | link(rel='stylesheet' href='//cdnjs.cloudflare.com/ajax/libs/milligram/1.3.0/milligram.css') 8 | link(rel='stylesheet' href='/css/main.css') 9 | // Fav Icon 10 | link(href='/favicon-32x32.webp' rel='shortcut icon') 11 | link(href='/favicon-32x32.webp' rel='apple-touch-icon') 12 | 13 | script(type='application/javascript' src='https://cdnjs.cloudflare.com/ajax/libs/jquery/3.4.1/jquery.min.js') 14 | script(type='application/javascript' src='/js/main.js') 15 | -------------------------------------------------------------------------------- /webhooks-app/src/views/includes/header.pug: -------------------------------------------------------------------------------- 1 | header 2 | .navigation 3 | .container 4 | a(class='navigation-title' href='/') 5 | h3(class='title') HubSpot JavaScript Sample Webhooks 6 | 7 | ul(class='navigation-list float-right') 8 | li(class='navigation-item') 9 | a(class='navigation-link' href='/login') OAuth2 10 | -------------------------------------------------------------------------------- /webhooks-app/src/views/includes/layout.pug: -------------------------------------------------------------------------------- 1 | doctype html 2 | html(lang='en') 3 | include head 4 | body 5 | main(class='wrapper') 6 | include header 7 | block content 8 | include footer 9 | -------------------------------------------------------------------------------- /webhooks-app/src/views/login.pug: -------------------------------------------------------------------------------- 1 | extends includes/layout 2 | block content 3 | .container 4 | .authorize-button 5 | a(class='button' href='/auth/oauth') Authorize 6 | -------------------------------------------------------------------------------- /webhooks-app/tools/wait-for-it.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Use this script to test if a given TCP host/port are available 3 | 4 | WAITFORIT_cmdname=${0##*/} 5 | 6 | echoerr() { if [[ $WAITFORIT_QUIET -ne 1 ]]; then echo "$@" 1>&2; fi } 7 | 8 | usage() 9 | { 10 | cat << USAGE >&2 11 | Usage: 12 | $WAITFORIT_cmdname host:port [-s] [-t timeout] [-- command args] 13 | -h HOST | --host=HOST Host or IP under test 14 | -p PORT | --port=PORT TCP port under test 15 | Alternatively, you specify the host and port as host:port 16 | -s | --strict Only execute subcommand if the test succeeds 17 | -q | --quiet Don't output any status messages 18 | -t TIMEOUT | --timeout=TIMEOUT 19 | Timeout in seconds, zero for no timeout 20 | -- COMMAND ARGS Execute command with args after the test finishes 21 | USAGE 22 | exit 1 23 | } 24 | 25 | wait_for() 26 | { 27 | if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then 28 | echoerr "$WAITFORIT_cmdname: waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" 29 | else 30 | echoerr "$WAITFORIT_cmdname: waiting for $WAITFORIT_HOST:$WAITFORIT_PORT without a timeout" 31 | fi 32 | WAITFORIT_start_ts=$(date +%s) 33 | while : 34 | do 35 | if [[ $WAITFORIT_ISBUSY -eq 1 ]]; then 36 | nc -z $WAITFORIT_HOST $WAITFORIT_PORT 37 | WAITFORIT_result=$? 38 | else 39 | (echo > /dev/tcp/$WAITFORIT_HOST/$WAITFORIT_PORT) >/dev/null 2>&1 40 | WAITFORIT_result=$? 41 | fi 42 | if [[ $WAITFORIT_result -eq 0 ]]; then 43 | WAITFORIT_end_ts=$(date +%s) 44 | echoerr "$WAITFORIT_cmdname: $WAITFORIT_HOST:$WAITFORIT_PORT is available after $((WAITFORIT_end_ts - WAITFORIT_start_ts)) seconds" 45 | break 46 | fi 47 | sleep 1 48 | done 49 | return $WAITFORIT_result 50 | } 51 | 52 | wait_for_wrapper() 53 | { 54 | # In order to support SIGINT during timeout: http://unix.stackexchange.com/a/57692 55 | if [[ $WAITFORIT_QUIET -eq 1 ]]; then 56 | timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --quiet --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & 57 | else 58 | timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & 59 | fi 60 | WAITFORIT_PID=$! 61 | trap "kill -INT -$WAITFORIT_PID" INT 62 | wait $WAITFORIT_PID 63 | WAITFORIT_RESULT=$? 64 | if [[ $WAITFORIT_RESULT -ne 0 ]]; then 65 | echoerr "$WAITFORIT_cmdname: timeout occurred after waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" 66 | fi 67 | return $WAITFORIT_RESULT 68 | } 69 | 70 | # process arguments 71 | while [[ $# -gt 0 ]] 72 | do 73 | case "$1" in 74 | *:* ) 75 | WAITFORIT_hostport=(${1//:/ }) 76 | WAITFORIT_HOST=${WAITFORIT_hostport[0]} 77 | WAITFORIT_PORT=${WAITFORIT_hostport[1]} 78 | shift 1 79 | ;; 80 | --child) 81 | WAITFORIT_CHILD=1 82 | shift 1 83 | ;; 84 | -q | --quiet) 85 | WAITFORIT_QUIET=1 86 | shift 1 87 | ;; 88 | -s | --strict) 89 | WAITFORIT_STRICT=1 90 | shift 1 91 | ;; 92 | -h) 93 | WAITFORIT_HOST="$2" 94 | if [[ $WAITFORIT_HOST == "" ]]; then break; fi 95 | shift 2 96 | ;; 97 | --host=*) 98 | WAITFORIT_HOST="${1#*=}" 99 | shift 1 100 | ;; 101 | -p) 102 | WAITFORIT_PORT="$2" 103 | if [[ $WAITFORIT_PORT == "" ]]; then break; fi 104 | shift 2 105 | ;; 106 | --port=*) 107 | WAITFORIT_PORT="${1#*=}" 108 | shift 1 109 | ;; 110 | -t) 111 | WAITFORIT_TIMEOUT="$2" 112 | if [[ $WAITFORIT_TIMEOUT == "" ]]; then break; fi 113 | shift 2 114 | ;; 115 | --timeout=*) 116 | WAITFORIT_TIMEOUT="${1#*=}" 117 | shift 1 118 | ;; 119 | --) 120 | shift 121 | WAITFORIT_CLI=("$@") 122 | break 123 | ;; 124 | --help) 125 | usage 126 | ;; 127 | *) 128 | echoerr "Unknown argument: $1" 129 | usage 130 | ;; 131 | esac 132 | done 133 | 134 | if [[ "$WAITFORIT_HOST" == "" || "$WAITFORIT_PORT" == "" ]]; then 135 | echoerr "Error: you need to provide a host and port to test." 136 | usage 137 | fi 138 | 139 | WAITFORIT_TIMEOUT=${WAITFORIT_TIMEOUT:-15} 140 | WAITFORIT_STRICT=${WAITFORIT_STRICT:-0} 141 | WAITFORIT_CHILD=${WAITFORIT_CHILD:-0} 142 | WAITFORIT_QUIET=${WAITFORIT_QUIET:-0} 143 | 144 | # check to see if timeout is from busybox? 145 | WAITFORIT_TIMEOUT_PATH=$(type -p timeout) 146 | WAITFORIT_TIMEOUT_PATH=$(realpath $WAITFORIT_TIMEOUT_PATH 2>/dev/null || readlink -f $WAITFORIT_TIMEOUT_PATH) 147 | if [[ $WAITFORIT_TIMEOUT_PATH =~ "busybox" ]]; then 148 | WAITFORIT_ISBUSY=1 149 | WAITFORIT_BUSYTIMEFLAG="-t" 150 | 151 | else 152 | WAITFORIT_ISBUSY=0 153 | WAITFORIT_BUSYTIMEFLAG="" 154 | fi 155 | 156 | if [[ $WAITFORIT_CHILD -gt 0 ]]; then 157 | wait_for 158 | WAITFORIT_RESULT=$? 159 | exit $WAITFORIT_RESULT 160 | else 161 | if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then 162 | wait_for_wrapper 163 | WAITFORIT_RESULT=$? 164 | else 165 | wait_for 166 | WAITFORIT_RESULT=$? 167 | fi 168 | fi 169 | 170 | if [[ $WAITFORIT_CLI != "" ]]; then 171 | if [[ $WAITFORIT_RESULT -ne 0 && $WAITFORIT_STRICT -eq 1 ]]; then 172 | echoerr "$WAITFORIT_cmdname: strict mode, refusing to execute subprocess" 173 | exit $WAITFORIT_RESULT 174 | fi 175 | exec "${WAITFORIT_CLI[@]}" 176 | else 177 | exit $WAITFORIT_RESULT 178 | fi 179 | -------------------------------------------------------------------------------- /wrapper-file-upload-app/.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: http://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # Unix-style newlines with a newline ending every file 7 | [*] 8 | end_of_line = lf 9 | insert_final_newline = true 10 | indent_style = space 11 | indent_size = 2 12 | charset = utf-8 13 | trim_trailing_whitespace = true 14 | 15 | [*.md] 16 | trim_trailing_whitespace = false -------------------------------------------------------------------------------- /wrapper-file-upload-app/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:10 2 | 3 | WORKDIR /app/src 4 | 5 | COPY src/package.json ./ 6 | RUN npm install 7 | 8 | EXPOSE 3000 9 | -------------------------------------------------------------------------------- /wrapper-file-upload-app/README.md: -------------------------------------------------------------------------------- 1 | # HubSpot-nodejs file upload sample 2 | 3 | This is a sample app for the [node-hubspot wrapper](https://www.npmjs.com/package/hubspot). Currently, this app focuses on file upload functionality 4 | 5 | - [Using OAuth 2.0 access tokens](https://developers.hubspot.com/docs/methods/oauth2/get-access-and-refresh-tokens) 6 | - [Upload a new file API](https://developers.hubspot.com/docs/methods/files/post_files) 7 | - [Wrapper methods](https://github.com/MadKudu/node-hubspot#files) 8 | 9 | Please see the documentation on [How do I create an app in HubSpot?](https://developers.hubspot.com/docs/faq/how-do-i-create-an-app-in-hubspot) 10 | 11 | This application demonstrates the use of [uploadByUrl method of SDK](https://github.com/MadKudu/node-hubspot/blob/eeaddcd74274468d576f0365d9417d5db0d845fa/lib/file.js#L52) 12 | 13 | ### Setup App 14 | 15 | Make sure you have [Docker](https://www.docker.com/) installed. 16 | Make sure you have [Docker Compose](https://docs.docker.com/compose/) installed. 17 | 18 | ### Configure 19 | 20 | 1. Copy .env.template to .env 21 | 2. Paste your HubSpot Client Id and HubSpot Client Secret as the value for HUBSPOT_CLIENT_ID and HUBSPOT_CLIENT_SECRET in .env 22 | 23 | ### Running 24 | 25 | The best way to run this project (with the least configuration), is using docker cli. 26 | 27 | ```bash 28 | docker-compose up 29 | ``` 30 | You should now be able to navigate to [http://localhost:3000](http://localhost:3000) and use the application. 31 | -------------------------------------------------------------------------------- /wrapper-file-upload-app/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | 3 | services: 4 | web: 5 | env_file: 6 | - src/.env 7 | build: . 8 | volumes: 9 | - ./src:/app/src 10 | - /app/src/node_modules 11 | ports: 12 | - 3000:3000 13 | command: npm run start 14 | -------------------------------------------------------------------------------- /wrapper-file-upload-app/src/.env.template: -------------------------------------------------------------------------------- 1 | HUBSPOT_CLIENT_ID= 2 | HUBSPOT_CLIENT_SECRET= 3 | DEBUG=file_upload:* 4 | -------------------------------------------------------------------------------- /wrapper-file-upload-app/src/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | 'eslint:recommended', 4 | 'standard', 5 | 'plugin:import/errors', 6 | 'plugin:import/warnings', 7 | 'plugin:promise/recommended', 8 | 'plugin:node/recommended', 9 | 'plugin:prettier/recommended', 10 | ], 11 | plugins: [ 12 | 'prefer-arrow' 13 | ], 14 | env: { 15 | es6: true, 16 | node: true, 17 | }, 18 | rules: { 19 | 'prefer-arrow-callback': 2, 20 | 'prefer-template': 2, 21 | 'no-template-curly-in-string': 2, 22 | }, 23 | } 24 | -------------------------------------------------------------------------------- /wrapper-file-upload-app/src/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "quoteProps": "as-needed", 4 | "bracketSpacing": true, 5 | "arrowParens": "always", 6 | "semi": false, 7 | "printWidth": 120, 8 | "trailingComma": "es5", 9 | "endOfLine": "lf" 10 | } 11 | -------------------------------------------------------------------------------- /wrapper-file-upload-app/src/index.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config({ path: '.env' }) 2 | const debug = require('debug')('file_upload:index') 3 | 4 | const fs = require('fs') 5 | const _ = require('lodash') 6 | const path = require('path') 7 | const express = require('express') 8 | const Hubspot = require('hubspot') 9 | const formidable = require('formidable') 10 | 11 | const PORT = 3000 12 | 13 | const CLIENT_ID = process.env.HUBSPOT_CLIENT_ID 14 | const CLIENT_SECRET = process.env.HUBSPOT_CLIENT_SECRET 15 | const SCOPES = 'files' 16 | const REDIRECT_URI = `http://localhost:${PORT}/oauth-callback` 17 | const UPLOAD_RESULT_URL_PROPERTY = 'friendly_url' 18 | 19 | let fileByUrl 20 | let fileFromComputer 21 | 22 | let tokenStore = {} 23 | 24 | const checkEnv = (req, res, next) => { 25 | if (_.startsWith(req.url, '/error')) return next() 26 | 27 | if (_.isNil(CLIENT_ID)) return res.redirect('/error?msg=Please set HUBSPOT_CLIENT_ID env variable to proceed') 28 | if (_.isNil(CLIENT_SECRET)) return res.redirect('/error?msg=Please set HUBSPOT_CLIENT_SECRET env variable to proceed') 29 | 30 | next() 31 | } 32 | 33 | const isAuthorized = () => { 34 | return !_.isEmpty(tokenStore.refresh_token) 35 | } 36 | 37 | const isTokenExpired = () => { 38 | return Date.now() >= tokenStore.updated_at + tokenStore.expires_in * 1000 39 | } 40 | 41 | const fileToBuffer = (file) => { 42 | return new Promise((resolve, reject) => { 43 | fs.readFile(file.path, (err, data) => { 44 | if (err) return reject(err) 45 | 46 | resolve(data) 47 | }) 48 | }) 49 | } 50 | 51 | const refreshToken = async () => { 52 | hubspot = new Hubspot({ 53 | clientId: CLIENT_ID, 54 | clientSecret: CLIENT_SECRET, 55 | redirectUri: REDIRECT_URI, 56 | scopes: SCOPES, 57 | refreshToken: tokenStore.refresh_token, 58 | }) 59 | 60 | tokenStore = await hubspot.refreshAccessToken() 61 | tokenStore.updated_at = Date.now() 62 | debug('Updated tokens', tokenStore) 63 | } 64 | 65 | const app = express() 66 | 67 | let hubspot = new Hubspot({ 68 | clientId: CLIENT_ID, 69 | clientSecret: CLIENT_SECRET, 70 | redirectUri: REDIRECT_URI, 71 | scopes: SCOPES, 72 | }) 73 | 74 | app.use(express.static('public')) 75 | app.set('view engine', 'pug') 76 | app.set('views', path.join(__dirname, 'views')) 77 | 78 | app.use((req, res, next) => { 79 | debug(req.method, req.url) 80 | next() 81 | }) 82 | 83 | app.use(checkEnv) 84 | 85 | app.get('/', async (req, res) => { 86 | try { 87 | if (!isAuthorized()) return res.render('login') 88 | if (isTokenExpired()) await refreshToken() 89 | res.render('dashboard', { fileByUrl: fileByUrl || '', fileFromComputer: fileFromComputer }) 90 | } catch (e) { 91 | console.error(e) 92 | res.redirect(`/error?msg=${e.message}`) 93 | } 94 | }) 95 | 96 | app.use('/oauth', async (req, res) => { 97 | const authorizationUrlParams = { 98 | client_id: CLIENT_ID, 99 | redirect_uri: REDIRECT_URI, 100 | scopes: SCOPES, 101 | } 102 | 103 | // Use the client to get authorization Url 104 | // https://www.npmjs.com/package/hubspot 105 | debug('Creating authorization Url') 106 | const authorizationUrl = hubspot.oauth.getAuthorizationUrl(authorizationUrlParams) 107 | debug('Authorization Url', authorizationUrl) 108 | 109 | res.redirect(authorizationUrl) 110 | }) 111 | 112 | app.use('/oauth-callback', async (req, res) => { 113 | const code = _.get(req, 'query.code') 114 | 115 | // Get OAuth 2.0 Access Token and Refresh Tokens 116 | // POST /oauth/v1/token 117 | // https://developers.hubspot.com/docs/methods/oauth2/get-access-and-refresh-tokens 118 | debug('Retrieving access token by code:', code) 119 | tokenStore = await hubspot.oauth.getAccessToken({ code }) 120 | debug('Retrieving access token result:', tokenStore) 121 | tokenStore.updated_at = Date.now() 122 | 123 | // Set token for the 124 | // https://www.npmjs.com/package/hubspot 125 | hubspot.setAccessToken(tokenStore.access_token) 126 | res.redirect('/') 127 | }) 128 | 129 | app.get('/login', (req, res) => { 130 | tokenStore = {} 131 | fileByUrl = null 132 | fileFromComputer = null 133 | 134 | res.redirect('/') 135 | }) 136 | 137 | app.get('/reset', (req, res) => { 138 | fileByUrl = null 139 | fileFromComputer = null 140 | 141 | res.redirect('/') 142 | }) 143 | 144 | app.get('/upload', async (req, res) => { 145 | const url = _.get(req, 'query.url') 146 | 147 | if (url) { 148 | debug('uploading file by URL ', url) 149 | 150 | // Upload a new file by URL 151 | // POST /filemanager/api/v2/files 152 | // https://developers.hubspot.com/docs/methods/files/post_files 153 | const uploadingResult = await hubspot.files.uploadByUrl({ url, name: `${Date.now()}` }) 154 | fileByUrl = _.get(uploadingResult, `objects[0].${UPLOAD_RESULT_URL_PROPERTY}`) 155 | } 156 | res.redirect('/') 157 | }) 158 | 159 | app.post('/upload', async (req, res) => { 160 | try { 161 | new formidable.IncomingForm().parse(req, async (err, fields, files) => { 162 | if (err) throw err 163 | 164 | const name = _.get(files, 'content.name') 165 | debug('uploading file from computer', name) 166 | const content = await fileToBuffer(files.content) 167 | 168 | // Upload a new file from computer 169 | // POST /filemanager/api/v2/files 170 | // https://developers.hubspot.com/docs/methods/files/post_files 171 | const uploadingResult = await hubspot.files.upload({ content, name }) 172 | fileFromComputer = _.get(uploadingResult, `objects[0].${UPLOAD_RESULT_URL_PROPERTY}`) 173 | 174 | res.redirect('/') 175 | }) 176 | } catch (e) { 177 | debug(e) 178 | } 179 | }) 180 | 181 | app.get('/error', (req, res) => { 182 | res.render('error', { error: req.query.msg }) 183 | }) 184 | 185 | app.use((error, req, res, next) => { 186 | res.render('error', { error: error.message }) 187 | }) 188 | 189 | app.listen(PORT, () => debug(`Listening on http://localhost:${PORT}`)) 190 | -------------------------------------------------------------------------------- /wrapper-file-upload-app/src/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hubspot-file-upload", 3 | "version": "1.0.0", 4 | "engines": { 5 | "node": ">= 10.13.0" 6 | }, 7 | "description": "hubspot-node client sample applications", 8 | "main": "index.js", 9 | "scripts": { 10 | "start": "./node_modules/.bin/if-env NODE_ENV=development && nodemon --delay 3 index.js || node index.js", 11 | "lint": "npm run prettier && npm run eslint", 12 | "eslint": "./node_modules/.bin/eslint .. --fix", 13 | "prettier": "./node_modules/.bin/prettier --write index.js ./js/**/*.js" 14 | }, 15 | "keywords": [ 16 | "hubspot", 17 | "oauth", 18 | "files", 19 | "sample", 20 | "example" 21 | ], 22 | "author": "hubspot", 23 | "license": "Apache-2.0", 24 | "dependencies": { 25 | "body-parser": "^1.19.0", 26 | "debug": "^4.1.1", 27 | "dotenv": "^8.1.0", 28 | "express": "^4.16.3", 29 | "formidable": "^1.2.1", 30 | "hubspot": "^2.3.5", 31 | "if-env": "^1.0.4", 32 | "lodash": "^4.17.15", 33 | "pug": "^2.0.4" 34 | }, 35 | "devDependencies": { 36 | "eslint": "^6.5.1", 37 | "eslint-config-prettier": "^6.4.0", 38 | "eslint-config-standard": "14.1.0", 39 | "eslint-plugin-import": "^2.18.2", 40 | "eslint-plugin-node": "^10.0.0", 41 | "eslint-plugin-prefer-arrow": "^1.1.6", 42 | "eslint-plugin-prettier": "^3.1.1", 43 | "eslint-plugin-promise": "^4.2.1", 44 | "eslint-plugin-standard": "4.0.1", 45 | "nodemon": "^2.0.1", 46 | "prettier": "^1.18.2" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /wrapper-file-upload-app/src/public/css/main.css: -------------------------------------------------------------------------------- 1 | .wrapper .container { 2 | padding-bottom: 2rem; 3 | padding-top: 2rem; 4 | } 5 | 6 | .navigation { 7 | background: #f4f5f6; 8 | border-bottom: .1rem solid #d1d1d1; 9 | display: block; 10 | height: 5.2rem; 11 | left: 0; 12 | max-width: 100%; 13 | width: 100%; 14 | } 15 | 16 | .navigation .container { 17 | padding-bottom: 0; 18 | padding-top: 0 19 | } 20 | 21 | .navigation .navigation-list { 22 | list-style: none; 23 | margin-bottom: 0; 24 | } 25 | 26 | .navigation .navigation-item { 27 | float: left; 28 | margin-bottom: 0; 29 | margin-left: 2.5rem; 30 | position: relative 31 | } 32 | 33 | .navigation .navigation-title, .navigation .title { 34 | color: #606c76; 35 | position: relative 36 | } 37 | 38 | .navigation .navigation-link, .navigation .navigation-title, .navigation .title { 39 | display: inline; 40 | font-size: 1.6rem; 41 | line-height: 5.2rem; 42 | padding: 0; 43 | text-decoration: none 44 | } 45 | 46 | .navigation .navigation-link.active { 47 | color: #606c76 48 | } 49 | 50 | .authorize-button { 51 | text-align: center; 52 | } 53 | 54 | form { 55 | display: flex; 56 | flex-direction: row; 57 | justify-content: space-between; 58 | } 59 | -------------------------------------------------------------------------------- /wrapper-file-upload-app/src/public/favicon-32x32.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HubSpot/integration-examples-nodejs/118db05d4770745a60e5ad89031000ff09ea160e/wrapper-file-upload-app/src/public/favicon-32x32.webp -------------------------------------------------------------------------------- /wrapper-file-upload-app/src/views/dashboard.pug: -------------------------------------------------------------------------------- 1 | extends includes/layout 2 | block content 3 | .container 4 | h3 Upload by URL 5 | .row 6 | .column 7 | form(action='/upload') 8 | input(type='text' name='url' placeholder='URL..' id='url') 9 | span 10 | input(class='button-primary' type='submit' value='Upload') 11 | .column 12 | span Result: 13 | -if (fileByUrl) 14 | a(href=fileByUrl target='blank') #{fileByUrl} 15 | 16 | h3 Upload from the computer 17 | .row 18 | .column 19 | form(method='post' action='/upload' enctype="multipart/form-data") 20 | input(type='file' name='content' id='file') 21 | span 22 | input(class='button-primary float-right' type='submit' value='Upload') 23 | .column 24 | span Result: 25 | -if (fileFromComputer) 26 | a(href=fileFromComputer target='blank') #{fileFromComputer} 27 | -------------------------------------------------------------------------------- /wrapper-file-upload-app/src/views/error.pug: -------------------------------------------------------------------------------- 1 | extends includes/layout 2 | block content 3 | .container 4 | h4 Error 5 | p #{error} 6 | -------------------------------------------------------------------------------- /wrapper-file-upload-app/src/views/includes/footer.pug: -------------------------------------------------------------------------------- 1 | .footer 2 | .container 3 | -------------------------------------------------------------------------------- /wrapper-file-upload-app/src/views/includes/head.pug: -------------------------------------------------------------------------------- 1 | head 2 | meta(charset='UTF-8') 3 | meta(name='description' content='HubSpot JavaScript Sample Webhooks') 4 | title HubSpot JavaScript Sample Webhooks 5 | link(rel='stylesheet' href='//fonts.googleapis.com/css?family=Roboto:300,300italic,700,700italic') 6 | link(rel='stylesheet' href='//cdnjs.cloudflare.com/ajax/libs/normalize/5.0.0/normalize.css') 7 | link(rel='stylesheet' href='//cdnjs.cloudflare.com/ajax/libs/milligram/1.3.0/milligram.css') 8 | link(rel='stylesheet' href='/css/main.css') 9 | // Fav Icon 10 | link(href='/favicon-32x32.webp' rel='shortcut icon') 11 | link(href='/favicon-32x32.webp' rel='apple-touch-icon') 12 | -------------------------------------------------------------------------------- /wrapper-file-upload-app/src/views/includes/header.pug: -------------------------------------------------------------------------------- 1 | header 2 | .navigation 3 | .container 4 | a(class='navigation-title' href='/') 5 | h3(class='title') HubSpot JavaScript Sample File Upload 6 | 7 | ul(class='navigation-list float-right') 8 | li(class='navigation-item') 9 | a(class='navigation-link' href='/reset') Reset Links 10 | li(class='navigation-item') 11 | a(class='navigation-link' href='/login') Logout (OAuth2) 12 | -------------------------------------------------------------------------------- /wrapper-file-upload-app/src/views/includes/layout.pug: -------------------------------------------------------------------------------- 1 | doctype html 2 | html(lang='en') 3 | include head 4 | body 5 | main(class='wrapper') 6 | include header 7 | block content 8 | include footer 9 | -------------------------------------------------------------------------------- /wrapper-file-upload-app/src/views/login.pug: -------------------------------------------------------------------------------- 1 | extends includes/layout 2 | block content 3 | .container 4 | .authorize-button 5 | a(class='button' href='/oauth') Authorize 6 | --------------------------------------------------------------------------------