diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..c4be042 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,29 @@ +name: Trigger Meta Repo Update + +on: + pull_request: + branches: + - github-action-test # Instead of main + types: + - closed + +jobs: + trigger-meta-repo: + if: github.event.pull_request.merged == true + runs-on: ubuntu-latest + + steps: + - name: Send repository update event + run: | + curl -X POST -H "Authorization: token ${{ secrets.GH_PAT }}" \ + -H "Accept: application/vnd.github.v3+json" \ + https://api.github.com/repos/hubspotdev/CODE-Hub/actions/workflows/update-meta-repo.yml/dispatches \ + -d '{ + "ref": "main", + "inputs": { + "repo": "'"${{ github.repository }}"'", + "commit": "'"${{ github.sha }}"'", + "pr_title": "'"${{ github.event.pull_request.title }}"'", + "pr_url": "'"${{ github.event.pull_request.html_url }}"'" + } + }' diff --git a/.github/workflows/trigger-submodule-update.yml b/.github/workflows/trigger-submodule-update.yml new file mode 100644 index 0000000..36dd124 --- /dev/null +++ b/.github/workflows/trigger-submodule-update.yml @@ -0,0 +1,27 @@ +name: Trigger Meta Repo Update + +on: + pull_request: + types: + - closed + +jobs: + trigger-meta-repo: + if: github.event.pull_request.merged == true + runs-on: ubuntu-latest + + steps: + - name: Send repository update event + run: | + curl -X POST -H "Authorization: token ${{ secrets.GH_PAT }}" \ + -H "Accept: application/vnd.github.v3+json" \ + https://api.github.com/repos/hubspotdev/CODE-Hub/dispatches \ + -d '{ + "event_type": "update-meta-repo", + "client_payload": { + "repo": "'"${{ github.repository }}"'", + "commit": "'"${{ github.sha }}"'", + "pr_title": "'"${{ github.event.pull_request.title }}"'", + "pr_url": "'"${{ github.event.pull_request.html_url }}"'" + } + }' diff --git a/README.md b/README.md index eeb72a2..0c49aa4 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,18 @@ # CRM Object Sync -CRM Object Sync repository demonstrates best practices for syncing CRM contact records between HubSpot and external applications. +A demonstration of best integration practices for syncing CRM contact records between HubSpot and external applications for product management use cases. ## Table of Contents + - [What this project does](#what-this-project-does) -- [Why is this project useful](#why-is-this-project-useful) -- [Getting started with the project](#getting-started-with-the-project) - - [Setup](#setup) - - [Scopes](#scopes) +- [Why is this project useful?](#why-is-this-project-useful) +- [Setup](#setup) +- [Scopes](#scopes) - [Endpoints](#endpoints) - - [Authentication Endpoints](#authentication-endpoints) - - [Synchronization Endpoints](#synchronization-endpoints) + - [Authentication](#authentication) + - [Contact Management](#contact-management) - [Available Scripts](#available-scripts) +- [Project Structure](#project-structure) - [Dependencies](#dependencies) - [Core](#core) - [Development](#development) @@ -19,10 +20,9 @@ CRM Object Sync repository demonstrates best practices for syncing CRM contact r - [Who maintains and contributes to this project](#who-maintains-and-contributes-to-this-project) - [License](#license) - ## What this project does: -This CRM Object Sync repository offers guidelines and practical examples to help maintain data consistency and simplify management across multiple platforms. +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. ## Why is this project useful: @@ -32,102 +32,104 @@ This project demonstrates how to: - Create and seed a PostgreSQL database with contact records -- Sync of seeded contact records from the database to HubSpot, saving the generated `hs_object_id` back to the database +- Sync seeded contact records from the database to HubSpot, saving the generated hs_object_id back to the database - Sync contact records from HubSpot to the database: - - 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. - - - 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. - - ## Getting started with the project: - -### Setup: - -1. Download and install [PostgreSQL](https://www.postgresql.org/download/), make sure it's running, and create an empty database. You need the username and password (defaults username is postgres and no password) - -2. Clone the repo - -3. Create the .env file with these entries (see examples in the [.env.example](./.env.example) file): - - DATABASE_URL the (local) url to the postgres database (e.g. `postgresql://{username}:{password}@localhost:5432/{database name}` - - CLIENT_ID from Hubspot public app - - CLIENT_SECRET from Hubspot public app - -4. Run `npm install` to install the required Node packages. - -5. Run `npm run db-init` to create the necessary tables in PostgreSQL - -6. Optional: Run `npm run db-seed` to seed the database with test data - -7. In your [HubSpot public app](https://developers.hubspot.com/docs/api/creating-an-app), add `localhost:3000/oauth-callback` as a redirect URL, set the required scopes to be those in the [Scopes](#scopes) section down below - -8. Run `npm run dev` to start the server - -9. Visit `http://localhost:3000/api/install` in a browser to get the OAuth install link - -### Scopes - -- `crm.objects.contacts.read` - View properties and other details about contacts -- `crm.objects.contacts.write` - View properties and create, delete, and make changes to contacts -- `crm.objects.companies.read` - View properties and other details about companies -- `crm.objects.companies.write` - View properties and create, delete, or make changes to companies -- `crm.schemas.contacts.read` - View details about property settings for contacts. -- `crm.schemas.contacts.write` - Create, delete, or make changes to property settings for contacts -- `crm.schemas.companies.read` - View details about property settings for companies -- `crm.schemas.companies.write` - Create, delete, or make changes to property settings for companies -- `oauth` - Basic scope required for OAuth. This scope is added by default to all apps - -## Endpoints: -### Authentication Endpoints - -- `GET /api/install`: Sends a simple HTML response containing a link (authUrl) for users to authenticate. The link opens in a new tab when clicked. This should be the first step a new user or client performs to initiate the OAuth2 authorization process. - -- `GET /oauth-callback`: It processes the authorization code to obtain an access token for the user and any failure in retrieving it redirects with an error message. - -- `GET /` : Once authenticated, the access token can be retrieved using this endpoint. This ensures that any subsequent API operations requiring authentication can be performed. - -### Synchronization Endpoints - -- `GET /initial-contacts-sync`: After establishing authentication and obtaining an access token, the initial **synchronization of contacts from HubSpot to the local database** can occur. - -- `GET /contacts`: This endpoint fetches contacts from the local database. - -- `GET /sync-contacts`: This is used to **synchronize any updates or new contact data from the local database to HubSpot**. Email is used as a primary key for logical deduplication, making it crucial that email addresses are correctly managed and non-null where possible. To minimize errors, we first retrieve existing contacts from HubSpot and exclude those already known from our batch. The following methods are employed to send new contacts to HubSpot and to store their HubSpot object IDs back in our local database. + - 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. + + - 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. + +## Setup + +1. **Prerequisites** + + - Go to [HubSpot Developer Portal](https://developers.hubspot.com/) + - Create a new public app + - Configure the following scopes: + - `crm.objects.contacts.read` + - `crm.objects.contacts.write` + - `crm.objects.companies.read` + - `crm.objects.companies.write` + - `crm.schemas.contacts.read` + - `crm.schemas.contacts.write` + - `crm.schemas.companies.read` + - `crm.schemas.companies.write` + - Add `http://localhost:3001/oauth-callback` as a redirect URL + - Save your Client ID and Client Secret for the next steps + - Install [PostgreSQL](https://www.postgresql.org/download/) + - Create an empty database + - Have HubSpot app credentials ready + +2. **Install Dependencies** + +- 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) +- Clone the repo +- Create the .env file with these entries: + - DATABASE_URL the (local) url to the postgres database (e.g. postgresql://{username}:{password}@localhost:5432/{database name}) + - CLIENT_ID from Hubspot public app + - CLIENT_SECRET from Hubspot public app +- Run `npm install` to install the required Node packages. +- In your HubSpot public app, add `localhost:3001/api/install/oauth-callback` as a redirect URL + Run npm run dev to start the server + Visit http://localhost:3001/api/install in a browser to get the OAuth install link + -Run `npm run seed` to seed the database with test data, select an industry for the data examples + -Once the server is running, you can access the application and API documentation at http://localhost:3001/api-docs. + +## Endpoints + +### Authentication + +- `GET /api/install` - Returns installation page with HubSpot OAuth link +- `GET /oauth-callback` - Processes OAuth authorization code +- `GET /` - Retrieves access token for authenticated user + +### Contact Management + +- `GET /contacts` - Fetches contacts from local database +- `GET /initial-contacts-sync` - Syncs contacts from HubSpot to local database +- `GET /sync-contacts` - Syncs contacts from local database to HubSpot + - Uses email as primary key for deduplication + - Excludes existing HubSpot contacts from sync batch + +### Documentation + +- `GET /api-docs` - Returns API documentation + +## Scopes + +- `crm.schemas.companies.write` +- `crm.schemas.contacts.write` +- `crm.schemas.companies.read` +- `crm.schemas.contacts.read` +- `crm.objects.companies.write` +- `crm.objects.contacts.write` +- `crm.objects.companies.read` +- `crm.objects.contacts.read` ## Available Scripts - `npm run dev` - Start development server -- `npm run prod` - Run the production build -- `npm run build` - Build TypeScript files -- `npm run db-seed` - Seed the database -- `npm run db-init` - Initialize database schema -- `npm test` - Run test suite -- `npm run test:watch` - Run tests in watch mode +- `npm run db-init` - Initialize database tables +- `npm run db-seed` - Seed database with test data +- `npm test` - Run tests - `npm run test:coverage` - Generate test coverage report ## Dependencies ### Core -- @hubspot/api-client - HubSpot API integration -- @hubspot/cli-lib - HubSpot CLI tools -- @prisma/client - Database ORM -- express - Web framework -- dotenv - Environment configuration -- @ngrok/ngrok - Secure tunneling -- axios - HTTP client -- prompts - CLI prompts + +- Express +- Prisma +- PostgreSQL +- HubSpot Client Libraries ### Development -- typescript - Programming language -- jest - Testing framework -- prisma - Database toolkit -- nodemon - Development server -- supertest - API testing -- eslint - Code linting -- ts-node - TypeScript execution -- prettier - Code formatting -- ts-jest - TypeScript testing support +- Jest +- TypeScript +- ESLint +- Prettier ## Where to get help? diff --git a/dist/prisma/seed.js b/dist/prisma/seed.js index a5929f0..7239f67 100644 --- a/dist/prisma/seed.js +++ b/dist/prisma/seed.js @@ -1,38 +1,33 @@ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); +exports.main = void 0; +const faker_1 = require("@faker-js/faker"); const client_1 = require("@prisma/client"); -const contacts_1 = require("./contacts"); const prisma = new client_1.PrismaClient(); -async function main() { - for (let contact of contacts_1.contacts) { - await prisma.contacts.create({ - data: contact - }); - } -} -main().catch(e => { - console.log(e); - process.exit(1); -}).finally(() => { - prisma.$disconnect(); -}); /*Create dataset, mapping over an array*/ -// const data = Array.from({ length:100 }).map(() => ({ -// firstName: faker.person.firstName(), -// lastName: faker.person.lastName(), -// email: faker.internet.email() -// })); +const data = Array.from({ length: 1000 }).map(() => ({ + first_name: faker_1.faker.person.firstName(), + last_name: faker_1.faker.person.lastName(), + email: faker_1.faker.internet.email().toLowerCase() //normalize before adding to db +})); /*Run seed command and the function below inserts data in the database*/ -// async function main(){ -// await prisma.contacts.createMany({ -// data -// }); -// } -// main() -// .catch((e) => { -// console.log(e); -// process.exit(1) -// }) -// .finally(() => { -// prisma.$disconnect(); -// }) +async function main() { + console.log(`=== Generated ${data.length} contacts ===`); + await prisma.contacts.createMany({ + data, + skipDuplicates: true // fakerjs will repeat emails + }); +} +exports.main = main; +// Only run if this file is being executed directly +if (require.main === module) { + main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); +} +exports.default = prisma; diff --git a/dist/src/app.js b/dist/src/app.js index b80a51f..e1b433c 100644 --- a/dist/src/app.js +++ b/dist/src/app.js @@ -3,57 +3,121 @@ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); +exports.startServer = exports.server = exports.app = void 0; const express_1 = __importDefault(require("express")); -const client_1 = require("@prisma/client"); const auth_1 = require("./auth"); require("dotenv/config"); -const utils_1 = require("./utils"); -const prisma = new client_1.PrismaClient(); +const utils_1 = require("./utils/utils"); +const initialSyncFromHubSpot_1 = require("./initialSyncFromHubSpot"); +const initialSyncToHubSpot_1 = require("./initialSyncToHubSpot"); +const clients_1 = require("./clients"); +const error_1 = __importDefault(require("./utils/error")); +const logger_1 = require("./utils/logger"); +const swagger_ui_express_1 = __importDefault(require("swagger-ui-express")); +const swagger_1 = require("./swagger"); const app = (0, express_1.default)(); +exports.app = app; app.use(express_1.default.json()); app.use(express_1.default.urlencoded({ extended: true })); app.get('/contacts', async (req, res) => { - const prisma = new client_1.PrismaClient(); - const contacts = await prisma.contacts.findMany({}); - res.send(contacts); + try { + const contacts = await clients_1.prisma.contacts.findMany({}); + res.send(contacts); + } + catch (error) { + (0, error_1.default)(error, 'Error fetching contacts'); + res + .status(500) + .json({ message: 'An error occurred while fetching contacts.' }); + } +}); +app.get('/api/install', (req, res) => { + res.send(`
${auth_1.authUrl}`); +}); +app.get('/sync-contacts', async (req, res) => { + try { + const syncResults = await (0, initialSyncToHubSpot_1.syncContactsToHubSpot)(); + res.send(syncResults); + } + catch (error) { + (0, error_1.default)(error, 'Error syncing contacts'); + res + .status(500) + .json({ message: 'An error occurred while syncing contacts.' }); + } }); -app.get("/api/install", (req, res) => { - res.send(auth_1.authUrl); +app.get('/', async (req, res) => { + try { + const accessToken = await (0, auth_1.getAccessToken)((0, utils_1.getCustomerId)()); + res.send(accessToken); + } + catch (error) { + (0, error_1.default)(error, 'Error fetching access token'); + res + .status(500) + .json({ message: 'An error occurred while fetching the access token.' }); + } }); -app.get("/oauth-callback", async (req, res) => { +app.get('/oauth-callback', async (req, res) => { const code = req.query.code; if (code) { try { const authInfo = await (0, auth_1.redeemCode)(code.toString()); const accessToken = authInfo.accessToken; - res.redirect(`http://localhost:${utils_1.PORT - 1}/`); + logger_1.logger.info({ + type: 'HubSpot', + logMessage: { + message: 'OAuth complete!' + } + }); + res.redirect(`http://localhost:${utils_1.PORT}/`); } catch (error) { - res.redirect(`/?errMessage=${error.message}`); + (0, error_1.default)(error, 'Error redeeming code during OAuth'); + res.redirect(`/?errMessage=${error.message || 'An error occurred during the OAuth process.'}`); } } + else { + logger_1.logger.error({ + type: 'HubSpot', + logMessage: { + message: 'Error: code parameter is missing.' + } + }); + res + .status(400) + .json({ message: 'Code parameter is missing in the query string.' }); + } }); -// app.post("/addcontacts", async (req, res) => { -// try{ -// const {id, email, first_name, last_name, hs_object_id} = req.body -// const newContact = await prisma.contacts.create({ -// data: { -// id, -// email, -// first_name, -// last_name, -// hs_object_id -// } -// }) -// res.json(newContact) -// } -// catch (error:any) { -// console.log(error.message) -// res.status(500).json({ -// message: "Internal Server Error", -// }) -// } -// }) -app.listen(utils_1.PORT, function () { - console.log('App is listening on port ${port}'); +app.get('/initial-contacts-sync', async (req, res) => { + try { + const syncResults = await (0, initialSyncFromHubSpot_1.initialContactsSync)(); + res.send(syncResults); + } + catch (error) { + (0, error_1.default)(error, 'Error during initial contacts sync'); + res + .status(500) + .json({ message: 'An error occurred during the initial contacts sync.' }); + } }); +app.use('/api-docs', swagger_ui_express_1.default.serve, swagger_ui_express_1.default.setup(swagger_1.specs)); +let server = null; +exports.server = server; +function startServer() { + if (!server) { + exports.server = server = app.listen(utils_1.PORT, function (err) { + if (err) { + console.error('Error starting server:', err); + return; + } + console.log(`App is listening on port ${utils_1.PORT}`); + }); + } + return server; +} +exports.startServer = startServer; +// Start the server only if this file is run directly +if (require.main === module) { + startServer(); +} diff --git a/dist/src/auth.js b/dist/src/auth.js index b1b7324..6649b5b 100644 --- a/dist/src/auth.js +++ b/dist/src/auth.js @@ -1,51 +1,47 @@ "use strict"; -var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { - if (k2 === undefined) k2 = k; - var desc = Object.getOwnPropertyDescriptor(m, k); - if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { - desc = { enumerable: true, get: function() { return m[k]; } }; - } - Object.defineProperty(o, k2, desc); -}) : (function(o, m, k, k2) { - if (k2 === undefined) k2 = k; - o[k2] = m[k]; -})); -var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { - Object.defineProperty(o, "default", { enumerable: true, value: v }); -}) : function(o, v) { - o["default"] = v; -}); -var __importStar = (this && this.__importStar) || function (mod) { - if (mod && mod.__esModule) return mod; - var result = {}; - if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); - __setModuleDefault(result, mod); - return result; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); -exports.getAccessToken = exports.redeemCode = exports.exchangeForTokens = exports.authUrl = void 0; +exports.authenticateHubspotClient = exports.getAccessToken = exports.redeemCode = exports.exchangeForTokens = exports.authUrl = void 0; require("dotenv/config"); -const hubspot = __importStar(require("@hubspot/api-client")); const client_1 = require("@prisma/client"); -const utils_1 = require("./utils"); -const CLIENT_ID = process.env.CLIENT_ID || "CLIENT_ID required"; -const CLIENT_SECRET = process.env.CLIENT_SECRET || "CLIENT_SECRET required"; +const utils_1 = require("./utils/utils"); +const clients_1 = require("./clients"); +const error_1 = __importDefault(require("./utils/error")); +class MissingRequiredError extends Error { + constructor(message, options) { + message = message + 'is missing, please add it to your .env file'; + super(message, options); + } +} +const CLIENT_ID = process.env.CLIENT_ID; +const CLIENT_SECRET = process.env.CLIENT_SECRET; +if (!CLIENT_ID) { + throw new MissingRequiredError('CLIENT_ID ', undefined); +} +if (!CLIENT_SECRET) { + throw new MissingRequiredError('CLIENT_SECRET ', undefined); +} const REDIRECT_URI = `http://localhost:${utils_1.PORT}/oauth-callback`; const SCOPES = [ - "crm.schemas.companies.write", - "crm.schemas.contacts.write", - "crm.schemas.companies.read", - "crm.schemas.contacts.read", + 'crm.schemas.companies.write', + 'crm.schemas.contacts.write', + 'crm.schemas.companies.read', + 'crm.schemas.contacts.read', + 'crm.objects.companies.write', + 'crm.objects.contacts.write', + 'crm.objects.companies.read', + 'crm.objects.contacts.read' ]; const EXCHANGE_CONSTANTS = { redirect_uri: REDIRECT_URI, client_id: CLIENT_ID, - client_secret: CLIENT_SECRET, + client_secret: CLIENT_SECRET }; -const hubspotClient = new hubspot.Client(); const prisma = new client_1.PrismaClient(); -const scopeString = SCOPES.toString().replaceAll(",", " "); -const authUrl = hubspotClient.oauth.getAuthorizationUrl(CLIENT_ID, REDIRECT_URI, scopeString); +const scopeString = SCOPES.toString().replaceAll(',', ' '); +const authUrl = clients_1.hubspotClient.oauth.getAuthorizationUrl(CLIENT_ID, REDIRECT_URI, scopeString); exports.authUrl = authUrl; const getExpiresAt = (expiresIn) => { const now = new Date(); @@ -55,23 +51,23 @@ const redeemCode = async (code) => { return await exchangeForTokens({ ...EXCHANGE_CONSTANTS, code, - grant_type: "authorization_code", + grant_type: 'authorization_code' }); }; exports.redeemCode = redeemCode; const getHubSpotId = async (accessToken) => { - hubspotClient.setAccessToken(accessToken); - const hubspotAccountInfoResponse = await hubspotClient.apiRequest({ - path: "/account-info/v3/details", - method: "GET", + clients_1.hubspotClient.setAccessToken(accessToken); + const hubspotAccountInfoResponse = await clients_1.hubspotClient.apiRequest({ + path: '/account-info/v3/details', + method: 'GET' }); const hubspotAccountInfo = await hubspotAccountInfoResponse.json(); const hubSpotportalId = hubspotAccountInfo.portalId; return hubSpotportalId.toString(); }; const exchangeForTokens = async (exchangeProof) => { - const { code, redirect_uri, client_id, client_secret, grant_type, refresh_token, } = exchangeProof; - const tokenResponse = await hubspotClient.oauth.tokensApi.createToken(grant_type, code, redirect_uri, client_id, client_secret, refresh_token); + const { code, redirect_uri, client_id, client_secret, grant_type, refresh_token } = exchangeProof; + const tokenResponse = await clients_1.hubspotClient.oauth.tokensApi.create(grant_type, code, redirect_uri, client_id, client_secret, refresh_token); try { const accessToken = tokenResponse.accessToken; const refreshToken = tokenResponse.refreshToken; @@ -81,14 +77,14 @@ const exchangeForTokens = async (exchangeProof) => { const hsPortalId = await getHubSpotId(accessToken); const tokenInfo = await prisma.authorization.upsert({ where: { - customerId: customerId, + customerId: customerId }, update: { refreshToken, accessToken, expiresIn, expiresAt, - hsPortalId, + hsPortalId }, create: { refreshToken, @@ -96,8 +92,8 @@ const exchangeForTokens = async (exchangeProof) => { expiresIn, expiresAt, hsPortalId, - customerId, - }, + customerId + } }); return tokenInfo; } @@ -114,11 +110,11 @@ const getAccessToken = async (customerId) => { select: { accessToken: true, expiresAt: true, - refreshToken: true, + refreshToken: true }, where: { - customerId, - }, + customerId + } })); if (currentCreds?.expiresAt && currentCreds?.expiresAt > new Date()) { return currentCreds?.accessToken; @@ -126,8 +122,8 @@ const getAccessToken = async (customerId) => { else { const updatedCreds = await exchangeForTokens({ ...EXCHANGE_CONSTANTS, - grant_type: "refresh_token", - refresh_token: currentCreds?.refreshToken, + grant_type: 'refresh_token', + refresh_token: currentCreds?.refreshToken }); if (updatedCreds instanceof Error) { throw updatedCreds; @@ -143,3 +139,30 @@ const getAccessToken = async (customerId) => { } }; exports.getAccessToken = getAccessToken; +function applyHubSpotAccessToken(accessToken) { + try { + clients_1.hubspotClient.setAccessToken(accessToken); + return clients_1.hubspotClient; + } + catch (error) { + (0, error_1.default)(error, 'Error setting HubSpot access token'); + throw new Error(`Failed to apply access token: ${error instanceof Error ? error.message : 'Unknown error'}`); + } +} +async function authenticateHubspotClient() { + try { + const customerId = (0, utils_1.getCustomerId)(); + const accessToken = await getAccessToken(customerId); + if (!accessToken) { + throw new Error(`No access token returned for customer ID: ${customerId}`); + } + return applyHubSpotAccessToken(accessToken); + } + catch (error) { + (0, error_1.default)(error, 'Error retrieving HubSpot access token'); + throw error instanceof Error + ? new Error(`Failed to authenticate HubSpot client: ${error.message}`) + : new Error('Failed to authenticate HubSpot client due to an unknown error'); + } +} +exports.authenticateHubspotClient = authenticateHubspotClient; diff --git a/package-lock.json b/package-lock.json index b715597..dea6da0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,9 @@ "axios": "^1.4.0", "dotenv": "^16.1.4", "express": "^4.17.1", - "prompts": "^2.4.2" + "prompts": "^2.4.2", + "swagger-jsdoc": "^6.2.8", + "swagger-ui-express": "^5.0.1" }, "devDependencies": { "@faker-js/faker": "^8.4.0", @@ -25,6 +27,8 @@ "@types/node": "^14.18.63", "@types/prompts": "^2.4.4", "@types/supertest": "^6.0.2", + "@types/swagger-jsdoc": "^6.0.4", + "@types/swagger-ui-express": "^4.1.7", "@typescript-eslint/eslint-plugin": "^6.19.1", "@typescript-eslint/parser": "^6.19.1", "eslint": "^8.56.0", @@ -62,6 +66,50 @@ "node": ">=6.0.0" } }, + "node_modules/@apidevtools/json-schema-ref-parser": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-9.1.2.tgz", + "integrity": "sha512-r1w81DpR+KyRWd3f+rk6TNqMgedmAxZP5v5KWlXQWlgMUUtyEJch0DKEci1SorPMiSeM8XPl7MZ3miJ60JIpQg==", + "license": "MIT", + "dependencies": { + "@jsdevtools/ono": "^7.1.3", + "@types/json-schema": "^7.0.6", + "call-me-maybe": "^1.0.1", + "js-yaml": "^4.1.0" + } + }, + "node_modules/@apidevtools/openapi-schemas": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@apidevtools/openapi-schemas/-/openapi-schemas-2.1.0.tgz", + "integrity": "sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/@apidevtools/swagger-methods": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz", + "integrity": "sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==", + "license": "MIT" + }, + "node_modules/@apidevtools/swagger-parser": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-10.0.3.tgz", + "integrity": "sha512-sNiLY51vZOmSPFZA5TF35KZ2HbgYklQnTSDnkghamzLb3EkNtcQnrBQEj5AOCxHpTtXpqMCRM1CrmV2rG6nw4g==", + "license": "MIT", + "dependencies": { + "@apidevtools/json-schema-ref-parser": "^9.0.6", + "@apidevtools/openapi-schemas": "^2.0.4", + "@apidevtools/swagger-methods": "^3.0.2", + "@jsdevtools/ono": "^7.1.3", + "call-me-maybe": "^1.0.1", + "z-schema": "^5.0.1" + }, + "peerDependencies": { + "openapi-types": ">=7" + } + }, "node_modules/@babel/code-frame": { "version": "7.26.2", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", @@ -2997,6 +3045,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@jsdevtools/ono": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", + "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", + "license": "MIT" + }, "node_modules/@ngrok/ngrok": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/@ngrok/ngrok/-/ngrok-0.5.2.tgz", @@ -3335,6 +3389,13 @@ "@prisma/debug": "5.9.0" } }, + "node_modules/@scarf/scarf": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", + "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", + "hasInstallScript": true, + "license": "Apache-2.0" + }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -3539,8 +3600,7 @@ "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==" }, "node_modules/@types/methods": { "version": "1.1.4", @@ -3688,6 +3748,24 @@ "@types/superagent": "^8.1.0" } }, + "node_modules/@types/swagger-jsdoc": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/swagger-jsdoc/-/swagger-jsdoc-6.0.4.tgz", + "integrity": "sha512-W+Xw5epcOZrF/AooUM/PccNMSAFOKWZA5dasNyMujTwsBkU74njSJBpvCCJhHAJ95XRMzQrrW844Btu0uoetwQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/swagger-ui-express": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@types/swagger-ui-express/-/swagger-ui-express-4.1.7.tgz", + "integrity": "sha512-ovLM9dNincXkzH4YwyYpll75vhzPBlWx6La89wwvYH7mHjVpf0X0K/vR/aUM7SRxmr5tt9z7E5XJcjQ46q+S3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/serve-static": "*" + } + }, "node_modules/@types/yargs": { "version": "17.0.33", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", @@ -4842,6 +4920,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/call-me-maybe": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz", + "integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==", + "license": "MIT" + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -5071,6 +5155,15 @@ "node": ">= 0.8" } }, + "node_modules/commander": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.0.tgz", + "integrity": "sha512-zP4jEKbe8SHzKJYQmq8Y9gYjtO/POJLgIdKgV7B9qNmABVFVc+ctqSX6iXh4mCpJfRBOabiZ2YKPg8ciDw6C+Q==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/component-emitter": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", @@ -5501,7 +5594,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", - "dev": true, "dependencies": { "esutils": "^2.0.2" }, @@ -10812,6 +10904,13 @@ "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==" }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -10824,6 +10923,12 @@ "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==" }, + "node_modules/lodash.mergewith": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz", + "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==", + "license": "MIT" + }, "node_modules/lodash.truncate": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", @@ -11501,6 +11606,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/openapi-types": { + "version": "12.1.3", + "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", + "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==", + "license": "MIT", + "peer": true + }, "node_modules/optionator": { "version": "0.9.3", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", @@ -13367,6 +13479,83 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/swagger-jsdoc": { + "version": "6.2.8", + "resolved": "https://registry.npmjs.org/swagger-jsdoc/-/swagger-jsdoc-6.2.8.tgz", + "integrity": "sha512-VPvil1+JRpmJ55CgAtn8DIcpBs0bL5L3q5bVQvF4tAW/k/9JYSj7dCpaYCAv5rufe0vcCbBRQXGvzpkWjvLklQ==", + "license": "MIT", + "dependencies": { + "commander": "6.2.0", + "doctrine": "3.0.0", + "glob": "7.1.6", + "lodash.mergewith": "^4.6.2", + "swagger-parser": "^10.0.3", + "yaml": "2.0.0-1" + }, + "bin": { + "swagger-jsdoc": "bin/swagger-jsdoc.js" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/swagger-jsdoc/node_modules/glob": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", + "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/swagger-parser": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/swagger-parser/-/swagger-parser-10.0.3.tgz", + "integrity": "sha512-nF7oMeL4KypldrQhac8RyHerJeGPD1p2xDh900GPvc+Nk7nWP6jX2FcC7WmkinMoAmoO774+AFXcWsW8gMWEIg==", + "license": "MIT", + "dependencies": { + "@apidevtools/swagger-parser": "10.0.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/swagger-ui-dist": { + "version": "5.18.3", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.18.3.tgz", + "integrity": "sha512-G33HFW0iFNStfY2x6QXO2JYVMrFruc8AZRX0U/L71aA7WeWfX2E5Nm8E/tsipSZJeIZZbSjUDeynLK/wcuNWIw==", + "license": "Apache-2.0", + "dependencies": { + "@scarf/scarf": "=1.4.0" + } + }, + "node_modules/swagger-ui-express": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-5.0.1.tgz", + "integrity": "sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==", + "license": "MIT", + "dependencies": { + "swagger-ui-dist": ">=5.0.0" + }, + "engines": { + "node": ">= v0.10.32" + }, + "peerDependencies": { + "express": ">=4.0.0 || >=5.0.0-beta" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -14021,6 +14210,15 @@ "spdx-expression-parse": "^3.0.0" } }, + "node_modules/validator": { + "version": "13.12.0", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.12.0.tgz", + "integrity": "sha512-c1Q0mCiPlgdTVVVIJIrBuxNicYE+t/7oKeI9MWLj3fh/uq2Pxh/3eeWbVZ4OcGW1TUf53At0njHw5SMdA3tmMg==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -14258,6 +14456,15 @@ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "license": "ISC" }, + "node_modules/yaml": { + "version": "2.0.0-1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.0.0-1.tgz", + "integrity": "sha512-W7h5dEhywMKenDJh2iX/LABkbFnBxasD27oyXWDS/feDsxiw0dD5ncXdYXgkvAsXIY2MpW/ZKkr9IU30DBdMNQ==", + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", @@ -14316,6 +14523,36 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/z-schema": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/z-schema/-/z-schema-5.0.5.tgz", + "integrity": "sha512-D7eujBWkLa3p2sIpJA0d1pr7es+a7m0vFAnZLlCEKq/Ij2k0MLi9Br2UPxoxdYystm5K1yeBGzub0FlYUEWj2Q==", + "license": "MIT", + "dependencies": { + "lodash.get": "^4.4.2", + "lodash.isequal": "^4.5.0", + "validator": "^13.7.0" + }, + "bin": { + "z-schema": "bin/z-schema" + }, + "engines": { + "node": ">=8.0.0" + }, + "optionalDependencies": { + "commander": "^9.4.1" + } + }, + "node_modules/z-schema/node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": "^12.20.0 || >=14" + } } } } diff --git a/package.json b/package.json index 8b6f7f6..1b9777e 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,8 @@ "axios": "^1.4.0", "dotenv": "^16.1.4", "express": "^4.17.1", - "prompts": "^2.4.2" + "prompts": "^2.4.2", + "swagger-jsdoc": "^6.2.8" }, "devDependencies": { "@faker-js/faker": "^8.4.0", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7d98b4e..30ac5f0 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -29,7 +29,7 @@ model Companies { model Authorization { customerId String @id @db.VarChar(255) hsPortalId String @db.VarChar(255) - accessToken String @db.VarChar(255) + accessToken String @db.VarChar(512) refreshToken String @db.VarChar(255) expiresIn Int? expiresAt DateTime? @db.Timestamp(6) diff --git a/src/app.ts b/src/app.ts index 0eb40c8..5f34914 100644 --- a/src/app.ts +++ b/src/app.ts @@ -8,6 +8,8 @@ import { prisma } from './clients'; import handleError from './utils/error'; import { logger } from './utils/logger'; import { Server } from 'http'; +import swaggerUi from 'swagger-ui-express'; +import { specs } from './swagger'; const app: Application = express(); app.use(express.json()); @@ -100,6 +102,8 @@ app.get('/initial-contacts-sync', async (req: Request, res: Response) => { } }); +app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(specs)); + let server: Server | null = null; function startServer() { diff --git a/src/auth.ts b/src/auth.ts index 4b5bf88..8fd4895 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -3,6 +3,8 @@ import 'dotenv/config'; import { Authorization, PrismaClient } from '@prisma/client'; import { PORT, getCustomerId } from './utils/utils'; import { hubspotClient } from './clients'; +import { Client } from '@hubspot/api-client'; +import handleError from './utils/error'; interface ExchangeProof { grant_type: string; @@ -175,4 +177,42 @@ const getAccessToken = async (customerId: string): Promise