Files
medusa-store/packages/modules/product/src/services/product-module-service.ts
Frane Polić 4757281677 feat(core-flows,product,types): scoped variant images (#13623)
* wip(product): variant images

* fix: return type

* wip: repo and list approach

* fix: redo repo method, make test pass

* fix: change getVariantImages impl

* feat: update test

* feat: API and core flows layer

* wip: integration spec

* fix: deterministic test

* chore: refactor and simplify, cleanup, remove repo method

* wip: batch add all images to all vairants

* fix: remove, expand testing

* refactor: pass variants instead of refetch

* chore: expand integration test

* feat: test multi assign route

* fix: remove `/admin/products/:id/variants/images` route

* feat: batch images to variant endpoint

* fix: length assertion

* feat: variant thumbnail

* fix: send variant thumbnail by default

* fix: product export test assertion

* fix: test

* feat: variant thumbnail on line item

* fix: add missing list and count method, update types

* feat: optimise variant images lookups

* feat: thumbnail management in core flows

* fix: typos, type, build

* feat: cascade delete to pivot table, rm unused unused fields

* feat(dashboard): variant images management UI (#13670)

* wip(dashboard): setup variant media form

* wip: cleanup table and images, wip check handler

* feat: proper sidebar functionallity

* fefat: add js-sdk and hooks

* feat: allow only one selection

* wip: lazy load variants in the table

* feat: new variants management for images on product details

* chore: refactor

* wip: variant details page work

* fix: cleanup media section, fix issues and types

* feat: correct scoped images, cleanup in edit modal

* feat: js sdk and hooks, filter out product images on variant details, labels, add API call and wrap UI

* chore: cleanup

* refacto: rename route

* feat: thumbnail functionallity

* fix: refresh checked after revalidation load

* fix: rm unused, refactor type

* Create thirty-clocks-refuse.md

* feat: new add remove variant media layout

* feat: new image add UX

---------

Co-authored-by: Oli Juhl <59018053+olivermrbl@users.noreply.github.com>

* fix: table name in migration

* chore: update changesets

---------

Co-authored-by: Oli Juhl <59018053+olivermrbl@users.noreply.github.com>
2025-10-26 15:15:40 +01:00

2454 lines
72 KiB
TypeScript

import {
Context,
DAL,
FilterableProductOptionValueProps,
FindConfig,
IEventBusModuleService,
InferEntityType,
InternalModuleDeclaration,
ModuleJoinerConfig,
ModulesSdkTypes,
ProductTypes,
} from "@medusajs/framework/types"
import {
Product,
ProductCategory,
ProductCollection,
ProductImage,
ProductOption,
ProductOptionValue,
ProductTag,
ProductType,
ProductVariant,
ProductVariantProductImage,
} from "@models"
import { ProductCategoryService } from "@services"
import {
arrayDifference,
createMedusaMikroOrmEventSubscriber,
EmitEvents,
generateEntityId,
InjectManager,
InjectTransactionManager,
isDefined,
isPresent,
isString,
isValidHandle,
kebabCase,
MedusaContext,
MedusaError,
MedusaService,
MessageAggregator,
Modules,
ProductStatus,
removeUndefined,
toHandle,
} from "@medusajs/framework/utils"
import { EntityManager } from "@mikro-orm/core"
import { ProductRepository } from "../repositories"
import {
UpdateCategoryInput,
UpdateCollectionInput,
UpdateProductInput,
UpdateProductOptionInput,
UpdateProductVariantInput,
UpdateTagInput,
UpdateTypeInput,
VariantImageInputArray,
} from "../types"
import { joinerConfig } from "./../joiner-config"
import { eventBuilders } from "../utils/events"
type InjectedDependencies = {
baseRepository: DAL.RepositoryService
productRepository: ProductRepository
productService: ModulesSdkTypes.IMedusaInternalService<any, any>
productVariantService: ModulesSdkTypes.IMedusaInternalService<any, any>
productTagService: ModulesSdkTypes.IMedusaInternalService<any>
productCategoryService: ProductCategoryService
productCollectionService: ModulesSdkTypes.IMedusaInternalService<any>
productImageService: ModulesSdkTypes.IMedusaInternalService<any>
productImageProductService: ModulesSdkTypes.IMedusaInternalService<any>
productTypeService: ModulesSdkTypes.IMedusaInternalService<any>
productOptionService: ModulesSdkTypes.IMedusaInternalService<any>
productOptionValueService: ModulesSdkTypes.IMedusaInternalService<any>
productVariantProductImageService: ModulesSdkTypes.IMedusaInternalService<any>
[Modules.EVENT_BUS]?: IEventBusModuleService
}
export default class ProductModuleService
extends MedusaService<{
Product: {
dto: ProductTypes.ProductDTO
}
ProductCategory: {
dto: ProductTypes.ProductCategoryDTO
}
ProductCollection: {
dto: ProductTypes.ProductCollectionDTO
}
ProductOption: {
dto: ProductTypes.ProductOptionDTO
}
ProductOptionValue: {
dto: ProductTypes.ProductOptionValueDTO
}
ProductTag: {
dto: ProductTypes.ProductTagDTO
}
ProductType: {
dto: ProductTypes.ProductTypeDTO
}
ProductVariant: {
dto: ProductTypes.ProductVariantDTO
}
ProductImage: {
dto: ProductTypes.ProductImageDTO
}
}>({
Product,
ProductCategory,
ProductCollection,
ProductOption,
ProductOptionValue,
ProductTag,
ProductType,
ProductVariant,
ProductImage,
})
implements ProductTypes.IProductModuleService
{
protected baseRepository_: DAL.RepositoryService
protected readonly productRepository_: ProductRepository
protected readonly productService_: ModulesSdkTypes.IMedusaInternalService<
InferEntityType<typeof Product>
>
protected readonly productVariantService_: ModulesSdkTypes.IMedusaInternalService<
InferEntityType<typeof ProductVariant>
>
protected readonly productCategoryService_: ProductCategoryService
protected readonly productTagService_: ModulesSdkTypes.IMedusaInternalService<
InferEntityType<typeof ProductTag>
>
protected readonly productCollectionService_: ModulesSdkTypes.IMedusaInternalService<
InferEntityType<typeof ProductCollection>
>
protected readonly productImageService_: ModulesSdkTypes.IMedusaInternalService<
InferEntityType<typeof ProductImage>
>
protected readonly productTypeService_: ModulesSdkTypes.IMedusaInternalService<
InferEntityType<typeof ProductType>
>
protected readonly productOptionService_: ModulesSdkTypes.IMedusaInternalService<
InferEntityType<typeof ProductOption>
>
protected readonly productOptionValueService_: ModulesSdkTypes.IMedusaInternalService<
InferEntityType<typeof ProductOptionValue>
>
protected readonly productVariantProductImageService_: ModulesSdkTypes.IMedusaInternalService<
InferEntityType<typeof ProductVariantProductImage>
>
protected readonly eventBusModuleService_?: IEventBusModuleService
constructor(
{
baseRepository,
productRepository,
productService,
productVariantService,
productTagService,
productCategoryService,
productCollectionService,
productImageService,
productTypeService,
productOptionService,
productOptionValueService,
productVariantProductImageService,
[Modules.EVENT_BUS]: eventBusModuleService,
}: InjectedDependencies,
protected readonly moduleDeclaration: InternalModuleDeclaration
) {
// @ts-ignore
// eslint-disable-next-line prefer-rest-params
super(...arguments)
this.baseRepository_ = baseRepository
this.productRepository_ = productRepository
this.productService_ = productService
this.productVariantService_ = productVariantService
this.productTagService_ = productTagService
this.productCategoryService_ = productCategoryService
this.productCollectionService_ = productCollectionService
this.productImageService_ = productImageService
this.productTypeService_ = productTypeService
this.productOptionService_ = productOptionService
this.productOptionValueService_ = productOptionValueService
this.productVariantProductImageService_ = productVariantProductImageService
this.eventBusModuleService_ = eventBusModuleService
}
__joinerConfig(): ModuleJoinerConfig {
return joinerConfig
}
@InjectManager()
// @ts-ignore
async retrieveProduct(
productId: string,
config?: FindConfig<ProductTypes.ProductDTO>,
@MedusaContext() sharedContext?: Context
): Promise<ProductTypes.ProductDTO> {
const product = await this.productService_.retrieve(
productId,
this.getProductFindConfig_(config),
sharedContext
)
return this.baseRepository_.serialize<ProductTypes.ProductDTO>(product)
}
@InjectManager()
// @ts-ignore
async listProducts(
filters?: ProductTypes.FilterableProductProps,
config?: FindConfig<ProductTypes.ProductDTO>,
sharedContext?: Context
): Promise<ProductTypes.ProductDTO[]> {
const products = await this.productService_.list(
filters,
this.getProductFindConfig_(config),
sharedContext
)
return this.baseRepository_.serialize<ProductTypes.ProductDTO[]>(products)
}
@InjectManager()
// @ts-ignore
async listAndCountProducts(
filters?: ProductTypes.FilterableProductProps,
config?: FindConfig<ProductTypes.ProductDTO>,
sharedContext?: Context
): Promise<[ProductTypes.ProductDTO[], number]> {
const [products, count] = await this.productService_.listAndCount(
filters,
this.getProductFindConfig_(config),
sharedContext
)
const serializedProducts = await this.baseRepository_.serialize<
ProductTypes.ProductDTO[]
>(products)
return [serializedProducts, count]
}
protected getProductFindConfig_(
config?: FindConfig<ProductTypes.ProductDTO>
): FindConfig<ProductTypes.ProductDTO> {
const hasImagesRelation = config?.relations?.includes("images")
return {
...config,
order: {
...(config?.order ?? { id: "ASC" }),
...(hasImagesRelation
? {
images: {
rank: "ASC",
...((config?.order?.images as object) ?? {}),
},
}
: {}),
},
}
}
// @ts-expect-error
createProductVariants(
data: ProductTypes.CreateProductVariantDTO[],
sharedContext?: Context
): Promise<ProductTypes.ProductVariantDTO[]>
// @ts-expect-error
createProductVariants(
data: ProductTypes.CreateProductVariantDTO,
sharedContext?: Context
): Promise<ProductTypes.ProductVariantDTO>
@InjectManager()
@EmitEvents()
// @ts-expect-error
async createProductVariants(
data:
| ProductTypes.CreateProductVariantDTO[]
| ProductTypes.CreateProductVariantDTO,
@MedusaContext() sharedContext: Context = {}
): Promise<
ProductTypes.ProductVariantDTO[] | ProductTypes.ProductVariantDTO
> {
const input = Array.isArray(data) ? data : [data]
const variants = await this.createVariants_(input, sharedContext)
const createdVariants = await this.baseRepository_.serialize<
ProductTypes.ProductVariantDTO[]
>(variants)
return Array.isArray(data) ? createdVariants : createdVariants[0]
}
@InjectTransactionManager()
protected async createVariants_(
data: ProductTypes.CreateProductVariantDTO[],
@MedusaContext() sharedContext: Context = {}
): Promise<InferEntityType<typeof ProductVariant>[]> {
if (data.some((v) => !v.product_id)) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"Unable to create variants without specifying a product_id"
)
}
const productOptions = await this.productOptionService_.list(
{
product_id: [...new Set<string>(data.map((v) => v.product_id!))],
},
{
relations: ["values"],
},
sharedContext
)
const variants = await this.productVariantService_.list(
{
product_id: [...new Set<string>(data.map((v) => v.product_id!))],
},
{
relations: ["options"],
},
sharedContext
)
const productVariantsWithOptions =
ProductModuleService.assignOptionsToVariants(data, productOptions)
ProductModuleService.checkIfVariantWithOptionsAlreadyExists(
productVariantsWithOptions as any,
variants
)
const createdVariants = await this.productVariantService_.create(
productVariantsWithOptions,
sharedContext
)
return createdVariants
}
async upsertProductVariants(
data: ProductTypes.UpsertProductVariantDTO[],
sharedContext?: Context
): Promise<ProductTypes.ProductVariantDTO[]>
async upsertProductVariants(
data: ProductTypes.UpsertProductVariantDTO,
sharedContext?: Context
): Promise<ProductTypes.ProductVariantDTO>
@InjectTransactionManager()
@EmitEvents()
async upsertProductVariants(
data:
| ProductTypes.UpsertProductVariantDTO[]
| ProductTypes.UpsertProductVariantDTO,
@MedusaContext() sharedContext: Context = {}
): Promise<
ProductTypes.ProductVariantDTO[] | ProductTypes.ProductVariantDTO
> {
const input = Array.isArray(data) ? data : [data]
const forUpdate = input.filter(
(variant): variant is UpdateProductVariantInput => !!variant.id
)
const forCreate = input.filter(
(variant): variant is ProductTypes.CreateProductVariantDTO => !variant.id
)
let created: ProductTypes.ProductVariantDTO[] = []
let updated: InferEntityType<typeof ProductVariant>[] = []
if (forCreate.length) {
created = await this.createProductVariants(forCreate, sharedContext)
}
if (forUpdate.length) {
updated = await this.updateVariants_(forUpdate, sharedContext)
}
const result = [...created, ...updated]
const allVariants = await this.baseRepository_.serialize<
ProductTypes.ProductVariantDTO[] | ProductTypes.ProductVariantDTO
>(result)
return Array.isArray(data) ? allVariants : allVariants[0]
}
// @ts-expect-error
updateProductVariants(
id: string,
data: ProductTypes.UpdateProductVariantDTO,
sharedContext?: Context
): Promise<ProductTypes.ProductVariantDTO>
// @ts-expect-error
updateProductVariants(
selector: ProductTypes.FilterableProductVariantProps,
data: ProductTypes.UpdateProductVariantDTO,
sharedContext?: Context
): Promise<ProductTypes.ProductVariantDTO[]>
@InjectManager()
@EmitEvents()
// @ts-expect-error
async updateProductVariants(
idOrSelector: string | ProductTypes.FilterableProductVariantProps,
data: ProductTypes.UpdateProductVariantDTO,
@MedusaContext() sharedContext: Context = {}
): Promise<
ProductTypes.ProductVariantDTO[] | ProductTypes.ProductVariantDTO
> {
let normalizedInput: UpdateProductVariantInput[] = []
if (isString(idOrSelector)) {
normalizedInput = [{ id: idOrSelector, ...data }]
} else {
const variants = await this.productVariantService_.list(
idOrSelector,
{},
sharedContext
)
normalizedInput = variants.map((variant) => ({
id: variant.id,
...data,
}))
}
const variants = await this.updateVariants_(normalizedInput, sharedContext)
const updatedVariants = await this.baseRepository_.serialize<
ProductTypes.ProductVariantDTO[]
>(variants)
return isString(idOrSelector) ? updatedVariants[0] : updatedVariants
}
@InjectTransactionManager()
protected async updateVariants_(
data: UpdateProductVariantInput[],
@MedusaContext() sharedContext: Context = {}
): Promise<InferEntityType<typeof ProductVariant>[]> {
// Validation step
const variantIdsToUpdate = data.map(({ id }) => id)
const variants = await this.productVariantService_.list(
{ id: variantIdsToUpdate },
{},
sharedContext
)
const allVariants = await this.productVariantService_.list(
{ product_id: variants.map((v) => v.product_id) },
{ relations: ["options"] },
sharedContext
)
if (variants.length !== data.length) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Cannot update non-existing variants with ids: ${arrayDifference(
variantIdsToUpdate,
variants.map(({ id }) => id)
).join(", ")}`
)
}
// Data normalization
const variantsWithProductId: UpdateProductVariantInput[] = variants.map(
(v) => ({
...data.find((d) => d.id === v.id),
id: v.id,
product_id: v.product_id,
})
)
const productOptions = await this.productOptionService_.list(
{
product_id: Array.from(
new Set(variantsWithProductId.map((v) => v.product_id!))
),
},
{ relations: ["values"] },
sharedContext
)
const productVariantsWithOptions =
ProductModuleService.assignOptionsToVariants(
variantsWithProductId,
productOptions
)
if (data.some((d) => !!d.options)) {
ProductModuleService.checkIfVariantWithOptionsAlreadyExists(
productVariantsWithOptions as any,
allVariants
)
}
const { entities: productVariants } =
await this.productVariantService_.upsertWithReplace(
productVariantsWithOptions,
{
relations: ["options"],
},
sharedContext
)
return productVariants
}
// @ts-expect-error
createProductTags(
data: ProductTypes.CreateProductTagDTO[],
sharedContext?: Context
): Promise<ProductTypes.ProductTagDTO[]>
// @ts-expect-error
createProductTags(
data: ProductTypes.CreateProductTagDTO,
sharedContext?: Context
): Promise<ProductTypes.ProductTagDTO>
@InjectManager()
@EmitEvents()
// @ts-expect-error
async createProductTags(
data: ProductTypes.CreateProductTagDTO[] | ProductTypes.CreateProductTagDTO,
@MedusaContext() sharedContext: Context = {}
): Promise<ProductTypes.ProductTagDTO[] | ProductTypes.ProductTagDTO> {
const input = Array.isArray(data) ? data : [data]
const tags = await this.productTagService_.create(input, sharedContext)
const createdTags = await this.baseRepository_.serialize<
ProductTypes.ProductTagDTO[]
>(tags)
return Array.isArray(data) ? createdTags : createdTags[0]
}
async upsertProductTags(
data: ProductTypes.UpsertProductTagDTO[],
sharedContext?: Context
): Promise<ProductTypes.ProductTagDTO[]>
async upsertProductTags(
data: ProductTypes.UpsertProductTagDTO,
sharedContext?: Context
): Promise<ProductTypes.ProductTagDTO>
@InjectManager()
@EmitEvents()
async upsertProductTags(
data: ProductTypes.UpsertProductTagDTO[] | ProductTypes.UpsertProductTagDTO,
@MedusaContext() sharedContext: Context = {}
): Promise<ProductTypes.ProductTagDTO[] | ProductTypes.ProductTagDTO> {
const tags = await this.upsertProductTags_(data, sharedContext)
const allTags = await this.baseRepository_.serialize<
ProductTypes.ProductTagDTO[] | ProductTypes.ProductTagDTO
>(Array.isArray(data) ? tags : tags[0])
return allTags
}
@InjectTransactionManager()
protected async upsertProductTags_(
data: ProductTypes.UpsertProductTagDTO[] | ProductTypes.UpsertProductTagDTO,
@MedusaContext() sharedContext: Context = {}
): Promise<InferEntityType<typeof ProductTag>[]> {
const input = Array.isArray(data) ? data : [data]
const forUpdate = input.filter((tag): tag is UpdateTagInput => !!tag.id)
const forCreate = input.filter(
(tag): tag is ProductTypes.CreateProductTagDTO => !tag.id
)
let created: InferEntityType<typeof ProductTag>[] = []
let updated: InferEntityType<typeof ProductTag>[] = []
if (forCreate.length) {
created = await this.productTagService_.create(forCreate, sharedContext)
}
if (forUpdate.length) {
updated = await this.productTagService_.update(forUpdate, sharedContext)
}
return [...created, ...updated]
}
// @ts-expect-error
updateProductTags(
id: string,
data: ProductTypes.UpdateProductTagDTO,
sharedContext?: Context
): Promise<ProductTypes.ProductTagDTO>
// @ts-expect-error
updateProductTags(
selector: ProductTypes.FilterableProductTagProps,
data: ProductTypes.UpdateProductTagDTO,
sharedContext?: Context
): Promise<ProductTypes.ProductTagDTO[]>
@InjectManager()
@EmitEvents()
// @ts-expect-error
async updateProductTags(
idOrSelector: string | ProductTypes.FilterableProductTagProps,
data: ProductTypes.UpdateProductTagDTO,
@MedusaContext() sharedContext: Context = {}
): Promise<ProductTypes.ProductTagDTO[] | ProductTypes.ProductTagDTO> {
let normalizedInput: UpdateTagInput[] = []
if (isString(idOrSelector)) {
// Check if the tag exists in the first place
await this.productTagService_.retrieve(idOrSelector, {}, sharedContext)
normalizedInput = [{ id: idOrSelector, ...data }]
} else {
const tags = await this.productTagService_.list(
idOrSelector,
{},
sharedContext
)
normalizedInput = tags.map((tag) => ({
id: tag.id,
...data,
}))
}
const tags = await this.productTagService_.update(
normalizedInput,
sharedContext
)
const updatedTags = await this.baseRepository_.serialize<
ProductTypes.ProductTagDTO[]
>(tags)
return isString(idOrSelector) ? updatedTags[0] : updatedTags
}
// @ts-expect-error
createProductTypes(
data: ProductTypes.CreateProductTypeDTO[],
sharedContext?: Context
): Promise<ProductTypes.ProductTypeDTO[]>
// @ts-expect-error
createProductTypes(
data: ProductTypes.CreateProductTypeDTO,
sharedContext?: Context
): Promise<ProductTypes.ProductTypeDTO>
@InjectManager()
@EmitEvents()
// @ts-expect-error
async createProductTypes(
data:
| ProductTypes.CreateProductTypeDTO[]
| ProductTypes.CreateProductTypeDTO,
@MedusaContext() sharedContext: Context = {}
): Promise<ProductTypes.ProductTypeDTO[] | ProductTypes.ProductTypeDTO> {
const input = Array.isArray(data) ? data : [data]
const types = await this.productTypeService_.create(input, sharedContext)
const createdTypes = await this.baseRepository_.serialize<
ProductTypes.ProductTypeDTO[]
>(types)
return Array.isArray(data) ? createdTypes : createdTypes[0]
}
async upsertProductTypes(
data: ProductTypes.UpsertProductTypeDTO[],
sharedContext?: Context
): Promise<ProductTypes.ProductTypeDTO[]>
async upsertProductTypes(
data: ProductTypes.UpsertProductTypeDTO,
sharedContext?: Context
): Promise<ProductTypes.ProductTypeDTO>
@InjectManager()
@EmitEvents()
async upsertProductTypes(
data:
| ProductTypes.UpsertProductTypeDTO[]
| ProductTypes.UpsertProductTypeDTO,
@MedusaContext() sharedContext: Context = {}
): Promise<ProductTypes.ProductTypeDTO[] | ProductTypes.ProductTypeDTO> {
const types = await this.upsertProductTypes_(data, sharedContext)
const result = await this.baseRepository_.serialize<
ProductTypes.ProductTypeDTO[] | ProductTypes.ProductTypeDTO
>(types)
return Array.isArray(data) ? result : result[0]
}
@InjectTransactionManager()
protected async upsertProductTypes_(
data:
| ProductTypes.UpsertProductTypeDTO
| ProductTypes.UpsertProductTypeDTO[],
sharedContext?: Context
): Promise<InferEntityType<typeof ProductType>[]> {
const input = Array.isArray(data) ? data : [data]
const forUpdate = input.filter((type): type is UpdateTypeInput => !!type.id)
const forCreate = input.filter(
(type): type is ProductTypes.CreateProductTypeDTO => !type.id
)
let created: InferEntityType<typeof ProductType>[] = []
let updated: InferEntityType<typeof ProductType>[] = []
if (forCreate.length) {
created = await this.productTypeService_.create(forCreate, sharedContext)
}
if (forUpdate.length) {
updated = await this.productTypeService_.update(forUpdate, sharedContext)
}
return [...created, ...updated]
}
// @ts-expect-error
updateProductTypes(
id: string,
data: ProductTypes.UpdateProductTypeDTO,
sharedContext?: Context
): Promise<ProductTypes.ProductTypeDTO>
// @ts-expect-error
updateProductTypes(
selector: ProductTypes.FilterableProductTypeProps,
data: ProductTypes.UpdateProductTypeDTO,
sharedContext?: Context
): Promise<ProductTypes.ProductTypeDTO[]>
@InjectManager()
@EmitEvents()
// @ts-expect-error
async updateProductTypes(
idOrSelector: string | ProductTypes.FilterableProductTypeProps,
data: ProductTypes.UpdateProductTypeDTO,
@MedusaContext() sharedContext: Context = {}
): Promise<ProductTypes.ProductTypeDTO[] | ProductTypes.ProductTypeDTO> {
let normalizedInput: UpdateTypeInput[] = []
if (isString(idOrSelector)) {
// Check if the type exists in the first place
await this.productTypeService_.retrieve(idOrSelector, {}, sharedContext)
normalizedInput = [{ id: idOrSelector, ...data }]
} else {
const types = await this.productTypeService_.list(
idOrSelector,
{},
sharedContext
)
normalizedInput = types.map((type) => ({
id: type.id,
...data,
}))
}
const types = await this.productTypeService_.update(
normalizedInput,
sharedContext
)
const updatedTypes = await this.baseRepository_.serialize<
ProductTypes.ProductTypeDTO[]
>(types)
return isString(idOrSelector) ? updatedTypes[0] : updatedTypes
}
// @ts-expect-error
createProductOptions(
data: ProductTypes.CreateProductOptionDTO[],
sharedContext?: Context
): Promise<ProductTypes.ProductOptionDTO[]>
// @ts-expect-error
createProductOptions(
data: ProductTypes.CreateProductOptionDTO,
sharedContext?: Context
): Promise<ProductTypes.ProductOptionDTO>
@InjectManager()
@EmitEvents()
// @ts-expect-error
async createProductOptions(
data:
| ProductTypes.CreateProductOptionDTO[]
| ProductTypes.CreateProductOptionDTO,
@MedusaContext() sharedContext: Context = {}
): Promise<ProductTypes.ProductOptionDTO[] | ProductTypes.ProductOptionDTO> {
const input = Array.isArray(data) ? data : [data]
const options = await this.createOptions_(input, sharedContext)
const createdOptions = await this.baseRepository_.serialize<
ProductTypes.ProductOptionDTO[]
>(options)
return Array.isArray(data) ? createdOptions : createdOptions[0]
}
@InjectTransactionManager()
protected async createOptions_(
data: ProductTypes.CreateProductOptionDTO[],
@MedusaContext() sharedContext: Context = {}
): Promise<InferEntityType<typeof ProductOption>[]> {
if (data.some((v) => !v.product_id)) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"Tried to create options without specifying a product_id"
)
}
const normalizedInput = data.map((opt) => {
return {
...opt,
values: opt.values?.map((v) => {
return typeof v === "string" ? { value: v } : v
}),
}
})
return await this.productOptionService_.create(
normalizedInput,
sharedContext
)
}
async upsertProductOptions(
data: ProductTypes.UpsertProductOptionDTO[],
sharedContext?: Context
): Promise<ProductTypes.ProductOptionDTO[]>
async upsertProductOptions(
data: ProductTypes.UpsertProductOptionDTO,
sharedContext?: Context
): Promise<ProductTypes.ProductOptionDTO>
@InjectTransactionManager()
@EmitEvents()
async upsertProductOptions(
data:
| ProductTypes.UpsertProductOptionDTO[]
| ProductTypes.UpsertProductOptionDTO,
@MedusaContext() sharedContext: Context = {}
): Promise<ProductTypes.ProductOptionDTO[] | ProductTypes.ProductOptionDTO> {
const input = Array.isArray(data) ? data : [data]
const forUpdate = input.filter(
(option): option is UpdateProductOptionInput => !!option.id
)
const forCreate = input.filter(
(option): option is ProductTypes.CreateProductOptionDTO => !option.id
)
let created: InferEntityType<typeof ProductOption>[] = []
let updated: InferEntityType<typeof ProductOption>[] = []
if (forCreate.length) {
created = await this.createOptions_(forCreate, sharedContext)
}
if (forUpdate.length) {
updated = await this.updateOptions_(forUpdate, sharedContext)
}
const result = [...created, ...updated]
const allOptions = await this.baseRepository_.serialize<
ProductTypes.ProductOptionDTO[] | ProductTypes.ProductOptionDTO
>(result)
return Array.isArray(data) ? allOptions : allOptions[0]
}
// @ts-expect-error
updateProductOptions(
id: string,
data: ProductTypes.UpdateProductOptionDTO,
sharedContext?: Context
): Promise<ProductTypes.ProductOptionDTO>
// @ts-expect-error
updateProductOptions(
selector: ProductTypes.FilterableProductOptionProps,
data: ProductTypes.UpdateProductOptionDTO,
sharedContext?: Context
): Promise<ProductTypes.ProductOptionDTO[]>
@InjectManager()
@EmitEvents()
// @ts-expect-error
async updateProductOptions(
idOrSelector: string | ProductTypes.FilterableProductOptionProps,
data: ProductTypes.UpdateProductOptionDTO,
@MedusaContext() sharedContext: Context = {}
): Promise<ProductTypes.ProductOptionDTO[] | ProductTypes.ProductOptionDTO> {
let normalizedInput: UpdateProductOptionInput[] = []
if (isString(idOrSelector)) {
await this.productOptionService_.retrieve(idOrSelector, {}, sharedContext)
normalizedInput = [{ id: idOrSelector, ...data }]
} else {
const options = await this.productOptionService_.list(
idOrSelector,
{},
sharedContext
)
normalizedInput = options.map((option) => ({
id: option.id,
...data,
}))
}
const options = await this.updateOptions_(normalizedInput, sharedContext)
const updatedOptions = await this.baseRepository_.serialize<
ProductTypes.ProductOptionDTO[]
>(options)
return isString(idOrSelector) ? updatedOptions[0] : updatedOptions
}
@InjectTransactionManager()
protected async updateOptions_(
data: UpdateProductOptionInput[],
@MedusaContext() sharedContext: Context = {}
): Promise<InferEntityType<typeof ProductOption>[]> {
// Validation step
if (data.some((option) => !option.id)) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"Tried to update options without specifying an ID"
)
}
const dbOptions = await this.productOptionService_.list(
{ id: data.map(({ id }) => id) },
{ relations: ["values"] },
sharedContext
)
if (dbOptions.length !== data.length) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Cannot update non-existing options with ids: ${arrayDifference(
data.map(({ id }) => id),
dbOptions.map(({ id }) => id)
).join(", ")}`
)
}
// Data normalization
const normalizedInput = data.map((opt) => {
const dbValues = dbOptions.find(({ id }) => id === opt.id)?.values || []
const normalizedValues = opt.values?.map((v) => {
return typeof v === "string" ? { value: v } : v
})
return {
...opt,
...(normalizedValues
? {
// Oftentimes the options are only passed by value without an id, even if they exist in the DB
values: normalizedValues.map((normVal) => {
if ("id" in normVal) {
return normVal
}
const dbVal = dbValues.find(
(dbVal) => dbVal.value === normVal.value
)
if (!dbVal) {
return normVal
}
return {
id: dbVal.id,
value: normVal.value,
}
}),
}
: {}),
} as UpdateProductOptionInput
})
const { entities: productOptions } =
await this.productOptionService_.upsertWithReplace(
normalizedInput,
{ relations: ["values"] },
sharedContext
)
return productOptions
}
// @ts-expect-error
createProductCollections(
data: ProductTypes.CreateProductCollectionDTO[],
sharedContext?: Context
): Promise<ProductTypes.ProductCollectionDTO[]>
// @ts-expect-error
createProductCollections(
data: ProductTypes.CreateProductCollectionDTO,
sharedContext?: Context
): Promise<ProductTypes.ProductCollectionDTO>
@InjectManager()
@EmitEvents()
// @ts-expect-error
async createProductCollections(
data:
| ProductTypes.CreateProductCollectionDTO[]
| ProductTypes.CreateProductCollectionDTO,
@MedusaContext() sharedContext: Context = {}
): Promise<
ProductTypes.ProductCollectionDTO[] | ProductTypes.ProductCollectionDTO
> {
const input = Array.isArray(data) ? data : [data]
const collections = await this.createCollections_(input, sharedContext)
const createdCollections = await this.baseRepository_.serialize<
ProductTypes.ProductCollectionDTO[]
>(collections)
return Array.isArray(data) ? createdCollections : createdCollections[0]
}
@InjectTransactionManager()
async createCollections_(
data: ProductTypes.CreateProductCollectionDTO[],
@MedusaContext() sharedContext: Context = {}
): Promise<InferEntityType<typeof ProductCollection>[]> {
const normalizedInput = data.map(
ProductModuleService.normalizeCreateProductCollectionInput
)
// It's safe to use upsertWithReplace here since we only have product IDs and the only operation to do is update the product
// with the collection ID
const { entities: productCollections } =
await this.productCollectionService_.upsertWithReplace(
normalizedInput,
{ relations: ["products"] },
sharedContext
)
return productCollections
}
async upsertProductCollections(
data: ProductTypes.UpsertProductCollectionDTO[],
sharedContext?: Context
): Promise<ProductTypes.ProductCollectionDTO[]>
async upsertProductCollections(
data: ProductTypes.UpsertProductCollectionDTO,
sharedContext?: Context
): Promise<ProductTypes.ProductCollectionDTO>
@InjectManager()
@EmitEvents()
async upsertProductCollections(
data:
| ProductTypes.UpsertProductCollectionDTO[]
| ProductTypes.UpsertProductCollectionDTO,
@MedusaContext() sharedContext: Context = {}
): Promise<
ProductTypes.ProductCollectionDTO[] | ProductTypes.ProductCollectionDTO
> {
const collections = await this.upsertCollections_(data, sharedContext)
const serializedCollections = await this.baseRepository_.serialize<
ProductTypes.ProductCollectionDTO[]
>(collections)
return Array.isArray(data)
? serializedCollections
: serializedCollections[0]
}
@InjectTransactionManager()
protected async upsertCollections_(
data:
| ProductTypes.UpsertProductCollectionDTO[]
| ProductTypes.UpsertProductCollectionDTO,
@MedusaContext() sharedContext: Context = {}
): Promise<InferEntityType<typeof ProductCollection>[]> {
const input = Array.isArray(data) ? data : [data]
const forUpdate = input.filter(
(collection): collection is UpdateCollectionInput => !!collection.id
)
const forCreate = input.filter(
(collection): collection is ProductTypes.CreateProductCollectionDTO =>
!collection.id
)
let created: InferEntityType<typeof ProductCollection>[] = []
let updated: InferEntityType<typeof ProductCollection>[] = []
if (forCreate.length) {
created = await this.createCollections_(forCreate, sharedContext)
}
if (forUpdate.length) {
updated = await this.updateCollections_(forUpdate, sharedContext)
}
return [...created, ...updated]
}
// @ts-expect-error
updateProductCollections(
id: string,
data: ProductTypes.UpdateProductCollectionDTO,
sharedContext?: Context
): Promise<ProductTypes.ProductCollectionDTO>
// @ts-expect-error
updateProductCollections(
selector: ProductTypes.FilterableProductCollectionProps,
data: ProductTypes.UpdateProductCollectionDTO,
sharedContext?: Context
): Promise<ProductTypes.ProductCollectionDTO[]>
@InjectManager()
@EmitEvents()
// @ts-expect-error
async updateProductCollections(
idOrSelector: string | ProductTypes.FilterableProductCollectionProps,
data: ProductTypes.UpdateProductCollectionDTO,
@MedusaContext() sharedContext: Context = {}
): Promise<
ProductTypes.ProductCollectionDTO[] | ProductTypes.ProductCollectionDTO
> {
let normalizedInput: UpdateCollectionInput[] = []
if (isString(idOrSelector)) {
await this.productCollectionService_.retrieve(
idOrSelector,
{},
sharedContext
)
normalizedInput = [{ id: idOrSelector, ...data }]
} else {
const collections = await this.productCollectionService_.list(
idOrSelector,
{},
sharedContext
)
normalizedInput = collections.map((collection) => ({
id: collection.id,
...data,
}))
}
const collections = await this.updateCollections_(
normalizedInput,
sharedContext
)
const updatedCollections = await this.baseRepository_.serialize<
ProductTypes.ProductCollectionDTO[]
>(collections)
return isString(idOrSelector) ? updatedCollections[0] : updatedCollections
}
@InjectTransactionManager()
protected async updateCollections_(
data: UpdateCollectionInput[],
@MedusaContext() sharedContext: Context = {}
): Promise<InferEntityType<typeof ProductCollection>[]> {
const normalizedInput = data.map(
ProductModuleService.normalizeUpdateProductCollectionInput
) as UpdateCollectionInput[]
// TODO: Maybe we can update upsertWithReplace to not remove oneToMany entities, but just disassociate them? With that we can remove the code below.
// Another alternative is to not allow passing product_ids to a collection, and instead set the collection_id through the product update call.
const updatedCollections = await this.productCollectionService_.update(
normalizedInput.map((c) =>
removeUndefined({ ...c, products: undefined })
),
sharedContext
)
const collections: InferEntityType<typeof ProductCollection>[] = []
const toUpdate: {
selector: ProductTypes.FilterableProductProps
data: ProductTypes.UpdateProductDTO
}[] = []
updatedCollections.forEach((collectionData) => {
const input = normalizedInput.find((c) => c.id === collectionData.id)
const productsToUpdate = (input as any)?.products
const dissociateSelector = {
collection_id: collectionData.id,
}
const associateSelector = {}
if (isDefined(productsToUpdate)) {
const productIds = productsToUpdate.map((p) => p.id)
dissociateSelector["id"] = { $nin: productIds }
associateSelector["id"] = { $in: productIds }
}
if (isPresent(dissociateSelector["id"])) {
toUpdate.push({
selector: dissociateSelector,
data: {
collection_id: null,
},
})
}
if (isPresent(associateSelector["id"])) {
toUpdate.push({
selector: associateSelector,
data: {
collection_id: collectionData.id,
},
})
}
collections.push({
...collectionData,
products: productsToUpdate ?? [],
} as InferEntityType<typeof ProductCollection>)
})
if (toUpdate.length) {
await this.productService_.update(toUpdate, sharedContext)
}
return collections
}
// @ts-expect-error
createProductCategories(
data: ProductTypes.CreateProductCategoryDTO[],
sharedContext?: Context
): Promise<ProductTypes.ProductCategoryDTO[]>
// @ts-expect-error
createProductCategories(
data: ProductTypes.CreateProductCategoryDTO,
sharedContext?: Context
): Promise<ProductTypes.ProductCategoryDTO>
@InjectManager()
@EmitEvents()
// @ts-expect-error
async createProductCategories(
data:
| ProductTypes.CreateProductCategoryDTO[]
| ProductTypes.CreateProductCategoryDTO,
@MedusaContext() sharedContext: Context = {}
): Promise<
ProductTypes.ProductCategoryDTO[] | ProductTypes.ProductCategoryDTO
> {
const input = (Array.isArray(data) ? data : [data]).map(
(productCategory) => {
productCategory.handle ??= kebabCase(productCategory.name)
return productCategory
}
)
const categories = await this.productCategoryService_.create(
input,
sharedContext
)
const createdCategories = await this.baseRepository_.serialize<
ProductTypes.ProductCategoryDTO[]
>(categories)
// TODO: Same as the update categories, for some reason I cant get the tree repository update
eventBuilders.createdProductCategory({
data: createdCategories,
sharedContext,
})
return Array.isArray(data) ? createdCategories : createdCategories[0]
}
async upsertProductCategories(
data: ProductTypes.UpsertProductCategoryDTO[],
sharedContext?: Context
): Promise<ProductTypes.ProductCategoryDTO[]>
async upsertProductCategories(
data: ProductTypes.UpsertProductCategoryDTO,
sharedContext?: Context
): Promise<ProductTypes.ProductCategoryDTO>
@InjectManager()
@EmitEvents()
async upsertProductCategories(
data:
| ProductTypes.UpsertProductCategoryDTO[]
| ProductTypes.UpsertProductCategoryDTO,
@MedusaContext() sharedContext: Context = {}
): Promise<
ProductTypes.ProductCategoryDTO[] | ProductTypes.ProductCategoryDTO
> {
const categories = await this.upsertProductCategories_(data, sharedContext)
const serializedCategories = await this.baseRepository_.serialize<
ProductTypes.ProductCategoryDTO[]
>(categories)
return Array.isArray(data) ? serializedCategories : serializedCategories[0]
}
@InjectTransactionManager()
protected async upsertProductCategories_(
data:
| ProductTypes.UpsertProductCategoryDTO[]
| ProductTypes.UpsertProductCategoryDTO,
@MedusaContext() sharedContext: Context = {}
): Promise<InferEntityType<typeof ProductCategory>[]> {
const input = Array.isArray(data) ? data : [data]
const forUpdate = input.filter(
(category): category is UpdateCategoryInput => !!category.id
)
let forCreate = input.filter(
(category): category is ProductTypes.CreateProductCategoryDTO =>
!category.id
)
let created: InferEntityType<typeof ProductCategory>[] = []
let updated: InferEntityType<typeof ProductCategory>[] = []
if (forCreate.length) {
forCreate = forCreate.map((productCategory) => {
productCategory.handle ??= kebabCase(productCategory.name)
return productCategory
})
created = await this.productCategoryService_.create(
forCreate,
sharedContext
)
}
if (forUpdate.length) {
updated = await this.productCategoryService_.update(
forUpdate,
sharedContext
)
}
// TODO: Same as the update categories, for some reason I cant get the tree repository update
// event. I ll need to investigate this
if (created.length) {
eventBuilders.createdProductCategory({
data: created,
sharedContext,
})
}
if (updated.length) {
eventBuilders.updatedProductCategory({
data: updated,
sharedContext,
})
}
return [...created, ...updated]
}
// @ts-expect-error
updateProductCategories(
id: string,
data: ProductTypes.UpdateProductCategoryDTO,
sharedContext?: Context
): Promise<ProductTypes.ProductCategoryDTO>
// @ts-expect-error
updateProductCategories(
selector: ProductTypes.FilterableProductTypeProps,
data: ProductTypes.UpdateProductCategoryDTO,
sharedContext?: Context
): Promise<ProductTypes.ProductCategoryDTO[]>
@InjectManager()
@EmitEvents()
// @ts-expect-error
async updateProductCategories(
idOrSelector: string | ProductTypes.FilterableProductTypeProps,
data: ProductTypes.UpdateProductCategoryDTO,
@MedusaContext() sharedContext: Context = {}
): Promise<
ProductTypes.ProductCategoryDTO | ProductTypes.ProductCategoryDTO[]
> {
const categories = await this.updateProductCategories_(
idOrSelector,
data,
sharedContext
)
const serializedCategories = await this.baseRepository_.serialize<
ProductTypes.ProductCategoryDTO[]
>(categories)
// TODO: for some reason I cant get the tree repository update
// event. I ll need to investigate this
eventBuilders.updatedProductCategory({
data: serializedCategories,
sharedContext,
})
return isString(idOrSelector)
? serializedCategories[0]
: serializedCategories
}
@InjectTransactionManager()
protected async updateProductCategories_(
idOrSelector: string | ProductTypes.FilterableProductTypeProps,
data: ProductTypes.UpdateProductCategoryDTO,
@MedusaContext() sharedContext: Context = {}
): Promise<InferEntityType<typeof ProductCategory>[]> {
let normalizedInput: UpdateCategoryInput[] = []
if (isString(idOrSelector)) {
// Check if the type exists in the first place
await this.productCategoryService_.retrieve(
idOrSelector,
{},
sharedContext
)
normalizedInput = [{ id: idOrSelector, ...data }]
} else {
const categories = await this.productCategoryService_.list(
idOrSelector,
{},
sharedContext
)
normalizedInput = categories.map((type) => ({
id: type.id,
...data,
}))
}
const categories = await this.productCategoryService_.update(
normalizedInput,
sharedContext
)
return categories
}
//@ts-expect-error
createProducts(
data: ProductTypes.CreateProductDTO[],
sharedContext?: Context
): Promise<ProductTypes.ProductDTO[]>
// @ts-expect-error
createProducts(
data: ProductTypes.CreateProductDTO,
sharedContext?: Context
): Promise<ProductTypes.ProductDTO>
@InjectManager()
@EmitEvents()
// @ts-expect-error
async createProducts(
data: ProductTypes.CreateProductDTO[] | ProductTypes.CreateProductDTO,
@MedusaContext() sharedContext: Context = {}
): Promise<ProductTypes.ProductDTO[] | ProductTypes.ProductDTO> {
const input = Array.isArray(data) ? data : [data]
const products = await this.createProducts_(input, sharedContext)
const createdProducts = await this.baseRepository_.serialize<
ProductTypes.ProductDTO[]
>(products)
return Array.isArray(data) ? createdProducts : createdProducts[0]
}
async upsertProducts(
data: ProductTypes.UpsertProductDTO[],
sharedContext?: Context
): Promise<ProductTypes.ProductDTO[]>
async upsertProducts(
data: ProductTypes.UpsertProductDTO,
sharedContext?: Context
): Promise<ProductTypes.ProductDTO>
@InjectTransactionManager()
@EmitEvents()
async upsertProducts(
data: ProductTypes.UpsertProductDTO[] | ProductTypes.UpsertProductDTO,
@MedusaContext() sharedContext: Context = {}
): Promise<ProductTypes.ProductDTO[] | ProductTypes.ProductDTO> {
const input = Array.isArray(data) ? data : [data]
const forUpdate = input.filter(
(product): product is UpdateProductInput => !!product.id
)
const forCreate = input.filter(
(product): product is ProductTypes.CreateProductDTO => !product.id
)
let created: ProductTypes.ProductDTO[] = []
let updated: InferEntityType<typeof Product>[] = []
if (forCreate.length) {
created = await this.createProducts(forCreate, sharedContext)
}
if (forUpdate.length) {
updated = await this.updateProducts_(forUpdate, sharedContext)
}
const result = [...created, ...updated]
const allProducts = await this.baseRepository_.serialize<
ProductTypes.ProductDTO[] | ProductTypes.ProductDTO
>(result)
return Array.isArray(data) ? allProducts : allProducts[0]
}
// @ts-expect-error
updateProducts(
id: string,
data: ProductTypes.UpdateProductDTO,
sharedContext?: Context
): Promise<ProductTypes.ProductDTO>
// @ts-expect-error
updateProducts(
selector: ProductTypes.FilterableProductProps,
data: ProductTypes.UpdateProductDTO,
sharedContext?: Context
): Promise<ProductTypes.ProductDTO[]>
@InjectManager()
@EmitEvents()
// @ts-expect-error
async updateProducts(
idOrSelector: string | ProductTypes.FilterableProductProps,
data: ProductTypes.UpdateProductDTO,
@MedusaContext() sharedContext: Context = {}
): Promise<ProductTypes.ProductDTO[] | ProductTypes.ProductDTO> {
let normalizedInput: UpdateProductInput[] = []
if (isString(idOrSelector)) {
// This will throw if the product does not exist
await this.productService_.retrieve(idOrSelector, {}, sharedContext)
normalizedInput = [{ id: idOrSelector, ...data }]
} else {
const products = await this.productService_.list(
idOrSelector,
{},
sharedContext
)
normalizedInput = products.map((product) => ({
id: product.id,
...data,
}))
}
const products = await this.updateProducts_(normalizedInput, sharedContext)
const updatedProducts = await this.baseRepository_.serialize<
ProductTypes.ProductDTO[]
>(products)
return isString(idOrSelector) ? updatedProducts[0] : updatedProducts
}
@InjectTransactionManager()
protected async createProducts_(
data: ProductTypes.CreateProductDTO[],
@MedusaContext() sharedContext: Context = {}
): Promise<InferEntityType<typeof Product>[]> {
const normalizedProducts = await this.normalizeCreateProductInput(
data,
sharedContext
)
for (const product of normalizedProducts) {
this.validateProductCreatePayload(product)
}
const tagIds = normalizedProducts
.flatMap((d) => (d as any).tags ?? [])
.map((t) => t.id)
let existingTags: InferEntityType<typeof ProductTag>[] = []
if (tagIds.length) {
existingTags = await this.productTagService_.list(
{
id: tagIds,
},
{},
sharedContext
)
}
const existingTagsMap = new Map(existingTags.map((tag) => [tag.id, tag]))
const productsToCreate = normalizedProducts.map((product) => {
const productId = generateEntityId(product.id, "prod")
product.id = productId
if ((product as any).categories?.length) {
;(product as any).categories = (product as any).categories.map(
(category: { id: string }) => category.id
)
}
if (product.variants?.length) {
const normalizedVariants = product.variants.map((variant) => {
const variantId = generateEntityId((variant as any).id, "variant")
;(variant as any).id = variantId
Object.entries(variant.options ?? {}).forEach(([key, value]) => {
const productOption = product.options?.find(
(option) => option.title === key
)!
const productOptionValue = productOption.values?.find(
(optionValue) => (optionValue as any).value === value
)!
;(productOptionValue as any).variants ??= []
;(productOptionValue as any).variants.push(variant)
})
delete variant.options
return variant
})
product.variants = normalizedVariants
}
if ((product as any).tags?.length) {
;(product as any).tags = (product as any).tags.map(
(tag: { id: string }) => {
const existingTag = existingTagsMap.get(tag.id)
if (existingTag) {
return existingTag
}
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Tag with id ${tag.id} not found. Please create the tag before associating it with the product.`
)
}
)
}
return product
})
const createdProducts = await this.productService_.create(
productsToCreate,
sharedContext
)
return createdProducts
}
@InjectTransactionManager()
protected async updateProducts_(
data: UpdateProductInput[],
@MedusaContext() sharedContext: Context = {}
): Promise<InferEntityType<typeof Product>[]> {
// We have to do that manually because this method is bypassing the product service and goes
// directly to the custom product repository
const manager = (sharedContext.transactionManager ??
sharedContext.manager) as EntityManager
const subscriber = createMedusaMikroOrmEventSubscriber(
["updateProducts_"],
this as unknown as ReturnType<typeof MedusaService<any>>
)
if (manager && subscriber) {
manager
.getEventManager()
.registerSubscriber(new subscriber(sharedContext))
}
const originalProducts = await this.productService_.list(
{
id: data.map((d) => d.id),
},
{
relations: ["options", "options.values", "variants", "images", "tags"],
},
sharedContext
)
const normalizedProducts = await this.normalizeUpdateProductInput(
data,
originalProducts
)
for (const product of normalizedProducts) {
this.validateProductUpdatePayload(product)
}
const updatedProducts = await this.productRepository_.deepUpdate(
normalizedProducts,
ProductModuleService.validateVariantOptions,
sharedContext
)
return updatedProducts
}
// @ts-expect-error
updateProductOptionValues(
idOrSelector: string,
data: ProductTypes.UpdateProductOptionValueDTO,
sharedContext?: Context
): Promise<ProductTypes.ProductOptionValueDTO>
// @ts-expect-error
updateProductOptionValues(
selector: FilterableProductOptionValueProps,
data: ProductTypes.UpdateProductOptionValueDTO,
sharedContext?: Context
): Promise<ProductTypes.ProductOptionValueDTO[]>
// @ts-expect-error
async updateProductOptionValues(
idOrSelector: string | FilterableProductOptionValueProps,
data: ProductTypes.UpdateProductOptionValueDTO,
sharedContext: Context = {}
): Promise<
ProductTypes.ProductOptionValueDTO | ProductTypes.ProductOptionValueDTO[]
> {
// TODO: There is a missmatch in the API which lead to function with different number of
// arguments. Therefore, applying the MedusaContext() decorator to the function will not work
// because the context arg index will differ from method to method.
sharedContext.messageAggregator ??= new MessageAggregator()
let normalizedInput: ({
id: string
} & ProductTypes.UpdateProductOptionValueDTO)[] = []
if (isString(idOrSelector)) {
// This will throw if the product option value does not exist
await this.productOptionValueService_.retrieve(
idOrSelector,
{},
sharedContext
)
normalizedInput = [{ id: idOrSelector, ...data }]
} else {
const productOptionValues = await this.productOptionValueService_.list(
idOrSelector,
{},
sharedContext
)
normalizedInput = productOptionValues.map((product) => ({
id: product.id,
...data,
}))
}
const productOptionValues = await this.updateProductOptionValues_(
normalizedInput,
sharedContext
)
const updatedProductOptionValues = await this.baseRepository_.serialize<
ProductTypes.ProductOptionValueDTO[]
>(productOptionValues)
// TODO: Because of the wrong method override, we have to compensate to prevent breaking
// changes right now
const groupedEvents = sharedContext.messageAggregator!.getMessages()
if (
Object.values(groupedEvents).flat().length > 0 &&
this.eventBusModuleService_
) {
const promises: Promise<void>[] = []
for (const group of Object.keys(groupedEvents)) {
promises.push(
this.eventBusModuleService_!.emit(groupedEvents[group], {
internal: true,
})
)
}
await Promise.all(promises)
sharedContext.messageAggregator.clearMessages()
}
return isString(idOrSelector)
? updatedProductOptionValues[0]
: updatedProductOptionValues
}
@InjectTransactionManager()
protected async updateProductOptionValues_(
normalizedInput: ({
id: string
} & ProductTypes.UpdateProductOptionValueDTO)[],
@MedusaContext() sharedContext: Context = {}
): Promise<InferEntityType<typeof ProductOptionValue>[]> {
return await this.productOptionValueService_.update(
normalizedInput,
sharedContext
)
}
/**
* Validates the manually provided handle value of the product
* to be URL-safe
*/
protected validateProductPayload(
productData: UpdateProductInput | ProductTypes.CreateProductDTO
) {
if (productData.handle && !isValidHandle(productData.handle)) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Invalid product handle '${productData.handle}'. It must contain URL safe characters`
)
}
}
protected validateProductCreatePayload(
productData: ProductTypes.CreateProductDTO
) {
this.validateProductPayload(productData)
if (!productData.title) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Product title is required`
)
}
const options = productData.options
const missingOptionsVariants: string[] = []
if (options?.length) {
productData.variants?.forEach((variant) => {
options.forEach((option) => {
if (!variant.options?.[option.title]) {
missingOptionsVariants.push(variant.title)
}
})
})
}
if (missingOptionsVariants.length) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Product "${
productData.title
}" has variants with missing options: [${missingOptionsVariants.join(
", "
)}]`
)
}
}
protected validateProductUpdatePayload(productData: UpdateProductInput) {
this.validateProductPayload(productData)
}
protected async normalizeCreateProductInput<
T extends ProductTypes.CreateProductDTO | ProductTypes.CreateProductDTO[],
TOutput = T extends ProductTypes.CreateProductDTO[]
? ProductTypes.CreateProductDTO[]
: ProductTypes.CreateProductDTO
>(
products: T,
@MedusaContext() sharedContext: Context = {}
): Promise<TOutput> {
const products_ = Array.isArray(products) ? products : [products]
const normalizedProducts = (await this.normalizeUpdateProductInput(
products_ as UpdateProductInput[]
)) as ProductTypes.CreateProductDTO[]
for (const productData of normalizedProducts) {
if (!productData.handle && productData.title) {
productData.handle = toHandle(productData.title)
}
if (!productData.status) {
productData.status = ProductStatus.DRAFT
}
if (!productData.thumbnail && productData.images?.length) {
productData.thumbnail = productData.images[0].url
}
// TODO: these props are typed as number, the model expect a string, the API expect number etc
// There is some inconsistency here, we should fix it
if ("weight" in productData) {
productData.weight = productData.weight?.toString() as any
}
if ("length" in productData) {
productData.length = productData.length?.toString() as any
}
if ("height" in productData) {
productData.height = productData.height?.toString() as any
}
if ("width" in productData) {
productData.width = productData.width?.toString() as any
}
if (productData.images?.length) {
productData.images = productData.images.map((image, index) =>
(image as { rank?: number }).rank != null
? image
: {
...image,
rank: index,
}
)
}
}
return (
Array.isArray(products) ? normalizedProducts : normalizedProducts[0]
) as TOutput
}
/**
* Normalizes the input for the update product input
* @param products - The products to normalize
* @param originalProducts - The original products to use for the normalization (must include options and option values relations)
* @returns The normalized products
*/
protected async normalizeUpdateProductInput<
T extends UpdateProductInput | UpdateProductInput[],
TOutput = T extends UpdateProductInput[]
? UpdateProductInput[]
: UpdateProductInput
>(
products: T,
originalProducts?: InferEntityType<typeof Product>[]
): Promise<TOutput> {
const products_ = Array.isArray(products) ? products : [products]
const productsIds = products_.map((p) => p.id).filter(Boolean)
let dbOptions: InferEntityType<typeof ProductOption>[] = []
if (productsIds.length) {
// Re map options to handle non serialized data as well
dbOptions =
originalProducts
?.map((originalProduct) =>
originalProduct.options.map((option) => option)
)
.flat()
.filter(Boolean) ?? []
}
const normalizedProducts: UpdateProductInput[] = []
for (const product of products_) {
const productData = { ...product }
if (productData.is_giftcard) {
productData.discountable = false
}
if (productData.options?.length) {
;(productData as any).options = productData.options?.map((option) => {
const dbOption = dbOptions.find(
(o) =>
(o.title === option.title || o.id === option.id) &&
o.product_id === productData.id
)
return {
title: option.title,
values: option.values?.map((value) => {
const dbValue = dbOption?.values?.find(
(val) => val.value === value
)
return {
value: value,
...(dbValue ? { id: dbValue.id } : {}),
}
}),
...(dbOption ? { id: dbOption.id } : {}),
}
})
}
if (productData.tag_ids) {
;(productData as any).tags = productData.tag_ids.map((cid) => ({
id: cid,
}))
delete productData.tag_ids
}
if (productData.category_ids) {
;(productData as any).categories = productData.category_ids.map(
(cid) => ({
id: cid,
})
)
delete productData.category_ids
}
normalizedProducts.push(productData)
}
return (
Array.isArray(products) ? normalizedProducts : normalizedProducts[0]
) as TOutput
}
protected static normalizeCreateProductCollectionInput(
collection: ProductTypes.CreateProductCollectionDTO
): ProductTypes.CreateProductCollectionDTO {
const collectionData =
ProductModuleService.normalizeUpdateProductCollectionInput(
collection
) as ProductTypes.CreateProductCollectionDTO
if (!collectionData.handle && collectionData.title) {
collectionData.handle = kebabCase(collectionData.title)
}
return collectionData
}
protected static normalizeUpdateProductCollectionInput(
collection: ProductTypes.CreateProductCollectionDTO | UpdateCollectionInput
): ProductTypes.CreateProductCollectionDTO | UpdateCollectionInput {
const collectionData = { ...collection }
if (Array.isArray(collectionData.product_ids)) {
;(collectionData as any).products = collectionData.product_ids.map(
(pid) => ({ id: pid })
)
delete collectionData.product_ids
}
return collectionData
}
protected static validateVariantOptions(
variants:
| ProductTypes.CreateProductVariantDTO[]
| ProductTypes.UpdateProductVariantDTO[],
options: InferEntityType<typeof ProductOption>[]
) {
const variantsWithOptions = ProductModuleService.assignOptionsToVariants(
variants.map((v) => ({
...v,
// adding product_id to the variant to make it valid for the assignOptionsToVariants function
...(options.length ? { product_id: options[0].product_id } : {}),
})),
options
)
ProductModuleService.checkIfVariantsHaveUniqueOptionsCombinations(
variantsWithOptions as any
)
}
protected static assignOptionsToVariants(
variants:
| ProductTypes.CreateProductVariantDTO[]
| ProductTypes.UpdateProductVariantDTO[],
options: InferEntityType<typeof ProductOption>[]
):
| ProductTypes.CreateProductVariantDTO[]
| ProductTypes.UpdateProductVariantDTO[] {
if (!variants.length) {
return variants
}
const variantsWithOptions = variants.map((variant: any) => {
const numOfProvidedVariantOptionValues = Object.keys(
variant.options || {}
).length
const productsOptions = options.filter(
(o) => o.product_id === variant.product_id
)
if (
numOfProvidedVariantOptionValues &&
productsOptions.length !== numOfProvidedVariantOptionValues
) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Product has ${productsOptions.length} option values but there were ${numOfProvidedVariantOptionValues} provided option values for the variant: ${variant.title}.`
)
}
const variantOptions = Object.entries(variant.options || {}).map(
([key, val]) => {
const option = productsOptions.find((o) => o.title === key)
const optionValue = option?.values?.find(
(v: any) => (v.value?.value ?? v.value) === val
)
if (!optionValue) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Option value ${val} does not exist for option ${key}`
)
}
return {
id: optionValue.id,
}
}
)
if (!variantOptions.length) {
return variant
}
return {
...variant,
options: variantOptions,
}
})
return variantsWithOptions
}
/**
* Validate that `data` doesn't create or update a variant to have same options combination
* as an existing variant on the product.
* @param data - create / update payloads
* @param variants - existing variants
* @protected
*/
protected static checkIfVariantWithOptionsAlreadyExists(
data: ((
| ProductTypes.CreateProductVariantDTO
| UpdateProductVariantInput
) & { options: { id: string }[]; product_id: string })[],
variants: InferEntityType<typeof ProductVariant>[]
) {
for (const variantData of data) {
const existingVariant = variants.find((v) => {
if (
variantData.product_id! !== v.product_id ||
!variantData.options?.length
) {
return false
}
if ((variantData as UpdateProductVariantInput)?.id === v.id) {
return false
}
return (variantData.options as unknown as { id: string }[])!.every(
(optionValue) => {
const variantOptionValue = v.options.find(
(vo) => vo.id === optionValue.id
)
return !!variantOptionValue
}
)
})
if (existingVariant) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Variant (${existingVariant.title}) with provided options already exists.`
)
}
}
}
/**
* Validate that array of variants that we are upserting doesn't have variants with the same options.
* @param variants -
* @protected
*/
protected static checkIfVariantsHaveUniqueOptionsCombinations(
variants: (ProductTypes.UpdateProductVariantDTO & {
options: { id: string }[]
})[]
) {
for (let i = 0; i < variants.length; i++) {
const variant = variants[i]
for (let j = i + 1; j < variants.length; j++) {
const compareVariant = variants[j]
const exists = variant.options?.every(
(optionValue) =>
!!compareVariant.options.find(
(compareOptionValue) => compareOptionValue.id === optionValue.id
)
)
if (exists) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Variant "${variant.title}" has same combination of option values as "${compareVariant.title}".`
)
}
}
}
}
@InjectManager()
// @ts-ignore
async listProductVariants(
filters?: ProductTypes.FilterableProductVariantProps,
config?: FindConfig<ProductTypes.ProductVariantDTO>,
@MedusaContext() sharedContext: Context = {}
): Promise<ProductTypes.ProductVariantDTO[]> {
const shouldLoadImages = config?.relations?.includes("images")
const relations = [...(config?.relations || [])]
if (shouldLoadImages) {
relations.push("product.images")
}
const variants = await this.productVariantService_.list(
filters,
{
...config,
relations,
},
sharedContext
)
if (shouldLoadImages) {
// Get variant images for all variants
const variantImagesMap = await this.getVariantImages(
variants,
sharedContext
)
for (const variant of variants) {
variant.images = variantImagesMap.get(variant.id) || []
}
}
return this.baseRepository_.serialize<ProductTypes.ProductVariantDTO[]>(
variants
)
}
@InjectManager()
// @ts-ignore
async listAndCountProductVariants(
filters?: ProductTypes.FilterableProductVariantProps,
config?: FindConfig<ProductTypes.ProductVariantDTO>,
@MedusaContext() sharedContext: Context = {}
): Promise<[ProductTypes.ProductVariantDTO[], number]> {
const shouldLoadImages = config?.relations?.includes("images")
const relations = [...(config?.relations || [])]
if (shouldLoadImages) {
relations.push("product.images")
}
const [variants, count] = await this.productVariantService_.listAndCount(
filters,
{
...config,
relations,
},
sharedContext
)
if (shouldLoadImages) {
// Get variant images for all variants
const variantImagesMap = await this.getVariantImages(
variants,
sharedContext
)
for (const variant of variants) {
variant.images = variantImagesMap.get(variant.id) || []
}
}
const serializedVariants = await this.baseRepository_.serialize<
ProductTypes.ProductVariantDTO[]
>(variants)
return [serializedVariants, count]
}
@InjectManager()
// @ts-ignore
async retrieveProductVariant(
id: string,
config?: FindConfig<any>,
@MedusaContext() sharedContext: Context = {}
): Promise<any> {
const shouldLoadImages = config?.relations?.includes("images")
const relations = [...(config?.relations || [])]
if (shouldLoadImages) {
relations.push("images", "product", "product.images")
}
const variant = await this.productVariantService_.retrieve(
id,
{
...config,
relations,
},
sharedContext
)
if (shouldLoadImages) {
const variantImages = await this.getVariantImages(
[variant],
sharedContext
)
variant.images = variantImages.get(id) || []
}
return this.baseRepository_.serialize(variant)
}
@InjectManager()
async addImageToVariant(
data: VariantImageInputArray,
@MedusaContext() sharedContext: Context = {}
): Promise<{ id: string }[]> {
const productVariantProductImage = await this.addImageToVariant_(
data,
sharedContext
)
return productVariantProductImage as { id: string }[]
}
@InjectTransactionManager()
protected async addImageToVariant_(
data: VariantImageInputArray,
@MedusaContext() sharedContext: Context = {}
): Promise<{ id: string } | { id: string }[]> {
// TODO: consider validation that image and variant are on the same product
const productVariantProductImage =
await this.productVariantProductImageService_.create(data, sharedContext)
return (
productVariantProductImage as unknown as InferEntityType<
typeof ProductVariantProductImage
>[]
).map((vi) => ({ id: vi.id }))
}
@InjectManager()
async removeImageFromVariant(
data: VariantImageInputArray,
@MedusaContext() sharedContext: Context = {}
): Promise<void> {
await this.removeImageFromVariant_(data, sharedContext)
}
@InjectTransactionManager()
protected async removeImageFromVariant_(
data: VariantImageInputArray,
@MedusaContext() sharedContext: Context = {}
): Promise<void> {
const pairs = Array.isArray(data) ? data : [data]
const productVariantProductImages =
await this.productVariantProductImageService_.list({
$or: pairs,
})
await this.productVariantProductImageService_.delete(
productVariantProductImages.map((p) => p.id as string),
sharedContext
)
}
@InjectManager()
private async getVariantImages(
variants: Pick<
InferEntityType<typeof ProductVariant>,
"id" | "product_id"
>[],
context: Context = {}
): Promise<Map<string, InferEntityType<typeof ProductImage>[]>> {
if (variants.length === 0) {
return new Map()
}
// Create lookup maps for efficient processing
const uniqueProductIds = new Set<string>()
// Build lookup maps
for (const variant of variants) {
if (variant.product_id) {
uniqueProductIds.add(variant.product_id)
}
}
const allProductImages = (await this.listProductImages(
{ product_id: Array.from(uniqueProductIds) },
{
relations: ["variants"],
},
context
)) as (ProductTypes.ProductImageDTO & {
product_id: string
variants: InferEntityType<typeof ProductVariant>[]
})[]
// all product images
const imagesByProductId = new Map<string, typeof allProductImages>()
// variant specific images
const variantSpecificImageIds = new Map<string, Set<string>>()
// Single pass to build both lookup maps
for (const img of allProductImages) {
// Group by product_id
if (!imagesByProductId.has(img.product_id)) {
imagesByProductId.set(img.product_id, [])
}
imagesByProductId.get(img.product_id)!.push(img)
// Track variant-specific images
if (img.variants.length > 0) {
for (const variant of img.variants) {
if (!variantSpecificImageIds.has(variant.id)) {
variantSpecificImageIds.set(variant.id, new Set())
}
variantSpecificImageIds.get(variant.id)!.add(img.id || "")
}
}
}
const result = new Map<string, InferEntityType<typeof ProductImage>[]>()
for (const variant of variants) {
const productId = variant.product_id!
const productImages = imagesByProductId.get(productId) || []
const specificImageIds =
variantSpecificImageIds.get(variant.id) || new Set()
const variantImages = productImages.filter((img) => {
// general product image
if (!img.variants.length) {
return true
}
// Check if this image is specifically associated with this variant
return specificImageIds.has(img.id || "")
})
result.set(
variant.id,
variantImages as InferEntityType<typeof ProductImage>[]
)
}
return result
}
}