From 43e60cd22b88a92927dc9e5912e74ca9c0038682 Mon Sep 17 00:00:00 2001 From: nael Date: Tue, 16 Apr 2024 01:15:54 +0200 Subject: [PATCH] :sparkles: Added provider connections --- docker-compose.dev.yml | 9 + docker-compose.source.yml | 9 + docker-compose.yml | 9 + packages/api/package.json | 1 + packages/api/scripts/oauthConnector.js | 12 +- .../connections/connections.controller.ts | 8 + .../@core/connections/connections.module.ts | 3 + .../marketing_automation.connection.module.ts | 27 +++ .../getresponse/getresponse.service.ts | 194 ++++++++++++++++++ .../services/mailchimp/mailchimp.service.ts | 152 ++++++++++++++ ...marketing_automation.connection.service.ts | 104 ++++++++++ .../services/podium/podium.service.ts | 177 ++++++++++++++++ .../services/registry.service.ts | 26 +++ .../marketing_automation/types/index.ts | 19 ++ packages/shared/src/authUrl.ts | 2 +- packages/shared/src/utils.ts | 7 +- 16 files changed, 747 insertions(+), 12 deletions(-) create mode 100644 packages/api/src/@core/connections/marketing_automation/marketing_automation.connection.module.ts create mode 100644 packages/api/src/@core/connections/marketing_automation/services/getresponse/getresponse.service.ts create mode 100644 packages/api/src/@core/connections/marketing_automation/services/mailchimp/mailchimp.service.ts create mode 100644 packages/api/src/@core/connections/marketing_automation/services/marketing_automation.connection.service.ts create mode 100644 packages/api/src/@core/connections/marketing_automation/services/podium/podium.service.ts create mode 100644 packages/api/src/@core/connections/marketing_automation/services/registry.service.ts create mode 100644 packages/api/src/@core/connections/marketing_automation/types/index.ts diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 61b756d30..dee956b2c 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -102,6 +102,15 @@ services: QUICKBOOKS_ACCOUNTING_CLOUD_CLIENT_SECRET: ${QUICKBOOKS_ACCOUNTING_CLOUD_CLIENT_SECRET} WAVE_FINANCIAL_ACCOUNTING_CLOUD_CLIENT_ID: ${WAVE_FINANCIAL_ACCOUNTING_CLOUD_CLIENT_ID} WAVE_FINANCIAL_ACCOUNTING_CLOUD_CLIENT_SECRET: ${WAVE_FINANCIAL_ACCOUNTING_CLOUD_CLIENT_SECRET} + GETRESPONSE_MARKETING_AUTOMATION_CLOUD_CLIENT_ID: ${GETRESPONSE_MARKETING_AUTOMATION_CLOUD_CLIENT_ID} + GETRESPONSE_MARKETING_AUTOMATION_CLOUD_CLIENT_SECRET: ${GETRESPONSE_MARKETING_AUTOMATION_CLOUD_CLIENT_SECRET} + MAILCHIMP_MARKETING_AUTOMATION_CLOUD_CLIENT_ID: ${MAILCHIMP_MARKETING_AUTOMATION_CLOUD_CLIENT_ID} + MAILCHIMP_MARKETING_AUTOMATION_CLOUD_CLIENT_SECRET: ${MAILCHIMP_MARKETING_AUTOMATION_CLOUD_CLIENT_SECRET} + PODIUM_MARKETING_AUTOMATION_CLOUD_CLIENT_ID: ${PODIUM_MARKETING_AUTOMATION_CLOUD_CLIENT_ID} + PODIUM_MARKETING_AUTOMATION_CLOUD_CLIENT_SECRET: ${PODIUM_MARKETING_AUTOMATION_CLOUD_CLIENT_SECRET} + + + restart: unless-stopped ports: diff --git a/docker-compose.source.yml b/docker-compose.source.yml index ab53001bd..0f64c2933 100644 --- a/docker-compose.source.yml +++ b/docker-compose.source.yml @@ -102,6 +102,15 @@ services: QUICKBOOKS_ACCOUNTING_CLOUD_CLIENT_SECRET: ${QUICKBOOKS_ACCOUNTING_CLOUD_CLIENT_SECRET} WAVE_FINANCIAL_ACCOUNTING_CLOUD_CLIENT_ID: ${WAVE_FINANCIAL_ACCOUNTING_CLOUD_CLIENT_ID} WAVE_FINANCIAL_ACCOUNTING_CLOUD_CLIENT_SECRET: ${WAVE_FINANCIAL_ACCOUNTING_CLOUD_CLIENT_SECRET} + GETRESPONSE_MARKETING_AUTOMATION_CLOUD_CLIENT_ID: ${GETRESPONSE_MARKETING_AUTOMATION_CLOUD_CLIENT_ID} + GETRESPONSE_MARKETING_AUTOMATION_CLOUD_CLIENT_SECRET: ${GETRESPONSE_MARKETING_AUTOMATION_CLOUD_CLIENT_SECRET} + MAILCHIMP_MARKETING_AUTOMATION_CLOUD_CLIENT_ID: ${MAILCHIMP_MARKETING_AUTOMATION_CLOUD_CLIENT_ID} + MAILCHIMP_MARKETING_AUTOMATION_CLOUD_CLIENT_SECRET: ${MAILCHIMP_MARKETING_AUTOMATION_CLOUD_CLIENT_SECRET} + PODIUM_MARKETING_AUTOMATION_CLOUD_CLIENT_ID: ${PODIUM_MARKETING_AUTOMATION_CLOUD_CLIENT_ID} + PODIUM_MARKETING_AUTOMATION_CLOUD_CLIENT_SECRET: ${PODIUM_MARKETING_AUTOMATION_CLOUD_CLIENT_SECRET} + + + restart: unless-stopped ports: diff --git a/docker-compose.yml b/docker-compose.yml index fb5dae314..4e500a7fa 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -97,6 +97,15 @@ services: QUICKBOOKS_ACCOUNTING_CLOUD_CLIENT_SECRET: ${QUICKBOOKS_ACCOUNTING_CLOUD_CLIENT_SECRET} WAVE_FINANCIAL_ACCOUNTING_CLOUD_CLIENT_ID: ${WAVE_FINANCIAL_ACCOUNTING_CLOUD_CLIENT_ID} WAVE_FINANCIAL_ACCOUNTING_CLOUD_CLIENT_SECRET: ${WAVE_FINANCIAL_ACCOUNTING_CLOUD_CLIENT_SECRET} + GETRESPONSE_MARKETING_AUTOMATION_CLOUD_CLIENT_ID: ${GETRESPONSE_MARKETING_AUTOMATION_CLOUD_CLIENT_ID} + GETRESPONSE_MARKETING_AUTOMATION_CLOUD_CLIENT_SECRET: ${GETRESPONSE_MARKETING_AUTOMATION_CLOUD_CLIENT_SECRET} + MAILCHIMP_MARKETING_AUTOMATION_CLOUD_CLIENT_ID: ${MAILCHIMP_MARKETING_AUTOMATION_CLOUD_CLIENT_ID} + MAILCHIMP_MARKETING_AUTOMATION_CLOUD_CLIENT_SECRET: ${MAILCHIMP_MARKETING_AUTOMATION_CLOUD_CLIENT_SECRET} + PODIUM_MARKETING_AUTOMATION_CLOUD_CLIENT_ID: ${PODIUM_MARKETING_AUTOMATION_CLOUD_CLIENT_ID} + PODIUM_MARKETING_AUTOMATION_CLOUD_CLIENT_SECRET: ${PODIUM_MARKETING_AUTOMATION_CLOUD_CLIENT_SECRET} + + + restart: unless-stopped diff --git a/packages/api/package.json b/packages/api/package.json index 96d8eba08..1eac78130 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -111,3 +111,4 @@ "testEnvironment": "node" } } + \ No newline at end of file diff --git a/packages/api/scripts/oauthConnector.js b/packages/api/scripts/oauthConnector.js index 5998ce2e4..6eb16a701 100755 --- a/packages/api/scripts/oauthConnector.js +++ b/packages/api/scripts/oauthConnector.js @@ -127,7 +127,7 @@ export class ${providerUpper}ConnectionService implements I${verticalUpper}Conne refresh_token: this.cryptoService.encrypt(data.refresh_token), account_url: "", expiration_timestamp: new Date( - new Date().getTime() + Number(data.expires_ina) * 1000, + new Date().getTime() + Number(data.expires_in) * 1000, ), status: 'valid', created_at: new Date(), @@ -145,7 +145,7 @@ export class ${providerUpper}ConnectionService implements I${verticalUpper}Conne access_token: this.cryptoService.encrypt(data.access_token), refresh_token: this.cryptoService.encrypt(data.refresh_token), expiration_timestamp: new Date( - new Date().getTime() + Number(data.expires_ina) * 1000, + new Date().getTime() + Number(data.expires_in) * 1000, ), status: 'valid', created_at: new Date(), @@ -166,12 +166,6 @@ export class ${providerUpper}ConnectionService implements I${verticalUpper}Conne async handleTokenRefresh(opts: RefreshParams) { try { - const { connectionId, refreshToken } = opts; - const formData = new URLSearchParams({ - grant_type: 'refresh_token', - refresh_token: this.cryptoService.decrypt(refreshToken), - }); - const { connectionId, refreshToken, projectId } = opts; const CREDENTIALS = (await this.cService.getCredentials( projectId, @@ -204,7 +198,7 @@ export class ${providerUpper}ConnectionService implements I${verticalUpper}Conne access_token: this.cryptoService.encrypt(data.access_token), refresh_token: this.cryptoService.encrypt(data.refresh_token), expiration_timestamp: new Date( - new Date().getTime() + Number(data.expires_ina) * 1000, + new Date().getTime() + Number(data.expires_in) * 1000, ), }, }); diff --git a/packages/api/src/@core/connections/connections.controller.ts b/packages/api/src/@core/connections/connections.controller.ts index d7562ba42..5fbacab2a 100644 --- a/packages/api/src/@core/connections/connections.controller.ts +++ b/packages/api/src/@core/connections/connections.controller.ts @@ -8,6 +8,7 @@ import { ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger'; import { TicketingConnectionsService } from './ticketing/services/ticketing.connection.service'; import { ProviderVertical } from '@panora/shared'; import { AccountingConnectionsService } from './accounting/services/accounting.connection.service'; +import { MarketingAutomationConnectionsService } from './marketing_automation/services/marketing_automation.connection.service'; export type StateDataType = { projectId: string; @@ -24,6 +25,7 @@ export class ConnectionsController { private readonly crmConnectionsService: CrmConnectionsService, private readonly ticketingConnectionsService: TicketingConnectionsService, private readonly accountingConnectionsService: AccountingConnectionsService, + private readonly marketingAutomationConnectionsService: MarketingAutomationConnectionsService, private logger: LoggerService, private prisma: PrismaService, ) { @@ -84,6 +86,12 @@ export class ConnectionsController { case ProviderVertical.HRIS: break; case ProviderVertical.MarketingAutomation: + this.marketingAutomationConnectionsService.handleMarketingAutomationCallBack( + projectId, + linkedUserId, + providerName, + code, + ); break; case ProviderVertical.Ticketing: this.ticketingConnectionsService.handleTicketingCallBack( diff --git a/packages/api/src/@core/connections/connections.module.ts b/packages/api/src/@core/connections/connections.module.ts index 85cd4b220..3372594d3 100644 --- a/packages/api/src/@core/connections/connections.module.ts +++ b/packages/api/src/@core/connections/connections.module.ts @@ -5,6 +5,7 @@ import { LoggerService } from '@@core/logger/logger.service'; import { PrismaService } from '@@core/prisma/prisma.service'; import { TicketingConnectionModule } from './ticketing/ticketing.connection.module'; import { AccountingConnectionModule } from './accounting/accounting.connection.module'; +import { MarketingAutomationConnectionsModule } from './marketing_automation/marketing_automation.connection.module'; @Module({ controllers: [ConnectionsController], @@ -12,12 +13,14 @@ import { AccountingConnectionModule } from './accounting/accounting.connection.m CrmConnectionModule, TicketingConnectionModule, AccountingConnectionModule, + MarketingAutomationConnectionsModule, ], providers: [LoggerService, PrismaService], exports: [ CrmConnectionModule, TicketingConnectionModule, AccountingConnectionModule, + MarketingAutomationConnectionsModule, ], }) export class ConnectionsModule {} diff --git a/packages/api/src/@core/connections/marketing_automation/marketing_automation.connection.module.ts b/packages/api/src/@core/connections/marketing_automation/marketing_automation.connection.module.ts new file mode 100644 index 000000000..7195614a3 --- /dev/null +++ b/packages/api/src/@core/connections/marketing_automation/marketing_automation.connection.module.ts @@ -0,0 +1,27 @@ +import { Module } from '@nestjs/common'; +import { PrismaService } from '@@core/prisma/prisma.service'; +import { LoggerService } from '@@core/logger/logger.service'; +import { WebhookService } from '@@core/webhook/webhook.service'; +import { WebhookModule } from '@@core/webhook/webhook.module'; +import { EnvironmentService } from '@@core/environment/environment.service'; +import { EncryptionService } from '@@core/encryption/encryption.service'; +import { ConnectionsStrategiesService } from '@@core/connections-strategies/connections-strategies.service'; +import { MarketingAutomationConnectionsService } from './services/marketing_automation.connection.service'; +import { ServiceRegistry } from './services/registry.service'; + +@Module({ + imports: [WebhookModule], + providers: [ + MarketingAutomationConnectionsService, + PrismaService, + LoggerService, + WebhookService, + EnvironmentService, + EncryptionService, + ServiceRegistry, + ConnectionsStrategiesService, + //PROVIDERS SERVICES + ], + exports: [MarketingAutomationConnectionsService], +}) +export class MarketingAutomationConnectionsModule {} diff --git a/packages/api/src/@core/connections/marketing_automation/services/getresponse/getresponse.service.ts b/packages/api/src/@core/connections/marketing_automation/services/getresponse/getresponse.service.ts new file mode 100644 index 000000000..66ad6861f --- /dev/null +++ b/packages/api/src/@core/connections/marketing_automation/services/getresponse/getresponse.service.ts @@ -0,0 +1,194 @@ +import { Injectable } from '@nestjs/common'; +import axios from 'axios'; +import { PrismaService } from '@@core/prisma/prisma.service'; +import { Action, handleServiceError } from '@@core/utils/errors'; +import { LoggerService } from '@@core/logger/logger.service'; +import { v4 as uuidv4 } from 'uuid'; +import { EnvironmentService } from '@@core/environment/environment.service'; +import { EncryptionService } from '@@core/encryption/encryption.service'; +import { + CallbackParams, + RefreshParams, + IMarketingAutomationConnectionService, +} from '../../types'; +import { ServiceRegistry } from '../registry.service'; +import { AuthStrategy, providersConfig } from '@panora/shared'; +import { OAuth2AuthData, providerToType } from '@panora/shared'; +import { ConnectionsStrategiesService } from '@@core/connections-strategies/connections-strategies.service'; + +export type GetresponseOAuthResponse = { + access_token: string; + refresh_token: string; + expires_in: string; + token_type: string; + scope: any; +}; + +@Injectable() +export class GetresponseConnectionService + implements IMarketingAutomationConnectionService +{ + private readonly type: string; + + constructor( + private prisma: PrismaService, + private logger: LoggerService, + private env: EnvironmentService, + private cryptoService: EncryptionService, + private registry: ServiceRegistry, + private cService: ConnectionsStrategiesService, + ) { + this.logger.setContext(GetresponseConnectionService.name); + this.registry.registerService('getresponse', this); + this.type = providerToType( + 'getresponse', + 'marketing_automation', + AuthStrategy.oauth2, + ); + } + + async handleCallback(opts: CallbackParams) { + try { + const { linkedUserId, projectId, code } = opts; + const isNotUnique = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedUserId, + provider_slug: 'getresponse', + vertical: 'marketing_automation', + }, + }); + + //reconstruct the redirect URI that was passed in the githubend it must be the same + const REDIRECT_URI = `${this.env.getOAuthRredirectBaseUrl()}/connections/oauth/callback`; + const CREDENTIALS = (await this.cService.getCredentials( + projectId, + this.type, + )) as OAuth2AuthData; + + const formData = new URLSearchParams({ + code: code, + grant_type: 'authorization_code', + }); + const res = await axios.post( + 'https://api.getresponse.com/v3/token', + formData.toString(), + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded;charset=utf-8', + Authorization: `Basic ${Buffer.from( + `${CREDENTIALS.CLIENT_ID}:${CREDENTIALS.CLIENT_SECRET}`, + ).toString('base64')}`, + }, + }, + ); + const data: GetresponseOAuthResponse = res.data; + this.logger.log( + 'OAuth credentials : getresponse ticketing ' + JSON.stringify(data), + ); + + let db_res; + const connection_token = uuidv4(); + + if (isNotUnique) { + db_res = await this.prisma.connections.update({ + where: { + id_connection: isNotUnique.id_connection, + }, + data: { + access_token: this.cryptoService.encrypt(data.access_token), + refresh_token: this.cryptoService.encrypt(data.refresh_token), + account_url: + providersConfig['marketing_automation']['getresponse'].urls + .apiUrl, + expiration_timestamp: new Date( + new Date().getTime() + Number(data.expires_in) * 1000, + ), + status: 'valid', + created_at: new Date(), + }, + }); + } else { + db_res = await this.prisma.connections.create({ + data: { + id_connection: uuidv4(), + connection_token: connection_token, + provider_slug: 'getresponse', + vertical: 'marketing_automation', + token_type: 'oauth', + account_url: + providersConfig['marketing_automation']['getresponse'].urls + .apiUrl, + access_token: this.cryptoService.encrypt(data.access_token), + refresh_token: this.cryptoService.encrypt(data.refresh_token), + expiration_timestamp: new Date( + new Date().getTime() + Number(data.expires_in) * 1000, + ), + status: 'valid', + created_at: new Date(), + projects: { + connect: { id_project: projectId }, + }, + linked_users: { + connect: { id_linked_user: linkedUserId }, + }, + }, + }); + } + return db_res; + } catch (error) { + handleServiceError( + error, + this.logger, + 'getresponse', + Action.oauthCallback, + ); + } + } + + async handleTokenRefresh(opts: RefreshParams) { + try { + const { connectionId, refreshToken, projectId } = opts; + const CREDENTIALS = (await this.cService.getCredentials( + projectId, + this.type, + )) as OAuth2AuthData; + const formData = new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: this.cryptoService.decrypt(refreshToken), + }); + const res = await axios.post( + 'https://api.getresponse.com/v3/token', + formData.toString(), + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded;charset=utf-8', + Authorization: `Basic ${Buffer.from( + `${CREDENTIALS.CLIENT_ID}:${CREDENTIALS.CLIENT_SECRET}`, + ).toString('base64')}`, + }, + }, + ); + const data: GetresponseOAuthResponse = res.data; + await this.prisma.connections.update({ + where: { + id_connection: connectionId, + }, + data: { + access_token: this.cryptoService.encrypt(data.access_token), + refresh_token: this.cryptoService.encrypt(data.refresh_token), + expiration_timestamp: new Date( + new Date().getTime() + Number(data.expires_in) * 1000, + ), + }, + }); + this.logger.log('OAuth credentials updated : getresponse '); + } catch (error) { + handleServiceError( + error, + this.logger, + 'getresponse', + Action.oauthRefresh, + ); + } + } +} diff --git a/packages/api/src/@core/connections/marketing_automation/services/mailchimp/mailchimp.service.ts b/packages/api/src/@core/connections/marketing_automation/services/mailchimp/mailchimp.service.ts new file mode 100644 index 000000000..b4d7945e0 --- /dev/null +++ b/packages/api/src/@core/connections/marketing_automation/services/mailchimp/mailchimp.service.ts @@ -0,0 +1,152 @@ +import { Injectable } from '@nestjs/common'; +import axios from 'axios'; +import { PrismaService } from '@@core/prisma/prisma.service'; +import { Action, handleServiceError } from '@@core/utils/errors'; +import { LoggerService } from '@@core/logger/logger.service'; +import { v4 as uuidv4 } from 'uuid'; +import { EnvironmentService } from '@@core/environment/environment.service'; +import { EncryptionService } from '@@core/encryption/encryption.service'; +import { + CallbackParams, + RefreshParams, + IMarketingAutomationConnectionService, +} from '../../types'; +import { ServiceRegistry } from '../registry.service'; +import { AuthStrategy } from '@panora/shared'; +import { OAuth2AuthData, providerToType } from '@panora/shared'; +import { ConnectionsStrategiesService } from '@@core/connections-strategies/connections-strategies.service'; + +export type MailchimpOAuthResponse = { + access_token: string; + refresh_token: string; + expires_in: string; + token_type: string; +}; + +@Injectable() +export class MailchimpConnectionService + implements IMarketingAutomationConnectionService +{ + private readonly type: string; + + constructor( + private prisma: PrismaService, + private logger: LoggerService, + private env: EnvironmentService, + private cryptoService: EncryptionService, + private registry: ServiceRegistry, + private cService: ConnectionsStrategiesService, + ) { + this.logger.setContext(MailchimpConnectionService.name); + this.registry.registerService('mailchimp', this); + this.type = providerToType( + 'mailchimp', + 'marketing_automation', + AuthStrategy.oauth2, + ); + } + + async handleCallback(opts: CallbackParams) { + try { + const { linkedUserId, projectId, code } = opts; + const isNotUnique = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedUserId, + provider_slug: 'mailchimp', + vertical: 'marketing_automation', + }, + }); + + //reconstruct the redirect URI that was passed in the githubend it must be the same + const REDIRECT_URI = `${this.env.getOAuthRredirectBaseUrl()}/connections/oauth/callback`; + const CREDENTIALS = (await this.cService.getCredentials( + projectId, + this.type, + )) as OAuth2AuthData; + + const formData = new URLSearchParams({ + client_id: CREDENTIALS.CLIENT_ID, + client_secret: CREDENTIALS.CLIENT_SECRET, + redirect_uri: REDIRECT_URI, + code: code, + grant_type: 'authorization_code', + }); + const res = await axios.post( + 'https://login.mailchimp.com/oauth2/token', + formData.toString(), + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded;charset=utf-8', + }, + }, + ); + const data: MailchimpOAuthResponse = res.data; + this.logger.log( + 'OAuth credentials : mailchimp ticketing ' + JSON.stringify(data), + ); + + //get right server to make right api calls + const res_ = await axios.post( + 'https://login.mailchimp.com/oauth2/metadata', + formData.toString(), + { + headers: { + Authorization: `OAuth ${data.access_token}`, + }, + }, + ); + const server_url = res_.data; + let db_res; + const connection_token = uuidv4(); + + if (isNotUnique) { + db_res = await this.prisma.connections.update({ + where: { + id_connection: isNotUnique.id_connection, + }, + data: { + access_token: this.cryptoService.encrypt(data.access_token), + refresh_token: this.cryptoService.encrypt(data.refresh_token), + account_url: server_url, + expiration_timestamp: new Date( + new Date().getTime() + Number(data.expires_in) * 1000, + ), + status: 'valid', + created_at: new Date(), + }, + }); + } else { + db_res = await this.prisma.connections.create({ + data: { + id_connection: uuidv4(), + connection_token: connection_token, + provider_slug: 'mailchimp', + vertical: 'marketing_automation', + token_type: 'oauth', + account_url: server_url, + access_token: this.cryptoService.encrypt(data.access_token), + refresh_token: this.cryptoService.encrypt(data.refresh_token), + expiration_timestamp: new Date( + new Date().getTime() + Number(data.expires_in) * 1000, + ), + status: 'valid', + created_at: new Date(), + projects: { + connect: { id_project: projectId }, + }, + linked_users: { + connect: { id_linked_user: linkedUserId }, + }, + }, + }); + } + return db_res; + } catch (error) { + handleServiceError(error, this.logger, 'mailchimp', Action.oauthCallback); + } + } + + async handleTokenRefresh(opts: RefreshParams) { + return; + } +} diff --git a/packages/api/src/@core/connections/marketing_automation/services/marketing_automation.connection.service.ts b/packages/api/src/@core/connections/marketing_automation/services/marketing_automation.connection.service.ts new file mode 100644 index 000000000..03b9cf934 --- /dev/null +++ b/packages/api/src/@core/connections/marketing_automation/services/marketing_automation.connection.service.ts @@ -0,0 +1,104 @@ +import { Injectable } from '@nestjs/common'; +import { NotFoundError, handleServiceError } from '@@core/utils/errors'; +import { LoggerService } from '@@core/logger/logger.service'; +import { WebhookService } from '@@core/webhook/webhook.service'; +import { connections as Connection } from '@prisma/client'; +import { PrismaService } from '@@core/prisma/prisma.service'; +import { v4 as uuidv4 } from 'uuid'; +import { CallbackParams, RefreshParams } from '../types'; +import { ServiceRegistry } from './registry.service'; + +@Injectable() +export class MarketingAutomationConnectionsService { + constructor( + private serviceRegistry: ServiceRegistry, + private webhook: WebhookService, + private logger: LoggerService, + private prisma: PrismaService, + ) { + this.logger.setContext(MarketingAutomationConnectionsService.name); + } + //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 + async handleMarketingAutomationCallBack( + projectId: string, + linkedUserId: string, + providerName: string, + code: string, + ) { + try { + const serviceName = providerName.toLowerCase(); + const service = this.serviceRegistry.getService(serviceName); + + if (!service) { + throw new NotFoundError(`Unknown provider, found ${providerName}`); + } + const callbackOpts: CallbackParams = { + linkedUserId: linkedUserId, + projectId: projectId, + code: code, + }; + const data: Connection = await service.handleCallback(callbackOpts); + + const event = await this.prisma.events.create({ + data: { + id_event: uuidv4(), + status: 'success', + type: 'connection.created', + method: 'GET', + url: '/oauth/callback', + provider: providerName.toLowerCase(), + direction: '0', + timestamp: new Date(), + id_linked_user: linkedUserId, + }, + }); + //directly send the webhook + await this.webhook.handlePriorityWebhook( + data, + 'connection.created', + projectId, + event.id_event, + ); + } catch (error) { + handleServiceError(error, this.logger); + } + } + + async handleMarketingAutomationTokensRefresh( + connectionId: string, + providerName: string, + refresh_token: string, + id_project: string, + account_url?: string, + ) { + try { + const serviceName = providerName.toLowerCase(); + const service = this.serviceRegistry.getService(serviceName); + if (!service) { + throw new NotFoundError(`Unknown provider, found ${providerName}`); + } + const refreshOpts: RefreshParams = { + connectionId: connectionId, + refreshToken: refresh_token, + account_url: account_url, + projectId: id_project, + }; + const data = await service.handleTokenRefresh(refreshOpts); + } catch (error) { + handleServiceError(error, this.logger); + } + } +} diff --git a/packages/api/src/@core/connections/marketing_automation/services/podium/podium.service.ts b/packages/api/src/@core/connections/marketing_automation/services/podium/podium.service.ts new file mode 100644 index 000000000..ec948abde --- /dev/null +++ b/packages/api/src/@core/connections/marketing_automation/services/podium/podium.service.ts @@ -0,0 +1,177 @@ +import { Injectable } from '@nestjs/common'; +import axios from 'axios'; +import { PrismaService } from '@@core/prisma/prisma.service'; +import { Action, handleServiceError } from '@@core/utils/errors'; +import { LoggerService } from '@@core/logger/logger.service'; +import { v4 as uuidv4 } from 'uuid'; +import { EnvironmentService } from '@@core/environment/environment.service'; +import { EncryptionService } from '@@core/encryption/encryption.service'; +import { + CallbackParams, + RefreshParams, + IMarketingAutomationConnectionService, +} from '../../types'; +import { ServiceRegistry } from '../registry.service'; +import { AuthStrategy, providersConfig } from '@panora/shared'; +import { OAuth2AuthData, providerToType } from '@panora/shared'; +import { ConnectionsStrategiesService } from '@@core/connections-strategies/connections-strategies.service'; + +export type PodiumOAuthResponse = { + access_token: string; + refresh_token: string; +}; + +@Injectable() +export class PodiumConnectionService + implements IMarketingAutomationConnectionService +{ + private readonly type: string; + + constructor( + private prisma: PrismaService, + private logger: LoggerService, + private env: EnvironmentService, + private cryptoService: EncryptionService, + private registry: ServiceRegistry, + private cService: ConnectionsStrategiesService, + ) { + this.logger.setContext(PodiumConnectionService.name); + this.registry.registerService('podium', this); + this.type = providerToType( + 'podium', + 'marketing_automation', + AuthStrategy.oauth2, + ); + } + async handleCallback(opts: CallbackParams) { + try { + const { linkedUserId, projectId, code } = opts; + const isNotUnique = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedUserId, + provider_slug: 'podium', + vertical: 'marketing_automation', + }, + }); + + //reconstruct the redirect URI that was passed in the githubend it must be the same + const REDIRECT_URI = `${this.env.getOAuthRredirectBaseUrl()}/connections/oauth/callback`; + const CREDENTIALS = (await this.cService.getCredentials( + projectId, + this.type, + )) as OAuth2AuthData; + + const formData = new URLSearchParams({ + client_id: CREDENTIALS.CLIENT_ID, + client_secret: CREDENTIALS.CLIENT_SECRET, + redirect_uri: REDIRECT_URI, + code: code, + grant_type: 'authorization_code', + }); + const res = await axios.post( + 'https://api.podium.com/oauth/token', + formData.toString(), + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded;charset=utf-8', + }, + }, + ); + const data: PodiumOAuthResponse = res.data; + this.logger.log( + 'OAuth credentials : podium ticketing ' + JSON.stringify(data), + ); + + let db_res; + const connection_token = uuidv4(); + + if (isNotUnique) { + db_res = await this.prisma.connections.update({ + where: { + id_connection: isNotUnique.id_connection, + }, + data: { + access_token: this.cryptoService.encrypt(data.access_token), + refresh_token: this.cryptoService.encrypt(data.refresh_token), + account_url: + providersConfig['marketing_automation']['podium'].urls.apiUrl, + expiration_timestamp: new Date( + new Date().getTime() + 10 * 60 * 60 * 1000, + ), + status: 'valid', + created_at: new Date(), + }, + }); + } else { + db_res = await this.prisma.connections.create({ + data: { + id_connection: uuidv4(), + connection_token: connection_token, + provider_slug: 'podium', + vertical: 'marketing_automation', + token_type: 'oauth', + account_url: + providersConfig['marketing_automation']['pdoum'].urls.apiUrl, + access_token: this.cryptoService.encrypt(data.access_token), + refresh_token: this.cryptoService.encrypt(data.refresh_token), + expiration_timestamp: new Date( + new Date().getTime() + 10 * 60 * 60 * 1000, + ), + status: 'valid', + created_at: new Date(), + projects: { + connect: { id_project: projectId }, + }, + linked_users: { + connect: { id_linked_user: linkedUserId }, + }, + }, + }); + } + return db_res; + } catch (error) { + handleServiceError(error, this.logger, 'podium', Action.oauthCallback); + } + } + + async handleTokenRefresh(opts: RefreshParams) { + try { + const { connectionId, refreshToken, projectId } = opts; + const CREDENTIALS = (await this.cService.getCredentials( + projectId, + this.type, + )) as OAuth2AuthData; + const formData = new URLSearchParams({ + client_id: CREDENTIALS.CLIENT_ID, + client_secret: CREDENTIALS.CLIENT_SECRET, + grant_type: 'refresh_token', + refresh_token: this.cryptoService.decrypt(refreshToken), + }); + const res = await axios.post( + 'https://api.podium.com/oauth/token', + formData.toString(), + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded;charset=utf-8', + }, + }, + ); + const data: PodiumOAuthResponse = res.data; + await this.prisma.connections.update({ + where: { + id_connection: connectionId, + }, + data: { + access_token: this.cryptoService.encrypt(data.access_token), + refresh_token: this.cryptoService.encrypt(data.refresh_token), + expiration_timestamp: new Date( + new Date().getTime() + 10 * 60 * 60 * 1000, + ), + }, + }); + this.logger.log('OAuth credentials updated : podium '); + } catch (error) { + handleServiceError(error, this.logger, 'podium', Action.oauthRefresh); + } + } +} diff --git a/packages/api/src/@core/connections/marketing_automation/services/registry.service.ts b/packages/api/src/@core/connections/marketing_automation/services/registry.service.ts new file mode 100644 index 000000000..b907b5f03 --- /dev/null +++ b/packages/api/src/@core/connections/marketing_automation/services/registry.service.ts @@ -0,0 +1,26 @@ +import { Injectable } from '@nestjs/common'; +import { IMarketingAutomationConnectionService } from '../types'; + +@Injectable() +export class ServiceRegistry { + private serviceMap: Map; + + constructor() { + this.serviceMap = new Map(); + } + + registerService( + serviceKey: string, + service: IMarketingAutomationConnectionService, + ) { + this.serviceMap.set(serviceKey, service); + } + + getService(integrationId: string): IMarketingAutomationConnectionService { + const service = this.serviceMap.get(integrationId); + if (!service) { + throw new Error(`Service not found for integration ID: ${integrationId}`); + } + return service; + } +} diff --git a/packages/api/src/@core/connections/marketing_automation/types/index.ts b/packages/api/src/@core/connections/marketing_automation/types/index.ts new file mode 100644 index 000000000..01185a641 --- /dev/null +++ b/packages/api/src/@core/connections/marketing_automation/types/index.ts @@ -0,0 +1,19 @@ +import { connections as Connection } from '@prisma/client'; + +export type CallbackParams = { + linkedUserId: string; + projectId: string; + code: string; +}; + +export type RefreshParams = { + connectionId: string; + refreshToken: string; + account_url?: string; + projectId: string; +}; + +export interface IMarketingAutomationConnectionService { + handleCallback(opts: CallbackParams): Promise; + handleTokenRefresh(opts: RefreshParams): Promise; +} diff --git a/packages/shared/src/authUrl.ts b/packages/shared/src/authUrl.ts index ce7fd0a39..23ad15a50 100644 --- a/packages/shared/src/authUrl.ts +++ b/packages/shared/src/authUrl.ts @@ -108,7 +108,7 @@ const handleOAuth2Url = async (input: HandleOAuth2Url) => { // Default URL structure let params = `client_id=${encodeURIComponent(clientId)}&redirect_uri=${encodedRedirectUrl}&state=${state}`; - const providersWithoutScopes = ["pipedrive", "clickup", "aha", "freeagent", "teamwork", "attio", "close", "teamleader"] + const providersWithoutScopes = ["pipedrive", "clickup", "aha", "freeagent", "teamwork", "attio", "close", "teamleader", 'getresponse'] // Adding scope for providers that require it, except for 'pipedrive' if (!providersWithoutScopes.includes(providerName) ) { diff --git a/packages/shared/src/utils.ts b/packages/shared/src/utils.ts index dac2eace1..2613f2a7f 100644 --- a/packages/shared/src/utils.ts +++ b/packages/shared/src/utils.ts @@ -888,6 +888,7 @@ export const providersConfig: ProvidersConfig = { active: false, authStrategy: AuthStrategy.api_key }, + //todo 'customerio': { scopes: '', urls: { @@ -910,6 +911,7 @@ export const providersConfig: ProvidersConfig = { active: false, authStrategy: AuthStrategy.oauth2 }, + //todo 'hubspot_marketing_hub': { scopes: '', urls: { @@ -920,6 +922,7 @@ export const providersConfig: ProvidersConfig = { description: "Sync & Create contacts, deals, companies, notes, engagements, stages, tasks and users", active: false }, + //todo 'keap': { scopes: '', urls: { @@ -932,7 +935,7 @@ export const providersConfig: ProvidersConfig = { active: false, authStrategy: AuthStrategy.oauth2 }, - 'klaviyo': { + 'klaviyo': { scopes: '', urls: { docsUrl: "https://developers.klaviyo.com/en/reference/api_overview", @@ -948,7 +951,7 @@ export const providersConfig: ProvidersConfig = { urls: { authBaseUrl: "https://login.mailchimp.com/oauth2/authorize", docsUrl: "https://mailchimp.com/developer/marketing/api/", - apiUrl: "" + apiUrl: "" //todo }, logoPath: 'https://play-lh.googleusercontent.com/EMobDJKabP1eY_63QHgPS_-TK3eRfxXaeOnERbcRaWAw573iaV74pXS9xOv997dRZtM', description: "Sync & Create contacts, deals, companies, notes, engagements, stages, tasks and users",