Skip to content

Commit

Permalink
feat(users): users see their reading stats
Browse files Browse the repository at this point in the history
- record reading
- get user reading stats

[Finishes #166790015]
  • Loading branch information
Herve Nkurikiyimfura authored and Herve Nkurikiyimfura committed Jul 30, 2019
1 parent f4a5078 commit 26e0b0f
Show file tree
Hide file tree
Showing 12 changed files with 255 additions and 22 deletions.
12 changes: 1 addition & 11 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ DATABASE_TEST=postgresql://dbusername:dbpassword@localhost:5432/dbnamefortest

SENDER_EMAIL=
SENDER_PASS=
SENDGRID_API_KEY=

SECRET=
SESSION_SECRET=
Expand All @@ -16,17 +17,6 @@ APP_URL_BACKEND=
SOCIAL_LOGIN_PASSWORD=
TWITTER_EMAIL=

FACEBOOK_CLIENT_ID =
FACEBOOK_CLIENT_SECRET =
GOOGLE_CLIENT_ID =
GOOGLE_CLIENT_SECRET =
TWITTER_CLIENT_ID =
TWITTER_CLIENT_SECRET =
APP_URL_BACKEND =
SOCIAL_LOGIN_PASSWORD =
TWITTER_EMAIL =


CLOUD_NAME=
CLOUD_API_KEY=
CLOUD_API_SECRET=
Expand Down
71 changes: 71 additions & 0 deletions controllers/readingStat.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import models from '../models/index';

const { articleStats, users, articles } = models;

/**
* Reading stats controller
* @exports
* @class
*/
class ReadingStats {
/**
* Record user reading stats
* @param {object} req - user's request
* @param {object} res - response
* @return {object} response
*/
static async recordArticleReading(req, res) {
try {
const authUser = await users.findOne({ where: { email: req.decoded.email } });
const { articleSlug } = req.params;
const [result, created] = await articleStats.findOrCreate({
where: { userId: authUser.id, articleSlug }, defaults: { numberOfReading: 1 }
});
if (!created) {
await articleStats.update(
{ numberOfReading: result.numberOfReading + 1 },
{ where: { id: result.id }, returning: true }
);
}
const getArticle = await articles.findOne({ where: { slug: articleSlug } });
res.status(201).send({
message: 'You are reading the article',
article: getArticle.title
});
} catch (error) {
return res.status(500).json({ error: 'Failed to record reading' });
}
}

/**
* Get user reading stats
* @param {object} req - user's request
* @param {object} res - response
* @return {object} response
*/
static async getArticleReadingStats(req, res) {
try {
const authUser = await users.findOne({ where: { email: req.decoded.email } });
const totalUserReadingStats = await articleStats.count({ where: { userId: authUser.id } });
const articlesRead = await articleStats.findAll({
where: { userId: authUser.id },
attributes: [
['numberOfReading', 'totalArticleRead'],
['updatedAt', 'lastTime']],
include: [
{
model: articles,
attributes: ['title', 'slug', 'body'],
}
]
});
if (totalUserReadingStats) {
return res.status(200).send({ totalArticlesRead: totalUserReadingStats, articlesRead });
}
} catch (error) {
return res.status(500).json({ error: 'Failed to get user reading stats' });
}
}
}

export default ReadingStats;
2 changes: 1 addition & 1 deletion middlewares/validations/articleValidations.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ class articleValidations {
const { title, body } = req.body;
const errors = [];
if (title === null || title === undefined
|| body === null || body === undefined) {
|| body === null || body === undefined) {
errors.push('title or body shouldn\'t be empty');
} else if (title.length <= 5 || body.length <= 20) {
errors.push('title length should be not less than 5 and body length shouldn\'t be less than 20');
Expand Down
45 changes: 45 additions & 0 deletions migrations/20190724205311-createTableArticleStats.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
const articleStatsMigration = {
up: (queryInterface, Sequelize) => queryInterface.createTable('articleStats', {
id: {
type: Sequelize.UUID,
defaultValue: Sequelize.UUIDV4,
allowNull: false,
primaryKey: true
},
userId: {
type: Sequelize.UUID,
allowNull: false,
references: {
model: 'users',
key: 'id'
},
onUpdate: 'CASCADE',
onDelete: 'CASCADE'
},
articleSlug: {
type: Sequelize.STRING,
allowNull: false,
references: {
model: 'articles',
key: 'slug'
},
onUpdate: 'CASCADE',
onDelete: 'CASCADE'
},
numberOfReading: {
allowNull: false,
type: Sequelize.INTEGER
},
createdAt: {
allowNull: false,
type: Sequelize.DATE
},
updatedAt: {
allowNull: false,
type: Sequelize.DATE
}
}),
down: queryInterface => queryInterface.dropTable('articleStats')
};

export default articleStatsMigration;
38 changes: 38 additions & 0 deletions models/articleStats.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
const articleStatsModel = (sequelize, DataTypes) => {
const ArticleStats = sequelize.define('articleStats', {
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
allowNull: false,
primaryKey: true,
},
userId: {
type: DataTypes.UUID,
allowNull: false,
onUpdate: 'CASCADE',
onDelete: 'CASCADE',
references: {
model: 'users',
key: 'id'
}
},
articleSlug: {
type: DataTypes.STRING,
allowNull: false,
onUpdate: 'CASCADE',
onDelete: 'CASCADE',
references: {
model: 'articles',
key: 'slug'
},
},
numberOfReading: { type: DataTypes.INTEGER }
}, {});
ArticleStats.associate = (models) => {
ArticleStats.belongsTo(models.users, { foreignKey: 'userId', onDelete: 'CASCADE' });
ArticleStats.belongsTo(models.articles, { foreignKey: 'articleSlug', onDelete: 'CASCADE', targetKey: 'slug' });
};
return ArticleStats;
};

export default articleStatsModel;
1 change: 1 addition & 0 deletions models/articles.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export default (sequelize, DataTypes) => {
articles.belongsTo(models.users, { as: 'author', foreignKey: 'authorId' });
articles.hasMany(models.ratings, { foreignKey: 'articleSlug', sourceKey: 'slug' });
articles.hasMany(models.likes, { foreignKey: 'articleSlug', sourceKey: 'slug' });
articles.hasMany(models.articleStats, { foreignKey: 'articleSlug', sourceKey: 'slug' });
};
return articles;
};
4 changes: 1 addition & 3 deletions models/users.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ export default (sequelize, DataTypes) => {
allowNull: false,
unique: true
},

email: {
type: DataTypes.STRING,
allowNull: false,
Expand All @@ -32,7 +31,6 @@ export default (sequelize, DataTypes) => {
type: DataTypes.INTEGER,
defaultValue: '0'
},

isVerified: {
allowNull: false,
type: DataTypes.BOOLEAN,
Expand All @@ -43,9 +41,9 @@ export default (sequelize, DataTypes) => {
users.hasMany(models.articles, { as: 'author', foreignKey: 'authorId' });
users.hasMany(models.ratings, { foreignKey: 'userId' });
users.hasMany(models.likes, { foreignKey: 'userId' });
users.hasMany(models.articles, { foreignKey: 'authorId', allowNull: false });
users.hasMany(models.comments, { foreignKey: 'authorId', allowNull: false });
users.hasMany(models.Follow, { as: 'User', foreignKey: 'followed' });
users.hasMany(models.articleStats, { foreignKey: 'userId' });
};
return users;
};
2 changes: 2 additions & 0 deletions routes/api/articles.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { checkArticleOwner } from '../../middlewares/checkResourceOwner';
import validate from '../../middlewares/validations/articleValidations';
import uploadImage from '../../middlewares/imageUpload';
import { checkToken } from '../../middlewares';
import articleStats from '../../controllers/readingStat';

const router = express.Router();

Expand Down Expand Up @@ -229,5 +230,6 @@ router.post('/articles/:slug/comments', checkToken, createComment);
* description: Article is not found.
*/
router.post('/articles/:slug/share/:channel', article.shareArticle);
router.post('/articles/:articleSlug/record-reading', checkToken, articleStats.recordArticleReading);

export default router;
3 changes: 3 additions & 0 deletions routes/api/users.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import Follower from '../../controllers/followers';
import { checkToken } from '../../middlewares';
import socialAuth from '../../controllers/socialAuth';
import resetPasswordController from '../../controllers/resetPassword';
import articleStats from '../../controllers/readingStat';

// const follower = new Follower();

Expand Down Expand Up @@ -282,4 +283,6 @@ router.post('/:username/follow', checkToken, Follower.follow);
router.get('/followers', checkToken, Follower.followers);
router.get('/following', checkToken, Follower.following);

router.get('/users/reading-stats', checkToken, articleStats.getArticleReadingStats);

export default router;
84 changes: 79 additions & 5 deletions tests/article.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ describe('CRUD articles routes', () => {
});

// @fail to create when the title is empty
it('should not create an article if either the title or the body is null', (done) => {
it('should not create an article if the title is null', (done) => {
chai.request(app)
.post('/api/articles')
.set('Authorization', `Bearer ${authToken}`)
Expand All @@ -63,6 +63,24 @@ describe('CRUD articles routes', () => {
});
});

// @fail to create when the title is empty
it('should not create an article if the body is null', (done) => {
chai.request(app)
.post('/api/articles')
.set('Authorization', `Bearer ${authToken}`)
.set('Content-Type', 'multipart/form-data')
.field('article', dummy.article1.title)
.field('body', '')
.field('tagList', dummy.article1.tagList)
.attach('image', '')
.end((err, res) => {
res.should.have.status(400);
res.body.should.be.an('Object');
res.body.should.have.property('errors');
done();
});
});

// @return validation error for not meeting the min characters for body or title requests
it('should not create an article if either the title or the body do not meet minimum charachers required', (done) => {
chai.request(app)
Expand Down Expand Up @@ -190,7 +208,7 @@ describe('CRUD articles routes', () => {
});

// @fail update if the title updated is empty
it('should not create an article if the title or body updated is empty', (done) => {
it('should not create an article if the title updated is empty', (done) => {
chai.request(app)
.put(`/api/articles/${articleSlug}`)
.set('Authorization', `Bearer ${authToken}`)
Expand All @@ -205,6 +223,22 @@ describe('CRUD articles routes', () => {
});
});

// @fail update if the body updated is empty
it('should not create an article if the body updated is empty', (done) => {
chai.request(app)
.put(`/api/articles/${articleSlug}`)
.set('Authorization', `Bearer ${authToken}`)
.set('Content-Type', 'multipart/form-data')
.field('body', dummy.article2.body)
.attach('image', '')
.end((err, res) => {
res.should.have.status(400);
res.body.should.be.an('Object');
res.body.should.have.property('errors');
done();
});
});

// @fail update When the body is less than 20 characters
it('should not create an article if the body doesn\'t have up to 20 characters', (done) => {
chai.request(app)
Expand Down Expand Up @@ -302,7 +336,7 @@ describe('Share articles across different channels', () => {
done();
});
});
it('should share to facebook', (done) => {
it('should share to facebook', (done) => {
chai.request(app)
.post(`/api/articles/${slugArticle}/share/facebook`)
.set('Authorization', `Bearer ${authToken}`)
Expand All @@ -312,7 +346,7 @@ describe('Share articles across different channels', () => {
done();
});
});
it('should share to twitter', (done) => {
it('should share to twitter', (done) => {
chai.request(app)
.post(`/api/articles/${slugArticle}/share/twitter`)
.set('Authorization', `Bearer ${authToken}`)
Expand All @@ -322,7 +356,7 @@ describe('Share articles across different channels', () => {
done();
});
});
it('should share to mail', (done) => {
it('should share to mail', (done) => {
chai.request(app)
.post(`/api/articles/${slugArticle}/share/mail`)
.set('Authorization', `Bearer ${authToken}`)
Expand All @@ -334,6 +368,46 @@ describe('Share articles across different channels', () => {
});
});

describe('users can see reading stats', () => {
it('Should record user reading', (done) => {
chai.request(app)
.post(`/api/articles/${slugArticle}/record-reading`)
.set('Authorization', `Bearer ${authToken}`)
.send()
.end((err, res) => {
if (err) done(err);
res.should.have.status(201);
res.body.should.have.property('message');
res.body.should.have.property('article');
done();
});
});
it('Should update the date a user read an article ', (done) => {
chai.request(app)
.post(`/api/articles/${slugArticle}/record-reading`)
.set('Authorization', `Bearer ${authToken}`)
.send()
.end((err, res) => {
if (err) done(err);
res.should.have.status(201);
res.body.should.have.property('message');
done();
});
});
it('Should return user reading stats', (done) => {
chai.request(app)
.get('/api/users/reading-stats')
.set('Authorization', `Bearer ${authToken}`)
.end((err, res) => {
if (err) done(err);
res.should.have.status(200);
res.body.should.have.property('totalArticlesRead');
res.body.should.have.property('articlesRead');
done();
});
});
});


export default {
authToken
Expand Down
Loading

0 comments on commit 26e0b0f

Please sign in to comment.