diff --git a/integration-tests/api/__tests__/admin/sales-channels.js b/integration-tests/api/__tests__/admin/sales-channels.js index 77075fec99..7ffebc6ace 100644 --- a/integration-tests/api/__tests__/admin/sales-channels.js +++ b/integration-tests/api/__tests__/admin/sales-channels.js @@ -895,5 +895,223 @@ describe("sales channels", () => { ) }) }) + + describe("POST /admin/products", () => { + let salesChannel + + beforeEach(async () => { + try { + await productSeeder(dbConnection) + await adminSeeder(dbConnection) + salesChannel = await simpleSalesChannelFactory(dbConnection, { + name: "test name", + description: "test description", + }) + } catch (err) { + console.log(err) + throw err + } + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + }) + + it("should creates a product that is assigned to a sales_channel", async () => { + const api = useApi() + + const payload = { + title: "Test", + description: "test-product-description", + type: { value: "test-type" }, + options: [{ title: "size" }, { title: "color" }], + variants: [ + { + title: "Test variant", + inventory_quantity: 10, + prices: [{ currency_code: "usd", amount: 100 }], + options: [{ value: "large" }, { value: "green" }], + }, + ], + sales_channels: [{ id: salesChannel.id }], + } + + const response = await api + .post("/admin/products", payload, { + headers: { + Authorization: "Bearer test_token", + }, + }) + .catch((err) => { + console.log(err) + }) + + expect(response.status).toEqual(200) + expect(response.data.product).toEqual( + expect.objectContaining({ + sales_channels: [ + expect.objectContaining({ + id: salesChannel.id, + name: salesChannel.name, + }), + ], + }) + ) + }) + }) + + describe("POST /admin/products/:id", () => { + let salesChannel + + beforeEach(async () => { + try { + await productSeeder(dbConnection) + await adminSeeder(dbConnection) + salesChannel = await simpleSalesChannelFactory(dbConnection, { + name: "test name", + description: "test description", + }) + } catch (err) { + console.log(err) + throw err + } + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + }) + + it("should update a product sales channels assignation with either a sales channel, null, [] or undefined", async () => { + const api = useApi() + + let response = await api + .post( + "/admin/products/test-product", + { + sales_channels: null, + }, + { + headers: { + Authorization: "Bearer test_token", + }, + } + ) + .catch((err) => { + console.log(err) + }) + + expect(response.status).toEqual(200) + expect(response.data.product).toEqual( + expect.objectContaining({ + sales_channels: [], + }) + ) + + response = await api + .post( + "/admin/products/test-product", + { + sales_channels: [{ id: salesChannel.id }], + }, + { + headers: { + Authorization: "Bearer test_token", + }, + } + ) + .catch((err) => { + console.log(err) + }) + + expect(response.status).toEqual(200) + expect(response.data.product).toEqual( + expect.objectContaining({ + sales_channels: [ + expect.objectContaining({ + id: salesChannel.id, + name: salesChannel.name, + }), + ], + }) + ) + + response = await api + .post( + "/admin/products/test-product", + {}, + { + headers: { + Authorization: "Bearer test_token", + }, + } + ) + .catch((err) => { + console.log(err) + }) + + expect(response.status).toEqual(200) + expect(response.data.product).toEqual( + expect.objectContaining({ + sales_channels: [ + expect.objectContaining({ + id: salesChannel.id, + name: salesChannel.name, + }), + ], + }) + ) + + response = await api + .post( + "/admin/products/test-product", + { + sales_channels: [], + }, + { + headers: { + Authorization: "Bearer test_token", + }, + } + ) + .catch((err) => { + console.log(err) + }) + + expect(response.status).toEqual(200) + expect(response.data.product).toEqual( + expect.objectContaining({ + sales_channels: [], + }) + ) + }) + + it("should throw on update if the sales channels does not exists", async () => { + const api = useApi() + + const err = await api + .post( + "/admin/products/test-product", + { + sales_channels: [{ id: "fake_id" }, { id: "fake_id_2" }], + }, + { + headers: { + Authorization: "Bearer test_token", + }, + } + ) + .catch((err) => err) + + expect(err.response.status).toEqual(400) + expect(err.response.data.message).toBe( + "Provided request body contains errors. Please check the data and retry the request" + ) + expect(err.response.data.errors).toEqual([ + "Sales Channels fake_id, fake_id_2 do not exist", + ]) + }) + }) }) }) diff --git a/packages/medusa/src/api/middlewares/await-middleware.ts b/packages/medusa/src/api/middlewares/await-middleware.ts index b47f9f5246..f9e02e8a6c 100644 --- a/packages/medusa/src/api/middlewares/await-middleware.ts +++ b/packages/medusa/src/api/middlewares/await-middleware.ts @@ -4,6 +4,14 @@ type handler = (req: Request, res: Response) => Promise export default (fn: handler): RequestHandler => { return (req: Request, res: Response, next: NextFunction) => { + if (req?.errors?.length) { + return res.status(400).json({ + errors: req.errors, + message: + "Provided request body contains errors. Please check the data and retry the request", + }) + } + return fn(req, res).catch(next) } } diff --git a/packages/medusa/src/api/middlewares/validators/product-existence.ts b/packages/medusa/src/api/middlewares/validators/product-existence.ts new file mode 100644 index 0000000000..8e5a049545 --- /dev/null +++ b/packages/medusa/src/api/middlewares/validators/product-existence.ts @@ -0,0 +1,33 @@ +import { NextFunction, Request, Response } from "express" +import { ProductService } from "../../../services" +import { ProductBatchSalesChannel } from "../../../types/sales-channels" + +export function validateProductsExist( + getProducts: (req) => ProductBatchSalesChannel[] | undefined +): (req: Request, res: Response, next: NextFunction) => Promise { + return async (req: Request, res: Response, next: NextFunction) => { + const products = getProducts(req) + + if (!products?.length) { + return next() + } + + const productService: ProductService = req.scope.resolve("productService") + + const productIds = products.map((product) => product.id) + const [existingProducts] = await productService.listAndCount({ + id: productIds, + }) + + const nonExistingProducts = productIds.filter( + (scId) => existingProducts.findIndex((sc) => sc.id === scId) === -1 + ) + + if (nonExistingProducts.length) { + req.errors = req.errors ?? [] + req.errors.push(`Products ${nonExistingProducts.join(", ")} do not exist`) + } + + return next() + } +} diff --git a/packages/medusa/src/api/middlewares/validators/sales-channel-existence.ts b/packages/medusa/src/api/middlewares/validators/sales-channel-existence.ts new file mode 100644 index 0000000000..1e4c7cb7f4 --- /dev/null +++ b/packages/medusa/src/api/middlewares/validators/sales-channel-existence.ts @@ -0,0 +1,37 @@ +import { NextFunction, Request, Response } from "express" +import { SalesChannelService } from "../../../services" +import { ProductSalesChannelReq } from "../../../types/product" + +export function validateSalesChannelsExist( + getSalesChannels: (req) => ProductSalesChannelReq[] | undefined +): (req: Request, res: Response, next: NextFunction) => Promise { + return async (req: Request, res: Response, next: NextFunction) => { + const salesChannels = getSalesChannels(req) + + if (!salesChannels?.length) { + return next() + } + + const salesChannelService: SalesChannelService = req.scope.resolve( + "salesChannelService" + ) + + const salesChannelIds = salesChannels.map((salesChannel) => salesChannel.id) + const [existingChannels] = await salesChannelService.listAndCount({ + id: salesChannelIds, + }) + + const nonExistingSalesChannels = salesChannelIds.filter( + (scId) => existingChannels.findIndex((sc) => sc.id === scId) === -1 + ) + + if (nonExistingSalesChannels.length) { + req.errors = req.errors ?? [] + req.errors.push( + `Sales Channels ${nonExistingSalesChannels.join(", ")} do not exist` + ) + } + + return next() + } +} diff --git a/packages/medusa/src/api/routes/admin/products/create-product.ts b/packages/medusa/src/api/routes/admin/products/create-product.ts index 48475ce267..d303d1c758 100644 --- a/packages/medusa/src/api/routes/admin/products/create-product.ts +++ b/packages/medusa/src/api/routes/admin/products/create-product.ts @@ -11,14 +11,21 @@ import { } from "class-validator" import { EntityManager } from "typeorm" import { defaultAdminProductFields, defaultAdminProductRelations } from "." +import SalesChannelFeatureFlag from "../../../../loaders/feature-flags/sales-channels" +import { ProductStatus } from "../../../../models" import { - ProductService, PricingService, + ProductService, ProductVariantService, ShippingProfileService, } from "../../../../services" -import { ProductStatus } from "../../../../models" +import { + ProductSalesChannelReq, + ProductTagReq, + ProductTypeReq, +} from "../../../../types/product" import { ProductVariantPricesCreateReq } from "../../../../types/product-variant" +import { FeatureFlagDecorators } from "../../../../utils/feature-flag-decorators" import { validator } from "../../../../utils/validator" /** @@ -85,6 +92,14 @@ import { validator } from "../../../../utils/validator" * value: * description: The value of the Tag, these will be upserted. * type: string + * sales_channels: + * description: [EXPERIMENTAL] Sales channels to associate the Product with. + * type: array + * items: + * properties: + * id: + * description: The id of an existing Sales channel. + * type: string * options: * description: The Options that the Product should have. These define on which properties the Product's Product Variants will differ. * type: array @@ -282,24 +297,6 @@ export default async (req, res) => { res.json({ product }) } -class ProductTypeReq { - @IsString() - @IsOptional() - id?: string - - @IsString() - value: string -} - -class ProductTagReq { - @IsString() - @IsOptional() - id?: string - - @IsString() - value: string -} - class ProductVariantOptionReq { @IsString() value: string @@ -439,6 +436,14 @@ export class AdminPostProductsReq { @IsArray() tags?: ProductTagReq[] + @FeatureFlagDecorators(SalesChannelFeatureFlag.key, [ + IsOptional(), + Type(() => ProductSalesChannelReq), + ValidateNested({ each: true }), + IsArray(), + ]) + sales_channels?: ProductSalesChannelReq[] + @IsOptional() @Type(() => ProductOptionReq) @ValidateNested({ each: true }) diff --git a/packages/medusa/src/api/routes/admin/products/index.ts b/packages/medusa/src/api/routes/admin/products/index.ts index ffc1072bf3..a0d3d6f34c 100644 --- a/packages/medusa/src/api/routes/admin/products/index.ts +++ b/packages/medusa/src/api/routes/admin/products/index.ts @@ -1,24 +1,32 @@ import { Router } from "express" import "reflect-metadata" -import { PricedProduct } from "../../../../types/pricing" import { Product, ProductTag, ProductType } from "../../../.." import { EmptyQueryParams, PaginatedResponse } from "../../../../types/common" -import middlewares, { transformQuery } from "../../../middlewares" -import { AdminGetProductsParams } from "./list-products" +import { PricedProduct } from "../../../../types/pricing" import { FlagRouter } from "../../../../utils/flag-router" +import middlewares, { transformQuery } from "../../../middlewares" +import { validateSalesChannelsExist } from "../../../middlewares/validators/sales-channel-existence" +import { AdminGetProductsParams } from "./list-products" const route = Router() export default (app, featureFlagRouter: FlagRouter) => { app.use("/products", route) - const relations = [...defaultAdminProductRelations] if (featureFlagRouter.isFeatureEnabled("sales_channels")) { - relations.push("sales_channels") + defaultAdminProductRelations.push("sales_channels") } - route.post("/", middlewares.wrap(require("./create-product").default)) - route.post("/:id", middlewares.wrap(require("./update-product").default)) + route.post( + "/", + validateSalesChannelsExist((req) => req.body?.sales_channels), + middlewares.wrap(require("./create-product").default) + ) + route.post( + "/:id", + validateSalesChannelsExist((req) => req.body?.sales_channels), + middlewares.wrap(require("./update-product").default) + ) route.get("/types", middlewares.wrap(require("./list-types").default)) route.get( "/tag-usage", @@ -63,7 +71,7 @@ export default (app, featureFlagRouter: FlagRouter) => { route.get( "/:id", transformQuery(EmptyQueryParams, { - defaultRelations: relations, + defaultRelations: defaultAdminProductRelations, defaultFields: defaultAdminProductFields, allowedFields: allowedAdminProductFields, isList: false, @@ -208,10 +216,10 @@ export * from "./delete-option" export * from "./delete-product" export * from "./delete-variant" export * from "./get-product" -export * from "./list-variants" export * from "./list-products" export * from "./list-tag-usage-count" export * from "./list-types" +export * from "./list-variants" export * from "./set-metadata" export * from "./update-option" export * from "./update-product" diff --git a/packages/medusa/src/api/routes/admin/products/update-product.ts b/packages/medusa/src/api/routes/admin/products/update-product.ts index d5935c2f3f..4179dd55c6 100644 --- a/packages/medusa/src/api/routes/admin/products/update-product.ts +++ b/packages/medusa/src/api/routes/admin/products/update-product.ts @@ -13,9 +13,16 @@ import { ValidateNested, } from "class-validator" import { defaultAdminProductFields, defaultAdminProductRelations } from "." +import SalesChannelFeatureFlag from "../../../../loaders/feature-flags/sales-channels" import { ProductStatus } from "../../../../models" -import { ProductService, PricingService } from "../../../../services" +import { PricingService, ProductService } from "../../../../services" +import { + ProductSalesChannelReq, + ProductTagReq, + ProductTypeReq, +} from "../../../../types/product" import { ProductVariantPricesUpdateReq } from "../../../../types/product-variant" +import { FeatureFlagDecorators } from "../../../../utils/feature-flag-decorators" import { validator } from "../../../../utils/validator" /** @@ -78,6 +85,14 @@ import { validator } from "../../../../utils/validator" * value: * description: The value of the Tag, these will be upserted. * type: string + * sales_channels: + * description: [EXPERIMENTAL] Sales channels to associate the Product with. + * type: array + * items: + * properties: + * id: + * description: The id of an existing Sales channel. + * type: string * options: * description: The Options that the Product should have. These define on which properties the Product's Product Variants will differ. * type: array @@ -220,24 +235,6 @@ export default async (req, res) => { res.json({ product }) } -class ProductTypeReq { - @IsString() - @IsOptional() - id?: string - - @IsString() - value: string -} - -class ProductTagReq { - @IsString() - @IsOptional() - id?: string - - @IsString() - value: string -} - class ProductVariantOptionReq { @IsString() value: string @@ -381,6 +378,14 @@ export class AdminPostProductsProductReq { @IsArray() tags?: ProductTagReq[] + @FeatureFlagDecorators(SalesChannelFeatureFlag.key, [ + IsOptional(), + Type(() => ProductSalesChannelReq), + ValidateNested({ each: true }), + IsArray(), + ]) + sales_channels: ProductSalesChannelReq[] | null + @IsOptional() @Type(() => ProductVariantReq) @ValidateNested({ each: true }) diff --git a/packages/medusa/src/api/routes/admin/sales-channels/__tests__/add-product-batch.js b/packages/medusa/src/api/routes/admin/sales-channels/__tests__/add-product-batch.ts similarity index 82% rename from packages/medusa/src/api/routes/admin/sales-channels/__tests__/add-product-batch.js rename to packages/medusa/src/api/routes/admin/sales-channels/__tests__/add-product-batch.ts index 39e2c6420b..457f54efc5 100644 --- a/packages/medusa/src/api/routes/admin/sales-channels/__tests__/add-product-batch.js +++ b/packages/medusa/src/api/routes/admin/sales-channels/__tests__/add-product-batch.ts @@ -9,7 +9,9 @@ describe("POST /admin/sales-channels/:id/products/batch", () => { beforeAll(async () => { subject = await request( "POST", - `/admin/sales-channels/${IdMap.getId("sales_channel_1")}/products/batch`, + `/admin/sales-channels/${IdMap.getId( + "sales_channel_1" + )}/products/batch`, { adminSession: { jwt: { @@ -17,7 +19,7 @@ describe("POST /admin/sales-channels/:id/products/batch", () => { }, }, payload: { - product_ids: [{ id: IdMap.getId("sales_channel_1_product_1") }] + product_ids: [{ id: "sales_channel_1_product_1" }], }, flags: ["sales_channels"], } @@ -32,7 +34,7 @@ describe("POST /admin/sales-channels/:id/products/batch", () => { expect(SalesChannelServiceMock.addProducts).toHaveBeenCalledTimes(1) expect(SalesChannelServiceMock.addProducts).toHaveBeenCalledWith( IdMap.getId("sales_channel_1"), - [IdMap.getId("sales_channel_1_product_1")] + ["sales_channel_1_product_1"] ) }) }) diff --git a/packages/medusa/src/api/routes/admin/sales-channels/index.ts b/packages/medusa/src/api/routes/admin/sales-channels/index.ts index d25bb3de04..100837ec06 100644 --- a/packages/medusa/src/api/routes/admin/sales-channels/index.ts +++ b/packages/medusa/src/api/routes/admin/sales-channels/index.ts @@ -1,17 +1,18 @@ import { Router } from "express" -import { DeleteResponse, PaginatedResponse } from "../../../../types/common" import "reflect-metadata" -import { isFeatureFlagEnabled } from "../../../middlewares/feature-flag-enabled" import { SalesChannel } from "../../../../models" +import { DeleteResponse, PaginatedResponse } from "../../../../types/common" import middlewares, { transformBody, transformQuery, } from "../../../middlewares" -import { AdminPostSalesChannelsSalesChannelReq } from "./update-sales-channel" -import { AdminPostSalesChannelsReq } from "./create-sales-channel" -import { AdminGetSalesChannelsParams } from "./list-sales-channels" -import { AdminDeleteSalesChannelsChannelProductsBatchReq } from "./delete-products-batch" +import { isFeatureFlagEnabled } from "../../../middlewares/feature-flag-enabled" +import { validateProductsExist } from "../../../middlewares/validators/product-existence" import { AdminPostSalesChannelsChannelProductsBatchReq } from "./add-product-batch" +import { AdminPostSalesChannelsReq } from "./create-sales-channel" +import { AdminDeleteSalesChannelsChannelProductsBatchReq } from "./delete-products-batch" +import { AdminGetSalesChannelsParams } from "./list-sales-channels" +import { AdminPostSalesChannelsSalesChannelReq } from "./update-sales-channel" const route = Router() @@ -55,6 +56,7 @@ export default (app) => { salesChannelRouter.post( "/products/batch", transformBody(AdminPostSalesChannelsChannelProductsBatchReq), + validateProductsExist((req) => req.body.product_ids), middlewares.wrap(require("./add-product-batch").default) ) @@ -77,10 +79,10 @@ export type AdminSalesChannelsListRes = PaginatedResponse & { sales_channels: SalesChannel[] } -export * from "./get-sales-channel" +export * from "./add-product-batch" export * from "./create-sales-channel" +export * from "./delete-products-batch" +export * from "./delete-sales-channel" +export * from "./get-sales-channel" export * from "./list-sales-channels" export * from "./update-sales-channel" -export * from "./delete-sales-channel" -export * from "./delete-products-batch" -export * from "./add-product-batch" diff --git a/packages/medusa/src/repositories/product.ts b/packages/medusa/src/repositories/product.ts index 81a313e249..fe5a64eda8 100644 --- a/packages/medusa/src/repositories/product.ts +++ b/packages/medusa/src/repositories/product.ts @@ -6,14 +6,15 @@ import { In, Repository, } from "typeorm" -import { PriceList } from "../models/price-list" -import { Product } from "../models/product" +import { PriceList, + Product, + SalesChannel +} from "../models" import { ExtendedFindConfig, Selector, WithRequiredProperty, } from "../types/common" -import { SalesChannel } from "../models"; export type ProductSelector = Omit, "tags"> & { tags: FindOperator diff --git a/packages/medusa/src/repositories/sales-channel.ts b/packages/medusa/src/repositories/sales-channel.ts index 5a4bd77063..49a262f269 100644 --- a/packages/medusa/src/repositories/sales-channel.ts +++ b/packages/medusa/src/repositories/sales-channel.ts @@ -6,11 +6,11 @@ import { Repository, } from "typeorm" import { SalesChannel } from "../models" -import { ExtendedFindConfig, Selector } from "../types/common" +import { ExtendedFindConfig, Selector } from "../types/common"; @EntityRepository(SalesChannel) export class SalesChannelRepository extends Repository { - public async getFreeTextSearchResultsAndCount( + public async getFreeTextSearchResultsAndCount( q: string, options: ExtendedFindConfig> = { where: {}, diff --git a/packages/medusa/src/services/__mocks__/product.js b/packages/medusa/src/services/__mocks__/product.js index 2473147a75..59bae5d3fc 100644 --- a/packages/medusa/src/services/__mocks__/product.js +++ b/packages/medusa/src/services/__mocks__/product.js @@ -63,7 +63,7 @@ export const products = { } export const ProductServiceMock = { - withTransaction: function () { + withTransaction: function() { return this }, create: jest.fn().mockImplementation((data) => { @@ -106,18 +106,16 @@ export const ProductServiceMock = { deleteOption: jest .fn() .mockReturnValue(Promise.resolve(products.productWithOptions)), - retrieveVariants: jest - .fn() - .mockImplementation((productId) => { - if (productId === IdMap.getId("product1")) { - return Promise.resolve([ - { id: IdMap.getId("1"), product_id: IdMap.getId("product1") }, - { id: IdMap.getId("2"), product_id: IdMap.getId("product1") } - ]) - } + retrieveVariants: jest.fn().mockImplementation((productId) => { + if (productId === IdMap.getId("product1")) { + return Promise.resolve([ + { id: IdMap.getId("1"), product_id: IdMap.getId("product1") }, + { id: IdMap.getId("2"), product_id: IdMap.getId("product1") }, + ]) + } - return [] - }), + return [] + }), retrieve: jest.fn().mockImplementation((productId) => { if (productId === IdMap.getId("product1")) { return Promise.resolve(products.product1) @@ -143,6 +141,9 @@ export const ProductServiceMock = { return Promise.resolve(products.product1) }), listAndCount: jest.fn().mockImplementation((data) => { + if (data?.id?.includes("sales_channel_1_product_1")) { + return Promise.resolve([[{ id: "sales_channel_1_product_1" }], 1]) + } return Promise.resolve([[products.product1, products.product2], 2]) }), list: jest.fn().mockImplementation((data) => { diff --git a/packages/medusa/src/services/__tests__/product.js b/packages/medusa/src/services/__tests__/product.js index 18049d646a..c4777f6fac 100644 --- a/packages/medusa/src/services/__tests__/product.js +++ b/packages/medusa/src/services/__tests__/product.js @@ -1,5 +1,6 @@ import { IdMap, MockRepository, MockManager } from "medusa-test-utils" import ProductService from "../product" +import { FlagRouter } from "../../utils/flag-router"; const eventBusService = { emit: jest.fn(), @@ -51,6 +52,7 @@ describe("ProductService", () => { const productService = new ProductService({ manager: MockManager, productRepository: productRepo, + featureFlagRouter: new FlagRouter({}), }) beforeEach(async () => { @@ -122,6 +124,7 @@ describe("ProductService", () => { productCollectionService, productTagRepository, productTypeRepository, + featureFlagRouter: new FlagRouter({}), }) beforeEach(() => { @@ -299,6 +302,7 @@ describe("ProductService", () => { eventBusService, cartRepository, priceSelectionStrategy, + featureFlagRouter: new FlagRouter({}), }) beforeEach(() => { @@ -455,7 +459,7 @@ describe("ProductService", () => { manager: MockManager, eventBusService, productRepository, - eventBusService, + featureFlagRouter: new FlagRouter({}), }) beforeEach(() => { @@ -504,6 +508,7 @@ describe("ProductService", () => { productOptionRepository, productVariantService, eventBusService, + featureFlagRouter: new FlagRouter({}), }) beforeEach(() => { @@ -562,6 +567,7 @@ describe("ProductService", () => { manager: MockManager, productRepository, eventBusService, + featureFlagRouter: new FlagRouter({}), }) beforeEach(() => { @@ -629,6 +635,7 @@ describe("ProductService", () => { productRepository, productOptionRepository, eventBusService, + featureFlagRouter: new FlagRouter({}), }) beforeEach(() => { @@ -736,6 +743,7 @@ describe("ProductService", () => { productRepository, productOptionRepository, eventBusService, + featureFlagRouter: new FlagRouter({}), }) beforeEach(() => { diff --git a/packages/medusa/src/services/__tests__/sales-channel.ts b/packages/medusa/src/services/__tests__/sales-channel.ts index 8832bf4ef9..d5886b216c 100644 --- a/packages/medusa/src/services/__tests__/sales-channel.ts +++ b/packages/medusa/src/services/__tests__/sales-channel.ts @@ -74,7 +74,6 @@ describe("SalesChannelService", () => { eventBusService: EventBusServiceMock as unknown as EventBusService, salesChannelRepository: salesChannelRepositoryMock, storeService: StoreServiceMock as unknown as StoreService, - productService: ProductServiceMock as unknown as ProductService, }) beforeEach(() => { @@ -97,7 +96,6 @@ describe("SalesChannelService", () => { manager: MockManager, eventBusService: EventBusServiceMock as unknown as EventBusService, salesChannelRepository: salesChannelRepositoryMock, - productService: ProductServiceMock as unknown as ProductService, storeService: { ...StoreServiceMock, retrieve: jest.fn().mockImplementation(() => { @@ -130,7 +128,6 @@ describe("SalesChannelService", () => { eventBusService: EventBusServiceMock as unknown as EventBusService, salesChannelRepository: salesChannelRepositoryMock, storeService: StoreServiceMock as unknown as StoreService, - productService: ProductServiceMock as unknown as ProductService, }) beforeEach(() => { @@ -161,7 +158,6 @@ describe("SalesChannelService", () => { eventBusService: EventBusServiceMock as unknown as EventBusService, salesChannelRepository: salesChannelRepositoryMock, storeService: StoreServiceMock as unknown as StoreService, - productService: ProductServiceMock as unknown as ProductService, }) const update = { @@ -197,7 +193,6 @@ describe("SalesChannelService", () => { eventBusService: EventBusServiceMock as unknown as EventBusService, salesChannelRepository: salesChannelRepositoryMock, storeService: StoreServiceMock as unknown as StoreService, - productService: ProductServiceMock as unknown as ProductService, }) afterEach(() => { @@ -261,7 +256,6 @@ describe("SalesChannelService", () => { manager: MockManager, eventBusService: EventBusServiceMock as unknown as EventBusService, salesChannelRepository: salesChannelRepositoryMock, - productService: ProductServiceMock as unknown as ProductService, storeService: { ...StoreServiceMock, retrieve: jest.fn().mockImplementation(() => { @@ -317,7 +311,6 @@ describe("SalesChannelService", () => { manager: MockManager, eventBusService: EventBusServiceMock as unknown as EventBusService, salesChannelRepository: salesChannelRepositoryMock, - productService: ProductServiceMock as unknown as ProductService, storeService: StoreServiceMock as unknown as StoreService, }) @@ -350,7 +343,6 @@ describe("SalesChannelService", () => { eventBusService: EventBusServiceMock as unknown as EventBusService, salesChannelRepository: salesChannelRepositoryMock, storeService: StoreServiceMock as unknown as StoreService, - productService: ProductServiceMock as unknown as ProductService, }) beforeEach(() => { diff --git a/packages/medusa/src/services/product.ts b/packages/medusa/src/services/product.ts index 1674632c20..0faa176684 100644 --- a/packages/medusa/src/services/product.ts +++ b/packages/medusa/src/services/product.ts @@ -1,7 +1,10 @@ +import { FlagRouter } from "../utils/flag-router" + import { MedusaError } from "medusa-core-utils" import { EntityManager } from "typeorm" -import { SearchService } from "." +import { ProductVariantService, SearchService } from "." import { TransactionBaseService } from "../interfaces" +import SalesChannelFeatureFlag from "../loaders/feature-flags/sales-channels" import { Product, ProductTag, @@ -29,7 +32,6 @@ import { import { buildQuery, setMetadata } from "../utils" import { formatException } from "../utils/exception-formatter" import EventBusService from "./event-bus" -import ProductVariantService from "./product-variant" type InjectedDependencies = { manager: EntityManager @@ -42,9 +44,13 @@ type InjectedDependencies = { productVariantService: ProductVariantService searchService: SearchService eventBusService: EventBusService + featureFlagRouter: FlagRouter } -class ProductService extends TransactionBaseService { +class ProductService extends TransactionBaseService< + ProductService, + InjectedDependencies +> { protected manager_: EntityManager protected transactionManager_: EntityManager | undefined @@ -57,6 +63,7 @@ class ProductService extends TransactionBaseService { protected readonly productVariantService_: ProductVariantService protected readonly searchService_: SearchService protected readonly eventBus_: EventBusService + protected readonly featureFlagRouter_: FlagRouter static readonly IndexName = `products` static readonly Events = { @@ -67,28 +74,19 @@ class ProductService extends TransactionBaseService { constructor({ manager, + productOptionRepository, productRepository, productVariantRepository, - productOptionRepository, eventBusService, productVariantService, productTypeRepository, productTagRepository, imageRepository, searchService, + featureFlagRouter, }: InjectedDependencies) { - super({ - manager, - productRepository, - productVariantRepository, - productOptionRepository, - eventBusService, - productVariantService, - productTypeRepository, - productTagRepository, - imageRepository, - searchService, - }) + // eslint-disable-next-line prefer-rest-params + super(arguments[0]) this.manager_ = manager this.productOptionRepository_ = productOptionRepository @@ -100,6 +98,7 @@ class ProductService extends TransactionBaseService { this.productTagRepository_ = productTagRepository this.imageRepository_ = imageRepository this.searchService_ = searchService + this.featureFlagRouter_ = featureFlagRouter } /** @@ -358,7 +357,14 @@ class ProductService extends TransactionBaseService { this.productOptionRepository_ ) - const { options, tags, type, images, ...rest } = productObject + const { + options, + tags, + type, + images, + sales_channels: salesChannels, + ...rest + } = productObject if (!rest.thumbnail && images?.length) { rest.thumbnail = images[0] @@ -384,11 +390,28 @@ class ProductService extends TransactionBaseService { product.type_id = (await productTypeRepo.upsertType(type))?.id || null } + if ( + this.featureFlagRouter_.isFeatureEnabled(SalesChannelFeatureFlag.key) + ) { + if (typeof salesChannels !== "undefined") { + product.sales_channels = [] + if (salesChannels?.length) { + const salesChannelIds = salesChannels?.map((sc) => sc.id) + product.sales_channels = salesChannelIds?.map( + (id) => ({ id } as SalesChannel) + ) + } + } + } + product = await productRepo.save(product) product.options = await Promise.all( (options ?? []).map(async (option) => { - const res = optionRepo.create({ ...option, product_id: product.id }) + const res = optionRepo.create({ + ...option, + product_id: product.id, + }) await optionRepo.save(res) return res }) @@ -436,11 +459,36 @@ class ProductService extends TransactionBaseService { ) const imageRepo = manager.getCustomRepository(this.imageRepository_) + const relations = ["variants", "tags", "images"] + + if ( + this.featureFlagRouter_.isFeatureEnabled(SalesChannelFeatureFlag.key) + ) { + if (typeof update.sales_channels !== "undefined") { + relations.push("sales_channels") + } + } else { + if (typeof update.sales_channels !== "undefined") { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + "the property sales_channels should no appears as part of the payload" + ) + } + } + const product = await this.retrieve(productId, { - relations: ["variants", "tags", "images"], + relations, }) - const { variants, metadata, images, tags, type, ...rest } = update + const { + variants, + metadata, + images, + tags, + type, + sales_channels: salesChannels, + ...rest + } = update if (!product.thumbnail && !update.thumbnail && images?.length) { product.thumbnail = images[0] @@ -462,6 +510,20 @@ class ProductService extends TransactionBaseService { product.tags = await productTagRepo.upsertTags(tags) } + if ( + this.featureFlagRouter_.isFeatureEnabled(SalesChannelFeatureFlag.key) + ) { + if (typeof salesChannels !== "undefined") { + product.sales_channels = [] + if (salesChannels?.length) { + const salesChannelIds = salesChannels?.map((sc) => sc.id) + product.sales_channels = salesChannelIds?.map( + (id) => ({ id } as SalesChannel) + ) + } + } + } + if (variants) { // Iterate product variants and update their properties accordingly for (const variant of product.variants) { diff --git a/packages/medusa/src/services/sales-channel.ts b/packages/medusa/src/services/sales-channel.ts index 76748c30eb..0de3a2a594 100644 --- a/packages/medusa/src/services/sales-channel.ts +++ b/packages/medusa/src/services/sales-channel.ts @@ -12,15 +12,12 @@ import { import { buildQuery } from "../utils" import EventBusService from "./event-bus" import StoreService from "./store" -import { formatException, PostgresError } from "../utils/exception-formatter" -import ProductService from "./product" type InjectedDependencies = { salesChannelRepository: typeof SalesChannelRepository eventBusService: EventBusService manager: EntityManager storeService: StoreService - productService: ProductService } class SalesChannelService extends TransactionBaseService { @@ -36,23 +33,25 @@ class SalesChannelService extends TransactionBaseService { protected readonly salesChannelRepository_: typeof SalesChannelRepository protected readonly eventBusService_: EventBusService protected readonly storeService_: StoreService - protected readonly productService_: ProductService constructor({ salesChannelRepository, eventBusService, manager, storeService, - productService, }: InjectedDependencies) { // eslint-disable-next-line prefer-rest-params - super(arguments[0]) + super({ + salesChannelRepository, + eventBusService, + manager, + storeService, + }) this.manager_ = manager this.salesChannelRepository_ = salesChannelRepository this.eventBusService_ = eventBusService this.storeService_ = storeService - this.productService_ = productService } /** @@ -280,36 +279,15 @@ class SalesChannelService extends TransactionBaseService { salesChannelId: string, productIds: string[] ): Promise { - return await this.atomicPhase_( - async (transactionManager) => { - const salesChannelRepo = transactionManager.getCustomRepository( - this.salesChannelRepository_ - ) + return await this.atomicPhase_(async (transactionManager) => { + const salesChannelRepo = transactionManager.getCustomRepository( + this.salesChannelRepository_ + ) - await salesChannelRepo.addProducts(salesChannelId, productIds) + await salesChannelRepo.addProducts(salesChannelId, productIds) - return await this.retrieve(salesChannelId) - }, - async (error: { code: string }) => { - if (error.code === PostgresError.FOREIGN_KEY_ERROR) { - const existingProducts = await this.productService_.list({ - id: productIds, - }) - - const nonExistingProducts = productIds.filter( - (cId) => existingProducts.findIndex((el) => el.id === cId) === -1 - ) - - throw new MedusaError( - MedusaError.Types.NOT_FOUND, - `The following product ids do not exist: ${JSON.stringify( - nonExistingProducts.join(", ") - )}` - ) - } - throw formatException(error) - } - ) + return await this.retrieve(salesChannelId) + }) } } diff --git a/packages/medusa/src/types/global.ts b/packages/medusa/src/types/global.ts index fa69b8dbb3..18f0251ab7 100644 --- a/packages/medusa/src/types/global.ts +++ b/packages/medusa/src/types/global.ts @@ -1,9 +1,9 @@ import { AwilixContainer } from "awilix" -import { Logger as _Logger } from "winston" +import { Request } from "express" import { LoggerOptions } from "typeorm" +import { Logger as _Logger } from "winston" import { Customer, User } from "../models" import { FindConfig, RequestQueryFields } from "./common" -import { Request } from "express" declare global { // eslint-disable-next-line @typescript-eslint/no-namespace @@ -16,6 +16,7 @@ declare global { listConfig: FindConfig retrieveConfig: FindConfig filterableFields: Record + errors: string[] } } } diff --git a/packages/medusa/src/types/product.ts b/packages/medusa/src/types/product.ts index ff051bf694..8e6d6036c5 100644 --- a/packages/medusa/src/types/product.ts +++ b/packages/medusa/src/types/product.ts @@ -7,14 +7,12 @@ import { IsString, ValidateNested, } from "class-validator" -import { FindOperator } from "typeorm" import { Product, ProductOptionValue, ProductStatus } from "../models" import { optionalBooleanMapper } from "../utils/validators/is-boolean" import { IsType } from "../utils/validators/is-type" import { DateComparisonOperator, FindConfig, - Selector, StringComparisonOperator, } from "./common" import { PriceListLoadConfig } from "./price-list" @@ -152,6 +150,7 @@ export type CreateProductInput = { tags?: CreateProductProductTagInput[] options?: CreateProductProductOption[] variants?: CreateProductProductVariantInput[] + sales_channels?: CreateProductProductSalesChannelInput[] | null weight?: number length?: number height?: number @@ -168,6 +167,10 @@ export type CreateProductProductTagInput = { value: string } +export type CreateProductProductSalesChannelInput = { + id: string +} + export type CreateProductProductTypeInput = { id?: string value: string @@ -243,3 +246,26 @@ export type ProductOptionInput = { } export type FindProductConfig = FindConfig & PriceListLoadConfig + +export class ProductSalesChannelReq { + @IsString() + id: string +} + +export class ProductTagReq { + @IsString() + @IsOptional() + id?: string + + @IsString() + value: string +} + +export class ProductTypeReq { + @IsString() + @IsOptional() + id?: string + + @IsString() + value: string +}