diff --git a/packages/api/.env.example b/packages/api/.env.example index b4be89b1e..48540c9b6 100644 --- a/packages/api/.env.example +++ b/packages/api/.env.example @@ -1,4 +1,24 @@ ENV=dev +PROD_DISTRIBUTION=managed # could be self-host if you want to run it locally DATABASE_URL= JWT_SECRET="SECRET" -POSTGRES_HOST= \ No newline at end of file +POSTGRES_HOST= + +# Sentry for logging errors +SENTRY_DSN=your_sentry_dsn_here + +# INTEGRATIONS PROVIDER CREDENTIALS FOR PANORA +# CRM +HUBSPOT_CLIENT_ID= +HUBSPOT_CLIENT_SECRET= +ZOHOCRM_CLIENT_ID= +ZOHOCRM_CLIENT_SECRET= +PIPEDRIVE_CLIENT_ID= +PIPEDRIVE_CLIENT_SECRET= +FRESHSALES_CLIENT_ID= +FRESHSALES_CLIENT_SECRET= +ZENDESK_CLIENT_ID= +ZENDESK_CLIENT_SECRET= + + +OAUTH_REDIRECT_BASE='http://localhost:3000' \ No newline at end of file diff --git a/packages/api/package.json b/packages/api/package.json index 5fc295e45..b75d51990 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -28,8 +28,11 @@ "@nestjs/mapped-types": "*", "@nestjs/passport": "^10.0.2", "@nestjs/platform-express": "^10.0.0", + "@nestjs/schedule": "^4.0.0", "@nestjs/swagger": "^7.1.14", "@prisma/client": "^5.4.2", + "@sentry/node": "^7.80.0", + "@sentry/tracing": "^7.80.0", "axios": "^1.5.1", "bcrypt": "^5.1.1", "crypto": "^1.0.1", diff --git a/packages/api/prisma/schema.prisma b/packages/api/prisma/schema.prisma index a50392aff..8c434c3b5 100644 --- a/packages/api/prisma/schema.prisma +++ b/packages/api/prisma/schema.prisma @@ -10,13 +10,13 @@ datasource db { model api_keys { id_api_key BigInt @id(map: "id_") @default(autoincrement()) api_key_hash String @unique(map: "unique_api_keys") - id_project Int id_user Int + id_project BigInt projects projects @relation(fields: [id_project], references: [id_project], onDelete: NoAction, onUpdate: NoAction, map: "fk_7") users users @relation(fields: [id_user], references: [id_user], onDelete: NoAction, onUpdate: NoAction, map: "fk_8") - @@index([id_project], map: "fk_1") @@index([id_user], map: "fk_2") + @@index([id_project], map: "fk_api_keys_projects") } model crm_contact_email_addresses { @@ -81,11 +81,13 @@ model organizations { } model projects { - id_project Int @id(map: "pk_projects") @default(autoincrement()) + id_project BigInt @id(map: "pk_projects") @default(autoincrement()) name String id_organization BigInt api_keys api_keys[] - organizations organizations @relation(fields: [id_organization], references: [id_organization], onDelete: NoAction, onUpdate: NoAction, map: "fk_6") + connections connections[] + linked_users linked_users[] + organizations organizations @relation(fields: [id_organization], references: [id_organization], onDelete: NoAction, onUpdate: NoAction, map: "fk_6") @@index([id_organization], map: "fk_1_projects") } @@ -105,3 +107,36 @@ model users { @@index([id_organization], map: "fk_1_users") } + +/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments +model connections { + id_connection BigInt @id(map: "pk_connections") @default(autoincrement()) + provider_slug String + account_url String? + token_type String + access_token String? + refresh_token String? + expiration_timestamp DateTime? @db.Timestamp(6) + created_at DateTime @db.Timestamp(6) + id_project BigInt + id_linked_user BigInt + linked_users linked_users @relation(fields: [id_linked_user], references: [id_linked_user], onDelete: NoAction, onUpdate: NoAction, map: "fk_11") + projects projects @relation(fields: [id_project], references: [id_project], onDelete: NoAction, onUpdate: NoAction, map: "fk_9") + + @@unique([access_token, refresh_token], map: "index_3") + @@index([id_project], map: "fk_1") + @@index([id_linked_user], map: "fk_connections_to_linkedusersid") +} + +/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments +model linked_users { + id_linked_user BigInt @id(map: "key_id_linked_users") @default(autoincrement()) + linked_user_origin_id String + alias String + status String + id_project BigInt + connections connections[] + projects projects @relation(fields: [id_project], references: [id_project], onDelete: NoAction, onUpdate: NoAction, map: "fk_10") + + @@index([id_project], map: "fk_proectid_linked_users") +} diff --git a/packages/api/src/@core/connections/connections.controller.spec.ts b/packages/api/src/@core/connections/connections.controller.spec.ts new file mode 100644 index 000000000..b3fdb3a29 --- /dev/null +++ b/packages/api/src/@core/connections/connections.controller.spec.ts @@ -0,0 +1,20 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ConnectionsController } from './connections.controller'; +import { ConnectionsService } from './services/connections.service'; + +describe('ConnectionsController', () => { + let controller: ConnectionsController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [ConnectionsController], + providers: [ConnectionsService], + }).compile(); + + controller = module.get(ConnectionsController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/packages/api/src/@core/connections/connections.controller.ts b/packages/api/src/@core/connections/connections.controller.ts new file mode 100644 index 000000000..9b2a920e6 --- /dev/null +++ b/packages/api/src/@core/connections/connections.controller.ts @@ -0,0 +1,30 @@ +import { Controller, Get, Query, Res } from '@nestjs/common'; +import { ConnectionsService } from './services/connections.service'; +import { Response } from 'express'; // Importing the Express Response type for type checking + +@Controller('connections') +export class ConnectionsController { + constructor(private readonly connectionsService: ConnectionsService) {} + + @Get('oauth/crm/callback') + handleCRMCallback( + @Res() res: Response, + @Query('projectId') projectId: string, + @Query('linkedUserId') linkedUserId: string, + @Query('providerName') providerName: string, + @Query('returnUrl') returnUrl: string, + @Query('code') code: string, + @Query('accountURL') zohoAccountURL?: string, + ) { + //TODO; ADD VERIFICATION OF PARAMS + + this.connectionsService.handleCRMCallBack( + projectId, + linkedUserId, + providerName, + code, + zohoAccountURL, + ); + res.redirect(returnUrl); + } +} diff --git a/packages/api/src/@core/connections/connections.module.ts b/packages/api/src/@core/connections/connections.module.ts new file mode 100644 index 000000000..3ce9d156b --- /dev/null +++ b/packages/api/src/@core/connections/connections.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { ConnectionsService } from './services/connections.service'; +import { ConnectionsController } from './connections.controller'; +import { CrmConnectionsService } from './services/crm/crm-connection.service'; +import { PrismaService } from '../prisma/prisma.service'; + +@Module({ + controllers: [ConnectionsController], + providers: [ConnectionsService, CrmConnectionsService, PrismaService], +}) +export class ConnectionsModule {} diff --git a/packages/api/src/@core/connections/dto/create-connection.dto.ts b/packages/api/src/@core/connections/dto/create-connection.dto.ts new file mode 100644 index 000000000..b77127c76 --- /dev/null +++ b/packages/api/src/@core/connections/dto/create-connection.dto.ts @@ -0,0 +1,24 @@ +class BaseConnectionDto { + customerId: string; + providerName: string; +} + +interface OAuth { + projectId: string; + returnUrl: string; +} + +interface ApiKey { + apiKey: string; +} + +interface AccessKeys { + accessKeyId: string; + secretAccessKey: string; +} + +export type CreateConnectionDto = BaseConnectionDto & T; + +export type CreateConnectionDtoOauth = CreateConnectionDto; +export type CreateConnectionDtoApiKey = CreateConnectionDto; +export type CreateConnectionDtoAccessKeys = CreateConnectionDto; diff --git a/packages/api/src/@core/connections/entities/connection.entity.ts b/packages/api/src/@core/connections/entities/connection.entity.ts new file mode 100644 index 000000000..31d07a5b4 --- /dev/null +++ b/packages/api/src/@core/connections/entities/connection.entity.ts @@ -0,0 +1 @@ +export class Connection {} diff --git a/packages/api/src/@core/connections/services/connections.service.spec.ts b/packages/api/src/@core/connections/services/connections.service.spec.ts new file mode 100644 index 000000000..bf464983c --- /dev/null +++ b/packages/api/src/@core/connections/services/connections.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ConnectionsService } from './connections.service'; + +describe('ConnectionsService', () => { + let service: ConnectionsService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ConnectionsService], + }).compile(); + + service = module.get(ConnectionsService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/packages/api/src/@core/connections/services/connections.service.ts b/packages/api/src/@core/connections/services/connections.service.ts new file mode 100644 index 000000000..7262297a5 --- /dev/null +++ b/packages/api/src/@core/connections/services/connections.service.ts @@ -0,0 +1,125 @@ +import { Injectable } from '@nestjs/common'; +import { CrmConnectionsService } from './crm/crm-connection.service'; +import { NotFoundError } from 'src/@core/utils/errors'; + +@Injectable() +export class ConnectionsService { + //STEP 1:[FRONTEND STEP] + //create a frontend SDK snippet in which an authorization embedded link is set up so when users click + // on it to grant access => they grant US the access and then when confirmed + /*const authUrl = + 'https://app.hubspot.com/oauth/authorize' + + `?client_id=${encodeURIComponent(CLIENT_ID)}` + + `&scope=${encodeURIComponent(SCOPES)}` + + `&redirect_uri=${encodeURIComponent(REDIRECT_URI)}`;*/ //oauth/callback + + // oauth server calls this redirect callback + // WE WOULD HAVE CREATED A DEV ACCOUNT IN THE 5 CRMs (Panora dev account) + // we catch the tmp token and swap it against oauth2 server for access/refresh tokens + // to perform actions on his behalf + // this call pass 1. integrationID 2. CustomerId 3. Panora Api Key + constructor(private crmConnectionService: CrmConnectionsService) {} + + async handleCRMCallBack( + projectId: string, + linkedUserId: string, + providerName: string, + code: string, + zohoAccountURL?: string, + ) { + try { + switch (providerName) { + case 'hubspot': + if (!code) { + throw new NotFoundError('no hubspot code found'); + } + return this.crmConnectionService.handleHubspotCallback( + linkedUserId, + projectId, + code, + ); + case 'zoho': + if (!code || !zohoAccountURL) { + throw new NotFoundError('no zoho code/ zoho AccountURL found'); + } + return this.crmConnectionService.handleZohoCallback( + linkedUserId, + projectId, + code, + zohoAccountURL, + ); + case 'pipedrive': + if (!code) { + throw new NotFoundError('no pipedrive code found'); + } + return this.crmConnectionService.handlePipedriveCallback( + linkedUserId, + projectId, + code, + ); + case 'freshsales': + //todo: LATER + break; + case 'zendesk': + if (!code) { + throw new NotFoundError('no zendesk code found'); + } + return this.crmConnectionService.handleZendeskCallback( + linkedUserId, + projectId, + code, + ); + default: + return; + } + } catch (error) { + if (error instanceof NotFoundError) { + console.log(error); + } + return error; + } + } + + async handleCRMTokensRefresh( + connectionId: bigint, + providerId: string, + refresh_token: string, + account_url?: string, + ) { + try { + switch (providerId) { + case 'hubspot': + return this.crmConnectionService.handleHubspotTokenRefresh( + connectionId, + refresh_token, + ); + case 'zoho': + return this.crmConnectionService.handleZohoTokenRefresh( + connectionId, + refresh_token, + account_url, + ); + case 'pipedrive': + return this.crmConnectionService.handlePipedriveTokenRefresh( + connectionId, + refresh_token, + ); + case 'freshsales': + //todo: LATER + break; + case 'zendesk': + return this.crmConnectionService.handleZendeskTokenRefresh( + connectionId, + refresh_token, + ); + default: + return; + } + } catch (error) { + if (error instanceof NotFoundError) { + console.log(error); + } + return error; + } + } +} diff --git a/packages/api/src/@core/connections/services/crm/crm-connection.service.ts b/packages/api/src/@core/connections/services/crm/crm-connection.service.ts new file mode 100644 index 000000000..cee6644b1 --- /dev/null +++ b/packages/api/src/@core/connections/services/crm/crm-connection.service.ts @@ -0,0 +1,432 @@ +import { Injectable } from '@nestjs/common'; +import axios from 'axios'; +import { + HubspotOAuthResponse, + PipeDriveOAuthResponse, + ZendeskOAuthResponse, + ZohoOAuthResponse, +} from './types'; +import { PrismaService } from 'src/@core/prisma/prisma.service'; +import config from 'src/@core/utils/config'; +import { Prisma } from '@prisma/client'; + +@Injectable() +export class CrmConnectionsService { + constructor(private prisma: PrismaService) {} + + async handleHubspotCallback( + linkedUserId: string, + projectId: string, + code: string, + ) { + try { + //first create a linked_user + //reconstruct the redirect URI that was passed in the frontend it must be the same + const REDIRECT_URI = `${config.OAUTH_REDIRECT_BASE}/oauth/crm/callback`; //tocheck + + const formData = { + grant_type: 'authorization_code', + client_id: config.HUBSPOT_CLIENT_ID, + client_secret: config.HUBSPOT_CLIENT_SECRET, + redirect_uri: REDIRECT_URI, + code: code, + }; + const res = await axios.post( + 'https://api.hubapi.com/oauth/v1/token', + formData, + ); + const data: HubspotOAuthResponse = res.data; + console.log('OAuth credentials : hubspot ', data); + // save tokens for this customer inside our db + //TODO: encrypt the access token and refresh tokens + const db_res = await this.prisma.connections.create({ + data: { + provider_slug: 'hubspot', + token_type: 'oauth', + access_token: data.access_token, + refresh_token: data.refresh_token, + expiration_timestamp: new Date( + new Date().getTime() + data.expires_in * 1000, + ), + created_at: new Date(), + projects: { + connect: { id_project: BigInt(projectId) }, + }, + linked_users: { + connect: { id_linked_user: BigInt(linkedUserId) }, + }, + //id of the end-customer defined in the company application, this is how requests could be made on behlaf of the user + // without it, we cant retrieve the right row in our db + }, + }); + } catch (error) { + if (axios.isAxiosError(error)) { + // Handle Axios-specific errors + console.error('Error with Axios request:', error.response?.data); + } + if (error instanceof Prisma.PrismaClientKnownRequestError) { + console.error('Error with Prisma request:', error); + } + console.log(error); + } + } + + async handleZohoCallback( + linkedUserId: string, + projectId: string, + code: string, + accountURL: string, + ) { + try { + //reconstruct the redirect URI that was passed in the frontend it must be the same + const REDIRECT_URI = `${config.OAUTH_REDIRECT_BASE}/oauth/crm/callback`; // TODO; + + const formData = { + grant_type: 'authorization_code', + client_id: config.ZOHOCRM_CLIENT_ID, + client_secret: config.ZOHOCRM_CLIENT_SECRET, + redirect_uri: REDIRECT_URI, + code: code, + }; + const res = await axios.post( + `https://${accountURL}/oauth/v2/token`, + formData, + ); + const data: ZohoOAuthResponse = res.data; + console.log('OAuth credentials : zoho ', data); + //TODO: encrypt the access token and refresh tokens + const db_res = await this.prisma.connections.create({ + data: { + provider_slug: 'zoho', + token_type: 'oauth', + access_token: data.access_token, + refresh_token: data.refresh_token, + expiration_timestamp: new Date( + new Date().getTime() + data.expires_in * 1000, + ), + created_at: new Date(), + projects: { + connect: { id_project: BigInt(projectId) }, + }, + linked_users: { + connect: { id_linked_user: BigInt(linkedUserId) }, + }, + account_url: accountURL, + }, + }); + } catch (error) { + if (axios.isAxiosError(error)) { + // Handle Axios-specific errors + console.error('Error with Axios request:', error.response?.data); + } + if (error instanceof Prisma.PrismaClientKnownRequestError) { + console.error('Error with Prisma request:', error); + } + console.log(error); + } + } + + async handlePipedriveCallback( + linkedUserId: string, + projectId: string, + code: string, + ) { + try { + //reconstruct the redirect URI that was passed in the frontend it must be the same + const REDIRECT_URI = `${config.OAUTH_REDIRECT_BASE}/oauth/crm/callback`; // TODO; + + const formData = { + grant_type: 'authorization_code', + redirect_uri: REDIRECT_URI, + code: code, + }; + const res = await axios.post( + 'https://oauth.pipedrive.com/oauth/token', + formData, + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded;charset=utf-8', + Authorization: `Basic ${Buffer.from( + `${config.PIPEDRIVE_CLIENT_ID}:${config.PIPEDRIVE_CLIENT_SECRET}`, + ).toString('base64')}`, + }, + }, + ); + //TODO: handle if res throws an error + const data: PipeDriveOAuthResponse = res.data; + console.log('OAuth credentials : pipedrive ', data); + // save tokens for this customer inside our db + //TODO: encrypt the access token and refresh tokens + const db_res = await this.prisma.connections.create({ + data: { + provider_slug: 'pipedrive', + token_type: 'oauth', + access_token: data.access_token, + refresh_token: data.refresh_token, + expiration_timestamp: new Date( + new Date().getTime() + data.expires_in * 1000, + ), + created_at: new Date(), + projects: { + connect: { id_project: BigInt(projectId) }, + }, + linked_users: { + connect: { id_linked_user: BigInt(linkedUserId) }, + }, + }, + }); + } catch (error) { + if (axios.isAxiosError(error)) { + // Handle Axios-specific errors + console.error('Error with Axios request:', error.response?.data); + } + if (error instanceof Prisma.PrismaClientKnownRequestError) { + console.error('Error with Prisma request:', error); + } + console.log(error); + } + } + + //TODO: later + async handleFreshsalesCallback() { + return; + } + + async handleZendeskCallback( + linkedUserId: string, + projectId: string, + code: string, + ) { + try { + //reconstruct the redirect URI that was passed in the frontend it must be the same + const REDIRECT_URI = `${config.OAUTH_REDIRECT_BASE}/oauth/crm/callback`; // TODO; + + const formData = { + grant_type: 'authorization_code', + redirect_uri: REDIRECT_URI, + code: code, + }; + const res = await axios.post( + 'https://api.getbase.com/oauth2/token', + formData, + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded;charset=utf-8', + Authorization: `Basic ${Buffer.from( + `${config.ZENDESK_CLIENT_ID}:${config.ZENDESK_CLIENT_SECRET}`, + ).toString('base64')}`, + }, + }, + ); + //TODO: handle if res throws an error + const data: ZendeskOAuthResponse = res.data; + console.log('OAuth credentials : zendesk ', data); + // save tokens for this customer inside our db + //TODO: encrypt the access token and refresh tokens + const db_res = await this.prisma.connections.create({ + data: { + provider_slug: 'zendesk', + token_type: 'oauth', + access_token: data.access_token, + refresh_token: data.refresh_token, + expiration_timestamp: new Date( + new Date().getTime() + data.expires_in * 1000, + ), + created_at: new Date(), + projects: { + connect: { id_project: BigInt(projectId) }, + }, + linked_users: { + connect: { id_linked_user: BigInt(linkedUserId) }, + }, + }, + }); + } catch (error) { + if (axios.isAxiosError(error)) { + // Handle Axios-specific errors + console.error('Error with Axios request:', error.response?.data); + } + if (error instanceof Prisma.PrismaClientKnownRequestError) { + console.error('Error with Prisma request:', error); + } + console.log(error); + } + } + + async handleHubspotTokenRefresh(connectionId: bigint, refresh_token: string) { + try { + const REDIRECT_URI = `${config.OAUTH_REDIRECT_BASE}/oauth/crm/callback`; + + const formData = { + grant_type: 'refresh_token', + client_id: config.HUBSPOT_CLIENT_ID, + client_secret: config.HUBSPOT_CLIENT_SECRET, + redirect_uri: REDIRECT_URI, + refresh_token: refresh_token, + }; + const res = await axios.post( + 'https://api.hubapi.com/oauth/v1/token', + formData, + ); + const data: HubspotOAuthResponse = res.data; + await this.prisma.connections.update({ + where: { + id_connection: connectionId, + }, + data: { + access_token: data.access_token, + refresh_token: data.refresh_token, + expiration_timestamp: new Date( + new Date().getTime() + data.expires_in * 1000, + ), + }, + }); + console.log('OAuth credentials updated : hubspot ', data); + } catch (error) { + if (axios.isAxiosError(error)) { + // Handle Axios-specific errors + console.error('Error with Axios request:', error.response?.data); + } + if (error instanceof Prisma.PrismaClientKnownRequestError) { + console.error('Error with Prisma request:', error); + } + console.log(error); + } + } + + async handlePipedriveTokenRefresh( + connectionId: bigint, + refresh_token: string, + ) { + try { + const REDIRECT_URI = `${config.OAUTH_REDIRECT_BASE}/oauth/crm/callback`; + + const formData = { + grant_type: 'refresh_token', + redirect_uri: REDIRECT_URI, + refresh_token: refresh_token, + }; + const res = await axios.post( + 'https://oauth.pipedrive.com/oauth/token', + formData, + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded;charset=utf-8', + Authorization: `Basic ${Buffer.from( + `${config.PIPEDRIVE_CLIENT_ID}:${config.PIPEDRIVE_CLIENT_SECRET}`, + ).toString('base64')}`, + }, + }, + ); + const data: HubspotOAuthResponse = res.data; + await this.prisma.connections.update({ + where: { + id_connection: connectionId, + }, + data: { + access_token: data.access_token, + refresh_token: data.refresh_token, + expiration_timestamp: new Date( + new Date().getTime() + data.expires_in * 1000, + ), + }, + }); + console.log('OAuth credentials updated : pipedrive ', data); + } catch (error) { + if (axios.isAxiosError(error)) { + // Handle Axios-specific errors + console.error('Error with Axios request:', error.response?.data); + } + if (error instanceof Prisma.PrismaClientKnownRequestError) { + console.error('Error with Prisma request:', error); + } + console.log(error); + } + } + + async handleZohoTokenRefresh( + connectionId: bigint, + refresh_token: string, + account_url: string, + ) { + try { + const REDIRECT_URI = `${config.OAUTH_REDIRECT_BASE}/oauth/crm/callback`; + + const formData = { + grant_type: 'refresh_token', + client_id: config.HUBSPOT_CLIENT_ID, + client_secret: config.HUBSPOT_CLIENT_SECRET, + redirect_uri: REDIRECT_URI, + refresh_token: refresh_token, + }; + const res = await axios.post(`${account_url}/oauth/v2/token`, formData); + const data: ZohoOAuthResponse = res.data; + await this.prisma.connections.update({ + where: { + id_connection: connectionId, + }, + data: { + access_token: data.access_token, + refresh_token: data.refresh_token, + expiration_timestamp: new Date( + new Date().getTime() + data.expires_in * 1000, + ), + }, + }); + console.log('OAuth credentials updated : zoho ', data); + } catch (error) { + if (axios.isAxiosError(error)) { + // Handle Axios-specific errors + console.error('Error with Axios request:', error.response?.data); + } + if (error instanceof Prisma.PrismaClientKnownRequestError) { + console.error('Error with Prisma request:', error); + } + console.log(error); + } + } + + async handleZendeskTokenRefresh(connectionId: bigint, refresh_token: string) { + try { + const formData = { + grant_type: 'refresh_token', + refresh_token: refresh_token, + }; + const res = await axios.post( + 'https://api.getbase.com/oauth2/token', + formData, + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded;charset=utf-8', + Authorization: `Basic ${Buffer.from( + `${config.ZENDESK_CLIENT_ID}:${config.ZENDESK_CLIENT_SECRET}`, + ).toString('base64')}`, + }, + }, + ); + const data: ZendeskOAuthResponse = res.data; + await this.prisma.connections.update({ + where: { + id_connection: connectionId, + }, + data: { + access_token: data.access_token, + refresh_token: data.refresh_token, + expiration_timestamp: new Date( + new Date().getTime() + data.expires_in * 1000, + ), + }, + }); + console.log('OAuth credentials updated : zendesk ', data); + } catch (error) { + if (axios.isAxiosError(error)) { + // Handle Axios-specific errors + console.error('Error with Axios request:', error.response?.data); + } + if (error instanceof Prisma.PrismaClientKnownRequestError) { + console.error('Error with Prisma request:', error); + } + console.log(error); + } + } +} diff --git a/packages/api/src/@core/connections/services/crm/types.ts b/packages/api/src/@core/connections/services/crm/types.ts new file mode 100644 index 000000000..1962ca428 --- /dev/null +++ b/packages/api/src/@core/connections/services/crm/types.ts @@ -0,0 +1,29 @@ +export interface HubspotOAuthResponse { + refresh_token: string; + access_token: string; + expires_in: number; +} +export interface ZohoOAuthResponse { + access_token: string; + refresh_token: string; + api_domain: string; + token_type: string; + expires_in: number; +} + +export interface PipeDriveOAuthResponse { + access_token: string; + token_type: string; + refresh_token: string; + scope: string[]; + expires_in: number; + api_domain: string; +} + +export interface ZendeskOAuthResponse { + access_token: string; + token_type: string; + refresh_token: string; + scope: string; + expires_in: number; +} diff --git a/packages/api/src/@core/sentry/sentry.interceptor.ts b/packages/api/src/@core/sentry/sentry.interceptor.ts new file mode 100644 index 000000000..5e9980288 --- /dev/null +++ b/packages/api/src/@core/sentry/sentry.interceptor.ts @@ -0,0 +1,22 @@ +// sentry.interceptor.ts +import { + CallHandler, + ExecutionContext, + Injectable, + NestInterceptor, +} from '@nestjs/common'; +import { Observable } from 'rxjs'; +import * as Sentry from '@sentry/node'; +import { catchError } from 'rxjs/operators'; + +@Injectable() +export class SentryInterceptor implements NestInterceptor { + intercept(context: ExecutionContext, next: CallHandler): Observable { + return next.handle().pipe( + catchError((error) => { + Sentry.captureException(error); + throw error; + }), + ); + } +} diff --git a/packages/api/src/@core/sentry/sentry.module.ts b/packages/api/src/@core/sentry/sentry.module.ts new file mode 100644 index 000000000..89c08392a --- /dev/null +++ b/packages/api/src/@core/sentry/sentry.module.ts @@ -0,0 +1,32 @@ +// sentry.module.ts +import { Module, DynamicModule, Global } from '@nestjs/common'; +import * as Sentry from '@sentry/node'; +import { SentryInterceptor } from './sentry.interceptor'; +import config from '../utils/config'; + +@Global() +@Module({}) +export class SentryModule { + static forRoot(): DynamicModule { + const isProduction = config.NODE_ENV === 'production'; + const sentry_dsn = config.SENTRY_DSN; + const distribution = config.PROD_DISTRIBUTION; + + //enable sentry only if we are in production environment and if the product is managed by Panora + if (isProduction && sentry_dsn && distribution == 'managed') { + Sentry.init({ + dsn: sentry_dsn, + }); + } + + return { + module: SentryModule, + providers: isProduction + ? [{ provide: 'APP_INTERCEPTOR', useClass: SentryInterceptor }] + : [], + exports: isProduction + ? [{ provide: 'APP_INTERCEPTOR', useClass: SentryInterceptor }] + : [], + }; + } +} diff --git a/packages/api/src/@core/tasks/tasks.service.spec.ts b/packages/api/src/@core/tasks/tasks.service.spec.ts new file mode 100644 index 000000000..cb482301f --- /dev/null +++ b/packages/api/src/@core/tasks/tasks.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { TasksService } from './tasks.service'; + +describe('TasksService', () => { + let service: TasksService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [TasksService], + }).compile(); + + service = module.get(TasksService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/packages/api/src/@core/tasks/tasks.service.ts b/packages/api/src/@core/tasks/tasks.service.ts new file mode 100644 index 000000000..ef3c6b71b --- /dev/null +++ b/packages/api/src/@core/tasks/tasks.service.ts @@ -0,0 +1,45 @@ +// tasks.service.ts +import { Injectable, OnModuleInit } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { PrismaService } from '../prisma/prisma.service'; +import { ConnectionsService } from '../connections/services/connections.service'; + +@Injectable() +export class TasksService implements OnModuleInit { + constructor( + private prisma: PrismaService, + private connectionsService: ConnectionsService, + ) {} + + onModuleInit() { + this.handleCron(); + } + + @Cron(CronExpression.EVERY_HOUR) + async handleCron() { + // refresh all tokens that expire in 3 days + const threeDaysFromNow = new Date(); + threeDaysFromNow.setDate(threeDaysFromNow.getDate() + 3); + + const connectionsToRefresh = await this.prisma.connections.findMany({ + where: { + expiration_timestamp: { + lte: threeDaysFromNow, + }, + }, + }); + + for (const connection of connectionsToRefresh) { + if (connection.refresh_token) { + const account_url = + connection.provider_slug == 'zoho' ? connection.account_url : ''; + await this.connectionsService.handleCRMTokensRefresh( + connection.id_connection, + connection.provider_slug, + connection.refresh_token, + account_url, + ); + } + } + } +} diff --git a/packages/api/src/@core/utils/config.ts b/packages/api/src/@core/utils/config.ts new file mode 100644 index 000000000..61e6f8fda --- /dev/null +++ b/packages/api/src/@core/utils/config.ts @@ -0,0 +1,18 @@ +const config = { + HUBSPOT_CLIENT_ID: process.env.HUBSPOT_CLIENT_ID, + HUBSPOT_CLIENT_SECRET: process.env.HUBSPOT_CLIENT_SECRET, + ZOHOCRM_CLIENT_ID: process.env.ZOHOCRM_CLIENT_ID, + ZOHOCRM_CLIENT_SECRET: process.env.ZOHOCRM_CLIENT_SECRET, + PIPEDRIVE_CLIENT_ID: process.env.PIPEDRIVE_CLIENT_ID, + PIPEDRIVE_CLIENT_SECRET: process.env.PIPEDRIVE_CLIENT_SECRET, + FRESHSALES_CLIENT_ID: process.env.FRESHSALES_CLIENT_ID, + FRESHSALES_CLIENT_SECRET: process.env.FRESHSALES_CLIENT_SECRET, + ZENDESK_CLIENT_ID: process.env.ZENDESK_CLIENT_ID, + ZENDESK_CLIENT_SECRET: process.env.ZENDESK_CLIENT_SECRET, + OAUTH_REDIRECT_BASE: process.env.OAUTH_REDIRECT_BASE, + SENTRY_DSN: process.env.SENTRY_DSN, + NODE_ENV: process.env.ENV, + PROD_DISTRIBUTION: process.env.PROD_DISTRIBUTION, +}; + +export default config; diff --git a/packages/api/src/@core/utils/errors.ts b/packages/api/src/@core/utils/errors.ts new file mode 100644 index 000000000..b7d4af4fd --- /dev/null +++ b/packages/api/src/@core/utils/errors.ts @@ -0,0 +1,33 @@ +import { HttpException, HttpStatus } from '@nestjs/common'; + +// Custom error for general application errors +export class AppError extends Error { + constructor(message: string) { + super(message); + this.name = 'AppError'; + } +} + +// Custom error for not found (404) errors +export class NotFoundError extends HttpException { + constructor(message: string) { + super(message, HttpStatus.NOT_FOUND); + this.name = 'NotFoundError'; + } +} + +// Custom error for bad request (400) errors +export class BadRequestError extends HttpException { + constructor(message: string) { + super(message, HttpStatus.BAD_REQUEST); + this.name = 'BadRequestError'; + } +} + +// Custom error for unauthorized (401) errors +export class UnauthorizedError extends HttpException { + constructor(message: string) { + super(message, HttpStatus.UNAUTHORIZED); + this.name = 'UnauthorizedError'; + } +} diff --git a/packages/api/src/app.module.ts b/packages/api/src/app.module.ts index 88408fda7..aeb9563b8 100644 --- a/packages/api/src/app.module.ts +++ b/packages/api/src/app.module.ts @@ -5,10 +5,29 @@ import { CrmModule } from './crm/crm.module'; import { AuthModule } from './@core/auth/auth.module'; import { AuthService } from './@core/auth/auth.service'; import { ConfigModule } from '@nestjs/config'; +import { ConnectionsModule } from './@core/connections/connections.module'; +import { ScheduleModule } from '@nestjs/schedule'; +import { TasksService } from './@core/tasks/tasks.service'; +import { SentryModule } from './@core/sentry/sentry.module'; +import { ConnectionsService } from './@core/connections/services/connections.service'; +import { CrmConnectionsService } from './@core/connections/services/crm/crm-connection.service'; @Module({ - imports: [CrmModule, AuthModule, ConfigModule.forRoot({ isGlobal: true })], + imports: [ + CrmModule, + AuthModule, + ConfigModule.forRoot({ isGlobal: true }), + ConnectionsModule, + ScheduleModule.forRoot(), + SentryModule.forRoot(), + ], controllers: [AppController], - providers: [AppService, AuthService], + providers: [ + AppService, + AuthService, + TasksService, + ConnectionsService, + CrmConnectionsService, + ], }) export class AppModule {} diff --git a/packages/api/src/crm/contact/services/contact.service.ts b/packages/api/src/crm/contact/services/contact.service.ts index 0bc1291f5..aa58c2c24 100644 --- a/packages/api/src/crm/contact/services/contact.service.ts +++ b/packages/api/src/crm/contact/services/contact.service.ts @@ -79,6 +79,7 @@ export class ContactService { } async addContact(createContactDto: CreateContactDto, integrationId: string) { + //TODO; customerId must be passed here const job_resp_create = await this.prisma.jobs.create({ data: { status: 'initialized', @@ -98,6 +99,7 @@ export class ContactService { //TODO: get the destination provider => call destinationCRMInDb() const dest: any = 'freshsales'; let resp: ApiResponse; + //TODO: desunify the data according to the target obj wanted switch (dest) { case 'freshsales': resp = await this.freshsales.addContact(createContactDto); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cf92c29a9..25b869b8d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,12 +41,21 @@ importers: '@nestjs/platform-express': specifier: ^10.0.0 version: 10.0.0(@nestjs/common@10.0.0)(@nestjs/core@10.0.0) + '@nestjs/schedule': + specifier: ^4.0.0 + version: 4.0.0(@nestjs/common@10.0.0)(@nestjs/core@10.0.0)(reflect-metadata@0.1.13) '@nestjs/swagger': specifier: ^7.1.14 version: 7.1.14(@nestjs/common@10.0.0)(@nestjs/core@10.0.0)(class-transformer@0.2.3)(class-validator@0.11.1)(reflect-metadata@0.1.13) '@prisma/client': specifier: ^5.4.2 version: 5.4.2(prisma@5.4.2) + '@sentry/node': + specifier: ^7.80.0 + version: 7.80.0 + '@sentry/tracing': + specifier: ^7.80.0 + version: 7.80.0 axios: specifier: ^1.5.1 version: 1.5.1 @@ -56,12 +65,9 @@ importers: crypto: specifier: ^1.0.1 version: 1.0.1 -<<<<<<< HEAD -======= dotenv: specifier: ^16.3.1 version: 16.3.1 ->>>>>>> feat/fix-auth passport: specifier: ^0.6.0 version: 0.6.0 @@ -3541,6 +3547,20 @@ packages: transitivePeerDependencies: - supports-color + /@nestjs/schedule@4.0.0(@nestjs/common@10.0.0)(@nestjs/core@10.0.0)(reflect-metadata@0.1.13): + resolution: {integrity: sha512-zz4h54m/F/1qyQKvMJCRphmuwGqJltDAkFxUXCVqJBXEs5kbPt93Pza3heCQOcMH22MZNhGlc9DmDMLXVHmgVQ==} + peerDependencies: + '@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0 + '@nestjs/core': ^8.0.0 || ^9.0.0 || ^10.0.0 + reflect-metadata: ^0.1.12 + dependencies: + '@nestjs/common': 10.0.0(class-transformer@0.2.3)(class-validator@0.11.1)(reflect-metadata@0.1.13)(rxjs@7.8.1) + '@nestjs/core': 10.0.0(@nestjs/common@10.0.0)(@nestjs/platform-express@10.0.0)(reflect-metadata@0.1.13)(rxjs@7.8.1) + cron: 3.1.3 + reflect-metadata: 0.1.13 + uuid: 9.0.1 + dev: false + /@nestjs/schematics@10.0.0(chokidar@3.5.3)(typescript@5.1.3): resolution: {integrity: sha512-gfUy/N1m1paN33BXq4d7HoCM+zM4rFxYjqAb8jkrBfBHiwyEhHHozfX/aRy/kOnAcy/VP8v4Zs4HKKrbRRlHnw==} peerDependencies: @@ -3659,6 +3679,55 @@ packages: resolution: {integrity: sha512-fqeucJ3LH0e1eyFdT0zRx+oETLancu5+n4lhiYECyEz6H2RDskPJHJYHkVc0LhkU4Uv7fuEnppKU3nVKNzMh8g==} requiresBuild: true + /@sentry-internal/tracing@7.80.0: + resolution: {integrity: sha512-P1Ab9gamHLsbH9D82i1HY8xfq9dP8runvc4g50AAd6OXRKaJ45f2KGRZUmnMEVqBQ7YoPYp2LFMkrhNYbcZEoQ==} + engines: {node: '>=8'} + dependencies: + '@sentry/core': 7.80.0 + '@sentry/types': 7.80.0 + '@sentry/utils': 7.80.0 + dev: false + + /@sentry/core@7.80.0: + resolution: {integrity: sha512-nJiiymdTSEyI035/rdD3VOq6FlOZ2wWLR5bit9LK8a3rzHU3UXkwScvEo6zYgs0Xp1sC0yu1S9+0BEiYkmi29A==} + engines: {node: '>=8'} + dependencies: + '@sentry/types': 7.80.0 + '@sentry/utils': 7.80.0 + dev: false + + /@sentry/node@7.80.0: + resolution: {integrity: sha512-J35fqe8J5ac/17ZXT0ML3opYGTOclqYNE9Sybs1y9n6BqacHyzH8By72YrdI03F7JJDHwrcGw+/H8hGpkCwi0Q==} + engines: {node: '>=8'} + dependencies: + '@sentry-internal/tracing': 7.80.0 + '@sentry/core': 7.80.0 + '@sentry/types': 7.80.0 + '@sentry/utils': 7.80.0 + https-proxy-agent: 5.0.1 + transitivePeerDependencies: + - supports-color + dev: false + + /@sentry/tracing@7.80.0: + resolution: {integrity: sha512-y9zBVMpCgY5Y6dBZrnKKHf6K9YWjGo3S35tPwDV1mQLml64bi6bNr6Fc6OBzXyrl9OTJAO71A1Z7DlAu6BQY9w==} + engines: {node: '>=8'} + dependencies: + '@sentry-internal/tracing': 7.80.0 + dev: false + + /@sentry/types@7.80.0: + resolution: {integrity: sha512-4bpMO+2jWiWLDa8zbTASWWNLWe6yhjfPsa7/6VH5y9x1NGtL8oRbqUsTgsvjF3nmeHEMkHQsC8NHPaQ/ibFmZQ==} + engines: {node: '>=8'} + dev: false + + /@sentry/utils@7.80.0: + resolution: {integrity: sha512-XbBCEl6uLvE50ftKwrEo6XWdDaZXHXu+kkHXTPWQEcnbvfZKLuG9V0Hxtxxq3xQgyWmuF05OH1GcqYqiO+v5Yg==} + engines: {node: '>=8'} + dependencies: + '@sentry/types': 7.80.0 + dev: false + /@sideway/address@4.1.4: resolution: {integrity: sha512-7vwq+rOHVWjyXxVlR76Agnvhy8I9rpzjosTESvmhNeXOXdZZB15Fl+TI9x1SiHZH5Jv2wTGduSxFDIaq0m3DUw==} dependencies: @@ -4040,6 +4109,10 @@ packages: '@types/node': 20.3.1 dev: false + /@types/luxon@3.3.4: + resolution: {integrity: sha512-H9OXxv4EzJwE75aTPKpiGXJq+y4LFxjpsdgKwSmr503P5DkWc3AG7VAFYrFNVvqemT5DfgZJV9itYhqBHSGujA==} + dev: false + /@types/mdast@3.0.14: resolution: {integrity: sha512-gVZ04PGgw1qLZKsnWnyFv4ORnaJ+DXLdHTVSFbU8yX6xZ34Bjg4Q32yPkmveUP1yItXReKfB0Aknlh/3zxTKAw==} dependencies: @@ -6034,6 +6107,13 @@ packages: resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} dev: true + /cron@3.1.3: + resolution: {integrity: sha512-KVxeKTKYj2eNzN4ElnT6nRSbjbfhyxR92O/Jdp6SH3pc05CDJws59jBrZWEMQlxevCiE6QUTrXy+Im3vC3oD3A==} + dependencies: + '@types/luxon': 3.3.4 + luxon: 3.4.3 + dev: false + /cross-fetch@3.1.8: resolution: {integrity: sha512-cvA+JwZoU0Xq+h6WkMvAUqPEYy92Obet6UdKLfW60qn99ftItKjB5T+BkyWOFWe2pUyfQ+IJHmpOTznqk1M6Kg==} dependencies: @@ -9445,6 +9525,11 @@ packages: dependencies: yallist: 4.0.0 + /luxon@3.4.3: + resolution: {integrity: sha512-tFWBiv3h7z+T/tDaoxA8rqTxy1CHV6gHS//QdaH4pulbq/JuBSGgQspQQqcgnwdAx6pNI7cmvz5Sv/addzHmUg==} + engines: {node: '>=12'} + dev: false + /macos-release@2.5.1: resolution: {integrity: sha512-DXqXhEM7gW59OjZO8NIjBCz9AQ1BEMrfiOAl4AYByHCtVHRF4KoGNO8mqQeM8lRCtQe/UnJ4imO/d2HdkKsd+A==} engines: {node: '>=6'}