From 703e51b134de5cc6d55287b500942072347dd71f Mon Sep 17 00:00:00 2001 From: nael Date: Fri, 9 Aug 2024 06:46:52 +0200 Subject: [PATCH] :sparkles: Amazon integ start --- .../ecommerce/ecommerce.connection.module.ts | 6 + .../ecommerce/services/faire/faire.service.ts | 189 ++++++++++++++ .../mercadolibre/mercadolibre.service.ts | 242 ++++++++++++++++++ .../services/webflow/webflow.service.ts | 185 +++++++++++++ packages/shared/src/authUrl.ts | 3 + packages/shared/src/connectors/metadata.ts | 42 +++ 6 files changed, 667 insertions(+) create mode 100644 packages/api/src/@core/connections/ecommerce/services/faire/faire.service.ts create mode 100644 packages/api/src/@core/connections/ecommerce/services/mercadolibre/mercadolibre.service.ts create mode 100644 packages/api/src/@core/connections/ecommerce/services/webflow/webflow.service.ts diff --git a/packages/api/src/@core/connections/ecommerce/ecommerce.connection.module.ts b/packages/api/src/@core/connections/ecommerce/ecommerce.connection.module.ts index 3eb3df7e4..a178c0fa8 100644 --- a/packages/api/src/@core/connections/ecommerce/ecommerce.connection.module.ts +++ b/packages/api/src/@core/connections/ecommerce/ecommerce.connection.module.ts @@ -11,6 +11,9 @@ import { WoocommerceConnectionService } from './services/woocommerce/woocommerce import { SquarespaceConnectionService } from './services/squarespace/squarespace.service'; import { BigcommerceConnectionService } from './services/bigcommerce/bigcommerce.service'; import { EbayConnectionService } from './services/ebay/ebay.service'; +import { WebflowConnectionService } from './services/webflow/webflow.service'; +import { FaireConnectionService } from './services/faire/faire.service'; +import { MercadolibreConnectionService } from './services/mercadolibre/mercadolibre.service'; @Module({ imports: [WebhookModule, BullQueueModule], @@ -26,6 +29,9 @@ import { EbayConnectionService } from './services/ebay/ebay.service'; SquarespaceConnectionService, BigcommerceConnectionService, EbayConnectionService, + WebflowConnectionService, + FaireConnectionService, + MercadolibreConnectionService, ], exports: [EcommerceConnectionsService], }) diff --git a/packages/api/src/@core/connections/ecommerce/services/faire/faire.service.ts b/packages/api/src/@core/connections/ecommerce/services/faire/faire.service.ts new file mode 100644 index 000000000..0ce6ea90d --- /dev/null +++ b/packages/api/src/@core/connections/ecommerce/services/faire/faire.service.ts @@ -0,0 +1,189 @@ +import { EncryptionService } from '@@core/@core-services/encryption/encryption.service'; +import { LoggerService } from '@@core/@core-services/logger/logger.service'; +import { PrismaService } from '@@core/@core-services/prisma/prisma.service'; +import { RetryHandler } from '@@core/@core-services/request-retry/retry.handler'; +import { ConnectionsStrategiesService } from '@@core/connections-strategies/connections-strategies.service'; +import { ConnectionUtils } from '@@core/connections/@utils'; +import { + AbstractBaseConnectionService, + OAuthCallbackParams, + PassthroughInput, + RefreshParams, +} from '@@core/connections/@utils/types'; +import { PassthroughResponse } from '@@core/passthrough/types'; +import { Injectable } from '@nestjs/common'; +import { + AuthStrategy, + CONNECTORS_METADATA, + OAuth2AuthData, + providerToType, +} from '@panora/shared'; +import { v4 as uuidv4 } from 'uuid'; +import { ServiceRegistry } from '../registry.service'; +import { EnvironmentService } from '@@core/@core-services/environment/environment.service'; +import axios from 'axios'; + +export interface FaireOAuthResponse { + accessToken: string; + tokenType: string; +} + +@Injectable() +export class FaireConnectionService extends AbstractBaseConnectionService { + private readonly type: string; + + constructor( + protected prisma: PrismaService, + private logger: LoggerService, + protected cryptoService: EncryptionService, + private env: EnvironmentService, + private registry: ServiceRegistry, + private connectionUtils: ConnectionUtils, + private cService: ConnectionsStrategiesService, + private retryService: RetryHandler, + ) { + super(prisma, cryptoService); + this.logger.setContext(FaireConnectionService.name); + this.registry.registerService('faire', this); + this.type = providerToType('faire', 'ecommerce', AuthStrategy.oauth2); + } + + async passthrough( + input: PassthroughInput, + connectionId: string, + ): Promise { + try { + const { headers } = input; + const config = await this.constructPassthrough(input, connectionId); + + const connection = await this.prisma.connections.findUnique({ + where: { + id_connection: connectionId, + }, + }); + + const CREDENTIALS = (await this.cService.getCredentials( + connection.id_project, + this.type, + )) as OAuth2AuthData; + + const access_token = JSON.parse( + this.cryptoService.decrypt(connection.access_token), + ); + config.headers = { + ...config.headers, + ...headers, + 'X-FAIRE-OAUTH-ACCESS-TOKEN': access_token, + 'X-FAIRE-APP-CREDENTIALS': Buffer.from( + `${CREDENTIALS.CLIENT_ID}:${CREDENTIALS.CLIENT_SECRET}`, + ).toString('base64'), + }; + + return await this.retryService.makeRequest( + { + method: config.method, + url: config.url, + data: config.data, + headers: config.headers, + }, + 'ecommerce.faire.passthrough', + config.linkedUserId, + ); + } catch (error) { + throw error; + } + } + + async handleCallback(opts: OAuthCallbackParams) { + try { + const { linkedUserId, projectId, code } = opts; + const isNotUnique = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedUserId, + provider_slug: 'faire', + vertical: 'ecommerce', + }, + }); + if (isNotUnique) return; + //reconstruct the redirect URI that was passed in the frontend it must be the same + const REDIRECT_URI = `${this.env.getPanoraBaseUrl()}/connections/oauth/callback`; + + const CREDENTIALS = (await this.cService.getCredentials( + projectId, + this.type, + )) as OAuth2AuthData; + + const formData = new URLSearchParams({ + redirect_url: REDIRECT_URI, + applicationId: CREDENTIALS.CLIENT_ID, + applicationSecret: CREDENTIALS.CLIENT_SECRET, + scope: CONNECTORS_METADATA['ecommerce']['faire'].scopes, + authorization_code: code, + grant_type: 'AUTHORIZATION_CODE', + }); + const res = await axios.post( + 'https://www.faire.com/api/external-api-oauth2/token', + formData.toString(), + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded;charset=utf-8', + }, + }, + ); + const data: FaireOAuthResponse = res.data; + // save tokens for this customer inside our db + let db_res; + const connection_token = uuidv4(); + const BASE_API_URL = CONNECTORS_METADATA['ecommerce']['faire'].urls + .apiUrl as string; + + if (isNotUnique) { + // Update existing connection + db_res = await this.prisma.connections.update({ + where: { + id_connection: isNotUnique.id_connection, + }, + data: { + access_token: this.cryptoService.encrypt(data.accessToken), + status: 'valid', + created_at: new Date(), + }, + }); + } else { + // Create new connection + db_res = await this.prisma.connections.create({ + data: { + id_connection: uuidv4(), + connection_token: connection_token, + provider_slug: 'faire', + vertical: 'ecommerce', + token_type: 'oauth2', + account_url: BASE_API_URL, + access_token: this.cryptoService.encrypt(data.accessToken), + status: 'valid', + created_at: new Date(), + projects: { + connect: { id_project: projectId }, + }, + linked_users: { + connect: { + id_linked_user: await this.connectionUtils.getLinkedUserId( + projectId, + linkedUserId, + ), + }, + }, + }, + }); + } + this.logger.log('Successfully added tokens inside DB ' + db_res); + return db_res; + } catch (error) { + throw error; + } + } + + async handleTokenRefresh(opts: RefreshParams) { + return; + } +} diff --git a/packages/api/src/@core/connections/ecommerce/services/mercadolibre/mercadolibre.service.ts b/packages/api/src/@core/connections/ecommerce/services/mercadolibre/mercadolibre.service.ts new file mode 100644 index 000000000..61a3ba1ff --- /dev/null +++ b/packages/api/src/@core/connections/ecommerce/services/mercadolibre/mercadolibre.service.ts @@ -0,0 +1,242 @@ +import { EncryptionService } from '@@core/@core-services/encryption/encryption.service'; +import { LoggerService } from '@@core/@core-services/logger/logger.service'; +import { PrismaService } from '@@core/@core-services/prisma/prisma.service'; +import { RetryHandler } from '@@core/@core-services/request-retry/retry.handler'; +import { ConnectionsStrategiesService } from '@@core/connections-strategies/connections-strategies.service'; +import { ConnectionUtils } from '@@core/connections/@utils'; +import { + AbstractBaseConnectionService, + OAuthCallbackParams, + PassthroughInput, + RefreshParams, +} from '@@core/connections/@utils/types'; +import { PassthroughResponse } from '@@core/passthrough/types'; +import { Injectable } from '@nestjs/common'; +import { + AuthStrategy, + CONNECTORS_METADATA, + OAuth2AuthData, + providerToType, +} from '@panora/shared'; +import { v4 as uuidv4 } from 'uuid'; +import { ServiceRegistry } from '../registry.service'; +import { EnvironmentService } from '@@core/@core-services/environment/environment.service'; +import axios from 'axios'; + +export interface MercadolibreOAuthResponse { + access_token: string; + token_type: string; + expires_in: string; + user_id: string; + scope: string; + refresh_token: string; +} + +@Injectable() +export class MercadolibreConnectionService extends AbstractBaseConnectionService { + private readonly type: string; + + constructor( + protected prisma: PrismaService, + private logger: LoggerService, + protected cryptoService: EncryptionService, + private env: EnvironmentService, + private registry: ServiceRegistry, + private connectionUtils: ConnectionUtils, + private cService: ConnectionsStrategiesService, + private retryService: RetryHandler, + ) { + super(prisma, cryptoService); + this.logger.setContext(MercadolibreConnectionService.name); + this.registry.registerService('mercadolibre', this); + this.type = providerToType( + 'mercadolibre', + 'ecommerce', + AuthStrategy.oauth2, + ); + } + + async passthrough( + input: PassthroughInput, + connectionId: string, + ): Promise { + try { + const { headers } = input; + const config = await this.constructPassthrough(input, connectionId); + + const connection = await this.prisma.connections.findUnique({ + where: { + id_connection: connectionId, + }, + }); + + const CREDENTIALS = (await this.cService.getCredentials( + connection.id_project, + this.type, + )) as OAuth2AuthData; + + const access_token = JSON.parse( + this.cryptoService.decrypt(connection.access_token), + ); + config.headers = { + ...config.headers, + ...headers, + 'X-FAIRE-OAUTH-ACCESS-TOKEN': access_token, + 'X-FAIRE-APP-CREDENTIALS': Buffer.from( + `${CREDENTIALS.CLIENT_ID}:${CREDENTIALS.CLIENT_SECRET}`, + ).toString('base64'), + }; + + return await this.retryService.makeRequest( + { + method: config.method, + url: config.url, + data: config.data, + headers: config.headers, + }, + 'ecommerce.mercadolibre.passthrough', + config.linkedUserId, + ); + } catch (error) { + throw error; + } + } + + async handleCallback(opts: OAuthCallbackParams) { + try { + const { linkedUserId, projectId, code } = opts; + const isNotUnique = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedUserId, + provider_slug: 'mercadolibre', + vertical: 'ecommerce', + }, + }); + if (isNotUnique) return; + //reconstruct the redirect URI that was passed in the frontend it must be the same + const REDIRECT_URI = `${this.env.getPanoraBaseUrl()}/connections/oauth/callback`; + + const CREDENTIALS = (await this.cService.getCredentials( + projectId, + this.type, + )) as OAuth2AuthData; + + const formData = new URLSearchParams({ + redirect_uri: REDIRECT_URI, + client_id: CREDENTIALS.CLIENT_ID, + client_secret: CREDENTIALS.CLIENT_SECRET, + code: code, + grant_type: 'authorization_code', + }); + const res = await axios.post( + 'https://api.mercadolibre.com/oauth/token', + formData.toString(), + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded;charset=utf-8', + }, + }, + ); + const data: MercadolibreOAuthResponse = res.data; + // save tokens for this customer inside our db + let db_res; + const connection_token = uuidv4(); + const BASE_API_URL = CONNECTORS_METADATA['ecommerce']['mercadolibre'].urls + .apiUrl as string; + if (isNotUnique) { + // Update existing connection + 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), + expiration_timestamp: new Date( + new Date().getTime() + Number(data.expires_in) * 1000, + ), + status: 'valid', + created_at: new Date(), + }, + }); + } else { + // Create new connection + db_res = await this.prisma.connections.create({ + data: { + id_connection: uuidv4(), + connection_token: connection_token, + provider_slug: 'mercadolibre', + vertical: 'ecommerce', + token_type: 'oauth2', + account_url: BASE_API_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: await this.connectionUtils.getLinkedUserId( + projectId, + linkedUserId, + ), + }, + }, + }, + }); + } + this.logger.log('Successfully added tokens inside DB ' + db_res); + return db_res; + } catch (error) { + throw error; + } + } + + 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), + client_id: CREDENTIALS.CLIENT_ID, + client_secret: CREDENTIALS.CLIENT_SECRET, + }); + + const res = await axios.post( + 'https://api.mercadolibre.com/oauth/token', + formData.toString(), + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded;charset=utf-8', + }, + }, + ); + const data: MercadolibreOAuthResponse = res.data; + const res_ = 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 : ebay'); + } catch (error) { + throw error; + } + } +} diff --git a/packages/api/src/@core/connections/ecommerce/services/webflow/webflow.service.ts b/packages/api/src/@core/connections/ecommerce/services/webflow/webflow.service.ts new file mode 100644 index 000000000..1341ceda8 --- /dev/null +++ b/packages/api/src/@core/connections/ecommerce/services/webflow/webflow.service.ts @@ -0,0 +1,185 @@ +import { EncryptionService } from '@@core/@core-services/encryption/encryption.service'; +import { LoggerService } from '@@core/@core-services/logger/logger.service'; +import { PrismaService } from '@@core/@core-services/prisma/prisma.service'; +import { RetryHandler } from '@@core/@core-services/request-retry/retry.handler'; +import { ConnectionsStrategiesService } from '@@core/connections-strategies/connections-strategies.service'; +import { ConnectionUtils } from '@@core/connections/@utils'; +import { + AbstractBaseConnectionService, + OAuthCallbackParams, + PassthroughInput, + RefreshParams, +} from '@@core/connections/@utils/types'; +import { PassthroughResponse } from '@@core/passthrough/types'; +import { Injectable } from '@nestjs/common'; +import { + AuthStrategy, + CONNECTORS_METADATA, + OAuth2AuthData, + providerToType, +} from '@panora/shared'; +import { v4 as uuidv4 } from 'uuid'; +import { ServiceRegistry } from '../registry.service'; +import { EnvironmentService } from '@@core/@core-services/environment/environment.service'; +import axios from 'axios'; + +export interface WebflowOAuthResponse { + access_token: string; +} + +@Injectable() +export class WebflowConnectionService extends AbstractBaseConnectionService { + private readonly type: string; + + constructor( + protected prisma: PrismaService, + private logger: LoggerService, + protected cryptoService: EncryptionService, + private env: EnvironmentService, + private registry: ServiceRegistry, + private connectionUtils: ConnectionUtils, + private cService: ConnectionsStrategiesService, + private retryService: RetryHandler, + ) { + super(prisma, cryptoService); + this.logger.setContext(WebflowConnectionService.name); + this.registry.registerService('webflow', this); + this.type = providerToType('webflow', 'ecommerce', AuthStrategy.oauth2); + } + + async passthrough( + input: PassthroughInput, + connectionId: string, + ): Promise { + try { + const { headers } = input; + const config = await this.constructPassthrough(input, connectionId); + + const connection = await this.prisma.connections.findUnique({ + where: { + id_connection: connectionId, + }, + }); + + const access_token = JSON.parse( + this.cryptoService.decrypt(connection.access_token), + ); + config.headers = { + ...config.headers, + ...headers, + Authorization: `Bearer ${access_token}`, + }; + + return await this.retryService.makeRequest( + { + method: config.method, + url: config.url, + data: config.data, + headers: config.headers, + }, + 'ecommerce.webflow.passthrough', + config.linkedUserId, + ); + } catch (error) { + throw error; + } + } + + async handleCallback(opts: OAuthCallbackParams) { + try { + const { linkedUserId, projectId, code } = opts; + const isNotUnique = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedUserId, + provider_slug: 'webflow', + vertical: 'ecommerce', + }, + }); + if (isNotUnique) return; + //reconstruct the redirect URI that was passed in the frontend it must be the same + const REDIRECT_URI = `${this.env.getPanoraBaseUrl()}/connections/oauth/callback`; + + const CREDENTIALS = (await this.cService.getCredentials( + projectId, + this.type, + )) as OAuth2AuthData; + + const formData = new URLSearchParams({ + redirect_uri: REDIRECT_URI, + client_id: CREDENTIALS.CLIENT_ID, + client_secret: CREDENTIALS.CLIENT_SECRET, + code: code, + }); + const res = await axios.post( + 'https://api.webflow.com/oauth/access_token', + formData.toString(), + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded;charset=utf-8', + }, + }, + ); + const data: WebflowOAuthResponse = res.data; + // save tokens for this customer inside our db + let db_res; + const connection_token = uuidv4(); + const BASE_API_URL = CONNECTORS_METADATA['ecommerce']['webflow'].urls + .apiUrl as string; + // get the site id for the token + const site = await axios.get('https://api.webflow.com/v2/sites', { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded;charset=utf-8', + Authorization: `Bearer ${data.access_token}`, + }, + }); + const site_id = site.data.sites[0].id; + if (isNotUnique) { + // Update existing connection + db_res = await this.prisma.connections.update({ + where: { + id_connection: isNotUnique.id_connection, + }, + data: { + access_token: this.cryptoService.encrypt(data.access_token), + status: 'valid', + created_at: new Date(), + }, + }); + } else { + // Create new connection + db_res = await this.prisma.connections.create({ + data: { + id_connection: uuidv4(), + connection_token: connection_token, + provider_slug: 'webflow', + vertical: 'ecommerce', + token_type: 'oauth2', + account_url: `${BASE_API_URL}/sites/${site_id}`, + access_token: this.cryptoService.encrypt(data.access_token), + status: 'valid', + created_at: new Date(), + projects: { + connect: { id_project: projectId }, + }, + linked_users: { + connect: { + id_linked_user: await this.connectionUtils.getLinkedUserId( + projectId, + linkedUserId, + ), + }, + }, + }, + }); + } + this.logger.log('Successfully added tokens inside DB ' + db_res); + return db_res; + } catch (error) { + throw error; + } + } + + async handleTokenRefresh(opts: RefreshParams) { + return; + } +} diff --git a/packages/shared/src/authUrl.ts b/packages/shared/src/authUrl.ts index 743499225..fd364c44d 100644 --- a/packages/shared/src/authUrl.ts +++ b/packages/shared/src/authUrl.ts @@ -165,6 +165,9 @@ const handleOAuth2Url = async (input: HandleOAuth2Url) => { if(providerName === 'pipedrive' || providerName === 'shopify' || providerName === 'squarespace') { params = `client_id=${encodeURIComponent(clientId)}&redirect_uri=${encodedRedirectUrl}&state=${state}`; } + if(providerName === 'faire'){ + params = `applicationId=${encodeURIComponent(clientId)}&redirectUrl=${encodedRedirectUrl}&state=${state}`; + } if (needsScope(providerName, vertical) && scopes) { if (providerName === 'slack') { diff --git a/packages/shared/src/connectors/metadata.ts b/packages/shared/src/connectors/metadata.ts index 033b4b98e..41dc7fb9d 100644 --- a/packages/shared/src/connectors/metadata.ts +++ b/packages/shared/src/connectors/metadata.ts @@ -2862,6 +2862,48 @@ export const CONNECTORS_METADATA: ProvidersConfig = { strategy: AuthStrategy.oauth2 }, }, + 'webflow': { + scopes: '', + urls: { + docsUrl: 'https://developers.webflow.com/data/reference/rest-introduction', + apiUrl: 'https://api.webflow.com/v2', + authBaseUrl: 'https://webflow.com/oauth/authorize' + }, + logoPath: 'https://dailybrand.co.zw/wp-content/uploads/2023/10/webflow-2.png', + description: 'Sync & Create orders, fulfillments, fulfillment orders, customers and products', + active: false, + authStrategy: { + strategy: AuthStrategy.oauth2 + }, + }, + 'faire': { + scopes: 'READ_PRODUCTS WRITE_PRODUCTS READ_ORDERS WRITE_ORDERS READ_INVENTORIES WRITE_INVENTORIES', + urls: { + docsUrl: 'https://faire.github.io/external-api-v2-docs', + apiUrl: 'https://www.faire.com/external-api', + authBaseUrl: 'https://faire.com/oauth2/authorize' + }, + logoPath: 'https://images.privco.com/production/41d79c31c7d70549830a684a77bf3076.png', + description: 'Sync & Create orders, fulfillments, fulfillment orders, customers and products', + active: false, + authStrategy: { + strategy: AuthStrategy.oauth2 + }, + }, + 'mercadolibre': { + scopes: '', + urls: { + docsUrl: 'https://global-selling.mercadolibre.com/devsite/introduction-globalselling', + apiUrl: 'https://api.mercadolibre.com', + authBaseUrl: 'https://global-selling.mercadolibre.com/authorization' + }, + logoPath: 'https://api.getkoala.com/web/companies/mercadolibre.com/logo', + description: 'Sync & Create orders, fulfillments, fulfillment orders, customers and products', + active: false, + authStrategy: { + strategy: AuthStrategy.oauth2 + }, + }, 'woocommerce': { urls: { docsUrl: 'https://woocommerce.github.io/woocommerce-rest-api-docs/#introduction',