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:
committed by
GitHub
parent
5ce8839c54
commit
3b28998421
@@ -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",
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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"]
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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"
|
||||
|
||||
@@ -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[]>
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user