From b56193b37e82f69d854ed78c72db65563a0ae9a7 Mon Sep 17 00:00:00 2001 From: Alexandra Collins Date: Mon, 18 Mar 2019 17:48:09 +0100 Subject: [PATCH 1/7] feat(rate-article): implement article rating feature - create an endpoint for rating an article - create an endpoint for getting rated article - create a helper function for getting average article rating - create an article rating controller - write test - document endpoints using swagger - create articles seeders and setup the script - create middleware for validating ratings and checking article [Finish #164198186] --- .env.example | 1 + controllers/articleRatingController.js | 63 ++++++++++++++++ .../20190317233511-create-ratings.js | 38 ++++++++++ .../seeders/20190318124046-articles-seeder.js | 56 ++++++++++++++ helpers/articleRatingHelper.js | 11 +++ middlewares/ratings.js | 25 +++++++ models/Article.js | 5 +- models/Ratings.js | 32 ++++++++ models/index.js | 1 + package.json | 5 +- routes/auth.js | 2 +- routes/index.js | 2 + routes/ratings.js | 74 +++++++++++++++++++ .../integrations/routes/articleRating.test.js | 34 +++++++++ 14 files changed, 346 insertions(+), 3 deletions(-) create mode 100644 controllers/articleRatingController.js create mode 100644 database/migrations/20190317233511-create-ratings.js create mode 100644 database/seeders/20190318124046-articles-seeder.js create mode 100644 helpers/articleRatingHelper.js create mode 100644 middlewares/ratings.js create mode 100644 models/Ratings.js create mode 100644 routes/ratings.js create mode 100644 test/integrations/routes/articleRating.test.js diff --git a/.env.example b/.env.example index 93f1a67..b32f0d1 100644 --- a/.env.example +++ b/.env.example @@ -26,5 +26,6 @@ GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= GOOGLE_CALLBACK_URL= +JWT_TOKEN= API_DOMAIN=localhost:3000 diff --git a/controllers/articleRatingController.js b/controllers/articleRatingController.js new file mode 100644 index 0000000..c1b4fc3 --- /dev/null +++ b/controllers/articleRatingController.js @@ -0,0 +1,63 @@ +import models from '../models'; +import Response from '../helpers/responseHelper'; +import { STATUS } from '../helpers/constants'; +import getAverageRatings from '../helpers/articleRatingHelper'; + +const { Ratings } = models; +/** + * Class representing article rating controller. + * + * @export + * @class ArticleRatingController +*/ +export default class ArticleRatingController { + /** + * Sends the request payload to the database and returns the article object + * @static + * @param {function} req the request object + * @param {function} res the resposne object + * @returns {function} the Article object + */ + static async create(req, res) { + const userId = req.user.id; + const { articleId } = req.params; + const { body: { stars } } = req; + try { + const articleRating = await Ratings.create({ + userId, + articleId, + stars + }); + return Response.send(res, STATUS.CREATED, articleRating, 'sucsess'); + } catch (err) { + return Response.send(res, STATUS.SERVER_ERROR, err, 'failed'); + } + } + + /** + * Sends the request payload to the database and returns the article object + * @static + * @param {function} req the request object + * @param {function} res the resposne object + * @returns {function} the Article object + */ + static async get(req, res) { + try { + const getRatings = await Ratings.findAndCountAll({ + where: { + articleId: req.params.articleId, + } + }); + if (getRatings.count >= 1) { + const averageRatings = getAverageRatings(getRatings, getRatings.count); + const finalRatings = Object.assign({}, getRatings, { + averageRatings + }); + return Response.send(res, STATUS.OK, finalRatings, 'success'); + } + return Response.send(res, STATUS.NOT_FOUND, [], 'No ratings for this article'); + } catch (err) { + return Response.send(res, STATUS.SERVER_ERROR, err, 'failed'); + } + } +} diff --git a/database/migrations/20190317233511-create-ratings.js b/database/migrations/20190317233511-create-ratings.js new file mode 100644 index 0000000..f2d8842 --- /dev/null +++ b/database/migrations/20190317233511-create-ratings.js @@ -0,0 +1,38 @@ +export default { + up: (queryInterface, Sequelize) => queryInterface.createTable('ratings', { + id: { + allowNull: false, + autoIncrement: true, + primaryKey: true, + type: Sequelize.INTEGER + }, + userId: { + type: Sequelize.INTEGER, + allowNull: false, + references: { + model: 'users', + key: 'id' + } + }, + articleId: { + type: Sequelize.INTEGER, + allowNull: false, + references: { + model: 'articles', + key: 'id' + }, + }, + stars: { + type: Sequelize.INTEGER, + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE + }, + }), + down: (queryInterface, Sequelize) => queryInterface.dropTable('ratings') +}; diff --git a/database/seeders/20190318124046-articles-seeder.js b/database/seeders/20190318124046-articles-seeder.js new file mode 100644 index 0000000..f3ba2b9 --- /dev/null +++ b/database/seeders/20190318124046-articles-seeder.js @@ -0,0 +1,56 @@ +import faker from 'faker'; + +export default { + up: (queryInterface, Sequelize) => queryInterface.bulkInsert('articles', [{ + title: 'Javascript', + slug: 'Javascript-2db9e3cd-f9ed-4f9c-a8f6-e5731c8e1415', + body: faker.lorem.paragraphs(), + createdAt: new Date(), + description: faker.random.words(15), + readTime: '2 minutes read', + updatedAt: new Date(), + authorId: 1, + }, + { + title: 'NodeJs', + slug: 'NodeJs-2db9e3cd-f9ed-4f9c-a9f6-e5731c8e1515', + body: faker.lorem.paragraphs(), + createdAt: new Date(), + description: faker.random.words(15), + readTime: '2 minutes read', + updatedAt: new Date(), + authorId: 2, + }, + { + title: 'ExpressJs', + slug: 'ExpressJs-2db9e3cd-f9ed-4f9c-a9f6-e5731c8f1515', + body: faker.lorem.paragraphs(), + createdAt: new Date(), + description: faker.random.words(15), + readTime: '2 minutes read', + updatedAt: new Date(), + authorId: 2, + }, + { + title: 'ReactJs', + slug: 'ReactJs-2db9e3cd-f9ed-4f9c-a9f6-e5731c8e1525', + body: faker.lorem.paragraphs(), + createdAt: new Date(), + description: faker.random.words(15), + readTime: '2 minutes read', + updatedAt: new Date(), + authorId: 1, + }, + { + title: 'VueJs', + slug: 'VueJs-2db9e3cd-f9ed-4f9c-a9f6-e5731c8e1515', + body: faker.lorem.paragraphs(), + createdAt: new Date(), + description: faker.random.words(15), + readTime: '2 minutes read', + updatedAt: new Date(), + authorId: 2, + }], {}), + // eslint-disable-next-line no-unused-expressions + down: (queryInterface, Sequelize) => queryInterface.bulkDelete('article', null, {}) +}; diff --git a/helpers/articleRatingHelper.js b/helpers/articleRatingHelper.js new file mode 100644 index 0000000..e111b9d --- /dev/null +++ b/helpers/articleRatingHelper.js @@ -0,0 +1,11 @@ +let sum = 0; +const getAverageRatings = (ratings, count) => { + ratings.rows.map((value) => { + sum += value.stars; + return sum; + }); + const average = sum / count; + return average; +}; + +export default getAverageRatings; diff --git a/middlewares/ratings.js b/middlewares/ratings.js new file mode 100644 index 0000000..874a983 --- /dev/null +++ b/middlewares/ratings.js @@ -0,0 +1,25 @@ +import Response from '../helpers/responseHelper'; +import { STATUS } from '../helpers/constants'; +import models from '../models'; + +const { Article } = models; + +export const validateRatings = (req, res, next) => { + const { body: { stars } } = req; + if (!stars || stars < 1 || stars > 5) { + return Response.send(res, STATUS.BAD_REQUEST, [], 'Input cannot be less than 1 or greater than 5', false); + } + return next(); +}; + +export const checkArticle = async (req, res, next) => { + const foundArticle = await Article.findAndCountAll({ + where: { + id: req.params.articleId, + } + }); + if (foundArticle.count >= 1) { + return next(); + } + return Response.send(res, STATUS.NOT_FOUND, {}, 'ARTICLE NOT FOUND', 'Failure'); +}; diff --git a/models/Article.js b/models/Article.js index e03954b..8e2fdeb 100644 --- a/models/Article.js +++ b/models/Article.js @@ -14,7 +14,10 @@ const Article = (sequelize, DataTypes) => { allowNull: false, unique: true }, - slug: DataTypes.STRING, + slug: { + type: DataTypes.STRING, + unique: true + }, body: { type: DataTypes.TEXT, allowNull: false diff --git a/models/Ratings.js b/models/Ratings.js new file mode 100644 index 0000000..5bae096 --- /dev/null +++ b/models/Ratings.js @@ -0,0 +1,32 @@ +/** + * A model class representing article rating + * + * @param {Sequelize} sequelize - Sequelize object + * @param {Sequelize.DataTypes} DataTypes - A convinient object holding data types + * @return {Sequelize.Model} - Application Settings Model + */ +const Ratings = (sequelize, DataTypes) => { + const RatingSchema = sequelize.define('ratings', { + stars: { + type: DataTypes.INTEGER, + allowNull: false, + validate: { + min: 1, + max: 5, + } + }, + }, {}); + RatingSchema.associate = (models) => { + RatingSchema.belongsTo(models.User, { + foreignKey: 'userId', + onDelete: 'CASCADE', + }); + RatingSchema.belongsTo(models.Article, { + foreignKey: 'articleId', + onDelete: 'CASCADE', + }); + }; + return RatingSchema; +}; + +export default Ratings; diff --git a/models/index.js b/models/index.js index c0df5bf..3993441 100644 --- a/models/index.js +++ b/models/index.js @@ -18,6 +18,7 @@ const models = { Article: sequelize.import('./Article.js'), Setting: sequelize.import('./Setting.js'), ArticleLike: sequelize.import('./ArticleLikes.js'), + Ratings: sequelize.import('./Ratings.js'), }; Object.keys(models).forEach((key) => { diff --git a/package.json b/package.json index f83de17..0a8cff7 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,10 @@ "sequelize": "sequelize", "start": "node dist/bin/www.js", "start:dev": "nodemon --exec npm run dev", - "pretest": "NODE_ENV=test npm run migrate", + "undo:migrate:test": "NODE_ENV=test sequelize db:migrate:undo:all", + "seed:test": "NODE_ENV=test sequelize db:seed:all", + "migrate:test": "NODE_ENV=test sequelize db:migrate", + "pretest": "NODE_ENV=test npm run undo:migrate:test && npm run migrate:test && npm run seed:test", "test": "nyc --reporter=html --reporter=text --reporter=lcov mocha --timeout 50000" }, "author": "Andela Simulations Programme", diff --git a/routes/auth.js b/routes/auth.js index d673266..326e461 100644 --- a/routes/auth.js +++ b/routes/auth.js @@ -11,7 +11,7 @@ const router = express.Router(); * definitions: * User: * properties: - * id: + * id: * type: int * email: * type: string diff --git a/routes/index.js b/routes/index.js index 95a685f..8a0e8cf 100644 --- a/routes/index.js +++ b/routes/index.js @@ -4,6 +4,7 @@ import profile from './profile'; import authRoute from './auth'; import articles from './articles'; import settingRouter from './settings'; +import ratings from './ratings'; const router = express.Router(); @@ -27,5 +28,6 @@ router.use(articles); router.use('/auth', authRoute); router.use('/articles', articles); router.use('/setting', settingRouter); +router.use('/articles', ratings); export default router; diff --git a/routes/ratings.js b/routes/ratings.js new file mode 100644 index 0000000..987936f --- /dev/null +++ b/routes/ratings.js @@ -0,0 +1,74 @@ +import express from 'express'; +import authenticate from '../middlewares/authenticate'; +import articleRatings from '../controllers/articleRatingController'; +import { validateRatings, checkArticle } from '../middlewares/ratings'; + +const router = express.Router(); +/** + * @swagger + * definitions: + * Article: + * properties: + * id: + * type: int + * token: + * type: string + * title: + * type: string + * body: + * type: string + * Response: + * properties: + * code: + * type: int + * data: + * type: object + * message: + * type: string + * status: + * type: boolean + * + */ + +/** + * @swagger + * /api/v1/articles/:id/ratings: + * post: + * tags: + * - article ratings + * description: rating an article + * produces: + * - application/json + * responses: + * 200: + * description: Success + * schema: + * $ref: '#/definitions/Article' + */ + +router.post('/:articleId/ratings', + authenticate, + checkArticle, + validateRatings, + articleRatings.create); + +/** + * @swagger + * /api/v1/articles/:id/ratings: + * get: + * tags: + * - article ratings + * description: get a rated article + * produces: + * - application/json + * responses: + * 200: + * description: Success + * schema: + * $ref: '#/definitions/Article' + */ +router.get('/:articleId/ratings', + checkArticle, + articleRatings.get); + +export default router; diff --git a/test/integrations/routes/articleRating.test.js b/test/integrations/routes/articleRating.test.js new file mode 100644 index 0000000..5f6f08c --- /dev/null +++ b/test/integrations/routes/articleRating.test.js @@ -0,0 +1,34 @@ +import chai, { expect } from 'chai'; +import chaiHttp from 'chai-http'; +import app from '../../../index'; + +chai.use(chaiHttp); + +describe('Article Rating API endpoint', () => { + const token = process.env.JWT_TOKEN; + describe('/rating Post Endpoint', () => { + const rateArticle = { + stars: 5, + }; + + it('should create an article', async () => { + const res = await chai.request(app) + .post('/api/v1/articles/1/ratings') + .set({ Authorization: `Bearer ${token}` }) + .send(rateArticle); + expect(res.body.code).to.equal(201); + expect(res.body.data).to.be.an('object'); + expect(res.body.status).to.equal(true); + }); + }); + + describe('/rating Get Endpoint', () => { + it('should get a rated article', async () => { + const res = await chai.request(app) + .get('/api/v1/articles/1/ratings'); + expect(res.body.code).to.equal(200); + expect(res.body.message).to.equal('success'); + expect(res.body.status).to.equal(true); + }); + }); +}); From 1cda9d422d22dd8d61992c2aa207d5421276b2dc Mon Sep 17 00:00:00 2001 From: David whyte Date: Wed, 20 Mar 2019 17:20:18 +0100 Subject: [PATCH 2/7] feat: comment history - create models and migrations - handle relationships - create comment history on comment update - handle history limit - handle errors - check tests [Deivers #164198204] --- controllers/commentsController.js | 27 ++++++++++- .../20190320121330-create-comment-history.js | 26 +++++++++++ models/Comment.js | 4 ++ models/CommentHistory.js | 45 +++++++++++++++++++ 4 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 database/migrations/20190320121330-create-comment-history.js create mode 100644 models/CommentHistory.js diff --git a/controllers/commentsController.js b/controllers/commentsController.js index 4d2a632..99d8a30 100644 --- a/controllers/commentsController.js +++ b/controllers/commentsController.js @@ -2,6 +2,7 @@ import models from '../models'; import Response from '../helpers/responseHelper'; import { STATUS } from '../helpers/constants'; import paginationHelper from '../helpers/articleHelpers'; +import Logger from '../helpers/logger'; /** * Wrapper class for sending comments objects as response. @@ -64,9 +65,12 @@ export default class CommentsController { ] }, as: 'author' + }, + { + model: models.CommentHistory, } ], - attributes: { exclude: ['authorId'] }, + attributes: {}, limit, offset, order: [['createdAt']] @@ -76,6 +80,7 @@ export default class CommentsController { } = paginationHelper.getResourcesAsPages(req, comments); return Response.send(res, code, data, 'comments successfully fetched', status); } catch (error) { + console.log(error); return Response.send(res, STATUS.BAD_REQUEST, error, 'Server error', false); } } @@ -94,6 +99,26 @@ export default class CommentsController { const { body } = req.body; const { id } = req.params; + // here we get the commet to be updated and then we create a new comment history for it + // with the previous comment then we update the comment data + try { + const oldCommentData = await models.Comment.findOne({ + where: { articleId, id, authorId }, + raw: true + }); + const count = await models.CommentHistory.count({ where: { commentId: id } }); + console.log(count); + // limiting the comment history to 5 + if (count < 5) { + const oldMessage = oldCommentData.body; + await models.CommentHistory.create({ body: oldMessage, commentId: id }); + } else { + return Response.send(res, STATUS.BAD_REQUEST, null, 'You can only edit a comment 5 times', false); + } + } catch (e) { + Logger.log(e); + return Response.send(res, STATUS.BAD_REQUEST, null, 'Server error', false); + } try { const comment = await models.Comment.update({ body: body.trim(), isAnonymousUser }, { where: { articleId, id, authorId }, diff --git a/database/migrations/20190320121330-create-comment-history.js b/database/migrations/20190320121330-create-comment-history.js new file mode 100644 index 0000000..b36a882 --- /dev/null +++ b/database/migrations/20190320121330-create-comment-history.js @@ -0,0 +1,26 @@ +export default { + up: (queryInterface, Sequelize) => queryInterface.createTable('CommentHistory', { + id: { + allowNull: false, + autoIncrement: true, + primaryKey: true, + type: Sequelize.INTEGER + }, + commentId: { + allowNull: false, + type: Sequelize.INTEGER + }, + body: { + type: Sequelize.STRING + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE + } + }), + down: (queryInterface, Sequelize) => queryInterface.dropTable('CommentHistory'), +}; diff --git a/models/Comment.js b/models/Comment.js index e3a728f..d5633c1 100644 --- a/models/Comment.js +++ b/models/Comment.js @@ -33,11 +33,15 @@ const Comment = (sequelize, DataTypes) => { targetKey: 'id', foreignKey: 'articleId' }); + models.Comment.belongsTo(models.User, { foreignKey: 'authorId', onDelete: 'CASCADE', as: 'author' }); + models.Comment.hasMany(models.CommentHistory, { + foreignKey: 'commentId', + }); }; return CommentSchema; }; diff --git a/models/CommentHistory.js b/models/CommentHistory.js new file mode 100644 index 0000000..2d983ba --- /dev/null +++ b/models/CommentHistory.js @@ -0,0 +1,45 @@ +/** + * Comment History model + * @param {Sequelize} sequelize + * @param {Sequelize.DataTypes} DataTypes + * @returns {Sequelize.models} - CommentHistory model + */ + +const CommentHistory = (sequelize, DataTypes) => { + const CommentHistorySchema = sequelize.define( + 'CommentHistory', + { + body: { + type: DataTypes.TEXT, + allowNull: false, + }, + createdAt: { + allowNull: false, + type: DataTypes.DATE, + defaultValue: sequelize.NOW + }, + commentId: { + allowNull: false, + type: DataTypes.INTEGER + }, + updatedAt: { + type: DataTypes.DATE, + defaultValue: sequelize.NOW, + onUpdate: sequelize.NOW + } + }, + { + freezeTableName: true, + tableName: 'CommentHistory' + } + ); + CommentHistorySchema.associate = (models) => { + models.CommentHistory.belongsTo(models.Comment, { + onDelete: 'CASCADE', + foreignKey: 'commentId' + }); + }; + + return CommentHistorySchema; +}; +export default CommentHistory; From 7ca4b5a3d9074ad024ee8decacbc5d11fd834ff7 Mon Sep 17 00:00:00 2001 From: David whyte Date: Wed, 20 Mar 2019 17:35:10 +0100 Subject: [PATCH 3/7] Update commentsController.js --- controllers/commentsController.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/controllers/commentsController.js b/controllers/commentsController.js index 99d8a30..bcfc8fa 100644 --- a/controllers/commentsController.js +++ b/controllers/commentsController.js @@ -80,7 +80,7 @@ export default class CommentsController { } = paginationHelper.getResourcesAsPages(req, comments); return Response.send(res, code, data, 'comments successfully fetched', status); } catch (error) { - console.log(error); + Logger.log(error); return Response.send(res, STATUS.BAD_REQUEST, error, 'Server error', false); } } @@ -107,7 +107,6 @@ export default class CommentsController { raw: true }); const count = await models.CommentHistory.count({ where: { commentId: id } }); - console.log(count); // limiting the comment history to 5 if (count < 5) { const oldMessage = oldCommentData.body; From 184d7fc71d4f6c831bca1d56a1f93f0cf44f34a5 Mon Sep 17 00:00:00 2001 From: Lekan Omoniyi Date: Thu, 21 Mar 2019 01:56:09 +0100 Subject: [PATCH 4/7] feat(stats): implements user statistics - add History model - send cookies during user login - add History migration file - add statsHelper function - add stats route - add stats test [Delivers #164198201] --- .env.example | 3 + controllers/articlesController.js | 3 + controllers/statsController.js | 24 ++++++++ controllers/usersController.js | 1 + .../20190320080157-create-history.js | 41 ++++++++++++++ helpers/statsHelper.js | 35 ++++++++++++ index.js | 17 +++--- middlewares/articlesMiddleware.js | 1 + middlewares/statsMiddleware.js | 43 +++++++++++++++ models/Article.js | 2 + models/History.js | 24 ++++++++ models/User.js | 1 + routes/articles.js | 2 +- routes/index.js | 2 + routes/stats.js | 45 +++++++++++++++ test/integrations/routes/stats.test.js | 46 ++++++++++++++++ test/unit/middlewares/stats.test.js | 55 +++++++++++++++++++ 17 files changed, 336 insertions(+), 9 deletions(-) create mode 100644 controllers/statsController.js create mode 100644 database/migrations/20190320080157-create-history.js create mode 100644 helpers/statsHelper.js create mode 100644 middlewares/statsMiddleware.js create mode 100644 models/History.js create mode 100644 routes/stats.js create mode 100644 test/integrations/routes/stats.test.js create mode 100644 test/unit/middlewares/stats.test.js diff --git a/.env.example b/.env.example index 93f1a67..d96a18d 100644 --- a/.env.example +++ b/.env.example @@ -26,5 +26,8 @@ GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= GOOGLE_CALLBACK_URL= +# sessions details +COOKIE_SECRET= + API_DOMAIN=localhost:3000 diff --git a/controllers/articlesController.js b/controllers/articlesController.js index 4ed1c9a..877e705 100644 --- a/controllers/articlesController.js +++ b/controllers/articlesController.js @@ -2,6 +2,7 @@ import models from '../models'; import Response from '../helpers/responseHelper'; import articleHelpers from '../helpers/articleHelpers'; import { STATUS } from '../helpers/constants'; +import statsHelper from '../helpers/statsHelper'; const { ArticleCategory } = models; @@ -106,6 +107,7 @@ export default class ArticlesController { * @returns {function} an array of Articles object */ static async getOne(req, res) { + const { email } = res.locals; const { slug } = req.params; try { const article = await models.Article.findOne({ @@ -120,6 +122,7 @@ export default class ArticlesController { if (!article) { return Response.send(res, STATUS.NOT_FOUND, [], `no article with slug: ${slug} found`, false); } + await statsHelper.confirmUser(email, article.id, article.categoryId); return Response.send(res, STATUS.OK, article, 'article was successfully fetched', true); } catch (error) { return Response.send(res, STATUS.BAD_REQUEST, error, 'server error', false); diff --git a/controllers/statsController.js b/controllers/statsController.js new file mode 100644 index 0000000..4172069 --- /dev/null +++ b/controllers/statsController.js @@ -0,0 +1,24 @@ +// import models from '../models'; +import Response from '../helpers/responseHelper'; +import { STATUS } from '../helpers/constants'; + +/** + * Wrapper class for sending user statistics numbers and breakdowns as response. + * + * @export + * @class StatiticsController + */ +export default class StatiticsController { + /** + * This controller combines the results of the calculated stats + * and returns the user stats object + * @static + * @param {function} req The request object + * @param {fucntion} res The response object + * @returns {function} The stats object + */ + static async sendUserStats(req, res) { + const { stats } = res.locals; + return Response.send(res, STATUS.OK, stats, 'stats successfully fetched', true); + } +} diff --git a/controllers/usersController.js b/controllers/usersController.js index 755eb9e..c304ad4 100644 --- a/controllers/usersController.js +++ b/controllers/usersController.js @@ -171,6 +171,7 @@ class UsersController { const token = await generateToken(payload); // respond with token + request.session.email = email; return response .status(200) .json({ token, id: user.id }); diff --git a/database/migrations/20190320080157-create-history.js b/database/migrations/20190320080157-create-history.js new file mode 100644 index 0000000..91fa5bf --- /dev/null +++ b/database/migrations/20190320080157-create-history.js @@ -0,0 +1,41 @@ +/* eslint no-unused-vars: ["error", { "args": "none" }] */ +export default { + up: (queryInterface, Sequelize) => (queryInterface.createTable('Histories', { + id: { + allowNull: false, + autoIncrement: true, + primaryKey: true, + type: Sequelize.INTEGER + }, + articleId: { + type: Sequelize.INTEGER, + onDelete: 'CASCADE', + allowNull: false, + references: { + model: 'Articles', + key: 'id' + } + }, + userId: { + type: Sequelize.INTEGER, + allowNull: false, + references: { + model: 'Users', + key: 'id' + }, + }, + categoryId: { + type: Sequelize.INTEGER, + allowNull: false, + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE + } + })), + down: (queryInterface, Sequelize) => queryInterface.dropTable('Histories') +}; diff --git a/helpers/statsHelper.js b/helpers/statsHelper.js new file mode 100644 index 0000000..81c3f3c --- /dev/null +++ b/helpers/statsHelper.js @@ -0,0 +1,35 @@ +import models from '../models'; + +/** + * Wrapper class for recording the user visit history. + * + * @export + * @class StatsHelper + */ +export default class StatsHelper { + /** + * Verifies the user via cookies before creating a row + * of the userId, articleId, and categoryId + * to save a record of the read history of the user + * @static + * @param {function} email The request object + * @param {fucntion} articleId The response object + * @param {function} categoryId The express built in next middleware + * @returns {function} returns an empty string to allow request continuation. + */ + static async confirmUser(email, articleId, categoryId) { + const user = await models.User.findOne({ + where: { email }, + attributes: { exclude: ['password', 'isConfirmed', 'updatedAt', 'createdAt', 'deletedAt', 'email'] } + }); + if (user) { + const userId = user.dataValues.id; + const content = { userId, articleId, categoryId }; + const historyCheck = await models.History.findOne({ + where: { userId, articleId, categoryId } + }); + if (!historyCheck) await models.History.create(content); + } + return ''; + } +} diff --git a/index.js b/index.js index cfcd52d..e0075e3 100644 --- a/index.js +++ b/index.js @@ -31,14 +31,15 @@ app.use(morgan(':remote-addr - ":method :url :status ":user-agent"', { stream: logger.stream(), skip: () => !isProduction })); -app.use( - session({ - secret: 'authorshaven', - cookie: { maxAge: 60000 }, - resave: false, - saveUninitialized: false - }) -); + +const sessOptions = { + secret: process.env.COOKIE_SECRET, + cookie: { maxAge: 60 * 1000 * 60 * 24 * 31, secure: true }, + resave: false, + saveUninitialized: false, +}; + +app.use(session(sessOptions)); const { User } = models; diff --git a/middlewares/articlesMiddleware.js b/middlewares/articlesMiddleware.js index b405697..dd538d3 100644 --- a/middlewares/articlesMiddleware.js +++ b/middlewares/articlesMiddleware.js @@ -105,6 +105,7 @@ export default class AriclesMiddleware { res, STATUS.BAD_REQUEST, [], 'slug is not a string', false, ); } + if (req.session && req.session.email) res.locals.email = req.session.email; return next(); } diff --git a/middlewares/statsMiddleware.js b/middlewares/statsMiddleware.js new file mode 100644 index 0000000..2486053 --- /dev/null +++ b/middlewares/statsMiddleware.js @@ -0,0 +1,43 @@ +import models from '../models'; +import Response from '../helpers/responseHelper'; +import { STATUS } from '../helpers/constants'; + +/** + * Wrapper class for calculating user statistics. + * + * @export + * @class StatisticsMiddleware + */ +export default class StatisticsMiddleware { + /** + * Verifies the request payload before calling the + * express next() middleware to continue with the comments sendUserStats controller + * @static + * @param {function} req The request object + * @param {fucntion} res The response object + * @param {function} next The express built in next middleware + * @returns {function} returns the error object or returns next() + */ + static async getUserStats(req, res, next) { + const userId = req.user.id; + const stats = {}; + + if (!Number(req.params.id)) return Response.send(res, STATUS.BAD_REQUEST, [], 'the provided params is invalid', false); + + if (userId !== Number(req.params.id)) return Response.send(res, STATUS.FORBIDDEN, [], 'You are not authorized to view this stat', false); + + const commentsResult = await models.Comment.findAndCountAll({ where: { authorId: userId } }); + const likesResult = await models.ArticleLike.findAndCountAll({ where: { userId } }); + const bookmarkedResult = await models.Bookmark.findAndCountAll({ where: { userId } }); + const articleResult = await models.History.findAndCountAll({ where: { userId } }); + + stats.bookmarked = bookmarkedResult.count; + stats.comments = commentsResult.count; + stats.liked = likesResult.count; + stats.articles = articleResult.count; + + res.locals.stats = stats; + + return next(); + } +} diff --git a/models/Article.js b/models/Article.js index 0b71880..a036068 100644 --- a/models/Article.js +++ b/models/Article.js @@ -45,6 +45,7 @@ const Article = (sequelize, DataTypes) => { foreignKey: 'authorId' }); ArticleSchema.hasMany(models.ArticleLike, { foreignKey: 'articleId' }); + ArticleSchema.hasMany(models.Comment, { foreignKey: 'articleId' }); ArticleSchema.belongsTo(models.ArticleCategory, { targetKey: 'id', as: 'articleCategory', @@ -57,6 +58,7 @@ const Article = (sequelize, DataTypes) => { otherKey: 'tagName', as: 'tagList' }); + ArticleSchema.hasMany(models.History, { foreignKey: 'articleId' }); }; return ArticleSchema; }; diff --git a/models/History.js b/models/History.js new file mode 100644 index 0000000..fac9ed2 --- /dev/null +++ b/models/History.js @@ -0,0 +1,24 @@ +/** + * + * @param {Sequelize} sequelize + * @param {Sequelize.DataTypes} DataTypes + * @returns {Sequelize.models} - History model + */ +export default (sequelize, DataTypes) => { + const History = sequelize.define( + 'History', + { + categoryId: { + type: DataTypes.INTEGER, + allowNull: false, + } + }, + {} + ); + History.associate = (models) => { + // associations can be defined here + History.belongsTo(models.Article, { foreignKey: 'articleId' }); + History.belongsTo(models.User, { foreignKey: 'userId' }); + }; + return History; +}; diff --git a/models/User.js b/models/User.js index 24c55eb..2250330 100644 --- a/models/User.js +++ b/models/User.js @@ -60,6 +60,7 @@ export default (sequelize, DataTypes) => { through: models.UserFollowers }); User.hasMany(models.Comment, { foreignKey: 'authorId', as: 'authors' }); + User.hasMany(models.History, { foreignKey: 'userId' }); }; /** diff --git a/routes/articles.js b/routes/articles.js index 09d7b53..e39597d 100644 --- a/routes/articles.js +++ b/routes/articles.js @@ -44,7 +44,7 @@ const articles = express.Router(); * schema: * $ref: '#/definitions/Article' */ -articles.get('/:slug', authenticate, articlesMiddleware.validateGetOneArticle, articlesController.getOne); +articles.get('/:slug', articlesMiddleware.validateGetOneArticle, articlesController.getOne); /** * @swagger diff --git a/routes/index.js b/routes/index.js index b120014..98f3887 100644 --- a/routes/index.js +++ b/routes/index.js @@ -8,6 +8,7 @@ import bookmark from './bookmarks'; import tags from './tags'; import notificationsRouter from './notifications'; import comments from './comments'; +import stats from './stats'; const router = express.Router(); @@ -35,5 +36,6 @@ router.use('/bookmarks', bookmark); router.use('/tags', tags); router.use('/notification', notificationsRouter); router.use('/articles', comments); +router.use('/users', stats); export default router; diff --git a/routes/stats.js b/routes/stats.js new file mode 100644 index 0000000..3902b4d --- /dev/null +++ b/routes/stats.js @@ -0,0 +1,45 @@ +import express from 'express'; +import authenticate from '../middlewares/authenticate'; +import statsMiddleware from '../middlewares/statsMiddleware'; +import statsController from '../controllers/statsController'; + +const comments = express.Router(); +/** + * @swagger + * definitions: + * Stats: + * properties: + * bookmarked: + * type: integer + * liked: + * type: integer + * comments: + * type: integer + * articles: + * type: integer + */ + +/** + * @swagger + * /api/v1/users/:id/stats: + * get: + * tags: + * - stats + * description: Provide statistics on user's activities + * produces: + * - application/json + * parameters: + * - name: id + * description: user's Id + * in: query + * required: true + * type: integer + * responses: + * 200: + * description: Successfully fetched + * schema: + * $ref: '#/definitions/Stats' + */ +comments.get('/:id/stats', authenticate, statsMiddleware.getUserStats, statsController.sendUserStats); + +export default comments; diff --git a/test/integrations/routes/stats.test.js b/test/integrations/routes/stats.test.js new file mode 100644 index 0000000..af2412f --- /dev/null +++ b/test/integrations/routes/stats.test.js @@ -0,0 +1,46 @@ +/* eslint-disable no-unused-expressions */ +import chai, { expect } from 'chai'; +import faker from 'faker'; +import chaiHttp from 'chai-http'; +import app from '../../../index'; +import { STATUS } from '../../../helpers/constants'; + +chai.use(chaiHttp); + +let authpayload; +let authToken; + +const dummyUser = { + email: faker.internet.email(), + password: 'i2345678', + username: faker.name.firstName() +}; + +before(async () => { + authpayload = await chai + .request(app) + .post('/api/v1/users') + .send(dummyUser); + authToken = authpayload.body.data.token; + dummyUser.id = authpayload.body.data.id; +}); + +describe('Stats Endpoint: /api/v1/users/:id/stats', () => { + it('Should fetch user stats', (done) => { + chai + .request(app) + .get(`/api/v1/users/${dummyUser.id}/stats`) + .set({ Authorization: `Bearer ${authToken}` }) + .end((err, res) => { + expect(res).to.have.status(STATUS.OK); + expect(res.body).to.be.an('object'); + expect(res.body).to.haveOwnProperty('code').to.equal(STATUS.OK); + expect(res.body.data).to.haveOwnProperty('articles'); + expect(res.body.data).to.haveOwnProperty('bookmarked'); + expect(res.body.data).to.haveOwnProperty('liked'); + expect(res.body.data).to.haveOwnProperty('comments'); + expect(res.body.message).to.equal('stats successfully fetched'); + done(); + }); + }); +}); diff --git a/test/unit/middlewares/stats.test.js b/test/unit/middlewares/stats.test.js new file mode 100644 index 0000000..129d235 --- /dev/null +++ b/test/unit/middlewares/stats.test.js @@ -0,0 +1,55 @@ +/* eslint-disable no-unused-expressions */ +import chai, { expect } from 'chai'; +import faker from 'faker'; +import chaiHttp from 'chai-http'; +import app from '../../../index'; +import { STATUS } from '../../../helpers/constants'; + +chai.use(chaiHttp); + +let authpayload; +let authToken; + +const dummyUser = { + email: faker.internet.email(), + password: 'i2345678', + username: faker.name.firstName() +}; + +before(async () => { + authpayload = await chai + .request(app) + .post('/api/v1/users') + .send(dummyUser); + authToken = authpayload.body.data.token; + dummyUser.id = authpayload.body.data.id; +}); + +describe('Stats Endpoint: /api/v1/users/:id/stats', () => { + it('Should return an error when user id is an empty string', (done) => { + chai + .request(app) + .get(`/api/v1/users/${' '}/stats`) + .set({ Authorization: `Bearer ${authToken}` }) + .end((err, res) => { + expect(res).to.have.status(STATUS.BAD_REQUEST); + expect(res.body).to.be.an('object'); + expect(res.body).to.haveOwnProperty('code').to.equal(STATUS.BAD_REQUEST); + expect(res.body.message).to.equal('the provided params is invalid'); + done(); + }); + }); + it('Should return an error when user id and params id and params id don\'t match', (done) => { + chai + .request(app) + .get(`/api/v1/users/${dummyUser.id + 300}/stats`) + .set({ Authorization: `Bearer ${authToken}` }) + .end((err, res) => { + expect(res).to.have.status(STATUS.FORBIDDEN); + expect(res.body).to.be.an('object'); + expect(res.body).to.haveOwnProperty('code').to.equal(STATUS.FORBIDDEN); + expect(res.body.message).to.equal('You are not authorized to view this stat'); + done(); + }); + }); +}); From 10abb6e99309efadcfe43f6aa306dfb23631fd61 Mon Sep 17 00:00:00 2001 From: David whyte Date: Thu, 21 Mar 2019 15:58:24 +0100 Subject: [PATCH 5/7] Update comments.test.js --- test/integrations/routes/comments.test.js | 95 +++++++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/test/integrations/routes/comments.test.js b/test/integrations/routes/comments.test.js index 6afa44e..9729e00 100644 --- a/test/integrations/routes/comments.test.js +++ b/test/integrations/routes/comments.test.js @@ -86,6 +86,7 @@ describe('API endpoint: /api/articles/:slug/comments (Routes)', () => { expect(res).to.have.status(STATUS.OK); expect(res.body).to.be.an('object'); expect(res.body).to.haveOwnProperty('code').to.equal(STATUS.OK); + expect(res.body.data.articles[0]).to.haveOwnProperty('CommentHistories'); expect(res.body.message).to.equal('comments successfully fetched'); done(); }); @@ -107,6 +108,100 @@ describe('API endpoint: /api/articles/:slug/comments (Routes)', () => { }); }); + it('Should update a comment2', (done) => { + const comment = { body: faker.lorem.sentence() }; + chai + .request(app) + .put(`/api/v1/articles/${newSlug}/comments/${commentId}`) + .send(comment) + .set({ Authorization: `Bearer ${authToken}` }) + .end((err, res) => { + expect(res).to.have.status(STATUS.CREATED); + expect(res.body).to.be.an('object'); + expect(res.body).to.haveOwnProperty('code').to.equal(STATUS.CREATED); + expect(res.body.message).to.equal('comment successfully updated'); + done(); + }); + }); + + it('Should update a comment3', (done) => { + const comment = { body: faker.lorem.sentence() }; + chai + .request(app) + .put(`/api/v1/articles/${newSlug}/comments/${commentId}`) + .send(comment) + .set({ Authorization: `Bearer ${authToken}` }) + .end((err, res) => { + expect(res).to.have.status(STATUS.CREATED); + expect(res.body).to.be.an('object'); + expect(res.body).to.haveOwnProperty('code').to.equal(STATUS.CREATED); + expect(res.body.message).to.equal('comment successfully updated'); + done(); + }); + }); + + it('Should update a comment4', (done) => { + const comment = { body: faker.lorem.sentence() }; + chai + .request(app) + .put(`/api/v1/articles/${newSlug}/comments/${commentId}`) + .send(comment) + .set({ Authorization: `Bearer ${authToken}` }) + .end((err, res) => { + expect(res).to.have.status(STATUS.CREATED); + expect(res.body).to.be.an('object'); + expect(res.body).to.haveOwnProperty('code').to.equal(STATUS.CREATED); + expect(res.body.message).to.equal('comment successfully updated'); + done(); + }); + }); + + it('Should update a comment5', (done) => { + const comment = { body: faker.lorem.sentence() }; + chai + .request(app) + .put(`/api/v1/articles/${newSlug}/comments/${commentId}`) + .send(comment) + .set({ Authorization: `Bearer ${authToken}` }) + .end((err, res) => { + expect(res).to.have.status(STATUS.CREATED); + expect(res.body).to.be.an('object'); + expect(res.body).to.haveOwnProperty('code').to.equal(STATUS.CREATED); + expect(res.body.message).to.equal('comment successfully updated'); + done(); + }); + }); + + it('Should not edit a comment after 5 edits', (done) => { + const comment = { body: faker.lorem.sentence() }; + chai + .request(app) + .put(`/api/v1/articles/${newSlug}/comments/${commentId}`) + .send(comment) + .set({ Authorization: `Bearer ${authToken}` }) + .end((err, res) => { + expect(res).to.have.status(STATUS.BAD_REQUEST); + expect(res.body).to.be.an('object'); + expect(res.body).to.haveOwnProperty('code').to.equal(STATUS.BAD_REQUEST); + done(); + }); + }); + + it('Should get all comments with their history', (done) => { + chai + .request(app) + .get(`/api/v1/articles/${newSlug}/comments`) + .set({ Authorization: `Bearer ${authToken}` }) + .end((err, res) => { + expect(res).to.have.status(STATUS.OK); + expect(res.body).to.be.an('object'); + expect(res.body).to.haveOwnProperty('code').to.equal(STATUS.OK); + expect(res.body.data.articles[0]).to.haveOwnProperty('CommentHistories'); + expect(res.body.message).to.equal('comments successfully fetched'); + done(); + }); + }); + it('Should delete a comment', (done) => { chai .request(app) From 62424035cb9f95fc40b7fb472322a3cd94465ebd Mon Sep 17 00:00:00 2001 From: Alexandra Collins Date: Thu, 21 Mar 2019 16:43:26 +0100 Subject: [PATCH 6/7] include ratings to article controller --- controllers/articlesController.js | 6 +++++- models/Article.js | 1 + routes/ratings.js | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/controllers/articlesController.js b/controllers/articlesController.js index 097379e..243a131 100644 --- a/controllers/articlesController.js +++ b/controllers/articlesController.js @@ -5,7 +5,7 @@ import articleHelpers from '../helpers/articleHelpers'; import { STATUS } from '../helpers/constants'; import Logger from '../helpers/logger'; -const { Article, Bookmark } = models; +const { Article, Bookmark, ratings } = models; /** * Wrapper class for sending article objects as response. @@ -121,6 +121,10 @@ export default class ArticlesController { where: { ...categoryQuery, } + }, + { + model: ratings, + attributes: ['stars', 'userId'] } ], }); diff --git a/models/Article.js b/models/Article.js index 03962b9..77409ac 100644 --- a/models/Article.js +++ b/models/Article.js @@ -60,6 +60,7 @@ const Article = (sequelize, DataTypes) => { otherKey: 'tagName', as: 'tagList' }); + ArticleSchema.hasMany(models.ratings, { foreignKey: 'articleId' }); }; return ArticleSchema; }; diff --git a/routes/ratings.js b/routes/ratings.js index 987936f..6a70600 100644 --- a/routes/ratings.js +++ b/routes/ratings.js @@ -40,7 +40,7 @@ const router = express.Router(); * produces: * - application/json * responses: - * 200: + * 201: * description: Success * schema: * $ref: '#/definitions/Article' From ad54cd8384d73953ba2e5d81f73fafb711690c7c Mon Sep 17 00:00:00 2001 From: David whyte Date: Fri, 22 Mar 2019 09:42:59 +0100 Subject: [PATCH 7/7] Update comments.test.js --- test/integrations/routes/comments.test.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/integrations/routes/comments.test.js b/test/integrations/routes/comments.test.js index 9729e00..8f3f53b 100644 --- a/test/integrations/routes/comments.test.js +++ b/test/integrations/routes/comments.test.js @@ -108,7 +108,7 @@ describe('API endpoint: /api/articles/:slug/comments (Routes)', () => { }); }); - it('Should update a comment2', (done) => { + it('Should update a comment the second time', (done) => { const comment = { body: faker.lorem.sentence() }; chai .request(app) @@ -124,7 +124,7 @@ describe('API endpoint: /api/articles/:slug/comments (Routes)', () => { }); }); - it('Should update a comment3', (done) => { + it('Should update a comment the third time', (done) => { const comment = { body: faker.lorem.sentence() }; chai .request(app) @@ -140,7 +140,7 @@ describe('API endpoint: /api/articles/:slug/comments (Routes)', () => { }); }); - it('Should update a comment4', (done) => { + it('Should update a comment the fourth time', (done) => { const comment = { body: faker.lorem.sentence() }; chai .request(app) @@ -156,7 +156,7 @@ describe('API endpoint: /api/articles/:slug/comments (Routes)', () => { }); }); - it('Should update a comment5', (done) => { + it('Should update a comment the fifth time', (done) => { const comment = { body: faker.lorem.sentence() }; chai .request(app)