diff --git a/backend/api/category/services/category.js b/backend/api/category/services/category.js index d0dcc88..fed2e02 100644 --- a/backend/api/category/services/category.js +++ b/backend/api/category/services/category.js @@ -1,8 +1,15 @@ 'use strict'; -/** - * Read the documentation (https://strapi.io/documentation/3.0.0-beta.x/concepts/services.html#core-services) - * to customize this service - */ +const escapeRegex = (str) => str.replace(/[.*+\-?^${}()|[\]\\]/g, '\\$&'); -module.exports = {}; +module.exports = { + getCategoriesIds(categoryName) { + if (!categoryName) return []; + + return strapi + .query('category') + .model.find({ path: { $regex: `,${escapeRegex(categoryName)},` } }) + .select('_id') + .then((result) => result.map((v) => (v ? v.toObject()._id : null))); + }, +}; diff --git a/backend/api/product/config/schema.graphql.js b/backend/api/product/config/schema.graphql.js index 3890551..67246e6 100644 --- a/backend/api/product/config/schema.graphql.js +++ b/backend/api/product/config/schema.graphql.js @@ -1,7 +1,13 @@ module.exports = { + definition: ` + type ProductSearchResult { + nbHits: Int! + products: [Product]! + } + `, query: ` productBySlug(slug: String!): Product - productsSearch(query: String, sort: String, limit: Int, start: Int, category: String, priceRange: [Int]): [Product] + productsSearch(query: String, limit: Int, start: Int, category: String): ProductSearchResult newProducts: [Product] `, resolver: { diff --git a/backend/api/product/controllers/Product.js b/backend/api/product/controllers/Product.js index 873b4ac..082add7 100644 --- a/backend/api/product/controllers/Product.js +++ b/backend/api/product/controllers/Product.js @@ -45,33 +45,32 @@ const addStockStatus = (entity) => { return { ...entity, stockStatus: stockStatus }; }; -const escapeRegex = (str) => str.replace(/[.*+\-?^${}()|[\]\\]/g, '\\$&'); - module.exports = { async searchEnhanced(ctx) { try { - const { _category, _query, _sort, _limit, _start, _priceRange } = Joi.attempt( - ctx.query, - searchEnhancedSchema - ); + const { _category, _query, _limit, _start } = Joi.attempt(ctx.query, searchEnhancedSchema); - const query = {}; + const categoriesIds = await strapi.services.category.getCategoriesIds(_category); + const filterQuery = categoriesIds.map((id) => `category = "${id}"`).join(' OR '); - if (_category) query.category = escapeRegex(_category); - if (_query) query.query = escapeRegex(_query.substring(0, 30)); - if (_sort) query.sort = _sort; - if (_limit) query.limit = _limit; - if (_start) query.start = _start; - if (_priceRange) { - query.minPrice = _priceRange[0]; - query.maxPrice = _priceRange[1]; - } + const searchResult = await strapi.services.productsearch.searchProduct(_query, { + limit: _limit, + offset: _start, + filters: filterQuery || undefined, + }); - const entities = await strapi.services.product.searchEnhanced(query); + const { hits, nbHits } = searchResult; - return entities.map((entity) => - sanitizeEntity(addStockStatus(entity), { model: strapi.models.product }) - ); + const products = ( + await Promise.all( + hits.map(async (hit) => { + const product = await strapi.services.product.findOne({ id: hit._id }); + return sanitizeEntity(addStockStatus(product), { model: strapi.models.product }); + }) + ) + ).filter((product) => product !== null); + + return { nbHits, products }; } catch (e) { if (Joi.isError(e)) { ctx.throw(400, 'invalid input'); diff --git a/backend/api/product/models/Product.js b/backend/api/product/models/Product.js index 6467c58..e65d6bd 100644 --- a/backend/api/product/models/Product.js +++ b/backend/api/product/models/Product.js @@ -5,39 +5,21 @@ */ module.exports = { - // Before saving a value. - // Fired before an `insert` or `update` query. - // beforeSave: async (model) => {}, - // After saving a value. - // Fired after an `insert` or `update` query. - // afterSave: async (model, result) => {}, - // Before fetching all values. - // Fired before a `fetchAll` operation. - // beforeFetchAll: async (model) => {}, - // After fetching all values. - // Fired after a `fetchAll` operation. - // afterFetchAll: async (model, results) => {}, - // Fired before a `fetch` operation. - // beforeFetch: async (model) => {}, - // After fetching a value. - // Fired after a `fetch` operation. - // afterFetch: async (model, result) => {}, - // Before creating a value. - // Fired before an `insert` query. - // beforeCreate: async (model) => {}, - // After creating a value. - // Fired after an `insert` query. - // afterCreate: async (model, result) => {}, - // Before updating a value. - // Fired before an `update` query. - // beforeUpdate: async (model) => {}, - // After updating a value. - // Fired after an `update` query. - // afterUpdate: async (model, result) => {}, - // Before destroying a value. - // Fired before a `delete` query. - // beforeDestroy: async (model) => {}, - // After destroying a value. - // Fired after a `delete` query. - // afterDestroy: async (model, result) => {} + lifecycles: { + afterCreate: async (result) => { + if (!result || !result._id) return; + + await strapi.services.productsearch.updateProduct(result); + }, + afterUpdate: async (result) => { + if (!result || !result._id) return; + + await strapi.services.productsearch.updateProduct(result); + }, + afterDelete: async (result) => { + if (!result || !result._id) return; + + await strapi.services.productsearch.deleteProdut(result); + }, + }, }; diff --git a/backend/api/product/services/Product.js b/backend/api/product/services/Product.js index 55e49e0..1f1c28a 100644 --- a/backend/api/product/services/Product.js +++ b/backend/api/product/services/Product.js @@ -127,6 +127,15 @@ module.exports = { return { ref, qnt, price: cost }; } + strapi.services.productsearch + .partialUpdateProduct({ + _id, + reference: ref, + quantity: newStock, + price: newPrice, + }) + .catch(console.error); + return { ref, qnt: newStock, price: newPrice }; } catch { // Most likely product not found diff --git a/backend/api/product/services/productSearch.js b/backend/api/product/services/productSearch.js new file mode 100644 index 0000000..2603d29 --- /dev/null +++ b/backend/api/product/services/productSearch.js @@ -0,0 +1,65 @@ +const { MeiliSearch } = require('meilisearch'); +const _ = require('lodash'); + +const meili = new MeiliSearch({ + host: strapi.config.get('custom.meiliHost', 'http://127.0.0.1:7700'), + apiKey: strapi.config.get('custom.meiliApiKey', ''), +}); + +const PUBLIC_FIELDS = [ + '_id', + 'slug', + 'stockStatus', + 'language', + 'publishedDate', + 'bookPages', + 'bookPublisher', + 'bookEdition', + 'bookAuthor', + 'reference', + 'price', + 'description', + 'name', + 'createdAt', + 'updatedAt', + 'category', +]; + +const extractCategory = (product) => ({ + ...product, + category: product.category && product.category._id, +}); + +const updateProduct = async (product) => { + if (product.show) { + await meili.index('product').addDocuments([_.pick(extractCategory(product), PUBLIC_FIELDS)]); + } else { + await deleteProduct(product); + } +}; + +const partialUpdateProduct = async (product) => { + if (product.show === false) { + await deleteProduct(product); + } else { + await meili.index('product').updateDocuments([_.pick(extractCategory(product), PUBLIC_FIELDS)]); + } +}; + +const deleteProduct = async (product) => { + try { + await meili.index('product').deleteDocument(product._id); + } catch (e) { + console.error(e); + console.error('Failed to delete product from meilisearch'); + } +}; + +const searchProduct = (query, properties) => meili.index('product').search(query, properties); + +module.exports = { + updateProduct, + partialUpdateProduct, + deleteProduct, + searchProduct, +}; diff --git a/backend/config/custom.js b/backend/config/custom.js index 4918f58..c15e6a0 100644 --- a/backend/config/custom.js +++ b/backend/config/custom.js @@ -4,4 +4,6 @@ module.exports = ({ env }) => ({ euPagoSandbox: env('EUPAGO_TOKEN', 'demo-').startsWith('demo-'), frontendUrl: env('FRONTEND_URL', 'http://localhost:3000'), previewSecret: env('PREVIEW_SECRET', ''), + meiliHost: env('MEILI_HOST', 'http://127.0.0.1:7700'), + meiliApiKey: env('MEILI_API_KEY', ''), }); diff --git a/backend/migrations/meilisearch.js b/backend/migrations/meilisearch.js new file mode 100644 index 0000000..0e01a72 --- /dev/null +++ b/backend/migrations/meilisearch.js @@ -0,0 +1,101 @@ +const { MeiliSearch } = require('meilisearch'); +const path = require('path'); +const fs = require('fs'); +const _ = require('lodash'); + +const PUBLIC_FIELDS = [ + '_id', + 'slug', + 'stockStatus', + 'language', + 'publishedDate', + 'bookPages', + 'bookPublisher', + 'bookEdition', + 'bookAuthor', + 'reference', + 'price', + 'description', + 'name', + 'createdAt', + 'updatedAt', + 'category', +]; + +const createStrapiApp = async (projectPath) => { + if (!projectPath) { + throw new Error(` +-> Path to strapi project is missing. +-> Usage: node meilisearch.js [path]`); + } + + let strapi; + let app; + try { + strapi = require(require.resolve('strapi', { paths: [projectPath] })); + const pkgJSON = require(path.resolve(projectPath, 'package.json')); + if (!pkgJSON || !pkgJSON.dependencies || !pkgJSON.dependencies.strapi) { + throw new Error(); + } + } catch (e) { + throw new Error(` +-> Strapi lib couldn\'t be found. Are the node_modules installed? +-> Fix: yarn install or npm install`); + } + + try { + app = await strapi({ dir: projectPath }).load(); + } catch (e) { + throw new Error(` +-> The migration couldn't be proceed because Strapi app couldn't start. +-> ${e.message}`); + } + + return app; +}; + +const extractCategory = (product) => ({ + ...product, + category: product.category && product.category._id, +}); + +const run = async () => { + const projectPath = process.argv[2]; + const app = await createStrapiApp(projectPath); + + const meili = new MeiliSearch({ + host: app.config.get('custom.meiliHost', 'http://127.0.0.1:7700'), + apiKey: app.config.get('custom.meiliApiKey', ''), + }); + + const index = meili.index('product'); + + try { + await index.deleteAllDocuments(); + } catch (e) { + // Ignore + } + + const count = await app.services.product.count(); + let products; + let i = 0; + while (i < count) { + products = await app.services.product.find({ _start: i }); + i += products.length; + await index.addDocuments( + products + .filter((prod) => prod.show) + .map((prod) => _.pick(extractCategory(prod), PUBLIC_FIELDS)) + ); + } +}; + +run() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .then(() => { + console.log('Migration successfully finished! 🎉'); + process.exit(0); + }); diff --git a/backend/package.json b/backend/package.json index 8268ecd..af34f50 100644 --- a/backend/package.json +++ b/backend/package.json @@ -21,6 +21,7 @@ "crypto": "^1.0.1", "email-templates": "^8.0.3", "joi": "^17.4.0", + "meilisearch": "^0.18.1", "sharp": "^0.27.2", "strapi": "3.5.3", "strapi-admin": "3.5.3", diff --git a/backend/yarn.lock b/backend/yarn.lock index 178b408..44bfbca 100644 --- a/backend/yarn.lock +++ b/backend/yarn.lock @@ -3722,6 +3722,13 @@ cross-fetch@^3.0.4: dependencies: node-fetch "2.6.1" +cross-fetch@^3.0.5: + version "3.1.1" + resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.1.1.tgz#a7ed5a9201d46223d805c5e9ecdc23ea600219eb" + integrity sha512-eIF+IHQpRzoGd/0zPrwQmHwDC90mdvjk+hcbYhKoaRrEk4GEIDqdjs/MljmdPPoHTQudbmWS+f0hZsEpFaEvWw== + dependencies: + node-fetch "2.6.1" + cross-spawn@^6.0.0, cross-spawn@^6.0.5: version "6.0.5" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4" @@ -7435,6 +7442,13 @@ media-typer@0.3.0: resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" integrity sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g= +meilisearch@^0.18.1: + version "0.18.1" + resolved "https://registry.yarnpkg.com/meilisearch/-/meilisearch-0.18.1.tgz#6b12da60628ec5d6438ba677a8295971e1035938" + integrity sha512-eGdcx5/0ktj5I2vC/bNbHg3ORuq9nh++fA39t03nG31J1SoFJmnx3Z0r83Q/yrfA+hAP/qhCI/6B8dHMEcCQJg== + dependencies: + cross-fetch "^3.0.5" + memoize-one@^5.0.0: version "5.1.1" resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.1.1.tgz#047b6e3199b508eaec03504de71229b8eb1d75c0" diff --git a/frontend/components/products/ProductQuery.js b/frontend/components/products/ProductQuery.js index c9e3a76..5ccd488 100644 --- a/frontend/components/products/ProductQuery.js +++ b/frontend/components/products/ProductQuery.js @@ -3,39 +3,28 @@ import { Alert } from '@material-ui/lab'; import gql from 'graphql-tag'; import PropTypes from 'prop-types'; import React, { useState } from 'react'; -import { Fade, LinearProgress } from '@material-ui/core'; +import { Fade, LinearProgress, Typography } from '@material-ui/core'; import LoadMore from './LoadMore'; import ProductList from './ProductList'; const PRODUCTS_QUERY = gql` - query SEARCH_PRODUCTS( - $search: String - $sort: String - $priceRange: [Int] - $category: String - $limit: Int - $start: Int - ) { - productsSearch( - query: $search - sort: $sort - priceRange: $priceRange - category: $category - limit: $limit - start: $start - ) { - id - name - shortDescription - images(limit: 1) { - url + query SEARCH_PRODUCTS($search: String, $category: String, $limit: Int, $start: Int) { + productsSearch(query: $search, category: $category, limit: $limit, start: $start) { + nbHits + products { + id + name + shortDescription + images(limit: 1) { + url + } + price + reference + slug + stockStatus + type + bookAuthor } - price - reference - slug - stockStatus - type - bookAuthor } globalDiscount { discounts { @@ -57,10 +46,10 @@ const getListName = (search, category) => { return 'General Product Listing'; }; -const ProductQuery = ({ sort, priceRange, search, category }) => { +const ProductQuery = ({ search, category }) => { const [hasMoreToLoad, setHasMoreToLoad] = useState(true); const { loading, error, data, fetchMore } = useQuery(PRODUCTS_QUERY, { - variables: { search, sort, priceRange, category, limit, start: 0 }, + variables: { search, category, limit, start: 0 }, fetchPolicy: 'cache-and-network', }); @@ -71,14 +60,17 @@ const ProductQuery = ({ sort, priceRange, search, category }) => { const loadMore = () => fetchMore({ variables: { - start: data.productsSearch.length, + start: data.productsSearch.products.length, }, updateQuery: (prev, { fetchMoreResult }) => { if (!fetchMoreResult) return prev; - if (fetchMoreResult.productsSearch.length === 0) setHasMoreToLoad(false); + if (fetchMoreResult.productsSearch.products.length === 0) setHasMoreToLoad(false); return { ...prev, - productsSearch: [...prev.productsSearch, ...fetchMoreResult.productsSearch], + productsSearch: { + ...fetchMoreResult.productsSearch, + products: [...prev.productsSearch.products, ...fetchMoreResult.productsSearch.products], + }, }; }, }); @@ -96,7 +88,7 @@ const ProductQuery = ({ sort, priceRange, search, category }) => { ), }); - const products = (data.productsSearch || []).map(handleProduct); + const products = (data.productsSearch.products || []).map(handleProduct); return ( <> @@ -109,9 +101,14 @@ const ProductQuery = ({ sort, priceRange, search, category }) => { + {data.productsSearch.nbHits > 0 && ( + + Foram encontrados {data.productsSearch.nbHits} resultados + + )} @@ -119,15 +116,11 @@ const ProductQuery = ({ sort, priceRange, search, category }) => { }; ProductQuery.propTypes = { - sort: PropTypes.string, - priceRange: PropTypes.arrayOf(PropTypes.number), search: PropTypes.string, category: PropTypes.string, }; ProductQuery.defaultProps = { - sort: undefined, - priceRange: undefined, search: undefined, category: undefined, }; diff --git a/meilisearch/.gitignore b/meilisearch/.gitignore new file mode 100644 index 0000000..0671e42 --- /dev/null +++ b/meilisearch/.gitignore @@ -0,0 +1,2 @@ +data.ms/ +meili.env diff --git a/meilisearch/docker-compose.yml b/meilisearch/docker-compose.yml new file mode 100644 index 0000000..3f99c2c --- /dev/null +++ b/meilisearch/docker-compose.yml @@ -0,0 +1,10 @@ +version: '3.8' + +services: + meilisearch: + image: getmeili/meilisearch:v0.20.0 + ports: + - 7700:7700 + volumes: + - ./data.ms:/data.ms + env_file: meili.env diff --git a/meilisearch/meili.sample.env b/meilisearch/meili.sample.env new file mode 100644 index 0000000..6d35466 --- /dev/null +++ b/meilisearch/meili.sample.env @@ -0,0 +1,3 @@ +MEILI_ENV=production +MEILI_MASTER_KEY= +MEILI_NO_ANALYTICS=true \ No newline at end of file