Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

#167313420 A user should be able to see their reading statistics #49

Merged
merged 9 commits into from
Aug 30, 2019
6 changes: 6 additions & 0 deletions src/controllers/articles.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import articleService from '../services/article.service';
import Helper from '../helpers/helper';
import NotificationServices from '../services/notification.service';
import cloudinaryHelper from '../helpers/cloudinaryHelper';
import statsService from '../services/db.service';

const { notifyViaEmailAndPush } = NotificationServices;

Expand Down Expand Up @@ -119,6 +120,11 @@ class Articles {
const article = _.pick(findArticle, ['slug', 'title', 'description', 'body', 'taglist', 'favorited', 'favoritedcount', 'flagged', 'images', 'views']);
const readTime = Helper.calculateReadTime(article.body);
article.readtime = readTime;
if (req.auth) {
const readerId = req.auth.id;
const item = 'article';
await statsService.createStat({ slug: req.params.slug, item, readerId }, 'Stats');
}
return res.status(200).json({
status: 200,
message: 'Article successfully retrieved',
Expand Down
4 changes: 4 additions & 0 deletions src/controllers/comments.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import UserService from '../services/user.service';
import models from '../models';
import NotificationServices from '../services/notification.service';
import Util from '../helpers/util';
import StatsService from '../services/db.service';

const util = new Util();

Expand Down Expand Up @@ -107,6 +108,9 @@ class Comments {
await util.setError(200, 'No comments found');
return util.send(res);
}
const readerId = req.auth.id;
const item = 'comment';
await StatsService.createStat({ readerId, item, slug: 'all comments' }, 'Stats');
await util.setSuccess(200, 'All comments successfully retrieved', comments);
return util.send(res);
}
Expand Down
15 changes: 15 additions & 0 deletions src/controllers/stats.controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/* eslint-disable require-jsdoc */
import StatsService from '../services/db.service';
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parsing error: 'import' and 'export' may appear only with 'sourceType: module'

import { success } from '../helpers/responses';

const { getStat } = StatsService;

class statsController {
static async getStats(req, res) {
const readerId = req.auth.id;
const stats = await getStat({ readerId }, 'Stats');
return success('your reading stats', stats).send(res);
}
}

export default statsController;
18 changes: 18 additions & 0 deletions src/helpers/responses.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import Util from './util';
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parsing error: 'import' and 'export' may appear only with 'sourceType: module'


const util = new Util();

export const notFound = (msg) => {
util.setError(404, `${msg} not found`);
return util;
};

export const error = (msg, code = 400) => {
util.setError(code, msg);
return util;
};

export const success = (msg, data = null) => {
util.setSuccess(200, msg, data);
return util;
};
37 changes: 37 additions & 0 deletions src/middlewares/stats.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/* eslint-disable require-jsdoc */

import jwt from 'jsonwebtoken';
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parsing error: 'import' and 'export' may appear only with 'sourceType: module'


import StatsService from '../services/db.service';
import { notFound, error } from '../helpers/responses';

const { getStat } = StatsService;

class statsWare {
static async checkStats(req, res, next) {
Anguandia marked this conversation as resolved.
Show resolved Hide resolved
if (!req.auth) {
return error('you are not logged in').send(res);
}
const readerId = req.auth.id;
const stats = await getStat({ readerId }, 'Stats');
if (!stats.length) {
return notFound('reading stats').send(res);
}
next();
}

static async saveStat(req, res, next) {
try {
let token = req.headers['x-access-token'] || req.headers.authorization;
token = token.slice(7, token.length);
jwt.verify(token, process.env.SECRET_KEY, (err, decode) => {
req.auth = decode;
next();
});
} catch (err) {
next();
}
}
}

export default statsWare;
Anguandia marked this conversation as resolved.
Show resolved Hide resolved
33 changes: 33 additions & 0 deletions src/migrations/20190828152317-create-stats.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
'use strict';
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.createTable('Stats', {
id: {
allowNull: false,
autoIncrement: true,
primaryKey: true,
type: Sequelize.INTEGER
},
slug: {
type: Sequelize.STRING
},
item: {
type: Sequelize.STRING
},
readerId: {
type: Sequelize.INTEGER
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens if the user is deleted?

},
createdAt: {
allowNull: false,
type: Sequelize.DATE
},
updatedAt: {
allowNull: false,
type: Sequelize.DATE
}
});
},
down: (queryInterface, Sequelize) => {
return queryInterface.dropTable('Stats');
}
};
12 changes: 12 additions & 0 deletions src/models/stats.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
'use strict';
module.exports = (sequelize, DataTypes) => {
const Stats = sequelize.define('Stats', {
slug: DataTypes.STRING,
item: DataTypes.STRING,
readerId: DataTypes.INTEGER
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In my opinion I think we can have a relationship between readerId and user table. What do you think @Anguandia

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, readerId actually translates to the id in the user table

}, {});
Stats.associate = function (models) {
// associations can be defined here
}
return Stats;
};
7 changes: 5 additions & 2 deletions src/models/user.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,7 @@ module.exports = (sequelize, DataTypes) => {
}, {});

