Files
medusa-store/packages/modules/product/src/services/product-module-service.ts
T
2024-05-17 15:31:50 +05:30

1508 lines
46 KiB
TypeScript

import {
Context,
DAL,
IEventBusModuleService,
InternalModuleDeclaration,
ModuleJoinerConfig,
ModulesSdkTypes,
ProductTypes,
} from "@medusajs/types"
import {
Image,
Product,
ProductCategory,
ProductCollection,
ProductOption,
ProductOptionValue,
ProductTag,
ProductType,
ProductVariant,
} from "@models"
import { ProductCategoryService, ProductService } from "@services"
import {
arrayDifference,
InjectManager,
InjectTransactionManager,
isString,
kebabCase,
MedusaContext,
MedusaError,
ModulesSdkUtils,
ProductStatus,
promiseAll,
removeUndefined,
isValidHandle,
toHandle,
isPresent,
} from "@medusajs/utils"
import {
ProductCategoryEventData,
ProductCategoryEvents,
ProductCollectionEventData,
ProductCollectionEvents,
ProductEventData,
ProductEvents,
UpdateCollectionInput,
UpdateProductInput,
UpdateProductOptionInput,
UpdateProductVariantInput,
UpdateTypeInput,
} from "../types"
import { entityNameToLinkableKeysMap, joinerConfig } from "./../joiner-config"
type InjectedDependencies = {
baseRepository: DAL.RepositoryService
productService: ProductService<any>
productVariantService: ModulesSdkTypes.InternalModuleService<any, any>
productTagService: ModulesSdkTypes.InternalModuleService<any>
productCategoryService: ProductCategoryService<any>
productCollectionService: ModulesSdkTypes.InternalModuleService<any>
productImageService: ModulesSdkTypes.InternalModuleService<any>
productTypeService: ModulesSdkTypes.InternalModuleService<any>
productOptionService: ModulesSdkTypes.InternalModuleService<any>
productOptionValueService: ModulesSdkTypes.InternalModuleService<any>
eventBusModuleService?: IEventBusModuleService
}
const generateMethodForModels = [
{ model: ProductCategory, singular: "Category", plural: "Categories" },
{ model: ProductCollection, singular: "Collection", plural: "Collections" },
{ model: ProductOption, singular: "Option", plural: "Options" },
{ model: ProductTag, singular: "Tag", plural: "Tags" },
{ model: ProductType, singular: "Type", plural: "Types" },
{ model: ProductVariant, singular: "Variant", plural: "Variants" },
]
export default class ProductModuleService<
TProduct extends Product = Product,
TProductVariant extends ProductVariant = ProductVariant,
TProductTag extends ProductTag = ProductTag,
TProductCollection extends ProductCollection = ProductCollection,
TProductCategory extends ProductCategory = ProductCategory,
TProductImage extends Image = Image,
TProductType extends ProductType = ProductType,
TProductOption extends ProductOption = ProductOption,
TProductOptionValue extends ProductOptionValue = ProductOptionValue
>
extends ModulesSdkUtils.abstractModuleServiceFactory<
InjectedDependencies,
ProductTypes.ProductDTO,
{
ProductCategory: {
dto: ProductTypes.ProductCategoryDTO
singular: "Category"
plural: "Categories"
}
ProductCollection: {
dto: ProductTypes.ProductCollectionDTO
singular: "Collection"
plural: "Collections"
}
ProductOption: {
dto: ProductTypes.ProductOptionDTO
singular: "Option"
plural: "Options"
}
ProductTag: {
dto: ProductTypes.ProductTagDTO
singular: "Tag"
plural: "Tags"
}
ProductType: {
dto: ProductTypes.ProductTypeDTO
singular: "Type"
plural: "Types"
}
ProductVariant: {
dto: ProductTypes.ProductVariantDTO
singular: "Variant"
plural: "Variants"
}
}
>(Product, generateMethodForModels, entityNameToLinkableKeysMap)
implements ProductTypes.IProductModuleService
{
protected baseRepository_: DAL.RepositoryService
protected readonly productService_: ProductService<TProduct>
// eslint-disable-next-line max-len
protected readonly productVariantService_: ModulesSdkTypes.InternalModuleService<TProductVariant>
// eslint-disable-next-line max-len
protected readonly productCategoryService_: ProductCategoryService<TProductCategory>
// eslint-disable-next-line max-len
protected readonly productTagService_: ModulesSdkTypes.InternalModuleService<TProductTag>
// eslint-disable-next-line max-len
protected readonly productCollectionService_: ModulesSdkTypes.InternalModuleService<TProductCollection>
// eslint-disable-next-line max-len
protected readonly productImageService_: ModulesSdkTypes.InternalModuleService<TProductImage>
// eslint-disable-next-line max-len
protected readonly productTypeService_: ModulesSdkTypes.InternalModuleService<TProductType>
// eslint-disable-next-line max-len
protected readonly productOptionService_: ModulesSdkTypes.InternalModuleService<TProductOption>
// eslint-disable-next-line max-len
protected readonly productOptionValueService_: ModulesSdkTypes.InternalModuleService<TProductOptionValue>
protected readonly eventBusModuleService_?: IEventBusModuleService
constructor(
{
baseRepository,
productService,
productVariantService,
productTagService,
productCategoryService,
productCollectionService,
productImageService,
productTypeService,
productOptionService,
productOptionValueService,
eventBusModuleService,
}: InjectedDependencies,
protected readonly moduleDeclaration: InternalModuleDeclaration
) {
// @ts-ignore
// eslint-disable-next-line prefer-rest-params
super(...arguments)
this.baseRepository_ = baseRepository
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.eventBusModuleService_ = eventBusModuleService
}
__joinerConfig(): ModuleJoinerConfig {
return joinerConfig
}
// TODO: Add options validation, among other things
createVariants(
data: ProductTypes.CreateProductVariantDTO[],
sharedContext?: Context
): Promise<ProductTypes.ProductVariantDTO[]>
createVariants(
data: ProductTypes.CreateProductVariantDTO,
sharedContext?: Context
): Promise<ProductTypes.ProductVariantDTO>
@InjectManager("baseRepository_")
async createVariants(
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("baseRepository_")
protected async createVariants_(
data: ProductTypes.CreateProductVariantDTO[],
@MedusaContext() sharedContext: Context = {}
): Promise<ProductVariant[]> {
if (data.some((v) => !v.product_id)) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"Tried 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!))],
},
{
take: null,
relations: ["values"],
},
sharedContext
)
const productVariantsWithOptions =
ProductModuleService.assignOptionsToVariants(data, productOptions)
return await this.productVariantService_.create(
productVariantsWithOptions,
sharedContext
)
}
async upsertVariants(
data: ProductTypes.UpsertProductVariantDTO[],
sharedContext?: Context
): Promise<ProductTypes.ProductVariantDTO[]>
async upsertVariants(
data: ProductTypes.UpsertProductVariantDTO,
sharedContext?: Context
): Promise<ProductTypes.ProductVariantDTO>
@InjectTransactionManager("baseRepository_")
async upsertVariants(
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: ProductVariant[] = []
let updated: ProductVariant[] = []
if (forCreate.length) {
created = await this.createVariants_(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]
}
updateVariants(
id: string,
data: ProductTypes.UpdateProductVariantDTO,
sharedContext?: Context
): Promise<ProductTypes.ProductVariantDTO>
updateVariants(
selector: ProductTypes.FilterableProductVariantProps,
data: ProductTypes.UpdateProductVariantDTO,
sharedContext?: Context
): Promise<ProductTypes.ProductVariantDTO[]>
@InjectManager("baseRepository_")
async updateVariants(
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("baseRepository_")
protected async updateVariants_(
data: UpdateProductVariantInput[],
@MedusaContext() sharedContext: Context = {}
): Promise<TProductVariant[]> {
// Validation step
const variantIdsToUpdate = data.map(({ id }) => id)
const variants = await this.productVariantService_.list(
{ id: variantIdsToUpdate },
{ take: null },
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!))
),
},
{ take: null, relations: ["values"] },
sharedContext
)
return this.productVariantService_.upsertWithReplace(
ProductModuleService.assignOptionsToVariants(
variantsWithProductId,
productOptions
),
{
relations: ["options"],
},
sharedContext
)
}
@InjectTransactionManager("baseRepository_")
async createTags(
data: ProductTypes.CreateProductTagDTO[],
@MedusaContext() sharedContext: Context = {}
): Promise<ProductTypes.ProductTagDTO[]> {
const productTags = await this.productTagService_.create(
data,
sharedContext
)
return await this.baseRepository_.serialize(productTags)
}
@InjectTransactionManager("baseRepository_")
async updateTags(
data: ProductTypes.UpdateProductTagDTO[],
@MedusaContext() sharedContext: Context = {}
): Promise<ProductTypes.ProductTagDTO[]> {
const productTags = await this.productTagService_.update(
data,
sharedContext
)
return await this.baseRepository_.serialize(productTags)
}
createTypes(
data: ProductTypes.CreateProductTypeDTO[],
sharedContext?: Context
): Promise<ProductTypes.ProductTypeDTO[]>
createTypes(
data: ProductTypes.CreateProductTypeDTO,
sharedContext?: Context
): Promise<ProductTypes.ProductTypeDTO>
@InjectManager("baseRepository_")
async createTypes(
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 upsertTypes(
data: ProductTypes.UpsertProductTypeDTO[],
sharedContext?: Context
): Promise<ProductTypes.ProductTypeDTO[]>
async upsertTypes(
data: ProductTypes.UpsertProductTypeDTO,
sharedContext?: Context
): Promise<ProductTypes.ProductTypeDTO>
@InjectTransactionManager("baseRepository_")
async upsertTypes(
data:
| ProductTypes.UpsertProductTypeDTO[]
| ProductTypes.UpsertProductTypeDTO,
@MedusaContext() sharedContext: Context = {}
): Promise<ProductTypes.ProductTypeDTO[] | ProductTypes.ProductTypeDTO> {
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: ProductType[] = []
let updated: ProductType[] = []
if (forCreate.length) {
created = await this.productTypeService_.create(forCreate, sharedContext)
}
if (forUpdate.length) {
updated = await this.productTypeService_.update(forUpdate, sharedContext)
}
const result = [...created, ...updated]
const allTypes = await this.baseRepository_.serialize<
ProductTypes.ProductTypeDTO[] | ProductTypes.ProductTypeDTO
>(result)
return Array.isArray(data) ? allTypes : allTypes[0]
}
updateTypes(
id: string,
data: ProductTypes.UpdateProductTypeDTO,
sharedContext?: Context
): Promise<ProductTypes.ProductTypeDTO>
updateTypes(
selector: ProductTypes.FilterableProductTypeProps,
data: ProductTypes.UpdateProductTypeDTO,
sharedContext?: Context
): Promise<ProductTypes.ProductTypeDTO[]>
@InjectManager("baseRepository_")
async updateTypes(
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
}
createOptions(
data: ProductTypes.CreateProductOptionDTO[],
sharedContext?: Context
): Promise<ProductTypes.ProductOptionDTO[]>
createOptions(
data: ProductTypes.CreateProductOptionDTO,
sharedContext?: Context
): Promise<ProductTypes.ProductOptionDTO>
@InjectManager("baseRepository_")
async createOptions(
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("baseRepository_")
protected async createOptions_(
data: ProductTypes.CreateProductOptionDTO[],
@MedusaContext() sharedContext: Context = {}
): Promise<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 upsertOptions(
data: ProductTypes.UpsertProductOptionDTO[],
sharedContext?: Context
): Promise<ProductTypes.ProductOptionDTO[]>
async upsertOptions(
data: ProductTypes.UpsertProductOptionDTO,
sharedContext?: Context
): Promise<ProductTypes.ProductOptionDTO>
@InjectTransactionManager("baseRepository_")
async upsertOptions(
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: ProductOption[] = []
let updated: 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]
}
updateOptions(
id: string,
data: ProductTypes.UpdateProductOptionDTO,
sharedContext?: Context
): Promise<ProductTypes.ProductOptionDTO>
updateOptions(
selector: ProductTypes.FilterableProductOptionProps,
data: ProductTypes.UpdateProductOptionDTO,
sharedContext?: Context
): Promise<ProductTypes.ProductOptionDTO[]>
@InjectManager("baseRepository_")
async updateOptions(
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("baseRepository_")
protected async updateOptions_(
data: UpdateProductOptionInput[],
@MedusaContext() sharedContext: Context = {}
): Promise<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) },
{ take: null, 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
})
return await this.productOptionService_.upsertWithReplace(
normalizedInput,
{ relations: ["values"] },
sharedContext
)
}
createCollections(
data: ProductTypes.CreateProductCollectionDTO[],
sharedContext?: Context
): Promise<ProductTypes.ProductCollectionDTO[]>
createCollections(
data: ProductTypes.CreateProductCollectionDTO,
sharedContext?: Context
): Promise<ProductTypes.ProductCollectionDTO>
@InjectManager("baseRepository_")
async createCollections(
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)
await this.eventBusModuleService_?.emit<ProductCollectionEventData>(
collections.map(({ id }) => ({
eventName: ProductCollectionEvents.COLLECTION_CREATED,
data: { id },
}))
)
return Array.isArray(data) ? createdCollections : createdCollections[0]
}
@InjectTransactionManager("baseRepository_")
async createCollections_(
data: ProductTypes.CreateProductCollectionDTO[],
@MedusaContext() sharedContext: Context = {}
): Promise<TProductCollection[]> {
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
return await this.productCollectionService_.upsertWithReplace(
normalizedInput,
{ relations: ["products"] },
sharedContext
)
}
async upsertCollections(
data: ProductTypes.UpsertProductCollectionDTO[],
sharedContext?: Context
): Promise<ProductTypes.ProductCollectionDTO[]>
async upsertCollections(
data: ProductTypes.UpsertProductCollectionDTO,
sharedContext?: Context
): Promise<ProductTypes.ProductCollectionDTO>
@InjectTransactionManager("baseRepository_")
async upsertCollections(
data:
| ProductTypes.UpsertProductCollectionDTO[]
| ProductTypes.UpsertProductCollectionDTO,
@MedusaContext() sharedContext: Context = {}
): Promise<
ProductTypes.ProductCollectionDTO[] | ProductTypes.ProductCollectionDTO
> {
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: ProductCollection[] = []
let updated: ProductCollection[] = []
if (forCreate.length) {
created = await this.createCollections_(forCreate, sharedContext)
}
if (forUpdate.length) {
updated = await this.updateCollections_(forUpdate, sharedContext)
}
const result = [...created, ...updated]
const allCollections = await this.baseRepository_.serialize<
ProductTypes.ProductCollectionDTO[] | ProductTypes.ProductCollectionDTO
>(result)
if (created.length) {
await this.eventBusModuleService_?.emit<ProductCollectionEventData>(
created.map(({ id }) => ({
eventName: ProductCollectionEvents.COLLECTION_CREATED,
data: { id },
}))
)
}
if (updated.length) {
await this.eventBusModuleService_?.emit<ProductCollectionEventData>(
updated.map(({ id }) => ({
eventName: ProductCollectionEvents.COLLECTION_UPDATED,
data: { id },
}))
)
}
return Array.isArray(data) ? allCollections : allCollections[0]
}
updateCollections(
id: string,
data: ProductTypes.UpdateProductCollectionDTO,
sharedContext?: Context
): Promise<ProductTypes.ProductCollectionDTO>
updateCollections(
selector: ProductTypes.FilterableProductCollectionProps,
data: ProductTypes.UpdateProductCollectionDTO,
sharedContext?: Context
): Promise<ProductTypes.ProductCollectionDTO[]>
@InjectManager("baseRepository_")
async updateCollections(
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)
await this.eventBusModuleService_?.emit<ProductCollectionEventData>(
updatedCollections.map(({ id }) => ({
eventName: ProductCollectionEvents.COLLECTION_UPDATED,
data: { id },
}))
)
return isString(idOrSelector) ? updatedCollections[0] : updatedCollections
}
@InjectTransactionManager("baseRepository_")
protected async updateCollections_(
data: UpdateCollectionInput[],
@MedusaContext() sharedContext: Context = {}
): Promise<TProductCollection[]> {
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: TProductCollection[] = []
const updateSelectorAndData = updatedCollections.flatMap(
(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 (!!productsToUpdate?.length) {
const productIds = productsToUpdate.map((p) => p.id)
dissociateSelector["id"] = { $nin: productIds }
associateSelector["id"] = { $in: productIds }
}
const result: Record<string, any>[] = [
{
selector: dissociateSelector,
data: {
collection_id: null,
},
},
]
if (isPresent(associateSelector)) {
result.push({
selector: associateSelector,
data: {
collection_id: collectionData.id,
},
})
}
collections.push({
...collectionData,
products: productsToUpdate ?? [],
})
return result
}
)
await this.productService_.update(updateSelectorAndData, sharedContext)
return collections
}
@InjectManager("baseRepository_")
async createCategory(
data: ProductTypes.CreateProductCategoryDTO,
@MedusaContext() sharedContext: Context = {}
): Promise<ProductTypes.ProductCategoryDTO> {
const result = await this.createCategory_(data, sharedContext)
return await this.baseRepository_.serialize(result)
}
@InjectTransactionManager("baseRepository_")
async createCategory_(
data: ProductTypes.CreateProductCategoryDTO,
@MedusaContext() sharedContext: Context = {}
): Promise<ProductCategory> {
const productCategory = await this.productCategoryService_.create(
data,
sharedContext
)
await this.eventBusModuleService_?.emit<ProductCategoryEventData>(
ProductCategoryEvents.CATEGORY_CREATED,
{ id: productCategory.id }
)
return productCategory
}
@InjectTransactionManager("baseRepository_")
async updateCategory(
categoryId: string,
data: ProductTypes.UpdateProductCategoryDTO,
@MedusaContext() sharedContext: Context = {}
): Promise<ProductTypes.ProductCategoryDTO> {
const productCategory = await this.productCategoryService_.update(
categoryId,
data,
sharedContext
)
await this.eventBusModuleService_?.emit<ProductCategoryEventData>(
ProductCategoryEvents.CATEGORY_UPDATED,
{ id: productCategory.id }
)
return await this.baseRepository_.serialize(productCategory, {
populate: true,
})
}
@InjectTransactionManager("baseRepository_")
async deleteCategory(
categoryId: string,
@MedusaContext() sharedContext: Context = {}
): Promise<void> {
await this.productCategoryService_.delete(categoryId, sharedContext)
await this.eventBusModuleService_?.emit<ProductCategoryEventData>(
ProductCategoryEvents.CATEGORY_DELETED,
{ id: categoryId }
)
}
create(
data: ProductTypes.CreateProductDTO[],
sharedContext?: Context
): Promise<ProductTypes.ProductDTO[]>
create(
data: ProductTypes.CreateProductDTO,
sharedContext?: Context
): Promise<ProductTypes.ProductDTO>
@InjectManager("baseRepository_")
async create(
data: ProductTypes.CreateProductDTO[] | ProductTypes.CreateProductDTO,
@MedusaContext() sharedContext: Context = {}
): Promise<ProductTypes.ProductDTO[] | ProductTypes.ProductDTO> {
const input = Array.isArray(data) ? data : [data]
const products = await this.create_(input, sharedContext)
const createdProducts = await this.baseRepository_.serialize<
ProductTypes.ProductDTO[]
>(products)
await this.eventBusModuleService_?.emit<ProductEventData>(
createdProducts.map(({ id }) => ({
eventName: ProductEvents.PRODUCT_CREATED,
data: { id },
}))
)
return Array.isArray(data) ? createdProducts : createdProducts[0]
}
async upsert(
data: ProductTypes.UpsertProductDTO[],
sharedContext?: Context
): Promise<ProductTypes.ProductDTO[]>
async upsert(
data: ProductTypes.UpsertProductDTO,
sharedContext?: Context
): Promise<ProductTypes.ProductDTO>
@InjectTransactionManager("baseRepository_")
async upsert(
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: Product[] = []
let updated: Product[] = []
if (forCreate.length) {
created = await this.create_(forCreate, sharedContext)
}
if (forUpdate.length) {
updated = await this.update_(forUpdate, sharedContext)
}
const result = [...created, ...updated]
const allProducts = await this.baseRepository_.serialize<
ProductTypes.ProductDTO[] | ProductTypes.ProductDTO
>(result)
if (created.length) {
await this.eventBusModuleService_?.emit<ProductEventData>(
created.map(({ id }) => ({
eventName: ProductEvents.PRODUCT_CREATED,
data: { id },
}))
)
}
if (updated.length) {
await this.eventBusModuleService_?.emit<ProductEventData>(
updated.map(({ id }) => ({
eventName: ProductEvents.PRODUCT_UPDATED,
data: { id },
}))
)
}
return Array.isArray(data) ? allProducts : allProducts[0]
}
update(
id: string,
data: ProductTypes.UpdateProductDTO,
sharedContext?: Context
): Promise<ProductTypes.ProductDTO>
update(
selector: ProductTypes.FilterableProductProps,
data: ProductTypes.UpdateProductDTO,
sharedContext?: Context
): Promise<ProductTypes.ProductDTO[]>
@InjectManager("baseRepository_")
async update(
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.update_(normalizedInput, sharedContext)
const updatedProducts = await this.baseRepository_.serialize<
ProductTypes.ProductDTO[]
>(products)
await this.eventBusModuleService_?.emit<ProductEventData>(
updatedProducts.map(({ id }) => ({
eventName: ProductEvents.PRODUCT_UPDATED,
data: { id },
}))
)
return isString(idOrSelector) ? updatedProducts[0] : updatedProducts
}
@InjectTransactionManager("baseRepository_")
protected async create_(
data: ProductTypes.CreateProductDTO[],
@MedusaContext() sharedContext: Context = {}
): Promise<TProduct[]> {
const normalizedInput = await promiseAll(
data.map(async (d) => {
const normalized = await this.normalizeCreateProductInput(
d,
sharedContext
)
this.validateProductPayload(normalized)
return normalized
})
)
const productData = await this.productService_.upsertWithReplace(
normalizedInput,
{
relations: ["images", "tags", "categories"],
},
sharedContext
)
await promiseAll(
// Note: It's safe to rely on the order here as `upsertWithReplace` preserves the order of the input
normalizedInput.map(async (product, i) => {
const upsertedProduct: any = productData[i]
upsertedProduct.options = []
upsertedProduct.variants = []
if (product.options?.length) {
upsertedProduct.options =
await this.productOptionService_.upsertWithReplace(
product.options?.map((option) => ({
...option,
product_id: upsertedProduct.id,
})) ?? [],
{ relations: ["values"] },
sharedContext
)
}
if (product.variants?.length) {
upsertedProduct.variants =
await this.productVariantService_.upsertWithReplace(
ProductModuleService.assignOptionsToVariants(
product.variants?.map((v) => ({
...v,
product_id: upsertedProduct.id,
})) ?? [],
upsertedProduct.options
),
{ relations: ["options"] },
sharedContext
)
}
})
)
return productData
}
@InjectTransactionManager("baseRepository_")
protected async update_(
data: UpdateProductInput[],
@MedusaContext() sharedContext: Context = {}
): Promise<TProduct[]> {
const normalizedInput = await promiseAll(
data.map(async (d) => {
const normalized = await this.normalizeUpdateProductInput(
d,
sharedContext
)
this.validateProductPayload(normalized)
return normalized
})
)
const productData = await this.productService_.upsertWithReplace(
normalizedInput,
{
relations: ["images", "tags", "categories"],
},
sharedContext
)
// There is more than 1-level depth of relations here, so we need to handle the options and variants manually
await promiseAll(
// Note: It's safe to rely on the order here as `upsertWithReplace` preserves the order of the input
normalizedInput.map(async (product, i) => {
const upsertedProduct: any = productData[i]
let allOptions: any[] = []
if (product.options?.length) {
upsertedProduct.options =
await this.productOptionService_.upsertWithReplace(
product.options?.map((option) => ({
...option,
product_id: upsertedProduct.id,
})) ?? [],
{ relations: ["values"] },
sharedContext
)
// Since we handle the options and variants outside of the product upsert, we need to clean up manually
await this.productOptionService_.delete(
{
product_id: upsertedProduct.id,
id: {
$nin: upsertedProduct.options.map(({ id }) => id),
},
},
sharedContext
)
allOptions = upsertedProduct.options
} else {
// If the options weren't affected, but the user is changing the variants, make sure we have all options available locally
if (product.variants?.length) {
allOptions = await this.productOptionService_.list(
{ product_id: upsertedProduct.id },
{ take: null, relations: ["values"] },
sharedContext
)
}
}
if (product.variants?.length) {
upsertedProduct.variants =
await this.productVariantService_.upsertWithReplace(
ProductModuleService.assignOptionsToVariants(
product.variants?.map((v) => ({
...v,
product_id: upsertedProduct.id,
})) ?? [],
allOptions
),
{ relations: ["options"] },
sharedContext
)
await this.productVariantService_.delete(
{
product_id: upsertedProduct.id,
id: {
$nin: upsertedProduct.variants.map(({ id }) => id),
},
},
sharedContext
)
}
})
)
return productData
}
/**
* 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. It must contain URL safe characters"
)
}
}
protected async normalizeCreateProductInput(
product: ProductTypes.CreateProductDTO,
@MedusaContext() sharedContext: Context = {}
): Promise<ProductTypes.CreateProductDTO> {
const productData = (await this.normalizeUpdateProductInput(
product as UpdateProductInput,
sharedContext
)) as ProductTypes.CreateProductDTO
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
}
return productData
}
protected async normalizeUpdateProductInput(
product: UpdateProductInput,
@MedusaContext() sharedContext: Context = {}
): Promise<UpdateProductInput> {
const productData = { ...product }
if (productData.is_giftcard) {
productData.discountable = false
}
if (productData.tags?.length && productData.tags.some((t) => !t.id)) {
const dbTags = await this.productTagService_.list(
{
value: productData.tags
.map((t) => t.value)
.filter((v) => !!v) as string[],
},
{ take: null },
sharedContext
)
productData.tags = productData.tags.map((tag) => {
const dbTag = dbTags.find((t) => t.value === tag.value)
return {
...tag,
...(dbTag ? { id: dbTag.id } : {}),
}
})
}
if (productData.options?.length) {
;(productData as any).options = productData.options?.map((option) => {
return {
title: option.title,
values: option.values?.map((value) => {
return {
value: value,
}
}),
}
})
}
if (productData.category_ids) {
;(productData as any).categories = productData.category_ids.map(
(cid) => ({
id: cid,
})
)
delete productData.category_ids
}
return productData
}
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 (collectionData.product_ids?.length) {
;(collectionData as any).products = collectionData.product_ids.map(
(pid) => ({
id: pid,
})
)
delete collectionData.product_ids
}
return collectionData
}
protected static assignOptionsToVariants(
variants:
| ProductTypes.CreateProductVariantDTO[]
| ProductTypes.UpdateProductVariantDTO[],
options: ProductOption[]
):
| ProductTypes.CreateProductVariantDTO[]
| ProductTypes.UpdateProductVariantDTO[] {
if (!variants.length) {
return variants
}
const variantsWithOptions = variants.map((variant: any) => {
const variantOptions = Object.entries(variant.options ?? {}).map(
([key, val]) => {
const option = options.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
}
}