Skip to content

Commit

Permalink
Merge pull request #43 from andela/ft-search-functionality-166816116
Browse files Browse the repository at this point in the history
#166816116 User can search and filter articles
  • Loading branch information
nedemenang committed Jul 26, 2019
2 parents 98a627a + a65400a commit 4074008
Show file tree
Hide file tree
Showing 13 changed files with 632 additions and 10 deletions.
4 changes: 3 additions & 1 deletion .babelrc
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,7 @@
}
}
],
]
],
"sourceMaps": true,
"retainLines": true
}
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ coverage
# Compiled binary addons (http://nodejs.org/api/addons.html)
build/Release
dist
uploads

.vscode

# Dependency directory
node_modules
Expand Down
42 changes: 42 additions & 0 deletions src/controllers/search.controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/* eslint-disable no-nested-ternary */
import searchBy from '../services/search.service';
import Helper from '../services/helper';

export default {
/**
* @method searchFilter
* Handles the logic for filtering and searching based on user input
* @param {object} request
* @param {object} response
* @param {function} next
* @returns {object|function} API response object
*/

async searchFilter(request, response, next) {
try {
const { tag, author, title } = request.query;
let searchResult;
switch (Object.keys(request.query).length > 1) {
case true:
searchResult = await searchBy('multiple', request.query);
break;
default:
searchResult = author
? await searchBy('author', author)
: tag
? await searchBy('tag', tag)
: title
? await searchBy('title', title)
: undefined;
}
if (!searchResult) {
return Helper.failResponse(response, 400, {
message: `Invalid filter provided. You can only filter by 'tag', 'title' or 'author'`
});
}
return Helper.successResponse(response, 200, searchResult);
} catch (error) {
next(error);
}
}
};
49 changes: 49 additions & 0 deletions src/middlewares/search.middleware.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import Helper from '../services/helper';

