Merge pull request #381 from medusajs/feat/meilisearch-plugin

feat: meilisearch product plugin
This commit is contained in:
Sebastian Rindom
2021-10-18 17:15:13 +02:00
committed by GitHub
31 changed files with 6219 additions and 86 deletions

View File

@@ -19,6 +19,7 @@
/packages/medusa/src/subscribers/notification.js
/packages/medusa/src/subscribers/order.js
/packages/medusa/src/subscribers/product.js
/packages/medusa/src/loaders/api.js
/packages/medusa/src/loaders/database.js
@@ -64,6 +65,7 @@
/packages/medusa-payment-manual
/packages/medusa-payment-paypal
/packages/medusa-payment-stripe
/packages/medusa-plugin-meilisearch
/packages/medusa-plugin-add-ons
/packages/medusa-plugin-brightpearl
/packages/medusa-plugin-contentful

View File

@@ -0,0 +1,3 @@
export const indexTypes = {
products: "products",
}

View File

@@ -1,6 +1,7 @@
export { countries } from "./countries"
export { isoCountryLookup } from "./countries"
export { transformIdableFields } from "./transform-idable-fields"
export { indexTypes } from "./index-types"
export { default as Validator } from "./validator"
export { default as MedusaError } from "./errors"
export { default as getConfigFile } from "./get-config-file"

View File

