From 35c72475a7fe522bd0db70c922ec69e8474f6250 Mon Sep 17 00:00:00 2001 From: nael Date: Thu, 18 Jul 2024 10:13:44 +0200 Subject: [PATCH] :tada: Added files --- .../unification/core-unification.service.ts | 20 ++ .../connections-strategies.module.ts | 2 - .../@core/connections/connections.module.ts | 3 + .../ecommerce/ecommerce.connection.module.ts | 24 ++ .../services/ecommerce.connection.service.ts | 104 +++++++ .../ecommerce/services/registry.service.ts | 25 ++ .../services/shopify/shopify.service.ts | 84 ++++++ .../connections/ecommerce/types/index.ts | 7 + packages/api/src/@core/sync/sync.service.ts | 42 +++ .../src/@core/utils/types/desunify.input.ts | 4 +- packages/api/src/@core/utils/types/index.ts | 7 +- .../types/original/original.ecommerce.ts | 68 +++++ .../api/src/@core/utils/types/unify.output.ts | 4 +- .../api/src/ecommerce/@lib/@types/index.ts | 152 ++--------- .../src/ecommerce/@lib/@unification/index.ts | 27 +- .../api/src/ecommerce/@lib/@utils/index.ts | 253 ------------------ .../ecommerce/customer/customer.controller.ts | 120 +++++++++ .../src/ecommerce/customer/customer.module.ts | 31 +++ .../customer/services/customer.service.ts | 234 ++++++++++++++++ .../customer/services/registry.service.ts | 25 ++ .../customer/services/shopify/index.ts | 68 +++++ .../customer/services/shopify/mappers.ts | 78 ++++++ .../customer/services/shopify/types.ts | 8 + .../ecommerce/customer/sync/sync.processor.ts | 19 ++ .../ecommerce/customer/sync/sync.service.ts | 197 ++++++++++++++ .../api/src/ecommerce/customer/types/index.ts | 33 +++ .../ecommerce/customer/types/model.unified.ts | 61 +++++ .../api/src/ecommerce/customer/utils/index.ts | 1 + .../api/src/ecommerce/ecommerce.module.ts | 44 +-- .../fulfillment/fulfillment.controller.ts | 120 +++++++++ .../fulfillment/fulfillment.module.ts | 41 +++ .../fulfillment/services/ashby/index.ts | 70 +++++ .../fulfillment/services/ashby/mappers.ts | 73 +++++ .../fulfillment/services/ashby/types.ts | 8 + .../services/department.service.ts | 234 ++++++++++++++++ .../fulfillment/services/registry.service.ts | 25 ++ .../fulfillment/sync/sync.processor.ts | 19 ++ .../fulfillment/sync/sync.service.ts | 203 ++++++++++++++ .../src/ecommerce/fulfillment/types/index.ts | 36 +++ .../fulfillment/types/model.unified.ts | 61 +++++ .../src/ecommerce/fulfillment/utils/index.ts | 1 + .../fulfillmentorders.controller.ts | 120 +++++++++ .../fulfillmentorders.module.ts | 41 +++ .../fulfillmentorders/services/ashby/index.ts | 70 +++++ .../services/ashby/mappers.ts | 73 +++++ .../fulfillmentorders/services/ashby/types.ts | 8 + .../services/department.service.ts | 234 ++++++++++++++++ .../services/registry.service.ts | 25 ++ .../fulfillmentorders/sync/sync.processor.ts | 19 ++ .../fulfillmentorders/sync/sync.service.ts | 203 ++++++++++++++ .../fulfillmentorders/types/index.ts | 36 +++ .../fulfillmentorders/types/model.unified.ts | 61 +++++ .../fulfillmentorders/utils/index.ts | 1 + .../src/ecommerce/order/order.controller.ts | 120 +++++++++ .../api/src/ecommerce/order/order.module.ts | 41 +++ .../ecommerce/order/services/ashby/index.ts | 70 +++++ .../ecommerce/order/services/ashby/mappers.ts | 73 +++++ .../ecommerce/order/services/ashby/types.ts | 8 + .../order/services/department.service.ts | 234 ++++++++++++++++ .../order/services/registry.service.ts | 25 ++ .../ecommerce/order/sync/sync.processor.ts | 19 ++ .../src/ecommerce/order/sync/sync.service.ts | 203 ++++++++++++++ .../api/src/ecommerce/order/types/index.ts | 36 +++ .../ecommerce/order/types/model.unified.ts | 61 +++++ .../api/src/ecommerce/order/utils/index.ts | 1 + .../ecommerce/product/product.controller.ts | 120 +++++++++ .../src/ecommerce/product/product.module.ts | 31 +++ .../product/services/product.service.ts | 234 ++++++++++++++++ .../product/services/registry.service.ts | 25 ++ .../product/services/shopify/index.ts | 68 +++++ .../product/services/shopify/mappers.ts | 72 +++++ .../product/services/shopify/types.ts | 5 + .../ecommerce/product/sync/sync.processor.ts | 19 ++ .../ecommerce/product/sync/sync.service.ts | 198 ++++++++++++++ .../api/src/ecommerce/product/types/index.ts | 33 +++ .../ecommerce/product/types/model.unified.ts | 53 ++++ .../api/src/ecommerce/product/utils/index.ts | 1 + packages/api/tsconfig.json | 1 + 78 files changed, 4827 insertions(+), 451 deletions(-) create mode 100644 packages/api/src/@core/connections/ecommerce/ecommerce.connection.module.ts create mode 100644 packages/api/src/@core/connections/ecommerce/services/ecommerce.connection.service.ts create mode 100644 packages/api/src/@core/connections/ecommerce/services/registry.service.ts create mode 100644 packages/api/src/@core/connections/ecommerce/services/shopify/shopify.service.ts create mode 100644 packages/api/src/@core/connections/ecommerce/types/index.ts create mode 100644 packages/api/src/@core/utils/types/original/original.ecommerce.ts create mode 100644 packages/api/src/ecommerce/customer/customer.controller.ts create mode 100644 packages/api/src/ecommerce/customer/customer.module.ts create mode 100644 packages/api/src/ecommerce/customer/services/customer.service.ts create mode 100644 packages/api/src/ecommerce/customer/services/registry.service.ts create mode 100644 packages/api/src/ecommerce/customer/services/shopify/index.ts create mode 100644 packages/api/src/ecommerce/customer/services/shopify/mappers.ts create mode 100644 packages/api/src/ecommerce/customer/services/shopify/types.ts create mode 100644 packages/api/src/ecommerce/customer/sync/sync.processor.ts create mode 100644 packages/api/src/ecommerce/customer/sync/sync.service.ts create mode 100644 packages/api/src/ecommerce/customer/types/index.ts create mode 100644 packages/api/src/ecommerce/customer/types/model.unified.ts create mode 100644 packages/api/src/ecommerce/customer/utils/index.ts create mode 100644 packages/api/src/ecommerce/fulfillment/fulfillment.controller.ts create mode 100644 packages/api/src/ecommerce/fulfillment/fulfillment.module.ts create mode 100644 packages/api/src/ecommerce/fulfillment/services/ashby/index.ts create mode 100644 packages/api/src/ecommerce/fulfillment/services/ashby/mappers.ts create mode 100644 packages/api/src/ecommerce/fulfillment/services/ashby/types.ts create mode 100644 packages/api/src/ecommerce/fulfillment/services/department.service.ts create mode 100644 packages/api/src/ecommerce/fulfillment/services/registry.service.ts create mode 100644 packages/api/src/ecommerce/fulfillment/sync/sync.processor.ts create mode 100644 packages/api/src/ecommerce/fulfillment/sync/sync.service.ts create mode 100644 packages/api/src/ecommerce/fulfillment/types/index.ts create mode 100644 packages/api/src/ecommerce/fulfillment/types/model.unified.ts create mode 100644 packages/api/src/ecommerce/fulfillment/utils/index.ts create mode 100644 packages/api/src/ecommerce/fulfillmentorders/fulfillmentorders.controller.ts create mode 100644 packages/api/src/ecommerce/fulfillmentorders/fulfillmentorders.module.ts create mode 100644 packages/api/src/ecommerce/fulfillmentorders/services/ashby/index.ts create mode 100644 packages/api/src/ecommerce/fulfillmentorders/services/ashby/mappers.ts create mode 100644 packages/api/src/ecommerce/fulfillmentorders/services/ashby/types.ts create mode 100644 packages/api/src/ecommerce/fulfillmentorders/services/department.service.ts create mode 100644 packages/api/src/ecommerce/fulfillmentorders/services/registry.service.ts create mode 100644 packages/api/src/ecommerce/fulfillmentorders/sync/sync.processor.ts create mode 100644 packages/api/src/ecommerce/fulfillmentorders/sync/sync.service.ts create mode 100644 packages/api/src/ecommerce/fulfillmentorders/types/index.ts create mode 100644 packages/api/src/ecommerce/fulfillmentorders/types/model.unified.ts create mode 100644 packages/api/src/ecommerce/fulfillmentorders/utils/index.ts create mode 100644 packages/api/src/ecommerce/order/order.controller.ts create mode 100644 packages/api/src/ecommerce/order/order.module.ts create mode 100644 packages/api/src/ecommerce/order/services/ashby/index.ts create mode 100644 packages/api/src/ecommerce/order/services/ashby/mappers.ts create mode 100644 packages/api/src/ecommerce/order/services/ashby/types.ts create mode 100644 packages/api/src/ecommerce/order/services/department.service.ts create mode 100644 packages/api/src/ecommerce/order/services/registry.service.ts create mode 100644 packages/api/src/ecommerce/order/sync/sync.processor.ts create mode 100644 packages/api/src/ecommerce/order/sync/sync.service.ts create mode 100644 packages/api/src/ecommerce/order/types/index.ts create mode 100644 packages/api/src/ecommerce/order/types/model.unified.ts create mode 100644 packages/api/src/ecommerce/order/utils/index.ts create mode 100644 packages/api/src/ecommerce/product/product.controller.ts create mode 100644 packages/api/src/ecommerce/product/product.module.ts create mode 100644 packages/api/src/ecommerce/product/services/product.service.ts create mode 100644 packages/api/src/ecommerce/product/services/registry.service.ts create mode 100644 packages/api/src/ecommerce/product/services/shopify/index.ts create mode 100644 packages/api/src/ecommerce/product/services/shopify/mappers.ts create mode 100644 packages/api/src/ecommerce/product/services/shopify/types.ts create mode 100644 packages/api/src/ecommerce/product/sync/sync.processor.ts create mode 100644 packages/api/src/ecommerce/product/sync/sync.service.ts create mode 100644 packages/api/src/ecommerce/product/types/index.ts create mode 100644 packages/api/src/ecommerce/product/types/model.unified.ts create mode 100644 packages/api/src/ecommerce/product/utils/index.ts diff --git a/packages/api/src/@core/@core-services/unification/core-unification.service.ts b/packages/api/src/@core/@core-services/unification/core-unification.service.ts index 1af81a74d..a0ec17d50 100644 --- a/packages/api/src/@core/@core-services/unification/core-unification.service.ts +++ b/packages/api/src/@core/@core-services/unification/core-unification.service.ts @@ -45,6 +45,17 @@ export class CoreUnification { if (sourceObject == null) return []; let targetType_: TargetObject; switch (vertical.toLowerCase()) { + case ConnectorCategory.Ecommerce: + targetType_ = targetType as EcommerceObject; + const ecommerceRegistry = this.registry.getService('ecommerce'); + return ecommerceRegistry.unify({ + sourceObject, + targetType_, + providerName, + connectionId, + customFieldMappings, + extraParams, + }); case ConnectorCategory.Crm: targetType_ = targetType as CrmObject; const crmRegistry = this.registry.getService('crm'); @@ -151,6 +162,15 @@ export class CoreUnification { try { let targetType_: TargetObject; switch (vertical.toLowerCase()) { + case ConnectorCategory.Ecommerce: + targetType_ = targetType as EcommerceObject; + const ecommerceRegistry = this.registry.getService('crm'); + return ecommerceRegistry.desunify({ + sourceObject, + targetType_, + providerName, + customFieldMappings, + }); case ConnectorCategory.Crm: targetType_ = targetType as CrmObject; const crmRegistry = this.registry.getService('crm'); diff --git a/packages/api/src/@core/connections-strategies/connections-strategies.module.ts b/packages/api/src/@core/connections-strategies/connections-strategies.module.ts index cb6f3abb5..958733274 100644 --- a/packages/api/src/@core/connections-strategies/connections-strategies.module.ts +++ b/packages/api/src/@core/connections-strategies/connections-strategies.module.ts @@ -1,5 +1,3 @@ -import { EncryptionService } from '@@core/@core-services/encryption/encryption.service'; -import { LoggerService } from '@@core/@core-services/logger/logger.service'; import { ValidateUserService } from '@@core/utils/services/validate-user.service'; import { Module } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; diff --git a/packages/api/src/@core/connections/connections.module.ts b/packages/api/src/@core/connections/connections.module.ts index 62942200d..834317431 100644 --- a/packages/api/src/@core/connections/connections.module.ts +++ b/packages/api/src/@core/connections/connections.module.ts @@ -11,6 +11,7 @@ import { HrisConnectionModule } from './hris/hris.connection.module'; import { ManagementConnectionsModule } from './management/management.connection.module'; import { MarketingAutomationConnectionsModule } from './marketingautomation/marketingautomation.connection.module'; import { TicketingConnectionModule } from './ticketing/ticketing.connection.module'; +import { EcommerceConnectionModule } from './ecommerce/ecommerce.connection.module'; @Module({ controllers: [ConnectionsController], @@ -23,6 +24,7 @@ import { TicketingConnectionModule } from './ticketing/ticketing.connection.modu MarketingAutomationConnectionsModule, FilestorageConnectionModule, HrisConnectionModule, + EcommerceConnectionModule, SyncModule, ], providers: [ValidateUserService, OAuthTokenRefreshService], @@ -35,6 +37,7 @@ import { TicketingConnectionModule } from './ticketing/ticketing.connection.modu AtsConnectionModule, MarketingAutomationConnectionsModule, FilestorageConnectionModule, + EcommerceConnectionModule, HrisConnectionModule, ManagementConnectionsModule, ], diff --git a/packages/api/src/@core/connections/ecommerce/ecommerce.connection.module.ts b/packages/api/src/@core/connections/ecommerce/ecommerce.connection.module.ts new file mode 100644 index 000000000..668914d7a --- /dev/null +++ b/packages/api/src/@core/connections/ecommerce/ecommerce.connection.module.ts @@ -0,0 +1,24 @@ +import { EnvironmentService } from '@@core/@core-services/environment/environment.service'; +import { BullQueueModule } from '@@core/@core-services/queues/queue.module'; +import { WebhookModule } from '@@core/@core-services/webhooks/panora-webhooks/webhook.module'; +import { WebhookService } from '@@core/@core-services/webhooks/panora-webhooks/webhook.service'; +import { ConnectionsStrategiesService } from '@@core/connections-strategies/connections-strategies.service'; +import { Module } from '@nestjs/common'; +import { EcommerceConnectionsService } from './services/ecommerce.connection.service'; +import { ServiceRegistry } from './services/registry.service'; +import { ShopifyConnectionService } from './services/shopify/shopify.service'; + +@Module({ + imports: [WebhookModule, BullQueueModule], + providers: [ + EcommerceConnectionsService, + WebhookService, + EnvironmentService, + ServiceRegistry, + ConnectionsStrategiesService, + //PROVIDERS SERVICES, + ShopifyConnectionService, + ], + exports: [EcommerceConnectionsService], +}) +export class EcommerceConnectionModule {} diff --git a/packages/api/src/@core/connections/ecommerce/services/ecommerce.connection.service.ts b/packages/api/src/@core/connections/ecommerce/services/ecommerce.connection.service.ts new file mode 100644 index 000000000..850b37411 --- /dev/null +++ b/packages/api/src/@core/connections/ecommerce/services/ecommerce.connection.service.ts @@ -0,0 +1,104 @@ +import { LoggerService } from '@@core/@core-services/logger/logger.service'; +import { PrismaService } from '@@core/@core-services/prisma/prisma.service'; +import { WebhookService } from '@@core/@core-services/webhooks/panora-webhooks/webhook.service'; +import { + CallbackParams, + IConnectionCategory, + RefreshParams, +} from '@@core/connections/@utils/types'; +import { Injectable } from '@nestjs/common'; +import { connections as Connection } from '@prisma/client'; +import { v4 as uuidv4 } from 'uuid'; +import { ServiceRegistry } from './registry.service'; +import { CategoryConnectionRegistry } from '@@core/@core-services/registries/connections-categories.registry'; + +@Injectable() +export class EcommerceConnectionsService implements IConnectionCategory { + constructor( + private serviceRegistry: ServiceRegistry, + private connectionCategoryRegistry: CategoryConnectionRegistry, + private webhook: WebhookService, + private logger: LoggerService, + private prisma: PrismaService, + ) { + this.logger.setContext(EcommerceConnectionsService.name); + this.connectionCategoryRegistry.registerService('ecommerce', this); + } + //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 handleCallBack( + providerName: string, + callbackOpts: CallbackParams, + type_strategy: 'oauth' | 'apikey' | 'basic', + ) { + try { + const serviceName = providerName.toLowerCase(); + + const service = this.serviceRegistry.getService(serviceName); + + if (!service) { + throw new ReferenceError(`Unknown provider, found ${providerName}`); + } + 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: `/${type_strategy}/callback`, + provider: providerName.toLowerCase(), + direction: '0', + timestamp: new Date(), + id_linked_user: callbackOpts.linkedUserId, + }, + }); + //directly send the webhook + await this.webhook.deliverWebhook( + data, + 'connection.created', + callbackOpts.projectId, + event.id_event, + ); + } catch (error) { + throw error; + } + } + + async handleTokensRefresh( + 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 ReferenceError(`Unknown provider, found ${providerName}`); + } + const refreshOpts: RefreshParams = { + connectionId: connectionId, + refreshToken: refresh_token, + account_url: account_url, + projectId: id_project, + }; + await service.handleTokenRefresh(refreshOpts); + } catch (error) { + throw error; + } + } +} diff --git a/packages/api/src/@core/connections/ecommerce/services/registry.service.ts b/packages/api/src/@core/connections/ecommerce/services/registry.service.ts new file mode 100644 index 000000000..b7dd2e244 --- /dev/null +++ b/packages/api/src/@core/connections/ecommerce/services/registry.service.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@nestjs/common'; +import { IEcommerceConnectionService } from '../types'; + +@Injectable() +export class ServiceRegistry { + private serviceMap: Map; + + constructor() { + this.serviceMap = new Map(); + } + + registerService(serviceKey: string, service: IEcommerceConnectionService) { + this.serviceMap.set(serviceKey, service); + } + + getService(integrationId: string): IEcommerceConnectionService { + const service = this.serviceMap.get(integrationId); + if (!service) { + throw new ReferenceError( + `Service not found for integration ID: ${integrationId}`, + ); + } + return service; + } +} diff --git a/packages/api/src/@core/connections/ecommerce/services/shopify/shopify.service.ts b/packages/api/src/@core/connections/ecommerce/services/shopify/shopify.service.ts new file mode 100644 index 000000000..0803b5046 --- /dev/null +++ b/packages/api/src/@core/connections/ecommerce/services/shopify/shopify.service.ts @@ -0,0 +1,84 @@ +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 { ConnectionUtils } from '@@core/connections/@utils'; +import { APIKeyCallbackParams } from '@@core/connections/@utils/types'; +import { Injectable } from '@nestjs/common'; +import { CONNECTORS_METADATA } from '@panora/shared'; +import { v4 as uuidv4 } from 'uuid'; +import { IEcommerceConnectionService } from '../../types'; +import { ServiceRegistry } from '../registry.service'; + +@Injectable() +export class ShopifyConnectionService implements IEcommerceConnectionService { + constructor( + private prisma: PrismaService, + private logger: LoggerService, + private cryptoService: EncryptionService, + private registry: ServiceRegistry, + private connectionUtils: ConnectionUtils, + ) { + this.logger.setContext(ShopifyConnectionService.name); + this.registry.registerService('ashby', this); + } + + async handleCallback(opts: APIKeyCallbackParams) { + try { + const { linkedUserId, projectId } = opts; + const isNotUnique = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedUserId, + provider_slug: 'ashby', + vertical: 'ecommerce', + }, + }); + + 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(opts.apikey), + account_url: CONNECTORS_METADATA['ecommerce']['ashby'].urls + .apiUrl as string, + status: 'valid', + created_at: new Date(), + }, + }); + } else { + db_res = await this.prisma.connections.create({ + data: { + id_connection: uuidv4(), + connection_token: connection_token, + provider_slug: 'ashby', + vertical: 'ecommerce', + token_type: 'api_key', + account_url: CONNECTORS_METADATA['ecommerce']['ashby'].urls + .apiUrl as string, + access_token: this.cryptoService.encrypt(opts.apikey), + status: 'valid', + created_at: new Date(), + projects: { + connect: { id_project: projectId }, + }, + linked_users: { + connect: { + id_linked_user: await this.connectionUtils.getLinkedUserId( + projectId, + linkedUserId, + ), + }, + }, + }, + }); + } + return db_res; + } catch (error) { + throw error; + } + } +} diff --git a/packages/api/src/@core/connections/ecommerce/types/index.ts b/packages/api/src/@core/connections/ecommerce/types/index.ts new file mode 100644 index 000000000..788cb2c03 --- /dev/null +++ b/packages/api/src/@core/connections/ecommerce/types/index.ts @@ -0,0 +1,7 @@ +import { CallbackParams, RefreshParams } from '@@core/connections/@utils/types'; +import { connections as Connection } from '@prisma/client'; + +export interface IEcommerceConnectionService { + handleCallback(opts: CallbackParams): Promise; + handleTokenRefresh?(opts: RefreshParams): Promise; +} diff --git a/packages/api/src/@core/sync/sync.service.ts b/packages/api/src/@core/sync/sync.service.ts index 5bfc34e63..572b242ed 100644 --- a/packages/api/src/@core/sync/sync.service.ts +++ b/packages/api/src/@core/sync/sync.service.ts @@ -30,6 +30,9 @@ export class CoreSyncService { case ConnectorCategory.Ats: await this.handleAtsSync(provider, linkedUserId); break; + case ConnectorCategory.Ecommerce: + await this.handleEcommerceSync(provider, linkedUserId); + break; } } catch (error) { throw error; @@ -278,6 +281,45 @@ export class CoreSyncService { } } + async handleEcommerceSync(provider: string, linkedUserId: string) { + const tasks = [ + () => + this.registry.getService('ecommerce', 'order').syncForLinkedUser({ + integrationId: provider, + linkedUserId: linkedUserId, + }), + () => + this.registry + .getService('ecommerce', 'fulfillmentorders') + .syncForLinkedUser({ + integrationId: provider, + linkedUserId: linkedUserId, + }), + () => + this.registry.getService('ecommerce', 'fulfillment').syncForLinkedUser({ + integrationId: provider, + linkedUserId: linkedUserId, + }), + () => + this.registry.getService('ecommerce', 'product').syncForLinkedUser({ + integrationId: provider, + linkedUserId: linkedUserId, + }), + () => + this.registry.getService('ecommerce', 'customer').syncForLinkedUser({ + integrationId: provider, + linkedUserId: linkedUserId, + }), + ]; + for (const task of tasks) { + try { + await task(); + } catch (error) { + this.logger.error(`Ecommerce Task failed: ${error.message}`, error); + } + } + } + async handleAtsSync(provider: string, linkedUserId: string) { const tasks = [ () => diff --git a/packages/api/src/@core/utils/types/desunify.input.ts b/packages/api/src/@core/utils/types/desunify.input.ts index 2d7715b5d..f98eba7a0 100644 --- a/packages/api/src/@core/utils/types/desunify.input.ts +++ b/packages/api/src/@core/utils/types/desunify.input.ts @@ -1,6 +1,7 @@ import { AccountingObjectInput } from './original/original.accounting'; import { AtsObjectInput } from './original/original.ats'; import { CrmObjectInput } from './original/original.crm'; +import { EcommerceObjectInput } from './original/original.ecommerce'; import { FileStorageObjectInput } from './original/original.file-storage'; import { HrisObjectInput } from './original/original.hris'; import { MarketingAutomationObjectInput } from './original/original.marketing-automation'; @@ -13,4 +14,5 @@ export type DesunifyReturnType = | MarketingAutomationObjectInput | AccountingObjectInput | FileStorageObjectInput - | HrisObjectInput; + | HrisObjectInput + | EcommerceObjectInput; diff --git a/packages/api/src/@core/utils/types/index.ts b/packages/api/src/@core/utils/types/index.ts index 3ff05148c..7232987e2 100644 --- a/packages/api/src/@core/utils/types/index.ts +++ b/packages/api/src/@core/utils/types/index.ts @@ -19,6 +19,7 @@ import { MarketingAutomationObject, UnifiedMarketingAutomation, } from '@marketingautomation/@lib/@types'; +import { UnifiedEcommerce } from '@ecommerce/@lib/@types'; export type Unified = | UnifiedCrm @@ -27,7 +28,8 @@ export type Unified = | UnifiedMarketingAutomation | UnifiedAts | UnifiedHris - | UnifiedAccounting; + | UnifiedAccounting + | UnifiedEcommerce; export type UnifyReturnType = Unified | Unified[]; @@ -38,7 +40,8 @@ export type TargetObject = | AccountingObject | FileStorageObject | MarketingAutomationObject - | TicketingObject; + | TicketingObject + | EcommerceObject; export type StandardObject = TargetObject; diff --git a/packages/api/src/@core/utils/types/original/original.ecommerce.ts b/packages/api/src/@core/utils/types/original/original.ecommerce.ts new file mode 100644 index 000000000..e9608f06c --- /dev/null +++ b/packages/api/src/@core/utils/types/original/original.ecommerce.ts @@ -0,0 +1,68 @@ +/* INPUT */ + +import { + ShopifyProductInput, + ShopifyProductOutput, +} from '@ecommerce/product/services/shopify/types'; +import { + ShopifyOrderInput, + ShopifyOrderOutput, +} from '@ecommerce/order/services/shopify/types'; +import { + ShopifyFulfillmentOrdersInput, + ShopifyFulfillmentOrdersOutput, +} from '@ecommerce/fulfillmentorders/services/shopify/types'; +import { + ShopifyCustomerInput, + ShopifyCustomerOutput, +} from '@ecommerce/customer/services/shopify/types'; +import { + ShopifyFulfillmentInput, + ShopifyFulfillmentOrderOutput, +} from '@ecommerce/fulfillment/services/shopify/types'; + +/* product */ +export type OriginalProductInput = ShopifyProductInput; + +/* order */ +export type OriginalOrderInput = ShopifyOrderInput; + +/* fulfillmentorders */ +export type OriginalFulfillmentOrdersInput = ShopifyFulfillmentOrdersInput; + +/* customer */ +export type OriginalCustomerInput = ShopifyCustomerInput; + +/* fulfillment */ +export type OriginalFulfillmentInput = ShopifyFulfillmentInput; + +export type EcommerceObjectInput = + | OriginalProductInput + | OriginalOrderInput + | OriginalFulfillmentOrdersInput + | OriginalCustomerInput + | OriginalFulfillmentInput; + +/* OUTPUT */ + +/* product */ +export type OriginalProductOutput = ShopifyProductInput; + +/* order */ +export type OriginalOrderOutput = ShopifyOrderInput; + +/* fulfillmentorders */ +export type OriginalFulfillmentOrdersOutput = ShopifyFulfillmentOrdersInput; + +/* customer */ +export type OriginalCustomerOutput = ShopifyCustomerInput; + +/* fulfillment */ +export type OriginalFulfillmentOutput = ShopifyFulfillmentInput; + +export type EcommerceObjectOutput = + | OriginalProductOutput + | OriginalOrderOutput + | OriginalFulfillmentOrdersOutput + | OriginalCustomerOutput + | OriginalFulfillmentOutput; diff --git a/packages/api/src/@core/utils/types/unify.output.ts b/packages/api/src/@core/utils/types/unify.output.ts index 1f2b62ba5..e0725b161 100644 --- a/packages/api/src/@core/utils/types/unify.output.ts +++ b/packages/api/src/@core/utils/types/unify.output.ts @@ -1,6 +1,7 @@ import { AccountingObjectOutput } from './original/original.accounting'; import { AtsObjectOutput } from './original/original.ats'; import { CrmObjectOutput } from './original/original.crm'; +import { EcommerceObjectOutput } from './original/original.ecommerce'; import { FileStorageObjectOutput } from './original/original.file-storage'; import { HrisObjectOutput } from './original/original.hris'; import { MarketingAutomationObjectOutput } from './original/original.marketing-automation'; @@ -13,4 +14,5 @@ export type UnifySourceType = | MarketingAutomationObjectOutput | AccountingObjectOutput | FileStorageObjectOutput - | HrisObjectOutput; + | HrisObjectOutput + | EcommerceObjectOutput; diff --git a/packages/api/src/ecommerce/@lib/@types/index.ts b/packages/api/src/ecommerce/@lib/@types/index.ts index 83983990e..333831a6f 100644 --- a/packages/api/src/ecommerce/@lib/@types/index.ts +++ b/packages/api/src/ecommerce/@lib/@types/index.ts @@ -1,142 +1,20 @@ -import { IActivityService } from '@ats/activity/types'; -import { - UnifiedActivityInput, - UnifiedActivityOutput, -} from '@ats/activity/types/model.unified'; -import { IApplicationService } from '@ats/application/types'; -import { - UnifiedApplicationInput, - UnifiedApplicationOutput, -} from '@ats/application/types/model.unified'; -import { IAttachmentService } from '@ats/attachment/types'; -import { - UnifiedAttachmentInput, - UnifiedAttachmentOutput, -} from '@ats/attachment/types/model.unified'; -import { ICandidateService } from '@ats/candidate/types'; -import { - UnifiedCandidateInput, - UnifiedCandidateOutput, -} from '@ats/candidate/types/model.unified'; -import { IDepartmentService } from '@ats/department/types'; -import { - UnifiedDepartmentInput, - UnifiedDepartmentOutput, -} from '@ats/department/types/model.unified'; -import { IEeocsService } from '@ats/eeocs/types'; -import { - UnifiedEeocsInput, - UnifiedEeocsOutput, -} from '@ats/eeocs/types/model.unified'; -import { IInterviewService } from '@ats/interview/types'; -import { - UnifiedInterviewInput, - UnifiedInterviewOutput, -} from '@ats/interview/types/model.unified'; -import { IJobService } from '@ats/job/types'; -import { - UnifiedJobInput, - UnifiedJobOutput, -} from '@ats/job/types/model.unified'; -import { IJobInterviewStageService } from '@ats/jobinterviewstage/types'; -import { - UnifiedJobInterviewStageInput, - UnifiedJobInterviewStageOutput, -} from '@ats/jobinterviewstage/types/model.unified'; -import { IOfferService } from '@ats/offer/types'; -import { - UnifiedOfferInput, - UnifiedOfferOutput, -} from '@ats/offer/types/model.unified'; -import { IOfficeService } from '@ats/office/types'; -import { - UnifiedOfficeInput, - UnifiedOfficeOutput, -} from '@ats/office/types/model.unified'; -import { IRejectReasonService } from '@ats/rejectreason/types'; -import { - UnifiedRejectReasonInput, - UnifiedRejectReasonOutput, -} from '@ats/rejectreason/types/model.unified'; -import { IScoreCardService } from '@ats/scorecard/types'; -import { - UnifiedScoreCardInput, - UnifiedScoreCardOutput, -} from '@ats/scorecard/types/model.unified'; -import { ITagService } from '@ats/tag/types'; -import { - UnifiedTagInput, - UnifiedTagOutput, -} from '@ats/tag/types/model.unified'; -import { IUserService } from '@ats/user/types'; -import { - UnifiedUserInput, - UnifiedUserOutput, -} from '@ats/user/types/model.unified'; -import { ApiProperty } from '@nestjs/swagger'; -import { IsIn, IsString } from 'class-validator'; +import { IProductService } from '@ecommerce/product/types'; export type UnifiedEcommerce = | UnifiedOrderInput | UnifiedOrderOutput - | UnifiedApplicationInput - | UnifiedApplicationOutput - | UnifiedAttachmentInput; + | UnifiedCustomerInput + | UnifiedCustomerOutput + | UnifiedFulfillmentInput + | UnifiedFulfillmentOutput + | UnifiedFulfillmentOrdersInput + | UnifiedFulfillmentOrdersOutput + | UnifiedProductInput + | UnifiedProductOutput; -export type IAtsService = - | IActivityService - | IApplicationService - | IAttachmentService - | ICandidateService - | IDepartmentService - | IInterviewService - | IJobInterviewStageService - | IJobService - | IOfferService - | IOfficeService - | IRejectReasonService - | IScoreCardService - | ITagService - | IUserService - | IEeocsService; - -export class Email { - @ApiProperty({ - type: String, - description: 'The email address', - }) - @IsString() - email_address: string; - - @ApiProperty({ - type: String, - description: - 'The email address type. Authorized values are either PERSONAL or WORK.', - }) - @IsIn(['PERSONAL', 'WORK']) - @IsString() - email_address_type: string; -} - -export class Phone { - @ApiProperty({ - type: String, - description: - 'The phone number starting with a plus (+) followed by the country code (e.g +336676778890 for France)', - }) - @IsString() - phone_number: string; - - @ApiProperty({ - type: String, - description: 'The phone type. Authorized values are either MOBILE or WORK', - }) - @IsIn(['MOBILE', 'WORK']) - @IsString() - phone_type: string; -} - -export class Url { - url: string; - url_type: 'WEBSITE' | 'BLOG' | 'LINKEDIN' | 'GITHUB' | 'OTHER' | string; -} +export type IEcommerceService = + | IProductService + | IOrderService + | IFulfillmentService + | IFulfillmentOrdersService + | ICustomerService; diff --git a/packages/api/src/ecommerce/@lib/@unification/index.ts b/packages/api/src/ecommerce/@lib/@unification/index.ts index f890a35ea..d203c8ed9 100644 --- a/packages/api/src/ecommerce/@lib/@unification/index.ts +++ b/packages/api/src/ecommerce/@lib/@unification/index.ts @@ -1,21 +1,18 @@ -import { AtsObject } from '@ats/@lib/@types'; -import { BullQueueService } from '@@core/@core-services/queues/shared.service'; -import { CoreUnification } from '@@core/@core-services/unification/core-unification.service'; +import { MappersRegistry } from '@@core/@core-services/registries/mappers.registry'; +import { UnificationRegistry } from '@@core/@core-services/registries/unification.registry'; import { Unified, UnifyReturnType } from '@@core/utils/types'; -import { UnifySourceType } from '@@core/utils/types/unify.output'; -import { AtsObjectInput } from '@@core/utils/types/original/original.ats'; import { IUnification } from '@@core/utils/types/interface'; -import { UnificationRegistry } from '@@core/@core-services/registries/unification.registry'; -import { MappersRegistry } from '@@core/@core-services/registries/mappers.registry'; +import { EcommerceObjectInput } from '@@core/utils/types/original/original.ecommerce'; +import { UnifySourceType } from '@@core/utils/types/unify.output'; import { Injectable } from '@nestjs/common'; @Injectable() -export class AtsUnificationService implements IUnification { +export class EcommerceUnificationService implements IUnification { constructor( - private registry: UnificationRegistry, + private registry: UnificationRegistry, private mappersRegistry: MappersRegistry, ) { - this.registry.registerService('ats', this); + this.registry.registerService('ecommerce', this); } async desunify({ sourceObject, @@ -24,15 +21,15 @@ export class AtsUnificationService implements IUnification { customFieldMappings, }: { sourceObject: T; - targetType_: AtsObject; + targetType_: EcommerceObject; providerName: string; customFieldMappings?: { slug: string; remote_id: string; }[]; - }): Promise { + }): Promise { const mapping = this.mappersRegistry.getService( - 'ats', + 'ecommerce', targetType_, providerName, ); @@ -53,7 +50,7 @@ export class AtsUnificationService implements IUnification { customFieldMappings, }: { sourceObject: T; - targetType_: AtsObject; + targetType_: EcommerceObject; providerName: string; connectionId: string; customFieldMappings?: { @@ -62,7 +59,7 @@ export class AtsUnificationService implements IUnification { }[]; }): Promise { const mapping = this.mappersRegistry.getService( - 'ats', + 'ecommerce', targetType_, providerName, ); diff --git a/packages/api/src/ecommerce/@lib/@utils/index.ts b/packages/api/src/ecommerce/@lib/@utils/index.ts index 2f676c809..6c924617a 100644 --- a/packages/api/src/ecommerce/@lib/@utils/index.ts +++ b/packages/api/src/ecommerce/@lib/@utils/index.ts @@ -1,261 +1,8 @@ import { v4 as uuidv4 } from 'uuid'; import { Injectable } from '@nestjs/common'; import { PrismaService } from '@@core/@core-services/prisma/prisma.service'; -import { Email, Phone, Url } from '../@types'; @Injectable() export class Utils { constructor(private readonly prisma: PrismaService) {} - - async getJobUuidFromRemoteId(remote_id: string, connection_id: string) { - try { - const res = await this.prisma.ats_jobs.findFirst({ - where: { - remote_id: remote_id, - id_connection: connection_id, - }, - }); - if (!res) { - return undefined; - } - return res.id_ats_job; - } catch (error) { - throw error; - } - } - - async getCandidateUuidFromRemoteId(remote_id: string, connection_id: string) { - try { - const res = await this.prisma.ats_candidates.findFirst({ - where: { - remote_id: remote_id, - id_connection: connection_id, - }, - }); - if (!res) { - return undefined; - } - return res.id_ats_candidate; - } catch (error) { - throw error; - } - } - - async getCandidateRemoteIdFromUuid(uuid: string) { - try { - const res = await this.prisma.ats_candidates.findUnique({ - where: { - id_ats_candidate: uuid, - }, - }); - if (!res) { - return undefined; - } - return res.remote_id; - } catch (error) { - throw error; - } - } - async getUserRemoteIdFromUuid(uuid: string) { - try { - const res = await this.prisma.ats_users.findUnique({ - where: { - id_ats_user: uuid, - }, - }); - if (!res) { - return undefined; - } - return res.remote_id; - } catch (error) { - throw error; - } - } - async getInterviewStageRemoteIdFromUuid(uuid: string) { - try { - const res = await this.prisma.ats_job_interview_stages.findUnique({ - where: { - id_ats_job_interview_stage: uuid, - }, - }); - if (!res) { - return undefined; - } - return res.remote_id; - } catch (error) { - throw error; - } - } - async getJobRemoteIdFromUuid(uuid: string) { - try { - const res = await this.prisma.ats_jobs.findUnique({ - where: { - id_ats_job: uuid, - }, - }); - if (!res) { - return undefined; - } - return res.remote_id; - } catch (error) { - throw error; - } - } - - async getRejectReasonUuidFromRemoteId( - remote_id: string, - connection_id: string, - ) { - try { - const res = await this.prisma.ats_reject_reasons.findFirst({ - where: { - remote_id: remote_id, - id_connection: connection_id, - }, - }); - if (!res) { - return undefined; - } - return res.id_ats_reject_reason; - } catch (error) { - throw error; - } - } - - async getUserUuidFromRemoteId(remote_id: string, connection_id: string) { - try { - const res = await this.prisma.ats_users.findFirst({ - where: { - remote_id: remote_id, - id_connection: connection_id, - }, - }); - if (!res) { - return undefined; - } - return res.id_ats_user; - } catch (error) { - throw error; - } - } - - async getStageUuidFromRemoteId(remote_id: string, connection_id: string) { - try { - const res = await this.prisma.ats_job_interview_stages.findFirst({ - where: { - remote_id: remote_id, - id_connection: connection_id, - }, - }); - if (!res) { - return undefined; - } - return res.id_ats_job_interview_stage; - } catch (error) { - throw error; - } - } - async getApplicationUuidFromRemoteId( - remote_id: string, - connection_id: string, - ) { - try { - const res = await this.prisma.ats_applications.findFirst({ - where: { - remote_id: remote_id, - id_connection: connection_id, - }, - }); - if (!res) { - return undefined; - } - return res.id_ats_application; - } catch (error) { - throw error; - } - } - async getDepartmentUuidFromRemoteId( - remote_id: string, - connection_id: string, - ) { - try { - const res = await this.prisma.ats_departments.findFirst({ - where: { - remote_id: remote_id, - id_connection: connection_id, - }, - }); - if (!res) { - return undefined; - } - return res.id_ats_department; - } catch (error) { - throw error; - } - } - - async getOfficeUuidFromRemoteId(remote_id: string, connection_id: string) { - try { - const res = await this.prisma.ats_offices.findFirst({ - where: { - remote_id: remote_id, - id_connection: connection_id, - }, - }); - if (!res) { - return undefined; - } - return res.id_ats_office; - } catch (error) { - throw error; - } - } - - normalizeEmailsAndNumbers(email_addresses: Email[], phone_numbers: Phone[]) { - let normalizedEmails = []; - const normalizedPhones = []; - - if (email_addresses) { - normalizedEmails = email_addresses.map((email) => ({ - value: email.email_address, - created_at: new Date(), - modified_at: new Date(), - id_ats_candidate_email_address: uuidv4(), - type: - email.email_address_type === '' ? 'work' : email.email_address_type, - })); - } - if (phone_numbers) { - phone_numbers.forEach((phone) => { - if (!phone.phone_number) return; - normalizedPhones.push({ - created_at: new Date(), - modified_at: new Date(), - id_ats_candidate_phone_number: uuidv4(), - type: phone.phone_type === '' ? 'work' : phone.phone_type, - value: phone.phone_number, - }); - }); - } - return { - normalizedEmails, - normalizedPhones, - }; - } - normalizeUrls(urls: Url[]) { - const normalizedUrls = []; - if (urls) { - urls.forEach((url) => { - if (!url.url) return; - normalizedUrls.push({ - created_at: new Date(), - modified_at: new Date(), - id_ats_candidate_url: uuidv4(), - type: url.url_type || null, - value: url.url, - }); - }); - } - return normalizedUrls; - } } diff --git a/packages/api/src/ecommerce/customer/customer.controller.ts b/packages/api/src/ecommerce/customer/customer.controller.ts new file mode 100644 index 000000000..c54c2e4d2 --- /dev/null +++ b/packages/api/src/ecommerce/customer/customer.controller.ts @@ -0,0 +1,120 @@ +import { + Controller, + Post, + Body, + Query, + Get, + Patch, + Param, + Headers, + UseGuards, +} from '@nestjs/common'; +import { LoggerService } from '@@core/@core-services/logger/logger.service'; +import { + ApiBody, + ApiOperation, + ApiParam, + ApiQuery, + ApiTags, + ApiHeader, +} from '@nestjs/swagger'; +import { ApiCustomResponse } from '@@core/utils/types'; +import { CustomerService } from './services/customer.service'; +import { + UnifiedCustomerInput, + UnifiedCustomerOutput, +} from './types/model.unified'; +import { ConnectionUtils } from '@@core/connections/@utils'; +import { ApiKeyAuthGuard } from '@@core/auth/guards/api-key.guard'; +import { FetchObjectsQueryDto } from '@@core/utils/dtos/fetch-objects-query.dto'; + +@ApiTags('ecommerce/customer') +@Controller('ecommerce/customer') +export class CustomerController { + constructor( + private readonly customerService: CustomerService, + private logger: LoggerService, + private connectionUtils: ConnectionUtils, + ) { + this.logger.setContext(CustomerController.name); + } + + @ApiOperation({ + operationId: 'getCustomers', + summary: 'List a batch of Customers', + }) + @ApiHeader({ + name: 'x-connection-token', + required: true, + description: 'The connection token', + example: 'b008e199-eda9-4629-bd41-a01b6195864a', + }) + @ApiCustomResponse(UnifiedCustomerOutput) + @UseGuards(ApiKeyAuthGuard) + @Get() + async getCustomers( + @Headers('x-connection-token') connection_token: string, + @Query() query: FetchObjectsQueryDto, + ) { + try { + const { linkedUserId, remoteSource, connectionId } = + await this.connectionUtils.getConnectionMetadataFromConnectionToken( + connection_token, + ); + const { remote_data, limit, cursor } = query; + return this.customerService.getCustomers( + connectionId, + remoteSource, + linkedUserId, + limit, + remote_data, + cursor, + ); + } catch (error) { + throw new Error(error); + } + } + + @ApiOperation({ + operationId: 'getCustomer', + summary: 'Retrieve a Customer', + description: 'Retrieve a customer from any connected Ats software', + }) + @ApiParam({ + name: 'id', + required: true, + type: String, + description: 'id of the customer you want to retrieve.', + }) + @ApiQuery({ + name: 'remote_data', + required: false, + type: Boolean, + description: 'Set to true to include data from the original Ats software.', + }) + @ApiHeader({ + name: 'x-connection-token', + required: true, + description: 'The connection token', + example: 'b008e199-eda9-4629-bd41-a01b6195864a', + }) + @ApiCustomResponse(UnifiedCustomerOutput) + @UseGuards(ApiKeyAuthGuard) + @Get(':id') + async retrieve( + @Headers('x-connection-token') connection_token: string, + @Param('id') id: string, + @Query('remote_data') remote_data?: boolean, + ) { + const { linkedUserId, remoteSource } = + await this.connectionUtils.getConnectionMetadataFromConnectionToken( + connection_token, + ); + return this.customerService.getCustomer( + id, + linkedUserId, + remoteSource, + remote_data, + ); + } +} diff --git a/packages/api/src/ecommerce/customer/customer.module.ts b/packages/api/src/ecommerce/customer/customer.module.ts new file mode 100644 index 000000000..0e3182db1 --- /dev/null +++ b/packages/api/src/ecommerce/customer/customer.module.ts @@ -0,0 +1,31 @@ +import { IngestDataService } from '@@core/@core-services/unification/ingest-data.service'; +import { WebhookService } from '@@core/@core-services/webhooks/panora-webhooks/webhook.service'; +import { Module } from '@nestjs/common'; +import { CustomerController } from './customer.controller'; +import { CustomerService } from './services/customer.service'; +import { ServiceRegistry } from './services/registry.service'; +import { ShopifyService } from './services/shopify'; +import { SyncService } from './sync/sync.service'; +import { BullQueueModule } from '@@core/@core-services/queues/queue.module'; +import { CoreUnification } from '@@core/@core-services/unification/core-unification.service'; +import { Utils } from '@ecommerce/@lib/@utils'; +import { ShopifyCustomerMapper } from './services/shopify/mappers'; + +@Module({ + imports: [BullQueueModule], + controllers: [CustomerController], + providers: [ + CustomerService, + CoreUnification, + SyncService, + WebhookService, + ServiceRegistry, + IngestDataService, + Utils, + ShopifyCustomerMapper, + /* PROVIDERS SERVICES */ + ShopifyService, + ], + exports: [SyncService], +}) +export class CustomerModule {} diff --git a/packages/api/src/ecommerce/customer/services/customer.service.ts b/packages/api/src/ecommerce/customer/services/customer.service.ts new file mode 100644 index 000000000..1a55d736a --- /dev/null +++ b/packages/api/src/ecommerce/customer/services/customer.service.ts @@ -0,0 +1,234 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '@@core/@core-services/prisma/prisma.service'; +import { LoggerService } from '@@core/@core-services/logger/logger.service'; +import { v4 as uuidv4 } from 'uuid'; +import { UnifiedCustomerOutput } from '../types/model.unified'; + +@Injectable() +export class CustomerService { + constructor(private prisma: PrismaService, private logger: LoggerService) { + this.logger.setContext(CustomerService.name); + } + + async getCustomer( + id_ecommerce_customer: string, + linkedUserId: string, + integrationId: string, + remote_data?: boolean, + ): Promise { + try { + const customer = await this.prisma.ecom_customers.findUnique({ + where: { + id_ecom_customer: id_ecommerce_customer, + }, + }); + + if (!customer) { + throw new Error(`Customer with ID ${id_ecommerce_customer} not found.`); + } + + // Fetch field mappings for the customer + const values = await this.prisma.value.findMany({ + where: { + entity: { + ressource_owner_id: customer.id_ecom_customer, + }, + }, + include: { + attribute: true, + }, + }); + + // Create a map to store unique field mappings + const fieldMappingsMap = new Map(); + + values.forEach((value) => { + fieldMappingsMap.set(value.attribute.slug, value.data); + }); + + // Convert the map to an array of objects + const field_mappings = Array.from(fieldMappingsMap, ([key, value]) => ({ + [key]: value, + })); + + // Transform to UnifiedCustomerOutput format + const unifiedCustomer: UnifiedCustomerOutput = { + id: customer.id_ecom_customer, + name: customer.name, + field_mappings: field_mappings, + remote_id: customer.remote_id, + created_at: customer.created_at, + modified_at: customer.modified_at, + }; + + let res: UnifiedCustomerOutput = unifiedCustomer; + if (remote_data) { + const resp = await this.prisma.remote_data.findFirst({ + where: { + ressource_owner_id: customer.id_ecom_customer, + }, + }); + const remote_data = JSON.parse(resp.data); + + res = { + ...res, + remote_data: remote_data, + }; + } + await this.prisma.events.create({ + data: { + id_event: uuidv4(), + status: 'success', + type: 'ecommerce.customer.pull', + method: 'GET', + url: '/ecommerce/customer', + provider: integrationId, + direction: '0', + timestamp: new Date(), + id_linked_user: linkedUserId, + }, + }); + + return res; + } catch (error) { + throw error; + } + } + + async getCustomers( + connection_id: string, + integrationId: string, + linkedUserId: string, + limit: number, + remote_data?: boolean, + cursor?: string, + ): Promise<{ + data: UnifiedCustomerOutput[]; + prev_cursor: null | string; + next_cursor: null | string; + }> { + try { + let prev_cursor = null; + let next_cursor = null; + + if (cursor) { + const isCursorPresent = await this.prisma.ecom_customers.findFirst({ + where: { + id_connection: connection_id, + id_ecom_customer: cursor, + }, + }); + if (!isCursorPresent) { + throw new ReferenceError(`The provided cursor does not exist!`); + } + } + + const customers = await this.prisma.ecom_customers.findMany({ + take: limit + 1, + cursor: cursor + ? { + id_ecom_customer: cursor, + } + : undefined, + orderBy: { + created_at: 'asc', + }, + where: { + id_connection: connection_id, + }, + }); + + if (customers.length === limit + 1) { + next_cursor = Buffer.from( + customers[customers.length - 1].id_ecom_customer, + ).toString('base64'); + customers.pop(); + } + + if (cursor) { + prev_cursor = Buffer.from(cursor).toString('base64'); + } + + const unifiedCustomers: UnifiedCustomerOutput[] = await Promise.all( + customers.map(async (customer) => { + // Fetch field mappings for the customer + const values = await this.prisma.value.findMany({ + where: { + entity: { + ressource_owner_id: customer.id_ecom_customer, + }, + }, + include: { + attribute: true, + }, + }); + + // Create a map to store unique field mappings + const fieldMappingsMap = new Map(); + + values.forEach((value) => { + fieldMappingsMap.set(value.attribute.slug, value.data); + }); + + // Convert the map to an array of objects + const field_mappings = Array.from( + fieldMappingsMap, + ([key, value]) => ({ + [key]: value, + }), + ); + + // Transform to UnifiedCustomerOutput format + return { + id: customer.id_ecom_customer, + name: customer.name, + field_mappings: field_mappings, + remote_id: customer.remote_id, + created_at: customer.created_at, + modified_at: customer.modified_at, + }; + }), + ); + + let res: UnifiedCustomerOutput[] = unifiedCustomers; + + if (remote_data) { + const remote_array_data: UnifiedCustomerOutput[] = await Promise.all( + res.map(async (customer) => { + const resp = await this.prisma.remote_data.findFirst({ + where: { + ressource_owner_id: customer.id, + }, + }); + const remote_data = JSON.parse(resp.data); + return { ...customer, remote_data }; + }), + ); + + res = remote_array_data; + } + + await this.prisma.events.create({ + data: { + id_event: uuidv4(), + status: 'success', + type: 'ecommerce.customer.pull', + method: 'GET', + url: '/ecommerce/customers', + provider: integrationId, + direction: '0', + timestamp: new Date(), + id_linked_user: linkedUserId, + }, + }); + + return { + data: res, + prev_cursor, + next_cursor, + }; + } catch (error) { + throw error; + } + } +} diff --git a/packages/api/src/ecommerce/customer/services/registry.service.ts b/packages/api/src/ecommerce/customer/services/registry.service.ts new file mode 100644 index 000000000..14657d83e --- /dev/null +++ b/packages/api/src/ecommerce/customer/services/registry.service.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@nestjs/common'; +import { ICustomerService } from '../types'; + +@Injectable() +export class ServiceRegistry { + private serviceMap: Map; + + constructor() { + this.serviceMap = new Map(); + } + + registerService(serviceKey: string, service: ICustomerService) { + this.serviceMap.set(serviceKey, service); + } + + getService(integrationId: string): ICustomerService { + const service = this.serviceMap.get(integrationId); + if (!service) { + throw new ReferenceError( + `Service not found for integration ID: ${integrationId}`, + ); + } + return service; + } +} diff --git a/packages/api/src/ecommerce/customer/services/shopify/index.ts b/packages/api/src/ecommerce/customer/services/shopify/index.ts new file mode 100644 index 000000000..c74e19823 --- /dev/null +++ b/packages/api/src/ecommerce/customer/services/shopify/index.ts @@ -0,0 +1,68 @@ +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 { ApiResponse } from '@@core/utils/types'; +import { DesunifyReturnType } from '@@core/utils/types/desunify.input'; +import { SyncParam } from '@@core/utils/types/interface'; +import { OriginalCustomerOutput } from '@@core/utils/types/original/original.ecommerce'; +import { ICustomerService } from '@ecommerce/customer/types'; +import { Injectable } from '@nestjs/common'; +import axios from 'axios'; +import { ServiceRegistry } from '../registry.service'; +import { ShopifyCustomerOutput } from './types'; + +@Injectable() +export class ShopifyService implements ICustomerService { + constructor( + private prisma: PrismaService, + private logger: LoggerService, + private cryptoService: EncryptionService, + private registry: ServiceRegistry, + ) { + this.logger.setContext( + EcommerceObject.customer.toUpperCase() + ':' + ShopifyService.name, + ); + this.registry.registerService('shopify', this); + } + addCustomer( + customerData: DesunifyReturnType, + linkedUserId: string, + ): Promise> { + throw new Error('Method not implemented.'); + } + + async sync(data: SyncParam): Promise> { + try { + const { linkedUserId } = data; + + const connection = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedUserId, + provider_slug: 'ashby', + vertical: 'ecommerce', + }, + }); + const resp = await axios.post( + `${connection.account_url}/departement.list`, + { + headers: { + 'Content-Type': 'application/json', + Authorization: `Basic ${Buffer.from( + `${this.cryptoService.decrypt(connection.access_token)}:`, + ).toString('base64')}`, + }, + }, + ); + const customers: ShopifyCustomerOutput[] = resp.data.results; + this.logger.log(`Synced ashby customers !`); + + return { + data: customers, + message: 'Shopify customers retrieved', + statusCode: 200, + }; + } catch (error) { + throw error; + } + } +} diff --git a/packages/api/src/ecommerce/customer/services/shopify/mappers.ts b/packages/api/src/ecommerce/customer/services/shopify/mappers.ts new file mode 100644 index 000000000..d7eb631e0 --- /dev/null +++ b/packages/api/src/ecommerce/customer/services/shopify/mappers.ts @@ -0,0 +1,78 @@ +import { ShopifyCustomerInput, ShopifyCustomerOutput } from './types'; +import { + UnifiedCustomerInput, + UnifiedCustomerOutput, +} from '@ecommerce/customer/types/model.unified'; +import { ICustomerMapper } from '@ecommerce/customer/types'; +import { MappersRegistry } from '@@core/@core-services/registries/mappers.registry'; +import { Injectable } from '@nestjs/common'; +import { CoreUnification } from '@@core/@core-services/unification/core-unification.service'; +import { Utils } from '@ecommerce/@lib/@utils'; + +@Injectable() +export class ShopifyCustomerMapper implements ICustomerMapper { + constructor( + private mappersRegistry: MappersRegistry, + private utils: Utils, + private coreUnificationService: CoreUnification, + ) { + this.mappersRegistry.registerService( + 'ecommerce', + 'customer', + 'ashby', + this, + ); + } + + async desunify( + source: UnifiedCustomerInput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): Promise { + return; + } + + async unify( + source: ShopifyCustomerOutput | ShopifyCustomerOutput[], + connectionId: string, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): Promise { + if (!Array.isArray(source)) { + return await this.mapSingleCustomerToUnified( + source, + connectionId, + customFieldMappings, + ); + } + // Handling array of ShopifyCustomerOutput + return Promise.all( + source.map((customer) => + this.mapSingleCustomerToUnified( + customer, + connectionId, + customFieldMappings, + ), + ), + ); + } + + private async mapSingleCustomerToUnified( + customer: ShopifyCustomerOutput, + connectionId: string, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): Promise { + return { + remote_id: customer.id, + remote_data: customer, + name: customer.name || null, + }; + } +} diff --git a/packages/api/src/ecommerce/customer/services/shopify/types.ts b/packages/api/src/ecommerce/customer/services/shopify/types.ts new file mode 100644 index 000000000..06402b02b --- /dev/null +++ b/packages/api/src/ecommerce/customer/services/shopify/types.ts @@ -0,0 +1,8 @@ +export interface ShopifyCustomerInput { + id: string; + name: string; + isArchived: boolean; + parentId: string; +} + +export type ShopifyCustomerOutput = Partial; diff --git a/packages/api/src/ecommerce/customer/sync/sync.processor.ts b/packages/api/src/ecommerce/customer/sync/sync.processor.ts new file mode 100644 index 000000000..1f01a3006 --- /dev/null +++ b/packages/api/src/ecommerce/customer/sync/sync.processor.ts @@ -0,0 +1,19 @@ +import { Processor, Process } from '@nestjs/bull'; +import { Job } from 'bull'; +import { SyncService } from './sync.service'; +import { Queues } from '@@core/@core-services/queues/types'; + +@Processor(Queues.SYNC_JOBS_WORKER) +export class SyncProcessor { + constructor(private syncService: SyncService) {} + + @Process('ecommerce-sync-customers') + async handleSyncCustomers(job: Job) { + try { + console.log(`Processing queue -> ecommerce-sync-customers ${job.id}`); + await this.syncService.kickstartSync(); + } catch (error) { + console.error('Error syncing ecommerce customers', error); + } + } +} diff --git a/packages/api/src/ecommerce/customer/sync/sync.service.ts b/packages/api/src/ecommerce/customer/sync/sync.service.ts new file mode 100644 index 000000000..088d36930 --- /dev/null +++ b/packages/api/src/ecommerce/customer/sync/sync.service.ts @@ -0,0 +1,197 @@ +import { LoggerService } from '@@core/@core-services/logger/logger.service'; +import { PrismaService } from '@@core/@core-services/prisma/prisma.service'; +import { BullQueueService } from '@@core/@core-services/queues/shared.service'; +import { CoreSyncRegistry } from '@@core/@core-services/registries/core-sync.registry'; +import { CoreUnification } from '@@core/@core-services/unification/core-unification.service'; +import { IngestDataService } from '@@core/@core-services/unification/ingest-data.service'; +import { WebhookService } from '@@core/@core-services/webhooks/panora-webhooks/webhook.service'; +import { FieldMappingService } from '@@core/field-mapping/field-mapping.service'; +import { IBaseSync, SyncLinkedUserType } from '@@core/utils/types/interface'; +import { OriginalCustomerOutput } from '@@core/utils/types/original/original.ecommerce'; +import { Injectable, OnModuleInit } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; +import { ATS_PROVIDERS } from '@panora/shared'; +import { v4 as uuidv4 } from 'uuid'; +import { ServiceRegistry } from '../services/registry.service'; +import { ICustomerService } from '../types'; +import { UnifiedCustomerOutput } from '../types/model.unified'; + +@Injectable() +export class SyncService implements OnModuleInit, IBaseSync { + constructor( + private prisma: PrismaService, + private logger: LoggerService, + private webhook: WebhookService, + private fieldMappingService: FieldMappingService, + private serviceRegistry: ServiceRegistry, + private coreUnification: CoreUnification, + private registry: CoreSyncRegistry, + private bullQueueService: BullQueueService, + private ingestService: IngestDataService, + ) { + this.logger.setContext(SyncService.name); + this.registry.registerService('ecommerce', 'customer', this); + } + + async onModuleInit() { + try { + await this.bullQueueService.queueSyncJob( + 'ecommerce-sync-customers', + '0 0 * * *', + ); + } catch (error) { + throw error; + } + } + + @Cron('0 */8 * * *') // every 8 hours + async kickstartSync(user_id?: string) { + try { + this.logger.log('Syncing customers...'); + const users = user_id + ? [ + await this.prisma.users.findUnique({ + where: { + id_user: user_id, + }, + }), + ] + : await this.prisma.users.findMany(); + if (users && users.length > 0) { + for (const user of users) { + const projects = await this.prisma.projects.findMany({ + where: { + id_user: user.id_user, + }, + }); + for (const project of projects) { + const id_project = project.id_project; + const linkedUsers = await this.prisma.linked_users.findMany({ + where: { + id_project: id_project, + }, + }); + linkedUsers.map(async (linkedUser) => { + try { + const providers = ECOMMERCE_PROVIDERS; + for (const provider of providers) { + try { + await this.syncForLinkedUser({ + integrationId: provider, + linkedUserId: linkedUser.id_linked_user, + }); + } catch (error) { + throw error; + } + } + } catch (error) { + throw error; + } + }); + } + } + } + } catch (error) { + throw error; + } + } + + async syncForLinkedUser(param: SyncLinkedUserType) { + try { + const { integrationId, linkedUserId } = param; + const service: ICustomerService = + this.serviceRegistry.getService(integrationId); + if (!service) return; + + await this.ingestService.syncForLinkedUser< + UnifiedCustomerOutput, + OriginalCustomerOutput, + ICustomerService + >(integrationId, linkedUserId, 'ecommerce', 'customer', service, []); + } catch (error) { + throw error; + } + } + + async saveToDb( + connection_id: string, + linkedUserId: string, + customers: UnifiedCustomerOutput[], + originSource: string, + remote_data: Record[], + ): Promise { + try { + const customers_results: EcommerceCustomer[] = []; + + const updateOrCreateCustomer = async ( + customer: UnifiedCustomerOutput, + originId: string, + ) => { + let existingCustomer; + if (!originId) { + existingCustomer = await this.prisma.ecommerce_customers.findFirst({ + where: { + name: customer.name, + id_connection: connection_id, + }, + }); + } else { + existingCustomer = await this.prisma.ecommerce_customers.findFirst({ + where: { + remote_id: originId, + id_connection: connection_id, + }, + }); + } + + const baseData: any = { + name: customer.name ?? null, + modified_at: new Date(), + }; + + if (existingCustomer) { + return await this.prisma.ecommerce_customers.update({ + where: { + id_ecommerce_customer: existingCustomer.id_ecommerce_customer, + }, + data: baseData, + }); + } else { + return await this.prisma.ecommerce_customers.create({ + data: { + ...baseData, + id_ecommerce_customer: uuidv4(), + created_at: new Date(), + remote_id: originId, + id_connection: connection_id, + }, + }); + } + }; + + for (let i = 0; i < customers.length; i++) { + const customer = customers[i]; + const originId = customer.remote_id; + + const res = await updateOrCreateCustomer(customer, originId); + const customer_id = res.id_ecommerce_customer; + customers_results.push(res); + + // Process field mappings + await this.ingestService.processFieldMappings( + customer.field_mappings, + customer_id, + originSource, + linkedUserId, + ); + + // Process remote data + await this.ingestService.processRemoteData(customer_id, remote_data[i]); + } + + return customers_results; + } catch (error) { + throw error; + } + } +} diff --git a/packages/api/src/ecommerce/customer/types/index.ts b/packages/api/src/ecommerce/customer/types/index.ts new file mode 100644 index 000000000..9690bbfbd --- /dev/null +++ b/packages/api/src/ecommerce/customer/types/index.ts @@ -0,0 +1,33 @@ +import { DesunifyReturnType } from '@@core/utils/types/desunify.input'; +import { UnifiedCustomerInput, UnifiedCustomerOutput } from './model.unified'; +import { OriginalCustomerOutput } from '@@core/utils/types/original/original.ecommerce'; +import { ApiResponse } from '@@core/utils/types'; +import { IBaseObjectService, SyncParam } from '@@core/utils/types/interface'; + +export interface ICustomerService extends IBaseObjectService { + addCustomer( + customerData: DesunifyReturnType, + linkedUserId: string, + ): Promise>; + + sync(data: SyncParam): Promise>; +} + +export interface ICustomerMapper { + desunify( + source: UnifiedCustomerInput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): DesunifyReturnType; + + unify( + source: OriginalCustomerOutput | OriginalCustomerOutput[], + connectionId: string, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): Promise; +} diff --git a/packages/api/src/ecommerce/customer/types/model.unified.ts b/packages/api/src/ecommerce/customer/types/model.unified.ts new file mode 100644 index 000000000..ce5130e9a --- /dev/null +++ b/packages/api/src/ecommerce/customer/types/model.unified.ts @@ -0,0 +1,61 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsUUID, IsOptional, IsString, IsDateString } from 'class-validator'; + +export class UnifiedCustomerInput { + @ApiPropertyOptional({ + type: String, + description: 'The name of the customer', + }) + @IsString() + @IsOptional() + name?: string; + + @ApiPropertyOptional({ + type: {}, + description: + 'The custom field mappings of the object between the remote 3rd party & Panora', + }) + @IsOptional() + field_mappings?: Record; +} + +export class UnifiedCustomerOutput extends UnifiedCustomerInput { + @ApiPropertyOptional({ + type: String, + description: 'The UUID of the customer', + }) + @IsUUID() + @IsOptional() + id?: string; + + @ApiPropertyOptional({ + type: String, + description: + 'The remote ID of the customer in the context of the 3rd Party', + }) + @IsString() + @IsOptional() + remote_id?: string; + + @ApiPropertyOptional({ + type: {}, + description: + 'The remote data of the customer in the context of the 3rd Party', + }) + @IsOptional() + remote_data?: Record; + + @ApiPropertyOptional({ + type: {}, + description: 'The created date of the object', + }) + @IsOptional() + created_at?: any; + + @ApiPropertyOptional({ + type: {}, + description: 'The modified date of the object', + }) + @IsOptional() + modified_at?: any; +} diff --git a/packages/api/src/ecommerce/customer/utils/index.ts b/packages/api/src/ecommerce/customer/utils/index.ts new file mode 100644 index 000000000..f849788c1 --- /dev/null +++ b/packages/api/src/ecommerce/customer/utils/index.ts @@ -0,0 +1 @@ +/* PUT ALL UTILS FUNCTIONS USED IN YOUR OBJECT METHODS HERE */ diff --git a/packages/api/src/ecommerce/ecommerce.module.ts b/packages/api/src/ecommerce/ecommerce.module.ts index 52a076f76..a65b8e0d4 100644 --- a/packages/api/src/ecommerce/ecommerce.module.ts +++ b/packages/api/src/ecommerce/ecommerce.module.ts @@ -1,46 +1,10 @@ import { Module } from '@nestjs/common'; -import { ActivityModule } from './activity/activity.module'; -import { ApplicationModule } from './application/application.module'; -import { AttachmentModule } from './attachment/attachment.module'; -import { CandidateModule } from './candidate/candidate.module'; -import { DepartmentModule } from './department/department.module'; -import { InterviewModule } from './interview/interview.module'; -import { JobInterviewStageModule } from './jobinterviewstage/jobinterviewstage.module'; -import { JobModule } from './job/job.module'; -import { OfferModule } from './offer/offer.module'; -import { OfficeModule } from './office/office.module'; -import { RejectReasonModule } from './rejectreason/rejectreason.module'; -import { ScoreCardModule } from './scorecard/scorecard.module'; -import { TagModule } from './tag/tag.module'; -import { UserModule } from './user/user.module'; -import { EeocsModule } from './eeocs/eeocs.module'; import { EcommerceUnificationService } from './@lib/@unification'; +import { ProductModule } from './product/product.module'; @Module({ - exports: [ - ActivityModule, - ApplicationModule, - AttachmentModule, - CandidateModule, - DepartmentModule, - ], + exports: [ProductModule], providers: [EcommerceUnificationService], - imports: [ - ActivityModule, - ApplicationModule, - AttachmentModule, - CandidateModule, - DepartmentModule, - InterviewModule, - JobInterviewStageModule, - JobModule, - OfferModule, - OfficeModule, - RejectReasonModule, - ScoreCardModule, - TagModule, - UserModule, - EeocsModule, - ], + imports: [ProductModule], }) -export class AtsModule {} +export class EcommerceModule {} diff --git a/packages/api/src/ecommerce/fulfillment/fulfillment.controller.ts b/packages/api/src/ecommerce/fulfillment/fulfillment.controller.ts new file mode 100644 index 000000000..18f984923 --- /dev/null +++ b/packages/api/src/ecommerce/fulfillment/fulfillment.controller.ts @@ -0,0 +1,120 @@ +import { + Controller, + Post, + Body, + Query, + Get, + Patch, + Param, + Headers, + UseGuards, +} from '@nestjs/common'; +import { LoggerService } from '@@core/@core-services/logger/logger.service'; +import { + ApiBody, + ApiOperation, + ApiParam, + ApiQuery, + ApiTags, + ApiHeader, +} from '@nestjs/swagger'; +import { ApiCustomResponse } from '@@core/utils/types'; +import { DepartmentService } from './services/department.service'; +import { + UnifiedDepartmentInput, + UnifiedDepartmentOutput, +} from './types/model.unified'; +import { ConnectionUtils } from '@@core/connections/@utils'; +import { ApiKeyAuthGuard } from '@@core/auth/guards/api-key.guard'; +import { FetchObjectsQueryDto } from '@@core/utils/dtos/fetch-objects-query.dto'; + +@ApiTags('ats/department') +@Controller('ats/department') +export class DepartmentController { + constructor( + private readonly departmentService: DepartmentService, + private logger: LoggerService, + private connectionUtils: ConnectionUtils, + ) { + this.logger.setContext(DepartmentController.name); + } + + @ApiOperation({ + operationId: 'getDepartments', + summary: 'List a batch of Departments', + }) + @ApiHeader({ + name: 'x-connection-token', + required: true, + description: 'The connection token', + example: 'b008e199-eda9-4629-bd41-a01b6195864a', + }) + @ApiCustomResponse(UnifiedDepartmentOutput) + @UseGuards(ApiKeyAuthGuard) + @Get() + async getDepartments( + @Headers('x-connection-token') connection_token: string, + @Query() query: FetchObjectsQueryDto, + ) { + try { + const { linkedUserId, remoteSource, connectionId } = + await this.connectionUtils.getConnectionMetadataFromConnectionToken( + connection_token, + ); + const { remote_data, limit, cursor } = query; + return this.departmentService.getDepartments( + connectionId, + remoteSource, + linkedUserId, + limit, + remote_data, + cursor, + ); + } catch (error) { + throw new Error(error); + } + } + + @ApiOperation({ + operationId: 'getDepartment', + summary: 'Retrieve a Department', + description: 'Retrieve a department from any connected Ats software', + }) + @ApiParam({ + name: 'id', + required: true, + type: String, + description: 'id of the department you want to retrieve.', + }) + @ApiQuery({ + name: 'remote_data', + required: false, + type: Boolean, + description: 'Set to true to include data from the original Ats software.', + }) + @ApiHeader({ + name: 'x-connection-token', + required: true, + description: 'The connection token', + example: 'b008e199-eda9-4629-bd41-a01b6195864a', + }) + @ApiCustomResponse(UnifiedDepartmentOutput) + @UseGuards(ApiKeyAuthGuard) + @Get(':id') + async retrieve( + @Headers('x-connection-token') connection_token: string, + @Param('id') id: string, + @Query('remote_data') remote_data?: boolean, + ) { + const { linkedUserId, remoteSource } = + await this.connectionUtils.getConnectionMetadataFromConnectionToken( + connection_token, + ); + return this.departmentService.getDepartment( + id, + linkedUserId, + remoteSource, + remote_data, + ); + } +} diff --git a/packages/api/src/ecommerce/fulfillment/fulfillment.module.ts b/packages/api/src/ecommerce/fulfillment/fulfillment.module.ts new file mode 100644 index 000000000..7d939d74a --- /dev/null +++ b/packages/api/src/ecommerce/fulfillment/fulfillment.module.ts @@ -0,0 +1,41 @@ +import { EncryptionService } from '@@core/@core-services/encryption/encryption.service'; +import { LoggerService } from '@@core/@core-services/logger/logger.service'; +import { WebhookService } from '@@core/@core-services/webhooks/panora-webhooks/webhook.service'; +import { ConnectionUtils } from '@@core/connections/@utils'; +import { FieldMappingService } from '@@core/field-mapping/field-mapping.service'; +import { Module } from '@nestjs/common'; +import { DepartmentController } from './fulfillment.controller'; +import { DepartmentService } from './services/department.service'; +import { ServiceRegistry } from './services/registry.service'; +import { SyncService } from './sync/sync.service'; +import { IngestDataService } from '@@core/@core-services/unification/ingest-data.service'; +import { AshbyService } from './services/ashby'; + +import { BullQueueModule } from '@@core/@core-services/queues/queue.module'; + +import { CoreUnification } from '@@core/@core-services/unification/core-unification.service'; +import { AshbyDepartmentMapper } from './services/ashby/mappers'; +import { Utils } from '@ats/@lib/@utils'; + +@Module({ + imports: [BullQueueModule], + controllers: [DepartmentController], + providers: [ + DepartmentService, + CoreUnification, + + SyncService, + WebhookService, + + ServiceRegistry, + + IngestDataService, + + Utils, + AshbyDepartmentMapper, + /* PROVIDERS SERVICES */ + AshbyService, + ], + exports: [SyncService], +}) +export class DepartmentModule {} diff --git a/packages/api/src/ecommerce/fulfillment/services/ashby/index.ts b/packages/api/src/ecommerce/fulfillment/services/ashby/index.ts new file mode 100644 index 000000000..cceda214e --- /dev/null +++ b/packages/api/src/ecommerce/fulfillment/services/ashby/index.ts @@ -0,0 +1,70 @@ +import { Injectable } from '@nestjs/common'; +import { IDepartmentService } from '@ats/department/types'; +import { AtsObject } from '@ats/@lib/@types'; +import axios from 'axios'; +import { PrismaService } from '@@core/@core-services/prisma/prisma.service'; +import { LoggerService } from '@@core/@core-services/logger/logger.service'; +import { ActionType, handle3rdPartyServiceError } from '@@core/utils/errors'; +import { EncryptionService } from '@@core/@core-services/encryption/encryption.service'; +import { ApiResponse } from '@@core/utils/types'; +import { ServiceRegistry } from '../registry.service'; +import { AshbyDepartmentInput, AshbyDepartmentOutput } from './types'; +import { DesunifyReturnType } from '@@core/utils/types/desunify.input'; +import { OriginalDepartmentOutput } from '@@core/utils/types/original/original.ats'; +import { SyncParam } from '@@core/utils/types/interface'; + +@Injectable() +export class AshbyService implements IDepartmentService { + constructor( + private prisma: PrismaService, + private logger: LoggerService, + private cryptoService: EncryptionService, + private registry: ServiceRegistry, + ) { + this.logger.setContext( + AtsObject.department.toUpperCase() + ':' + AshbyService.name, + ); + this.registry.registerService('ashby', this); + } + addDepartment( + departmentData: DesunifyReturnType, + linkedUserId: string, + ): Promise> { + throw new Error('Method not implemented.'); + } + + async sync(data: SyncParam): Promise> { + try { + const { linkedUserId } = data; + + const connection = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedUserId, + provider_slug: 'ashby', + vertical: 'ats', + }, + }); + const resp = await axios.post( + `${connection.account_url}/departement.list`, + { + headers: { + 'Content-Type': 'application/json', + Authorization: `Basic ${Buffer.from( + `${this.cryptoService.decrypt(connection.access_token)}:`, + ).toString('base64')}`, + }, + }, + ); + const departments: AshbyDepartmentOutput[] = resp.data.results; + this.logger.log(`Synced ashby departments !`); + + return { + data: departments, + message: 'Ashby departments retrieved', + statusCode: 200, + }; + } catch (error) { + throw error; + } + } +} diff --git a/packages/api/src/ecommerce/fulfillment/services/ashby/mappers.ts b/packages/api/src/ecommerce/fulfillment/services/ashby/mappers.ts new file mode 100644 index 000000000..877efdf88 --- /dev/null +++ b/packages/api/src/ecommerce/fulfillment/services/ashby/mappers.ts @@ -0,0 +1,73 @@ +import { AshbyDepartmentInput, AshbyDepartmentOutput } from './types'; +import { + UnifiedDepartmentInput, + UnifiedDepartmentOutput, +} from '@ats/department/types/model.unified'; +import { IDepartmentMapper } from '@ats/department/types'; +import { MappersRegistry } from '@@core/@core-services/registries/mappers.registry'; +import { Injectable } from '@nestjs/common'; +import { CoreUnification } from '@@core/@core-services/unification/core-unification.service'; +import { Utils } from '@ats/@lib/@utils'; + +@Injectable() +export class AshbyDepartmentMapper implements IDepartmentMapper { + constructor( + private mappersRegistry: MappersRegistry, + private utils: Utils, + private coreUnificationService: CoreUnification, + ) { + this.mappersRegistry.registerService('ats', 'department', 'ashby', this); + } + + async desunify( + source: UnifiedDepartmentInput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): Promise { + return; + } + + async unify( + source: AshbyDepartmentOutput | AshbyDepartmentOutput[], + connectionId: string, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): Promise { + if (!Array.isArray(source)) { + return await this.mapSingleDepartmentToUnified( + source, + connectionId, + customFieldMappings, + ); + } + // Handling array of AshbyDepartmentOutput + return Promise.all( + source.map((department) => + this.mapSingleDepartmentToUnified( + department, + connectionId, + customFieldMappings, + ), + ), + ); + } + + private async mapSingleDepartmentToUnified( + department: AshbyDepartmentOutput, + connectionId: string, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): Promise { + return { + remote_id: department.id, + remote_data: department, + name: department.name || null, + }; + } +} diff --git a/packages/api/src/ecommerce/fulfillment/services/ashby/types.ts b/packages/api/src/ecommerce/fulfillment/services/ashby/types.ts new file mode 100644 index 000000000..d3c47509a --- /dev/null +++ b/packages/api/src/ecommerce/fulfillment/services/ashby/types.ts @@ -0,0 +1,8 @@ +export interface AshbyDepartmentInput { + id: string; + name: string; + isArchived: boolean; + parentId: string; +} + +export type AshbyDepartmentOutput = Partial; diff --git a/packages/api/src/ecommerce/fulfillment/services/department.service.ts b/packages/api/src/ecommerce/fulfillment/services/department.service.ts new file mode 100644 index 000000000..deb50851d --- /dev/null +++ b/packages/api/src/ecommerce/fulfillment/services/department.service.ts @@ -0,0 +1,234 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '@@core/@core-services/prisma/prisma.service'; +import { LoggerService } from '@@core/@core-services/logger/logger.service'; +import { v4 as uuidv4 } from 'uuid'; +import { UnifiedDepartmentOutput } from '../types/model.unified'; + +@Injectable() +export class DepartmentService { + constructor(private prisma: PrismaService, private logger: LoggerService) { + this.logger.setContext(DepartmentService.name); + } + + async getDepartment( + id_ats_department: string, + linkedUserId: string, + integrationId: string, + remote_data?: boolean, + ): Promise { + try { + const department = await this.prisma.ats_departments.findUnique({ + where: { + id_ats_department: id_ats_department, + }, + }); + + if (!department) { + throw new Error(`Department with ID ${id_ats_department} not found.`); + } + + // Fetch field mappings for the department + const values = await this.prisma.value.findMany({ + where: { + entity: { + ressource_owner_id: department.id_ats_department, + }, + }, + include: { + attribute: true, + }, + }); + + // Create a map to store unique field mappings + const fieldMappingsMap = new Map(); + + values.forEach((value) => { + fieldMappingsMap.set(value.attribute.slug, value.data); + }); + + // Convert the map to an array of objects + const field_mappings = Array.from(fieldMappingsMap, ([key, value]) => ({ + [key]: value, + })); + + // Transform to UnifiedDepartmentOutput format + const unifiedDepartment: UnifiedDepartmentOutput = { + id: department.id_ats_department, + name: department.name, + field_mappings: field_mappings, + remote_id: department.remote_id, + created_at: department.created_at, + modified_at: department.modified_at, + }; + + let res: UnifiedDepartmentOutput = unifiedDepartment; + if (remote_data) { + const resp = await this.prisma.remote_data.findFirst({ + where: { + ressource_owner_id: department.id_ats_department, + }, + }); + const remote_data = JSON.parse(resp.data); + + res = { + ...res, + remote_data: remote_data, + }; + } + await this.prisma.events.create({ + data: { + id_event: uuidv4(), + status: 'success', + type: 'ats.department.pull', + method: 'GET', + url: '/ats/department', + provider: integrationId, + direction: '0', + timestamp: new Date(), + id_linked_user: linkedUserId, + }, + }); + + return res; + } catch (error) { + throw error; + } + } + + async getDepartments( + connection_id: string, + integrationId: string, + linkedUserId: string, + limit: number, + remote_data?: boolean, + cursor?: string, + ): Promise<{ + data: UnifiedDepartmentOutput[]; + prev_cursor: null | string; + next_cursor: null | string; + }> { + try { + let prev_cursor = null; + let next_cursor = null; + + if (cursor) { + const isCursorPresent = await this.prisma.ats_departments.findFirst({ + where: { + id_connection: connection_id, + id_ats_department: cursor, + }, + }); + if (!isCursorPresent) { + throw new ReferenceError(`The provided cursor does not exist!`); + } + } + + const departments = await this.prisma.ats_departments.findMany({ + take: limit + 1, + cursor: cursor + ? { + id_ats_department: cursor, + } + : undefined, + orderBy: { + created_at: 'asc', + }, + where: { + id_connection: connection_id, + }, + }); + + if (departments.length === limit + 1) { + next_cursor = Buffer.from( + departments[departments.length - 1].id_ats_department, + ).toString('base64'); + departments.pop(); + } + + if (cursor) { + prev_cursor = Buffer.from(cursor).toString('base64'); + } + + const unifiedDepartments: UnifiedDepartmentOutput[] = await Promise.all( + departments.map(async (department) => { + // Fetch field mappings for the department + const values = await this.prisma.value.findMany({ + where: { + entity: { + ressource_owner_id: department.id_ats_department, + }, + }, + include: { + attribute: true, + }, + }); + + // Create a map to store unique field mappings + const fieldMappingsMap = new Map(); + + values.forEach((value) => { + fieldMappingsMap.set(value.attribute.slug, value.data); + }); + + // Convert the map to an array of objects + const field_mappings = Array.from( + fieldMappingsMap, + ([key, value]) => ({ + [key]: value, + }), + ); + + // Transform to UnifiedDepartmentOutput format + return { + id: department.id_ats_department, + name: department.name, + field_mappings: field_mappings, + remote_id: department.remote_id, + created_at: department.created_at, + modified_at: department.modified_at, + }; + }), + ); + + let res: UnifiedDepartmentOutput[] = unifiedDepartments; + + if (remote_data) { + const remote_array_data: UnifiedDepartmentOutput[] = await Promise.all( + res.map(async (department) => { + const resp = await this.prisma.remote_data.findFirst({ + where: { + ressource_owner_id: department.id, + }, + }); + const remote_data = JSON.parse(resp.data); + return { ...department, remote_data }; + }), + ); + + res = remote_array_data; + } + + await this.prisma.events.create({ + data: { + id_event: uuidv4(), + status: 'success', + type: 'ats.department.pull', + method: 'GET', + url: '/ats/departments', + provider: integrationId, + direction: '0', + timestamp: new Date(), + id_linked_user: linkedUserId, + }, + }); + + return { + data: res, + prev_cursor, + next_cursor, + }; + } catch (error) { + throw error; + } + } +} diff --git a/packages/api/src/ecommerce/fulfillment/services/registry.service.ts b/packages/api/src/ecommerce/fulfillment/services/registry.service.ts new file mode 100644 index 000000000..ee946e7b7 --- /dev/null +++ b/packages/api/src/ecommerce/fulfillment/services/registry.service.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@nestjs/common'; +import { IDepartmentService } from '../types'; + +@Injectable() +export class ServiceRegistry { + private serviceMap: Map; + + constructor() { + this.serviceMap = new Map(); + } + + registerService(serviceKey: string, service: IDepartmentService) { + this.serviceMap.set(serviceKey, service); + } + + getService(integrationId: string): IDepartmentService { + const service = this.serviceMap.get(integrationId); + if (!service) { + throw new ReferenceError( + `Service not found for integration ID: ${integrationId}`, + ); + } + return service; + } +} diff --git a/packages/api/src/ecommerce/fulfillment/sync/sync.processor.ts b/packages/api/src/ecommerce/fulfillment/sync/sync.processor.ts new file mode 100644 index 000000000..4c01a02f0 --- /dev/null +++ b/packages/api/src/ecommerce/fulfillment/sync/sync.processor.ts @@ -0,0 +1,19 @@ +import { Processor, Process } from '@nestjs/bull'; +import { Job } from 'bull'; +import { SyncService } from './sync.service'; +import { Queues } from '@@core/@core-services/queues/types'; + +@Processor(Queues.SYNC_JOBS_WORKER) +export class SyncProcessor { + constructor(private syncService: SyncService) {} + + @Process('ats-sync-departments') + async handleSyncDepartments(job: Job) { + try { + console.log(`Processing queue -> ats-sync-departments ${job.id}`); + await this.syncService.kickstartSync(); + } catch (error) { + console.error('Error syncing ats departments', error); + } + } +} diff --git a/packages/api/src/ecommerce/fulfillment/sync/sync.service.ts b/packages/api/src/ecommerce/fulfillment/sync/sync.service.ts new file mode 100644 index 000000000..61c7cbe95 --- /dev/null +++ b/packages/api/src/ecommerce/fulfillment/sync/sync.service.ts @@ -0,0 +1,203 @@ +import { Injectable, OnModuleInit } from '@nestjs/common'; +import { LoggerService } from '@@core/@core-services/logger/logger.service'; +import { PrismaService } from '@@core/@core-services/prisma/prisma.service'; +import { Cron } from '@nestjs/schedule'; +import { v4 as uuidv4 } from 'uuid'; +import { FieldMappingService } from '@@core/field-mapping/field-mapping.service'; +import { ServiceRegistry } from '../services/registry.service'; +import { WebhookService } from '@@core/@core-services/webhooks/panora-webhooks/webhook.service'; +import { CoreSyncRegistry } from '@@core/@core-services/registries/core-sync.registry'; +import { ApiResponse } from '@@core/utils/types'; +import { IDepartmentService } from '../types'; +import { OriginalDepartmentOutput } from '@@core/utils/types/original/original.ats'; +import { UnifiedDepartmentOutput } from '../types/model.unified'; +import { ats_departments as AtsDepartment } from '@prisma/client'; +import { ATS_PROVIDERS } from '@panora/shared'; +import { AtsObject } from '@ats/@lib/@types'; +import { BullQueueService } from '@@core/@core-services/queues/shared.service'; +import { IBaseSync, SyncLinkedUserType } from '@@core/utils/types/interface'; +import { IngestDataService } from '@@core/@core-services/unification/ingest-data.service'; +import { CoreUnification } from '@@core/@core-services/unification/core-unification.service'; + +@Injectable() +export class SyncService implements OnModuleInit, IBaseSync { + constructor( + private prisma: PrismaService, + private logger: LoggerService, + private webhook: WebhookService, + private fieldMappingService: FieldMappingService, + private serviceRegistry: ServiceRegistry, + private coreUnification: CoreUnification, + private registry: CoreSyncRegistry, + private bullQueueService: BullQueueService, + private ingestService: IngestDataService, + ) { + this.logger.setContext(SyncService.name); + this.registry.registerService('ats', 'department', this); + } + + async onModuleInit() { + try { + await this.bullQueueService.queueSyncJob( + 'ats-sync-departments', + '0 0 * * *', + ); + } catch (error) { + throw error; + } + } + + @Cron('0 */8 * * *') // every 8 hours + async kickstartSync(user_id?: string) { + try { + this.logger.log('Syncing departments...'); + const users = user_id + ? [ + await this.prisma.users.findUnique({ + where: { + id_user: user_id, + }, + }), + ] + : await this.prisma.users.findMany(); + if (users && users.length > 0) { + for (const user of users) { + const projects = await this.prisma.projects.findMany({ + where: { + id_user: user.id_user, + }, + }); + for (const project of projects) { + const id_project = project.id_project; + const linkedUsers = await this.prisma.linked_users.findMany({ + where: { + id_project: id_project, + }, + }); + linkedUsers.map(async (linkedUser) => { + try { + const providers = ATS_PROVIDERS; + for (const provider of providers) { + try { + await this.syncForLinkedUser({ + integrationId: provider, + linkedUserId: linkedUser.id_linked_user, + }); + } catch (error) { + throw error; + } + } + } catch (error) { + throw error; + } + }); + } + } + } + } catch (error) { + throw error; + } + } + + async syncForLinkedUser(param: SyncLinkedUserType) { + try { + const { integrationId, linkedUserId } = param; + const service: IDepartmentService = + this.serviceRegistry.getService(integrationId); + if (!service) return; + + await this.ingestService.syncForLinkedUser< + UnifiedDepartmentOutput, + OriginalDepartmentOutput, + IDepartmentService + >(integrationId, linkedUserId, 'ats', 'department', service, []); + } catch (error) { + throw error; + } + } + + async saveToDb( + connection_id: string, + linkedUserId: string, + departments: UnifiedDepartmentOutput[], + originSource: string, + remote_data: Record[], + ): Promise { + try { + const departments_results: AtsDepartment[] = []; + + const updateOrCreateDepartment = async ( + department: UnifiedDepartmentOutput, + originId: string, + ) => { + let existingDepartment; + if (!originId) { + existingDepartment = await this.prisma.ats_departments.findFirst({ + where: { + name: department.name, + id_connection: connection_id, + }, + }); + } else { + existingDepartment = await this.prisma.ats_departments.findFirst({ + where: { + remote_id: originId, + id_connection: connection_id, + }, + }); + } + + const baseData: any = { + name: department.name ?? null, + modified_at: new Date(), + }; + + if (existingDepartment) { + return await this.prisma.ats_departments.update({ + where: { + id_ats_department: existingDepartment.id_ats_department, + }, + data: baseData, + }); + } else { + return await this.prisma.ats_departments.create({ + data: { + ...baseData, + id_ats_department: uuidv4(), + created_at: new Date(), + remote_id: originId, + id_connection: connection_id, + }, + }); + } + }; + + for (let i = 0; i < departments.length; i++) { + const department = departments[i]; + const originId = department.remote_id; + + const res = await updateOrCreateDepartment(department, originId); + const department_id = res.id_ats_department; + departments_results.push(res); + + // Process field mappings + await this.ingestService.processFieldMappings( + department.field_mappings, + department_id, + originSource, + linkedUserId, + ); + + // Process remote data + await this.ingestService.processRemoteData( + department_id, + remote_data[i], + ); + } + + return departments_results; + } catch (error) { + throw error; + } + } +} diff --git a/packages/api/src/ecommerce/fulfillment/types/index.ts b/packages/api/src/ecommerce/fulfillment/types/index.ts new file mode 100644 index 000000000..2cca2d7d6 --- /dev/null +++ b/packages/api/src/ecommerce/fulfillment/types/index.ts @@ -0,0 +1,36 @@ +import { DesunifyReturnType } from '@@core/utils/types/desunify.input'; +import { + UnifiedDepartmentInput, + UnifiedDepartmentOutput, +} from './model.unified'; +import { OriginalDepartmentOutput } from '@@core/utils/types/original/original.ats'; +import { ApiResponse } from '@@core/utils/types'; +import { IBaseObjectService, SyncParam } from '@@core/utils/types/interface'; + +export interface IDepartmentService extends IBaseObjectService { + addDepartment( + departmentData: DesunifyReturnType, + linkedUserId: string, + ): Promise>; + + sync(data: SyncParam): Promise>; +} + +export interface IDepartmentMapper { + desunify( + source: UnifiedDepartmentInput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): DesunifyReturnType; + + unify( + source: OriginalDepartmentOutput | OriginalDepartmentOutput[], + connectionId: string, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): Promise; +} diff --git a/packages/api/src/ecommerce/fulfillment/types/model.unified.ts b/packages/api/src/ecommerce/fulfillment/types/model.unified.ts new file mode 100644 index 000000000..a874dcf6b --- /dev/null +++ b/packages/api/src/ecommerce/fulfillment/types/model.unified.ts @@ -0,0 +1,61 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsUUID, IsOptional, IsString, IsDateString } from 'class-validator'; + +export class UnifiedDepartmentInput { + @ApiPropertyOptional({ + type: String, + description: 'The name of the department', + }) + @IsString() + @IsOptional() + name?: string; + + @ApiPropertyOptional({ + type: {}, + description: + 'The custom field mappings of the object between the remote 3rd party & Panora', + }) + @IsOptional() + field_mappings?: Record; +} + +export class UnifiedDepartmentOutput extends UnifiedDepartmentInput { + @ApiPropertyOptional({ + type: String, + description: 'The UUID of the department', + }) + @IsUUID() + @IsOptional() + id?: string; + + @ApiPropertyOptional({ + type: String, + description: + 'The remote ID of the department in the context of the 3rd Party', + }) + @IsString() + @IsOptional() + remote_id?: string; + + @ApiPropertyOptional({ + type: {}, + description: + 'The remote data of the department in the context of the 3rd Party', + }) + @IsOptional() + remote_data?: Record; + + @ApiPropertyOptional({ + type: {}, + description: 'The created date of the object', + }) + @IsOptional() + created_at?: any; + + @ApiPropertyOptional({ + type: {}, + description: 'The modified date of the object', + }) + @IsOptional() + modified_at?: any; +} diff --git a/packages/api/src/ecommerce/fulfillment/utils/index.ts b/packages/api/src/ecommerce/fulfillment/utils/index.ts new file mode 100644 index 000000000..f849788c1 --- /dev/null +++ b/packages/api/src/ecommerce/fulfillment/utils/index.ts @@ -0,0 +1 @@ +/* PUT ALL UTILS FUNCTIONS USED IN YOUR OBJECT METHODS HERE */ diff --git a/packages/api/src/ecommerce/fulfillmentorders/fulfillmentorders.controller.ts b/packages/api/src/ecommerce/fulfillmentorders/fulfillmentorders.controller.ts new file mode 100644 index 000000000..18f984923 --- /dev/null +++ b/packages/api/src/ecommerce/fulfillmentorders/fulfillmentorders.controller.ts @@ -0,0 +1,120 @@ +import { + Controller, + Post, + Body, + Query, + Get, + Patch, + Param, + Headers, + UseGuards, +} from '@nestjs/common'; +import { LoggerService } from '@@core/@core-services/logger/logger.service'; +import { + ApiBody, + ApiOperation, + ApiParam, + ApiQuery, + ApiTags, + ApiHeader, +} from '@nestjs/swagger'; +import { ApiCustomResponse } from '@@core/utils/types'; +import { DepartmentService } from './services/department.service'; +import { + UnifiedDepartmentInput, + UnifiedDepartmentOutput, +} from './types/model.unified'; +import { ConnectionUtils } from '@@core/connections/@utils'; +import { ApiKeyAuthGuard } from '@@core/auth/guards/api-key.guard'; +import { FetchObjectsQueryDto } from '@@core/utils/dtos/fetch-objects-query.dto'; + +@ApiTags('ats/department') +@Controller('ats/department') +export class DepartmentController { + constructor( + private readonly departmentService: DepartmentService, + private logger: LoggerService, + private connectionUtils: ConnectionUtils, + ) { + this.logger.setContext(DepartmentController.name); + } + + @ApiOperation({ + operationId: 'getDepartments', + summary: 'List a batch of Departments', + }) + @ApiHeader({ + name: 'x-connection-token', + required: true, + description: 'The connection token', + example: 'b008e199-eda9-4629-bd41-a01b6195864a', + }) + @ApiCustomResponse(UnifiedDepartmentOutput) + @UseGuards(ApiKeyAuthGuard) + @Get() + async getDepartments( + @Headers('x-connection-token') connection_token: string, + @Query() query: FetchObjectsQueryDto, + ) { + try { + const { linkedUserId, remoteSource, connectionId } = + await this.connectionUtils.getConnectionMetadataFromConnectionToken( + connection_token, + ); + const { remote_data, limit, cursor } = query; + return this.departmentService.getDepartments( + connectionId, + remoteSource, + linkedUserId, + limit, + remote_data, + cursor, + ); + } catch (error) { + throw new Error(error); + } + } + + @ApiOperation({ + operationId: 'getDepartment', + summary: 'Retrieve a Department', + description: 'Retrieve a department from any connected Ats software', + }) + @ApiParam({ + name: 'id', + required: true, + type: String, + description: 'id of the department you want to retrieve.', + }) + @ApiQuery({ + name: 'remote_data', + required: false, + type: Boolean, + description: 'Set to true to include data from the original Ats software.', + }) + @ApiHeader({ + name: 'x-connection-token', + required: true, + description: 'The connection token', + example: 'b008e199-eda9-4629-bd41-a01b6195864a', + }) + @ApiCustomResponse(UnifiedDepartmentOutput) + @UseGuards(ApiKeyAuthGuard) + @Get(':id') + async retrieve( + @Headers('x-connection-token') connection_token: string, + @Param('id') id: string, + @Query('remote_data') remote_data?: boolean, + ) { + const { linkedUserId, remoteSource } = + await this.connectionUtils.getConnectionMetadataFromConnectionToken( + connection_token, + ); + return this.departmentService.getDepartment( + id, + linkedUserId, + remoteSource, + remote_data, + ); + } +} diff --git a/packages/api/src/ecommerce/fulfillmentorders/fulfillmentorders.module.ts b/packages/api/src/ecommerce/fulfillmentorders/fulfillmentorders.module.ts new file mode 100644 index 000000000..2b2128304 --- /dev/null +++ b/packages/api/src/ecommerce/fulfillmentorders/fulfillmentorders.module.ts @@ -0,0 +1,41 @@ +import { EncryptionService } from '@@core/@core-services/encryption/encryption.service'; +import { LoggerService } from '@@core/@core-services/logger/logger.service'; +import { WebhookService } from '@@core/@core-services/webhooks/panora-webhooks/webhook.service'; +import { ConnectionUtils } from '@@core/connections/@utils'; +import { FieldMappingService } from '@@core/field-mapping/field-mapping.service'; +import { Module } from '@nestjs/common'; +import { DepartmentController } from './fulfillmentorders.controller'; +import { DepartmentService } from './services/department.service'; +import { ServiceRegistry } from './services/registry.service'; +import { SyncService } from './sync/sync.service'; +import { IngestDataService } from '@@core/@core-services/unification/ingest-data.service'; +import { AshbyService } from './services/ashby'; + +import { BullQueueModule } from '@@core/@core-services/queues/queue.module'; + +import { CoreUnification } from '@@core/@core-services/unification/core-unification.service'; +import { AshbyDepartmentMapper } from './services/ashby/mappers'; +import { Utils } from '@ats/@lib/@utils'; + +@Module({ + imports: [BullQueueModule], + controllers: [DepartmentController], + providers: [ + DepartmentService, + CoreUnification, + + SyncService, + WebhookService, + + ServiceRegistry, + + IngestDataService, + + Utils, + AshbyDepartmentMapper, + /* PROVIDERS SERVICES */ + AshbyService, + ], + exports: [SyncService], +}) +export class DepartmentModule {} diff --git a/packages/api/src/ecommerce/fulfillmentorders/services/ashby/index.ts b/packages/api/src/ecommerce/fulfillmentorders/services/ashby/index.ts new file mode 100644 index 000000000..cceda214e --- /dev/null +++ b/packages/api/src/ecommerce/fulfillmentorders/services/ashby/index.ts @@ -0,0 +1,70 @@ +import { Injectable } from '@nestjs/common'; +import { IDepartmentService } from '@ats/department/types'; +import { AtsObject } from '@ats/@lib/@types'; +import axios from 'axios'; +import { PrismaService } from '@@core/@core-services/prisma/prisma.service'; +import { LoggerService } from '@@core/@core-services/logger/logger.service'; +import { ActionType, handle3rdPartyServiceError } from '@@core/utils/errors'; +import { EncryptionService } from '@@core/@core-services/encryption/encryption.service'; +import { ApiResponse } from '@@core/utils/types'; +import { ServiceRegistry } from '../registry.service'; +import { AshbyDepartmentInput, AshbyDepartmentOutput } from './types'; +import { DesunifyReturnType } from '@@core/utils/types/desunify.input'; +import { OriginalDepartmentOutput } from '@@core/utils/types/original/original.ats'; +import { SyncParam } from '@@core/utils/types/interface'; + +@Injectable() +export class AshbyService implements IDepartmentService { + constructor( + private prisma: PrismaService, + private logger: LoggerService, + private cryptoService: EncryptionService, + private registry: ServiceRegistry, + ) { + this.logger.setContext( + AtsObject.department.toUpperCase() + ':' + AshbyService.name, + ); + this.registry.registerService('ashby', this); + } + addDepartment( + departmentData: DesunifyReturnType, + linkedUserId: string, + ): Promise> { + throw new Error('Method not implemented.'); + } + + async sync(data: SyncParam): Promise> { + try { + const { linkedUserId } = data; + + const connection = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedUserId, + provider_slug: 'ashby', + vertical: 'ats', + }, + }); + const resp = await axios.post( + `${connection.account_url}/departement.list`, + { + headers: { + 'Content-Type': 'application/json', + Authorization: `Basic ${Buffer.from( + `${this.cryptoService.decrypt(connection.access_token)}:`, + ).toString('base64')}`, + }, + }, + ); + const departments: AshbyDepartmentOutput[] = resp.data.results; + this.logger.log(`Synced ashby departments !`); + + return { + data: departments, + message: 'Ashby departments retrieved', + statusCode: 200, + }; + } catch (error) { + throw error; + } + } +} diff --git a/packages/api/src/ecommerce/fulfillmentorders/services/ashby/mappers.ts b/packages/api/src/ecommerce/fulfillmentorders/services/ashby/mappers.ts new file mode 100644 index 000000000..877efdf88 --- /dev/null +++ b/packages/api/src/ecommerce/fulfillmentorders/services/ashby/mappers.ts @@ -0,0 +1,73 @@ +import { AshbyDepartmentInput, AshbyDepartmentOutput } from './types'; +import { + UnifiedDepartmentInput, + UnifiedDepartmentOutput, +} from '@ats/department/types/model.unified'; +import { IDepartmentMapper } from '@ats/department/types'; +import { MappersRegistry } from '@@core/@core-services/registries/mappers.registry'; +import { Injectable } from '@nestjs/common'; +import { CoreUnification } from '@@core/@core-services/unification/core-unification.service'; +import { Utils } from '@ats/@lib/@utils'; + +@Injectable() +export class AshbyDepartmentMapper implements IDepartmentMapper { + constructor( + private mappersRegistry: MappersRegistry, + private utils: Utils, + private coreUnificationService: CoreUnification, + ) { + this.mappersRegistry.registerService('ats', 'department', 'ashby', this); + } + + async desunify( + source: UnifiedDepartmentInput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): Promise { + return; + } + + async unify( + source: AshbyDepartmentOutput | AshbyDepartmentOutput[], + connectionId: string, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): Promise { + if (!Array.isArray(source)) { + return await this.mapSingleDepartmentToUnified( + source, + connectionId, + customFieldMappings, + ); + } + // Handling array of AshbyDepartmentOutput + return Promise.all( + source.map((department) => + this.mapSingleDepartmentToUnified( + department, + connectionId, + customFieldMappings, + ), + ), + ); + } + + private async mapSingleDepartmentToUnified( + department: AshbyDepartmentOutput, + connectionId: string, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): Promise { + return { + remote_id: department.id, + remote_data: department, + name: department.name || null, + }; + } +} diff --git a/packages/api/src/ecommerce/fulfillmentorders/services/ashby/types.ts b/packages/api/src/ecommerce/fulfillmentorders/services/ashby/types.ts new file mode 100644 index 000000000..d3c47509a --- /dev/null +++ b/packages/api/src/ecommerce/fulfillmentorders/services/ashby/types.ts @@ -0,0 +1,8 @@ +export interface AshbyDepartmentInput { + id: string; + name: string; + isArchived: boolean; + parentId: string; +} + +export type AshbyDepartmentOutput = Partial; diff --git a/packages/api/src/ecommerce/fulfillmentorders/services/department.service.ts b/packages/api/src/ecommerce/fulfillmentorders/services/department.service.ts new file mode 100644 index 000000000..deb50851d --- /dev/null +++ b/packages/api/src/ecommerce/fulfillmentorders/services/department.service.ts @@ -0,0 +1,234 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '@@core/@core-services/prisma/prisma.service'; +import { LoggerService } from '@@core/@core-services/logger/logger.service'; +import { v4 as uuidv4 } from 'uuid'; +import { UnifiedDepartmentOutput } from '../types/model.unified'; + +@Injectable() +export class DepartmentService { + constructor(private prisma: PrismaService, private logger: LoggerService) { + this.logger.setContext(DepartmentService.name); + } + + async getDepartment( + id_ats_department: string, + linkedUserId: string, + integrationId: string, + remote_data?: boolean, + ): Promise { + try { + const department = await this.prisma.ats_departments.findUnique({ + where: { + id_ats_department: id_ats_department, + }, + }); + + if (!department) { + throw new Error(`Department with ID ${id_ats_department} not found.`); + } + + // Fetch field mappings for the department + const values = await this.prisma.value.findMany({ + where: { + entity: { + ressource_owner_id: department.id_ats_department, + }, + }, + include: { + attribute: true, + }, + }); + + // Create a map to store unique field mappings + const fieldMappingsMap = new Map(); + + values.forEach((value) => { + fieldMappingsMap.set(value.attribute.slug, value.data); + }); + + // Convert the map to an array of objects + const field_mappings = Array.from(fieldMappingsMap, ([key, value]) => ({ + [key]: value, + })); + + // Transform to UnifiedDepartmentOutput format + const unifiedDepartment: UnifiedDepartmentOutput = { + id: department.id_ats_department, + name: department.name, + field_mappings: field_mappings, + remote_id: department.remote_id, + created_at: department.created_at, + modified_at: department.modified_at, + }; + + let res: UnifiedDepartmentOutput = unifiedDepartment; + if (remote_data) { + const resp = await this.prisma.remote_data.findFirst({ + where: { + ressource_owner_id: department.id_ats_department, + }, + }); + const remote_data = JSON.parse(resp.data); + + res = { + ...res, + remote_data: remote_data, + }; + } + await this.prisma.events.create({ + data: { + id_event: uuidv4(), + status: 'success', + type: 'ats.department.pull', + method: 'GET', + url: '/ats/department', + provider: integrationId, + direction: '0', + timestamp: new Date(), + id_linked_user: linkedUserId, + }, + }); + + return res; + } catch (error) { + throw error; + } + } + + async getDepartments( + connection_id: string, + integrationId: string, + linkedUserId: string, + limit: number, + remote_data?: boolean, + cursor?: string, + ): Promise<{ + data: UnifiedDepartmentOutput[]; + prev_cursor: null | string; + next_cursor: null | string; + }> { + try { + let prev_cursor = null; + let next_cursor = null; + + if (cursor) { + const isCursorPresent = await this.prisma.ats_departments.findFirst({ + where: { + id_connection: connection_id, + id_ats_department: cursor, + }, + }); + if (!isCursorPresent) { + throw new ReferenceError(`The provided cursor does not exist!`); + } + } + + const departments = await this.prisma.ats_departments.findMany({ + take: limit + 1, + cursor: cursor + ? { + id_ats_department: cursor, + } + : undefined, + orderBy: { + created_at: 'asc', + }, + where: { + id_connection: connection_id, + }, + }); + + if (departments.length === limit + 1) { + next_cursor = Buffer.from( + departments[departments.length - 1].id_ats_department, + ).toString('base64'); + departments.pop(); + } + + if (cursor) { + prev_cursor = Buffer.from(cursor).toString('base64'); + } + + const unifiedDepartments: UnifiedDepartmentOutput[] = await Promise.all( + departments.map(async (department) => { + // Fetch field mappings for the department + const values = await this.prisma.value.findMany({ + where: { + entity: { + ressource_owner_id: department.id_ats_department, + }, + }, + include: { + attribute: true, + }, + }); + + // Create a map to store unique field mappings + const fieldMappingsMap = new Map(); + + values.forEach((value) => { + fieldMappingsMap.set(value.attribute.slug, value.data); + }); + + // Convert the map to an array of objects + const field_mappings = Array.from( + fieldMappingsMap, + ([key, value]) => ({ + [key]: value, + }), + ); + + // Transform to UnifiedDepartmentOutput format + return { + id: department.id_ats_department, + name: department.name, + field_mappings: field_mappings, + remote_id: department.remote_id, + created_at: department.created_at, + modified_at: department.modified_at, + }; + }), + ); + + let res: UnifiedDepartmentOutput[] = unifiedDepartments; + + if (remote_data) { + const remote_array_data: UnifiedDepartmentOutput[] = await Promise.all( + res.map(async (department) => { + const resp = await this.prisma.remote_data.findFirst({ + where: { + ressource_owner_id: department.id, + }, + }); + const remote_data = JSON.parse(resp.data); + return { ...department, remote_data }; + }), + ); + + res = remote_array_data; + } + + await this.prisma.events.create({ + data: { + id_event: uuidv4(), + status: 'success', + type: 'ats.department.pull', + method: 'GET', + url: '/ats/departments', + provider: integrationId, + direction: '0', + timestamp: new Date(), + id_linked_user: linkedUserId, + }, + }); + + return { + data: res, + prev_cursor, + next_cursor, + }; + } catch (error) { + throw error; + } + } +} diff --git a/packages/api/src/ecommerce/fulfillmentorders/services/registry.service.ts b/packages/api/src/ecommerce/fulfillmentorders/services/registry.service.ts new file mode 100644 index 000000000..ee946e7b7 --- /dev/null +++ b/packages/api/src/ecommerce/fulfillmentorders/services/registry.service.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@nestjs/common'; +import { IDepartmentService } from '../types'; + +@Injectable() +export class ServiceRegistry { + private serviceMap: Map; + + constructor() { + this.serviceMap = new Map(); + } + + registerService(serviceKey: string, service: IDepartmentService) { + this.serviceMap.set(serviceKey, service); + } + + getService(integrationId: string): IDepartmentService { + const service = this.serviceMap.get(integrationId); + if (!service) { + throw new ReferenceError( + `Service not found for integration ID: ${integrationId}`, + ); + } + return service; + } +} diff --git a/packages/api/src/ecommerce/fulfillmentorders/sync/sync.processor.ts b/packages/api/src/ecommerce/fulfillmentorders/sync/sync.processor.ts new file mode 100644 index 000000000..4c01a02f0 --- /dev/null +++ b/packages/api/src/ecommerce/fulfillmentorders/sync/sync.processor.ts @@ -0,0 +1,19 @@ +import { Processor, Process } from '@nestjs/bull'; +import { Job } from 'bull'; +import { SyncService } from './sync.service'; +import { Queues } from '@@core/@core-services/queues/types'; + +@Processor(Queues.SYNC_JOBS_WORKER) +export class SyncProcessor { + constructor(private syncService: SyncService) {} + + @Process('ats-sync-departments') + async handleSyncDepartments(job: Job) { + try { + console.log(`Processing queue -> ats-sync-departments ${job.id}`); + await this.syncService.kickstartSync(); + } catch (error) { + console.error('Error syncing ats departments', error); + } + } +} diff --git a/packages/api/src/ecommerce/fulfillmentorders/sync/sync.service.ts b/packages/api/src/ecommerce/fulfillmentorders/sync/sync.service.ts new file mode 100644 index 000000000..61c7cbe95 --- /dev/null +++ b/packages/api/src/ecommerce/fulfillmentorders/sync/sync.service.ts @@ -0,0 +1,203 @@ +import { Injectable, OnModuleInit } from '@nestjs/common'; +import { LoggerService } from '@@core/@core-services/logger/logger.service'; +import { PrismaService } from '@@core/@core-services/prisma/prisma.service'; +import { Cron } from '@nestjs/schedule'; +import { v4 as uuidv4 } from 'uuid'; +import { FieldMappingService } from '@@core/field-mapping/field-mapping.service'; +import { ServiceRegistry } from '../services/registry.service'; +import { WebhookService } from '@@core/@core-services/webhooks/panora-webhooks/webhook.service'; +import { CoreSyncRegistry } from '@@core/@core-services/registries/core-sync.registry'; +import { ApiResponse } from '@@core/utils/types'; +import { IDepartmentService } from '../types'; +import { OriginalDepartmentOutput } from '@@core/utils/types/original/original.ats'; +import { UnifiedDepartmentOutput } from '../types/model.unified'; +import { ats_departments as AtsDepartment } from '@prisma/client'; +import { ATS_PROVIDERS } from '@panora/shared'; +import { AtsObject } from '@ats/@lib/@types'; +import { BullQueueService } from '@@core/@core-services/queues/shared.service'; +import { IBaseSync, SyncLinkedUserType } from '@@core/utils/types/interface'; +import { IngestDataService } from '@@core/@core-services/unification/ingest-data.service'; +import { CoreUnification } from '@@core/@core-services/unification/core-unification.service'; + +@Injectable() +export class SyncService implements OnModuleInit, IBaseSync { + constructor( + private prisma: PrismaService, + private logger: LoggerService, + private webhook: WebhookService, + private fieldMappingService: FieldMappingService, + private serviceRegistry: ServiceRegistry, + private coreUnification: CoreUnification, + private registry: CoreSyncRegistry, + private bullQueueService: BullQueueService, + private ingestService: IngestDataService, + ) { + this.logger.setContext(SyncService.name); + this.registry.registerService('ats', 'department', this); + } + + async onModuleInit() { + try { + await this.bullQueueService.queueSyncJob( + 'ats-sync-departments', + '0 0 * * *', + ); + } catch (error) { + throw error; + } + } + + @Cron('0 */8 * * *') // every 8 hours + async kickstartSync(user_id?: string) { + try { + this.logger.log('Syncing departments...'); + const users = user_id + ? [ + await this.prisma.users.findUnique({ + where: { + id_user: user_id, + }, + }), + ] + : await this.prisma.users.findMany(); + if (users && users.length > 0) { + for (const user of users) { + const projects = await this.prisma.projects.findMany({ + where: { + id_user: user.id_user, + }, + }); + for (const project of projects) { + const id_project = project.id_project; + const linkedUsers = await this.prisma.linked_users.findMany({ + where: { + id_project: id_project, + }, + }); + linkedUsers.map(async (linkedUser) => { + try { + const providers = ATS_PROVIDERS; + for (const provider of providers) { + try { + await this.syncForLinkedUser({ + integrationId: provider, + linkedUserId: linkedUser.id_linked_user, + }); + } catch (error) { + throw error; + } + } + } catch (error) { + throw error; + } + }); + } + } + } + } catch (error) { + throw error; + } + } + + async syncForLinkedUser(param: SyncLinkedUserType) { + try { + const { integrationId, linkedUserId } = param; + const service: IDepartmentService = + this.serviceRegistry.getService(integrationId); + if (!service) return; + + await this.ingestService.syncForLinkedUser< + UnifiedDepartmentOutput, + OriginalDepartmentOutput, + IDepartmentService + >(integrationId, linkedUserId, 'ats', 'department', service, []); + } catch (error) { + throw error; + } + } + + async saveToDb( + connection_id: string, + linkedUserId: string, + departments: UnifiedDepartmentOutput[], + originSource: string, + remote_data: Record[], + ): Promise { + try { + const departments_results: AtsDepartment[] = []; + + const updateOrCreateDepartment = async ( + department: UnifiedDepartmentOutput, + originId: string, + ) => { + let existingDepartment; + if (!originId) { + existingDepartment = await this.prisma.ats_departments.findFirst({ + where: { + name: department.name, + id_connection: connection_id, + }, + }); + } else { + existingDepartment = await this.prisma.ats_departments.findFirst({ + where: { + remote_id: originId, + id_connection: connection_id, + }, + }); + } + + const baseData: any = { + name: department.name ?? null, + modified_at: new Date(), + }; + + if (existingDepartment) { + return await this.prisma.ats_departments.update({ + where: { + id_ats_department: existingDepartment.id_ats_department, + }, + data: baseData, + }); + } else { + return await this.prisma.ats_departments.create({ + data: { + ...baseData, + id_ats_department: uuidv4(), + created_at: new Date(), + remote_id: originId, + id_connection: connection_id, + }, + }); + } + }; + + for (let i = 0; i < departments.length; i++) { + const department = departments[i]; + const originId = department.remote_id; + + const res = await updateOrCreateDepartment(department, originId); + const department_id = res.id_ats_department; + departments_results.push(res); + + // Process field mappings + await this.ingestService.processFieldMappings( + department.field_mappings, + department_id, + originSource, + linkedUserId, + ); + + // Process remote data + await this.ingestService.processRemoteData( + department_id, + remote_data[i], + ); + } + + return departments_results; + } catch (error) { + throw error; + } + } +} diff --git a/packages/api/src/ecommerce/fulfillmentorders/types/index.ts b/packages/api/src/ecommerce/fulfillmentorders/types/index.ts new file mode 100644 index 000000000..2cca2d7d6 --- /dev/null +++ b/packages/api/src/ecommerce/fulfillmentorders/types/index.ts @@ -0,0 +1,36 @@ +import { DesunifyReturnType } from '@@core/utils/types/desunify.input'; +import { + UnifiedDepartmentInput, + UnifiedDepartmentOutput, +} from './model.unified'; +import { OriginalDepartmentOutput } from '@@core/utils/types/original/original.ats'; +import { ApiResponse } from '@@core/utils/types'; +import { IBaseObjectService, SyncParam } from '@@core/utils/types/interface'; + +export interface IDepartmentService extends IBaseObjectService { + addDepartment( + departmentData: DesunifyReturnType, + linkedUserId: string, + ): Promise>; + + sync(data: SyncParam): Promise>; +} + +export interface IDepartmentMapper { + desunify( + source: UnifiedDepartmentInput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): DesunifyReturnType; + + unify( + source: OriginalDepartmentOutput | OriginalDepartmentOutput[], + connectionId: string, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): Promise; +} diff --git a/packages/api/src/ecommerce/fulfillmentorders/types/model.unified.ts b/packages/api/src/ecommerce/fulfillmentorders/types/model.unified.ts new file mode 100644 index 000000000..a874dcf6b --- /dev/null +++ b/packages/api/src/ecommerce/fulfillmentorders/types/model.unified.ts @@ -0,0 +1,61 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsUUID, IsOptional, IsString, IsDateString } from 'class-validator'; + +export class UnifiedDepartmentInput { + @ApiPropertyOptional({ + type: String, + description: 'The name of the department', + }) + @IsString() + @IsOptional() + name?: string; + + @ApiPropertyOptional({ + type: {}, + description: + 'The custom field mappings of the object between the remote 3rd party & Panora', + }) + @IsOptional() + field_mappings?: Record; +} + +export class UnifiedDepartmentOutput extends UnifiedDepartmentInput { + @ApiPropertyOptional({ + type: String, + description: 'The UUID of the department', + }) + @IsUUID() + @IsOptional() + id?: string; + + @ApiPropertyOptional({ + type: String, + description: + 'The remote ID of the department in the context of the 3rd Party', + }) + @IsString() + @IsOptional() + remote_id?: string; + + @ApiPropertyOptional({ + type: {}, + description: + 'The remote data of the department in the context of the 3rd Party', + }) + @IsOptional() + remote_data?: Record; + + @ApiPropertyOptional({ + type: {}, + description: 'The created date of the object', + }) + @IsOptional() + created_at?: any; + + @ApiPropertyOptional({ + type: {}, + description: 'The modified date of the object', + }) + @IsOptional() + modified_at?: any; +} diff --git a/packages/api/src/ecommerce/fulfillmentorders/utils/index.ts b/packages/api/src/ecommerce/fulfillmentorders/utils/index.ts new file mode 100644 index 000000000..f849788c1 --- /dev/null +++ b/packages/api/src/ecommerce/fulfillmentorders/utils/index.ts @@ -0,0 +1 @@ +/* PUT ALL UTILS FUNCTIONS USED IN YOUR OBJECT METHODS HERE */ diff --git a/packages/api/src/ecommerce/order/order.controller.ts b/packages/api/src/ecommerce/order/order.controller.ts new file mode 100644 index 000000000..18f984923 --- /dev/null +++ b/packages/api/src/ecommerce/order/order.controller.ts @@ -0,0 +1,120 @@ +import { + Controller, + Post, + Body, + Query, + Get, + Patch, + Param, + Headers, + UseGuards, +} from '@nestjs/common'; +import { LoggerService } from '@@core/@core-services/logger/logger.service'; +import { + ApiBody, + ApiOperation, + ApiParam, + ApiQuery, + ApiTags, + ApiHeader, +} from '@nestjs/swagger'; +import { ApiCustomResponse } from '@@core/utils/types'; +import { DepartmentService } from './services/department.service'; +import { + UnifiedDepartmentInput, + UnifiedDepartmentOutput, +} from './types/model.unified'; +import { ConnectionUtils } from '@@core/connections/@utils'; +import { ApiKeyAuthGuard } from '@@core/auth/guards/api-key.guard'; +import { FetchObjectsQueryDto } from '@@core/utils/dtos/fetch-objects-query.dto'; + +@ApiTags('ats/department') +@Controller('ats/department') +export class DepartmentController { + constructor( + private readonly departmentService: DepartmentService, + private logger: LoggerService, + private connectionUtils: ConnectionUtils, + ) { + this.logger.setContext(DepartmentController.name); + } + + @ApiOperation({ + operationId: 'getDepartments', + summary: 'List a batch of Departments', + }) + @ApiHeader({ + name: 'x-connection-token', + required: true, + description: 'The connection token', + example: 'b008e199-eda9-4629-bd41-a01b6195864a', + }) + @ApiCustomResponse(UnifiedDepartmentOutput) + @UseGuards(ApiKeyAuthGuard) + @Get() + async getDepartments( + @Headers('x-connection-token') connection_token: string, + @Query() query: FetchObjectsQueryDto, + ) { + try { + const { linkedUserId, remoteSource, connectionId } = + await this.connectionUtils.getConnectionMetadataFromConnectionToken( + connection_token, + ); + const { remote_data, limit, cursor } = query; + return this.departmentService.getDepartments( + connectionId, + remoteSource, + linkedUserId, + limit, + remote_data, + cursor, + ); + } catch (error) { + throw new Error(error); + } + } + + @ApiOperation({ + operationId: 'getDepartment', + summary: 'Retrieve a Department', + description: 'Retrieve a department from any connected Ats software', + }) + @ApiParam({ + name: 'id', + required: true, + type: String, + description: 'id of the department you want to retrieve.', + }) + @ApiQuery({ + name: 'remote_data', + required: false, + type: Boolean, + description: 'Set to true to include data from the original Ats software.', + }) + @ApiHeader({ + name: 'x-connection-token', + required: true, + description: 'The connection token', + example: 'b008e199-eda9-4629-bd41-a01b6195864a', + }) + @ApiCustomResponse(UnifiedDepartmentOutput) + @UseGuards(ApiKeyAuthGuard) + @Get(':id') + async retrieve( + @Headers('x-connection-token') connection_token: string, + @Param('id') id: string, + @Query('remote_data') remote_data?: boolean, + ) { + const { linkedUserId, remoteSource } = + await this.connectionUtils.getConnectionMetadataFromConnectionToken( + connection_token, + ); + return this.departmentService.getDepartment( + id, + linkedUserId, + remoteSource, + remote_data, + ); + } +} diff --git a/packages/api/src/ecommerce/order/order.module.ts b/packages/api/src/ecommerce/order/order.module.ts new file mode 100644 index 000000000..9d60d9594 --- /dev/null +++ b/packages/api/src/ecommerce/order/order.module.ts @@ -0,0 +1,41 @@ +import { EncryptionService } from '@@core/@core-services/encryption/encryption.service'; +import { LoggerService } from '@@core/@core-services/logger/logger.service'; +import { WebhookService } from '@@core/@core-services/webhooks/panora-webhooks/webhook.service'; +import { ConnectionUtils } from '@@core/connections/@utils'; +import { FieldMappingService } from '@@core/field-mapping/field-mapping.service'; +import { Module } from '@nestjs/common'; +import { DepartmentController } from './department.controller'; +import { DepartmentService } from './services/department.service'; +import { ServiceRegistry } from './services/registry.service'; +import { SyncService } from './sync/sync.service'; +import { IngestDataService } from '@@core/@core-services/unification/ingest-data.service'; +import { AshbyService } from './services/ashby'; + +import { BullQueueModule } from '@@core/@core-services/queues/queue.module'; + +import { CoreUnification } from '@@core/@core-services/unification/core-unification.service'; +import { AshbyDepartmentMapper } from './services/ashby/mappers'; +import { Utils } from '@ats/@lib/@utils'; + +@Module({ + imports: [BullQueueModule], + controllers: [DepartmentController], + providers: [ + DepartmentService, + CoreUnification, + + SyncService, + WebhookService, + + ServiceRegistry, + + IngestDataService, + + Utils, + AshbyDepartmentMapper, + /* PROVIDERS SERVICES */ + AshbyService, + ], + exports: [SyncService], +}) +export class DepartmentModule {} diff --git a/packages/api/src/ecommerce/order/services/ashby/index.ts b/packages/api/src/ecommerce/order/services/ashby/index.ts new file mode 100644 index 000000000..cceda214e --- /dev/null +++ b/packages/api/src/ecommerce/order/services/ashby/index.ts @@ -0,0 +1,70 @@ +import { Injectable } from '@nestjs/common'; +import { IDepartmentService } from '@ats/department/types'; +import { AtsObject } from '@ats/@lib/@types'; +import axios from 'axios'; +import { PrismaService } from '@@core/@core-services/prisma/prisma.service'; +import { LoggerService } from '@@core/@core-services/logger/logger.service'; +import { ActionType, handle3rdPartyServiceError } from '@@core/utils/errors'; +import { EncryptionService } from '@@core/@core-services/encryption/encryption.service'; +import { ApiResponse } from '@@core/utils/types'; +import { ServiceRegistry } from '../registry.service'; +import { AshbyDepartmentInput, AshbyDepartmentOutput } from './types'; +import { DesunifyReturnType } from '@@core/utils/types/desunify.input'; +import { OriginalDepartmentOutput } from '@@core/utils/types/original/original.ats'; +import { SyncParam } from '@@core/utils/types/interface'; + +@Injectable() +export class AshbyService implements IDepartmentService { + constructor( + private prisma: PrismaService, + private logger: LoggerService, + private cryptoService: EncryptionService, + private registry: ServiceRegistry, + ) { + this.logger.setContext( + AtsObject.department.toUpperCase() + ':' + AshbyService.name, + ); + this.registry.registerService('ashby', this); + } + addDepartment( + departmentData: DesunifyReturnType, + linkedUserId: string, + ): Promise> { + throw new Error('Method not implemented.'); + } + + async sync(data: SyncParam): Promise> { + try { + const { linkedUserId } = data; + + const connection = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedUserId, + provider_slug: 'ashby', + vertical: 'ats', + }, + }); + const resp = await axios.post( + `${connection.account_url}/departement.list`, + { + headers: { + 'Content-Type': 'application/json', + Authorization: `Basic ${Buffer.from( + `${this.cryptoService.decrypt(connection.access_token)}:`, + ).toString('base64')}`, + }, + }, + ); + const departments: AshbyDepartmentOutput[] = resp.data.results; + this.logger.log(`Synced ashby departments !`); + + return { + data: departments, + message: 'Ashby departments retrieved', + statusCode: 200, + }; + } catch (error) { + throw error; + } + } +} diff --git a/packages/api/src/ecommerce/order/services/ashby/mappers.ts b/packages/api/src/ecommerce/order/services/ashby/mappers.ts new file mode 100644 index 000000000..877efdf88 --- /dev/null +++ b/packages/api/src/ecommerce/order/services/ashby/mappers.ts @@ -0,0 +1,73 @@ +import { AshbyDepartmentInput, AshbyDepartmentOutput } from './types'; +import { + UnifiedDepartmentInput, + UnifiedDepartmentOutput, +} from '@ats/department/types/model.unified'; +import { IDepartmentMapper } from '@ats/department/types'; +import { MappersRegistry } from '@@core/@core-services/registries/mappers.registry'; +import { Injectable } from '@nestjs/common'; +import { CoreUnification } from '@@core/@core-services/unification/core-unification.service'; +import { Utils } from '@ats/@lib/@utils'; + +@Injectable() +export class AshbyDepartmentMapper implements IDepartmentMapper { + constructor( + private mappersRegistry: MappersRegistry, + private utils: Utils, + private coreUnificationService: CoreUnification, + ) { + this.mappersRegistry.registerService('ats', 'department', 'ashby', this); + } + + async desunify( + source: UnifiedDepartmentInput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): Promise { + return; + } + + async unify( + source: AshbyDepartmentOutput | AshbyDepartmentOutput[], + connectionId: string, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): Promise { + if (!Array.isArray(source)) { + return await this.mapSingleDepartmentToUnified( + source, + connectionId, + customFieldMappings, + ); + } + // Handling array of AshbyDepartmentOutput + return Promise.all( + source.map((department) => + this.mapSingleDepartmentToUnified( + department, + connectionId, + customFieldMappings, + ), + ), + ); + } + + private async mapSingleDepartmentToUnified( + department: AshbyDepartmentOutput, + connectionId: string, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): Promise { + return { + remote_id: department.id, + remote_data: department, + name: department.name || null, + }; + } +} diff --git a/packages/api/src/ecommerce/order/services/ashby/types.ts b/packages/api/src/ecommerce/order/services/ashby/types.ts new file mode 100644 index 000000000..d3c47509a --- /dev/null +++ b/packages/api/src/ecommerce/order/services/ashby/types.ts @@ -0,0 +1,8 @@ +export interface AshbyDepartmentInput { + id: string; + name: string; + isArchived: boolean; + parentId: string; +} + +export type AshbyDepartmentOutput = Partial; diff --git a/packages/api/src/ecommerce/order/services/department.service.ts b/packages/api/src/ecommerce/order/services/department.service.ts new file mode 100644 index 000000000..deb50851d --- /dev/null +++ b/packages/api/src/ecommerce/order/services/department.service.ts @@ -0,0 +1,234 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '@@core/@core-services/prisma/prisma.service'; +import { LoggerService } from '@@core/@core-services/logger/logger.service'; +import { v4 as uuidv4 } from 'uuid'; +import { UnifiedDepartmentOutput } from '../types/model.unified'; + +@Injectable() +export class DepartmentService { + constructor(private prisma: PrismaService, private logger: LoggerService) { + this.logger.setContext(DepartmentService.name); + } + + async getDepartment( + id_ats_department: string, + linkedUserId: string, + integrationId: string, + remote_data?: boolean, + ): Promise { + try { + const department = await this.prisma.ats_departments.findUnique({ + where: { + id_ats_department: id_ats_department, + }, + }); + + if (!department) { + throw new Error(`Department with ID ${id_ats_department} not found.`); + } + + // Fetch field mappings for the department + const values = await this.prisma.value.findMany({ + where: { + entity: { + ressource_owner_id: department.id_ats_department, + }, + }, + include: { + attribute: true, + }, + }); + + // Create a map to store unique field mappings + const fieldMappingsMap = new Map(); + + values.forEach((value) => { + fieldMappingsMap.set(value.attribute.slug, value.data); + }); + + // Convert the map to an array of objects + const field_mappings = Array.from(fieldMappingsMap, ([key, value]) => ({ + [key]: value, + })); + + // Transform to UnifiedDepartmentOutput format + const unifiedDepartment: UnifiedDepartmentOutput = { + id: department.id_ats_department, + name: department.name, + field_mappings: field_mappings, + remote_id: department.remote_id, + created_at: department.created_at, + modified_at: department.modified_at, + }; + + let res: UnifiedDepartmentOutput = unifiedDepartment; + if (remote_data) { + const resp = await this.prisma.remote_data.findFirst({ + where: { + ressource_owner_id: department.id_ats_department, + }, + }); + const remote_data = JSON.parse(resp.data); + + res = { + ...res, + remote_data: remote_data, + }; + } + await this.prisma.events.create({ + data: { + id_event: uuidv4(), + status: 'success', + type: 'ats.department.pull', + method: 'GET', + url: '/ats/department', + provider: integrationId, + direction: '0', + timestamp: new Date(), + id_linked_user: linkedUserId, + }, + }); + + return res; + } catch (error) { + throw error; + } + } + + async getDepartments( + connection_id: string, + integrationId: string, + linkedUserId: string, + limit: number, + remote_data?: boolean, + cursor?: string, + ): Promise<{ + data: UnifiedDepartmentOutput[]; + prev_cursor: null | string; + next_cursor: null | string; + }> { + try { + let prev_cursor = null; + let next_cursor = null; + + if (cursor) { + const isCursorPresent = await this.prisma.ats_departments.findFirst({ + where: { + id_connection: connection_id, + id_ats_department: cursor, + }, + }); + if (!isCursorPresent) { + throw new ReferenceError(`The provided cursor does not exist!`); + } + } + + const departments = await this.prisma.ats_departments.findMany({ + take: limit + 1, + cursor: cursor + ? { + id_ats_department: cursor, + } + : undefined, + orderBy: { + created_at: 'asc', + }, + where: { + id_connection: connection_id, + }, + }); + + if (departments.length === limit + 1) { + next_cursor = Buffer.from( + departments[departments.length - 1].id_ats_department, + ).toString('base64'); + departments.pop(); + } + + if (cursor) { + prev_cursor = Buffer.from(cursor).toString('base64'); + } + + const unifiedDepartments: UnifiedDepartmentOutput[] = await Promise.all( + departments.map(async (department) => { + // Fetch field mappings for the department + const values = await this.prisma.value.findMany({ + where: { + entity: { + ressource_owner_id: department.id_ats_department, + }, + }, + include: { + attribute: true, + }, + }); + + // Create a map to store unique field mappings + const fieldMappingsMap = new Map(); + + values.forEach((value) => { + fieldMappingsMap.set(value.attribute.slug, value.data); + }); + + // Convert the map to an array of objects + const field_mappings = Array.from( + fieldMappingsMap, + ([key, value]) => ({ + [key]: value, + }), + ); + + // Transform to UnifiedDepartmentOutput format + return { + id: department.id_ats_department, + name: department.name, + field_mappings: field_mappings, + remote_id: department.remote_id, + created_at: department.created_at, + modified_at: department.modified_at, + }; + }), + ); + + let res: UnifiedDepartmentOutput[] = unifiedDepartments; + + if (remote_data) { + const remote_array_data: UnifiedDepartmentOutput[] = await Promise.all( + res.map(async (department) => { + const resp = await this.prisma.remote_data.findFirst({ + where: { + ressource_owner_id: department.id, + }, + }); + const remote_data = JSON.parse(resp.data); + return { ...department, remote_data }; + }), + ); + + res = remote_array_data; + } + + await this.prisma.events.create({ + data: { + id_event: uuidv4(), + status: 'success', + type: 'ats.department.pull', + method: 'GET', + url: '/ats/departments', + provider: integrationId, + direction: '0', + timestamp: new Date(), + id_linked_user: linkedUserId, + }, + }); + + return { + data: res, + prev_cursor, + next_cursor, + }; + } catch (error) { + throw error; + } + } +} diff --git a/packages/api/src/ecommerce/order/services/registry.service.ts b/packages/api/src/ecommerce/order/services/registry.service.ts new file mode 100644 index 000000000..ee946e7b7 --- /dev/null +++ b/packages/api/src/ecommerce/order/services/registry.service.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@nestjs/common'; +import { IDepartmentService } from '../types'; + +@Injectable() +export class ServiceRegistry { + private serviceMap: Map; + + constructor() { + this.serviceMap = new Map(); + } + + registerService(serviceKey: string, service: IDepartmentService) { + this.serviceMap.set(serviceKey, service); + } + + getService(integrationId: string): IDepartmentService { + const service = this.serviceMap.get(integrationId); + if (!service) { + throw new ReferenceError( + `Service not found for integration ID: ${integrationId}`, + ); + } + return service; + } +} diff --git a/packages/api/src/ecommerce/order/sync/sync.processor.ts b/packages/api/src/ecommerce/order/sync/sync.processor.ts new file mode 100644 index 000000000..4c01a02f0 --- /dev/null +++ b/packages/api/src/ecommerce/order/sync/sync.processor.ts @@ -0,0 +1,19 @@ +import { Processor, Process } from '@nestjs/bull'; +import { Job } from 'bull'; +import { SyncService } from './sync.service'; +import { Queues } from '@@core/@core-services/queues/types'; + +@Processor(Queues.SYNC_JOBS_WORKER) +export class SyncProcessor { + constructor(private syncService: SyncService) {} + + @Process('ats-sync-departments') + async handleSyncDepartments(job: Job) { + try { + console.log(`Processing queue -> ats-sync-departments ${job.id}`); + await this.syncService.kickstartSync(); + } catch (error) { + console.error('Error syncing ats departments', error); + } + } +} diff --git a/packages/api/src/ecommerce/order/sync/sync.service.ts b/packages/api/src/ecommerce/order/sync/sync.service.ts new file mode 100644 index 000000000..61c7cbe95 --- /dev/null +++ b/packages/api/src/ecommerce/order/sync/sync.service.ts @@ -0,0 +1,203 @@ +import { Injectable, OnModuleInit } from '@nestjs/common'; +import { LoggerService } from '@@core/@core-services/logger/logger.service'; +import { PrismaService } from '@@core/@core-services/prisma/prisma.service'; +import { Cron } from '@nestjs/schedule'; +import { v4 as uuidv4 } from 'uuid'; +import { FieldMappingService } from '@@core/field-mapping/field-mapping.service'; +import { ServiceRegistry } from '../services/registry.service'; +import { WebhookService } from '@@core/@core-services/webhooks/panora-webhooks/webhook.service'; +import { CoreSyncRegistry } from '@@core/@core-services/registries/core-sync.registry'; +import { ApiResponse } from '@@core/utils/types'; +import { IDepartmentService } from '../types'; +import { OriginalDepartmentOutput } from '@@core/utils/types/original/original.ats'; +import { UnifiedDepartmentOutput } from '../types/model.unified'; +import { ats_departments as AtsDepartment } from '@prisma/client'; +import { ATS_PROVIDERS } from '@panora/shared'; +import { AtsObject } from '@ats/@lib/@types'; +import { BullQueueService } from '@@core/@core-services/queues/shared.service'; +import { IBaseSync, SyncLinkedUserType } from '@@core/utils/types/interface'; +import { IngestDataService } from '@@core/@core-services/unification/ingest-data.service'; +import { CoreUnification } from '@@core/@core-services/unification/core-unification.service'; + +@Injectable() +export class SyncService implements OnModuleInit, IBaseSync { + constructor( + private prisma: PrismaService, + private logger: LoggerService, + private webhook: WebhookService, + private fieldMappingService: FieldMappingService, + private serviceRegistry: ServiceRegistry, + private coreUnification: CoreUnification, + private registry: CoreSyncRegistry, + private bullQueueService: BullQueueService, + private ingestService: IngestDataService, + ) { + this.logger.setContext(SyncService.name); + this.registry.registerService('ats', 'department', this); + } + + async onModuleInit() { + try { + await this.bullQueueService.queueSyncJob( + 'ats-sync-departments', + '0 0 * * *', + ); + } catch (error) { + throw error; + } + } + + @Cron('0 */8 * * *') // every 8 hours + async kickstartSync(user_id?: string) { + try { + this.logger.log('Syncing departments...'); + const users = user_id + ? [ + await this.prisma.users.findUnique({ + where: { + id_user: user_id, + }, + }), + ] + : await this.prisma.users.findMany(); + if (users && users.length > 0) { + for (const user of users) { + const projects = await this.prisma.projects.findMany({ + where: { + id_user: user.id_user, + }, + }); + for (const project of projects) { + const id_project = project.id_project; + const linkedUsers = await this.prisma.linked_users.findMany({ + where: { + id_project: id_project, + }, + }); + linkedUsers.map(async (linkedUser) => { + try { + const providers = ATS_PROVIDERS; + for (const provider of providers) { + try { + await this.syncForLinkedUser({ + integrationId: provider, + linkedUserId: linkedUser.id_linked_user, + }); + } catch (error) { + throw error; + } + } + } catch (error) { + throw error; + } + }); + } + } + } + } catch (error) { + throw error; + } + } + + async syncForLinkedUser(param: SyncLinkedUserType) { + try { + const { integrationId, linkedUserId } = param; + const service: IDepartmentService = + this.serviceRegistry.getService(integrationId); + if (!service) return; + + await this.ingestService.syncForLinkedUser< + UnifiedDepartmentOutput, + OriginalDepartmentOutput, + IDepartmentService + >(integrationId, linkedUserId, 'ats', 'department', service, []); + } catch (error) { + throw error; + } + } + + async saveToDb( + connection_id: string, + linkedUserId: string, + departments: UnifiedDepartmentOutput[], + originSource: string, + remote_data: Record[], + ): Promise { + try { + const departments_results: AtsDepartment[] = []; + + const updateOrCreateDepartment = async ( + department: UnifiedDepartmentOutput, + originId: string, + ) => { + let existingDepartment; + if (!originId) { + existingDepartment = await this.prisma.ats_departments.findFirst({ + where: { + name: department.name, + id_connection: connection_id, + }, + }); + } else { + existingDepartment = await this.prisma.ats_departments.findFirst({ + where: { + remote_id: originId, + id_connection: connection_id, + }, + }); + } + + const baseData: any = { + name: department.name ?? null, + modified_at: new Date(), + }; + + if (existingDepartment) { + return await this.prisma.ats_departments.update({ + where: { + id_ats_department: existingDepartment.id_ats_department, + }, + data: baseData, + }); + } else { + return await this.prisma.ats_departments.create({ + data: { + ...baseData, + id_ats_department: uuidv4(), + created_at: new Date(), + remote_id: originId, + id_connection: connection_id, + }, + }); + } + }; + + for (let i = 0; i < departments.length; i++) { + const department = departments[i]; + const originId = department.remote_id; + + const res = await updateOrCreateDepartment(department, originId); + const department_id = res.id_ats_department; + departments_results.push(res); + + // Process field mappings + await this.ingestService.processFieldMappings( + department.field_mappings, + department_id, + originSource, + linkedUserId, + ); + + // Process remote data + await this.ingestService.processRemoteData( + department_id, + remote_data[i], + ); + } + + return departments_results; + } catch (error) { + throw error; + } + } +} diff --git a/packages/api/src/ecommerce/order/types/index.ts b/packages/api/src/ecommerce/order/types/index.ts new file mode 100644 index 000000000..2cca2d7d6 --- /dev/null +++ b/packages/api/src/ecommerce/order/types/index.ts @@ -0,0 +1,36 @@ +import { DesunifyReturnType } from '@@core/utils/types/desunify.input'; +import { + UnifiedDepartmentInput, + UnifiedDepartmentOutput, +} from './model.unified'; +import { OriginalDepartmentOutput } from '@@core/utils/types/original/original.ats'; +import { ApiResponse } from '@@core/utils/types'; +import { IBaseObjectService, SyncParam } from '@@core/utils/types/interface'; + +export interface IDepartmentService extends IBaseObjectService { + addDepartment( + departmentData: DesunifyReturnType, + linkedUserId: string, + ): Promise>; + + sync(data: SyncParam): Promise>; +} + +export interface IDepartmentMapper { + desunify( + source: UnifiedDepartmentInput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): DesunifyReturnType; + + unify( + source: OriginalDepartmentOutput | OriginalDepartmentOutput[], + connectionId: string, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): Promise; +} diff --git a/packages/api/src/ecommerce/order/types/model.unified.ts b/packages/api/src/ecommerce/order/types/model.unified.ts new file mode 100644 index 000000000..a874dcf6b --- /dev/null +++ b/packages/api/src/ecommerce/order/types/model.unified.ts @@ -0,0 +1,61 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsUUID, IsOptional, IsString, IsDateString } from 'class-validator'; + +export class UnifiedDepartmentInput { + @ApiPropertyOptional({ + type: String, + description: 'The name of the department', + }) + @IsString() + @IsOptional() + name?: string; + + @ApiPropertyOptional({ + type: {}, + description: + 'The custom field mappings of the object between the remote 3rd party & Panora', + }) + @IsOptional() + field_mappings?: Record; +} + +export class UnifiedDepartmentOutput extends UnifiedDepartmentInput { + @ApiPropertyOptional({ + type: String, + description: 'The UUID of the department', + }) + @IsUUID() + @IsOptional() + id?: string; + + @ApiPropertyOptional({ + type: String, + description: + 'The remote ID of the department in the context of the 3rd Party', + }) + @IsString() + @IsOptional() + remote_id?: string; + + @ApiPropertyOptional({ + type: {}, + description: + 'The remote data of the department in the context of the 3rd Party', + }) + @IsOptional() + remote_data?: Record; + + @ApiPropertyOptional({ + type: {}, + description: 'The created date of the object', + }) + @IsOptional() + created_at?: any; + + @ApiPropertyOptional({ + type: {}, + description: 'The modified date of the object', + }) + @IsOptional() + modified_at?: any; +} diff --git a/packages/api/src/ecommerce/order/utils/index.ts b/packages/api/src/ecommerce/order/utils/index.ts new file mode 100644 index 000000000..f849788c1 --- /dev/null +++ b/packages/api/src/ecommerce/order/utils/index.ts @@ -0,0 +1 @@ +/* PUT ALL UTILS FUNCTIONS USED IN YOUR OBJECT METHODS HERE */ diff --git a/packages/api/src/ecommerce/product/product.controller.ts b/packages/api/src/ecommerce/product/product.controller.ts new file mode 100644 index 000000000..c36787e98 --- /dev/null +++ b/packages/api/src/ecommerce/product/product.controller.ts @@ -0,0 +1,120 @@ +import { + Controller, + Post, + Body, + Query, + Get, + Patch, + Param, + Headers, + UseGuards, +} from '@nestjs/common'; +import { LoggerService } from '@@core/@core-services/logger/logger.service'; +import { + ApiBody, + ApiOperation, + ApiParam, + ApiQuery, + ApiTags, + ApiHeader, +} from '@nestjs/swagger'; +import { ApiCustomResponse } from '@@core/utils/types'; +import { ProductService } from './services/product.service'; +import { + UnifiedProductInput, + UnifiedProductOutput, +} from './types/model.unified'; +import { ConnectionUtils } from '@@core/connections/@utils'; +import { ApiKeyAuthGuard } from '@@core/auth/guards/api-key.guard'; +import { FetchObjectsQueryDto } from '@@core/utils/dtos/fetch-objects-query.dto'; + +@ApiTags('ecommerce/product') +@Controller('ecommerce/product') +export class ProductController { + constructor( + private readonly productService: ProductService, + private logger: LoggerService, + private connectionUtils: ConnectionUtils, + ) { + this.logger.setContext(ProductController.name); + } + + @ApiOperation({ + operationId: 'getProducts', + summary: 'List a batch of Products', + }) + @ApiHeader({ + name: 'x-connection-token', + required: true, + description: 'The connection token', + example: 'b008e199-eda9-4629-bd41-a01b6195864a', + }) + @ApiCustomResponse(UnifiedProductOutput) + @UseGuards(ApiKeyAuthGuard) + @Get() + async getProducts( + @Headers('x-connection-token') connection_token: string, + @Query() query: FetchObjectsQueryDto, + ) { + try { + const { linkedUserId, remoteSource, connectionId } = + await this.connectionUtils.getConnectionMetadataFromConnectionToken( + connection_token, + ); + const { remote_data, limit, cursor } = query; + return this.productService.getProducts( + connectionId, + remoteSource, + linkedUserId, + limit, + remote_data, + cursor, + ); + } catch (error) { + throw new Error(error); + } + } + + @ApiOperation({ + operationId: 'getProduct', + summary: 'Retrieve a Product', + description: 'Retrieve a product from any connected Ats software', + }) + @ApiParam({ + name: 'id', + required: true, + type: String, + description: 'id of the product you want to retrieve.', + }) + @ApiQuery({ + name: 'remote_data', + required: false, + type: Boolean, + description: 'Set to true to include data from the original Ats software.', + }) + @ApiHeader({ + name: 'x-connection-token', + required: true, + description: 'The connection token', + example: 'b008e199-eda9-4629-bd41-a01b6195864a', + }) + @ApiCustomResponse(UnifiedProductOutput) + @UseGuards(ApiKeyAuthGuard) + @Get(':id') + async retrieve( + @Headers('x-connection-token') connection_token: string, + @Param('id') id: string, + @Query('remote_data') remote_data?: boolean, + ) { + const { linkedUserId, remoteSource } = + await this.connectionUtils.getConnectionMetadataFromConnectionToken( + connection_token, + ); + return this.productService.getProduct( + id, + linkedUserId, + remoteSource, + remote_data, + ); + } +} diff --git a/packages/api/src/ecommerce/product/product.module.ts b/packages/api/src/ecommerce/product/product.module.ts new file mode 100644 index 000000000..3bd136a9e --- /dev/null +++ b/packages/api/src/ecommerce/product/product.module.ts @@ -0,0 +1,31 @@ +import { IngestDataService } from '@@core/@core-services/unification/ingest-data.service'; +import { WebhookService } from '@@core/@core-services/webhooks/panora-webhooks/webhook.service'; +import { Module } from '@nestjs/common'; +import { ProductController } from './product.controller'; +import { ProductService } from './services/product.service'; +import { ServiceRegistry } from './services/registry.service'; +import { SyncService } from './sync/sync.service'; +import { BullQueueModule } from '@@core/@core-services/queues/queue.module'; +import { CoreUnification } from '@@core/@core-services/unification/core-unification.service'; +import { Utils } from '@ecommerce/@lib/@utils'; +import { ShopifyService } from './services/shopify'; +import { ShopifyProductMapper } from './services/shopify/mappers'; + +@Module({ + imports: [BullQueueModule], + controllers: [ProductController], + providers: [ + ProductService, + CoreUnification, + SyncService, + WebhookService, + ServiceRegistry, + IngestDataService, + Utils, + ShopifyProductMapper, + /* PROVIDERS SERVICES */ + ShopifyService, + ], + exports: [SyncService], +}) +export class ProductModule {} diff --git a/packages/api/src/ecommerce/product/services/product.service.ts b/packages/api/src/ecommerce/product/services/product.service.ts new file mode 100644 index 000000000..5f0ff9e7f --- /dev/null +++ b/packages/api/src/ecommerce/product/services/product.service.ts @@ -0,0 +1,234 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '@@core/@core-services/prisma/prisma.service'; +import { LoggerService } from '@@core/@core-services/logger/logger.service'; +import { v4 as uuidv4 } from 'uuid'; +import { UnifiedProductOutput } from '../types/model.unified'; + +@Injectable() +export class ProductService { + constructor(private prisma: PrismaService, private logger: LoggerService) { + this.logger.setContext(ProductService.name); + } + + async getProduct( + id_ecommerce_product: string, + linkedUserId: string, + integrationId: string, + remote_data?: boolean, + ): Promise { + try { + const product = await this.prisma.ecom_products.findUnique({ + where: { + id_ecommerce_product: id_ecommerce_product, + }, + }); + + if (!product) { + throw new Error(`Product with ID ${id_ecommerce_product} not found.`); + } + + // Fetch field mappings for the product + const values = await this.prisma.value.findMany({ + where: { + entity: { + ressource_owner_id: product.id_ecommerce_product, + }, + }, + include: { + attribute: true, + }, + }); + + // Create a map to store unique field mappings + const fieldMappingsMap = new Map(); + + values.forEach((value) => { + fieldMappingsMap.set(value.attribute.slug, value.data); + }); + + // Convert the map to an array of objects + const field_mappings = Array.from(fieldMappingsMap, ([key, value]) => ({ + [key]: value, + })); + + // Transform to UnifiedProductOutput format + const unifiedProduct: UnifiedProductOutput = { + id: product.id_ecommerce_product, + name: product.name, + field_mappings: field_mappings, + remote_id: product.remote_id, + created_at: product.created_at, + modified_at: product.modified_at, + }; + + let res: UnifiedProductOutput = unifiedProduct; + if (remote_data) { + const resp = await this.prisma.remote_data.findFirst({ + where: { + ressource_owner_id: product.id_ecommerce_product, + }, + }); + const remote_data = JSON.parse(resp.data); + + res = { + ...res, + remote_data: remote_data, + }; + } + await this.prisma.events.create({ + data: { + id_event: uuidv4(), + status: 'success', + type: 'ecommerce.product.pull', + method: 'GET', + url: '/ecommerce/product', + provider: integrationId, + direction: '0', + timestamp: new Date(), + id_linked_user: linkedUserId, + }, + }); + + return res; + } catch (error) { + throw error; + } + } + + async getProducts( + connection_id: string, + integrationId: string, + linkedUserId: string, + limit: number, + remote_data?: boolean, + cursor?: string, + ): Promise<{ + data: UnifiedProductOutput[]; + prev_cursor: null | string; + next_cursor: null | string; + }> { + try { + let prev_cursor = null; + let next_cursor = null; + + if (cursor) { + const isCursorPresent = await this.prisma.ecom_products.findFirst({ + where: { + id_connection: connection_id, + id_ecommerce_product: cursor, + }, + }); + if (!isCursorPresent) { + throw new ReferenceError(`The provided cursor does not exist!`); + } + } + + const products = await this.prisma.ecom_products.findMany({ + take: limit + 1, + cursor: cursor + ? { + id_ecommerce_product: cursor, + } + : undefined, + orderBy: { + created_at: 'asc', + }, + where: { + id_connection: connection_id, + }, + }); + + if (products.length === limit + 1) { + next_cursor = Buffer.from( + products[products.length - 1].id_ecommerce_product, + ).toString('base64'); + products.pop(); + } + + if (cursor) { + prev_cursor = Buffer.from(cursor).toString('base64'); + } + + const unifiedProducts: UnifiedProductOutput[] = await Promise.all( + products.map(async (product) => { + // Fetch field mappings for the product + const values = await this.prisma.value.findMany({ + where: { + entity: { + ressource_owner_id: product.id_ecommerce_product, + }, + }, + include: { + attribute: true, + }, + }); + + // Create a map to store unique field mappings + const fieldMappingsMap = new Map(); + + values.forEach((value) => { + fieldMappingsMap.set(value.attribute.slug, value.data); + }); + + // Convert the map to an array of objects + const field_mappings = Array.from( + fieldMappingsMap, + ([key, value]) => ({ + [key]: value, + }), + ); + + // Transform to UnifiedProductOutput format + return { + id: product.id_ecommerce_product, + name: product.name, + field_mappings: field_mappings, + remote_id: product.remote_id, + created_at: product.created_at, + modified_at: product.modified_at, + }; + }), + ); + + let res: UnifiedProductOutput[] = unifiedProducts; + + if (remote_data) { + const remote_array_data: UnifiedProductOutput[] = await Promise.all( + res.map(async (product) => { + const resp = await this.prisma.remote_data.findFirst({ + where: { + ressource_owner_id: product.id, + }, + }); + const remote_data = JSON.parse(resp.data); + return { ...product, remote_data }; + }), + ); + + res = remote_array_data; + } + + await this.prisma.events.create({ + data: { + id_event: uuidv4(), + status: 'success', + type: 'ecommerce.product.pull', + method: 'GET', + url: '/ecommerce/products', + provider: integrationId, + direction: '0', + timestamp: new Date(), + id_linked_user: linkedUserId, + }, + }); + + return { + data: res, + prev_cursor, + next_cursor, + }; + } catch (error) { + throw error; + } + } +} diff --git a/packages/api/src/ecommerce/product/services/registry.service.ts b/packages/api/src/ecommerce/product/services/registry.service.ts new file mode 100644 index 000000000..503215aaa --- /dev/null +++ b/packages/api/src/ecommerce/product/services/registry.service.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@nestjs/common'; +import { IProductService } from '../types'; + +@Injectable() +export class ServiceRegistry { + private serviceMap: Map; + + constructor() { + this.serviceMap = new Map(); + } + + registerService(serviceKey: string, service: IProductService) { + this.serviceMap.set(serviceKey, service); + } + + getService(integrationId: string): IProductService { + const service = this.serviceMap.get(integrationId); + if (!service) { + throw new ReferenceError( + `Service not found for integration ID: ${integrationId}`, + ); + } + return service; + } +} diff --git a/packages/api/src/ecommerce/product/services/shopify/index.ts b/packages/api/src/ecommerce/product/services/shopify/index.ts new file mode 100644 index 000000000..5e31d0a75 --- /dev/null +++ b/packages/api/src/ecommerce/product/services/shopify/index.ts @@ -0,0 +1,68 @@ +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 { ApiResponse } from '@@core/utils/types'; +import { DesunifyReturnType } from '@@core/utils/types/desunify.input'; +import { SyncParam } from '@@core/utils/types/interface'; +import { OriginalProductOutput } from '@@core/utils/types/original/original.ecommerce'; +import { IProductService } from '@ecommerce/product/types'; +import { Injectable } from '@nestjs/common'; +import axios from 'axios'; +import { ServiceRegistry } from '../registry.service'; +import { ShopifyProductOutput } from './types'; + +@Injectable() +export class ShopifyService implements IProductService { + constructor( + private prisma: PrismaService, + private logger: LoggerService, + private cryptoService: EncryptionService, + private registry: ServiceRegistry, + ) { + this.logger.setContext( + EcommerceObject.product.toUpperCase() + ':' + ShopifyService.name, + ); + this.registry.registerService('shopify', this); + } + addProduct( + productData: DesunifyReturnType, + linkedUserId: string, + ): Promise> { + throw new Error('Method not implemented.'); + } + + async sync(data: SyncParam): Promise> { + try { + const { linkedUserId } = data; + + const connection = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedUserId, + provider_slug: 'shopify', + vertical: 'ecommerce', + }, + }); + const resp = await axios.post( + `${connection.account_url}/departement.list`, + { + headers: { + 'Content-Type': 'application/json', + Authorization: `Basic ${Buffer.from( + `${this.cryptoService.decrypt(connection.access_token)}:`, + ).toString('base64')}`, + }, + }, + ); + const products: ShopifyProductOutput[] = resp.data.results; + this.logger.log(`Synced shopify products !`); + + return { + data: products, + message: 'Shopify products retrieved', + statusCode: 200, + }; + } catch (error) { + throw error; + } + } +} diff --git a/packages/api/src/ecommerce/product/services/shopify/mappers.ts b/packages/api/src/ecommerce/product/services/shopify/mappers.ts new file mode 100644 index 000000000..b09a607b0 --- /dev/null +++ b/packages/api/src/ecommerce/product/services/shopify/mappers.ts @@ -0,0 +1,72 @@ +import { ShopifyProductInput, ShopifyProductOutput } from './types'; +import { + UnifiedProductInput, + UnifiedProductOutput, +} from '@ecommerce/product/types/model.unified'; +import { IProductMapper } from '@ecommerce/product/types'; +import { MappersRegistry } from '@@core/@core-services/registries/mappers.registry'; +import { Injectable } from '@nestjs/common'; +import { CoreUnification } from '@@core/@core-services/unification/core-unification.service'; +import { Utils } from '@ecommerce/@lib/@utils'; + +@Injectable() +export class ShopifyProductMapper implements IProductMapper { + constructor( + private mappersRegistry: MappersRegistry, + private utils: Utils, + private coreUnificationService: CoreUnification, + ) { + this.mappersRegistry.registerService('ecommerce', 'product', 'ashby', this); + } + + async desunify( + source: UnifiedProductInput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): Promise { + return; + } + + async unify( + source: ShopifyProductOutput | ShopifyProductOutput[], + connectionId: string, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): Promise { + if (!Array.isArray(source)) { + return await this.mapSingleProductToUnified( + source, + connectionId, + customFieldMappings, + ); + } + // Handling array of ShopifyProductOutput + return Promise.all( + source.map((product) => + this.mapSingleProductToUnified( + product, + connectionId, + customFieldMappings, + ), + ), + ); + } + + private async mapSingleProductToUnified( + product: ShopifyProductOutput, + connectionId: string, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): Promise { + return { + remote_id: product.id, + remote_data: product, + }; + } +} diff --git a/packages/api/src/ecommerce/product/services/shopify/types.ts b/packages/api/src/ecommerce/product/services/shopify/types.ts new file mode 100644 index 000000000..7f546791b --- /dev/null +++ b/packages/api/src/ecommerce/product/services/shopify/types.ts @@ -0,0 +1,5 @@ +export interface ShopifyProductInput { + id: string; +} + +export type ShopifyProductOutput = Partial; diff --git a/packages/api/src/ecommerce/product/sync/sync.processor.ts b/packages/api/src/ecommerce/product/sync/sync.processor.ts new file mode 100644 index 000000000..28b6b41de --- /dev/null +++ b/packages/api/src/ecommerce/product/sync/sync.processor.ts @@ -0,0 +1,19 @@ +import { Processor, Process } from '@nestjs/bull'; +import { Job } from 'bull'; +import { SyncService } from './sync.service'; +import { Queues } from '@@core/@core-services/queues/types'; + +@Processor(Queues.SYNC_JOBS_WORKER) +export class SyncProcessor { + constructor(private syncService: SyncService) {} + + @Process('ecommerce-sync-products') + async handleSyncProducts(job: Job) { + try { + console.log(`Processing queue -> ecommerce-sync-products ${job.id}`); + await this.syncService.kickstartSync(); + } catch (error) { + console.error('Error syncing ecommerce products', error); + } + } +} diff --git a/packages/api/src/ecommerce/product/sync/sync.service.ts b/packages/api/src/ecommerce/product/sync/sync.service.ts new file mode 100644 index 000000000..8afd0a4c4 --- /dev/null +++ b/packages/api/src/ecommerce/product/sync/sync.service.ts @@ -0,0 +1,198 @@ +import { LoggerService } from '@@core/@core-services/logger/logger.service'; +import { PrismaService } from '@@core/@core-services/prisma/prisma.service'; +import { BullQueueService } from '@@core/@core-services/queues/shared.service'; +import { CoreSyncRegistry } from '@@core/@core-services/registries/core-sync.registry'; +import { CoreUnification } from '@@core/@core-services/unification/core-unification.service'; +import { IngestDataService } from '@@core/@core-services/unification/ingest-data.service'; +import { WebhookService } from '@@core/@core-services/webhooks/panora-webhooks/webhook.service'; +import { FieldMappingService } from '@@core/field-mapping/field-mapping.service'; +import { IBaseSync, SyncLinkedUserType } from '@@core/utils/types/interface'; +import { OriginalProductOutput } from '@@core/utils/types/original/original.ecommerce'; +import { Injectable, OnModuleInit } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; +import { ATS_PROVIDERS } from '@panora/shared'; +import { ecommerce_products as EcommerceProduct } from '@prisma/client'; +import { v4 as uuidv4 } from 'uuid'; +import { ServiceRegistry } from '../services/registry.service'; +import { IProductService } from '../types'; +import { UnifiedProductOutput } from '../types/model.unified'; + +@Injectable() +export class SyncService implements OnModuleInit, IBaseSync { + constructor( + private prisma: PrismaService, + private logger: LoggerService, + private webhook: WebhookService, + private fieldMappingService: FieldMappingService, + private serviceRegistry: ServiceRegistry, + private coreUnification: CoreUnification, + private registry: CoreSyncRegistry, + private bullQueueService: BullQueueService, + private ingestService: IngestDataService, + ) { + this.logger.setContext(SyncService.name); + this.registry.registerService('ecommerce', 'product', this); + } + + async onModuleInit() { + try { + await this.bullQueueService.queueSyncJob( + 'ecommerce-sync-products', + '0 0 * * *', + ); + } catch (error) { + throw error; + } + } + + @Cron('0 */8 * * *') // every 8 hours + async kickstartSync(user_id?: string) { + try { + this.logger.log('Syncing products...'); + const users = user_id + ? [ + await this.prisma.users.findUnique({ + where: { + id_user: user_id, + }, + }), + ] + : await this.prisma.users.findMany(); + if (users && users.length > 0) { + for (const user of users) { + const projects = await this.prisma.projects.findMany({ + where: { + id_user: user.id_user, + }, + }); + for (const project of projects) { + const id_project = project.id_project; + const linkedUsers = await this.prisma.linked_users.findMany({ + where: { + id_project: id_project, + }, + }); + linkedUsers.map(async (linkedUser) => { + try { + const providers = ATS_PROVIDERS; + for (const provider of providers) { + try { + await this.syncForLinkedUser({ + integrationId: provider, + linkedUserId: linkedUser.id_linked_user, + }); + } catch (error) { + throw error; + } + } + } catch (error) { + throw error; + } + }); + } + } + } + } catch (error) { + throw error; + } + } + + async syncForLinkedUser(param: SyncLinkedUserType) { + try { + const { integrationId, linkedUserId } = param; + const service: IProductService = + this.serviceRegistry.getService(integrationId); + if (!service) return; + + await this.ingestService.syncForLinkedUser< + UnifiedProductOutput, + OriginalProductOutput, + IProductService + >(integrationId, linkedUserId, 'ecommerce', 'product', service, []); + } catch (error) { + throw error; + } + } + + async saveToDb( + connection_id: string, + linkedUserId: string, + products: UnifiedProductOutput[], + originSource: string, + remote_data: Record[], + ): Promise { + try { + const products_results: EcommerceProduct[] = []; + + const updateOrCreateProduct = async ( + product: UnifiedProductOutput, + originId: string, + ) => { + let existingProduct; + if (!originId) { + existingProduct = await this.prisma.ecom_products.findFirst({ + where: { + name: product.name, + id_connection: connection_id, + }, + }); + } else { + existingProduct = await this.prisma.ecom_products.findFirst({ + where: { + remote_id: originId, + id_connection: connection_id, + }, + }); + } + + const baseData: any = { + name: product.name ?? null, + modified_at: new Date(), + }; + + if (existingProduct) { + return await this.prisma.ecom_products.update({ + where: { + id_ecommerce_product: existingProduct.id_ecommerce_product, + }, + data: baseData, + }); + } else { + return await this.prisma.ecom_products.create({ + data: { + ...baseData, + id_ecommerce_product: uuidv4(), + created_at: new Date(), + remote_id: originId, + id_connection: connection_id, + }, + }); + } + }; + + for (let i = 0; i < products.length; i++) { + const product = products[i]; + const originId = product.remote_id; + + const res = await updateOrCreateProduct(product, originId); + const product_id = res.id_ecommerce_product; + products_results.push(res); + + // Process field mappings + await this.ingestService.processFieldMappings( + product.field_mappings, + product_id, + originSource, + linkedUserId, + ); + + // Process remote data + await this.ingestService.processRemoteData(product_id, remote_data[i]); + } + + return products_results; + } catch (error) { + throw error; + } + } +} diff --git a/packages/api/src/ecommerce/product/types/index.ts b/packages/api/src/ecommerce/product/types/index.ts new file mode 100644 index 000000000..ed1984bc0 --- /dev/null +++ b/packages/api/src/ecommerce/product/types/index.ts @@ -0,0 +1,33 @@ +import { DesunifyReturnType } from '@@core/utils/types/desunify.input'; +import { UnifiedProductInput, UnifiedProductOutput } from './model.unified'; +import { OriginalProductOutput } from '@@core/utils/types/original/original.ecommerce'; +import { ApiResponse } from '@@core/utils/types'; +import { IBaseObjectService, SyncParam } from '@@core/utils/types/interface'; + +export interface IProductService extends IBaseObjectService { + addProduct( + productData: DesunifyReturnType, + linkedUserId: string, + ): Promise>; + + sync(data: SyncParam): Promise>; +} + +export interface IProductMapper { + desunify( + source: UnifiedProductInput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): DesunifyReturnType; + + unify( + source: OriginalProductOutput | OriginalProductOutput[], + connectionId: string, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): Promise; +} diff --git a/packages/api/src/ecommerce/product/types/model.unified.ts b/packages/api/src/ecommerce/product/types/model.unified.ts new file mode 100644 index 000000000..813af5d6a --- /dev/null +++ b/packages/api/src/ecommerce/product/types/model.unified.ts @@ -0,0 +1,53 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsUUID, IsOptional, IsString, IsDateString } from 'class-validator'; + +export class UnifiedProductInput { + @ApiPropertyOptional({ + type: {}, + description: + 'The custom field mappings of the object between the remote 3rd party & Panora', + }) + @IsOptional() + field_mappings?: Record; +} + +export class UnifiedProductOutput extends UnifiedProductInput { + @ApiPropertyOptional({ + type: String, + description: 'The UUID of the department', + }) + @IsUUID() + @IsOptional() + id?: string; + + @ApiPropertyOptional({ + type: String, + description: + 'The remote ID of the department in the context of the 3rd Party', + }) + @IsString() + @IsOptional() + remote_id?: string; + + @ApiPropertyOptional({ + type: {}, + description: + 'The remote data of the department in the context of the 3rd Party', + }) + @IsOptional() + remote_data?: Record; + + @ApiPropertyOptional({ + type: {}, + description: 'The created date of the object', + }) + @IsOptional() + created_at?: any; + + @ApiPropertyOptional({ + type: {}, + description: 'The modified date of the object', + }) + @IsOptional() + modified_at?: any; +} diff --git a/packages/api/src/ecommerce/product/utils/index.ts b/packages/api/src/ecommerce/product/utils/index.ts new file mode 100644 index 000000000..f849788c1 --- /dev/null +++ b/packages/api/src/ecommerce/product/utils/index.ts @@ -0,0 +1 @@ +/* PUT ALL UTILS FUNCTIONS USED IN YOUR OBJECT METHODS HERE */ diff --git a/packages/api/tsconfig.json b/packages/api/tsconfig.json index 3bb8ac3a6..3425ce69e 100644 --- a/packages/api/tsconfig.json +++ b/packages/api/tsconfig.json @@ -20,6 +20,7 @@ "@marketingautomation/*": ["src/marketingautomation/*"], "@ats/*": ["src/ats/*"], "@accounting/*": ["src/accounting/*"], + "@ecommerce/*": ["src/ecommerce/*"] }, "incremental": true, "skipLibCheck": true,