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 <rmthamir@gmail.com>
This commit is contained in:
Frane Polić
2023-12-15 13:43:00 +01:00
committed by GitHub
parent 11b1a61969
commit 1d7888afca
24 changed files with 603 additions and 88 deletions

View File

@@ -0,0 +1,6 @@
---
"@medusajs/link-modules": patch
"@medusajs/medusa": patch
---
feat(medusa, link-module): SalesChannel<>Product joiner config

View File

@@ -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<ProductVariantFactoryData, "product_id">[]
sales_channels?: SalesChannelFactoryData[]
metadata?: Record<string, unknown>
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) {

View File

@@ -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" }),
],
})
)
})

View File

@@ -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<HandlerInput>): Promise<void> {
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 = {

View File

@@ -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<HandlerInput>): Promise<void> {
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<unknown>[] = []
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 = {

View File

@@ -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"

View File

@@ -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,
},
},
],
}

View File

@@ -22,4 +22,10 @@ export const LINKS = {
"shippingProfileService",
"profile_id"
),
ProductSalesChannel: composeLinkName(
Modules.PRODUCT,
"product_id",
"salesChannelService",
"sales_channel_id"
),
}

View File

@@ -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)
)
)
}

View File

@@ -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",
],
},
}
/**

View File

@@ -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"

View File

@@ -399,6 +399,10 @@ async function listAndCountProductWithIsolatedProductModule(
},
}
if (salesChannelIdFilter) {
query.product["sales_channels"]["__args"] = { id: salesChannelIdFilter }
}
const {
rows: products,
metadata: { count },

View File

@@ -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"

View File

@@ -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

View File

@@ -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<void> {
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<void> {
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);
`)
}
}

View File

@@ -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")
}
}

View File

@@ -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<string, unknown> | 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,

View File

@@ -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<SalesChannel> = {
options: ExtendedFindConfig<SalesChannel> & { withCount?: boolean } = {
where: {},
}
): Promise<[SalesChannel[], number]> {
): Promise<SalesChannel[] | [SalesChannel[], number]> {
const options_ = { ...options }
options_.where = options_.where as FindOptionsWhere<SalesChannel>
@@ -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<SalesChannel> = {
where: {},
}
): Promise<[SalesChannel[], number]> {
return (await this.getFreeTextSearchResults_(q, {
...options,
withCount: true,
})) as [SalesChannel[], number]
},
async getFreeTextSearchResults(
q: string,
options: ExtendedFindConfig<SalesChannel> = {
where: {},
}
): Promise<SalesChannel[]> {
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<void> {
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()

View File

@@ -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({

View File

@@ -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<Record<string, SalesChannel[]>> {
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

View File

@@ -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<SalesChannel>,
config: FindConfig<SalesChannel> = {
skip: 0,
take: 20,
}
): Promise<SalesChannel[]> {
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)
})

View File

@@ -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
}

View File

@@ -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",
],
},
}

View File

@@ -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
}