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