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