From 1d7888afca3900f8a29b72f8fd149fc3e1e2ea4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Frane=20Poli=C4=87?= <16856471+fPolic@users.noreply.github.com> Date: Fri, 15 Dec 2023 13:43:00 +0100 Subject: [PATCH] feat(medusa, link-modules): sales channel <> product module link (#5450) * feat: sales channel joiner config * feat: product sales channel link config, SC list method * feat: migration * fix: refactor list SC * refactor: SC repo api * chore: changeset * feat: add dedicated FF * feat: product<> sc join entity * fix: update case * fix: add FF on in the repository, fix tests * fix: assign id when FF is on * fix: target table * feat: product service - fetch SC with RQ * feat: admin list products & SC with isolated product domain * feat: get admin product * feat: store endpoints * fix: remove duplicate import * fix: remove "name" prop * feat: refactor * fix: product seeder if FF is on * fix: env * refactor: workflow product handlers to handle remote links * fix: condition * fix: use correct method * fix: build * wip: update FF * fix: update FF in the handlers * chore: migrate to medusav2 FF * chore: uncomment test * fix: product factory * fix: unlinking SC and product * fix: use module name variable * refactor: cleanup query definitions * fix: add constraint * chore: rename prop * fix: add hook * fix: address comments * fix: temp sc filtering * fix: use RQ to filter by SC * fix: add sc to filter to list --------- Co-authored-by: Riqwan Thamir --- .changeset/chilled-deers-prove.md | 6 + .../factories/simple-product-factory.ts | 24 ++- .../plugins/__tests__/product/admin/index.ts | 11 +- .../attach-sales-channel-to-products.ts | 48 ++++-- .../detach-sales-channel-from-products.ts | 46 ++++-- .../link-modules/src/definitions/index.ts | 1 + .../src/definitions/product-sales-channel.ts | 62 ++++++++ packages/link-modules/src/links.ts | 6 + .../api/routes/admin/products/get-product.ts | 31 ++-- .../src/api/routes/admin/products/index.ts | 12 ++ .../src/api/routes/store/products/index.ts | 12 ++ .../routes/store/products/list-products.ts | 4 + packages/medusa/src/joiner-configs/index.ts | 1 + .../joiner-configs/sales-channel-service.ts | 32 ++++ ...98056997411-product-sales-channels-link.ts | 38 +++++ .../src/models/product-sales-channel.ts | 20 +++ packages/medusa/src/models/sales-channel.ts | 17 +- .../medusa/src/repositories/sales-channel.ts | 50 +++++- .../src/services/__tests__/sales-channel.ts | 18 ++- packages/medusa/src/services/product.ts | 146 +++++++++++++++++- packages/medusa/src/services/sales-channel.ts | 55 ++++++- .../medusa/src/utils/generate-entity-id.ts | 2 +- .../utils/queries/products/list-products.ts | 47 ++---- .../utils/src/common/generate-entity-id.ts | 2 +- 24 files changed, 603 insertions(+), 88 deletions(-) create mode 100644 .changeset/chilled-deers-prove.md create mode 100644 packages/link-modules/src/definitions/product-sales-channel.ts create mode 100644 packages/medusa/src/joiner-configs/sales-channel-service.ts create mode 100644 packages/medusa/src/migrations/1698056997411-product-sales-channels-link.ts create mode 100644 packages/medusa/src/models/product-sales-channel.ts diff --git a/.changeset/chilled-deers-prove.md b/.changeset/chilled-deers-prove.md new file mode 100644 index 0000000000..7bbed2c322 --- /dev/null +++ b/.changeset/chilled-deers-prove.md @@ -0,0 +1,6 @@ +--- +"@medusajs/link-modules": patch +"@medusajs/medusa": patch +--- + +feat(medusa, link-module): SalesChannel<>Product joiner config diff --git a/integration-tests/factories/simple-product-factory.ts b/integration-tests/factories/simple-product-factory.ts index c995d05a46..2dbbc5265a 100644 --- a/integration-tests/factories/simple-product-factory.ts +++ b/integration-tests/factories/simple-product-factory.ts @@ -18,6 +18,7 @@ import { import { DataSource } from "typeorm" import faker from "faker" +import { generateEntityId } from "@medusajs/utils" export type ProductFactoryData = { id?: string @@ -30,6 +31,7 @@ export type ProductFactoryData = { variants?: Omit[] sales_channels?: SalesChannelFactoryData[] metadata?: Record + isMedusaV2Enabled?: boolean } export const simpleProductFactory = async ( @@ -41,6 +43,9 @@ export const simpleProductFactory = async ( faker.seed(seed) } + data.isMedusaV2Enabled = + data.isMedusaV2Enabled ?? process.env.MEDUSA_FF_MEDUSA_V2 == "true" + const manager = dataSource.manager const defaultProfile = await manager.findOne(ShippingProfile, { @@ -121,10 +126,27 @@ export const simpleProductFactory = async ( const toSave = manager.create(Product, productToCreate) - toSave.sales_channels = sales_channels + if (!data.isMedusaV2Enabled) { + toSave.sales_channels = sales_channels + } const product = await manager.save(toSave) + if (data.isMedusaV2Enabled) { + await manager.query( + `INSERT INTO "product_sales_channel" (id, product_id, sales_channel_id) + VALUES ${sales_channels + .map( + (sc) => + `('${generateEntityId(undefined, "prodsc")}', '${toSave.id}', '${ + sc.id + }')` + ) + .join(", ")}; + ` + ) + } + const optionId = `${prodId}-option` const options = data.options || [{ id: optionId, title: "Size" }] for (const o of options) { diff --git a/integration-tests/plugins/__tests__/product/admin/index.ts b/integration-tests/plugins/__tests__/product/admin/index.ts index 3d388e03ab..8d514214e7 100644 --- a/integration-tests/plugins/__tests__/product/admin/index.ts +++ b/integration-tests/plugins/__tests__/product/admin/index.ts @@ -572,7 +572,7 @@ describe("/admin/products", () => { const response = await api .post( - `/admin/products/${toUpdateWithSalesChannels}`, + `/admin/products/${toUpdateWithSalesChannels}?expand=sales_channels`, payload, adminHeaders ) @@ -584,11 +584,10 @@ describe("/admin/products", () => { expect(response?.data.product).toEqual( expect.objectContaining({ id: toUpdateWithSalesChannels, - // TODO: Introduce this in the sale channel PR - // sales_channels: [ - // expect.objectContaining({ id: "channel-2" }), - // expect.objectContaining({ id: "channel-3" }), - // ], + sales_channels: [ + expect.objectContaining({ id: "channel-2" }), + expect.objectContaining({ id: "channel-3" }), + ], }) ) }) diff --git a/packages/core-flows/src/handlers/product/attach-sales-channel-to-products.ts b/packages/core-flows/src/handlers/product/attach-sales-channel-to-products.ts index dd70c33f90..d76792be6d 100644 --- a/packages/core-flows/src/handlers/product/attach-sales-channel-to-products.ts +++ b/packages/core-flows/src/handlers/product/attach-sales-channel-to-products.ts @@ -1,5 +1,6 @@ import { WorkflowArguments } from "@medusajs/workflows-sdk" -import { promiseAll } from "@medusajs/utils" +import { MedusaV2Flag, promiseAll } from "@medusajs/utils" +import { Modules } from "@medusajs/modules-sdk" type ProductHandle = string type SalesChannelId = string @@ -17,6 +18,8 @@ export async function attachSalesChannelToProducts({ data, }: WorkflowArguments): Promise { const { manager } = context + const featureFlagRouter = container.resolve("featureFlagRouter") + const productsHandleSalesChannelsMap = data.productsHandleSalesChannelsMap const products = data.products @@ -35,16 +38,41 @@ export async function attachSalesChannelToProducts({ } }) - await promiseAll( - Array.from(salesChannelIdProductIdsMap.entries()).map( - async ([salesChannelId, productIds]) => { - return await salesChannelServiceTx.addProducts( - salesChannelId, - productIds - ) - } + if (featureFlagRouter.isFeatureEnabled(MedusaV2Flag.key)) { + const remoteLink = container.resolve("remoteLink") + const links: any[] = [] + + for (const [ + salesChannelId, + productIds, + ] of salesChannelIdProductIdsMap.entries()) { + productIds.forEach((id) => + links.push({ + [Modules.PRODUCT]: { + product_id: id, + }, + salesChannelService: { + sales_channel_id: salesChannelId, + }, + }) + ) + + await remoteLink.create(links) + } + + return + } else { + await promiseAll( + Array.from(salesChannelIdProductIdsMap.entries()).map( + async ([salesChannelId, productIds]) => { + return await salesChannelServiceTx.addProducts( + salesChannelId, + productIds + ) + } + ) ) - ) + } } attachSalesChannelToProducts.aliases = { diff --git a/packages/core-flows/src/handlers/product/detach-sales-channel-from-products.ts b/packages/core-flows/src/handlers/product/detach-sales-channel-from-products.ts index 2db2998ea2..72ecdcc1aa 100644 --- a/packages/core-flows/src/handlers/product/detach-sales-channel-from-products.ts +++ b/packages/core-flows/src/handlers/product/detach-sales-channel-from-products.ts @@ -1,5 +1,6 @@ import { WorkflowArguments } from "@medusajs/workflows-sdk" -import { promiseAll } from "@medusajs/utils" +import { MedusaV2Flag, promiseAll } from "@medusajs/utils" +import { Modules } from "@medusajs/modules-sdk" type ProductHandle = string type SalesChannelId = string @@ -15,6 +16,8 @@ export async function detachSalesChannelFromProducts({ data, }: WorkflowArguments): Promise { const { manager } = context + const featureFlagRouter = container.resolve("featureFlagRouter") + const productsHandleSalesChannelsMap = data.productsHandleSalesChannelsMap const products = data.products @@ -33,16 +36,41 @@ export async function detachSalesChannelFromProducts({ } }) - await promiseAll( - Array.from(salesChannelIdProductIdsMap.entries()).map( - async ([salesChannelId, productIds]) => { - return await salesChannelServiceTx.removeProducts( - salesChannelId, - productIds + if (featureFlagRouter.isFeatureEnabled(MedusaV2Flag.key)) { + const remoteLink = container.resolve("remoteLink") + const promises: Promise[] = [] + + for (const [ + salesChannelId, + productIds, + ] of salesChannelIdProductIdsMap.entries()) { + productIds.forEach((id) => + promises.push( + remoteLink.dismiss({ + [Modules.PRODUCT]: { + product_id: id, + }, + salesChannelService: { + sales_channel_id: salesChannelId, + }, + }) ) - } + ) + } + + return + } else { + await promiseAll( + Array.from(salesChannelIdProductIdsMap.entries()).map( + async ([salesChannelId, productIds]) => { + return await salesChannelServiceTx.removeProducts( + salesChannelId, + productIds + ) + } + ) ) - ) + } } detachSalesChannelFromProducts.aliases = { diff --git a/packages/link-modules/src/definitions/index.ts b/packages/link-modules/src/definitions/index.ts index e62cfe17fa..571e83bced 100644 --- a/packages/link-modules/src/definitions/index.ts +++ b/packages/link-modules/src/definitions/index.ts @@ -2,3 +2,4 @@ export * from "./inventory-level-stock-location" export * from "./product-variant-inventory-item" export * from "./product-variant-price-set" export * from "./product-shipping-profile" +export * from "./product-sales-channel" diff --git a/packages/link-modules/src/definitions/product-sales-channel.ts b/packages/link-modules/src/definitions/product-sales-channel.ts new file mode 100644 index 0000000000..8d675284e7 --- /dev/null +++ b/packages/link-modules/src/definitions/product-sales-channel.ts @@ -0,0 +1,62 @@ +import { Modules } from "@medusajs/modules-sdk" +import { ModuleJoinerConfig } from "@medusajs/types" +import { LINKS } from "../links" + +export const ProductSalesChannel: ModuleJoinerConfig = { + serviceName: LINKS.ProductSalesChannel, + isLink: true, + databaseConfig: { + tableName: "product_sales_channel", + idPrefix: "prodsc", + }, + alias: [ + { + name: "product_sales_channel", + }, + { + name: "product_sales_channels", + }, + ], + primaryKeys: ["id", "product_id", "sales_channel_id"], + relationships: [ + { + serviceName: Modules.PRODUCT, + primaryKey: "id", + foreignKey: "product_id", + alias: "product", + }, + { + serviceName: "salesChannelService", + isInternalService: true, + primaryKey: "id", + foreignKey: "sales_channel_id", + alias: "sales_channel", + }, + ], + extends: [ + { + serviceName: Modules.PRODUCT, + fieldAlias: { + sales_channels: "sales_channels_link.sales_channel", + }, + relationship: { + serviceName: LINKS.ProductSalesChannel, + primaryKey: "product_id", + foreignKey: "id", + alias: "sales_channels_link", + isList: true, + }, + }, + { + serviceName: "salesChannelService", + relationship: { + serviceName: LINKS.ProductSalesChannel, + isInternalService: true, + primaryKey: "sales_channel_id", + foreignKey: "id", + alias: "products_link", + isList: true, + }, + }, + ], +} diff --git a/packages/link-modules/src/links.ts b/packages/link-modules/src/links.ts index 3c8c3ea942..bda65e6f2c 100644 --- a/packages/link-modules/src/links.ts +++ b/packages/link-modules/src/links.ts @@ -22,4 +22,10 @@ export const LINKS = { "shippingProfileService", "profile_id" ), + ProductSalesChannel: composeLinkName( + Modules.PRODUCT, + "product_id", + "salesChannelService", + "sales_channel_id" + ), } diff --git a/packages/medusa/src/api/routes/admin/products/get-product.ts b/packages/medusa/src/api/routes/admin/products/get-product.ts index d60d69bf71..135505d8a4 100644 --- a/packages/medusa/src/api/routes/admin/products/get-product.ts +++ b/packages/medusa/src/api/routes/admin/products/get-product.ts @@ -68,15 +68,13 @@ export default async (req, res) => { const productService: ProductService = req.scope.resolve("productService") const pricingService: PricingService = req.scope.resolve("pricingService") const featureFlagRouter = req.scope.resolve("featureFlagRouter") + const isMedusaV2FlagOn = featureFlagRouter.isFeatureEnabled(MedusaV2Flag.key) const productVariantInventoryService: ProductVariantInventoryService = req.scope.resolve("productVariantInventoryService") - const salesChannelService: SalesChannelService = req.scope.resolve( - "salesChannelService" - ) let rawProduct - if (featureFlagRouter.isFeatureEnabled(MedusaV2Flag.key)) { + if (isMedusaV2FlagOn) { rawProduct = await retrieveProduct( req.scope, id, @@ -102,15 +100,30 @@ export default async (req, res) => { req.retrieveConfig.relations?.includes("variants") if (shouldSetAvailability) { - const [salesChannelsIds] = await salesChannelService.listAndCount( - {}, - { select: ["id"] } - ) + let salesChannels + + if (isMedusaV2FlagOn) { + const remoteQuery = req.scope.resolve("remoteQuery") + const query = { + sales_channel: { + fields: ["id"], + }, + } + salesChannels = await remoteQuery(query) + } else { + const salesChannelService: SalesChannelService = req.scope.resolve( + "salesChannelService" + ) + ;[salesChannels] = await salesChannelService.listAndCount( + {}, + { select: ["id"] } + ) + } decoratePromises.push( productVariantInventoryService.setProductAvailability( [product], - salesChannelsIds.map((salesChannel) => salesChannel.id) + salesChannels.map((salesChannel) => salesChannel.id) ) ) } diff --git a/packages/medusa/src/api/routes/admin/products/index.ts b/packages/medusa/src/api/routes/admin/products/index.ts index 1645a74766..bb25aeec77 100644 --- a/packages/medusa/src/api/routes/admin/products/index.ts +++ b/packages/medusa/src/api/routes/admin/products/index.ts @@ -238,6 +238,18 @@ export const defaultAdminProductRemoteQueryObject = { profile: { fields: ["id", "created_at", "updated_at", "deleted_at", "name", "type"], }, + sales_channels: { + fields: [ + "id", + "name", + "description", + "is_disabled", + "created_at", + "updated_at", + "deleted_at", + "metadata", + ], + }, } /** diff --git a/packages/medusa/src/api/routes/store/products/index.ts b/packages/medusa/src/api/routes/store/products/index.ts index ae07e8f908..eded227c34 100644 --- a/packages/medusa/src/api/routes/store/products/index.ts +++ b/packages/medusa/src/api/routes/store/products/index.ts @@ -198,6 +198,18 @@ export const defaultStoreProductRemoteQueryObject = { profile: { fields: ["id", "created_at", "updated_at", "deleted_at", "name", "type"], }, + sales_channels: { + fields: [ + "id", + "name", + "description", + "is_disabled", + "created_at", + "updated_at", + "deleted_at", + "metadata", + ], + }, } export * from "./list-products" diff --git a/packages/medusa/src/api/routes/store/products/list-products.ts b/packages/medusa/src/api/routes/store/products/list-products.ts index 92a1792c1c..ed33a2cf57 100644 --- a/packages/medusa/src/api/routes/store/products/list-products.ts +++ b/packages/medusa/src/api/routes/store/products/list-products.ts @@ -399,6 +399,10 @@ async function listAndCountProductWithIsolatedProductModule( }, } + if (salesChannelIdFilter) { + query.product["sales_channels"]["__args"] = { id: salesChannelIdFilter } + } + const { rows: products, metadata: { count }, diff --git a/packages/medusa/src/joiner-configs/index.ts b/packages/medusa/src/joiner-configs/index.ts index 4e41101c47..084dc203f6 100644 --- a/packages/medusa/src/joiner-configs/index.ts +++ b/packages/medusa/src/joiner-configs/index.ts @@ -1,4 +1,5 @@ export * as cart from "./cart-service" export * as customer from "./customer-service" export * as region from "./region-service" +export * as salesChannel from "./sales-channel-service" export * as shippingProfile from "./shipping-profile-service" diff --git a/packages/medusa/src/joiner-configs/sales-channel-service.ts b/packages/medusa/src/joiner-configs/sales-channel-service.ts new file mode 100644 index 0000000000..c67996455e --- /dev/null +++ b/packages/medusa/src/joiner-configs/sales-channel-service.ts @@ -0,0 +1,32 @@ +import { ModuleJoinerConfig } from "@medusajs/types" + +export default { + serviceName: "salesChannelService", + primaryKeys: ["id"], + linkableKeys: { sales_channel_id: "SalesChannel" }, + schema: ` + scalar Date + scalar JSON + + type SalesChannel { + id: ID! + name: String! + description: String! + is_disabled: Boolean + created_at: Date! + updated_at: Date! + deleted_at: Date + metadata: JSON + } + `, + alias: [ + { + name: "sales_channel", + args: { entity: "SalesChannel" }, + }, + { + name: "sales_channels", + args: { entity: "SalesChannel" }, + }, + ], +} as ModuleJoinerConfig diff --git a/packages/medusa/src/migrations/1698056997411-product-sales-channels-link.ts b/packages/medusa/src/migrations/1698056997411-product-sales-channels-link.ts new file mode 100644 index 0000000000..762c9db6dc --- /dev/null +++ b/packages/medusa/src/migrations/1698056997411-product-sales-channels-link.ts @@ -0,0 +1,38 @@ +import { MigrationInterface, QueryRunner } from "typeorm" +import { MedusaV2Flag } from "@medusajs/utils" + +export const featureFlag = MedusaV2Flag.key + +export class ProductSalesChannelsLink1698056997411 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "product_sales_channel" ADD COLUMN IF NOT EXISTS "id" text; + UPDATE "product_sales_channel" SET "id" = 'prodsc_' || substr(md5(random()::text), 0, 27) WHERE id is NULL; + ALTER TABLE "product_sales_channel" ALTER COLUMN "id" SET NOT NULL; + + ALTER TABLE "product_sales_channel" DROP CONSTRAINT IF EXISTS "PK_fd29b6a8bd641052628dee19583"; + ALTER TABLE "product_sales_channel" ADD CONSTRAINT "product_sales_channel_pk" PRIMARY KEY (id); + ALTER TABLE "product_sales_channel" ADD CONSTRAINT "product_sales_channel_product_id_sales_channel_id_unique" UNIQUE (product_id, sales_channel_id); + + ALTER TABLE "product_sales_channel" ADD COLUMN IF NOT EXISTS "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(); + ALTER TABLE "product_sales_channel" ADD COLUMN IF NOT EXISTS "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(); + ALTER TABLE "product_sales_channel" ADD COLUMN IF NOT EXISTS "deleted_at" TIMESTAMP WITH TIME ZONE; + `) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE product_sales_channel DROP CONSTRAINT IF EXISTS "product_sales_channel_pk"; + ALTER TABLE product_sales_channel DROP CONSTRAINT IF EXISTS "product_sales_channel_product_id_sales_channel_id_unique"; + ALTER TABLE product_sales_channel drop column if exists "id"; + + ALTER TABLE "product_sales_channel" DROP COLUMN IF EXISTS "created_at"; + ALTER TABLE "product_sales_channel" DROP COLUMN IF EXISTS "updated_at"; + ALTER TABLE "product_sales_channel" DROP COLUMN IF EXISTS "deleted_at"; + + ALTER TABLE product_sales_channel ADD CONSTRAINT "PK_product_sales_channel" PRIMARY KEY (product_id, sales_channel_id); + `) + } +} diff --git a/packages/medusa/src/models/product-sales-channel.ts b/packages/medusa/src/models/product-sales-channel.ts new file mode 100644 index 0000000000..75792c3cb2 --- /dev/null +++ b/packages/medusa/src/models/product-sales-channel.ts @@ -0,0 +1,20 @@ +import { BeforeInsert, Column, Entity } from "typeorm" +import { BaseEntity } from "../interfaces" +import { generateEntityId } from "../utils" + +@Entity("product_sales_channel") +export class ProductSalesChannel extends BaseEntity { + @Column({ type: "text" }) + sales_channel_id: string + + @Column({ type: "text" }) + product_id: string + + /** + * @apiIgnore + */ + @BeforeInsert() + private beforeInsert(): void { + this.id = generateEntityId(this.id, "prodsc") + } +} diff --git a/packages/medusa/src/models/sales-channel.ts b/packages/medusa/src/models/sales-channel.ts index 38b59a68b3..a25bc2e7f8 100644 --- a/packages/medusa/src/models/sales-channel.ts +++ b/packages/medusa/src/models/sales-channel.ts @@ -1,9 +1,10 @@ -import { BeforeInsert, Column, OneToMany } from "typeorm" +import { BeforeInsert, Column, JoinTable, ManyToMany, OneToMany } from "typeorm" import { FeatureFlagEntity } from "../utils/feature-flag-decorators" import { SoftDeletableEntity } from "../interfaces" import { DbAwareColumn, generateEntityId } from "../utils" import { SalesChannelLocation } from "./sales-channel-location" +import { Product } from "./product" @FeatureFlagEntity("sales_channels") export class SalesChannel extends SoftDeletableEntity { @@ -19,6 +20,20 @@ export class SalesChannel extends SoftDeletableEntity { @DbAwareColumn({ type: "jsonb", nullable: true }) metadata: Record | null + @ManyToMany(() => Product) + @JoinTable({ + name: "product_sales_channel", + inverseJoinColumn: { + name: "product_id", + referencedColumnName: "id", + }, + joinColumn: { + name: "sales_channel_id", + referencedColumnName: "id", + }, + }) + products: Product[] + @OneToMany( () => SalesChannelLocation, (scLocation) => scLocation.sales_channel, diff --git a/packages/medusa/src/repositories/sales-channel.ts b/packages/medusa/src/repositories/sales-channel.ts index 5c8350d0bd..e4d35442fc 100644 --- a/packages/medusa/src/repositories/sales-channel.ts +++ b/packages/medusa/src/repositories/sales-channel.ts @@ -2,18 +2,20 @@ import { DeleteResult, FindOptionsWhere, ILike, In } from "typeorm" import { SalesChannel } from "../models" import { ExtendedFindConfig } from "../types/common" import { dataSource } from "../loaders/database" +import { generateEntityId } from "../utils" +import { ProductSalesChannel } from "../models/product-sales-channel" const productSalesChannelTable = "product_sales_channel" export const SalesChannelRepository = dataSource .getRepository(SalesChannel) .extend({ - async getFreeTextSearchResultsAndCount( + async getFreeTextSearchResults_( q: string, - options: ExtendedFindConfig = { + options: ExtendedFindConfig & { withCount?: boolean } = { where: {}, } - ): Promise<[SalesChannel[], number]> { + ): Promise { const options_ = { ...options } options_.where = options_.where as FindOptionsWhere @@ -41,7 +43,31 @@ export const SalesChannelRepository = dataSource qb = qb.withDeleted() } - return await qb.getManyAndCount() + return await (options_.withCount ? qb.getManyAndCount() : qb.getMany()) + }, + + async getFreeTextSearchResultsAndCount( + q: string, + options: ExtendedFindConfig = { + where: {}, + } + ): Promise<[SalesChannel[], number]> { + return (await this.getFreeTextSearchResults_(q, { + ...options, + withCount: true, + })) as [SalesChannel[], number] + }, + + async getFreeTextSearchResults( + q: string, + options: ExtendedFindConfig = { + where: {}, + } + ): Promise { + return (await this.getFreeTextSearchResults_( + q, + options + )) as SalesChannel[] }, async removeProducts( @@ -62,16 +88,26 @@ export const SalesChannelRepository = dataSource async addProducts( salesChannelId: string, - productIds: string[] + productIds: string[], + isMedusaV2Enabled?: boolean ): Promise { - const valuesToInsert = productIds.map((id) => ({ + let valuesToInsert = productIds.map((id) => ({ sales_channel_id: salesChannelId, product_id: id, })) + if (isMedusaV2Enabled) { + valuesToInsert = valuesToInsert.map((v) => ({ + ...v, + id: generateEntityId(undefined, "prodsc"), + })) + } + await this.createQueryBuilder() .insert() - .into(productSalesChannelTable) + .into( + isMedusaV2Enabled ? ProductSalesChannel : productSalesChannelTable + ) .values(valuesToInsert) .orIgnore() .execute() diff --git a/packages/medusa/src/services/__tests__/sales-channel.ts b/packages/medusa/src/services/__tests__/sales-channel.ts index 41527ce27d..107bb25fc6 100644 --- a/packages/medusa/src/services/__tests__/sales-channel.ts +++ b/packages/medusa/src/services/__tests__/sales-channel.ts @@ -3,6 +3,7 @@ import { EventBusService, StoreService } from "../index" import SalesChannelService from "../sales-channel" import { EventBusServiceMock } from "../__mocks__/event-bus" import { store, StoreServiceMock } from "../__mocks__/store" +import { FlagRouter } from "@medusajs/utils" describe("SalesChannelService", () => { const salesChannelData = { @@ -68,6 +69,7 @@ describe("SalesChannelService", () => { eventBusService: EventBusServiceMock as unknown as EventBusService, salesChannelRepository: salesChannelRepositoryMock, storeService: StoreServiceMock as unknown as StoreService, + featureFlagRouter: new FlagRouter({}), }) beforeEach(() => { @@ -90,6 +92,7 @@ describe("SalesChannelService", () => { manager: MockManager, eventBusService: EventBusServiceMock as unknown as EventBusService, salesChannelRepository: salesChannelRepositoryMock, + featureFlagRouter: new FlagRouter({}), storeService: { ...StoreServiceMock, retrieve: jest.fn().mockImplementation(() => { @@ -119,6 +122,7 @@ describe("SalesChannelService", () => { describe("retrieve", () => { const salesChannelService = new SalesChannelService({ manager: MockManager, + featureFlagRouter: new FlagRouter({}), eventBusService: EventBusServiceMock as unknown as EventBusService, salesChannelRepository: salesChannelRepositoryMock, storeService: StoreServiceMock as unknown as StoreService, @@ -139,11 +143,9 @@ describe("SalesChannelService", () => { ...salesChannelData, }) - expect( - salesChannelRepositoryMock.findOne - ).toHaveBeenLastCalledWith({ + expect(salesChannelRepositoryMock.findOne).toHaveBeenLastCalledWith({ where: { id: IdMap.getId("sales_channel_1") }, - relationLoadStrategy: "query" + relationLoadStrategy: "query", }) }) }) @@ -151,6 +153,7 @@ describe("SalesChannelService", () => { describe("update", () => { const salesChannelService = new SalesChannelService({ manager: MockManager, + featureFlagRouter: new FlagRouter({}), eventBusService: EventBusServiceMock as unknown as EventBusService, salesChannelRepository: salesChannelRepositoryMock, storeService: StoreServiceMock as unknown as StoreService, @@ -186,6 +189,7 @@ describe("SalesChannelService", () => { describe("list", () => { const salesChannelService = new SalesChannelService({ manager: MockManager, + featureFlagRouter: new FlagRouter({}), eventBusService: EventBusServiceMock as unknown as EventBusService, salesChannelRepository: salesChannelRepositoryMock, storeService: StoreServiceMock as unknown as StoreService, @@ -255,6 +259,7 @@ describe("SalesChannelService", () => { describe("delete", () => { const salesChannelService = new SalesChannelService({ manager: MockManager, + featureFlagRouter: new FlagRouter({}), eventBusService: EventBusServiceMock as unknown as EventBusService, salesChannelRepository: salesChannelRepositoryMock, storeService: { @@ -310,6 +315,7 @@ describe("SalesChannelService", () => { describe("Remove products", () => { const salesChannelService = new SalesChannelService({ manager: MockManager, + featureFlagRouter: new FlagRouter({}), eventBusService: EventBusServiceMock as unknown as EventBusService, salesChannelRepository: salesChannelRepositoryMock, storeService: StoreServiceMock as unknown as StoreService, @@ -341,6 +347,7 @@ describe("SalesChannelService", () => { describe("Add products", () => { const salesChannelService = new SalesChannelService({ manager: MockManager, + featureFlagRouter: new FlagRouter({}), eventBusService: EventBusServiceMock as unknown as EventBusService, salesChannelRepository: salesChannelRepositoryMock, storeService: StoreServiceMock as unknown as StoreService, @@ -359,7 +366,8 @@ describe("SalesChannelService", () => { expect(salesChannelRepositoryMock.addProducts).toHaveBeenCalledTimes(1) expect(salesChannelRepositoryMock.addProducts).toHaveBeenCalledWith( IdMap.getId("sales_channel_1"), - [IdMap.getId("sales_channel_1_product_1")] + [IdMap.getId("sales_channel_1_product_1")], + false ) expect(salesChannel).toBeTruthy() expect(salesChannel).toEqual({ diff --git a/packages/medusa/src/services/product.ts b/packages/medusa/src/services/product.ts index d622061380..349f50eb97 100644 --- a/packages/medusa/src/services/product.ts +++ b/packages/medusa/src/services/product.ts @@ -2,11 +2,14 @@ import { buildRelations, buildSelects, FlagRouter, + MedusaV2Flag, objectToStringPath, promiseAll, selectorConstraintsToString, } from "@medusajs/utils" +import { RemoteQueryFunction } from "@medusajs/types" import { isDefined, MedusaError } from "medusa-core-utils" import { EntityManager, In } from "typeorm" + import { ProductVariantService, SearchService } from "." import { TransactionBaseService } from "../interfaces" import SalesChannelFeatureFlag from "../loaders/feature-flags/sales-channels" @@ -42,6 +45,7 @@ import { import { buildQuery, isString, setMetadata } from "../utils" import EventBusService from "./event-bus" import { CreateProductVariantInput } from "../types/product-variant" +import SalesChannelService from "./sales-channel" type InjectedDependencies = { manager: EntityManager @@ -54,8 +58,10 @@ type InjectedDependencies = { productCategoryRepository: typeof ProductCategoryRepository productVariantService: ProductVariantService searchService: SearchService + salesChannelService: SalesChannelService eventBusService: EventBusService featureFlagRouter: FlagRouter + remoteQuery: RemoteQueryFunction } class ProductService extends TransactionBaseService { @@ -69,8 +75,10 @@ class ProductService extends TransactionBaseService { protected readonly productCategoryRepository_: typeof ProductCategoryRepository protected readonly productVariantService_: ProductVariantService protected readonly searchService_: SearchService + protected readonly salesChannelService_: SalesChannelService protected readonly eventBus_: EventBusService protected readonly featureFlagRouter_: FlagRouter + protected remoteQuery_: RemoteQueryFunction static readonly IndexName = `products` static readonly Events = { @@ -90,6 +98,8 @@ class ProductService extends TransactionBaseService { productCategoryRepository, imageRepository, searchService, + remoteQuery, + salesChannelService, featureFlagRouter, }: InjectedDependencies) { // eslint-disable-next-line prefer-rest-params @@ -105,7 +115,9 @@ class ProductService extends TransactionBaseService { this.productTagRepository_ = productTagRepository this.imageRepository_ = imageRepository this.searchService_ = searchService + this.salesChannelService_ = salesChannelService this.featureFlagRouter_ = featureFlagRouter + this.remoteQuery_ = remoteQuery } /** @@ -167,17 +179,42 @@ class ProductService extends TransactionBaseService { const manager = this.activeManager_ const productRepo = manager.withRepository(this.productRepository_) + const hasSalesChannelsRelation = + config.relations?.includes("sales_channels") + + if ( + this.featureFlagRouter_.isFeatureEnabled(MedusaV2Flag.key) && + hasSalesChannelsRelation + ) { + config.relations = config.relations?.filter((r) => r !== "sales_channels") + } + const { q, query, relations } = this.prepareListQuery_(selector, config) + let count: number + let products: Product[] + if (q) { - return await productRepo.getFreeTextSearchResultsAndCount( + ;[products, count] = await productRepo.getFreeTextSearchResultsAndCount( q, query, relations ) + } else { + ;[products, count] = await productRepo.findWithRelationsAndCount( + relations, + query + ) } - return await productRepo.findWithRelationsAndCount(relations, query) + if ( + this.featureFlagRouter_.isFeatureEnabled(MedusaV2Flag.key) && + hasSalesChannelsRelation + ) { + await this.decorateProductsWithSalesChannels(products) + } + + return [products, count] } /** @@ -298,6 +335,16 @@ class ProductService extends TransactionBaseService { const manager = this.activeManager_ const productRepo = manager.withRepository(this.productRepository_) + const hasSalesChannelsRelation = + config.relations?.includes("sales_channels") + + if ( + this.featureFlagRouter_.isFeatureEnabled(MedusaV2Flag.key) && + hasSalesChannelsRelation + ) { + config.relations = config.relations?.filter((r) => r !== "sales_channels") + } + const { relations, ...query } = buildQuery(selector, config) const product = await productRepo.findOneWithRelations( @@ -314,6 +361,13 @@ class ProductService extends TransactionBaseService { ) } + if ( + this.featureFlagRouter_.isFeatureEnabled(MedusaV2Flag.key) && + hasSalesChannelsRelation + ) { + await this.decorateProductsWithSalesChannels([product]) + } + return product } @@ -465,7 +519,8 @@ class ProductService extends TransactionBaseService { } if ( - this.featureFlagRouter_.isFeatureEnabled(SalesChannelFeatureFlag.key) + this.featureFlagRouter_.isFeatureEnabled(SalesChannelFeatureFlag.key) && + !this.featureFlagRouter_.isFeatureEnabled(MedusaV2Flag.key) ) { if (isDefined(salesChannels)) { product.sales_channels = [] @@ -493,6 +548,20 @@ class ProductService extends TransactionBaseService { product = await productRepo.save(product) + if ( + isDefined(salesChannels) && + this.featureFlagRouter_.isFeatureEnabled(MedusaV2Flag.key) + ) { + if (salesChannels?.length) { + await Promise.all( + salesChannels?.map( + async (sc) => + await this.salesChannelService_.addProducts(sc.id, [product.id]) + ) + ) + } + } + product.options = await promiseAll( (options ?? []).map(async (option) => { const res = optionRepo.create({ @@ -638,7 +707,8 @@ class ProductService extends TransactionBaseService { } if ( - this.featureFlagRouter_.isFeatureEnabled(SalesChannelFeatureFlag.key) + this.featureFlagRouter_.isFeatureEnabled(SalesChannelFeatureFlag.key) && + !this.featureFlagRouter_.isFeatureEnabled(MedusaV2Flag.key) ) { if (isDefined(salesChannels)) { product.sales_channels = [] @@ -661,6 +731,17 @@ class ProductService extends TransactionBaseService { const result = await productRepo.save(product) + if (this.featureFlagRouter_.isFeatureEnabled(MedusaV2Flag.key)) { + if (salesChannels?.length) { + await promiseAll( + salesChannels?.map( + async (sc) => + await this.salesChannelService_.addProducts(sc.id, [product.id]) + ) + ) + } + } + await this.eventBus_ .withTransaction(manager) .emit(ProductService.Events.UPDATED, { @@ -1025,6 +1106,63 @@ class ProductService extends TransactionBaseService { q, } } + + /** + * Temporary method to join sales channels of a product using RemoteQuery while + * MedusaV2 FF is on. + * + * @param products + * @private + */ + private async decorateProductsWithSalesChannels(products: Product[]) { + const productIdSalesChannelMapMap = + await this.getSalesChannelModuleChannels(products.map((p) => p.id)) + + products.forEach( + (product) => + (product.sales_channels = productIdSalesChannelMapMap[product.id] ?? []) + ) + + return products + } + + /** + * Temporary method to fetch sales channels of a product using RemoteQuery while + * MedusaV2 FF is on. + * + * @param productIds + * @private + */ + private async getSalesChannelModuleChannels( + productIds: string[] + ): Promise> { + const query = { + product: { + __args: { filters: { id: productIds } }, + fields: ["id"], + sales_channels: { + fields: [ + "id", + "name", + "description", + "is_disabled", + "created_at", + "updated_at", + "deleted_at", + ], + }, + }, + } + + const ret = {} + const data = (await this.remoteQuery_(query)) as { + id: string + sales_channels: SalesChannel[] + }[] + data.forEach((record) => (ret[record.id] = record.sales_channels)) + + return ret + } } export default ProductService diff --git a/packages/medusa/src/services/sales-channel.ts b/packages/medusa/src/services/sales-channel.ts index 41e7c4d4f3..7087fa0c3f 100644 --- a/packages/medusa/src/services/sales-channel.ts +++ b/packages/medusa/src/services/sales-channel.ts @@ -1,11 +1,13 @@ +import { EntityManager } from "typeorm" +import { isDefined, MedusaError } from "medusa-core-utils" +import { FlagRouter, MedusaV2Flag } from "@medusajs/utils" + import { FindConfig, QuerySelector, Selector } from "../types/common" import { CreateSalesChannelInput, UpdateSalesChannelInput, } from "../types/sales-channels" -import { isDefined, MedusaError } from "medusa-core-utils" -import { EntityManager } from "typeorm" import { TransactionBaseService } from "../interfaces" import { SalesChannel } from "../models" import { SalesChannelRepository } from "../repositories/sales-channel" @@ -19,6 +21,7 @@ type InjectedDependencies = { eventBusService: EventBusService manager: EntityManager storeService: StoreService + featureFlagRouter: FlagRouter } class SalesChannelService extends TransactionBaseService { @@ -31,11 +34,13 @@ class SalesChannelService extends TransactionBaseService { protected readonly salesChannelRepository_: typeof SalesChannelRepository protected readonly eventBusService_: EventBusService protected readonly storeService_: StoreService + protected readonly featureFlagRouter_: FlagRouter constructor({ salesChannelRepository, eventBusService, storeService, + featureFlagRouter, }: InjectedDependencies) { // eslint-disable-next-line prefer-rest-params super(arguments[0]) @@ -43,6 +48,7 @@ class SalesChannelService extends TransactionBaseService { this.salesChannelRepository_ = salesChannelRepository this.eventBusService_ = eventBusService this.storeService_ = storeService + this.featureFlagRouter_ = featureFlagRouter } /** @@ -124,8 +130,9 @@ class SalesChannelService extends TransactionBaseService { } /** - * Lists sales channels based on the provided parameters and includes the count of + * Lists sales channels based on the provided parameters and include the count of * sales channels that match the query. + * * @return an array containing the sales channels as * the first element and the total count of sales channels that matches the query * as the second element. @@ -157,6 +164,38 @@ class SalesChannelService extends TransactionBaseService { return await salesChannelRepo.findAndCount(query) } + /** + * Lists sales channels based on the provided parameters. + * + * @return an array containing the sales channels + */ + async list( + selector: QuerySelector, + config: FindConfig = { + skip: 0, + take: 20, + } + ): Promise { + const salesChannelRepo = this.activeManager_.withRepository( + this.salesChannelRepository_ + ) + + const selector_ = { ...selector } + let q: string | undefined + if ("q" in selector_) { + q = selector_.q + delete selector_.q + } + + const query = buildQuery(selector_, config) + + if (q) { + return await salesChannelRepo.getFreeTextSearchResults(q, query) + } + + return await salesChannelRepo.find(query) + } + /** * Creates a SalesChannel * @@ -353,7 +392,15 @@ class SalesChannelService extends TransactionBaseService { this.salesChannelRepository_ ) - await salesChannelRepo.addProducts(salesChannelId, productIds) + const isMedusaV2Enabled = this.featureFlagRouter_.isFeatureEnabled( + MedusaV2Flag.key + ) + + await salesChannelRepo.addProducts( + salesChannelId, + productIds, + isMedusaV2Enabled + ) return await this.retrieve(salesChannelId) }) diff --git a/packages/medusa/src/utils/generate-entity-id.ts b/packages/medusa/src/utils/generate-entity-id.ts index c85f891ba8..4f4f61766e 100644 --- a/packages/medusa/src/utils/generate-entity-id.ts +++ b/packages/medusa/src/utils/generate-entity-id.ts @@ -5,7 +5,7 @@ import { ulid } from "ulid" * @param idProperty * @param prefix */ -export function generateEntityId(idProperty: string, prefix?: string): string { +export function generateEntityId(idProperty?: string, prefix?: string): string { if (idProperty) { return idProperty } diff --git a/packages/medusa/src/utils/queries/products/list-products.ts b/packages/medusa/src/utils/queries/products/list-products.ts index 5bc2402b1b..117fe0dde4 100644 --- a/packages/medusa/src/utils/queries/products/list-products.ts +++ b/packages/medusa/src/utils/queries/products/list-products.ts @@ -1,7 +1,7 @@ import { MedusaContainer } from "@medusajs/types" import { MedusaV2Flag, promiseAll } from "@medusajs/utils" -import { PriceListService, SalesChannelService } from "../../../services" +import { PriceListService } from "../../../services" import { getVariantsFromPriceList } from "./get-variants-from-price-list" export async function listProducts( @@ -23,35 +23,6 @@ export async function listProducts( const salesChannelIdFilter = filterableFields.sales_channel_id delete filterableFields.sales_channel_id - if (salesChannelIdFilter) { - const salesChannelService = container.resolve( - "salesChannelService" - ) as SalesChannelService - - promises.push( - salesChannelService - .listProductIdsBySalesChannelIds(salesChannelIdFilter) - .then((productIdsInSalesChannel) => { - let filteredProductIds = - productIdsInSalesChannel[salesChannelIdFilter] - - if (filterableFields.id) { - filterableFields.id = Array.isArray(filterableFields.id) - ? filterableFields.id - : [filterableFields.id] - - const salesChannelProductIdsSet = new Set(filteredProductIds) - - filteredProductIds = filterableFields.id.filter((productId) => - salesChannelProductIdsSet.has(productId) - ) - } - - filteredProductIds.map((id) => productIdsFilter.add(id)) - }) - ) - } - const priceListId = filterableFields.price_list_id delete filterableFields.price_list_id @@ -112,6 +83,10 @@ export async function listProducts( }, } + if (salesChannelIdFilter) { + query.product["sales_channels"]["__args"] = { id: salesChannelIdFilter } + } + const { rows: products, metadata: { count }, @@ -245,4 +220,16 @@ export const defaultAdminProductRemoteQueryObject = { profile: { fields: ["id", "created_at", "updated_at", "deleted_at", "name", "type"], }, + sales_channels: { + fields: [ + "id", + "name", + "description", + "is_disabled", + "created_at", + "updated_at", + "deleted_at", + "metadata", + ], + }, } diff --git a/packages/utils/src/common/generate-entity-id.ts b/packages/utils/src/common/generate-entity-id.ts index c85f891ba8..4f4f61766e 100644 --- a/packages/utils/src/common/generate-entity-id.ts +++ b/packages/utils/src/common/generate-entity-id.ts @@ -5,7 +5,7 @@ import { ulid } from "ulid" * @param idProperty * @param prefix */ -export function generateEntityId(idProperty: string, prefix?: string): string { +export function generateEntityId(idProperty?: string, prefix?: string): string { if (idProperty) { return idProperty }