export default {
/**
* @method searchFilterCheck
* Validates incoming request parameters on searching and filtering.
* @param {object} request
* @param {object} response
* @param {function} next
* @returns {object|function} API response object
*/

async searchFilterCheck(request, response, next) {
const filterParams = Object.keys(request.query);
if (filterParams.length < 1) {
return Helper.failResponse(response, 400, {
message: `Filter parameters cannot be empty`
});
}
const errors = [];
const { tag, author, title } = request.query;

if (filterParams.includes('tag')) {
if (!tag) {
errors.push('Tag cannot be empty');
}
request.query.tag = tag.toLowerCase().trim();
}

if (filterParams.includes('author')) {
if (!author) {
errors.push('Author cannot be empty');
}
request.query.author = author.toLowerCase().trim();
}

if (filterParams.includes('title')) {
if (!title) {
errors.push('Title cannot be empty');
}
request.query.title = title.toLowerCase().trim();
}

if (errors.length > 0) {
return Helper.failResponse(response, 400, { message: errors[0] });
}
next();
}
};
1 change: 0 additions & 1 deletion src/routes/v1/auth.route.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ router
.post('/login', validator('login'), checkValidationResult, login)
.post(
'/forgot_password',
verifyToken,
validator('forgotPassword'),
checkValidationResult,
forgotPassword
Expand Down
2 changes: 2 additions & 0 deletions src/routes/v1/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ import users from './user.route';
import article from './article.route';
import profile from './profile.route';
import socialAuth from './social.route';
import search from './search.route';

export default app => {
app.use(`${process.env.API_VERSION}/users`, auth);
app.use(`${process.env.API_VERSION}/users`, users);
app.use(`${process.env.API_VERSION}/articles`, article);
app.use(`${process.env.API_VERSION}/profiles`, profile);
app.use(`${process.env.API_VERSION}/auth`, socialAuth);
app.use(`${process.env.API_VERSION}/search`, search);
};
9 changes: 9 additions & 0 deletions src/routes/v1/search.route.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import express from 'express';
import searchController from '../../controllers/search.controller';
import filter from '../../middlewares/search.middleware';

const { searchFilterCheck } = filter;
const { searchFilter } = searchController;
const router = express.Router();
router.get('/', searchFilterCheck, searchFilter);
export default router;
3 changes: 0 additions & 3 deletions src/services/article.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,9 +79,6 @@ export const createArticleService = async data => {

if (images) {
await loopUpload(images);
// imagePaths.forEach(path => {
// fs.unlinkSync(path);
// });
}
const finalUploads = JSON.stringify(Object.assign({}, uploadedImage));
const article = await Article.create({
Expand Down
197 changes: 197 additions & 0 deletions src/services/search.service.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
/* eslint-disable default-case */
import Sequelize from 'sequelize';
import model from '../db/models';

const { Op } = Sequelize;
const { Article, User, Tag } = model;

/**
* @method setAuthorQuery
* Sets and applies filter query to the User model association
* @param {string} value filter/search value
* @returns {object} User model association
*/

const setAuthorQuery = async value => {
return {
model: User,
as: 'author',
where: {
[Op.or]: [
{ firstName: { [Op.iLike]: `%${value}%` } },
{ lastName: { [Op.iLike]: `%${value}%` } },
{ userName: { [Op.iLike]: `%${value}%` } }
]
}
};
};

/**
* @method setTagQuery
* Sets and applies filter query to the Tag model association
* @param {string} value filter/search value
* @returns {object} Tag model association
*/

const setTagQuery = async value => {
return {
model: Tag,
as: 'Tags',
where: {
name: { [Op.iLike]: `%${value}%` }
}
};
};

/**
* @method setMessage
* sets response message based on search result
* @param {array} searchResult
* @returns {string} result message
*/

const setMessage = async searchResult => {
let message = `No Article match found`;
if (searchResult.length > 0) {
message = `${searchResult.length} Article match(es) found`;
}
return message;
};

/**
* @method getSearchResult
* Handles the logic for querying the database based on search input/filter provided
* @param {boolean} multiple if number of search entry is more than one
* @param {object|array} queryParams object of search entry query or array of search entry query objects
* @param {boolean} hasTitle if title filter is applied to search entry
* @returns {array} array of instance(s) of article(s) found based on filter applied
*/

const getSearchResult = async (multiple, queryParams, hasTitle) => {
let include;

const titleValue =
Array.isArray(queryParams) && hasTitle
? queryParams.shift().title
: queryParams.title;

if (!multiple && hasTitle) include = undefined;
if (!multiple && !hasTitle) include = [queryParams];
if (multiple) include = queryParams;

const searchResult = await Article.findAll({
where: !hasTitle
? { isPublished: true }
: {
title: {
[Op.iLike]: `%${titleValue}%`
},
isPublished: true
},
include
});

return searchResult;
};

/**
* @method formatResponse
* Handles the logic for formatting search results
* @param {array} searchResult
* @returns {object} formatted response object
*/

const formatResponse = async searchResult => {
const mappedResult = searchResult.map(result => {
const author = result.author
? {
firstName: result.author.firstName,
lastName: result.author.lastName,
userName: result.author.userName,
email: result.author.email,
image: result.author.image
}
: undefined;
const tags = result.Tags
? {
name: result.Tags.map(tag => tag.name)
}
: undefined;

const response = {
id: result.id,
title: result.title,
slug: result.slug,
description: result.description,
body: result.body,
image: result.image,
viewsCount: result.viewsCount,
readTime: result.readTime,
averageRating: result.averageRating,
sumOfRating: result.sumOfRating,
publishedAt: result.publishedAt,
author,
tags
};
return response;
});
return mappedResult;
};

/**
* @method searchBy
* Handles the logic for filtering and searching based on parameters provided
* @param {string} filterType type of filter applied
* @param {string|object} value filter value or object containing filter values
* @returns {object} search result object
*/

const searchBy = async (filterType, value) => {
let queryObject;
let result;
let message;
let hasTitle = false;
let isMultiple = false;
const queryObjectArray = [];
switch (filterType) {
case 'tag':
queryObject = await setTagQuery(value);
result = await getSearchResult(isMultiple, queryObject, hasTitle);
message = await setMessage(result);
break;
case 'author':
queryObject = await setAuthorQuery(value);
result = await getSearchResult(isMultiple, queryObject, hasTitle);
message = await setMessage(result);
break;
case 'title':
hasTitle = true;
queryObject = { title: value };
result = await getSearchResult(isMultiple, queryObject, hasTitle);
message = await setMessage(result);
break;
case 'multiple':
isMultiple = true;
if ('title' in value) {
queryObjectArray.push({ title: value.title });
hasTitle = true;
}
if ('author' in value) {
const authorQuery = await setAuthorQuery(value.author);
queryObjectArray.push(authorQuery);
}
if ('tag' in value) {
const tagQuery = await setTagQuery(value.tag);
queryObjectArray.push(tagQuery);
}

result = await getSearchResult(isMultiple, queryObjectArray, hasTitle);
message = await setMessage(result);
break;
}

const searchResult = await formatResponse(result);
return { message, searchResult };
};

export default searchBy;
8 changes: 4 additions & 4 deletions src/tests/controller/article.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -588,13 +588,13 @@ describe('Article API endpoints', () => {
expect(response.body).to.have.property('data');
expect(response.body.status).to.equal('success');
expect(response.body.data.allArticles[0].title).to.equal(
'new article'
'first article title'
);
expect(response.body.data.allArticles[0].description).to.equal(
'this is a description'
'This is a description'
);
expect(response.body.data.allArticles[0].body).to.equal(
'this is a description this is a description'
'lorem ipsum whatever'
);
done();
});
Expand All @@ -607,7 +607,7 @@ describe('Article API endpoints', () => {
expect(response.body).to.have.property('data');
expect(response.body.status).to.equal('success');
expect(response.body.data.allArticles[0].title).to.equal(
'new article'
'first article title'
);
done();
});
Expand Down
Loading

0 comments on commit 4074008

Please sign in to comment.