Files
medusa-store/packages/medusa/src/services/product.js
2021-02-26 07:58:59 +01:00

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