695 lines
20 KiB
JavaScript
695 lines
20 KiB
JavaScript
import _ from "lodash"
|
|
import { MedusaError } from "medusa-core-utils"
|
|
import { BaseService } from "medusa-interfaces"
|
|
import { Brackets } from "typeorm"
|
|
|
|
/**
|
|
* Provides layer to manipulate products.
|
|
* @implements BaseService
|
|
*/
|
|
class ProductService extends BaseService {
|
|
static Events = {
|
|
UPDATED: "product.updated",
|
|
CREATED: "product.created",
|
|
}
|
|
|
|
constructor({
|
|
manager,
|
|
productRepository,
|
|
productVariantRepository,
|
|
productOptionRepository,
|
|
eventBusService,
|
|
productVariantService,
|
|
productCollectionService,
|
|
productTypeRepository,
|
|
productTagRepository,
|
|
}) {
|
|
super()
|
|
|
|
/** @private @const {EntityManager} */
|
|
this.manager_ = manager
|
|
|
|
/** @private @const {ProductOption} */
|
|
this.productOptionRepository_ = productOptionRepository
|
|
|
|
/** @private @const {Product} */
|
|
this.productRepository_ = productRepository
|
|
|
|
/** @private @const {ProductVariant} */
|
|
this.productVariantRepository_ = productVariantRepository
|
|
|
|
/** @private @const {EventBus} */
|
|
this.eventBus_ = eventBusService
|
|
|
|
/** @private @const {ProductVariantService} */
|
|
this.productVariantService_ = productVariantService
|
|
|
|
/** @private @const {ProductCollectionService} */
|
|
this.productCollectionService_ = productCollectionService
|
|
|
|
/** @private @const {ProductCollectionService} */
|
|
this.productTypeRepository_ = productTypeRepository
|
|
|
|
/** @private @const {ProductCollectionService} */
|
|
this.productTagRepository_ = productTagRepository
|
|
}
|
|
|
|
withTransaction(transactionManager) {
|
|
if (!transactionManager) {
|
|
return this
|
|
}
|
|
|
|
const cloned = new ProductService({
|
|
manager: transactionManager,
|
|
productRepository: this.productRepository_,
|
|
productVariantRepository: this.productVariantRepository_,
|
|
productOptionRepository: this.productOptionRepository_,
|
|
eventBusService: this.eventBus_,
|
|
productVariantService: this.productVariantService_,
|
|
productCollectionService: this.productCollectionService_,
|
|
productTagRepository: this.productTagRepository_,
|
|
productTypeRepository: this.productTypeRepository_,
|
|
})
|
|
|
|
cloned.transactionManager_ = transactionManager
|
|
|
|
return cloned
|
|
}
|
|
|
|
/**
|
|
* @param {Object} listOptions - the query object for find
|
|
* @return {Promise} the result of the find operation
|
|
*/
|
|
list(selector = {}, config = { relations: [], skip: 0, take: 20 }) {
|
|
const productRepo = this.manager_.getCustomRepository(
|
|
this.productRepository_
|
|
)
|
|
|
|
let q
|
|
if ("q" in selector) {
|
|
q = selector.q
|
|
delete selector.q
|
|
}
|
|
|
|
const query = this.buildQuery_(selector, config)
|
|
|
|
if (config.relations && config.relations.length > 0) {
|
|
query.relations = config.relations
|
|
}
|
|
|
|
if (config.select && config.select.length > 0) {
|
|
query.select = config.select
|
|
}
|
|
|
|
const rels = query.relations
|
|
delete query.relations
|
|
|
|
if (q) {
|
|
const where = query.where
|
|
|
|
delete where.description
|
|
delete where.title
|
|
|
|
query.join = {
|
|
alias: "product",
|
|
leftJoinAndSelect: {
|
|
variant: "product.variants",
|
|
collection: "product.collection",
|
|
},
|
|
}
|
|
|
|
query.where = qb => {
|
|
qb.where(where)
|
|
|
|
qb.andWhere(
|
|
new Brackets(qb => {
|
|
qb.where(`product.title ILIKE :q`, { q: `%${q}%` })
|
|
.orWhere(`product.description ILIKE :q`, { q: `%${q}%` })
|
|
.orWhere(`variant.title ILIKE :q`, { q: `%${q}%` })
|
|
.orWhere(`variant.sku ILIKE :q`, { q: `%${q}%` })
|
|
.orWhere(`collection.title ILIKE :q`, { q: `%${q}%` })
|
|
})
|
|
)
|
|
}
|
|
}
|
|
|
|
return productRepo.findWithRelations(rels, query)
|
|
}
|
|
|
|
/**
|
|
* Return the total number of documents in database
|
|
* @return {Promise} the result of the count operation
|
|
*/
|
|
count() {
|
|
const productRepo = this.manager_.getCustomRepository(
|
|
this.productRepository_
|
|
)
|
|
return productRepo.count()
|
|
}
|
|
|
|
/**
|
|
* Gets a product by id.
|
|
* Throws in case of DB Error and if product was not found.
|
|
* @param {string} productId - id of the product to get.
|
|
* @return {Promise<Product>} the result of the find one operation.
|
|
*/
|
|
async retrieve(productId, config = {}) {
|
|
const productRepo = this.manager_.getCustomRepository(
|
|
this.productRepository_
|
|
)
|
|
const validatedId = this.validateId_(productId)
|
|
|
|
const query = { where: { id: validatedId } }
|
|
|
|
if (config.relations && config.relations.length > 0) {
|
|
query.relations = config.relations
|
|
}
|
|
|
|
if (config.select && config.select.length > 0) {
|
|
query.select = config.select
|
|
}
|
|
|
|
const rels = query.relations
|
|
delete query.relations
|
|
const product = await productRepo.findOneWithRelations(rels, query)
|
|
|
|
if (!product) {
|
|
throw new MedusaError(
|
|
MedusaError.Types.NOT_FOUND,
|
|
`Product with id: ${productId} was not found`
|
|
)
|
|
}
|
|
|
|
return product
|
|
}
|
|
|
|
/**
|
|
* Gets all variants belonging to a product.
|
|
* @param {string} productId - the id of the product to get variants from.
|
|
* @return {Promise} an array of variants
|
|
*/
|
|
async retrieveVariants(productId) {
|
|
const product = await this.retrieve(productId, { relations: ["variants"] })
|
|
return product.variants
|
|
}
|
|
|
|
async listTypes() {
|
|
const productTypeRepository = this.manager_.getCustomRepository(
|
|
this.productTypeRepository_
|
|
)
|
|
|
|
return await productTypeRepository.find({})
|
|
}
|
|
|
|
async listTagsByUsage(count = 10) {
|
|
const tags = await this.manager_.query(
|
|
`
|
|
SELECT ID, O.USAGE_COUNT, PT.VALUE
|
|
FROM PRODUCT_TAG PT
|
|
LEFT JOIN
|
|
(SELECT COUNT(*) AS USAGE_COUNT,
|
|
PRODUCT_TAG_ID
|
|
FROM PRODUCT_TAGS
|
|
GROUP BY PRODUCT_TAG_ID) O ON O.PRODUCT_TAG_ID = PT.ID
|
|
ORDER BY O.USAGE_COUNT DESC
|
|
LIMIT $1`,
|
|
[count]
|
|
)
|
|
|
|
return tags
|
|
}
|
|
|
|
async upsertProductType_(type) {
|
|
const productTypeRepository = this.manager_.getCustomRepository(
|
|
this.productTypeRepository_
|
|
)
|
|
|
|
if (type === null) {
|
|
return null
|
|
}
|
|
|
|
const existing = await productTypeRepository.findOne({
|
|
where: { value: type.value },
|
|
})
|
|
|
|
if (existing) {
|
|
return existing
|
|
}
|
|
|
|
const created = productTypeRepository.create(type)
|
|
const result = await productTypeRepository.save(created)
|
|
|
|
return result.id
|
|
}
|
|
|
|
async upsertProductTags_(tags) {
|
|
const productTagRepository = this.manager_.getCustomRepository(
|
|
this.productTagRepository_
|
|
)
|
|
|
|
let newTags = []
|
|
for (const tag of tags) {
|
|
const existing = await productTagRepository.findOne({
|
|
where: { value: tag.value },
|
|
})
|
|
|
|
if (existing) {
|
|
newTags.push(existing)
|
|
} else {
|
|
const created = productTagRepository.create(tag)
|
|
const result = await productTagRepository.save(created)
|
|
newTags.push(result)
|
|
}
|
|
}
|
|
|
|
return newTags
|
|
}
|
|
|
|
/**
|
|
* Creates a product.
|
|
* @param {object} productObject - the product to create
|
|
* @return {Promise} resolves to the creation result.
|
|
*/
|
|
async create(productObject) {
|
|
return this.atomicPhase_(async manager => {
|
|
const productRepo = manager.getCustomRepository(this.productRepository_)
|
|
const optionRepo = manager.getCustomRepository(
|
|
this.productOptionRepository_
|
|
)
|
|
|
|
const { options, tags, type, ...rest } = productObject
|
|
|
|
let product = productRepo.create(rest)
|
|
|
|
if (tags) {
|
|
product.tags = await this.upsertProductTags_(tags)
|
|
}
|
|
|
|
if (typeof type !== `undefined`) {
|
|
product.type_id = await this.upsertProductType_(type)
|
|
}
|
|
|
|
product = await productRepo.save(product)
|
|
|
|
product.options = await Promise.all(
|
|
options.map(async o => {
|
|
const res = optionRepo.create({ ...o, product_id: product.id })
|
|
await optionRepo.save(res)
|
|
return res
|
|
})
|
|
)
|
|
|
|
const result = await this.retrieve(product.id, { relations: ["options"] })
|
|
|
|
await this.eventBus_
|
|
.withTransaction(manager)
|
|
.emit(ProductService.Events.CREATED, {
|
|
id: result.id,
|
|
})
|
|
return result
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Updates a product. Product variant updates should use dedicated methods,
|
|
* e.g. `addVariant`, etc. The function will throw errors if metadata or
|
|
* product variant updates are attempted.
|
|
* @param {string} productId - the id of the product. Must be a string that
|
|
* can be casted to an ObjectId
|
|
* @param {object} update - an object with the update values.
|
|
* @return {Promise} resolves to the update result.
|
|
*/
|
|
async update(productId, update) {
|
|
return this.atomicPhase_(async manager => {
|
|
const productRepo = manager.getCustomRepository(this.productRepository_)
|
|
const productVariantRepo = manager.getCustomRepository(
|
|
this.productVariantRepository_
|
|
)
|
|
|
|
const product = await this.retrieve(productId, {
|
|
relations: ["variants", "tags"],
|
|
})
|
|
|
|
const {
|
|
variants,
|
|
metadata,
|
|
options,
|
|
images,
|
|
tags,
|
|
type,
|
|
...rest
|
|
} = update
|
|
|
|
if (!product.thumbnail && !update.thumbnail && images && images.length) {
|
|
product.thumbnail = images[0]
|
|
}
|
|
|
|
if (metadata) {
|
|
product.metadata = this.setMetadata_(product, metadata)
|
|
}
|
|
|
|
if (typeof type !== `undefined`) {
|
|
product.type_id = await this.upsertProductType_(type)
|
|
}
|
|
|
|
if (tags) {
|
|
product.tags = await this.upsertProductTags_(tags)
|
|
}
|
|
|
|
if (variants) {
|
|
// Iterate product variants and update their properties accordingly
|
|
for (const variant of product.variants) {
|
|
const exists = variants.find(v => v.id && variant.id === v.id)
|
|
if (!exists) {
|
|
await productVariantRepo.remove(variant)
|
|
}
|
|
}
|
|
|
|
const newVariants = []
|
|
for (const newVariant of variants) {
|
|
if (newVariant.id) {
|
|
const variant = product.variants.find(v => v.id === newVariant.id)
|
|
|
|
if (!variant) {
|
|
throw new MedusaError(
|
|
MedusaError.Types.NOT_FOUND,
|
|
`Variant with id: ${newVariant.id} is not associated with this product`
|
|
)
|
|
}
|
|
|
|
const saved = await this.productVariantService_
|
|
.withTransaction(manager)
|
|
.update(variant, newVariant)
|
|
|
|
newVariants.push(saved)
|
|
} else {
|
|
// If the provided variant does not have an id, we assume that it
|
|
// should be created
|
|
const created = await this.productVariantService_
|
|
.withTransaction(manager)
|
|
.create(product.id, newVariant)
|
|
|
|
newVariants.push(created)
|
|
}
|
|
}
|
|
|
|
product.variants = newVariants
|
|
}
|
|
|
|
for (const [key, value] of Object.entries(rest)) {
|
|
product[key] = value
|
|
}
|
|
|
|
const result = await productRepo.save(product)
|
|
await this.eventBus_
|
|
.withTransaction(manager)
|
|
.emit(ProductService.Events.UPDATED, {
|
|
id: result.id,
|
|
})
|
|
return result
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Deletes a product from a given product id. The product's associated
|
|
* variants will also be deleted.
|
|
* @param {string} productId - the id of the product to delete. Must be
|
|
* castable as an ObjectId
|
|
* @return {Promise} empty promise
|
|
*/
|
|
async delete(productId) {
|
|
return this.atomicPhase_(async manager => {
|
|
const productRepo = manager.getCustomRepository(this.productRepository_)
|
|
|
|
// Should not fail, if product does not exist, since delete is idempotent
|
|
const product = await productRepo.findOne({ where: { id: productId } })
|
|
|
|
if (!product) return Promise.resolve()
|
|
|
|
await productRepo.softRemove(product)
|
|
|
|
return Promise.resolve()
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Adds an option to a product. Options can, for example, be "Size", "Color",
|
|
* etc. Will update all the products variants with a dummy value for the newly
|
|
* created option. The same option cannot be added more than once.
|
|
* @param {string} productId - the product to apply the new option to
|
|
* @param {string} optionTitle - the display title of the option, e.g. "Size"
|
|
* @return {Promise} the result of the model update operation
|
|
*/
|
|
async addOption(productId, optionTitle) {
|
|
return this.atomicPhase_(async manager => {
|
|
const productOptionRepo = manager.getCustomRepository(
|
|
this.productOptionRepository_
|
|
)
|
|
|
|
const product = await this.retrieve(productId, {
|
|
relations: ["options", "variants"],
|
|
})
|
|
|
|
if (product.options.find(o => o.title === optionTitle)) {
|
|
throw new MedusaError(
|
|
MedusaError.Types.INVALID_DATA,
|
|
`An option with the title: ${optionTitle} already exists`
|
|
)
|
|
}
|
|
|
|
const option = await productOptionRepo.create({
|
|
title: optionTitle,
|
|
product_id: productId,
|
|
})
|
|
|
|
await productOptionRepo.save(option)
|
|
|
|
for (const variant of product.variants) {
|
|
this.productVariantService_
|
|
.withTransaction(manager)
|
|
.addOptionValue(variant.id, option.id, "Default Value")
|
|
}
|
|
|
|
const result = await this.retrieve(productId)
|
|
|
|
await this.eventBus_
|
|
.withTransaction(manager)
|
|
.emit(ProductService.Events.UPDATED, result)
|
|
return result
|
|
})
|
|
}
|
|
|
|
async reorderVariants(productId, variantOrder) {
|
|
return this.atomicPhase_(async manager => {
|
|
const productRepo = manager.getCustomRepository(this.productRepository_)
|
|
|
|
const product = await this.retrieve(productId, {
|
|
relations: ["variants"],
|
|
})
|
|
|
|
if (product.variants.length !== variantOrder.length) {
|
|
throw new MedusaError(
|
|
MedusaError.Types.INVALID_DATA,
|
|
`Product variants and new variant order differ in length.`
|
|
)
|
|
}
|
|
|
|
product.variants = variantOrder.map(vId => {
|
|
const variant = product.variants.find(v => v.id === vId)
|
|
if (!variant) {
|
|
throw new MedusaError(
|
|
MedusaError.Types.INVALID_DATA,
|
|
`Product has no variant with id: ${vId}`
|
|
)
|
|
}
|
|
|
|
return variant
|
|
})
|
|
|
|
const result = productRepo.save(product)
|
|
await this.eventBus_
|
|
.withTransaction(manager)
|
|
.emit(ProductService.Events.UPDATED, result)
|
|
return result
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Changes the order of a product's options. Will throw if the length of
|
|
* optionOrder and the length of the product's options are different. Will
|
|
* throw optionOrder contains an id not associated with the product.
|
|
* @param {string} productId - the product whose options we are reordering
|
|
* @param {[ObjectId]} optionId - the ids of the product's options in the
|
|
* new order
|
|
* @return {Promise} the result of the update operation
|
|
*/
|
|
async reorderOptions(productId, optionOrder) {
|
|
return this.atomicPhase_(async manager => {
|
|
const productRepo = manager.getCustomRepository(this.productRepository_)
|
|
|
|
const product = await this.retrieve(productId, { relations: ["options"] })
|
|
|
|
if (product.options.length !== optionOrder.length) {
|
|
throw new MedusaError(
|
|
MedusaError.Types.INVALID_DATA,
|
|
`Product options and new options order differ in length.`
|
|
)
|
|
}
|
|
|
|
product.options = optionOrder.map(oId => {
|
|
const option = product.options.find(o => o.id === oId)
|
|
if (!option) {
|
|
throw new MedusaError(
|
|
MedusaError.Types.INVALID_DATA,
|
|
`Product has no option with id: ${oId}`
|
|
)
|
|
}
|
|
|
|
return option
|
|
})
|
|
|
|
const result = productRepo.save(product)
|
|
await this.eventBus_
|
|
.withTransaction(manager)
|
|
.emit(ProductService.Events.UPDATED, result)
|
|
return result
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Updates a product's option. Throws if the call tries to update an option
|
|
* not associated with the product. Throws if the updated title already exists.
|
|
* @param {string} productId - the product whose option we are updating
|
|
* @param {string} optionId - the id of the option we are updating
|
|
* @param {object} data - the data to update the option with
|
|
* @return {Promise} the updated product
|
|
*/
|
|
async updateOption(productId, optionId, data) {
|
|
return this.atomicPhase_(async manager => {
|
|
const productOptionRepo = manager.getCustomRepository(
|
|
this.productOptionRepository_
|
|
)
|
|
|
|
const product = await this.retrieve(productId, { relations: ["options"] })
|
|
|
|
const { title, values } = data
|
|
|
|
const optionExists = product.options.some(
|
|
o => o.title.toUpperCase() === title.toUpperCase() && o.id !== optionId
|
|
)
|
|
if (optionExists) {
|
|
throw new MedusaError(
|
|
MedusaError.Types.NOT_FOUND,
|
|
`An option with title ${title} already exists`
|
|
)
|
|
}
|
|
|
|
const productOption = await productOptionRepo.findOne({
|
|
where: { id: optionId },
|
|
})
|
|
|
|
if (!productOption) {
|
|
throw new MedusaError(
|
|
MedusaError.Types.NOT_FOUND,
|
|
`Option with id: ${optionId} deos not exists`
|
|
)
|
|
}
|
|
|
|
productOption.title = title
|
|
productOption.values = values
|
|
|
|
await productOptionRepo.save(productOption)
|
|
|
|
await this.eventBus_
|
|
.withTransaction(manager)
|
|
.emit(ProductService.Events.UPDATED, product)
|
|
return product
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Delete an option from a product.
|
|
* @param {string} productId - the product to delete an option from
|
|
* @param {string} optionId - the option to delete
|
|
* @return {Promise} the updated product
|
|
*/
|
|
async deleteOption(productId, optionId) {
|
|
return this.atomicPhase_(async manager => {
|
|
const productOptionRepo = manager.getCustomRepository(
|
|
this.productOptionRepository_
|
|
)
|
|
|
|
const product = await this.retrieve(productId, {
|
|
relations: ["variants", "variants.options"],
|
|
})
|
|
|
|
const productOption = await productOptionRepo.findOne({
|
|
where: { id: optionId, product_id: productId },
|
|
})
|
|
|
|
if (!productOption) {
|
|
return Promise.resolve()
|
|
}
|
|
|
|
// For the option we want to delete, make sure that all variants have the
|
|
// same option values. The reason for doing is, that we want to avoid
|
|
// duplicate variants. For example, if we have a product with size and
|
|
// color options, that has four variants: (black, 1), (black, 2),
|
|
// (blue, 1), (blue, 2) and we delete the size option from the product,
|
|
// we would end up with four variants: (black), (black), (blue), (blue).
|
|
// We now have two duplicate variants. To ensure that this does not
|
|
// happen, we will force the user to select which variants to keep.
|
|
const firstVariant = product.variants[0]
|
|
|
|
const valueToMatch = firstVariant.options.find(
|
|
o => o.option_id === optionId
|
|
).value
|
|
|
|
const equalsFirst = await Promise.all(
|
|
product.variants.map(async v => {
|
|
const option = v.options.find(o => o.option_id === optionId)
|
|
return option.value === valueToMatch
|
|
})
|
|
)
|
|
|
|
if (!equalsFirst.every(v => v)) {
|
|
throw new MedusaError(
|
|
MedusaError.Types.INVALID_DATA,
|
|
`To delete an option, first delete all variants, such that when option is deleted, no duplicate variants will exist.`
|
|
)
|
|
}
|
|
|
|
// If we reach this point, we can safely delete the product option
|
|
await productOptionRepo.softRemove(productOption)
|
|
|
|
await this.eventBus_
|
|
.withTransaction(manager)
|
|
.emit(ProductService.Events.UPDATED, product)
|
|
return product
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Decorates a product with product variants.
|
|
* @param {Product} product - the product to decorate.
|
|
* @param {string[]} fields - the fields to include.
|
|
* @param {string[]} expandFields - fields to expand.
|
|
* @return {Product} return the decorated product.
|
|
*/
|
|
async decorate(productId, fields = [], expandFields = []) {
|
|
const requiredFields = ["id", "metadata"]
|
|
|
|
fields = fields.concat(requiredFields)
|
|
|
|
const product = await this.retrieve(productId, {
|
|
select: fields,
|
|
relations: expandFields,
|
|
})
|
|
|
|
// const final = await this.runDecorators_(decorated)
|
|
return product
|
|
}
|
|
}
|
|
|
|
export default ProductService
|