├── .env.example ├── .eslintignore ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── config.yml ├── pull_request_template.md └── workflows │ ├── main.yml │ ├── trigger-submodule-update.yml │ └── update-submodule-template.yml ├── .gitignore ├── .prettierrc ├── LICENSE ├── README.md ├── dist ├── index.js ├── prisma.js ├── prisma │ ├── contacts.js │ └── seed.js └── src │ ├── app.js │ ├── auth.js │ └── utils.js ├── eslintrc ├── jest.config.js ├── package-lock.json ├── package.json ├── prisma ├── disconnect.ts ├── migrations │ ├── 20240123171836_adding_prisma_schema_to_db │ │ └── migration.sql │ ├── 20240131140150_31012024 │ │ └── migration.sql │ ├── 20240131141604_3101242 │ │ └── migration.sql │ ├── 20240131142530_3101243 │ │ └── migration.sql │ ├── 20240209185407_pyenv_shell_3_10_3 │ │ └── migration.sql │ ├── 20240209185524_optional_fields │ │ └── migration.sql │ └── migration_lock.toml ├── schema.prisma └── seed.ts ├── src ├── app.ts ├── auth.ts ├── clients.ts ├── initialSyncFromHubSpot.ts ├── initialSyncToHubSpot.ts ├── swagger.ts ├── swagger │ └── definitions.ts ├── types │ └── common.d.ts └── utils │ ├── error.ts │ ├── logger.ts │ ├── shutdown.ts │ └── utils.ts ├── tests └── app.test.ts └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | DATABASE_URL="postgresql://{username}:{password}@localhost:5432/{database name}" 2 | CLIENT_ID=my-client-id 3 | CLIENT_SECRET=my-client-secret 4 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Report any issues to help us improve CODE! 4 | title: "[BUG]" 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | 12 | 13 | **Description** 14 | A clear and concise description of what the bug is. 15 | 16 | **Steps To Reproduce** 17 | Steps to reproduce the behavior. 18 | 19 | **Expected behavior** 20 | A clear and concise description of what you expected to happen. 21 | 22 | **Screenshots** 23 | If applicable, add screenshots to help explain your problem. 24 | 25 | **Environment (please complete the following information):** 26 | - OS: [e.g. macOS, Windows, Linux] 27 | - Node version: [e.g. v14.17.0] 28 | - PostgreSQL version: [e.g. 13.3] 29 | - Browser [e.g. chrome, safari] 30 | 31 | **Additional context** 32 | Add any other context about the problem here. 33 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | # Description 2 | 3 | Please include a summary of the changes and the related issue. Please also include relevant motivation and context. List any dependencies that are required for this change. 4 | 5 | Fixes # (Tag or link relevant issues) 6 | 7 | ## Type of Change 8 | 9 | Please delete options that are not relevant. 10 | 11 | - [ ] Bug fix (non-breaking change which fixes an issue) 12 | - [ ] New feature (non-breaking change which adds functionality) 13 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) 14 | - [ ] This change requires a documentation update 15 | 16 | # How Has This Been Tested? 17 | 18 | Please include test coverage results that cover the changes referenced in the PR description. 19 | 20 | # Checklist: 21 | 22 | - [ ] I have performed a self-review of my code 23 | - [ ] I have commented my code, remember, these are resources to help developers identify patterns for HubSpot integrations 24 | - [ ] I have made corresponding changes to the documentation 25 | - [ ] My changes generate no new warnings 26 | - [ ] I have added tests that prove my fix is effective or that my feature works 27 | - [ ] New and existing unit tests pass locally with my changes 28 | - [ ] Any dependent changes have been merged and published in downstream modules 29 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Trigger Meta Repo Update 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - github-action-test # Instead of main 7 | types: 8 | - closed 9 | 10 | jobs: 11 | trigger-meta-repo: 12 | if: github.event.pull_request.merged == true 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Send repository update event 17 | run: | 18 | curl -X POST -H "Authorization: token ${{ secrets.GH_PAT }}" \ 19 | -H "Accept: application/vnd.github.v3+json" \ 20 | https://api.github.com/repos/hubspotdev/CODE-Hub/actions/workflows/update-meta-repo.yml/dispatches \ 21 | -d '{ 22 | "ref": "main", 23 | "inputs": { 24 | "repo": "'"${{ github.repository }}"'", 25 | "commit": "'"${{ github.sha }}"'", 26 | "pr_title": "'"${{ github.event.pull_request.title }}"'", 27 | "pr_url": "'"${{ github.event.pull_request.html_url }}"'" 28 | } 29 | }' 30 | -------------------------------------------------------------------------------- /.github/workflows/trigger-submodule-update.yml: -------------------------------------------------------------------------------- 1 | name: Trigger Meta Repo Update 2 | 3 | on: 4 | pull_request: 5 | types: 6 | - closed 7 | 8 | jobs: 9 | trigger-meta-repo: 10 | if: github.event.pull_request.merged == true 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Send repository update event 15 | run: | 16 | curl -X POST -H "Authorization: token ${{ secrets.GH_PAT }}" \ 17 | -H "Accept: application/vnd.github.v3+json" \ 18 | https://api.github.com/repos/hubspotdev/CODE-Hub/dispatches \ 19 | -d '{ 20 | "event_type": "update-meta-repo", 21 | "client_payload": { 22 | "repo": "'"${{ github.repository }}"'", 23 | "commit": "'"${{ github.sha }}"'", 24 | "pr_title": "'"${{ github.event.pull_request.title }}"'", 25 | "pr_url": "'"${{ github.event.pull_request.html_url }}"'" 26 | } 27 | }' 28 | -------------------------------------------------------------------------------- /.github/workflows/update-submodule-template.yml: -------------------------------------------------------------------------------- 1 | name: Update Template Files 2 | 3 | on: 4 | repository_dispatch: 5 | types: [update-templates] 6 | 7 | jobs: 8 | update-templates: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - name: Checkout repository 13 | uses: actions/checkout@v4 14 | with: 15 | token: ${{ secrets.GITHUB_TOKEN }} 16 | 17 | - name: Setup GitHub directories 18 | run: | 19 | mkdir -p .github/ISSUE_TEMPLATE 20 | mkdir -p .github/pull_request_template 21 | 22 | - name: Download PR Template 23 | if: github.event.client_payload.pr_template_updated == 'true' 24 | uses: actions/github-script@v6 25 | with: 26 | github-token: ${{ secrets.GH_PAT }} 27 | script: | 28 | try { 29 | // Log that we're starting the request 30 | console.log('Attempting to fetch PR-Template.md from CODE-Hub repository...'); 31 | 32 | // Make a direct request using the octokit request method 33 | const response = await github.request('GET /repos/{owner}/{repo}/contents/{path}', { 34 | owner: 'hubspotdev', 35 | repo: 'CODE-Hub', 36 | path: 'PR-Template.md', 37 | ref: 'main', 38 | headers: { 39 | 'Accept': 'application/vnd.github.v3.raw' 40 | } 41 | }); 42 | 43 | console.log('Successfully retrieved the file!'); 44 | 45 | // Write the content 46 | const fs = require('fs'); 47 | fs.writeFileSync('.github/pull_request_template.md', response.data); 48 | 49 | console.log('Successfully wrote template files'); 50 | } catch (error) { 51 | console.error('Error details:'); 52 | console.error(`Status: ${error.status}`); 53 | console.error(`Message: ${error.message}`); 54 | console.error(`Request URL: ${error.request?.url || 'N/A'}`); 55 | if (error.response?.data) { 56 | console.error('Response data:', JSON.stringify(error.response.data)); 57 | } 58 | throw error; 59 | } 60 | 61 | - name: Download Bug Template 62 | if: github.event.client_payload.bug_template_updated == 'true' 63 | uses: actions/github-script@v6 64 | with: 65 | github-token: ${{ secrets.GH_PAT }} # Use the same PAT here 66 | script: | 67 | const response = await github.rest.repos.getContent({ 68 | owner: 'hubspotdev', 69 | repo: 'CODE-Hub', 70 | path: 'PR-Template.md', 71 | ref: 'main' 72 | }); 73 | 74 | const content = Buffer.from(response.data.content, 'base64').toString(); 75 | const fs = require('fs'); 76 | 77 | fs.writeFileSync('.github/ISSUE_TEMPLATE/bug_report.md', content); 78 | 79 | - name: Check token permissions 80 | uses: actions/github-script@v6 81 | with: 82 | github-token: ${{ secrets.GH_PAT }} 83 | script: | 84 | try { 85 | console.log('Checking token permissions...'); 86 | const { data } = await github.rest.users.getAuthenticated(); 87 | console.log(`Authenticated as: ${data.login}`); 88 | 89 | // Try to list the repos to see if we have access to the CODE-Hub repo 90 | const repos = await github.rest.repos.get({ 91 | owner: 'hubspotdev', 92 | repo: 'CODE-Hub' 93 | }); 94 | console.log('Successfully accessed CODE-Hub repository information'); 95 | console.log(`Repository visibility: ${repos.data.visibility}`); 96 | console.log(`Default branch: ${repos.data.default_branch}`); 97 | } catch (error) { 98 | console.error('Error checking permissions:'); 99 | console.error(`Status: ${error.status}`); 100 | console.error(`Message: ${error.message}`); 101 | throw error; 102 | } 103 | 104 | - name: Create Pull Request 105 | if: success() 106 | uses: peter-evans/create-pull-request@v5 107 | with: 108 | token: ${{ secrets.GH_PAT }} 109 | commit-message: Update GitHub templates from CODE-Hub 110 | title: 'chore: Update GitHub templates from CODE-Hub' 111 | body: | 112 | This PR updates the GitHub templates from CODE-Hub repository. 113 | 114 | - Updates triggered by template changes 115 | - Automated PR created by GitHub Actions 116 | branch: update-github-templates 117 | base: main 118 | delete-branch: true 119 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | # Keep environment variables out of version control 3 | .env 4 | 5 | # Test coverage reports 6 | coverage/ 7 | coverage/* 8 | *.lcov 9 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "trailingComma": "none", 4 | "singleQuote": true, 5 | "printWidth": 80 6 | } 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 HubSpot Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CRM Object Sync 2 | 3 | A demonstration of best integration practices for syncing CRM contact records between HubSpot and external applications for product management use cases. 4 | 5 | ## Table of Contents 6 | 7 | - [What this project does](#what-this-project-does) 8 | - [Why is this project useful?](#why-is-this-project-useful) 9 | - [Setup](#setup) 10 | - [Scopes](#scopes) 11 | - [Endpoints](#endpoints) 12 | - [Authentication](#authentication) 13 | - [Contact Management](#contact-management) 14 | - [Available Scripts](#available-scripts) 15 | - [Project Structure](#project-structure) 16 | - [Dependencies](#dependencies) 17 | - [Core](#core) 18 | - [Development](#development) 19 | - [Where to get help?](#where-to-get-help) 20 | - [Who maintains and contributes to this project](#who-maintains-and-contributes-to-this-project) 21 | - [License](#license) 22 | 23 | ## What this project does: 24 | 25 | This CRM Object Sync repository demonstrates best integration practices for syncing CRM contact records between HubSpot and external applications for a product management use case. 26 | 27 | ## Why is this project useful: 28 | 29 | This project demonstrates how to: 30 | 31 | - Set up HubSpot authentication and generate OAuth access and refresh tokens 32 | 33 | - Create and seed a PostgreSQL database with contact records 34 | 35 | - Sync seeded contact records from the database to HubSpot, saving the generated hs_object_id back to the database 36 | 37 | - Sync contact records from HubSpot to the database: 38 | 39 | - The default sync option uses the Prisma upsert, matching by email. If there is a record match, it just adds the hs_object_id to the existing record. If the contact has no email, it creates a new record in the database. The job results will indicate how many records are upsert and the number of new records without email that were created. 40 | 41 | - The second option has more verbose reporting. It tries to create a new record in the database. If there's already a record with a matching email, it adds the hs_object_id to the existing record. Contacts without email are just created as normal. The results will indicate the number of records created (with or without email) and the number of existing records that the hs_object_id was added to. 42 | 43 | ## Setup 44 | 45 | 1. **Prerequisites** 46 | 47 | - Go to [HubSpot Developer Portal](https://developers.hubspot.com/) 48 | - Create a new public app 49 | - Configure the following scopes: 50 | - `crm.objects.contacts.read` 51 | - `crm.objects.contacts.write` 52 | - `crm.objects.companies.read` 53 | - `crm.objects.companies.write` 54 | - `crm.schemas.contacts.read` 55 | - `crm.schemas.contacts.write` 56 | - `crm.schemas.companies.read` 57 | - `crm.schemas.companies.write` 58 | - Add `http://localhost:3001/oauth-callback` as a redirect URL 59 | - Save your Client ID and Client Secret for the next steps 60 | - Install [PostgreSQL](https://www.postgresql.org/download/) 61 | - Create an empty database 62 | - Have HubSpot app credentials ready 63 | 64 | 2. **Install Dependencies** 65 | 66 | - Download and install PostgreSQL, make sure it's running, and create an empty database. You need the username and password (defaults username is postgres and no password) 67 | - Clone the repo 68 | - Create the .env file with these entries: 69 | - DATABASE_URL the (local) url to the postgres database (e.g. postgresql://{username}:{password}@localhost:5432/{database name}) 70 | - CLIENT_ID from Hubspot public app 71 | - CLIENT_SECRET from Hubspot public app 72 | - Run `npm install` to install the required Node packages. 73 | - In your HubSpot public app, add `localhost:3001/api/install/oauth-callback` as a redirect URL 74 | Run npm run dev to start the server 75 | Visit http://localhost:3001/api/install in a browser to get the OAuth install link 76 | -Run `npm run seed` to seed the database with test data, select an industry for the data examples 77 | -Once the server is running, you can access the application and API documentation at http://localhost:3001/api-docs. 78 | 79 | ## Endpoints 80 | 81 | ### Authentication 82 | 83 | - `GET /api/install` - Returns installation page with HubSpot OAuth link 84 | - `GET /oauth-callback` - Processes OAuth authorization code 85 | - `GET /` - Retrieves access token for authenticated user 86 | 87 | ### Contact Management 88 | 89 | - `GET /contacts` - Fetches contacts from local database 90 | - `GET /initial-contacts-sync` - Syncs contacts from HubSpot to local database 91 | - `GET /sync-contacts` - Syncs contacts from local database to HubSpot 92 | - Uses email as primary key for deduplication 93 | - Excludes existing HubSpot contacts from sync batch 94 | 95 | ### Documentation 96 | 97 | - `GET /api-docs` - Returns API documentation 98 | 99 | ## Scopes 100 | 101 | - `crm.schemas.companies.write` 102 | - `crm.schemas.contacts.write` 103 | - `crm.schemas.companies.read` 104 | - `crm.schemas.contacts.read` 105 | - `crm.objects.companies.write` 106 | - `crm.objects.contacts.write` 107 | - `crm.objects.companies.read` 108 | - `crm.objects.contacts.read` 109 | 110 | ## Available Scripts 111 | 112 | - `npm run dev` - Start development server 113 | - `npm run db-init` - Initialize database tables 114 | - `npm run db-seed` - Seed database with test data 115 | - `npm test` - Run tests 116 | - `npm run test:coverage` - Generate test coverage report 117 | 118 | ## Dependencies 119 | 120 | ### Core 121 | 122 | - Express 123 | - Prisma 124 | - PostgreSQL 125 | - HubSpot Client Libraries 126 | 127 | ### Development 128 | 129 | - Jest 130 | - TypeScript 131 | - ESLint 132 | - Prettier 133 | 134 | ## Where to get help? 135 | 136 | If you encounter any bugs or issues, please report them by opening a GitHub issue. For feedback or suggestions for new code examples, we encourage you to use this [form](https://survey.hsforms.com/1RT0f09LSTHuflzNtMbr2jA96it). 137 | 138 | ## Who maintains and contributes to this project 139 | 140 | Various teams at HubSpot that focus on developer experience and app marketplace quality maintain and contribute to this project. In particular, this project was made possible by @therealdadams, @rahmona-henry, @zman81988, @natalijabujevic0708, and @zradford 141 | 142 | ## License 143 | 144 | MIT 145 | -------------------------------------------------------------------------------- /dist/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | -------------------------------------------------------------------------------- /dist/prisma.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | const client_1 = require("@prisma/client"); 4 | const prisma = new client_1.PrismaClient(); 5 | exports.default = prisma; 6 | -------------------------------------------------------------------------------- /dist/prisma/contacts.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | exports.contacts = void 0; 4 | exports.contacts = [ 5 | { 6 | email: "1@hubspot.com", 7 | first_name: "One", 8 | last_name: "Contact" 9 | }, 10 | { 11 | email: "2@google.com", 12 | first_name: "Two", 13 | last_name: "Contact" 14 | } 15 | ]; 16 | -------------------------------------------------------------------------------- /dist/prisma/seed.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | exports.main = void 0; 4 | const faker_1 = require("@faker-js/faker"); 5 | const client_1 = require("@prisma/client"); 6 | const prisma = new client_1.PrismaClient(); 7 | /*Create dataset, mapping over an array*/ 8 | const data = Array.from({ length: 1000 }).map(() => ({ 9 | first_name: faker_1.faker.person.firstName(), 10 | last_name: faker_1.faker.person.lastName(), 11 | email: faker_1.faker.internet.email().toLowerCase() //normalize before adding to db 12 | })); 13 | /*Run seed command and the function below inserts data in the database*/ 14 | async function main() { 15 | console.log(`=== Generated ${data.length} contacts ===`); 16 | await prisma.contacts.createMany({ 17 | data, 18 | skipDuplicates: true // fakerjs will repeat emails 19 | }); 20 | } 21 | exports.main = main; 22 | // Only run if this file is being executed directly 23 | if (require.main === module) { 24 | main() 25 | .catch((e) => { 26 | console.error(e); 27 | process.exit(1); 28 | }) 29 | .finally(async () => { 30 | await prisma.$disconnect(); 31 | }); 32 | } 33 | exports.default = prisma; 34 | -------------------------------------------------------------------------------- /dist/src/app.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | var __importDefault = (this && this.__importDefault) || function (mod) { 3 | return (mod && mod.__esModule) ? mod : { "default": mod }; 4 | }; 5 | Object.defineProperty(exports, "__esModule", { value: true }); 6 | exports.startServer = exports.server = exports.app = void 0; 7 | const express_1 = __importDefault(require("express")); 8 | const auth_1 = require("./auth"); 9 | require("dotenv/config"); 10 | const utils_1 = require("./utils/utils"); 11 | const initialSyncFromHubSpot_1 = require("./initialSyncFromHubSpot"); 12 | const initialSyncToHubSpot_1 = require("./initialSyncToHubSpot"); 13 | const clients_1 = require("./clients"); 14 | const error_1 = __importDefault(require("./utils/error")); 15 | const logger_1 = require("./utils/logger"); 16 | const swagger_ui_express_1 = __importDefault(require("swagger-ui-express")); 17 | const swagger_1 = require("./swagger"); 18 | const app = (0, express_1.default)(); 19 | exports.app = app; 20 | app.use(express_1.default.json()); 21 | app.use(express_1.default.urlencoded({ extended: true })); 22 | app.get('/contacts', async (req, res) => { 23 | try { 24 | const contacts = await clients_1.prisma.contacts.findMany({}); 25 | res.send(contacts); 26 | } 27 | catch (error) { 28 | (0, error_1.default)(error, 'Error fetching contacts'); 29 | res 30 | .status(500) 31 | .json({ message: 'An error occurred while fetching contacts.' }); 32 | } 33 | }); 34 | app.get('/api/install', (req, res) => { 35 | res.send(`${auth_1.authUrl}`); 36 | }); 37 | app.get('/sync-contacts', async (req, res) => { 38 | try { 39 | const syncResults = await (0, initialSyncToHubSpot_1.syncContactsToHubSpot)(); 40 | res.send(syncResults); 41 | } 42 | catch (error) { 43 | (0, error_1.default)(error, 'Error syncing contacts'); 44 | res 45 | .status(500) 46 | .json({ message: 'An error occurred while syncing contacts.' }); 47 | } 48 | }); 49 | app.get('/', async (req, res) => { 50 | try { 51 | const accessToken = await (0, auth_1.getAccessToken)((0, utils_1.getCustomerId)()); 52 | res.send(accessToken); 53 | } 54 | catch (error) { 55 | (0, error_1.default)(error, 'Error fetching access token'); 56 | res 57 | .status(500) 58 | .json({ message: 'An error occurred while fetching the access token.' }); 59 | } 60 | }); 61 | app.get('/oauth-callback', async (req, res) => { 62 | const code = req.query.code; 63 | if (code) { 64 | try { 65 | const authInfo = await (0, auth_1.redeemCode)(code.toString()); 66 | const accessToken = authInfo.accessToken; 67 | logger_1.logger.info({ 68 | type: 'HubSpot', 69 | logMessage: { 70 | message: 'OAuth complete!' 71 | } 72 | }); 73 | res.redirect(`http://localhost:${utils_1.PORT}/`); 74 | } 75 | catch (error) { 76 | (0, error_1.default)(error, 'Error redeeming code during OAuth'); 77 | res.redirect(`/?errMessage=${error.message || 'An error occurred during the OAuth process.'}`); 78 | } 79 | } 80 | else { 81 | logger_1.logger.error({ 82 | type: 'HubSpot', 83 | logMessage: { 84 | message: 'Error: code parameter is missing.' 85 | } 86 | }); 87 | res 88 | .status(400) 89 | .json({ message: 'Code parameter is missing in the query string.' }); 90 | } 91 | }); 92 | app.get('/initial-contacts-sync', async (req, res) => { 93 | try { 94 | const syncResults = await (0, initialSyncFromHubSpot_1.initialContactsSync)(); 95 | res.send(syncResults); 96 | } 97 | catch (error) { 98 | (0, error_1.default)(error, 'Error during initial contacts sync'); 99 | res 100 | .status(500) 101 | .json({ message: 'An error occurred during the initial contacts sync.' }); 102 | } 103 | }); 104 | app.use('/api-docs', swagger_ui_express_1.default.serve, swagger_ui_express_1.default.setup(swagger_1.specs)); 105 | let server = null; 106 | exports.server = server; 107 | function startServer() { 108 | if (!server) { 109 | exports.server = server = app.listen(utils_1.PORT, function (err) { 110 | if (err) { 111 | console.error('Error starting server:', err); 112 | return; 113 | } 114 | console.log(`App is listening on port ${utils_1.PORT}`); 115 | }); 116 | } 117 | return server; 118 | } 119 | exports.startServer = startServer; 120 | // Start the server only if this file is run directly 121 | if (require.main === module) { 122 | startServer(); 123 | } 124 | -------------------------------------------------------------------------------- /dist/src/auth.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | var __importDefault = (this && this.__importDefault) || function (mod) { 3 | return (mod && mod.__esModule) ? mod : { "default": mod }; 4 | }; 5 | Object.defineProperty(exports, "__esModule", { value: true }); 6 | exports.authenticateHubspotClient = exports.getAccessToken = exports.redeemCode = exports.exchangeForTokens = exports.authUrl = void 0; 7 | require("dotenv/config"); 8 | const client_1 = require("@prisma/client"); 9 | const utils_1 = require("./utils/utils"); 10 | const clients_1 = require("./clients"); 11 | const error_1 = __importDefault(require("./utils/error")); 12 | class MissingRequiredError extends Error { 13 | constructor(message, options) { 14 | message = message + 'is missing, please add it to your .env file'; 15 | super(message, options); 16 | } 17 | } 18 | const CLIENT_ID = process.env.CLIENT_ID; 19 | const CLIENT_SECRET = process.env.CLIENT_SECRET; 20 | if (!CLIENT_ID) { 21 | throw new MissingRequiredError('CLIENT_ID ', undefined); 22 | } 23 | if (!CLIENT_SECRET) { 24 | throw new MissingRequiredError('CLIENT_SECRET ', undefined); 25 | } 26 | const REDIRECT_URI = `http://localhost:${utils_1.PORT}/oauth-callback`; 27 | const SCOPES = [ 28 | 'crm.schemas.companies.write', 29 | 'crm.schemas.contacts.write', 30 | 'crm.schemas.companies.read', 31 | 'crm.schemas.contacts.read', 32 | 'crm.objects.companies.write', 33 | 'crm.objects.contacts.write', 34 | 'crm.objects.companies.read', 35 | 'crm.objects.contacts.read' 36 | ]; 37 | const EXCHANGE_CONSTANTS = { 38 | redirect_uri: REDIRECT_URI, 39 | client_id: CLIENT_ID, 40 | client_secret: CLIENT_SECRET 41 | }; 42 | const prisma = new client_1.PrismaClient(); 43 | const scopeString = SCOPES.toString().replaceAll(',', ' '); 44 | const authUrl = clients_1.hubspotClient.oauth.getAuthorizationUrl(CLIENT_ID, REDIRECT_URI, scopeString); 45 | exports.authUrl = authUrl; 46 | const getExpiresAt = (expiresIn) => { 47 | const now = new Date(); 48 | return new Date(now.getTime() + expiresIn * 1000); 49 | }; 50 | const redeemCode = async (code) => { 51 | return await exchangeForTokens({ 52 | ...EXCHANGE_CONSTANTS, 53 | code, 54 | grant_type: 'authorization_code' 55 | }); 56 | }; 57 | exports.redeemCode = redeemCode; 58 | const getHubSpotId = async (accessToken) => { 59 | clients_1.hubspotClient.setAccessToken(accessToken); 60 | const hubspotAccountInfoResponse = await clients_1.hubspotClient.apiRequest({ 61 | path: '/account-info/v3/details', 62 | method: 'GET' 63 | }); 64 | const hubspotAccountInfo = await hubspotAccountInfoResponse.json(); 65 | const hubSpotportalId = hubspotAccountInfo.portalId; 66 | return hubSpotportalId.toString(); 67 | }; 68 | const exchangeForTokens = async (exchangeProof) => { 69 | const { code, redirect_uri, client_id, client_secret, grant_type, refresh_token } = exchangeProof; 70 | const tokenResponse = await clients_1.hubspotClient.oauth.tokensApi.create(grant_type, code, redirect_uri, client_id, client_secret, refresh_token); 71 | try { 72 | const accessToken = tokenResponse.accessToken; 73 | const refreshToken = tokenResponse.refreshToken; 74 | const expiresIn = tokenResponse.expiresIn; 75 | const expiresAt = getExpiresAt(expiresIn); 76 | const customerId = (0, utils_1.getCustomerId)(); 77 | const hsPortalId = await getHubSpotId(accessToken); 78 | const tokenInfo = await prisma.authorization.upsert({ 79 | where: { 80 | customerId: customerId 81 | }, 82 | update: { 83 | refreshToken, 84 | accessToken, 85 | expiresIn, 86 | expiresAt, 87 | hsPortalId 88 | }, 89 | create: { 90 | refreshToken, 91 | accessToken, 92 | expiresIn, 93 | expiresAt, 94 | hsPortalId, 95 | customerId 96 | } 97 | }); 98 | return tokenInfo; 99 | } 100 | catch (e) { 101 | console.error(` > Error exchanging ${exchangeProof.grant_type} for access token`); 102 | console.error(e); 103 | throw e; 104 | } 105 | }; 106 | exports.exchangeForTokens = exchangeForTokens; 107 | const getAccessToken = async (customerId) => { 108 | try { 109 | const currentCreds = (await prisma.authorization.findFirst({ 110 | select: { 111 | accessToken: true, 112 | expiresAt: true, 113 | refreshToken: true 114 | }, 115 | where: { 116 | customerId 117 | } 118 | })); 119 | if (currentCreds?.expiresAt && currentCreds?.expiresAt > new Date()) { 120 | return currentCreds?.accessToken; 121 | } 122 | else { 123 | const updatedCreds = await exchangeForTokens({ 124 | ...EXCHANGE_CONSTANTS, 125 | grant_type: 'refresh_token', 126 | refresh_token: currentCreds?.refreshToken 127 | }); 128 | if (updatedCreds instanceof Error) { 129 | throw updatedCreds; 130 | } 131 | else { 132 | return updatedCreds?.accessToken; 133 | } 134 | } 135 | } 136 | catch (error) { 137 | console.error(error); 138 | throw error; 139 | } 140 | }; 141 | exports.getAccessToken = getAccessToken; 142 | function applyHubSpotAccessToken(accessToken) { 143 | try { 144 | clients_1.hubspotClient.setAccessToken(accessToken); 145 | return clients_1.hubspotClient; 146 | } 147 | catch (error) { 148 | (0, error_1.default)(error, 'Error setting HubSpot access token'); 149 | throw new Error(`Failed to apply access token: ${error instanceof Error ? error.message : 'Unknown error'}`); 150 | } 151 | } 152 | async function authenticateHubspotClient() { 153 | try { 154 | const customerId = (0, utils_1.getCustomerId)(); 155 | const accessToken = await getAccessToken(customerId); 156 | if (!accessToken) { 157 | throw new Error(`No access token returned for customer ID: ${customerId}`); 158 | } 159 | return applyHubSpotAccessToken(accessToken); 160 | } 161 | catch (error) { 162 | (0, error_1.default)(error, 'Error retrieving HubSpot access token'); 163 | throw error instanceof Error 164 | ? new Error(`Failed to authenticate HubSpot client: ${error.message}`) 165 | : new Error('Failed to authenticate HubSpot client due to an unknown error'); 166 | } 167 | } 168 | exports.authenticateHubspotClient = authenticateHubspotClient; 169 | -------------------------------------------------------------------------------- /dist/src/utils.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | exports.getCustomerId = exports.PORT = void 0; 4 | const PORT = 3001; 5 | exports.PORT = PORT; 6 | const getCustomerId = () => "1"; // faking this because building an account provisiong/login system is out of scope 7 | exports.getCustomerId = getCustomerId; 8 | -------------------------------------------------------------------------------- /eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "plugins": ["@typescript-eslint", "prettier"], 5 | "extends": [ 6 | "eslint:recommended", 7 | "plugin:@typescript-eslint/eslint-recommended", 8 | "plugin:@typescript-eslint/recommended", 9 | "prettier" 10 | ], 11 | "rules": { "prettier/prettier": 2 } 12 | } 13 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | moduleFileExtensions: ['ts', 'js'], 5 | testMatch: ['**/*.test.ts'], 6 | setupFiles: ['dotenv/config'] 7 | }; 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "crm_object_sync", 3 | "version": "1.0.0", 4 | "description": "Getting started with syncing associations between records", 5 | "main": "index.js", 6 | "scripts": { 7 | "dev": "nodemon src/app.ts", 8 | "prod": "node dist/app.js", 9 | "build": "tsc", 10 | "db-seed": "npx prisma db seed", 11 | "db-init": "npx prisma db push", 12 | "db-view": "npx prisma studio", 13 | "db-migrate": "npx prisma migrate dev", 14 | "ts-node": "ts-node --compiler-options '{\"module\": \"CommonJS\"}'", 15 | "lint": "npx eslint", 16 | "prettier-format": "prettier --config .prettierrc 'src/**/*.ts' --write", 17 | "test": "jest", 18 | "test:watch": "jest --watch", 19 | "test:coverage": "jest --coverage" 20 | }, 21 | "prisma": { 22 | "seed": "ts-node prisma/seed.ts" 23 | }, 24 | "author": "", 25 | "license": "ISC", 26 | "dependencies": { 27 | "@hubspot/api-client": "^11.1.0", 28 | "@hubspot/cli-lib": "^4.1.14", 29 | "@ngrok/ngrok": "^0.5.2", 30 | "@prisma/client": "^4.15.0", 31 | "axios": "^1.4.0", 32 | "dotenv": "^16.1.4", 33 | "express": "^4.17.1", 34 | "prompts": "^2.4.2", 35 | "swagger-jsdoc": "^6.2.8" 36 | }, 37 | "devDependencies": { 38 | "@faker-js/faker": "^8.4.0", 39 | "@types/express": "^4.17.11", 40 | "@types/jest": "^29.5.14", 41 | "@types/node": "^14.18.63", 42 | "@types/prompts": "^2.4.4", 43 | "@types/supertest": "^6.0.2", 44 | "@typescript-eslint/eslint-plugin": "^6.19.1", 45 | "@typescript-eslint/parser": "^6.19.1", 46 | "eslint": "^8.56.0", 47 | "eslint-config-prettier": "^9.1.0", 48 | "eslint-plugin-prettier": "^5.1.3", 49 | "jest": "^29.7.0", 50 | "nodemon": "^2.0.7", 51 | "prettier": "^3.2.4", 52 | "prisma": "^5.8.1", 53 | "supertest": "^7.0.0", 54 | "ts-jest": "^29.2.5", 55 | "ts-node": "^10.9.2", 56 | "typescript": "^4.9.5" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /prisma/disconnect.ts: -------------------------------------------------------------------------------- 1 | import prismaSeed from '../prisma/seed'; 2 | 3 | async function disconnectPrisma(): Promise { 4 | try { 5 | console.log('Disconnecting from the database...'); 6 | await prismaSeed.$disconnect(); 7 | console.log('Disconnected from the database successfully.'); 8 | } catch (error) { 9 | console.error('Error while disconnecting from the database:', error); 10 | throw error; 11 | } 12 | } 13 | 14 | export default disconnectPrisma 15 | -------------------------------------------------------------------------------- /prisma/migrations/20240123171836_adding_prisma_schema_to_db/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "Contacts" ( 3 | "id" SERIAL NOT NULL, 4 | "email" TEXT NOT NULL, 5 | "first_name" TEXT, 6 | "last_name" TEXT, 7 | "hs_object_id" TEXT NOT NULL, 8 | 9 | CONSTRAINT "Contacts_pkey" PRIMARY KEY ("id") 10 | ); 11 | 12 | -- CreateTable 13 | CREATE TABLE "Companies" ( 14 | "id" SERIAL NOT NULL, 15 | "domain" TEXT, 16 | "name" TEXT, 17 | "hs_object_id" TEXT NOT NULL, 18 | 19 | CONSTRAINT "Companies_pkey" PRIMARY KEY ("id") 20 | ); 21 | 22 | -- CreateTable 23 | CREATE TABLE "Authorization" ( 24 | "customerId" VARCHAR(255) NOT NULL, 25 | "hsPortalId" VARCHAR(255) NOT NULL, 26 | "accessToken" VARCHAR(255) NOT NULL, 27 | "refreshToken" VARCHAR(255) NOT NULL, 28 | "expiresIn" INTEGER, 29 | "expiresAt" TIMESTAMP(6), 30 | 31 | CONSTRAINT "Authorization_pkey" PRIMARY KEY ("customerId") 32 | ); 33 | 34 | -- CreateIndex 35 | CREATE UNIQUE INDEX "Contacts_email_key" ON "Contacts"("email"); 36 | 37 | -- CreateIndex 38 | CREATE INDEX "Contacts_hs_object_id_idx" ON "Contacts"("hs_object_id"); 39 | 40 | -- CreateIndex 41 | CREATE INDEX "Companies_hs_object_id_idx" ON "Companies"("hs_object_id"); 42 | -------------------------------------------------------------------------------- /prisma/migrations/20240131140150_31012024/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "Contacts" ALTER COLUMN "hs_object_id" DROP NOT NULL; 3 | -------------------------------------------------------------------------------- /prisma/migrations/20240131141604_3101242/migration.sql: -------------------------------------------------------------------------------- 1 | -- DropIndex 2 | DROP INDEX "Contacts_email_key"; 3 | 4 | -- AlterTable 5 | ALTER TABLE "Companies" ALTER COLUMN "hs_object_id" DROP NOT NULL; 6 | -------------------------------------------------------------------------------- /prisma/migrations/20240131142530_3101243/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - A unique constraint covering the columns `[email]` on the table `Contacts` will be added. If there are existing duplicate values, this will fail. 5 | 6 | */ 7 | -- AlterTable 8 | ALTER TABLE "Contacts" ALTER COLUMN "email" DROP NOT NULL; 9 | 10 | -- CreateIndex 11 | CREATE UNIQUE INDEX "Contacts_email_key" ON "Contacts"("email"); 12 | -------------------------------------------------------------------------------- /prisma/migrations/20240209185407_pyenv_shell_3_10_3/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "SyncJobs" ( 3 | "id" SERIAL NOT NULL, 4 | "executionTime" TIMESTAMP NOT NULL, 5 | "success" JSON NOT NULL, 6 | "failures" JSON NOT NULL, 7 | 8 | CONSTRAINT "SyncJobs_pkey" PRIMARY KEY ("id") 9 | ); 10 | -------------------------------------------------------------------------------- /prisma/migrations/20240209185524_optional_fields/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "SyncJobs" ALTER COLUMN "executionTime" DROP NOT NULL, 3 | ALTER COLUMN "success" DROP NOT NULL, 4 | ALTER COLUMN "failures" DROP NOT NULL; 5 | -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "postgresql" -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | generator client { 2 | provider = "prisma-client-js" 3 | } 4 | 5 | datasource db { 6 | provider = "postgresql" 7 | url = env("DATABASE_URL") 8 | } 9 | 10 | model Contacts { 11 | id Int @id @default(autoincrement()) 12 | email String? @unique 13 | first_name String? 14 | last_name String? 15 | hs_object_id String? 16 | 17 | @@index([hs_object_id]) 18 | } 19 | 20 | model Companies { 21 | id Int @id @default(autoincrement()) 22 | domain String? 23 | name String? 24 | hs_object_id String? 25 | 26 | @@index([hs_object_id]) 27 | } 28 | 29 | model Authorization { 30 | customerId String @id @db.VarChar(255) 31 | hsPortalId String @db.VarChar(255) 32 | accessToken String @db.VarChar(512) 33 | refreshToken String @db.VarChar(255) 34 | expiresIn Int? 35 | expiresAt DateTime? @db.Timestamp(6) 36 | } 37 | 38 | model SyncJobs { 39 | id Int @id @default(autoincrement()) 40 | executionTime DateTime? @db.Timestamp 41 | success Json? @db.Json 42 | failures Json? @db.Json 43 | } 44 | -------------------------------------------------------------------------------- /prisma/seed.ts: -------------------------------------------------------------------------------- 1 | import { faker } from '@faker-js/faker'; 2 | import { PrismaClient } from '@prisma/client'; 3 | import { Prisma } from '@prisma/client'; 4 | 5 | const prisma = new PrismaClient(); 6 | 7 | /*Create dataset, mapping over an array*/ 8 | const data: Prisma.ContactsCreateManyInput[] = Array.from({ length: 1000 }).map( 9 | () => ({ 10 | first_name: faker.person.firstName(), 11 | last_name: faker.person.lastName(), 12 | 13 | email: faker.internet.email().toLowerCase() //normalize before adding to db 14 | }) 15 | ); 16 | 17 | /*Run seed command and the function below inserts data in the database*/ 18 | async function main() { 19 | console.log(`=== Generated ${data.length} contacts ===`); 20 | await prisma.contacts.createMany({ 21 | data, 22 | skipDuplicates: true // fakerjs will repeat emails 23 | }); 24 | } 25 | 26 | // Only run if this file is being executed directly 27 | if (require.main === module) { 28 | main() 29 | .catch((e) => { 30 | console.error(e); 31 | process.exit(1); 32 | }) 33 | .finally(async () => { 34 | await prisma.$disconnect(); 35 | }); 36 | } 37 | 38 | export { main }; // Export the main function instead 39 | export default prisma; 40 | -------------------------------------------------------------------------------- /src/app.ts: -------------------------------------------------------------------------------- 1 | import express, { Application, Request, Response } from 'express'; 2 | import { authUrl, redeemCode, getAccessToken } from './auth'; 3 | import 'dotenv/config'; 4 | import { PORT, getCustomerId } from './utils/utils'; 5 | import { initialContactsSync } from './initialSyncFromHubSpot'; 6 | import { syncContactsToHubSpot } from './initialSyncToHubSpot'; 7 | import { prisma } from './clients'; 8 | import handleError from './utils/error'; 9 | import { logger } from './utils/logger'; 10 | import { Server } from 'http'; 11 | import swaggerUi from 'swagger-ui-express'; 12 | import { specs } from './swagger'; 13 | 14 | const app: Application = express(); 15 | app.use(express.json()); 16 | app.use(express.urlencoded({ extended: true })); 17 | 18 | app.get('/contacts', async (req: Request, res: Response) => { 19 | try { 20 | const contacts = await prisma.contacts.findMany({}); 21 | res.send(contacts); 22 | } catch (error) { 23 | handleError(error, 'Error fetching contacts'); 24 | res 25 | .status(500) 26 | .json({ message: 'An error occurred while fetching contacts.' }); 27 | } 28 | }); 29 | 30 | app.get('/api/install', (req: Request, res: Response) => { 31 | res.send( 32 | `${authUrl}` 33 | ); 34 | }); 35 | 36 | app.get('/sync-contacts', async (req: Request, res: Response) => { 37 | try { 38 | const syncResults = await syncContactsToHubSpot(); 39 | res.send(syncResults); 40 | } catch (error) { 41 | handleError(error, 'Error syncing contacts'); 42 | res 43 | .status(500) 44 | .json({ message: 'An error occurred while syncing contacts.' }); 45 | } 46 | }); 47 | 48 | app.get('/', async (req: Request, res: Response) => { 49 | try { 50 | const accessToken = await getAccessToken(getCustomerId()); 51 | res.send(accessToken); 52 | } catch (error) { 53 | handleError(error, 'Error fetching access token'); 54 | res 55 | .status(500) 56 | .json({ message: 'An error occurred while fetching the access token.' }); 57 | } 58 | }); 59 | 60 | app.get('/oauth-callback', async (req: Request, res: Response) => { 61 | const code = req.query.code; 62 | 63 | if (code) { 64 | try { 65 | const authInfo = await redeemCode(code.toString()); 66 | const accessToken = authInfo.accessToken; 67 | logger.info({ 68 | type: 'HubSpot', 69 | logMessage: { 70 | message: 'OAuth complete!' 71 | } 72 | }); 73 | res.redirect(`http://localhost:${PORT}/`); 74 | } catch (error: any) { 75 | handleError(error, 'Error redeeming code during OAuth'); 76 | res.redirect( 77 | `/?errMessage=${error.message || 'An error occurred during the OAuth process.'}` 78 | ); 79 | } 80 | } else { 81 | logger.error({ 82 | type: 'HubSpot', 83 | logMessage: { 84 | message: 'Error: code parameter is missing.' 85 | } 86 | }); 87 | res 88 | .status(400) 89 | .json({ message: 'Code parameter is missing in the query string.' }); 90 | } 91 | }); 92 | 93 | app.get('/initial-contacts-sync', async (req: Request, res: Response) => { 94 | try { 95 | const syncResults = await initialContactsSync(); 96 | res.send(syncResults); 97 | } catch (error) { 98 | handleError(error, 'Error during initial contacts sync'); 99 | res 100 | .status(500) 101 | .json({ message: 'An error occurred during the initial contacts sync.' }); 102 | } 103 | }); 104 | 105 | app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(specs)); 106 | 107 | let server: Server | null = null; 108 | 109 | function startServer() { 110 | if (!server) { 111 | server = app.listen(PORT, function (err?: Error) { 112 | if (err) { 113 | console.error('Error starting server:', err); 114 | return; 115 | } 116 | console.log(`App is listening on port ${PORT}`); 117 | }); 118 | } 119 | return server; 120 | } 121 | 122 | // Start the server only if this file is run directly 123 | if (require.main === module) { 124 | startServer(); 125 | } 126 | 127 | export { app, server, startServer }; 128 | -------------------------------------------------------------------------------- /src/auth.ts: -------------------------------------------------------------------------------- 1 | import 'dotenv/config'; 2 | 3 | import { Authorization, PrismaClient } from '@prisma/client'; 4 | import { PORT, getCustomerId } from './utils/utils'; 5 | import { hubspotClient } from './clients'; 6 | import { Client } from '@hubspot/api-client'; 7 | import handleError from './utils/error'; 8 | 9 | interface ExchangeProof { 10 | grant_type: string; 11 | client_id: string; 12 | client_secret: string; 13 | redirect_uri: string; 14 | code?: string; 15 | refresh_token?: string; 16 | } 17 | 18 | class MissingRequiredError extends Error { 19 | constructor(message: string | undefined, options: ErrorOptions | undefined) { 20 | message = message + 'is missing, please add it to your .env file'; 21 | super(message, options); 22 | } 23 | } 24 | 25 | const CLIENT_ID = process.env.CLIENT_ID; 26 | const CLIENT_SECRET = process.env.CLIENT_SECRET; 27 | if (!CLIENT_ID) { 28 | throw new MissingRequiredError('CLIENT_ID ', undefined); 29 | } 30 | if (!CLIENT_SECRET) { 31 | throw new MissingRequiredError('CLIENT_SECRET ', undefined); 32 | } 33 | 34 | const REDIRECT_URI: string = `http://localhost:${PORT}/oauth-callback`; 35 | 36 | const SCOPES = [ 37 | 'crm.schemas.companies.write', 38 | 'crm.schemas.contacts.write', 39 | 'crm.schemas.companies.read', 40 | 'crm.schemas.contacts.read', 41 | 'crm.objects.companies.write', 42 | 'crm.objects.contacts.write', 43 | 'crm.objects.companies.read', 44 | 'crm.objects.contacts.read' 45 | ]; 46 | 47 | const EXCHANGE_CONSTANTS = { 48 | redirect_uri: REDIRECT_URI, 49 | client_id: CLIENT_ID, 50 | client_secret: CLIENT_SECRET 51 | }; 52 | 53 | const prisma = new PrismaClient(); 54 | 55 | const scopeString = SCOPES.toString().replaceAll(',', ' '); 56 | 57 | const authUrl = hubspotClient.oauth.getAuthorizationUrl( 58 | CLIENT_ID, 59 | REDIRECT_URI, 60 | scopeString 61 | ); 62 | 63 | const getExpiresAt = (expiresIn: number): Date => { 64 | const now = new Date(); 65 | 66 | return new Date(now.getTime() + expiresIn * 1000); 67 | }; 68 | 69 | const redeemCode = async (code: string): Promise => { 70 | return await exchangeForTokens({ 71 | ...EXCHANGE_CONSTANTS, 72 | code, 73 | grant_type: 'authorization_code' 74 | }); 75 | }; 76 | 77 | const getHubSpotId = async (accessToken: string) => { 78 | hubspotClient.setAccessToken(accessToken); 79 | const hubspotAccountInfoResponse = await hubspotClient.apiRequest({ 80 | path: '/account-info/v3/details', 81 | method: 'GET' 82 | }); 83 | 84 | const hubspotAccountInfo = await hubspotAccountInfoResponse.json(); 85 | const hubSpotportalId = hubspotAccountInfo.portalId; 86 | return hubSpotportalId.toString(); 87 | }; 88 | 89 | const exchangeForTokens = async ( 90 | exchangeProof: ExchangeProof 91 | ): Promise => { 92 | const { 93 | code, 94 | redirect_uri, 95 | client_id, 96 | client_secret, 97 | grant_type, 98 | refresh_token 99 | } = exchangeProof; 100 | const tokenResponse = await hubspotClient.oauth.tokensApi.create( 101 | grant_type, 102 | code, 103 | redirect_uri, 104 | client_id, 105 | client_secret, 106 | refresh_token 107 | ); 108 | 109 | try { 110 | const accessToken: string = tokenResponse.accessToken; 111 | const refreshToken: string = tokenResponse.refreshToken; 112 | const expiresIn: number = tokenResponse.expiresIn; 113 | const expiresAt: Date = getExpiresAt(expiresIn); 114 | const customerId = getCustomerId(); 115 | const hsPortalId = await getHubSpotId(accessToken); 116 | const tokenInfo = await prisma.authorization.upsert({ 117 | where: { 118 | customerId: customerId 119 | }, 120 | update: { 121 | refreshToken, 122 | accessToken, 123 | expiresIn, 124 | expiresAt, 125 | hsPortalId 126 | }, 127 | create: { 128 | refreshToken, 129 | accessToken, 130 | expiresIn, 131 | expiresAt, 132 | hsPortalId, 133 | customerId 134 | } 135 | }); 136 | 137 | return tokenInfo; 138 | } catch (e) { 139 | console.error( 140 | ` > Error exchanging ${exchangeProof.grant_type} for access token` 141 | ); 142 | console.error(e); 143 | throw e; 144 | } 145 | }; 146 | 147 | const getAccessToken = async (customerId: string): Promise => { 148 | try { 149 | const currentCreds = (await prisma.authorization.findFirst({ 150 | select: { 151 | accessToken: true, 152 | expiresAt: true, 153 | refreshToken: true 154 | }, 155 | where: { 156 | customerId 157 | } 158 | })) as Authorization; 159 | if (currentCreds?.expiresAt && currentCreds?.expiresAt > new Date()) { 160 | return currentCreds?.accessToken; 161 | } else { 162 | const updatedCreds = await exchangeForTokens({ 163 | ...EXCHANGE_CONSTANTS, 164 | grant_type: 'refresh_token', 165 | 166 | refresh_token: currentCreds?.refreshToken 167 | }); 168 | if (updatedCreds instanceof Error) { 169 | throw updatedCreds; 170 | } else { 171 | return updatedCreds?.accessToken; 172 | } 173 | } 174 | } catch (error) { 175 | console.error(error); 176 | throw error; 177 | } 178 | }; 179 | 180 | function applyHubSpotAccessToken(accessToken: string): Client { 181 | try { 182 | hubspotClient.setAccessToken(accessToken); 183 | return hubspotClient; 184 | } catch (error) { 185 | handleError(error, 'Error setting HubSpot access token'); 186 | throw new Error( 187 | `Failed to apply access token: ${error instanceof Error ? error.message : 'Unknown error'}` 188 | ); 189 | } 190 | } 191 | 192 | async function authenticateHubspotClient(): Promise { 193 | try { 194 | const customerId = getCustomerId(); 195 | const accessToken = await getAccessToken(customerId); 196 | if (!accessToken) { 197 | throw new Error( 198 | `No access token returned for customer ID: ${customerId}` 199 | ); 200 | } 201 | return applyHubSpotAccessToken(accessToken); 202 | } catch (error) { 203 | handleError(error, 'Error retrieving HubSpot access token'); 204 | throw error instanceof Error 205 | ? new Error(`Failed to authenticate HubSpot client: ${error.message}`) 206 | : new Error( 207 | 'Failed to authenticate HubSpot client due to an unknown error' 208 | ); 209 | } 210 | } 211 | 212 | export { 213 | authUrl, 214 | exchangeForTokens, 215 | redeemCode, 216 | getAccessToken, 217 | authenticateHubspotClient 218 | }; 219 | -------------------------------------------------------------------------------- /src/clients.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '@prisma/client'; 2 | import { Client } from '@hubspot/api-client'; 3 | 4 | const DEFAULT_LIMITER_OPTIONS = { 5 | minTime: 1000 / 9, 6 | maxConcurrent: 6, 7 | id: 'hubspot-client-limiter' 8 | }; 9 | 10 | export const prisma = new PrismaClient(); 11 | 12 | export const hubspotClient = new Client({ 13 | limiterOptions: DEFAULT_LIMITER_OPTIONS 14 | }); 15 | -------------------------------------------------------------------------------- /src/initialSyncFromHubSpot.ts: -------------------------------------------------------------------------------- 1 | import 'dotenv/config'; 2 | 3 | import { Contacts, Prisma } from '@prisma/client'; 4 | 5 | import { SimplePublicObject } from '@hubspot/api-client/lib/codegen/crm/contacts'; 6 | 7 | import { hubspotClient, prisma } from './clients'; 8 | 9 | // Use verbose (but slower) create or update functionality 10 | const useVerboseCreateOrUpdate: boolean = false; 11 | 12 | // HubSpot client rate limit settings 13 | 14 | // HubSpot Client arguments 15 | // Unused values must be undefined to avoid HubSpot client errors 16 | const pageLimit: number = 100; 17 | let after: string; 18 | const propertiesToGet: string[] = ['firstname', 'lastname', 'email']; 19 | let propertiesToGetWithHistory: string[]; 20 | let associationsToGet: string[]; 21 | const getArchived: boolean = false; 22 | 23 | // Types for handling create/update results 24 | type CreateUpdateUpsertResult = { 25 | recordDetails: Contacts; 26 | updateResult: string; 27 | }; 28 | 29 | type JobRunResults = { 30 | upsert: { 31 | count: number; 32 | records: CreateUpdateUpsertResult[]; 33 | }; 34 | created: { 35 | count: number; 36 | records: CreateUpdateUpsertResult[]; 37 | }; 38 | failed: { 39 | count: number; 40 | records: CreateUpdateUpsertResult[]; 41 | }; 42 | hsID_updated: { 43 | count: number; 44 | records: CreateUpdateUpsertResult[]; 45 | }; 46 | errors: { 47 | count: number; 48 | records: CreateUpdateUpsertResult[]; 49 | }; 50 | }; 51 | 52 | interface ContactsWithEmail extends SimplePublicObject { 53 | properties: { email: string }; 54 | } 55 | // Update function 1 - use upsert to create or update records 56 | // Faster but less verbose tracking of created vs. updated 57 | const upsertContact = async (contactData: SimplePublicObject) => { 58 | let upsertRecord: Contacts; 59 | let upsertResult: string; 60 | if (contactData.properties.email) { 61 | // Create the contact if no matching email 62 | // On matching email, update the HS ID but nothing else 63 | upsertRecord = await prisma.contacts.upsert({ 64 | where: { 65 | email: contactData.properties.email 66 | }, 67 | update: { 68 | // add the hs ID but don't update anything else 69 | hs_object_id: contactData.id 70 | }, 71 | create: { 72 | email: contactData.properties.email, 73 | first_name: contactData.properties.firstname, 74 | last_name: contactData.properties.lastname, 75 | hs_object_id: contactData.id 76 | } 77 | }); 78 | upsertResult = 'upsert'; 79 | } else { 80 | // no email, create without email 81 | upsertRecord = await prisma.contacts.create({ 82 | data: { 83 | first_name: contactData.properties.firstname, 84 | last_name: contactData.properties.lastname, 85 | hs_object_id: contactData.id 86 | } 87 | }); 88 | upsertResult = 'created'; 89 | } 90 | let result: CreateUpdateUpsertResult = { 91 | recordDetails: upsertRecord, 92 | updateResult: upsertResult 93 | }; 94 | 95 | return result; 96 | }; 97 | 98 | // Update function 2 - Try to create the record, fall back to update if that fails 99 | // Slower and will result in DB errors, but explicit tracking of created 100 | const verboseCreateOrUpdate = async (contactData: SimplePublicObject) => { 101 | let prismaRecord: Contacts; 102 | let updateResult: string; 103 | 104 | try { 105 | prismaRecord = await prisma.contacts.create({ 106 | data: { 107 | email: contactData.properties.email, 108 | first_name: contactData.properties.firstname, 109 | last_name: contactData.properties.lastname, 110 | hs_object_id: contactData.id 111 | } 112 | }); 113 | updateResult = 'created'; 114 | } catch (error) { 115 | console.log(error); 116 | if (error instanceof Prisma.PrismaClientKnownRequestError) { 117 | if (error.code === 'P2002') { 118 | const contactDataWithEmail = contactData as ContactsWithEmail; // Tell TS we always have an email address in this case 119 | // failed on unique property (i.e. email) 120 | // Update existing record by email, just add HS record id to record 121 | prismaRecord = await prisma.contacts.update({ 122 | where: { 123 | email: contactDataWithEmail.properties.email 124 | }, 125 | data: { 126 | // add the hs ID but don't update anything else 127 | hs_object_id: contactData.id 128 | } 129 | }); 130 | updateResult = 'hsID_updated'; 131 | } else { 132 | // some other known error but not existing email 133 | prismaRecord = { 134 | id: -1, 135 | email: contactData.properties.email, 136 | first_name: contactData.properties.firstname, 137 | last_name: contactData.properties.lastname, 138 | hs_object_id: contactData.id 139 | }; 140 | updateResult = error.code; // log Prisma error code, will be tracked as error in results 141 | } 142 | } else { 143 | // Any other failed create result 144 | prismaRecord = { 145 | id: -1, 146 | email: contactData.properties.email, 147 | first_name: contactData.properties.firstname, 148 | last_name: contactData.properties.lastname, 149 | hs_object_id: contactData.id 150 | }; 151 | updateResult = 'failed'; 152 | } 153 | } 154 | 155 | let result: CreateUpdateUpsertResult = { 156 | recordDetails: prismaRecord, 157 | updateResult: updateResult 158 | }; 159 | return result; 160 | }; 161 | 162 | // Initial sync FROM HubSpot contacts TO (local) database 163 | const initialContactsSync = async () => { 164 | console.log('started sync'); 165 | // const customerId = getCustomerId(); 166 | // const accessToken = await getAccessToken(customerId); 167 | 168 | // Track created/updated/upserted/any errors 169 | let jobRunResults: JobRunResults = { 170 | upsert: { 171 | count: 0, 172 | records: [] 173 | }, 174 | created: { 175 | count: 0, 176 | records: [] 177 | }, 178 | failed: { 179 | count: 0, 180 | records: [] 181 | }, 182 | hsID_updated: { 183 | count: 0, 184 | records: [] 185 | }, 186 | errors: { 187 | count: 0, 188 | records: [] 189 | } 190 | }; 191 | 192 | // Get all contacts using client 193 | const allContactsResponse: SimplePublicObject[] = 194 | await hubspotClient.crm.contacts.getAll( 195 | pageLimit, 196 | after, 197 | propertiesToGet, 198 | propertiesToGetWithHistory, 199 | associationsToGet, 200 | getArchived 201 | ); 202 | 203 | console.log(`Found ${allContactsResponse.length} contacts`); 204 | 205 | for (const element of allContactsResponse) { 206 | let createOrUpdateContactResult: CreateUpdateUpsertResult; 207 | if (useVerboseCreateOrUpdate) { 208 | createOrUpdateContactResult = await verboseCreateOrUpdate(element); 209 | } else { 210 | createOrUpdateContactResult = await upsertContact(element); 211 | } 212 | // Add to overall results based on result of create/update result 213 | switch (createOrUpdateContactResult.updateResult) { 214 | case 'upsert': 215 | jobRunResults.upsert.count += 1; 216 | jobRunResults.upsert.records.push(createOrUpdateContactResult); 217 | break; 218 | case 'created': 219 | jobRunResults.created.count += 1; 220 | jobRunResults.created.records.push(createOrUpdateContactResult); 221 | break; 222 | case 'hsID_updated': 223 | jobRunResults.hsID_updated.count += 1; 224 | jobRunResults.hsID_updated.records.push(createOrUpdateContactResult); 225 | break; 226 | case 'failed': 227 | jobRunResults.failed.count += 1; 228 | jobRunResults.failed.records.push(createOrUpdateContactResult); 229 | break; 230 | default: 231 | jobRunResults.errors.count += 1; 232 | jobRunResults.errors.records.push(createOrUpdateContactResult); 233 | break; 234 | } 235 | } 236 | 237 | return { 238 | total: allContactsResponse.length, 239 | results: jobRunResults 240 | }; 241 | }; 242 | 243 | export { initialContactsSync }; 244 | -------------------------------------------------------------------------------- /src/initialSyncToHubSpot.ts: -------------------------------------------------------------------------------- 1 | import 'dotenv/config'; 2 | 3 | import { Contacts, PrismaClient } from '@prisma/client'; 4 | import { Client } from '@hubspot/api-client'; 5 | import { authenticateHubspotClient } from './auth'; 6 | import { 7 | BatchReadInputSimplePublicObjectId, 8 | BatchResponseSimplePublicObjectStatusEnum, 9 | SimplePublicObjectInputForCreate, 10 | BatchResponseSimplePublicObjectWithErrors, 11 | BatchResponseSimplePublicObject, 12 | StandardError 13 | } from '@hubspot/api-client/lib/codegen/crm/contacts'; 14 | import { prisma, hubspotClient } from './clients'; 15 | 16 | interface KeyedContacts extends Contacts { 17 | [key: string]: any; 18 | } 19 | 20 | const MAX_BATCH_SIZE = 100; 21 | 22 | const splitBatchByMaxBatchSize = (contacts: Contacts[], start: number) => { 23 | return contacts.splice(start, MAX_BATCH_SIZE); 24 | }; 25 | 26 | interface ContactWithEmail extends Contacts { 27 | email: string; 28 | } 29 | 30 | class BatchToBeSynced { 31 | startingContacts: Contacts[] = []; 32 | 33 | cohortSize: number = 0; 34 | nativeIdsToRemoveFromBatchBeforeCreateAttempt = []; 35 | mapOfEmailsToNativeIds: Map = new Map(); // might want to make this a private property 36 | #batchReadInputs: BatchReadInputSimplePublicObjectId = { 37 | properties: [''], 38 | propertiesWithHistory: [''], 39 | inputs: [] 40 | }; 41 | #batchReadOutput: 42 | | BatchResponseSimplePublicObject 43 | | BatchResponseSimplePublicObjectWithErrors = { 44 | status: BatchResponseSimplePublicObjectStatusEnum.Pending, 45 | results: [], 46 | startedAt: new Date(), 47 | completedAt: new Date() 48 | }; 49 | #batchCreateOutput: 50 | | BatchResponseSimplePublicObject 51 | | BatchResponseSimplePublicObjectWithErrors = { 52 | status: BatchResponseSimplePublicObjectStatusEnum.Pending, 53 | results: [], 54 | startedAt: new Date(), 55 | completedAt: new Date() 56 | }; 57 | #batchReadError: Error | null = null; 58 | #syncErrors: StandardError[] | null = null; 59 | #saveErrors: Error[] | null = null; 60 | 61 | hubspotClient: Client; 62 | constructor(startingContacts: Contacts[], hubspotClient: Client) { 63 | this.hubspotClient = hubspotClient; 64 | this.startingContacts = startingContacts; 65 | 66 | this.cohortSize = this.startingContacts.length; 67 | 68 | if (!this.isLessThanMaxBatchSize()) { 69 | throw new Error( 70 | `Batch is too big, please supply less than ${MAX_BATCH_SIZE} ` 71 | ); 72 | } 73 | 74 | this.createMapOfEmailsToNativeIds(); 75 | this.readyBatchForBatchRead(); 76 | } 77 | isLessThanMaxBatchSize() { 78 | return this.startingContacts.length <= MAX_BATCH_SIZE; 79 | } 80 | 81 | createMapOfEmailsToNativeIds() { 82 | // Use for of loop to impreove readability 83 | for (let i = 0; i < this.startingContacts.length; i++) { 84 | const contact = this.startingContacts[i]; 85 | if (contact.email) { 86 | this.mapOfEmailsToNativeIds.set(contact.email, contact.id); 87 | // ignore contacts without email addresses for now 88 | } 89 | } 90 | } 91 | 92 | readyBatchForBatchRead() { 93 | // Filter out contacts that don't have an email address 94 | // Consider making this a private method, no real reason for it to be exposed 95 | const inputsWithEmails: ContactWithEmail[] = this.startingContacts.filter( 96 | (contact): contact is ContactWithEmail => !!contact.email 97 | ); 98 | const idsToRead = inputsWithEmails.map((contact) => { 99 | return { id: contact.email }; 100 | }); 101 | this.#batchReadInputs = { 102 | inputs: idsToRead, 103 | idProperty: 'email', 104 | properties: ['email', 'firstname', 'lastname'], 105 | propertiesWithHistory: [] 106 | }; 107 | } 108 | 109 | async batchRead() { 110 | await authenticateHubspotClient(); 111 | try { 112 | const response = await this.hubspotClient.crm.contacts.batchApi.read( 113 | this.#batchReadInputs 114 | ); 115 | this.#batchReadOutput = response; 116 | } catch (error) { 117 | if (error instanceof Error) { 118 | this.#batchReadError = error; 119 | } 120 | } 121 | } 122 | 123 | removeKnownContactsFromBatch() { 124 | const emailsOfKnownContacts = this.#batchReadOutput.results.map( 125 | (knownContact) => { 126 | return knownContact.properties.email 127 | ? knownContact.properties.email 128 | : ''; 129 | } 130 | ); 131 | 132 | for (const email of emailsOfKnownContacts) { 133 | this.mapOfEmailsToNativeIds.delete(email); 134 | } 135 | } 136 | 137 | async sendNetNewContactsToHubspot() { 138 | const contactsToSendToHubSpot: SimplePublicObjectInputForCreate[] = []; 139 | this.mapOfEmailsToNativeIds.forEach((nativeId, emailAddress) => { 140 | const matchedContact = this.startingContacts.find( 141 | (startingContact) => startingContact.email == emailAddress 142 | ); 143 | 144 | const propertiesToSend = ['email', 'firstname', 'lastname']; // Make this a DB call to mapped Properties when combined with property mapping use case 145 | 146 | if (!matchedContact) { 147 | return false; 148 | } 149 | const createPropertiesSection = ( 150 | contact: KeyedContacts, 151 | propertiesToSend: string[] 152 | ): SimplePublicObjectInputForCreate['properties'] => { 153 | const propertiesSection: SimplePublicObjectInputForCreate['properties'] = 154 | {}; 155 | for (const property of propertiesToSend) { 156 | contact[property] 157 | ? (propertiesSection[property] = contact[property]) 158 | : null; 159 | } 160 | return propertiesSection; 161 | }; 162 | const nonNullPropertiesToSend = createPropertiesSection( 163 | matchedContact, 164 | propertiesToSend 165 | ); 166 | const formattedContact = { 167 | associations: [], 168 | properties: nonNullPropertiesToSend 169 | }; 170 | 171 | contactsToSendToHubSpot.push(formattedContact); 172 | }); 173 | 174 | try { 175 | const response = await this.hubspotClient.crm.contacts.batchApi.create({ 176 | inputs: contactsToSendToHubSpot 177 | }); 178 | if ( 179 | response instanceof BatchResponseSimplePublicObjectWithErrors && 180 | response.errors 181 | ) { 182 | if (Array.isArray(this.#syncErrors)) { 183 | this.#syncErrors.concat(response.errors); 184 | } else { 185 | this.#syncErrors = response.errors; 186 | } 187 | } 188 | this.#batchCreateOutput = response; 189 | return response; 190 | } catch (error) { 191 | if (error instanceof Error) { 192 | if (this.#saveErrors) { 193 | this.#saveErrors.push(error); 194 | } else { 195 | this.#saveErrors = [error]; 196 | } 197 | } 198 | } 199 | } 200 | 201 | async saveHSContactIDToDatabase() { 202 | const savedContacts = this.#batchCreateOutput.results.length 203 | ? this.#batchCreateOutput.results 204 | : this.#batchReadOutput.results; 205 | for (const contact of savedContacts) { 206 | try { 207 | if (!contact.properties.email) { 208 | throw new Error('Need an email address to save contacts'); 209 | } 210 | await prisma.contacts.update({ 211 | where: { 212 | email: contact.properties.email 213 | }, 214 | data: { 215 | hs_object_id: contact.id 216 | } 217 | }); 218 | } catch (error) { 219 | throw new Error('Encountered an issue saving a record to the database'); 220 | } 221 | } 222 | } 223 | 224 | public get syncErrors() { 225 | return this.#syncErrors; 226 | } 227 | public get saveErrors() { 228 | return this.#saveErrors; 229 | } 230 | public get syncResults() { 231 | return this.#batchCreateOutput; 232 | } 233 | } 234 | 235 | const syncContactsToHubSpot = async () => { 236 | const prisma = new PrismaClient(); 237 | const localContacts = await prisma.contacts.findMany({ 238 | where: { hs_object_id: null } 239 | }); 240 | const syncJob = await prisma.syncJobs.create({ 241 | data: { executionTime: new Date() } 242 | }); 243 | 244 | let start = 0; 245 | let finalResults: any[] = []; 246 | let finalErrors: any[] = []; 247 | const syncJobId = syncJob.id; 248 | 249 | console.log( 250 | `===== Starting Sync Job for ${localContacts.length} contacts =====` 251 | ); 252 | while (localContacts.length > 0) { 253 | let batch = splitBatchByMaxBatchSize(localContacts, start); 254 | 255 | const syncCohort = new BatchToBeSynced(batch, hubspotClient); 256 | 257 | await syncCohort.batchRead(); 258 | 259 | syncCohort.removeKnownContactsFromBatch(); 260 | 261 | if (syncCohort.mapOfEmailsToNativeIds.size === 0) { 262 | // take the next set of 100 contacts 263 | console.log('all contacts were known, no need to create'); 264 | } else { 265 | await syncCohort.sendNetNewContactsToHubspot(); 266 | 267 | const errors = syncCohort.syncErrors; 268 | 269 | const results = syncCohort.syncResults; 270 | if (errors) { 271 | finalErrors.push(errors); 272 | } 273 | 274 | finalResults.push(results); 275 | console.log( 276 | `===== Finished current cohort, still have ${localContacts.length} contacts to sync =====` 277 | ); 278 | } 279 | await syncCohort.saveHSContactIDToDatabase(); 280 | } 281 | await prisma.syncJobs.update({ 282 | where: { id: syncJobId }, 283 | data: { success: finalResults, failures: finalErrors } 284 | }); 285 | 286 | console.log( 287 | `==== Batch sync complete, this job produced ${finalResults.length} successes and ${finalErrors.length} errors, check the syncJobs table for full results ====` 288 | ); 289 | 290 | return { results: { success: finalResults, errors: finalErrors } }; 291 | }; 292 | 293 | export { syncContactsToHubSpot }; 294 | -------------------------------------------------------------------------------- /src/swagger.ts: -------------------------------------------------------------------------------- 1 | import { apiEndpoints, commonSchemas } from './swagger/definitions'; 2 | import { PORT } from './utils/utils'; 3 | import swaggerJsdoc from 'swagger-jsdoc'; 4 | 5 | const options = { 6 | definition: { 7 | openapi: '3.0.0', 8 | info: { 9 | title: 'HubSpot Contact Sync API', 10 | version: '1.0.0', 11 | description: 12 | 'API for syncing and managing contacts between the local database and HubSpot' 13 | }, 14 | servers: [ 15 | { 16 | url: `http://localhost:${PORT}`, 17 | description: 'Local development server' 18 | } 19 | ], 20 | components: { 21 | schemas: commonSchemas 22 | }, 23 | paths: apiEndpoints 24 | }, 25 | apis: ['./src/app.ts'] // Since all routes are in app.ts 26 | }; 27 | 28 | const specs = swaggerJsdoc(options); 29 | 30 | export { specs }; 31 | -------------------------------------------------------------------------------- /src/swagger/definitions.ts: -------------------------------------------------------------------------------- 1 | export const commonSchemas = { 2 | Contact: { 3 | type: 'object', 4 | properties: { 5 | id: { 6 | type: 'string' 7 | }, 8 | email: { 9 | type: 'string' 10 | }, 11 | firstName: { 12 | type: 'string' 13 | }, 14 | lastName: { 15 | type: 'string' 16 | }, 17 | hubspotId: { 18 | type: 'string' 19 | }, 20 | createdAt: { 21 | type: 'string', 22 | format: 'date-time' 23 | }, 24 | updatedAt: { 25 | type: 'string', 26 | format: 'date-time' 27 | } 28 | } 29 | }, 30 | Error: { 31 | type: 'object', 32 | properties: { 33 | message: { 34 | type: 'string', 35 | description: 'Error message' 36 | } 37 | } 38 | }, 39 | SyncResults: { 40 | type: 'object', 41 | properties: { 42 | success: { 43 | type: 'boolean' 44 | }, 45 | message: { 46 | type: 'string' 47 | }, 48 | syncedCount: { 49 | type: 'number' 50 | }, 51 | errors: { 52 | type: 'array', 53 | items: { 54 | type: 'string' 55 | } 56 | } 57 | } 58 | } 59 | }; 60 | 61 | export const apiEndpoints = { 62 | '/contacts': { 63 | get: { 64 | summary: 'Get all contacts', 65 | description: 'Retrieves all contacts from the local database', 66 | responses: { 67 | '200': { 68 | description: 'Successfully retrieved contacts', 69 | content: { 70 | 'application/json': { 71 | schema: { 72 | type: 'array', 73 | items: { 74 | $ref: '#/components/schemas/Contact' 75 | } 76 | } 77 | } 78 | } 79 | }, 80 | '500': { 81 | description: 'Server error', 82 | content: { 83 | 'application/json': { 84 | schema: { 85 | $ref: '#/components/schemas/Error' 86 | } 87 | } 88 | } 89 | } 90 | } 91 | } 92 | }, 93 | '/api/install': { 94 | get: { 95 | summary: 'Get HubSpot installation URL', 96 | description: 97 | 'Returns an HTML page with the HubSpot OAuth installation link', 98 | responses: { 99 | '200': { 100 | description: 'HTML page with installation link', 101 | content: { 102 | 'text/html': { 103 | schema: { 104 | type: 'string' 105 | } 106 | } 107 | } 108 | } 109 | } 110 | } 111 | }, 112 | '/sync-contacts': { 113 | get: { 114 | summary: 'Sync contacts to HubSpot', 115 | description: 'Synchronizes contacts from local database to HubSpot', 116 | responses: { 117 | '200': { 118 | description: 'Sync results', 119 | content: { 120 | 'application/json': { 121 | schema: { 122 | $ref: '#/components/schemas/SyncResults' 123 | } 124 | } 125 | } 126 | }, 127 | '500': { 128 | description: 'Server error', 129 | content: { 130 | 'application/json': { 131 | schema: { 132 | $ref: '#/components/schemas/Error' 133 | } 134 | } 135 | } 136 | } 137 | } 138 | } 139 | }, 140 | '/': { 141 | get: { 142 | summary: 'Get access token', 143 | description: 144 | 'Retrieves the HubSpot access token for the current customer', 145 | responses: { 146 | '200': { 147 | description: 'Access token retrieved successfully', 148 | content: { 149 | 'application/json': { 150 | schema: { 151 | type: 'string' 152 | } 153 | } 154 | } 155 | }, 156 | '500': { 157 | description: 'Server error', 158 | content: { 159 | 'application/json': { 160 | schema: { 161 | $ref: '#/components/schemas/Error' 162 | } 163 | } 164 | } 165 | } 166 | } 167 | } 168 | }, 169 | '/oauth-callback': { 170 | get: { 171 | summary: 'OAuth callback endpoint', 172 | description: 'Handles the OAuth callback from HubSpot', 173 | parameters: [ 174 | { 175 | in: 'query', 176 | name: 'code', 177 | schema: { 178 | type: 'string' 179 | }, 180 | required: true, 181 | description: 'OAuth authorization code' 182 | } 183 | ], 184 | responses: { 185 | '302': { 186 | description: 'Redirect to home page after successful OAuth' 187 | }, 188 | '400': { 189 | description: 'Missing code parameter', 190 | content: { 191 | 'application/json': { 192 | schema: { 193 | $ref: '#/components/schemas/Error' 194 | } 195 | } 196 | } 197 | }, 198 | '500': { 199 | description: 'Server error', 200 | content: { 201 | 'application/json': { 202 | schema: { 203 | $ref: '#/components/schemas/Error' 204 | } 205 | } 206 | } 207 | } 208 | } 209 | } 210 | }, 211 | '/initial-contacts-sync': { 212 | get: { 213 | summary: 'Initial contacts sync from HubSpot', 214 | description: 215 | 'Performs initial synchronization of contacts from HubSpot to local database', 216 | responses: { 217 | '200': { 218 | description: 'Sync results', 219 | content: { 220 | 'application/json': { 221 | schema: { 222 | $ref: '#/components/schemas/SyncResults' 223 | } 224 | } 225 | } 226 | }, 227 | '500': { 228 | description: 'Server error', 229 | content: { 230 | 'application/json': { 231 | schema: { 232 | $ref: '#/components/schemas/Error' 233 | } 234 | } 235 | } 236 | } 237 | } 238 | } 239 | } 240 | }; 241 | -------------------------------------------------------------------------------- /src/types/common.d.ts: -------------------------------------------------------------------------------- 1 | export interface LogMessage { 2 | message: string; 3 | object?: any; 4 | context?:string; 5 | data?: any; 6 | stack?: string; 7 | code?: string; 8 | statusCode?: number; 9 | correlationId?: string; 10 | details?: any[]; 11 | error?: Error 12 | } 13 | 14 | type LogLevel = 'Info' | 'Warning' | 'Error'; 15 | 16 | export interface LogObject { 17 | logMessage : LogMessage, 18 | critical? : boolean, 19 | context? : string, 20 | type? : string 21 | level?: LogLevel 22 | } 23 | -------------------------------------------------------------------------------- /src/utils/error.ts: -------------------------------------------------------------------------------- 1 | import { logger } from "./logger"; 2 | import shutdown from "./shutdown"; 3 | import { LogObject } from "../types/common"; 4 | 5 | function isHubSpotApiError(error: any): boolean { 6 | // Check for presence of typical HubSpot headers 7 | const hasHubspotHeaders = 8 | error.headers && 9 | ("x-hubspot-correlation-id" in error.headers || 10 | "x-hubspot-ratelimit-max" in error.headers); 11 | 12 | // Check for presence of HubSpot-specific fields in the error body 13 | const hasHubspotFields = 14 | error.body && 15 | error.body.status === "error" && 16 | typeof error.body.correlationId === "string" && 17 | typeof error.body.groupsErrorCode === "string"; 18 | 19 | return ( 20 | hasHubspotHeaders || 21 | hasHubspotFields || 22 | Boolean( 23 | error?.message?.includes("hubapi") || 24 | error?.logMessage?.message?.body.includes("hubspot-correlation-id"), 25 | ) 26 | ); 27 | } 28 | 29 | function isGeneralPrismaError(error: any): boolean { 30 | return ( 31 | error?.stack?.includes("@prisma/client") || 32 | error?.message?.includes("prisma") 33 | ); 34 | } 35 | 36 | function formatError(logMessage: any, context: string = ""): any { 37 | const error: LogObject = { logMessage, context }; 38 | if (!error.type) { 39 | if (isGeneralPrismaError(logMessage)) { 40 | error.type = "Prisma"; 41 | } else if (isHubSpotApiError(logMessage)) { 42 | error.type = "Hubspot API"; 43 | } else if (logMessage instanceof Error) { 44 | error.type = "General"; 45 | } else { 46 | error.type = "Non-error object was thrown"; 47 | } 48 | } 49 | return error; 50 | } 51 | 52 | function handleError( 53 | error: any, 54 | context: string = "", 55 | critical: boolean = false, 56 | ): void { 57 | const formattedError = formatError(error, context); 58 | logger.error(formattedError); 59 | 60 | if (critical) shutdown(); 61 | } 62 | 63 | export default handleError; 64 | -------------------------------------------------------------------------------- /src/utils/logger.ts: -------------------------------------------------------------------------------- 1 | import { LogObject } from "../types/common"; 2 | 3 | class Logger { 4 | private log(message: LogObject): void { 5 | const timestamp = new Date().toISOString(); 6 | const logOutput = this.formatLogMessage(message, timestamp); 7 | 8 | switch (message.level) { 9 | case "Error": 10 | console.error(logOutput); 11 | break; 12 | case "Warning": 13 | console.warn(logOutput); 14 | break; 15 | case "Info": 16 | default: 17 | console.info(logOutput); 18 | break; 19 | } 20 | } 21 | 22 | private formatLogMessage(logObject: LogObject, timestamp: string): string { 23 | const { type = "Unknown", context, logMessage, level } = logObject; 24 | const { code, statusCode, correlationId, details, data, stack, message } = 25 | logMessage; 26 | 27 | const outputLines: string[] = [`${type} ${level} at ${timestamp}`]; 28 | 29 | if (context) outputLines.push(`Context: ${context}`); 30 | if (message && !stack) outputLines.push(`Message: ${message}`); 31 | if (stack) outputLines.push(`Stack: ${stack}`); 32 | if (code) outputLines.push(`Code: ${code}`); 33 | if (statusCode) outputLines.push(`StatusCode: ${statusCode}`); 34 | if (correlationId) outputLines.push(`Correlation ID: ${correlationId}`); 35 | if (details && details.length > 0) 36 | outputLines.push(`Details: ${JSON.stringify(details, null, 2)}`); 37 | if (data) outputLines.push(`Data: ${JSON.stringify(data, null, 2)}`); 38 | 39 | return outputLines.join("\n"); 40 | } 41 | 42 | public info(message: LogObject): void { 43 | message.level = "Info"; 44 | this.log(message); 45 | } 46 | 47 | public warn(message: LogObject): void { 48 | message.level = "Warning"; 49 | this.log(message); 50 | } 51 | 52 | public error(message: LogObject): void { 53 | message.level = "Error"; 54 | this.log(message); 55 | } 56 | } 57 | 58 | export const logger = new Logger(); 59 | -------------------------------------------------------------------------------- /src/utils/shutdown.ts: -------------------------------------------------------------------------------- 1 | import disconnectPrisma from '../../prisma/disconnect'; 2 | import { server } from '../app'; 3 | 4 | async function shutdown(): Promise { 5 | try { 6 | console.log('Initiating graceful shutdown...'); 7 | 8 | const closeServerPromise = new Promise((resolve, reject) => { 9 | if (!server) { 10 | console.log('No server instance to close.'); 11 | resolve(); 12 | return; 13 | } 14 | 15 | server.close((err) => { 16 | console.log('Server close callback called.'); 17 | if (err) { 18 | console.error('Error closing the server:', err); 19 | reject(err); 20 | } else { 21 | resolve(); 22 | } 23 | }); 24 | 25 | // Set a timeout in case the server does not close within a reasonable time 26 | setTimeout(() => { 27 | console.warn('Forcing server shutdown after timeout.'); 28 | resolve(); 29 | }, 5000); 30 | }); 31 | 32 | await Promise.all([ 33 | closeServerPromise 34 | .then(() => { 35 | console.log('HTTP server closed successfully.'); 36 | }) 37 | .catch((err) => { 38 | console.error('Error during server close:', err); 39 | }), 40 | disconnectPrisma().catch((err) => 41 | console.error('Error during Prisma disconnection:', err) 42 | ) 43 | ]); 44 | 45 | console.log('Graceful shutdown complete.'); 46 | process.exit(0); 47 | } catch (err) { 48 | console.error('Error during shutdown:', err); 49 | process.exit(1); 50 | } 51 | } 52 | 53 | export default shutdown; 54 | -------------------------------------------------------------------------------- /src/utils/utils.ts: -------------------------------------------------------------------------------- 1 | const PORT = 3000; 2 | const getCustomerId = () => '1'; // faking this because building an account provisiong/login system is out of scope 3 | 4 | export { PORT, getCustomerId }; 5 | -------------------------------------------------------------------------------- /tests/app.test.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import { 3 | describe, 4 | it, 5 | expect, 6 | jest, 7 | beforeEach, 8 | afterAll, 9 | beforeAll 10 | } from '@jest/globals'; 11 | import request from 'supertest'; 12 | import { Server } from 'http'; 13 | import { app } from '../src/app'; 14 | import { prisma } from '../src/clients'; 15 | import { syncContactsToHubSpot } from '../src/initialSyncToHubSpot'; 16 | import { initialContactsSync } from '../src/initialSyncFromHubSpot'; 17 | import { redeemCode, getAccessToken, authUrl } from '../src/auth'; 18 | import { getCustomerId } from '../src/utils/utils'; 19 | 20 | // Mock all external dependencies 21 | jest.mock('../src/clients', () => ({ 22 | prisma: { 23 | contacts: { 24 | findMany: jest.fn() 25 | } 26 | }, 27 | hubspotClient: { 28 | oauth: { 29 | getAuthorizationUrl: jest.fn(), 30 | defaultApi: { 31 | createToken: jest.fn() 32 | } 33 | } 34 | } 35 | })); 36 | 37 | jest.mock('../src/initialSyncToHubSpot'); 38 | jest.mock('../src/initialSyncFromHubSpot'); 39 | jest.mock('../src/auth', () => ({ 40 | authUrl: 'https://app.hubspot.com/oauth/authorize?mock=true', 41 | redeemCode: jest.fn(), 42 | getAccessToken: jest.fn() 43 | })); 44 | jest.mock('../src/utils/utils'); 45 | 46 | // Mock environment variables 47 | process.env.HUBSPOT_CLIENT_ID = 'test-client-id'; 48 | process.env.HUBSPOT_CLIENT_SECRET = 'test-client-secret'; 49 | process.env.HUBSPOT_SCOPE = 'test-scope'; 50 | process.env.REDIRECT_URI = 'http://localhost:3000/oauth-callback'; 51 | 52 | let server: Server; 53 | 54 | beforeAll((done) => { 55 | // Find a free port 56 | const port = 3001; // or any other port different from 3000 57 | server = app.listen(port, () => { 58 | console.log(`Test server running on port ${port}`); 59 | done(); 60 | }); 61 | }); 62 | 63 | afterAll((done) => { 64 | if (server) { 65 | console.log('Closing test server...'); 66 | server.close((err?: Error) => { 67 | if (err) { 68 | console.error('Error closing server:', err); 69 | done(err); 70 | } else { 71 | console.log('Test server closed successfully'); 72 | done(); 73 | } 74 | }); 75 | } else { 76 | done(); 77 | } 78 | }); 79 | 80 | describe('Express App', () => { 81 | beforeEach(() => { 82 | jest.clearAllMocks(); 83 | }); 84 | 85 | describe('GET /contacts', () => { 86 | it('should return contacts successfully', async () => { 87 | const mockContacts = [ 88 | { id: 1, name: 'John Doe', email: 'john@example.com' }, 89 | { id: 2, name: 'Jane Doe', email: 'jane@example.com' } 90 | ]; 91 | (prisma.contacts.findMany as jest.MockedFunction).mockResolvedValue( 92 | mockContacts 93 | ); 94 | 95 | const response = await request(server).get('/contacts').expect(200); 96 | 97 | expect(response.body).toEqual(mockContacts); 98 | }); 99 | 100 | it('should handle empty contact list', async () => { 101 | (prisma.contacts.findMany as jest.MockedFunction).mockResolvedValue( 102 | [] 103 | ); 104 | 105 | const response = await request(server).get('/contacts').expect(200); 106 | 107 | expect(response.body).toEqual([]); 108 | }); 109 | 110 | it('should handle database errors', async () => { 111 | (prisma.contacts.findMany as jest.MockedFunction).mockRejectedValue( 112 | new Error('Database connection failed') 113 | ); 114 | 115 | const response = await request(server).get('/contacts').expect(500); 116 | 117 | expect(response.body).toEqual({ 118 | message: 'An error occurred while fetching contacts.' 119 | }); 120 | }); 121 | }); 122 | 123 | describe('GET /api/install', () => { 124 | const mockUrl = 'https://app.hubspot.com/oauth/authorize?mock=true'; 125 | 126 | it('should return installation URL', async () => { 127 | const response = await request(server).get('/api/install').expect(200); 128 | 129 | expect(response.text).toContain(mockUrl); 130 | expect(response.text).toMatch( 131 | /.*.*.*<\/a>.*<\/body>.*<\/html>/ 132 | ); 133 | }); 134 | }); 135 | 136 | describe('GET /sync-contacts', () => { 137 | it('should sync contacts successfully', async () => { 138 | const mockSyncResults = { 139 | success: true, 140 | synced: 10, 141 | failed: 0 142 | }; 143 | (syncContactsToHubSpot as jest.MockedFunction).mockResolvedValue( 144 | mockSyncResults 145 | ); 146 | 147 | const response = await request(server).get('/sync-contacts').expect(200); 148 | 149 | expect(response.body).toEqual(mockSyncResults); 150 | }); 151 | 152 | it('should handle sync failures', async () => { 153 | (syncContactsToHubSpot as jest.MockedFunction).mockRejectedValue( 154 | new Error('Sync failed') 155 | ); 156 | 157 | const response = await request(server).get('/sync-contacts').expect(500); 158 | 159 | expect(response.body).toEqual({ 160 | message: 'An error occurred while syncing contacts.' 161 | }); 162 | }); 163 | }); 164 | 165 | describe('GET /', () => { 166 | it('should return access token successfully', async () => { 167 | const mockCustomerId = 'test-customer'; 168 | const mockAccessToken = 'valid-access-token'; 169 | (getCustomerId as jest.Mock).mockReturnValue(mockCustomerId); 170 | (getAccessToken as jest.MockedFunction).mockResolvedValue( 171 | mockAccessToken 172 | ); 173 | 174 | const response = await request(server).get('/').expect(200); 175 | 176 | expect(response.text).toBe(mockAccessToken); 177 | }); 178 | 179 | it('should handle token retrieval errors', async () => { 180 | (getCustomerId as jest.Mock).mockReturnValue('test-customer'); 181 | (getAccessToken as jest.MockedFunction).mockRejectedValue( 182 | new Error('Token retrieval failed') 183 | ); 184 | 185 | const response = await request(server).get('/').expect(500); 186 | 187 | expect(response.body).toEqual({ 188 | message: 'An error occurred while fetching the access token.' 189 | }); 190 | }); 191 | }); 192 | 193 | describe('GET /oauth-callback', () => { 194 | it('should handle valid code and redirect', async () => { 195 | const mockAuthInfo = { 196 | accessToken: 'valid-token', 197 | refreshToken: 'refresh-token', 198 | expiresIn: 3600, 199 | hubId: 'test-hub-id', 200 | portalId: 'test-hub-id' 201 | }; 202 | (redeemCode as jest.MockedFunction).mockResolvedValue(mockAuthInfo); 203 | 204 | const response = await request(server) 205 | .get('/oauth-callback') 206 | .query({ code: 'valid-code' }) 207 | .expect(302); 208 | 209 | expect(response.header.location).toMatch(/^http:\/\/localhost:/); 210 | }); 211 | 212 | it('should handle missing code parameter', async () => { 213 | const response = await request(server).get('/oauth-callback').expect(400); 214 | 215 | expect(response.body).toEqual({ 216 | message: 'Code parameter is missing in the query string.' 217 | }); 218 | }); 219 | 220 | it('should handle code redemption errors', async () => { 221 | (redeemCode as jest.MockedFunction).mockRejectedValue( 222 | new Error('Invalid code') 223 | ); 224 | 225 | const response = await request(server) 226 | .get('/oauth-callback') 227 | .query({ code: 'invalid-code' }) 228 | .expect(302); 229 | 230 | expect(response.header.location).toMatch(/errMessage=Invalid%20code/); 231 | }); 232 | }); 233 | 234 | describe('GET /initial-contacts-sync', () => { 235 | it('should perform initial sync successfully', async () => { 236 | const mockSyncResults = { 237 | imported: 15, 238 | skipped: 2, 239 | errors: [] 240 | }; 241 | (initialContactsSync as jest.MockedFunction).mockResolvedValue( 242 | mockSyncResults 243 | ); 244 | 245 | const response = await request(server) 246 | .get('/initial-contacts-sync') 247 | .expect(200); 248 | 249 | expect(response.body).toEqual(mockSyncResults); 250 | }); 251 | 252 | it('should handle sync errors', async () => { 253 | (initialContactsSync as jest.MockedFunction).mockRejectedValue( 254 | new Error('Sync failed') 255 | ); 256 | 257 | const response = await request(server) 258 | .get('/initial-contacts-sync') 259 | .expect(500); 260 | 261 | expect(response.body).toEqual({ 262 | message: 'An error occurred during the initial contacts sync.' 263 | }); 264 | }); 265 | }); 266 | }); 267 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "dist", 4 | "target": "ESNEXT" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */, 5 | "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */, 6 | "strict": true /* Enable all strict type-checking options. */, 7 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, 8 | "skipLibCheck": true /* Skip type checking of declaration files. */, 9 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */, 10 | "typeRoots": ["./types"] 11 | }, 12 | "ts-node": { "compilerOptions": { "typeRoots": ["./types"] }, "files": true } 13 | } 14 | --------------------------------------------------------------------------------