@@ -19,7 +19,7 @@ class BaseService {
* Used to build TypeORM queries.
*/
buildQuery_(selector, config = {}) {
const build = obj => {
const build = (obj) => {
const where = Object.entries(obj).reduce((acc, [key, value]) => {
switch (true) {
case value instanceof FindOperator:
@@ -49,11 +49,11 @@ class BaseService {
})
acc[key] = Raw(
a =>
(a) =>
subquery
.map((s, index) => `${a} ${s.operator} :${index}`)
.join(" AND "),
subquery.map(s => s.value)
subquery.map((s) => s.value)
)
break
default:
@@ -149,7 +149,7 @@ class BaseService {
return work(this.transactionManager_)
} else {
const temp = this.manager_
const doWork = async m => {
const doWork = async (m) => {
this.manager_ = m
this.transactionManager_ = m
try {
@@ -167,17 +167,17 @@ class BaseService {
if (isolation) {
let result
try {
result = await this.manager_.transaction(isolation, m => doWork(m))
result = await this.manager_.transaction(isolation, (m) => doWork(m))
return result
} catch (error) {
if (this.shouldRetryTransaction(error)) {
return this.manager_.transaction(isolation, m => doWork(m))
return this.manager_.transaction(isolation, (m) => doWork(m))
} else {
throw error
}
}
}
return this.manager_.transaction(m => doWork(m))
return this.manager_.transaction((m) => doWork(m))
}
}
@@ -230,7 +230,7 @@ class BaseService {
*/
runDecorators_(obj, fields = [], expandFields = []) {
return this.decorators_.reduce(async (acc, next) => {
return acc.then(res => next(res, fields, expandFields)).catch(() => acc)
return acc.then((res) => next(res, fields, expandFields)).catch(() => acc)
}, Promise.resolve(obj))
}
}

View File

@@ -5,3 +5,4 @@ export { default as FulfillmentService } from "./fulfillment-service"
export { default as FileService } from "./file-service"
export { default as NotificationService } from "./notification-service"
export { default as OauthService } from "./oauth-service"
export { default as SearchService } from "./search-service"

View File

@@ -0,0 +1,96 @@
import { BaseService } from "medusa-interfaces"
/**
* The interface that all search services must implement.
* @interface
*/
class SearchService extends BaseService {
constructor() {
super()
}
/**
* Used to create an index
* @param indexName {string} - the index name
* @param [options] {string} - the index name
* @return {Promise<{object}>} - returns response from search engine provider
*/
createIndex(indexName, options) {
throw Error("createIndex must be overridden by a child class")
}
/**
* Used to get an index
* @param indexName {string} - the index name.
* @return {Promise<{object}>} - returns response from search engine provider
*/
getIndex(indexName) {
throw Error("getIndex must be overridden by a child class")
}
/**
* Used to index documents by the search engine provider
* @param indexName {string} - the index name
* @param documents {Array.<Object>} - documents array to be indexed
* @param type {Array.<Object>} - type of documents to be added (e.g: products, regions, orders, etc)
* @return {Promise<{object}>} - returns response from search engine provider
*/
addDocuments(indexName, documents, type) {
throw Error("addDocuments must be overridden by a child class")
}
/**
* Used to replace documents
* @param indexName {string} - the index name.
* @param documents {Object} - array of document objects that will replace existing documents
* @param type {Array.<Object>} - type of documents to be replaced (e.g: products, regions, orders, etc)
* @return {Promise<{object}>} - returns response from search engine provider
*/
replaceDocuments(indexName, documents, type) {
throw Error("updateDocument must be overridden by a child class")
}
/**
* Used to delete document
* @param indexName {string} - the index name
* @param document_id {string} - the id of the document
* @return {Promise<{object}>} - returns response from search engine provider
*/
deleteDocument(indexName, document_id) {
throw Error("deleteDocument must be overridden by a child class")
}
/**
* Used to delete all documents
* @param indexName {string} - the index name
* @return {Promise<{object}>} - returns response from search engine provider
*/
deleteAllDocuments(indexName) {
throw Error("deleteAllDocuments must be overridden by a child class")
}
/**
* Used to search for a document in an index
* @param indexName {string} - the index name
* @param query {string} - the search query
* @param options {{ paginationOptions: { limit: number, offset: number }, filter: any, additionalOptions: any}}
* - any options passed to the request object other than the query and indexName
* - additionalOptions contain any provider specific options
* @return {Promise<{ hits: any[]; [k: string]: any; }>} returns response from search engine provider
*/
search(indexName, query, options) {
throw Error("search must be overridden by a child class")
}
/**
* Used to update the settings of an index
* @param indexName {string} - the index name
* @param settings {object} - settings object
* @return {Promise<{object}>} - returns response from search engine provider
*/
updateSettings(indexName, settings) {
throw Error("updateSettings must be overridden by a child class")
}
}
export default SearchService

View File

@@ -0,0 +1,13 @@
{
"plugins": [
"@babel/plugin-proposal-class-properties",
"@babel/plugin-transform-instanceof",
"@babel/plugin-transform-classes"
],
"presets": ["@babel/preset-env"],
"env": {
"test": {
"plugins": ["@babel/plugin-transform-runtime"]
}
}
}

View File

@@ -0,0 +1,8 @@
/src/subscribers
/api
/services
/models
/subscribers
/loaders
/utils

View File

@@ -0,0 +1,15 @@
/dist
.env
.DS_Store
/uploads
/node_modules
yarn-error.log
/dist
/api
/services
/models
/subscribers
/loaders
/utils

View File

@@ -0,0 +1,13 @@
/lib
node_modules
.DS_store
.env*
/*.js
!index.js
yarn.lock
src
.gitignore
.eslintrc
.babelrc
.prettierrc

View File

@@ -0,0 +1,20 @@
# medusa-plugin-meilisearch
Meilisearch Plugin for Medusa to search for products.
## Plugin Options
```
{
config: {
host: [your meilisearch host],
},
settings: {
[indexName]: [meilisearch settings passed to meilisearch's `updateSettings()` method]
// example
products: {
searchableAttributes: ["title", "description", "variant_sku", "type_value"],
displayedAttributes: ["title", "description", "variant_sku", "type_value"],
}
}
}
```

View File

@@ -0,0 +1 @@
//noop

View File

@@ -0,0 +1,3 @@
module.exports = {
testEnvironment: "node",
}

View File

@@ -0,0 +1,45 @@
{
"name": "medusa-plugin-meilisearch",
"version": "0.0.1",
"description": "A starter for Medusa projects.",
"main": "index.js",
"repository": {
"type": "git",
"url": "https://github.com/medusajs/medusa",
"directory": "packages/medusa-plugin-meilisearch"
},
"author": "Zakaria El Asri",
"license": "MIT",
"scripts": {
"build": "babel src -d .",
"prepare": "cross-env NODE_ENV=production npm run build",
"watch": "babel -w src --out-dir . --ignore **/__tests__",
"test": "jest"
},
"peerDependencies": {
"medusa-interfaces": "1.x",
"typeorm": "0.x"
},
"dependencies": {
"body-parser": "^1.19.0",
"lodash": "^4.17.21",
"medusa-core-utils": "^1.1.20",
"medusa-interfaces": "^1.1.23",
"meilisearch": "^0.20.0"
},
"devDependencies": {
"@babel/cli": "^7.7.5",
"@babel/core": "^7.7.5",
"@babel/node": "^7.7.4",
"@babel/plugin-proposal-class-properties": "^7.7.4",
"@babel/plugin-transform-instanceof": "^7.8.3",
"@babel/plugin-transform-runtime": "^7.7.6",
"@babel/preset-env": "^7.7.5",
"@babel/register": "^7.7.4",
"@babel/runtime": "^7.9.6",
"client-sessions": "^0.8.0",
"cross-env": "^5.2.1",
"eslint": "^6.8.0",
"jest": "^25.5.2"
}
}

View File

@@ -0,0 +1,14 @@
export default async (container, options) => {
try {
const meilisearchService = container.resolve("meilisearchService")
await Promise.all(
Object.entries(options.settings).map(([key, value]) =>
meilisearchService.updateSettings(key, value)
)
)
} catch (err) {
// ignore
console.log(err)
}
}

View File

@@ -0,0 +1,69 @@
import { SearchService } from "medusa-interfaces"
import { MeiliSearch } from "meilisearch"
import { indexTypes } from "medusa-core-utils"
import { transformProduct } from "../utils/transform-product"
class MeiliSearchService extends SearchService {
constructor(container, options) {
super()
this.options_ = options
this.client_ = new MeiliSearch(options.config)
}
createIndex(indexName, options) {
return this.client_.createIndex(indexName, options)
}
getIndex(indexName) {
return this.client_.index(indexName)
}
addDocuments(indexName, documents, type) {
const transformedDocuments = this.getTransformedDocuments(type, documents)
return this.client_.index(indexName).addDocuments(transformedDocuments)
}
replaceDocuments(indexName, documents, type) {
const transformedDocuments = this.getTransformedDocuments(type, documents)
return this.client_.index(indexName).addDocuments(transformedDocuments)
}
deleteDocument(indexName, document_id) {
return this.client_.index(indexName).deleteDocument(document_id)
}
deleteAllDocuments(indexName) {
return this.client_.index(indexName).deleteAllDocuments()
}
search(indexName, query, options) {
const { paginationOptions, filter, additionalOptions } = options
return this.client_
.index(indexName)
.search(query, { filter, ...paginationOptions, ...additionalOptions })
}
updateSettings(indexName, settings) {
return this.client_.index(indexName).updateSettings(settings)
}
getTransformedDocuments(type, documents) {
switch (type) {
case indexTypes.products:
return this.transformProducts(documents)
default:
return documents
}
}
transformProducts(products) {
if (!products) {
return []
}
return products.map(transformProduct)
}
}
export default MeiliSearchService

View File

@@ -0,0 +1,41 @@
const variantKeys = [
"sku",
"title",
"upc",
"ean",
"mid_code",
"hs_code",
"options",
]
const prefix = `variant`
export const transformProduct = (product) => {
const initialObj = variantKeys.reduce((obj, key) => {
obj[`${prefix}_${key}`] = []
return obj
}, {})
initialObj[`${prefix}_options_value`] = []
const flattenedVariantFields = product.variants.reduce((obj, variant) => {
variantKeys.forEach((k) => {
if (k === "options" && variant[k]) {
const values = variant[k].map((option) => option.value)
obj[`${prefix}_options_value`] =
obj[`${prefix}_options_value`].concat(values)
return
}
return variant[k] && obj[`${prefix}_${k}`].push(variant[k])
})
return obj
}, initialObj)
product.type_value = product.type && product.type.value
product.collection_title = product.collection && product.collection.title
product.collection_handle = product.collection && product.collection.handle
product.tags_value = product.tags ? product.tags.map((t) => t.value) : []
return {
...product,
...flattenedVariantFields,
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -7,6 +7,7 @@ export default app => {
app.use("/products", route)
route.get("/", middlewares.wrap(require("./list-products").default))
route.post("/search", middlewares.wrap(require("./search").default))
route.get("/:id", middlewares.wrap(require("./get-product").default))
return app

View File

@@ -0,0 +1,35 @@
import { Validator, MedusaError } from "medusa-core-utils"
import ProductService from "../../../../services/product"
export default async (req, res) => {
const schema = Validator.object()
.keys({
q: Validator.string().required(),
offset: Validator.number().optional(),
limit: Validator.number().optional(),
filter: Validator.any(),
})
.options({ allowUnknown: true })
const { value, error } = schema.validate(req.body)
if (error) {
throw new MedusaError(MedusaError.Types.INVALID_DATA, error.details)
}
try {
const { q, offset, limit, filter, ...options } = value
const paginationOptions = { offset, limit }
const searchService = req.scope.resolve("searchService")
const results = await searchService.search(ProductService.IndexName, q, {
paginationOptions,
filter,
additionalOptions: options,
})
res.status(200).send(results)
} catch (error) {
throw error
}
}

View File

@@ -16,6 +16,7 @@ import passportLoader from "./passport"
import pluginsLoader, { registerPluginModels } from "./plugins"
import defaultsLoader from "./defaults"
import Logger from "./logger"
import searchIndexLoader from "./search-index"
export default async ({ directory: rootDirectory, expressApp }) => {
const { configModule } = getConfigFile(rootDirectory, `medusa-config`)
@@ -101,12 +102,6 @@ export default async ({ directory: rootDirectory, expressApp }) => {
const servAct = Logger.success(servicesActivity, "Services initialized") || {}
track("SERVICES_INIT_COMPLETED", { duration: servAct.duration })
const subActivity = Logger.activity("Initializing subscribers")
track("SUBSCRIBERS_INIT_STARTED")
subscribersLoader({ container, activityId: subActivity })
const subAct = Logger.success(subActivity, "Subscribers initialized") || {}
track("SUBSCRIBERS_INIT_COMPLETED", { duration: subAct.duration })
const expActivity = Logger.activity("Initializing express")
track("EXPRESS_INIT_STARTED")
await expressLoader({
@@ -138,6 +133,12 @@ export default async ({ directory: rootDirectory, expressApp }) => {
const pAct = Logger.success(pluginsActivity, "Plugins intialized") || {}
track("PLUGINS_INIT_COMPLETED", { duration: pAct.duration })
const subActivity = Logger.activity("Initializing subscribers")
track("SUBSCRIBERS_INIT_STARTED")
subscribersLoader({ container, activityId: subActivity })
const subAct = Logger.success(subActivity, "Subscribers initialized") || {}
track("SUBSCRIBERS_INIT_COMPLETED", { duration: subAct.duration })
const apiActivity = Logger.activity("Initializing API")
track("API_INIT_STARTED")
await apiLoader({
@@ -155,6 +156,12 @@ export default async ({ directory: rootDirectory, expressApp }) => {
const dAct = Logger.success(defaultsActivity, "Defaults initialized") || {}
track("DEFAULTS_INIT_COMPLETED", { duration: dAct.duration })
const searchActivity = Logger.activity("Initializing search engine indexing")
track("SEARCH_ENGINE_INDEXING_STARTED")
searchIndexLoader({ container, activityId: searchActivity })
const searchAct = Logger.success(searchActivity, "Indexing completed") || {}
track("SEARCH_ENGINE_INDEXING_COMPLETED", { duration: searchAct.duration })
return { container, dbConnection, app: expressApp }
}

View File

@@ -8,6 +8,7 @@ import {
NotificationService,
FileService,
OauthService,
SearchService,
} from "medusa-interfaces"
import { getConfigFile, createRequireFromPath } from "medusa-core-utils"
import _ from "lodash"
@@ -249,7 +250,7 @@ async function registerServices(pluginDetails, container) {
)
// Add the service directly to the container in order to make simple
// resolution if we already know which payment provider we need to use
// resolution if we already know which fulfillment provider we need to use
container.register({
[name]: asFunction(
cradle => new loaded(cradle, pluginDetails.options)
@@ -263,7 +264,7 @@ async function registerServices(pluginDetails, container) {
)
// Add the service directly to the container in order to make simple
// resolution if we already know which payment provider we need to use
// resolution if we already know which notification provider we need to use
container.register({
[name]: asFunction(
cradle => new loaded(cradle, pluginDetails.options)
@@ -272,13 +273,22 @@ async function registerServices(pluginDetails, container) {
})
} else if (loaded.prototype instanceof FileService) {
// Add the service directly to the container in order to make simple
// resolution if we already know which payment provider we need to use
// resolution if we already know which file storage provider we need to use
container.register({
[name]: asFunction(
cradle => new loaded(cradle, pluginDetails.options)
),
[`fileService`]: aliasTo(name),
})
} else if (loaded.prototype instanceof SearchService) {
// Add the service directly to the container in order to make simple
// resolution if we already know which search provider we need to use
container.register({
[name]: asFunction(
cradle => new loaded(cradle, pluginDetails.options)
),
[`searchService`]: aliasTo(name),
})
} else {
container.register({
[name]: asFunction(

View File

@@ -0,0 +1,71 @@
import ProductService from "../services/product"
import { indexTypes } from "medusa-core-utils"
async function loadProductsIntoSearchEngine(container) {
const searchService = container.resolve("searchService")
const productService = container.resolve("productService")
const TAKE = 20
let hasMore = true
let lastSeenId = ""
while (hasMore) {
const products = await productService.list(
{ id: { gt: lastSeenId } },
{
select: [
"id",
"title",
"subtitle",
"description",
"handle",
"is_giftcard",
"discountable",
"thumbnail",
"profile_id",
"collection_id",
"type_id",
"origin_country",
"created_at",
"updated_at",
],
relations: [
"variants",
"tags",
"type",
"collection",
"variants.prices",
"variants.options",
"options",
],
take: TAKE,
order: { id: "ASC" },
}
)
if (products.length > 0) {
await searchService.addDocuments(
ProductService.IndexName,
products,
indexTypes.products
)
lastSeenId = products[products.length - 1].id
} else {
hasMore = false
}
}
}
export default async ({ container }) => {
const searchService = container.resolve("searchService")
const logger = container.resolve("logger")
if (searchService.isDefault) {
logger.warn(
"No search engine provider was found: make sure to include a search plugin to enable searching"
)
return
}
await loadProductsIntoSearchEngine(container)
}

View File

@@ -34,7 +34,7 @@ export class OrderRepository extends Repository<Order> {
const entitiesAndRelationsById = groupBy(entitiesAndRelations, "id")
return map(entities, e => merge({}, ...entitiesAndRelationsById[e.id]))
return map(entities, (e) => merge({}, ...entitiesAndRelationsById[e.id]))
}
public async findOneWithRelations(

View File

@@ -1,22 +1,35 @@
import { flatten, groupBy, map, merge } from "lodash"
import { EntityRepository, FindManyOptions, Repository } from "typeorm"
import {
OrderByCondition,
EntityRepository,
FindManyOptions,
Repository,
} from "typeorm"
import { Product } from "../models/product"
type DefaultWithoutRelations = Omit<FindManyOptions<Product>, "relations">
type CustomOptions = {
where?: DefaultWithoutRelations["where"] & { tags?: string[] }
order?: OrderByCondition
skip?: number
take?: number
}
type FindWithRelationsOptions = CustomOptions
@EntityRepository(Product)
export class ProductRepository extends Repository<Product> {
public async findWithRelations(
relations: Array<keyof Product> = [],
idsOrOptionsWithoutRelations: Omit<
FindManyOptions<Product>,
"relations"
> = {}
idsOrOptionsWithoutRelations: FindWithRelationsOptions = {}
): Promise<Product[]> {
let entities
let entities: Product[]
if (Array.isArray(idsOrOptionsWithoutRelations)) {
entities = await this.findByIds(idsOrOptionsWithoutRelations)
} else {
// Since tags are in a one-to-many realtion they cant be included in a
// regular query, to solve this add the join on tags seperately if
// Since tags are in a one-to-many realtion they cant be included in a
// regular query, to solve this add the join on tags seperately if
// the query exists
const tags = idsOrOptionsWithoutRelations.where.tags
delete idsOrOptionsWithoutRelations.where.tags
@@ -25,17 +38,15 @@ export class ProductRepository extends Repository<Product> {
.where(idsOrOptionsWithoutRelations.where)
.skip(idsOrOptionsWithoutRelations.skip)
.take(idsOrOptionsWithoutRelations.take)
if (tags) {
.orderBy(idsOrOptionsWithoutRelations.order)
if (tags) {
qb = qb
.leftJoinAndSelect("product.tags", "tags")
.andWhere(
`tags.id IN (:...ids)`, { ids: tags._value}
)
.andWhere(`tags.id IN (:...ids)`, { ids: tags._value })
}
entities = await qb
.getMany()
entities = await qb.getMany()
}
const entitiesIds = entities.map(({ id }) => id)

View File

@@ -802,6 +802,7 @@ describe("ProductVariantService", () => {
}
return Promise.resolve({
id: IdMap.getId("ironman"),
product_id: IdMap.getId("product-test"),
})
},
})
@@ -820,9 +821,20 @@ describe("ProductVariantService", () => {
await productVariantService.delete(IdMap.getId("ironman"))
expect(productVariantRepository.softRemove).toBeCalledTimes(1)
expect(productVariantRepository.softRemove).toBeCalledWith({
id: IdMap.getId("ironman"),
})
expect(productVariantRepository.softRemove).toBeCalledWith(
expect.objectContaining({
id: IdMap.getId("ironman"),
})
)
expect(eventBusService.emit).toHaveBeenCalledTimes(1)
expect(eventBusService.emit).toHaveBeenCalledWith(
"product-variant.deleted",
{
id: IdMap.getId("ironman"),
product_id: IdMap.getId("product-test"),
}
)
})
it("successfully resolves if variant does not exist", async () => {

View File

@@ -417,6 +417,7 @@ describe("ProductService", () => {
manager: MockManager,
eventBusService,
productRepository,
eventBusService,
})
beforeEach(() => {
@@ -429,6 +430,11 @@ describe("ProductService", () => {
expect(productRepository.softRemove).toBeCalledWith({
id: IdMap.getId("ironman"),
})
expect(eventBusService.emit).toBeCalledTimes(1)
expect(eventBusService.emit).toBeCalledWith("product.deleted", {
id: IdMap.getId("ironman"),
})
})
})

View File

@@ -119,7 +119,7 @@ class ProductVariantService extends BaseService {
* @return {Promise} resolves to the creation result.
*/
async create(productOrProductId, variant) {
return this.atomicPhase_(async manager => {
return this.atomicPhase_(async (manager) => {
const productRepo = manager.getCustomRepository(this.productRepository_)
const variantRepo = manager.getCustomRepository(
this.productVariantRepository_
@@ -148,8 +148,8 @@ class ProductVariantService extends BaseService {
)
}
product.options.forEach(option => {
if (!variant.options.find(vo => option.id === vo.option_id)) {
product.options.forEach((option) => {
if (!variant.options.find((vo) => option.id === vo.option_id)) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Variant options do not contain value for ${option.title}`
@@ -158,10 +158,10 @@ class ProductVariantService extends BaseService {
})
let variantExists = undefined
variantExists = product.variants.find(v => {
return v.options.every(option => {
variantExists = product.variants.find((v) => {
return v.options.every((option) => {
const variantOption = variant.options.find(
o => option.option_id === o.option_id
(o) => option.option_id === o.option_id
)
return option.value === variantOption.value
@@ -207,6 +207,7 @@ class ProductVariantService extends BaseService {
.withTransaction(manager)
.emit(ProductVariantService.Events.CREATED, {
id: result.id,
product_id: result.product_id,
})
return result
@@ -219,7 +220,7 @@ class ProductVariantService extends BaseService {
* @return {Promise}
*/
async publish(variantId) {
return this.atomicPhase_(async manager => {
return this.atomicPhase_(async (manager) => {
const variantRepo = manager.getCustomRepository(
this.productVariantRepository_
)
@@ -234,6 +235,7 @@ class ProductVariantService extends BaseService {
.withTransaction(manager)
.emit(ProductVariantService.Events.UPDATED, {
id: result.id,
product_id: result.product_id,
})
return result
@@ -250,7 +252,7 @@ class ProductVariantService extends BaseService {
* @return {Promise} resolves to the update result.
*/
async update(variantOrVariantId, update) {
return this.atomicPhase_(async manager => {
return this.atomicPhase_(async (manager) => {
const variantRepo = manager.getCustomRepository(
this.productVariantRepository_
)
@@ -310,6 +312,7 @@ class ProductVariantService extends BaseService {
.withTransaction(manager)
.emit(ProductVariantService.Events.UPDATED, {
id: result.id,
product_id: result.product_id,
fields: Object.keys(update),
})
return result
@@ -325,7 +328,7 @@ class ProductVariantService extends BaseService {
* @return {Promise} the result of the update operation
*/
async setCurrencyPrice(variantId, price) {
return this.atomicPhase_(async manager => {
return this.atomicPhase_(async (manager) => {
const moneyAmountRepo = manager.getCustomRepository(
this.moneyAmountRepository_
)
@@ -364,7 +367,7 @@ class ProductVariantService extends BaseService {
* @return {number} the price specific to the region
*/
async getRegionPrice(variantId, regionId) {
return this.atomicPhase_(async manager => {
return this.atomicPhase_(async (manager) => {
const moneyAmountRepo = manager.getCustomRepository(
this.moneyAmountRepository_
)
@@ -412,7 +415,7 @@ class ProductVariantService extends BaseService {
* @return {Promise} the result of the update operation
*/
async setRegionPrice(variantId, price) {
return this.atomicPhase_(async manager => {
return this.atomicPhase_(async (manager) => {
const moneyAmountRepo = manager.getCustomRepository(
this.moneyAmountRepository_
)
@@ -449,7 +452,7 @@ class ProductVariantService extends BaseService {
* @return {Promise} the result of the update operation.
*/
async updateOptionValue(variantId, optionId, optionValue) {
return this.atomicPhase_(async manager => {
return this.atomicPhase_(async (manager) => {
const productOptionValueRepo = manager.getCustomRepository(
this.productOptionValueRepository_
)
@@ -484,7 +487,7 @@ class ProductVariantService extends BaseService {
* @return {Promise} the result of the update operation.
*/
async addOptionValue(variantId, optionId, optionValue) {
return this.atomicPhase_(async manager => {
return this.atomicPhase_(async (manager) => {
const productOptionValueRepo = manager.getCustomRepository(
this.productOptionValueRepository_
)
@@ -508,7 +511,7 @@ class ProductVariantService extends BaseService {
* @return {Promise} empty promise
*/
async deleteOptionValue(variantId, optionId) {
return this.atomicPhase_(async manager => {
return this.atomicPhase_(async (manager) => {
const productOptionValueRepo = manager.getCustomRepository(
this.productOptionValueRepository_
)
@@ -558,7 +561,7 @@ class ProductVariantService extends BaseService {
},
}
query.where = qb => {
query.where = (qb) => {
qb.where(where).andWhere([
{ sku: ILike(`%${q}%`) },
{ title: ILike(`%${q}%`) },
@@ -578,7 +581,7 @@ class ProductVariantService extends BaseService {
* @return {Promise} empty promise
*/
async delete(variantId) {
return this.atomicPhase_(async manager => {
return this.atomicPhase_(async (manager) => {
const variantRepo = manager.getCustomRepository(
this.productVariantRepository_
)
@@ -592,7 +595,8 @@ class ProductVariantService extends BaseService {
await this.eventBus_
.withTransaction(manager)
.emit(ProductVariantService.Events.DELETED, {
id: variantId,
id: variant.id,
product_id: variant.product_id,
})
return Promise.resolve()

View File

@@ -8,6 +8,7 @@ import { Brackets } from "typeorm"
* @implements BaseService
*/
class ProductService extends BaseService {
static IndexName = `products`
static Events = {
UPDATED: "product.updated",
CREATED: "product.created",
@@ -25,6 +26,7 @@ class ProductService extends BaseService {
productTypeRepository,
productTagRepository,
imageRepository,
searchService,
}) {
super()
@@ -57,6 +59,9 @@ class ProductService extends BaseService {
/** @private @const {ImageRepository} */
this.imageRepository_ = imageRepository
/** @private @const {SearchService} */
this.searchService_ = searchService
}
withTransaction(transactionManager) {
@@ -123,7 +128,7 @@ class ProductService extends BaseService {
.select(["product.id"])
.where(where)
.andWhere(
new Brackets(qb => {
new Brackets((qb) => {
qb.where(`product.description ILIKE :q`, { q: `%${q}%` })
.orWhere(`product.title ILIKE :q`, { q: `%${q}%` })
.orWhere(`variant.title ILIKE :q`, { q: `%${q}%` })
@@ -135,7 +140,7 @@ class ProductService extends BaseService {
return productRepo.findWithRelations(
rels,
raw.map(i => i.id)
raw.map((i) => i.id)
)
}
@@ -277,7 +282,7 @@ class ProductService extends BaseService {
* @return {Promise} resolves to the creation result.
*/
async create(productObject) {
return this.atomicPhase_(async manager => {
return this.atomicPhase_(async (manager) => {
const productRepo = manager.getCustomRepository(this.productRepository_)
const optionRepo = manager.getCustomRepository(
this.productOptionRepository_
@@ -311,7 +316,7 @@ class ProductService extends BaseService {
product = await productRepo.save(product)
product.options = await Promise.all(
options.map(async o => {
options.map(async (o) => {
const res = optionRepo.create({ ...o, product_id: product.id })
await optionRepo.save(res)
return res
@@ -361,7 +366,7 @@ class ProductService extends BaseService {
* @return {Promise} resolves to the update result.
*/
async update(productId, update) {
return this.atomicPhase_(async manager => {
return this.atomicPhase_(async (manager) => {
const productRepo = manager.getCustomRepository(this.productRepository_)
const productVariantRepo = manager.getCustomRepository(
this.productVariantRepository_
@@ -371,15 +376,8 @@ class ProductService extends BaseService {
relations: ["variants", "tags", "images"],
})
const {
variants,
metadata,
options,
images,
tags,
type,
...rest
} = update
const { variants, metadata, options, images, tags, type, ...rest } =
update
if (!product.thumbnail && !update.thumbnail && images?.length) {
product.thumbnail = images[0]
@@ -404,7 +402,7 @@ class ProductService extends BaseService {
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)
const exists = variants.find((v) => v.id && variant.id === v.id)
if (!exists) {
await productVariantRepo.remove(variant)
}
@@ -415,7 +413,7 @@ class ProductService extends BaseService {
newVariant.variant_rank = i
if (newVariant.id) {
const variant = product.variants.find(v => v.id === newVariant.id)
const variant = product.variants.find((v) => v.id === newVariant.id)
if (!variant) {
throw new MedusaError(
@@ -466,7 +464,7 @@ class ProductService extends BaseService {
* @return {Promise} empty promise
*/
async delete(productId) {
return this.atomicPhase_(async manager => {
return this.atomicPhase_(async (manager) => {
const productRepo = manager.getCustomRepository(this.productRepository_)
// Should not fail, if product does not exist, since delete is idempotent
@@ -495,7 +493,7 @@ class ProductService extends BaseService {
* @return {Promise} the result of the model update operation
*/
async addOption(productId, optionTitle) {
return this.atomicPhase_(async manager => {
return this.atomicPhase_(async (manager) => {
const productOptionRepo = manager.getCustomRepository(
this.productOptionRepository_
)
@@ -504,7 +502,7 @@ class ProductService extends BaseService {
relations: ["options", "variants"],
})
if (product.options.find(o => o.title === optionTitle)) {
if (product.options.find((o) => o.title === optionTitle)) {
throw new MedusaError(
MedusaError.Types.DUPLICATE_ERROR,
`An option with the title: ${optionTitle} already exists`
@@ -534,7 +532,7 @@ class ProductService extends BaseService {
}
async reorderVariants(productId, variantOrder) {
return this.atomicPhase_(async manager => {
return this.atomicPhase_(async (manager) => {
const productRepo = manager.getCustomRepository(this.productRepository_)
const product = await this.retrieve(productId, {
@@ -548,8 +546,8 @@ class ProductService extends BaseService {
)
}
product.variants = variantOrder.map(vId => {
const variant = product.variants.find(v => v.id === vId)
product.variants = variantOrder.map((vId) => {
const variant = product.variants.find((v) => v.id === vId)
if (!variant) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
@@ -578,7 +576,7 @@ class ProductService extends BaseService {
* @return {Promise} the result of the update operation
*/
async reorderOptions(productId, optionOrder) {
return this.atomicPhase_(async manager => {
return this.atomicPhase_(async (manager) => {
const productRepo = manager.getCustomRepository(this.productRepository_)
const product = await this.retrieve(productId, { relations: ["options"] })
@@ -590,8 +588,8 @@ class ProductService extends BaseService {
)
}
product.options = optionOrder.map(oId => {
const option = product.options.find(o => o.id === oId)
product.options = optionOrder.map((oId) => {
const option = product.options.find((o) => o.id === oId)
if (!option) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
@@ -619,7 +617,7 @@ class ProductService extends BaseService {
* @return {Promise} the updated product
*/
async updateOption(productId, optionId, data) {
return this.atomicPhase_(async manager => {
return this.atomicPhase_(async (manager) => {
const productOptionRepo = manager.getCustomRepository(
this.productOptionRepository_
)
@@ -629,7 +627,8 @@ class ProductService extends BaseService {
const { title, values } = data
const optionExists = product.options.some(
o => o.title.toUpperCase() === title.toUpperCase() && o.id !== optionId
(o) =>
o.title.toUpperCase() === title.toUpperCase() && o.id !== optionId
)
if (optionExists) {
throw new MedusaError(
@@ -668,7 +667,7 @@ class ProductService extends BaseService {
* @return {Promise} the updated product
*/
async deleteOption(productId, optionId) {
return this.atomicPhase_(async manager => {
return this.atomicPhase_(async (manager) => {
const productOptionRepo = manager.getCustomRepository(
this.productOptionRepository_
)
@@ -696,17 +695,17 @@ class ProductService extends BaseService {
const firstVariant = product.variants[0]
const valueToMatch = firstVariant.options.find(
o => o.option_id === optionId
(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)
product.variants.map(async (v) => {
const option = v.options.find((o) => o.option_id === optionId)
return option.value === valueToMatch
})
)
if (!equalsFirst.every(v => v)) {
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.`

View File

@@ -0,0 +1,66 @@
import { SearchService } from "medusa-interfaces"
/**
* Default class that implements SearchService but provides stuv implementation for all methods
* @extends SearchService
*/
class DefaultSearchService extends SearchService {
constructor(container) {
super()
this.isDefault = true
this.logger_ = container.logger
}
createIndex(indexName, options) {
this.logger_.warn(
"This is an empty method: createIndex must be overridden by a child class"
)
}
getIndex(indexName) {
this.logger_.warn(
"This is an empty method: getIndex must be overridden by a child class"
)
}
addDocuments(indexName, documents, type) {
this.logger_.warn(
"This is an empty method: addDocuments must be overridden by a child class"
)
}
replaceDocuments(indexName, documents, type) {
this.logger_.warn(
"This is an empty method: replaceDocuments must be overridden by a child class"
)
}
deleteDocument(indexName, document_id) {
this.logger_.warn(
"This is an empty method: deleteDocument must be overridden by a child class"
)
}
deleteAllDocuments(indexName) {
this.logger_.warn(
"This is an empty method: deleteAllDocuments must be overridden by a child class"
)
}
search(indexName, query, options) {
this.logger_.warn(
"This is an empty method: search must be overridden a the child class"
)
return { hits: [] }
}
updateSettings(indexName, settings) {
this.logger_.warn(
"This is an empty method: updateSettings must be overridden by a child class"
)
}
}
export default DefaultSearchService

View File

@@ -0,0 +1,113 @@
import ProductVariantService from "../services/product-variant"
import ProductService from "../services/product"
import { indexTypes } from "medusa-core-utils"
const searchFields = [
"id",
"title",
"subtitle",
"description",
"handle",
"is_giftcard",
"discountable",
"thumbnail",
"profile_id",
"collection_id",
"type_id",
"origin_country",
"created_at",
"updated_at",
]
const searchRelations = [
"variants",
"tags",
"type",
"collection",
"variants.prices",
"variants.options",
"options",
]
class ProductSearchSubscriber {
constructor({ eventBusService, searchService, productService }) {
this.eventBus_ = eventBusService
this.searchService_ = searchService
this.productService_ = productService
this.eventBus_.subscribe(
ProductService.Events.CREATED,
this.handleProductCreation
)
this.eventBus_.subscribe(
ProductService.Events.UPDATED,
this.handleProductUpdate
)
this.eventBus_.subscribe(
ProductService.Events.DELETED,
this.handleProductDeletion
)
this.eventBus_.subscribe(
ProductVariantService.Events.CREATED,
this.handleProductVariantChange
)
this.eventBus_.subscribe(
ProductVariantService.Events.UPDATED,
this.handleProductVariantChange
)
this.eventBus_.subscribe(
ProductVariantService.Events.DELETED,
this.handleProductVariantChange
)
}
handleProductCreation = async (data) => {
const product = await this.retrieveProduct_(data.id)
await this.searchService.addDocuments(
ProductService.IndexName,
[product],
indexTypes.products
)
}
retrieveProduct_ = async (product_id) => {
return await this.productService_.retrieve(product_id, {
select: searchFields,
relations: searchRelations,
})
}
handleProductUpdate = async (data) => {
const product = await this.retrieveProduct_(data.id)
await this.meilisearchService_.addDocuments(
ProductService.IndexName,
[product],
indexTypes.products
)
}
handleProductDeletion = async (data) => {
await this.meilisearchService_.deleteDocument(
ProductService.IndexName,
data.id
)
}
handleProductVariantChange = async (data) => {
const product = await this.retrieveProduct_(data.product_id)
await this.meilisearchService_.addDocuments(
ProductService.IndexName,
[product],
indexTypes.products
)
}
}
export default ProductSearchSubscriber