Skip to content

Commit

Permalink
feature(bookmarks): Implement User should be able to bookmark article…
Browse files Browse the repository at this point in the history
…s for reading later feature

- create bookmark model and relationships with user and articles model
- write service methods to create bookmarks, get user bookmarks and remove user bookmarks
- write controller methods to create bookmarks, get user bookmarks and remove user bookmarks
- write middleware method to validate incoming requests for creating, getting and removing user bookmarks
- update router file to accomodate new routes
- write unit tests
- delivers[#166816120]
  • Loading branch information
fxola committed Jul 16, 2019
1 parent b8f7b9b commit c9840ca
Show file tree
Hide file tree
Showing 15 changed files with 580 additions and 36 deletions.
82 changes: 82 additions & 0 deletions src/controllers/bookmark.controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import {
createBookmark,
removeBookmark,
getBookmarks
} from '../services/bookmark.service';
import Helper from '../services/helper';

export default {
/**
* @method createBookmark
* Handles the logic for adding an article to a user's bookmark
* Route: POST api/v1/articles/:slug/bookmarks
* @param {object} request
* @param {object} response
* @param {function} next
* @returns {object|function} API Response object or next method
*/

async createBookmark(request, response, next) {
try {
const bookmarked = await createBookmark(
request.articleId,
request.user.id
);
if (bookmarked) {
return Helper.successResponse(response, 201, {
message: `Article added to bookmarks`
});
}
return Helper.failResponse(response, 409, {
message: `Article is already in your bookmarks`
});
} catch (error) {
next(error);
}
},

/**
* @method getUserBookmarks
* Route: GET api/v1/articles/bookmarks
* Handles the logic for fetching all a user's bookmark
* @param {object} request
* @param {object} response
* @param {function} next
* @returns {object|function} API Response object or next method
*/

async getUserBookmarks(request, response, next) {
try {
const bookmarks = await getBookmarks(request.user.id);
return Helper.successResponse(response, 200, bookmarks);
} catch (error) {
next(error);
}
},

/**
* @method removeUserBookmark
* Route: DELETE api/v1/articles/:slug/bookmarks
* Handles the logic for removing an article from a user's bookmark
* @param {object} request
* @param {object} response
* @param {function} next
* @returns {object|function} API Response object or next method
*/

async removeUserBookmark(request, response, next) {
try {
const removed = await removeBookmark(request.articleId, request.user.id);
if (removed) {
return Helper.successResponse(response, 200, {
message: `Article has been removed from your bookmarks`
});
}
return Helper.failResponse(response, 404, {
message: `Article is not present in your bookmarks`
});
} catch (error) {
next(error);
}
}
};
34 changes: 34 additions & 0 deletions src/db/migrations/20190715093702-create-bookmark.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.createTable('Bookmarks', {
id: {
allowNull: false,
autoIncrement: true,
primaryKey: true,
type: Sequelize.INTEGER
},
userId: {
type: Sequelize.INTEGER
},
articleId: {
type: Sequelize.INTEGER
},
isDeleted: {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: false
},
createdAt: {
allowNull: false,
type: Sequelize.DATE
},
updatedAt: {
allowNull: false,
type: Sequelize.DATE
}
});
},
down: queryInterface => {
return queryInterface.dropTable('Bookmarks');
}
};
10 changes: 10 additions & 0 deletions src/db/migrations/create-article.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,16 @@ module.exports = {
type: Sequelize.STRING,
allowNull: false
},
likesCount: {
type: Sequelize.INTEGER,
allowNull: true,
default: 0
},
viewsCount: {
type: Sequelize.INTEGER,
allowNull: true,
default: 0
},
body: {
type: Sequelize.TEXT,
allowNull: false
Expand Down
16 changes: 16 additions & 0 deletions src/db/models/article.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,16 @@ export default (sequelize, DataTypes) => {
args: false
}
},
likesCount: {
type: DataTypes.INTEGER,
allowNull: true,
default: 0
},
viewsCount: {
type: DataTypes.INTEGER,
allowNull: true,
default: 0
},
averageRating: {
type: DataTypes.INTEGER,
default: 0
Expand Down Expand Up @@ -88,6 +98,12 @@ export default (sequelize, DataTypes) => {
foreignKey: 'userId',
as: 'author'
});
Article.belongsToMany(models.User, {
through: 'Bookmark',
as: 'bookmarks',
foreignKey: 'articleId',
otherKey: 'userId'
});
};
return Article;
};
19 changes: 19 additions & 0 deletions src/db/models/bookmark.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
export default (sequelize, DataTypes) => {
const Bookmark = sequelize.define(
'Bookmark',
{
userId: DataTypes.INTEGER,
articleId: DataTypes.INTEGER,
isDeleted: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false
}
},
{}
);
Bookmark.associate = () => {
// associations can be defined here
};
return Bookmark;
};
6 changes: 6 additions & 0 deletions src/db/models/user.js
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,12 @@ export default (sequelize, DataTypes) => {
foreignKey: 'userId',
as: 'articles'
});
User.belongsToMany(models.Article, {
through: 'Bookmark',
as: 'bookmarks',
foreignKey: 'userId',
otherKey: 'articleId'
});
};
return User;
};
2 changes: 1 addition & 1 deletion src/helpers/utilities.helper.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export default {
*/

async isNumeric(value) {
return !!Number(value);
return !!Number(value) && value > 0;
},

/**
Expand Down
31 changes: 31 additions & 0 deletions src/middlewares/bookmarkCheck.middleware.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { getArticleInstance } from '../services/bookmark.service';
import Utilities from '../helpers/utilities.helper';
import Helper from '../services/helper';

export default {
/**
* @method bookmarkCheck
* Validates incoming bookmark requests
* @param {object} request
* @param {object} response
* @param {function} next
* @returns {object|function} error object response or next middleware function
*/

async bookmarkCheck(request, response, next) {
const validSlug = await Utilities.isValidSlug(request.params.slug);
if (!validSlug) {
return Helper.failResponse(response, 400, {
message: 'Article slug is not valid'
});
}
const article = await getArticleInstance(request.params.slug);
if (!article) {
return Helper.failResponse(response, 404, {
message: `Article does not exist`
});
}
request.articleId = article.id;
next();
}
};
19 changes: 19 additions & 0 deletions src/routes/v1/article.route.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import express from 'express';
import commentController from '../../controllers/comment.controller';
import bookmarkController from '../../controllers/bookmark.controller';
import authorization from '../../middlewares/auth.middleware';
import commentsCheck from '../../middlewares/commentsCheck.middleware';
import bookmarksCheck from '../../middlewares/bookmarkCheck.middleware';