user.associate = ({
Follow, Article, Highlight

Follow, Article, Highlight, Stats
}) => {
user.hasMany(Follow, {
foreignKey: 'followerId',
Expand All @@ -34,6 +33,10 @@ module.exports = (sequelize, DataTypes) => {
targetKey: 'id',
onUpdate: 'CASCADE',
onDelete: 'CASCADE'
});
user.hasMany(Stats, {
as: 'reader',
foreignKey: 'readerId'
})
user.belongsToMany(Article, {
through: 'BookMarks',
Expand Down
3 changes: 2 additions & 1 deletion src/routes/api/article/article.routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import tagController from '../../../controllers/tag';
import TagWare from '../../../middlewares/tag.middleware';
import highlight from '../../../controllers/highlight.controller';
import share from '../../../middlewares/shareHighlight.middleware';
import StatsWare from '../../../middlewares/stats';

const router = express.Router();
const {
Expand All @@ -24,7 +25,7 @@ const {
router.post('/:articleId/favorite', [auth, confirmEmailAuth, validateId], FavoritesController.createOrRemoveFavorite);
router.post('/', [auth, confirmEmailAuth], imageUpload.array('images', 10), validate(schema.articleSchema), articleController.createArticles);
router.get('/', checkQuery, articleController.getAllArticles);
router.get('/:slug', articleController.getOneArticle);
router.get('/:slug', StatsWare.saveStat, articleController.getOneArticle);
Anguandia marked this conversation as resolved.
Show resolved Hide resolved
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can an Unauthenticated user get his reading statistics?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No Patric, it is the user stats, and the only way of identifying the user is through his auth token/userid or email extracted from here

router.delete('/:slug', [auth, confirmEmailAuth], articleController.deleteArticle);
router.patch('/:slug', [auth, confirmEmailAuth], imageUpload.array('images', 10), articleController.UpdateArticle);

Expand Down
8 changes: 8 additions & 0 deletions src/routes/api/user/user.route.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import followController from '../../../controllers/follow.controller';
import resetPasswordValidation from '../../../middlewares/validators/resetpassword.validation';
import BookMarkController from '../../../controllers/bookmarks.controller';
import BookMarkWare from '../../../middlewares/bookmarks';
import statsController from '../../../controllers/stats.controller';
import statsWare from '../../../middlewares/stats';

const {
checkBookmark, checkUserBookMarks, checkDuplicate, createCopy,
Expand All @@ -23,6 +25,9 @@ const {
unCollect
} = BookMarkController;

const { getStats } = statsController;
const { saveStat, checkStats } = statsWare;

// bookmarks routes
router.post('/bookmarks/copy', createCopy, copyBookmark);
router.patch('/bookmarks/update', createCopy, editBookMark);
Expand All @@ -40,6 +45,9 @@ router.delete('/bookmarks/:name', [validateToken, confirmEmaiAuth], checkBookmar
router.delete('/bookmarks', [validateToken, confirmEmaiAuth], checkUserBookMarks, deleteUserBookMarks);


// stats route
router.get('/stats', saveStat, checkStats, getStats);

router.get('/verify', verifyEmail);
router.get('/allusers', [validateToken, admin, confirmEmaiAuth], UserController.getAllUsers);
router.post('/signup', validateUser, UserController.signup);
Expand Down
18 changes: 18 additions & 0 deletions src/services/db.service.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/* eslint-disable require-jsdoc */
import models from '../models';
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parsing error: 'import' and 'export' may appear only with 'sourceType: module'


const { Stats, Article } = models;
const Models = { Stats, Article };
const conditon = where => ({ where });

class StatsService {
static async createStat(where, model) {
return Models[model].create(where);
}

static async getStat(where, model) {
return Models[model].findAll(conditon(where));
}
}

export default StatsService;
67 changes: 67 additions & 0 deletions test/auth-stats.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { chai, expect, server } from './test-setup';
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parsing error: 'import' and 'export' may appear only with 'sourceType: module'


let token;
describe('Test user stats', () => {
before((done) => {
chai.request(server)
.post('/api/v1/users/login')
.send({ email: 'admin@gmail.com', password: 'ASqw12345' })
.end((error, res) => {
token = `Bearer ${res.body.token}`;
done();
});
});
describe('test getting stats', () => {
it('should return correct response if none', (done) => {
chai.request(server)
.get('/api/v1/users/stats')
.set('Authorization', token)
.end((err, res) => {
expect(res.status).to.be.equal(404);
expect(res.body.message).to.include('not found');
done();
});
});
it('should return correct response if user not logged in', (done) => {
chai.request(server)
.get('/api/v1/users/stats')
.end((err, res) => {
expect(res.status).to.be.equal(400);
expect(res.body.message).to.include('not logged in');
done();
});
});
});
describe('test stats gathering', () => {
it('should not populate stats if user unauthenticated', (done) => {
chai.request(server)
.get('/api/v1/articles/fakeslug2')
.end(() => {
chai.request(server)
.get('/api/v1/users/stats')
.set('Authorization', token)
.end((err, res) => {
expect(res.status).to.be.equal(404);
expect(res.body.message).to.include('not found');
done();
});
});
});
it('should log every read', (done) => {
chai.request(server)
.get('/api/v1/articles/fakeslug2')
.set('Authorization', token)
.end(() => {
chai.request(server)
.get('/api/v1/users/stats')
.set('Authorization', token)
.end((err, res) => {
expect(res.status).to.be.equal(200);
expect(res.body).to.have.deep.property('message', 'your reading stats');
expect(res.body.data).to.be.a('Array');
done();
});
});
});
});
});