feat(medusa): Create/update Product Sales Channels (#1870)

* feat(medusa): Allow to create/update object sales channels assignment

* feat(medusa): cleanup

* feat(medusa): Update oas

* feat(medusa): Only add relation if required

* feat(medusa): Add feature flag decorators

* style(medusa): PR feedback

* feat(medusa): Remove circular by moving sales channel product existence check to the repo layer

* feat(medusa): Reduce selected column as they are not necessary

* feat(medusa): Refactor repository and usage

* feat(medusa): Improve entity name formatting

* feat(medusa): Add feature flag to the service

* fix(medusa): typo

* test(medusa): fix unit tests

* feat(medusa): include feedback

* feat(medusa): Adds validator pipe for Sales Channel existence (#1930)

* feat(medusa): Allow to create/update object sales channels assignment

* feat(medusa): cleanup

* feat(medusa): Update oas

* feat(medusa): Only add relation if required

* feat(medusa): Add feature flag decorators

* style(medusa): PR feedback

* feat(medusa): Remove circular by moving sales channel product existence check to the repo layer

* feat(medusa): Reduce selected column as they are not necessary

* feat(medusa): Refactor repository and usage

* feat(medusa): Improve entity name formatting

* feat(medusa): Add feature flag to the service

* fix(medusa): typo

* test(medusa): fix unit tests

* feat(medusa): Adds validator pipe for Sales Channel existence

* feat: Move product payload classes to types file

* fix unit tests

* fix integration test

Co-authored-by: adrien2p <adrien.deperetti@gmail.com>

* feat(medusa): Revert base repository and related

* feat(medusa): cleanup

* remove base repo export

Co-authored-by: Oliver Windall Juhl <59018053+olivermrbl@users.noreply.github.com>
This commit is contained in:
Adrien de Peretti
2022-07-28 20:19:30 +02:00
committed by GitHub
parent 5ce8839c54
commit 3b28998421
18 changed files with 533 additions and 146 deletions

View File

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

View File

@@ -4,6 +4,14 @@ type handler = (req: Request, res: Response) => Promise<void>
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)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<Selector<Product>, "tags"> & {
tags: FindOperator<string[]>

View File

@@ -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<SalesChannel> {
public async getFreeTextSearchResultsAndCount(
public async getFreeTextSearchResultsAndCount(
q: string,
options: ExtendedFindConfig<SalesChannel, Selector<SalesChannel>> = {
where: {},

View File

@@ -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) => {

View File

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

View File

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

View File

@@ -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<ProductService> {
class ProductService extends TransactionBaseService<
ProductService,
InjectedDependencies
> {
protected manager_: EntityManager
protected transactionManager_: EntityManager | undefined
@@ -57,6 +63,7 @@ class ProductService extends TransactionBaseService<ProductService> {
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<ProductService> {
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<ProductService> {
this.productTagRepository_ = productTagRepository
this.imageRepository_ = imageRepository
this.searchService_ = searchService
this.featureFlagRouter_ = featureFlagRouter
}
/**
@@ -358,7 +357,14 @@ class ProductService extends TransactionBaseService<ProductService> {
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<ProductService> {
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<ProductService> {
)
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<ProductService> {
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) {

View File

@@ -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<SalesChannelService> {
@@ -36,23 +33,25 @@ class SalesChannelService extends TransactionBaseService<SalesChannelService> {
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<SalesChannelService> {
salesChannelId: string,
productIds: string[]
): Promise<SalesChannel | never> {
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)
})
}
}

View File

@@ -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<unknown>
retrieveConfig: FindConfig<unknown>
filterableFields: Record<string, unknown>
errors: string[]
}
}
}

View File

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