const router = express.Router();
const {
Expand All @@ -11,8 +13,15 @@ const {
getSingleComment
} = commentController;

const {
createBookmark,
getUserBookmarks,
removeUserBookmark
} = bookmarkController;

const { verifyToken } = authorization;
const { editCommentCheck, getArticlesCommentsCheck } = commentsCheck;
const { bookmarkCheck } = bookmarksCheck;

router.get(
'/:slug/comments',
Expand All @@ -39,4 +48,14 @@ router.patch(
editComment
);

router.post('/:slug/bookmarks', verifyToken, bookmarkCheck, createBookmark);

router.get('/bookmarks', verifyToken, getUserBookmarks);
router.delete(
'/:slug/bookmarks',
verifyToken,
bookmarkCheck,
removeUserBookmark
);

export default router;
114 changes: 114 additions & 0 deletions src/services/bookmark.service.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import model from '../db/models';

const { Article, Bookmark, User } = model;
/**
* @method createBookmark
* Interacts with the database to add an article to a user's bookmarks
* @param {number} articleId ID of the article to be bookmarked
* @param {number} userId ID of the user making the bookmark request
* @returns {object|boolean} Bookmark instance object or boolean if bookmarks exists
*/

export const createBookmark = async (articleId, userId) => {
const bookmarkExists = await Bookmark.findOne({
where: { articleId, userId, isDeleted: false }
});
if (bookmarkExists) return false;

const bookmarked = await Bookmark.create({
articleId,
userId
});
return bookmarked;
};

/**
* @method getBookmarks
* Interacts with the database to fetch all bookmarks for a user
* @param {number} userId ID of the user making the bookmark request
* @returns {object} Bookmarks response object
*/

export const getBookmarks = async userId => {
const userBookmarks = await User.findOne({
where: { id: userId },
attributes: {
exclude: [
'id',
'email',
'password',
'bio',
'image',
'twitterHandle',
'facebookHandle',
'confirmEmail',
'confirmEmailCode',
'isNotified',
'isPublished',
'passwordToken',
'socialAuth',
'roleType',
'createdAt',
'updatedAt'
]
},
include: [
{
model: Article,
as: 'bookmarks',
attributes: [
'title',
'body',
'image',
'likesCount',
'viewsCount',
'description'
],
through: {
model: Bookmark,
as: 'bookmarks',
where: { isDeleted: false },
attributes: {
exclude: ['userId', 'articleId', 'isDeleted']
}
}
}
]
});

if (userBookmarks.bookmarks.length < 1) {
return { message: `You currently don't have any bookmarks` };
}
return userBookmarks;
};

/**
* @method removeBookmark
* Interacts with the database to remove an article from a user's bookmarks
* @param {number} articleId
* @param {number} userId
* @returns {boolean} true if article was removed from bookmarks, false if otherwise
*/

export const removeBookmark = async (articleId, userId) => {
const bookmark = await Bookmark.findOne({
where: { articleId, userId, isDeleted: false }
});
if (!bookmark) return false;
await bookmark.update({ isDeleted: true });
return true;
};

/**
* @method getArticleInstance
* Queries the database to get an article instance
* @param {string} slug
* @returns {object} article instance object
*/

export const getArticleInstance = async slug => {
const articleInstance = await Article.findOne({
where: { slug }
});
return articleInstance;
};
Loading

0 comments on commit c9840ca

Please sign in to comment.