diff --git a/packages/api/scripts/init.sql b/packages/api/scripts/init.sql index 4aa51a322..b827837c5 100644 --- a/packages/api/scripts/init.sql +++ b/packages/api/scripts/init.sql @@ -549,7 +549,8 @@ CREATE TABLE connector_sets ecom_amazon boolean NULL, ecom_squarespace boolean NULL, ats_ashby boolean NULL, - crm_microsoftdynamicssales boolean NULL, + ecom_webflow boolean NULL, + crm_microsoftdynamicssales boolean NULL, CONSTRAINT PK_project_connector PRIMARY KEY ( id_connector_set ) ); diff --git a/packages/api/scripts/seed.sql b/packages/api/scripts/seed.sql index 9acc8bff4..72f7eb37e 100644 --- a/packages/api/scripts/seed.sql +++ b/packages/api/scripts/seed.sql @@ -1,10 +1,10 @@ INSERT INTO users (id_user, identification_strategy, email, password_hash, first_name, last_name) VALUES ('0ce39030-2901-4c56-8db0-5e326182ec6b', 'b2c','local@panora.dev', '$2b$10$Y7Q8TWGyGuc5ecdIASbBsuXMo3q/Rs3/cnY.mLZP4tUgfGUOCUBlG', 'local', 'Panora'); -INSERT INTO connector_sets (id_connector_set, crm_hubspot, crm_zoho, crm_pipedrive, crm_attio, crm_zendesk, crm_close, tcg_zendesk, tcg_gorgias, tcg_front, tcg_jira, tcg_gitlab, fs_box, tcg_github, hris_deel, hris_sage, ats_ashby, crm_microsoftdynamicssales) VALUES - ('1709da40-17f7-4d3a-93a0-96dc5da6ddd7', TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE), - ('852dfff8-ab63-4530-ae49-e4b2924407f8', TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE), - ('aed0f856-f802-4a79-8640-66d441581a99', TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE); +INSERT INTO connector_sets (id_connector_set, crm_hubspot, crm_zoho, crm_pipedrive, crm_attio, crm_zendesk, crm_close, tcg_zendesk, tcg_gorgias, tcg_front, tcg_jira, tcg_gitlab, fs_box, tcg_github, hris_deel, hris_sage, ats_ashby, crm_microsoftdynamicssales, ecom_webflow) VALUES + ('1709da40-17f7-4d3a-93a0-96dc5da6ddd7', TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE), + ('852dfff8-ab63-4530-ae49-e4b2924407f8', TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE), + ('aed0f856-f802-4a79-8640-66d441581a99', TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE); INSERT INTO projects (id_project, name, sync_mode, id_user, id_connector_set) VALUES ('1e468c15-aa57-4448-aa2b-7fed640d1e3d', 'Project 1', 'pull', '0ce39030-2901-4c56-8db0-5e326182ec6b', '1709da40-17f7-4d3a-93a0-96dc5da6ddd7'), diff --git a/packages/api/src/@core/utils/types/original/original.ecommerce.ts b/packages/api/src/@core/utils/types/original/original.ecommerce.ts index 95589729e..308185fc9 100644 --- a/packages/api/src/@core/utils/types/original/original.ecommerce.ts +++ b/packages/api/src/@core/utils/types/original/original.ecommerce.ts @@ -49,19 +49,33 @@ import { WoocommerceProductInput, WoocommerceProductOutput, } from '@ecommerce/product/services/woocommerce/types'; +import { + WebflowOrderInput, + WebflowOrderOutput, +} from '@ecommerce/order/services/webflow/types'; +import { + WebflowProductInput, + WebflowProductOutput, +} from '@ecommerce/product/services/webflow/types'; +import { + WebflowCustomerInput, + WebflowCustomerOutput, +} from '@ecommerce/customer/services/webflow/types'; /* product */ export type OriginalProductInput = | ShopifyProductInput | WoocommerceProductInput - | SquarespaceProductInput; + | SquarespaceProductInput + | WebflowProductInput; /* order */ export type OriginalOrderInput = | ShopifyOrderInput | WoocommerceOrderInput | SquarespaceOrderInput - | AmazonOrderInput; + | AmazonOrderInput + | WebflowOrderInput; /* fulfillmentorders */ export type OriginalFulfillmentOrdersInput = ShopifyFulfillmentOrdersInput; @@ -70,7 +84,8 @@ export type OriginalFulfillmentOrdersInput = ShopifyFulfillmentOrdersInput; export type OriginalCustomerInput = | ShopifyCustomerInput | WoocommerceCustomerInput - | SquarespaceCustomerInput; + | SquarespaceCustomerInput + | WebflowCustomerInput; /* fulfillment */ export type OriginalFulfillmentInput = ShopifyFulfillmentInput; @@ -88,14 +103,16 @@ export type EcommerceObjectInput = export type OriginalProductOutput = | ShopifyProductOutput | WoocommerceProductOutput - | SquarespaceProductOutput; + | SquarespaceProductOutput + | WebflowProductOutput; /* order */ export type OriginalOrderOutput = | ShopifyOrderOutput | WoocommerceOrderOutput | SquarespaceOrderOutput - | AmazonOrderOutput; + | AmazonOrderOutput + | WebflowOrderOutput; /* fulfillmentorders */ export type OriginalFulfillmentOrdersOutput = ShopifyFulfillmentOrdersOutput; @@ -105,7 +122,8 @@ export type OriginalCustomerOutput = | ShopifyCustomerOutput | WoocommerceCustomerOutput | SquarespaceCustomerOutput - | AmazonCustomerOutput; + | AmazonCustomerOutput + | WebflowCustomerOutput; /* fulfillment */ export type OriginalFulfillmentOutput = ShopifyFulfillmentOutput; diff --git a/packages/api/src/ecommerce/customer/customer.module.ts b/packages/api/src/ecommerce/customer/customer.module.ts index a09f9f380..ceead802d 100644 --- a/packages/api/src/ecommerce/customer/customer.module.ts +++ b/packages/api/src/ecommerce/customer/customer.module.ts @@ -13,6 +13,8 @@ import { WoocommerceCustomerMapper } from './services/woocommerce/mappers'; import { SyncService } from './sync/sync.service'; import { SquarespaceCustomerMapper } from './services/squarespace/mappers'; import { AmazonCustomerMapper } from './services/amazon/mappers'; +import { WebflowService } from './services/webflow'; +import { WebflowCustomerMapper } from './services/webflow/mappers'; @Module({ controllers: [CustomerController], providers: [ @@ -30,6 +32,8 @@ import { AmazonCustomerMapper } from './services/amazon/mappers'; /* PROVIDERS SERVICES */ ShopifyService, WoocommerceService, + WebflowService, + WebflowCustomerMapper, ], exports: [SyncService], }) diff --git a/packages/api/src/ecommerce/customer/services/webflow/index.ts b/packages/api/src/ecommerce/customer/services/webflow/index.ts new file mode 100644 index 000000000..54c747341 --- /dev/null +++ b/packages/api/src/ecommerce/customer/services/webflow/index.ts @@ -0,0 +1,60 @@ +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 { SyncParam } from '@@core/utils/types/interface'; +import { ICustomerService } from '@ecommerce/customer/types'; +import { Injectable } from '@nestjs/common'; +import { EcommerceObject } from '@panora/shared'; +import axios from 'axios'; +import { ServiceRegistry } from '../registry.service'; +import { WebflowCustomerOutput } from './types'; + +@Injectable() +export class WebflowService implements ICustomerService { + constructor( + private prisma: PrismaService, + private logger: LoggerService, + private cryptoService: EncryptionService, + private registry: ServiceRegistry, + ) { + this.logger.setContext( + `${EcommerceObject.customer.toUpperCase()}:${WebflowService.name}`, + ); + this.registry.registerService('webflow', this); + } + + async sync(data: SyncParam): Promise> { + try { + const { linkedUserId } = data; + + const connection = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedUserId, + provider_slug: 'webflow', + vertical: 'ecommerce', + }, + }); + + const resp = await axios.get(`${connection.account_url}/users`, { + headers: { + 'Content-Type': 'application/json', + authorization: `Bearer ${this.cryptoService.decrypt( + connection.access_token, + )}`, + }, + }); + const customers: WebflowCustomerOutput[] = resp.data.users; + + this.logger.log(`Synced webflow customers !`); + + return { + data: customers, + message: 'Webflow customers retrieved', + statusCode: 200, + }; + } catch (error) { + throw error; + } + } +} diff --git a/packages/api/src/ecommerce/customer/services/webflow/mappers.ts b/packages/api/src/ecommerce/customer/services/webflow/mappers.ts new file mode 100644 index 000000000..7aea5717d --- /dev/null +++ b/packages/api/src/ecommerce/customer/services/webflow/mappers.ts @@ -0,0 +1,91 @@ +import { WebflowCustomerInput, WebflowCustomerOutput } from './types'; +import { + UnifiedEcommerceCustomerInput, + UnifiedEcommerceCustomerOutput, +} 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 WebflowCustomerMapper implements ICustomerMapper { + constructor( + private mappersRegistry: MappersRegistry, + private utils: Utils, + private coreUnificationService: CoreUnification, + ) { + this.mappersRegistry.registerService( + 'ecommerce', + 'customer', + 'webflow', + this, + ); + } + + async desunify( + source: UnifiedEcommerceCustomerInput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): Promise { + return; + } + + async unify( + source: WebflowCustomerOutput | WebflowCustomerOutput[], + connectionId: string, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): Promise< + UnifiedEcommerceCustomerOutput | UnifiedEcommerceCustomerOutput[] + > { + if (!Array.isArray(source)) { + return await this.mapSingleCustomerToUnified( + source, + connectionId, + customFieldMappings, + ); + } + // Handling array of WebflowCustomerOutput + return Promise.all( + source.map((customer) => + this.mapSingleCustomerToUnified( + customer, + connectionId, + customFieldMappings, + ), + ), + ); + } + + private async mapSingleCustomerToUnified( + customer: WebflowCustomerOutput, + connectionId: string, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): Promise { + const result = { + remote_id: customer.id, + remote_data: customer, + email: customer.data.email, + first_name: customer.data.name, + last_name: null, + phone_number: null, + addresses: [], + field_mappings: + customFieldMappings?.reduce((acc, mapping) => { + acc[mapping.slug] = customer[mapping.remote_id]; + return acc; + }, {} as Record) || {}, + }; + + return result; + } +} diff --git a/packages/api/src/ecommerce/customer/services/webflow/types.ts b/packages/api/src/ecommerce/customer/services/webflow/types.ts new file mode 100644 index 000000000..6ac8c32a0 --- /dev/null +++ b/packages/api/src/ecommerce/customer/services/webflow/types.ts @@ -0,0 +1,33 @@ +// ref: https://docs.developers.webflow.com/data/reference/list-users + +export interface WebflowCustomerInput { + id: string; + isEmailVerified: boolean; + lastUpdated: string; + invitedOn: string; + createdOn: string; + lastLogin: string; + status: 'invited' | 'verified' | 'unverified'; + accessGroups: AccessGroup[]; + data: UserData; +} + +type AccessGroup = { + slug: string; + type: AccessGroupType; +}; + +enum AccessGroupType { + ADMIN = 'admin', // Assigned to the user via API or in the designer + ECOMMERCE = 'ecommerce', // Assigned to the user via an ecommerce purchase +} + +type UserData = { + name: string; + email: string; + acceptPrivacy: boolean; + acceptCommunications: boolean; + [key: string]: any; +}; + +export type WebflowCustomerOutput = Partial; diff --git a/packages/api/src/ecommerce/order/order.module.ts b/packages/api/src/ecommerce/order/order.module.ts index f74634825..568f01b47 100644 --- a/packages/api/src/ecommerce/order/order.module.ts +++ b/packages/api/src/ecommerce/order/order.module.ts @@ -10,6 +10,8 @@ import { ShopifyService } from './services/shopify'; import { ShopifyOrderMapper } from './services/shopify/mappers'; import { WoocommerceService } from './services/woocommerce'; import { WoocommerceOrderMapper } from './services/woocommerce/mappers'; +import { WebflowService } from './services/webflow'; +import { WebflowOrderMapper } from './services/webflow/mappers'; import { SyncService } from './sync/sync.service'; import { SquarespaceService } from './services/squarespace'; import { SquarespaceOrderMapper } from './services/squarespace/mappers'; @@ -35,6 +37,8 @@ import { AmazonService } from './services/amazon'; WoocommerceService, SquarespaceService, AmazonService, + WebflowService, + WebflowOrderMapper, ], exports: [SyncService], }) diff --git a/packages/api/src/ecommerce/order/services/webflow/index.ts b/packages/api/src/ecommerce/order/services/webflow/index.ts new file mode 100644 index 000000000..b3d1c5ca3 --- /dev/null +++ b/packages/api/src/ecommerce/order/services/webflow/index.ts @@ -0,0 +1,58 @@ +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 { SyncParam } from '@@core/utils/types/interface'; +import { IOrderService } from '@ecommerce/order/types'; +import { Injectable } from '@nestjs/common'; +import { EcommerceObject } from '@panora/shared'; +import axios from 'axios'; +import { ServiceRegistry } from '../registry.service'; +import { WebflowOrderInput, WebflowOrderOutput } from './types'; + +@Injectable() +export class WebflowService implements IOrderService { + constructor( + private prisma: PrismaService, + private logger: LoggerService, + private cryptoService: EncryptionService, + private registry: ServiceRegistry, + ) { + this.logger.setContext( + `${EcommerceObject.order.toUpperCase()}:${WebflowService.name}`, + ); + this.registry.registerService('webflow', this); + } + + async sync(data: SyncParam): Promise> { + try { + const connection = await this.prisma.connections.findFirst({ + where: { + id_linked_user: data.linkedUserId, + provider_slug: 'webflow', + vertical: 'ecommerce', + }, + }); + + // ref: https://docs.developers.webflow.com/data/reference/list-orders + const resp = await axios.get(`${connection.account_url}/orders`, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.cryptoService.decrypt( + connection.access_token, + )}`, + }, + }); + const orders: WebflowOrderOutput[] = resp.data.orders; + this.logger.log(`Synced webflow orders !`); + + return { + data: orders, + message: 'Webflow orders retrieved', + statusCode: 200, + }; + } catch (error) { + throw error; + } + } +} diff --git a/packages/api/src/ecommerce/order/services/webflow/mappers.ts b/packages/api/src/ecommerce/order/services/webflow/mappers.ts new file mode 100644 index 000000000..d3e4c3e35 --- /dev/null +++ b/packages/api/src/ecommerce/order/services/webflow/mappers.ts @@ -0,0 +1,126 @@ +import { + UnifiedEcommerceOrderInput, + UnifiedEcommerceOrderOutput, +} from '@ecommerce/order/types/model.unified'; +import { IOrderMapper } from '@ecommerce/order/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'; +import { WebflowOrderInput, WebflowOrderOutput } from './types'; + +@Injectable() +export class WebflowOrderMapper implements IOrderMapper { + constructor( + private mappersRegistry: MappersRegistry, + private utils: Utils, + private coreUnificationService: CoreUnification, + ) { + this.mappersRegistry.registerService('ecommerce', 'order', 'webflow', this); + } + + async desunify( + source: UnifiedEcommerceOrderInput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): Promise { + return; + } + + async unify( + source: WebflowOrderOutput | WebflowOrderOutput[], + connectionId: string, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): Promise { + if (!Array.isArray(source)) { + return await this.mapSingleOrderToUnified( + source, + connectionId, + customFieldMappings, + ); + } + return Promise.all( + source.map((order) => + this.mapSingleOrderToUnified(order, connectionId, customFieldMappings), + ), + ); + } + + private async mapSingleOrderToUnified( + source: WebflowOrderOutput, + connectionId: string, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): Promise { + const result: UnifiedEcommerceOrderOutput = { + remote_id: source.orderId, + remote_data: source, + created_at: source.acceptedOn, + order_status: this.mapWebflowStatusToUnified(source.status), + currency: source.customerPaid.unit, + total_price: source.totals.total.value, + fulfillment_status: this.mapWebflowStatusToUnified(source.status), + items: + source.purchasedItems?.map((item) => ({ + product_id: item.productId, + variant_id: item.variantId, + sku: item.variantSKU, + title: item.productName, + quantity: item.count, + price: item.variantPrice.value.toString(), + total: item.rowTotal.value.toString(), + variant_title: item.variantName, + weight: item.weight, + properties: [ + { + name: 'image_url', + value: item.variantImage.url, + }, + ], + })) || [], + field_mappings: {}, + }; + + result.total_discount = source.totals.extras + .filter((extra) => ['discount', 'discount-shipping'].includes(extra.type)) + .reduce((acc, curr) => acc + curr.price.value, 0); + + result.total_shipping = source.totals.extras + .filter((extra) => extra.type === 'shipping') + .reduce((acc, curr) => acc + curr.price.value, 0); + + result.total_tax = source.totals.extras + .filter((extra) => extra.type === 'tax') + .reduce((acc, curr) => acc + curr.price.value, 0); + + if (customFieldMappings && source.customData) { + for (const [key, value] of Object.entries(source.customData)) { + if (customFieldMappings.find((m) => m.slug === key)) { + result.field_mappings[key] = value; + } + } + } + + return result; + } + + private mapWebflowStatusToUnified( + status?: WebflowOrderInput['status'], + ): string { + switch (status) { + case 'pending': + return 'PENDING'; + case 'fulfilled': + return 'FULLFILLED'; + default: + return status; + } + } +} diff --git a/packages/api/src/ecommerce/order/services/webflow/types.ts b/packages/api/src/ecommerce/order/services/webflow/types.ts new file mode 100644 index 000000000..80bde1b47 --- /dev/null +++ b/packages/api/src/ecommerce/order/services/webflow/types.ts @@ -0,0 +1,175 @@ +import { CurrencyCode } from '@@core/utils/types'; + +export interface WebflowOrderInput { + readonly orderId: string; + status: + | 'pending' + | 'unfulfilled' + | 'fulfilled' + | 'disputed' + | 'dispute-lost' + | 'refunded'; + comment?: string | null; // A comment string for this Order, which is editable by API user (not used by Webflow). + orderComment?: string | null; // A comment that the customer left when making their Order + acceptedOn: string | null; + fulfilledOn: string | null; + refundedOn: string | null; + disputedOn: string | null; + disputeUpdatedOn: string | null; + disputeLastStatus: DisputeStatus | null; + customerPaid: Money; + netAmount: Money; + applicationFee: Money; + allAddresses: Address[]; + shippingAddress: Address; + shippingProvider?: string | null; + shippingTracking?: string | null; + shippingTrackingURL: string | null; + customerInfo: { + fullName: string; + email: string; + }; + purchasedItems: PurchasedItem[]; + purchasedItemsCount: number; + stripeDetails: StripeDetails | null; + stripeCard: StripeCard | null; + paypalDetails: PaypalDetails | null; + paymentProcessor?: string; + customData: Array>; // Array of objects + metadata: { + isBuyNow: boolean; + hasDownloads?: boolean; + paymentProcessor?: string; + }; + billingAddress: Address; + totals?: Totals; + isCustomerDeleted?: boolean; + isShippingRequired: boolean; + hasDownloads?: boolean; + downloadFiles?: { + id: string; + name: string; + url: string; + }[]; +} + +interface Money { + unit: CurrencyCode; // The three-letter ISO currency code + value: number; + string: string; // example: "$ 109.05 USD" +} + +interface Address { + type: 'billing' | 'shipping'; + japanType?: 'kana' | 'kanji' | null; // Represents a Japan-only address format. This field will only appear on orders placed from Japan. + addressee: string; + line1: string; + line2?: string; + city: string; + state: string; + country: string; + postalCode: string; +} + +interface FileObjectVariant { + url: string; + originalFileName: string; + size: number; + width: number; // in pixels + height: number; // in pixels +} + +interface FileObject { + size: number; + originalFileName: string; + createdOn: string; + contentType: string; // The MIME type of the image + width: number; // The image width in pixels + height: number; // The image height in pixels + variants: FileObjectVariant[]; +} + +interface PurchasedItem { + count: number; + rowTotal: Money; + productId: string; + productName: string; + productSlug: string; + variantId: string; + variantName: string; + variantSlug: string; + variantSKU: string; + variantImage: { + url: string; + file: FileObject | null; + }; + variantPrice: Money; + weight: number | null; + height: number | null; + width: number | null; + length: number | null; +} + +interface StripeDetails { + customerId: string | null; + paymentMethod: string | null; + chargeId: string | null; + disputeId: string | null; + paymentIntentId: string | null; + subscriptionId: string | null; + refundId: string | null; + refundReason: string | null; +} + +interface PaypalDetails { + orderId: string; + payerId: string; + captureId: string; + refundId: string; + refundReason: string; + disputeId: string; +} + +interface StripeCard { + last4: string; + brand: StripeCardBrands; + ownerName: string; + expires: { + month: number; + year: number; + }; +} + +interface Totals { + subtotal: Money; + extras: { + type: 'tax' | 'shipping' | 'discount' | 'discount-shipping'; + name: string; + description?: string; + price: Money; + }[]; + total: Money; +} + +enum DisputeStatus { + WARNING_NEEDS_RESPONSE = 'warning_needs_response', + WARNING_UNDER_REVIEW = 'warning_under_review', + WARNING_CLOSED = 'warning_closed', + NEEDS_RESPONSE = 'needs_response', + UNDER_REVIEW = 'under_review', + CHARGE_REFUNDED = 'charge_refunded', + WON = 'won', + LOST = 'lost', +} + +enum StripeCardBrands { + VISA = 'Visa', + AMEX = 'American Express', + MASTERCARD = 'MasterCard', + DISCOVER = 'Discover', + JCB = 'JCB', + DINERS_CLUB = 'Diners Club', + UNKNOWN = 'Unknown', +} + +export type WebflowOrderOutput = Partial; diff --git a/packages/api/src/ecommerce/product/product.module.ts b/packages/api/src/ecommerce/product/product.module.ts index feb69f6d7..ef8633954 100644 --- a/packages/api/src/ecommerce/product/product.module.ts +++ b/packages/api/src/ecommerce/product/product.module.ts @@ -12,6 +12,8 @@ import { SquarespaceService } from './services/squarespace'; import { SquarespaceProductMapper } from './services/squarespace/mappers'; import { WoocommerceService } from './services/woocommerce'; import { WoocommerceProductMapper } from './services/woocommerce/mappers'; +import { WebflowService } from './services/webflow'; +import { WebflowProductMapper } from './services/webflow/mappers'; import { SyncService } from './sync/sync.service'; @Module({ @@ -31,6 +33,8 @@ import { SyncService } from './sync/sync.service'; ShopifyService, WoocommerceService, SquarespaceService, + WebflowService, + WebflowProductMapper, ], exports: [SyncService], }) diff --git a/packages/api/src/ecommerce/product/services/webflow/index.ts b/packages/api/src/ecommerce/product/services/webflow/index.ts new file mode 100644 index 000000000..92ebdb1b4 --- /dev/null +++ b/packages/api/src/ecommerce/product/services/webflow/index.ts @@ -0,0 +1,100 @@ +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 { SyncParam } from '@@core/utils/types/interface'; +import { IProductService } from '@ecommerce/product/types'; +import { Injectable } from '@nestjs/common'; +import axios from 'axios'; +import { ServiceRegistry } from '../registry.service'; +import { EcommerceObject } from '@panora/shared'; +import { WebflowProductInput, WebflowProductOutput } from './types'; +import { DesunifyReturnType } from '@@core/utils/types/desunify.input'; +import { OriginalProductOutput } from '@@core/utils/types/original/original.ecommerce'; + +@Injectable() +export class WebflowService implements IProductService { + constructor( + private prisma: PrismaService, + private logger: LoggerService, + private cryptoService: EncryptionService, + private registry: ServiceRegistry, + ) { + this.logger.setContext( + `${EcommerceObject.product.toUpperCase()}:${WebflowService.name}`, + ); + this.registry.registerService('webflow', this); + } + + async addProduct( + productData: WebflowProductInput, + linkedUserId: string, + ): Promise> { + try { + const connection = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedUserId, + provider_slug: 'webflow', + vertical: 'ecommerce', + }, + }); + const resp = await axios.post( + // https://api.webflow.com/v2/sites/{site_id}/products + `${connection.account_url}/products`, + productData, + { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.cryptoService.decrypt( + connection.access_token, + )}`, + }, + }, + ); + + return { + data: resp.data, + message: 'Webflow product created', + statusCode: 201, + }; + } catch (error) { + throw error; + } + } + + async sync(data: SyncParam): Promise> { + try { + const { linkedUserId } = data; + + const connection = await this.prisma.connections.findFirst({ + where: { + id_linked_user: linkedUserId, + provider_slug: 'webflow', + vertical: 'ecommerce', + }, + }); + const resp = await axios.get( + // https://api.webflow.com/v2/sites/{site_id}/products + `${connection.account_url}/products`, + { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.cryptoService.decrypt( + connection.access_token, + )}`, + }, + }, + ); + const products: WebflowProductOutput[] = resp.data.items; + this.logger.log(`Synced webflow products !`); + + return { + data: products, + message: 'Webflow products retrieved', + statusCode: 200, + }; + } catch (error) { + throw error; + } + } +} diff --git a/packages/api/src/ecommerce/product/services/webflow/mappers.ts b/packages/api/src/ecommerce/product/services/webflow/mappers.ts new file mode 100644 index 000000000..7f9c68d0e --- /dev/null +++ b/packages/api/src/ecommerce/product/services/webflow/mappers.ts @@ -0,0 +1,119 @@ +import { + UnifiedEcommerceProductInput, + UnifiedEcommerceProductOutput, +} 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'; +import { WebflowProductInput, WebflowProductOutput } from './types'; + +@Injectable() +export class WebflowProductMapper implements IProductMapper { + constructor( + private mappersRegistry: MappersRegistry, + private utils: Utils, + private coreUnificationService: CoreUnification, + ) { + this.mappersRegistry.registerService( + 'ecommerce', + 'product', + 'webflow', + this, + ); + } + + async desunify( + source: UnifiedEcommerceProductInput, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): Promise> { + const res: Partial = { + product: { + fieldData: { + name: source.variants?.[0]?.title, + slug: source.product_url?.split('/').pop() || '', + description: source.description, + shippable: true, + }, + }, + sku: { + fieldData: { + name: source.variants?.[0]?.title, + slug: source.variants?.[0]?.sku, + sku: source.variants?.[0]?.sku, + price: { + value: parseInt(source.variants?.[0]?.price.toString(), 10), + unit: 'USD', + }, + }, + }, + }; + + customFieldMappings?.forEach((mapping) => { + if (mapping.slug === 'publishStatus') { + res.publishStatus = source[mapping.remote_id]; + return; + } + + res.product.fieldData[mapping.slug] = source[mapping.remote_id]; + }); + + return res; + } + + async unify( + source: WebflowProductOutput | WebflowProductOutput[], + connectionId: string, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): Promise { + if (!Array.isArray(source)) { + return await this.mapSingleProductToUnified( + source, + connectionId, + customFieldMappings, + ); + } + // Handling array of WebflowProductOutput + return Promise.all( + source.map((data) => + this.mapSingleProductToUnified(data, connectionId, customFieldMappings), + ), + ); + } + + private async mapSingleProductToUnified( + data: WebflowProductOutput, + connectionId: string, + customFieldMappings?: { + slug: string; + remote_id: string; + }[], + ): Promise { + return { + remote_id: data.product.id, + remote_data: data, + images_urls: (data.skus || []) + .map(({ fieldData: { mainImage } }) => mainImage?.url) + .filter(Boolean), + description: data.product.fieldData?.description, + tags: data.product.fieldData?.categories, + created_at: data.product?.createdOn, + modified_at: data.product?.lastUpdated, + variants: data.skus?.map((sku) => ({ + title: sku?.fieldData?.name, + price: sku?.fieldData?.price?.value, + sku: sku?.fieldData?.sku, + inventory_quantity: sku?.fieldData?.quantity, + weight: sku?.fieldData?.weight, + options: null, + })), + }; + } +} diff --git a/packages/api/src/ecommerce/product/services/webflow/types.ts b/packages/api/src/ecommerce/product/services/webflow/types.ts new file mode 100644 index 000000000..f6337ac07 --- /dev/null +++ b/packages/api/src/ecommerce/product/services/webflow/types.ts @@ -0,0 +1,107 @@ +// reference: https://docs.developers.webflow.com/data/reference/list-products + +export interface WebflowProductOutput { + product: ProductData; + skus: SkuData[]; +} + +export interface WebflowProductInput { + product: ProductData; + sku: SkuData; + publishStatus?: 'staging' | 'live'; +} + +export interface ProductData { + id?: string; + cmsLocaleId?: string; + lastPublished?: string; + lastUpdated?: string; + createdOn?: string; + isArchived?: boolean; + isDraft?: boolean; + fieldData: ProductFieldData; +} + +export interface ProductFieldData { + name: string; // required + slug: string; // required + description?: string; + shippable: boolean; + skuProperties?: SkuProperty[]; + categories?: string[]; + taxCategory?: string; + defaultSku?: string; + ecProductType?: string; + [key: string]: any; // for custom fields +} + +export interface SkuProperty { + id: string; // required + name: string; // required + enum: VariantOption[]; +} + +export interface VariantOption { + id: string; // required + name: string; // required + slug: string; // required +} + +export interface SkuData { + id?: string; + cmsLocaleId?: string; + lastPublished?: string; + lastUpdated?: string; + createdOn?: string; + fieldData: SkuFieldData; +} + +export interface SkuFieldData { + skuValues?: { [key: string]: string }; // maps SKU property ID to SKU value ID + name: string; // required + slug: string; // required + price: Price; // required + product?: string; + width?: number; + length?: number; + height?: number; + weight?: number; + sku: string; + mainImage?: Image | null; + moreImages?: Image[]; + downloadFiles?: DownloadFile[]; + compareAtPrice?: Price; + ecSkuBillingMethod?: 'one-time' | 'subscription'; + ecSkuSubscriptionPlan?: SubscriptionPlan; + trackInventory?: boolean; // Defaults to false + quantity?: number; + [key: string]: any; // for custom +} + +export interface Price { + value: number; // required + unit: string; // required +} + +export interface SubscriptionPlan { + interval: 'day' | 'week' | 'month' | 'year'; // required + frequency: number; // required + trial?: number; + plans?: { + id: string; + platform: string; + status: string; + }[]; +} + +export interface Image { + fileId: string; + url: string; + alt?: string | null; +} + +export interface DownloadFile { + name: string; + url: string; + id: string; +} diff --git a/packages/shared/src/connectors/enum.ts b/packages/shared/src/connectors/enum.ts index 0679963b8..49aaa60d1 100644 --- a/packages/shared/src/connectors/enum.ts +++ b/packages/shared/src/connectors/enum.ts @@ -12,7 +12,8 @@ export enum EcommerceConnectors { SHOPIFY = 'shopify', WOOCOMMERCE = 'woocommerce', SQUARESPACE = 'squarespace', - AMAZON = 'amazon' + AMAZON = 'amazon', + WEBFLOW = 'webflow' } export enum TicketingConnectors { diff --git a/packages/shared/src/connectors/index.ts b/packages/shared/src/connectors/index.ts index c99c194d7..3a284173f 100644 --- a/packages/shared/src/connectors/index.ts +++ b/packages/shared/src/connectors/index.ts @@ -5,4 +5,4 @@ export const ACCOUNTING_PROVIDERS = []; export const TICKETING_PROVIDERS = ['zendesk', 'front', 'jira', 'gitlab', 'github']; export const MARKETINGAUTOMATION_PROVIDERS = []; export const FILESTORAGE_PROVIDERS = ['box']; -export const ECOMMERCE_PROVIDERS = ['shopify', 'woocommerce', 'squarespace', 'amazon']; +export const ECOMMERCE_PROVIDERS = ['shopify', 'woocommerce', 'squarespace', 'amazon', 'webflow']; diff --git a/packages/shared/src/connectors/metadata.ts b/packages/shared/src/connectors/metadata.ts index b5baeafac..3949a4f6c 100644 --- a/packages/shared/src/connectors/metadata.ts +++ b/packages/shared/src/connectors/metadata.ts @@ -2920,7 +2920,7 @@ export const CONNECTORS_METADATA: ProvidersConfig = { }, logoPath: 'https://dailybrand.co.zw/wp-content/uploads/2023/10/webflow-2.png', description: 'Sync & Create orders, fulfillments, fulfillment orders, customers and products', - active: false, + active: true, authStrategy: { strategy: AuthStrategy.oauth2 },