diff --git a/dist/api/controllers/article.js b/dist/api/controllers/article.js new file mode 100644 index 0000000..933c266 --- /dev/null +++ b/dist/api/controllers/article.js @@ -0,0 +1,601 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.getArticle = exports.getAllArticles = exports.rateArticle = exports.getHighlights = exports.createHighlight = exports.dislikeArticle = exports.likeArticle = exports.createArticle = exports.getReadTime = void 0; + +var _sequelize = _interopRequireDefault(require("sequelize")); + +var _winston = require("winston"); + +var _models = require("../../models"); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +const { + Op +} = _sequelize.default; +const logger = (0, _winston.createLogger)({ + level: 'debug', + format: _winston.format.simple(), + transports: [new _winston.transports.Console()] +}); + +const formatTags = newArticleTags => { + return newArticleTags.split(',').map(word => word.replace(/(\s+)/g, '').trim()); +}; +/** + * + * @function insertTag + * @param {Array} tagArray - tags received + * @returns {Object} JSON object (JSend format) + */ + + +const insertTag = async tagArray => { + let insertedTags = []; + + try { + insertedTags = tagArray.map(async tagText => { + const newTag = await _models.Tag.findOrCreate({ + where: { + tagText: { + [Op.eq]: tagText + } + }, + defaults: { + tagText + } + }); + return newTag; + }); + return Promise.all(insertedTags); + } catch (error) { + logger.debug('Tag already present in database'); + } +}; +/** +* @export +* @function getReadTime +* @param {String} articleBody - article.body +* @returns {String} - Read Time eg '2 minutes' +*/ + + +const getReadTime = articleBody => { + // Read time is based on the average reading speed of an adult (roughly 275 WPM). + // We take the total word count of a post and translate it into minutes. + // Then, we add 12 seconds for each inline image. + // TIME = DISTANCE / SPEED + const words = articleBody.split(' '); + const wordCount = words.length; + const readTimeInMinutes = wordCount / 275; + let readTimeInSeconds = readTimeInMinutes * 60; + const imagesFound = articleBody.split(' { + readTimeInSeconds += 12; // add 12 seconds for each inline image + }); + let readTime = Math.ceil(readTimeInSeconds / 60); // convert back to minutes + + readTime += readTime > 1 ? ' minutes' : ' minute'; + return readTime; +}; +/** +* @export +* @function createArticle +* @param {Object} req - request received +* @param {Object} res - response object +* @returns {Object} JSON object (JSend format) +*/ + + +exports.getReadTime = getReadTime; + +const createArticle = async (req, res) => { + try { + const { + body: { + title, + description, + body, + references, + categoryId + }, + user: { + id: userId + } + } = req; // create the slug from the title by replacing spaces with hyphen + // eg. "Introduction to writing" becomes "introduction-to-writing" + + const slug = title.replace(/\s+/g, '-').toLowerCase(); + let newArticle = await _models.Article.create({ + slug, + title, + description, + body, + references, + categoryId, + authorId: userId + }); + + if (req.body.tags) { + const newArticleTags = req.body.tags; + const tagArray = formatTags(newArticleTags); + const newTags = await insertTag(tagArray); + newTags.forEach(async tag => { + await newArticle.addTags(tag[0].id); + }); + } + + newArticle = newArticle.toJSON(); + newArticle.readTime = getReadTime(newArticle.body); + return res.status(201).send({ + status: 'Success', + data: newArticle + }); + } catch (error) { + console.log(error); + return res.status(502).send({ + status: 'Error', + message: 'OOPS! an error occurred while trying to create your article, you do not seem to be logged in or signed up, log in and try again!' + }); + } +}; +/** + * @export + * @function likeArticle + * @param {Object} req - request received + * @param {Object} res - response object + * @returns {Object} JSON object (JSend format) + */ + + +exports.createArticle = createArticle; + +const likeArticle = async (req, res) => { + try { + const { + params: { + id: articleId + }, + user: { + id: userId + } + } = req; + const foundImpression = await _models.LikeDislike.findOne({ + where: { + articleId: { + [Op.eq]: articleId + }, + userId: { + [Op.eq]: userId + } + } + }); + + if (!foundImpression) { + const newImpression = await _models.LikeDislike.create({ + articleId, + userId, + like: true + }); + return res.status(200).send({ + status: 'success', + data: { + message: 'You liked this Article!', + impression: newImpression + } + }); + } + + if (foundImpression && foundImpression.like) { + const updatedImpression = await foundImpression.update({ + like: false + }, { + returning: true, + plain: true + }); + return res.status(200).send({ + status: 'success', + data: { + message: 'You unliked this Article!', + impression: updatedImpression + } + }); + } + + if (foundImpression && !foundImpression.like) { + const updatedImpression = await foundImpression.update({ + like: true, + dislike: false + }, { + returning: true, + plain: true + }); + return res.status(200).send({ + status: 'success', + data: { + message: 'You liked this Article!', + impression: updatedImpression + } + }); + } + } catch (e) { + return res.status(500).send({ + status: 'error', + message: 'Internal server error occured.' + }); + } +}; +/** + * @export + * @function dislikeArticle + * @param {Object} req - request received + * @param {Object} res - response object + * @returns {Object} JSON object (JSend format) + */ + + +exports.likeArticle = likeArticle; + +const dislikeArticle = async (req, res) => { + try { + const { + params: { + id: articleId + }, + user: { + id: userId + } + } = req; + const foundImpression = await _models.LikeDislike.findOne({ + where: { + articleId: { + [Op.eq]: articleId + }, + userId: { + [Op.eq]: userId + } + } + }); + + if (!foundImpression) { + const newImpression = await _models.LikeDislike.create({ + articleId, + userId, + dislike: true + }); + return res.status(201).send({ + status: 'success', + data: { + message: 'You disliked this Article!', + impression: newImpression + } + }); + } + + if (foundImpression && foundImpression.dislike) { + const updatedImpression = await foundImpression.update({ + dislike: false + }, { + returning: true, + plain: true + }); + return res.status(200).send({ + status: 'success', + data: { + message: 'You un-disliked this Article!', + impression: updatedImpression + } + }); + } + + if (foundImpression && !foundImpression.dislike) { + const updatedImpression = await foundImpression.update({ + like: false, + dislike: true + }, { + returning: true, + plain: true + }); + return res.status(200).send({ + status: 'success', + data: { + message: 'You disliked this Article!', + impression: updatedImpression + } + }); + } + } catch (e) { + return res.status(500).send({ + status: 'error', + message: 'Internal server error occured.' + }); + } +}; +/** + * @export + * @function createHighlight + * @param {Object} req - request received + * @param {Object} res - response object + * @returns {Object} JSON object (JSend format) + */ + + +exports.dislikeArticle = dislikeArticle; + +const createHighlight = async (req, res) => { + try { + const { + params: { + id: articleId + }, + user: { + id: readerId + }, + body: { + highlight, + comment + } + } = req; + const newHighlight = await _models.HighlightComment.create({ + articleId, + readerId, + highlight, + comment + }); + return res.status(201).send({ + status: 'success', + data: { + message: 'You highlighted successfully!', + highlight: newHighlight + } + }); + } catch (e) { + return res.status(500).send({ + status: 'error', + message: 'Internal server error occured.' + }); + } +}; +/** + * @export + * @function getHighlights + * @param {Object} req - request received + * @param {Object} res - response object + * @returns {Object} JSON object (JSend format) + */ + + +exports.createHighlight = createHighlight; + +const getHighlights = async (req, res) => { + try { + const { + params: { + id: articleId + }, + user: { + id: readerId + } + } = req; + const foundHighlights = await _models.HighlightComment.findAll({ + attributes: ['articleId', 'readerId', 'highlight', 'comment'], + where: { + articleId: { + [Op.eq]: articleId + }, + readerId: { + [Op.eq]: readerId + } + } + }); + + if (!foundHighlights.length) { + return res.status(404).send({ + status: 'fail', + data: { + message: 'No highlight was found' + } + }); + } + + return res.status(200).send({ + status: 'success', + data: { + message: 'You highlighted this Article!', + highlights: foundHighlights + } + }); + } catch (e) { + return res.status(500).send({ + status: 'error', + message: 'Internal server error occured.' + }); + } +}; +/** + * @export + * @function rateArticle + * @param {Object} req - request received + * @param {Object} res - response object + * @returns {Object} JSON object (JSend format) + */ + + +exports.getHighlights = getHighlights; + +const rateArticle = async (req, res) => { + try { + const { + params: { + articleId + }, + user: { + id: userId + }, + body: { + rating + } + } = req; + const foundArticle = await _models.Article.findOne({ + where: { + id: { + [Op.eq]: articleId + } + } + }); + + if (!foundArticle) { + return res.status(404).send({ + status: 'fail', + message: 'Article not found' + }); + } + + const articleAuthor = await _models.Article.findOne({ + where: { + id: { + [Op.eq]: articleId + }, + authorId: { + [Op.eq]: userId + } + } + }); + + if (!articleAuthor) { + const userRating = await _models.Rating.findOne({ + where: { + articleId: { + [Op.eq]: articleId + }, + userId: { + [Op.eq]: userId + } + } + }); + + if (!userRating) { + await _models.Rating.create({ + rating, + articleId, + userId + }); + const allRatings = await _models.Rating.findAndCountAll({ + attributes: [[foundArticle.sequelize.fn('AVG', foundArticle.sequelize.col('rating')), 'averageRating']], + where: { + articleId + } + }); + const { + count, + rows + } = allRatings; + const ratingData = rows[0].toJSON(); + const { + averageRating + } = ratingData; + const AvgRating = Math.ceil(averageRating); + const article = foundArticle.toJSON(); + article.ratingsCount = count; + article.averageRating = AvgRating; + return res.status(201).send({ + status: 'success', + message: 'Yaay! You just rated this article', + article + }); + } + + return res.status(401).send({ + status: 'fail', + message: 'You already rated this article' + }); + } + + return res.status(401).send({ + status: 'fail', + message: "Sorry! You can't rate your article" + }); + } catch (e) { + return res.status(502).send({ + status: 'Error', + message: 'OOPS! an error occurred while trying to rate article. log in and try again!' + }); + } +}; +/** +* @export +* @function getAllArticles +* @param {Object} req - request received +* @param {Object} res - response object +* @returns {Object} JSON object (JSend format) +*/ + + +exports.rateArticle = rateArticle; + +const getAllArticles = async (req, res) => { + try { + const articles = await _models.Article.findAll(); + const allArticles = articles.map(article => { + article = article.toJSON(); + article.readTime = getReadTime(article.body); + return article; + }); + return res.status(200).send({ + status: 'success', + data: allArticles + }); + } catch (err) { + return res.status(500).send({ + status: 'error', + message: 'Internal server error' + }); + } +}; +/** +* @export +* @function getArticle +* @param {Object} req - request received +* @param {Object} res - response object +* @returns {Object} JSON object (JSend format) +*/ + + +exports.getAllArticles = getAllArticles; + +const getArticle = async (req, res) => { + const { + params: { + id: articleId + } + } = req; + + try { + let foundArticle = await _models.Article.findByPk(articleId); + + if (!foundArticle) { + return res.status(404).send({ + status: 'fail', + message: 'Resource not found' + }); + } + + foundArticle = foundArticle.toJSON(); + foundArticle.readTime = getReadTime(foundArticle.body); + return res.status(200).send({ + status: 'success', + data: foundArticle + }); + } catch (err) { + return res.status(500).send({ + status: 'error', + message: 'Internal server error' + }); + } +}; + +exports.getArticle = getArticle; \ No newline at end of file diff --git a/dist/api/controllers/comments.js b/dist/api/controllers/comments.js new file mode 100644 index 0000000..bc7699d --- /dev/null +++ b/dist/api/controllers/comments.js @@ -0,0 +1,113 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.updateComment = exports.postComment = void 0; + +var _models = require("../../models"); + +const { + Op +} = _models.Sequelize; + +const postComment = async (req, res) => { + const { + params: { + articleId + }, + body: { + commentBody + }, + user: { + id: userId + } + } = req; + + try { + const newComment = await _models.Comment.create({ + commentBody, + articleId, + userId + }); + return res.status(201).send({ + status: 'success', + data: newComment + }); + } catch (error) { + return res.status(500).send({ + status: 'error', + message: 'Error saving comment', + data: error + }); + } +}; + +exports.postComment = postComment; + +const updateComment = async (req, res) => { + const { + params: { + articleId, + commentId + }, + body: { + commentBody + } + } = req; + + try { + const foundArticle = await _models.Article.findByPk(articleId); + + if (!foundArticle) { + res.status(404).send({ + status: 'fail', + message: 'Article not found' + }); + } + + const foundComments = await foundArticle.getComments({ + where: { + id: { + [Op.eq]: commentId + } + } + }); + const foundComment = foundComments[0]; + + if (!foundComment) { + res.status(404).send({ + status: 'fail', + message: 'Comment not found under this article' + }); + } // update the comment + + + const updatedComment = await foundComment.update({ + commentBody + }, { + returning: true, + plain: true + }); // save the old version of the comment in the comment history + + await updatedComment.createCommentHistory({ + commentId: foundComment.id, + commentBody: foundComment.commentBody + }); + const oldComments = await updatedComment.getCommentHistories(); + const comment = updatedComment.toJSON(); + comment.oldComments = oldComments; + res.status(200).send({ + status: 'success', + message: 'Comment updated', + data: comment + }); + } catch (error) { + return res.status(500).send({ + status: 'error', + message: 'Error updating comment' + }); + } +}; + +exports.updateComment = updateComment; \ No newline at end of file diff --git a/dist/api/controllers/user.js b/dist/api/controllers/user.js new file mode 100644 index 0000000..23bdfd0 --- /dev/null +++ b/dist/api/controllers/user.js @@ -0,0 +1,573 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.changeRole = exports.upgradeToAdmin = exports.resetPassword = exports.resetPasswordVerification = exports.followUser = exports.loginUser = exports.logout = exports.socialLogin = exports.verifyUser = exports.registerUser = void 0; + +var _dotenv = _interopRequireDefault(require("dotenv")); + +var _sequelize = _interopRequireDefault(require("sequelize")); + +var _winston = require("winston"); + +var _tokenize = require("../helpers/tokenization/tokenize"); + +var _models = require("../../models"); + +var _mailer = require("../helpers/mailer/mailer"); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +_dotenv.default.config(); + +const logger = (0, _winston.createLogger)({ + level: 'debug', + format: _winston.format.simple(), + transports: [new _winston.transports.Console()] +}); +const { + Op +} = _sequelize.default; +/** +* @export +* @function registerUser +* @param {Object} req - request received +* @param {Object} res - response object +* @returns {Object} JSON object (JSend format) +*/ + +const registerUser = async (req, res) => { + const { + firstname, + lastname, + username, + email, + password + } = req.body; + + try { + const foundUser = await _models.User.findOne({ + where: { + email: { + [Op.eq]: email + } + } + }); + + if (foundUser) { + return res.status(409).send({ + status: 'fail', + data: { + email: 'User with this email already exist.' + } + }); + } + + const createdUser = await _models.User.create({ + firstname, + lastname, + username, + email, + password + }); + const link = `${req.protocol}://${req.headers.host}/api/users/${createdUser.id}/verify`; + + if (process.env.NODE_ENV === 'production') { + try { + await (0, _mailer.sendVerificationMail)(username, email, link); + } catch (error) { + logger.debug('Email Error::', error); + } + } + + return res.status(201).send({ + status: 'success', + data: { + message: `A confirmation email has been sent to ${email}. Click on the confirmation button to verify the account`, + link + } + }); + } catch (e) { + res.status(500).send({ + status: 'error', + message: 'Internal server error occured.' + }); + } +}; +/** +* @export +* @function verifyUser +* @param {Object} req - request received +* @param {Object} res - response object +* @returns {Object} JSON object (JSend format) +*/ + + +exports.registerUser = registerUser; + +const verifyUser = async (req, res) => { + try { + const { + id + } = req.params; + const unverifiedUser = await _models.User.findByPk(id); + + if (!unverifiedUser) { + return res.status(404).send({ + status: 'fail', + data: { + message: 'user does not exist' + } + }); + } + + if (unverifiedUser.isVerified) { + return res.status(400).send({ + status: 'fail', + message: 'User is already verified' + }); + } + + const { + email, + username, + role + } = await unverifiedUser.update({ + isVerified: true + }, { + returning: true, + plain: true + }); + const token = (0, _tokenize.signToken)({ + id, + email, + role + }); + return res.status(200).send({ + status: 'success', + data: { + message: 'Verification successful. You\'re all set!', + user: { + username, + email, + token, + role + } + } + }); + } catch (e) { + return res.status(500).send({ + status: 'error', + message: 'Internal server error occured.' + }); + } +}; + +exports.verifyUser = verifyUser; + +const socialLogin = async (req, res) => { + const { + firstname, + lastname, + username, + email, + password, + imageUrl, + role + } = req.user; + + try { + const { + id + } = await _models.User.findOrCreate({ + where: { + email + }, + defaults: { + firstname, + lastname, + username, + password, + imageUrl, + isVerified: true, + role + } + }); + const token = (0, _tokenize.signToken)({ + id, + email, + role + }); + return res.status(200).send({ + status: 'success', + data: { + message: 'login successful', + user: { + username, + email, + role + }, + token + } + }); + } catch (e) { + res.status(500).send({ + status: 'fail', + message: 'internal server error occured' + }); + } +}; + +exports.socialLogin = socialLogin; + +const logout = (req, res) => { + req.session.destroy(err => { + if (err) { + return res.status(500).send({ + error: { + message: 'server error', + error: err + } + }); + } + }); + return res.status(200).send({ + status: 'success', + message: 'You successfully logged out' + }); +}; +/** + * @param {Object} req - request received + * @param {Object} res - response object + * @returns {Object} response object + */ + + +exports.logout = logout; + +const loginUser = async (req, res) => { + const userCredentials = req.body; + const { + email, + password + } = userCredentials; // Check if the user exists in the database + + const foundUser = await _models.User.findOne({ + where: { + email: { + [Op.eq]: email + } + } + }); + + if (!foundUser) { + return res.status(401).send({ + status: 'fail', + message: 'Provide correct login credentials' + }); + } + + if (!foundUser.isVerified) { + return res.status(403).send({ + status: 'fail', + message: 'Email not verified' + }); + } + + if (!_models.User.passwordMatch(foundUser.password, password)) { + return res.status(401).send({ + status: 'fail', + message: 'Provide correct login credentials' + }); + } + + const { + id, + role + } = foundUser; + const token = (0, _tokenize.signToken)({ + id, + email, + role + }); + return res.status(200).send({ + status: 'success', + data: { + userId: foundUser.id, + email, + token + } + }); +}; +/** + * @param {Object} req - request received + * @param {Object} res - response object + * @returns {Object} response object + */ + + +exports.loginUser = loginUser; + +const followUser = async (req, res) => { + const { + userId + } = req.params; // req.user is available after password authenticates user + + const followerId = req.user.id; + + try { + let followedUser = await _models.User.findByPk(userId); + const followingUser = await _models.User.findByPk(followerId); + + if (!followedUser || !followingUser) { + return res.status(404).send({ + status: 'fail', + message: 'account(s) not found' + }); + } + + await followedUser.addFollowers(followingUser); // get all followers retrieving only id + + const userFollowers = await followedUser.getFollowers({ + attributes: ['id'] + }); // we use toJSON to convert the sequelize model instance to json object + // so we can add the followers property to the followedUser object + // to be returned + + followedUser = followedUser.toJSON(); + followedUser.followers = userFollowers.map(item => item.id); // return the followed user + + return res.status(201).send({ + status: 'success', + data: followedUser + }); + } catch (error) { + res.status(500).send({ + status: 'error', + message: 'Internal server error occured.' + }); + } +}; + +exports.followUser = followUser; + +const resetPasswordVerification = async (req, res) => { + const { + body: { + email + } + } = req; + + try { + const foundUser = await _models.User.findOne({ + where: { + email: { + [Op.eq]: email + } + } + }); + + if (!foundUser) { + return res.status(404).send({ + status: 'fail', + message: 'User not found,Provide correct email address' + }); + } + + const token = (0, _tokenize.signToken)({ + email + }, '10m'); + const { + username + } = foundUser; + + if (process.env.NODE_ENV === 'production') { + try { + await (0, _mailer.resetPasswordVerificationMail)(username, foundUser.email, token); + } catch (error) { + logger.debug('Email Error::', error); + } + } + + return res.status(200).send({ + status: 'success', + data: { + message: `A confirmation email has been sent to ${foundUser.email}. Click on the confirmation button to verify the account`, + link: `${req.protocol}://${req.headers.host}/api/users/resetPassword/${token}` + } + }); + } catch (e) { + res.status(500).send({ + status: 'error', + message: 'Internal server error occured.' + }); + } +}; +/** + * @param {Object} req - request received + * @param {Object} res - response object + * @returns {Object} response object + */ + + +exports.resetPasswordVerification = resetPasswordVerification; + +const resetPassword = async (req, res) => { + try { + const { + params: { + token + }, + body: { + password + } + } = req; + const { + email + } = (0, _tokenize.verifyToken)(token); // Check if the user exists in the database + + const foundUser = await _models.User.findOne({ + where: { + email: { + [Op.eq]: email + } + } + }); + + if (!foundUser) { + return res.status(404).send({ + status: 'fail', + message: 'User not found,Provide correct email address' + }); + } + + const updatedUser = await foundUser.update({ + password + }, { + where: { + email: { + [Op.eq]: email + } + }, + returning: true, + plain: true + }); + return res.status(200).send({ + status: 'success', + data: { + userId: updatedUser.id, + email, + message: 'Password update Successful. You can now login' + } + }); + } catch (e) { + if (e.name === 'TokenExpiredError' || e.name === 'JsonWebTokenError') { + return res.status(401).send({ + status: 'fail', + message: 'Link has expired. Kindly re-initiate password change.' + }); + } + + res.status(500).send({ + status: 'error', + message: 'Internal server error occured.' + }); + } +}; + +exports.resetPassword = resetPassword; + +const upgradeToAdmin = async ({ + user: { + id + }, + body: { + pass + } +}, res) => { + if (pass !== process.env.ADMIN_PASS) { + return res.status(403).send({ + status: 'fail', + message: 'wrong pass' + }); + } + + try { + const [, [{ + username, + role: assignedRole + }]] = await _models.User.update({ + role: 'admin' + }, { + returning: true, + where: { + id: { + [Op.eq]: id + } + } + }); + res.status(200).send({ + status: 'success', + data: { + id, + username, + assignedRole + } + }); + } catch (error) { + res.status(500).send({ + status: 'error', + message: 'Internal server error occured.' + }); + } +}; + +exports.upgradeToAdmin = upgradeToAdmin; + +const changeRole = async ({ + body: { + id, + role: proposedRole + } +}, res) => { + try { + const user = await _models.User.update({ + role: proposedRole + }, { + returning: true, + where: { + id: { + [Op.eq]: id + } + } + }); + + if (user[0] === 0) { + return res.status(404).send({ + status: 'fail', + message: 'no user found with that id' + }); + } + + const [, [{ + username, + role: assignedRole + }]] = user; + res.status(200).send({ + status: 'success', + data: { + id, + username, + assignedRole + } + }); + } catch (error) { + res.status(500).send({ + status: 'error', + message: 'internal server error occured' + }); + } +}; + +exports.changeRole = changeRole; \ No newline at end of file diff --git a/dist/api/helpers/mailer/mailer.js b/dist/api/helpers/mailer/mailer.js new file mode 100644 index 0000000..0827dcb --- /dev/null +++ b/dist/api/helpers/mailer/mailer.js @@ -0,0 +1,53 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.resetPasswordVerificationMail = exports.sendVerificationMail = void 0; + +var _mail = _interopRequireDefault(require("@sendgrid/mail")); + +var _dotenv = _interopRequireDefault(require("dotenv")); + +var _templates = _interopRequireDefault(require("./templates")); + +var _resetPasswordTemplates = _interopRequireDefault(require("./resetPasswordTemplates")); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +_dotenv.default.config(); + +_mail.default.setApiKey(process.env.SENDGRID_KEY); +/** + * @export + * @function sendVerificationMail + * @param {Object} username - created User's username + * @param {Object} email - created User's email + * @param {Object} url - created User's ID + * @returns {null} null + */ + + +const sendVerificationMail = (username, email, url) => { + const msg = { + to: email, + from: 'support@authors-haven.com', + subject: '[Author\'s Haven] Email Verification', + html: (0, _templates.default)(username, url) + }; + return _mail.default.send(msg); +}; + +exports.sendVerificationMail = sendVerificationMail; + +const resetPasswordVerificationMail = (username, email, token) => { + const msg = { + to: email, + from: 'support@authors-haven.com', + subject: '[Author\'s Haven] Email Verification', + html: (0, _resetPasswordTemplates.default)(username, token) + }; + return _mail.default.send(msg); +}; + +exports.resetPasswordVerificationMail = resetPasswordVerificationMail; \ No newline at end of file diff --git a/dist/api/helpers/mailer/resetPasswordTemplates.js b/dist/api/helpers/mailer/resetPasswordTemplates.js new file mode 100644 index 0000000..0646fa9 --- /dev/null +++ b/dist/api/helpers/mailer/resetPasswordTemplates.js @@ -0,0 +1,139 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.default = void 0; + +const verifyTemplate = (username, token) => ` + + + + + + Coinbase + + + + + + + + + +
+ + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Coinbase +
+ Verify your email address + +
+ Hi ${username}, A request has been received to change the password for your Authors Haven account. Click the button below to + to change it. This password change is only valid for the next 30 mins. + +
+ Change Password + + +
+
+ + + + + + + + + + + + + + +
 
© + Author's + Haven +   |   + Courtesy + of Really Good Emails + +
 
+ +
+
+ + + +`; + +var _default = verifyTemplate; +exports.default = _default; \ No newline at end of file diff --git a/dist/api/helpers/mailer/templates.js b/dist/api/helpers/mailer/templates.js new file mode 100644 index 0000000..5978d48 --- /dev/null +++ b/dist/api/helpers/mailer/templates.js @@ -0,0 +1,140 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.default = void 0; + +const verifyTemplate = (username, url) => ` + + + + + + Coinbase + + + + + + + + + +
+ + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Coinbase +
+ Verify your email address + +
+ Hi ${username}, In order to start using your Author's Haven account, you need to + confirm your email + address. + +
+ Verify + Email Address + +
+
+ + + + + + + + + + + + + + +
 
© + Author's + Haven +   |   + Courtesy + of Really Good Emails + +
 
+ +
+
+ + + +`; + +var _default = verifyTemplate; +exports.default = _default; \ No newline at end of file diff --git a/dist/api/helpers/tokenization/tokenize.js b/dist/api/helpers/tokenization/tokenize.js new file mode 100644 index 0000000..da6bad0 --- /dev/null +++ b/dist/api/helpers/tokenization/tokenize.js @@ -0,0 +1,40 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.verifyToken = exports.signToken = void 0; + +var _jsonwebtoken = _interopRequireDefault(require("jsonwebtoken")); + +var _dotenv = _interopRequireDefault(require("dotenv")); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +_dotenv.default.config(); + +const secret = process.env.JWT_SECRET; +/** + * @export + * @function signToken + * @param {Object} payload - user object + * @param {string} exp - exp default 24hours + * @returns {string} Jwt token + */ + +const signToken = (payload, exp = '24h') => _jsonwebtoken.default.sign(payload, secret, { + expiresIn: exp +}); +/** + * @export + * @function verifyToken + * @param {Object} token - JWT token + * @returns {string} Payload + */ + + +exports.signToken = signToken; + +const verifyToken = token => _jsonwebtoken.default.verify(token, secret); + +exports.verifyToken = verifyToken; \ No newline at end of file diff --git a/dist/api/middlewares/authentication/authenticate.js b/dist/api/middlewares/authentication/authenticate.js new file mode 100644 index 0000000..0393eb1 --- /dev/null +++ b/dist/api/middlewares/authentication/authenticate.js @@ -0,0 +1,122 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.default = exports.passportCallback = void 0; + +var _passport = _interopRequireDefault(require("passport")); + +var _passportJwt = require("passport-jwt"); + +var _passportGoogleOauth = _interopRequireDefault(require("passport-google-oauth20")); + +var _passportFacebook = require("passport-facebook"); + +var _passportTwitter = require("passport-twitter"); + +var _dotenv = _interopRequireDefault(require("dotenv")); + +var _models = require("../../../models"); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i] != null ? arguments[i] : {}; var ownKeys = Object.keys(source); if (typeof Object.getOwnPropertySymbols === 'function') { ownKeys = ownKeys.concat(Object.getOwnPropertySymbols(source).filter(function (sym) { return Object.getOwnPropertyDescriptor(source, sym).enumerable; })); } ownKeys.forEach(function (key) { _defineProperty(target, key, source[key]); }); } return target; } + +function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } + +_dotenv.default.config(); + +const options = { + secretOrKey: process.env.JWT_SECRET, + jwtFromRequest: _passportJwt.ExtractJwt.fromAuthHeaderAsBearerToken() +}; +const { + GOOGLE_CLIENT_ID: googleClientID, + GOOGLE_CLIENT_SECRET: googleClientSecret, + FACEBOOK_APP_ID: facebookAppID, + FACEBOOK_APP_SECRET: facebookAppSecret, + TWITTER_CONSUMER_KEY: consumerKey, + TWITTER_CONSUMER_SECRET: consumerSecret, + DOMAIN: domain +} = process.env; + +const credentials = (platform, clientID, clientSecret) => { + const callbackURL = `${domain}/api/users/${platform}/redirect`; + return platform !== 'twitter' ? { + clientID, + clientSecret, + callbackURL + } : { + consumerKey, + consumerSecret, + callbackURL + }; +}; + +const passportCallback = (accessToken, refreshToken, { + id, + username, + displayName, + name, + emails: [{ + value: email + }], + photos: [{ + value: imageUrl + }] +}, done) => { + const { + givenName: firstname = displayName, + familyName: lastname + } = name || {}; + const profile = { + firstname, + lastname, + email, + imageUrl + }; + profile.username = username || name.givenName + id.slice(-6, -1); + profile.password = id.slice(0, 6); + profile.role = 'author'; + done(null, profile); +}; + +exports.passportCallback = passportCallback; +const profileFields = ['id', 'displayName', 'name', 'gender', 'profileUrl', 'email', 'photos']; +const googleCredentials = credentials('google', googleClientID, googleClientSecret); + +const facebookCredentials = _objectSpread({}, credentials('facebook', facebookAppID, facebookAppSecret), { + profileFields +}); + +const twitterCredentials = _objectSpread({}, credentials('twitter'), { + includeEmail: true +}); + +const googleStrategy = new _passportGoogleOauth.default(googleCredentials, passportCallback); +const facebookStrategy = new _passportFacebook.Strategy(facebookCredentials, passportCallback); +const twitterStrategy = new _passportTwitter.Strategy(twitterCredentials, passportCallback); + +_passport.default.use(googleStrategy); + +_passport.default.use(facebookStrategy); + +_passport.default.use(twitterStrategy); + +_passport.default.use(new _passportJwt.Strategy(options, async (payload, done) => { + try { + const user = await _models.User.findByPk(payload.id); + + if (!user) { + return done(new Error('Authentication failed'), false); + } + + return done(null, user); + } catch (error) { + return done(error, null); + } +})); + +var _default = _passport.default; +exports.default = _default; \ No newline at end of file diff --git a/dist/api/middlewares/validation/article.js b/dist/api/middlewares/validation/article.js new file mode 100644 index 0000000..49bc3c6 --- /dev/null +++ b/dist/api/middlewares/validation/article.js @@ -0,0 +1,72 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.ratingValidator = exports.createHighlightValidator = exports.newArticleValidator = void 0; + +var _joi = _interopRequireDefault(require("joi")); + +var _article = require("./schemas/article"); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +/** +* @export +* @function newArticleValidator +* @param {Object} req - request received +* @param {Object} res - response object +* @param {Object} next - next object +* @returns {Object} next object +*/ +const newArticleValidator = (req, res, next) => { + _joi.default.validate(req.body, _article.newArticleSchema).then(() => { + next(); + }).catch(error => { + res.status(422).send({ + error + }); + }); +}; +/** +* @export +* @function createHighlightValidator +* @param {Object} req - request received +* @param {Object} res - response object +* @param {Object} next - next object +* @returns {Object} next object +*/ + + +exports.newArticleValidator = newArticleValidator; + +const createHighlightValidator = (req, res, next) => { + _joi.default.validate(req.body, _article.createHighlightSchema).then(() => next()).catch(error => res.status(422).send({ + status: 'fail', + data: { + input: error.details[0].message + } + })); +}; +/** +* @export +* @function ratingValidator +* @param {Object} req - request received +* @param {Object} res - response object +* @param {Object} next - next object +* @returns {Object} next object +*/ + + +exports.createHighlightValidator = createHighlightValidator; + +const ratingValidator = (req, res, next) => { + _joi.default.validate(req.body, _article.ratingSchema).then(() => next()).catch(error => res.status(422).send({ + status: 'fail', + data: { + input: error.details[0].message + } + })); +}; + +exports.ratingValidator = ratingValidator; \ No newline at end of file diff --git a/dist/api/middlewares/validation/comment.js b/dist/api/middlewares/validation/comment.js new file mode 100644 index 0000000..acbf49d --- /dev/null +++ b/dist/api/middlewares/validation/comment.js @@ -0,0 +1,64 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.updateCommentValidator = exports.newCommentValidator = void 0; + +var _joi = _interopRequireDefault(require("joi")); + +var _comment = require("./schemas/comment"); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +const newCommentValidator = ({ + params: { + articleId + }, + body: { + commentBody + } +}, res, next) => { + const payload = { + articleId, + commentBody + }; + + _joi.default.validate(payload, _comment.newCommentSchema).then(() => { + next(); + }).catch(error => { + res.status(422).send({ + status: 'fail', + message: error + }); + }); +}; + +exports.newCommentValidator = newCommentValidator; + +const updateCommentValidator = ({ + params: { + articleId, + commentId + }, + body: { + commentBody + } +}, res, next) => { + const payload = { + articleId, + commentId, + commentBody + }; + + _joi.default.validate(payload, _comment.updateCommentSchema).then(() => { + next(); + }).catch(error => { + res.status(422).send({ + status: 'fail', + message: error + }); + }); +}; + +exports.updateCommentValidator = updateCommentValidator; \ No newline at end of file diff --git a/dist/api/middlewares/validation/schemas/article.js b/dist/api/middlewares/validation/schemas/article.js new file mode 100644 index 0000000..3fd4484 --- /dev/null +++ b/dist/api/middlewares/validation/schemas/article.js @@ -0,0 +1,47 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.ratingSchema = exports.createHighlightSchema = exports.newArticleSchema = void 0; + +var _joi = _interopRequireDefault(require("joi")); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +const title = _joi.default.string().min(10).required(); + +const description = _joi.default.string().min(10).required(); + +const body = _joi.default.string().min(20).required(); + +const references = _joi.default.array().items(_joi.default.string()); + +const categoryId = _joi.default.number().required(); + +const tags = _joi.default.string(); + +const highlight = _joi.default.string().trim().min(1).required(); + +const comment = _joi.default.string().trim().min(1).required(); + +const rating = _joi.default.number().integer().min(1).max(5).required(); + +const newArticleSchema = { + title, + description, + body, + references, + categoryId, + tags +}; +exports.newArticleSchema = newArticleSchema; +const createHighlightSchema = { + highlight, + comment +}; +exports.createHighlightSchema = createHighlightSchema; +const ratingSchema = { + rating +}; +exports.ratingSchema = ratingSchema; \ No newline at end of file diff --git a/dist/api/middlewares/validation/schemas/comment.js b/dist/api/middlewares/validation/schemas/comment.js new file mode 100644 index 0000000..9f7f22c --- /dev/null +++ b/dist/api/middlewares/validation/schemas/comment.js @@ -0,0 +1,28 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.updateCommentSchema = exports.newCommentSchema = void 0; + +var _joi = _interopRequireDefault(require("joi")); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +const articleId = _joi.default.string().trim().min(1).required(); + +const commentId = _joi.default.string().trim().min(1).required(); + +const commentBody = _joi.default.string().trim().min(1).max(140).required(); + +const newCommentSchema = { + commentBody, + articleId +}; +exports.newCommentSchema = newCommentSchema; +const updateCommentSchema = { + commentBody, + articleId, + commentId +}; +exports.updateCommentSchema = updateCommentSchema; \ No newline at end of file diff --git a/dist/api/middlewares/validation/schemas/user.js b/dist/api/middlewares/validation/schemas/user.js new file mode 100644 index 0000000..a6059af --- /dev/null +++ b/dist/api/middlewares/validation/schemas/user.js @@ -0,0 +1,42 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.changePasswordSchema = exports.resetPasswordSchema = exports.registrationRequestSchema = exports.loginRequestSchema = void 0; + +var _joi = _interopRequireDefault(require("joi")); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +const firstname = _joi.default.string().trim().strict().min(3).required(); + +const lastname = _joi.default.string().trim().strict().min(3).required(); + +const username = _joi.default.string().trim().alphanum().min(3).max(30).required(); + +const email = _joi.default.string().trim().strict().min(10).max(100).email().required(); + +const password = _joi.default.string().trim().strict().alphanum().min(8).max(40).required(); + +const registrationRequestSchema = { + firstname, + lastname, + username, + email, + password +}; +exports.registrationRequestSchema = registrationRequestSchema; +const loginRequestSchema = { + email, + password +}; +exports.loginRequestSchema = loginRequestSchema; +const resetPasswordSchema = { + email +}; +exports.resetPasswordSchema = resetPasswordSchema; +const changePasswordSchema = { + password +}; +exports.changePasswordSchema = changePasswordSchema; \ No newline at end of file diff --git a/dist/api/middlewares/validation/user.js b/dist/api/middlewares/validation/user.js new file mode 100644 index 0000000..3fca27a --- /dev/null +++ b/dist/api/middlewares/validation/user.js @@ -0,0 +1,129 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.changeRoleValidation = exports.changePasswordValidation = exports.resetPasswordValidation = exports.loginValidation = exports.registrationValidation = void 0; + +var _joi = _interopRequireDefault(require("joi")); + +var _user = require("./schemas/user"); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +/** +* @export +* @function registrationValidation +* @param {Object} req - request received +* @param {Object} res - response object +* @param {Object} next - next object +* @returns {Object} next object +*/ +const registrationValidation = (req, res, next) => { + _joi.default.validate(req.body, _user.registrationRequestSchema).then(() => next()).catch(error => res.status(422).send({ + status: 'fail', + data: { + input: error.details[0].message + } + })); +}; +/** +* @export +* @function loginValidation +* @param {Object} req - request received +* @param {Object} res - response object +* @param {Object} next - next object +* @returns {Object} next object +*/ + + +exports.registrationValidation = registrationValidation; + +const loginValidation = (req, res, next) => { + _joi.default.validate(req.body, _user.loginRequestSchema).then(() => next()).catch(error => res.status(422).send({ + status: 'fail', + data: { + input: error.details[0].message + } + })); +}; +/** +* @export +* @function requestPasswordValidation +* @param {Object} req - request received +* @param {Object} res - response object +* @param {Object} next - next object +* @returns {Object} next object +*/ + + +exports.loginValidation = loginValidation; + +const resetPasswordValidation = (req, res, next) => { + _joi.default.validate(req.body, _user.resetPasswordSchema).then(() => next()).catch(error => res.status(422).send({ + status: 'fail', + data: { + input: error.details[0].message + } + })); +}; +/** +* @export +* @function requestPasswordValidation +* @param {Object} req - request received +* @param {Object} res - response object +* @param {Object} next - next object +* @returns {Object} next object +*/ + + +exports.resetPasswordValidation = resetPasswordValidation; + +const changePasswordValidation = (req, res, next) => { + _joi.default.validate(req.body, _user.changePasswordSchema).then(() => next()).catch(error => res.status(422).send({ + status: 'fail', + data: { + input: error.details[0].message + } + })); +}; +/** +* @export +* @function requestPasswordValidation +* @param {Object} req - request received +* @param {Object} res - response object +* @param {Object} next - next object +* @returns {Object} next object +*/ + + +exports.changePasswordValidation = changePasswordValidation; + +const changeRoleValidation = ({ + user: { + role + }, + body: { + role: proposedRole + } +}, res, next) => { + if (role !== 'admin') { + return res.status(401).send({ + status: 'fail', + message: 'only admins can change user roles' + }); + } + + const availableRoles = ['admin', 'author']; + + if (!availableRoles.includes(proposedRole)) { + return res.status(422).send({ + status: 'fail', + message: 'not a valid role' + }); + } + + next(); +}; + +exports.changeRoleValidation = changeRoleValidation; \ No newline at end of file diff --git a/dist/api/routes/article.js b/dist/api/routes/article.js new file mode 100644 index 0000000..785c281 --- /dev/null +++ b/dist/api/routes/article.js @@ -0,0 +1,43 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.default = void 0; + +var _express = require("express"); + +var _passport = _interopRequireDefault(require("passport")); + +var _article = require("../middlewares/validation/article"); + +var _article2 = require("../controllers/article"); + +var _comment = _interopRequireDefault(require("./comment")); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +const articlesRouter = (0, _express.Router)(); +articlesRouter.post('/', _passport.default.authenticate('jwt', { + session: false +}), _article.newArticleValidator, _article2.createArticle); +articlesRouter.patch('/:id/likes', _passport.default.authenticate('jwt', { + session: false +}), _article2.likeArticle); +articlesRouter.patch('/:id/dislikes', _passport.default.authenticate('jwt', { + session: false +}), _article2.dislikeArticle); +articlesRouter.post('/:id/highlights', _passport.default.authenticate('jwt', { + session: false +}), _article.createHighlightValidator, _article2.createHighlight); +articlesRouter.get('/:id/highlights', _passport.default.authenticate('jwt', { + session: false +}), _article2.getHighlights); +articlesRouter.post('/:articleId/ratings', _passport.default.authenticate('jwt', { + session: false +}), _article.ratingValidator, _article2.rateArticle); +articlesRouter.use('/:articleId/comments', _comment.default); +articlesRouter.get('/', _article2.getAllArticles); +articlesRouter.get('/:id', _article2.getArticle); +var _default = articlesRouter; +exports.default = _default; \ No newline at end of file diff --git a/dist/api/routes/comment.js b/dist/api/routes/comment.js new file mode 100644 index 0000000..9b3540a --- /dev/null +++ b/dist/api/routes/comment.js @@ -0,0 +1,28 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.default = void 0; + +var _express = require("express"); + +var _passport = _interopRequireDefault(require("passport")); + +var _comment = require("../middlewares/validation/comment"); + +var _comments = require("../controllers/comments"); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +const commentsRouter = (0, _express.Router)({ + mergeParams: true +}); +commentsRouter.post('/', _passport.default.authenticate('jwt', { + session: false +}), _comment.newCommentValidator, _comments.postComment); +commentsRouter.patch('/:commentId', _passport.default.authenticate('jwt', { + session: false +}), _comment.newCommentValidator, _comments.updateComment); +var _default = commentsRouter; +exports.default = _default; \ No newline at end of file diff --git a/dist/api/routes/user.js b/dist/api/routes/user.js new file mode 100644 index 0000000..bbb7492 --- /dev/null +++ b/dist/api/routes/user.js @@ -0,0 +1,51 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.default = void 0; + +var _express = require("express"); + +var _passport = _interopRequireDefault(require("passport")); + +var _user = require("../controllers/user"); + +var _user2 = require("../middlewares/validation/user"); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +const userRouter = (0, _express.Router)(); +userRouter.post('/', _user2.registrationValidation, _user.registerUser); +userRouter.post('/login', _user2.loginValidation, _user.loginUser); +userRouter.post('/:userId/follow', _passport.default.authenticate('jwt', { + session: false +}), _user.followUser); +userRouter.patch('/:id/verify', _user.verifyUser); +userRouter.get('/google', _passport.default.authenticate('google', { + scope: ['profile', 'email'] +})); +userRouter.get('/facebook', _passport.default.authenticate('facebook', { + scope: 'email' +})); +userRouter.get('/twitter', _passport.default.authenticate('twitter')); +userRouter.get('/google/redirect', _passport.default.authenticate('google', { + session: false +}), _user.socialLogin); +userRouter.get('/facebook/redirect', _passport.default.authenticate('facebook', { + session: false +}), _user.socialLogin); +userRouter.get('/twitter/redirect', _passport.default.authenticate('twitter', { + session: false +}), _user.socialLogin); +userRouter.get('/logout', _user.logout); +userRouter.post('/resetPassword', _user2.resetPasswordValidation, _user.resetPasswordVerification); +userRouter.patch('/resetPassword/:token', _user2.changePasswordValidation, _user.resetPassword); +userRouter.patch('/role', _passport.default.authenticate('jwt', { + session: false +}), _user2.changeRoleValidation, _user.changeRole); +userRouter.patch('/admin', _passport.default.authenticate('jwt', { + session: false +}), _user.upgradeToAdmin); +var _default = userRouter; +exports.default = _default; \ No newline at end of file diff --git a/dist/config/config.js b/dist/config/config.js new file mode 100644 index 0000000..beb8cad --- /dev/null +++ b/dist/config/config.js @@ -0,0 +1,24 @@ +"use strict"; + +const dotenv = require('dotenv'); + +dotenv.config(); +module.exports = { + development: { + url: process.env.DATABASE_URL_DEV, + dialect: 'postgres', + operatorsAliases: false, + logging: false + }, + test: { + url: process.env.DATABASE_URL, + dialect: 'postgres', + operatorsAliases: false, + logging: false + }, + production: { + url: process.env.DATABASE_URL, + dialect: 'postgres', + operatorsAliases: false + } +}; \ No newline at end of file diff --git a/dist/config/session.js b/dist/config/session.js new file mode 100644 index 0000000..0973de5 --- /dev/null +++ b/dist/config/session.js @@ -0,0 +1,62 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.default = void 0; + +var _expressSession = _interopRequireDefault(require("express-session")); + +var _connectPgSimple = _interopRequireDefault(require("connect-pg-simple")); + +var _dotenv = _interopRequireDefault(require("dotenv")); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +_dotenv.default.config(); + +const { + SESS_SECRET: secret, + NODE_ENV: env, + DATABASE_URL_DEV, + DATABASE_URL +} = process.env; +const conString = env === 'development' ? DATABASE_URL_DEV : DATABASE_URL; + +const sessionManagementConfig = app => { + _expressSession.default.Session.prototype.login = function (user, cb) { + const { + req + } = this; + req.session.regenerate(err => { + if (err) { + cb(err); + } + }); + req.session.userInfo = user; + cb(); + }; + + const pgSessionStore = (0, _connectPgSimple.default)(_expressSession.default); + const pgStoreConfig = { + conString, + ttl: 1 * 60 * 60 + }; + app.use((0, _expressSession.default)({ + store: new pgSessionStore(pgStoreConfig), + secret, + resave: false, + saveUninitialized: true, + cookie: { + path: '/', + expires: true, + httpOnly: true, + secure: env === 'production', + maxAge: 1 * 60 * 60 * 1000 + }, + name: 'id' + })); +}; + +var _default = sessionManagementConfig; +exports.default = _default; \ No newline at end of file diff --git a/dist/index.js b/dist/index.js new file mode 100644 index 0000000..7d48713 --- /dev/null +++ b/dist/index.js @@ -0,0 +1,62 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.default = void 0; + +var _express = _interopRequireDefault(require("express")); + +var _cors = _interopRequireDefault(require("cors")); + +var _dotenv = _interopRequireDefault(require("dotenv")); + +var _chalk = _interopRequireDefault(require("chalk")); + +var _winston = require("winston"); + +var _session = _interopRequireDefault(require("./server/config/session")); + +var _authenticate = _interopRequireDefault(require("./server/api/middlewares/authentication/authenticate")); + +var _user = _interopRequireDefault(require("./server/api/routes/user")); + +var _article = _interopRequireDefault(require("./server/api/routes/article")); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +_dotenv.default.config(); + +const logger = (0, _winston.createLogger)({ + level: 'debug', + format: _winston.format.simple(), + transports: [new _winston.transports.Console()] +}); +const port = process.env.PORT || process.env.LOCAL_PORT; // Create global app object + +const app = (0, _express.default)(); +app.use((0, _cors.default)()); // Normal express config defaults + +app.use(require('morgan')('dev')); +app.use(_express.default.urlencoded({ + extended: true +})); +app.use(_express.default.json()); +(0, _session.default)(app); +app.use(_express.default.static(`${__dirname}/public`)); +app.use(_authenticate.default.initialize()); +app.use('/api/users', _user.default); +app.use('/api/articles', _article.default); +app.get('/', (req, res) => res.status(200).send({ + status: 'connection successful', + message: 'Welcome to Author Haven!' +})); +app.get('*', (req, res) => res.status(200).send({ + status: 'fail', + message: 'Route not found' +})); +app.listen(5000, function serverListner() { + logger.debug(`Server running on port ${_chalk.default.blue(port)}`); +}); +var _default = app; +exports.default = _default; \ No newline at end of file diff --git a/dist/migrations/20190204141618-roles.js b/dist/migrations/20190204141618-roles.js new file mode 100644 index 0000000..f31f3cb --- /dev/null +++ b/dist/migrations/20190204141618-roles.js @@ -0,0 +1,18 @@ +"use strict"; + +module.exports = { + up: (queryInterface, Sequelize) => queryInterface.createTable('Roles', { + id: { + allowNull: false, + primaryKey: true, + autoIncrement: true, + type: Sequelize.INTEGER + }, + name: { + allowNull: false, + type: Sequelize.TEXT, + unique: true + } + }), + down: queryInterface => queryInterface.dropTable('Roles') +}; \ No newline at end of file diff --git a/dist/migrations/20190204141619-create-users.js b/dist/migrations/20190204141619-create-users.js new file mode 100644 index 0000000..f358bae --- /dev/null +++ b/dist/migrations/20190204141619-create-users.js @@ -0,0 +1,97 @@ +"use strict"; + +module.exports = { + up(queryInterface, Sequelize) { + return queryInterface.sequelize.query('CREATE EXTENSION IF NOT EXISTS "uuid-ossp";').then(() => queryInterface.sequelize.query(`CREATE TABLE IF NOT EXISTS "session" ( + "sid" varchar NOT NULL COLLATE "default", + "sess" json NOT NULL, + "expire" timestamp(6) NOT NULL + ) + WITH (OIDS=FALSE); + ALTER TABLE "session" ADD CONSTRAINT "session_pkey" PRIMARY KEY ("sid") NOT DEFERRABLE INITIALLY IMMEDIATE;`)).then(() => queryInterface.createTable('User', { + id: { + allowNull: false, + primaryKey: true, + type: Sequelize.UUID, + defaultValue: Sequelize.literal('uuid_generate_v4()') + }, + firstname: { + allowNull: { + args: false, + msg: 'Please enter a username' + }, + type: Sequelize.STRING + }, + lastname: { + allowNull: { + args: false, + msg: 'Please enter a username' + }, + type: Sequelize.STRING + }, + username: { + allowNull: { + args: false, + msg: 'Please enter a username' + }, + type: Sequelize.STRING, + unique: { + args: true, + msg: 'Username already exist' + } + }, + email: { + allowNull: false, + type: Sequelize.STRING, + unique: true, + validate: { + isEmail: true + } + }, + password: { + allowNull: { + args: false, + msg: 'Please enter a password' + }, + type: Sequelize.STRING, + validate: { + len: [8, 72] + } + }, + role: { + allowNull: false, + type: Sequelize.TEXT, + defaultValue: 'author', + onDelete: 'CASCADE', + references: { + model: 'Roles', + key: 'name' + } + }, + bio: { + type: Sequelize.TEXT + }, + imageUrl: { + type: Sequelize.STRING + }, + isVerified: { + defaultValue: false, + allowNull: false, + type: Sequelize.BOOLEAN + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE + } + })); + }, + + down: async queryInterface => { + await queryInterface.dropTable('User'); + await queryInterface.dropTable('session'); + } +}; \ No newline at end of file diff --git a/dist/migrations/20190204142642-create-user-follower.js b/dist/migrations/20190204142642-create-user-follower.js new file mode 100644 index 0000000..622f167 --- /dev/null +++ b/dist/migrations/20190204142642-create-user-follower.js @@ -0,0 +1,27 @@ +"use strict"; + +module.exports = { + up: (queryInterface, Sequelize) => queryInterface.createTable('UserFollower', { + userId: { + allowNull: false, + type: Sequelize.UUID, + onDelete: 'CASCADE', + references: { + model: 'User', + key: 'id', + as: 'userId' + } + }, + followerId: { + allowNull: false, + type: Sequelize.UUID, + onDelete: 'CASCADE', + references: { + model: 'User', + key: 'id', + as: 'followerId' + } + } + }), + down: queryInterface => queryInterface.dropTable('UserFollower') +}; \ No newline at end of file diff --git a/dist/migrations/20190204144944-create-category.js b/dist/migrations/20190204144944-create-category.js new file mode 100644 index 0000000..0440b18 --- /dev/null +++ b/dist/migrations/20190204144944-create-category.js @@ -0,0 +1,17 @@ +"use strict"; + +module.exports = { + up: (queryInterface, Sequelize) => queryInterface.createTable('Category', { + id: { + allowNull: false, + primaryKey: true, + autoIncrement: true, + type: Sequelize.INTEGER + }, + categoryName: { + allowNull: false, + type: Sequelize.TEXT + } + }), + down: queryInterface => queryInterface.dropTable('Category') +}; \ No newline at end of file diff --git a/dist/migrations/20190204145037-create-articles.js b/dist/migrations/20190204145037-create-articles.js new file mode 100644 index 0000000..73a9999 --- /dev/null +++ b/dist/migrations/20190204145037-create-articles.js @@ -0,0 +1,70 @@ +"use strict"; + +module.exports = { + up: (queryInterface, Sequelize) => queryInterface.createTable('Article', { + id: { + allowNull: false, + primaryKey: true, + type: Sequelize.UUID, + defaultValue: Sequelize.literal('uuid_generate_v4()') + }, + slug: { + allowNull: { + args: false, + msg: 'Please enter a title' + }, + type: Sequelize.TEXT + }, + body: { + allowNull: { + args: false, + msg: 'Please enter your text' + }, + type: Sequelize.TEXT + }, + title: { + allowNull: { + args: false, + msg: 'Please give title' + }, + type: Sequelize.TEXT + }, + description: { + allowNull: { + args: false, + msg: 'Please give description' + }, + type: Sequelize.TEXT + }, + authorId: { + allowNull: false, + type: Sequelize.UUID, + onDelete: 'CASCADE', + references: { + model: 'User', + key: 'id' + } + }, + references: { + type: Sequelize.ARRAY(Sequelize.STRING) + }, + categoryId: { + allowNull: false, + type: Sequelize.INTEGER, + onDelete: 'CASCADE', + references: { + model: 'Category', + key: 'id' + } + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE + } + }), + down: queryInterface => queryInterface.dropTable('Article') +}; \ No newline at end of file diff --git a/dist/migrations/20190204150708-create-notifications.js b/dist/migrations/20190204150708-create-notifications.js new file mode 100644 index 0000000..93288d6 --- /dev/null +++ b/dist/migrations/20190204150708-create-notifications.js @@ -0,0 +1,43 @@ +"use strict"; + +module.exports = { + up: (queryInterface, Sequelize) => queryInterface.createTable('Notification', { + id: { + allowNull: false, + primaryKey: true, + type: Sequelize.UUID, + defaultValue: Sequelize.literal('uuid_generate_v4()') + }, + userId: { + allowNull: false, + type: Sequelize.UUID, + onDelete: 'CASCADE', + references: { + model: 'User', + key: 'id', + as: 'userId' + } + }, + notificationBody: { + allowNull: { + args: false, + msg: 'What is the notification about?' + }, + type: Sequelize.TEXT + }, + seen: { + allowNull: false, + defaultValue: false, + type: Sequelize.BOOLEAN + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE + } + }), + down: queryInterface => queryInterface.dropTable('Notification') +}; \ No newline at end of file diff --git a/dist/migrations/20190204152147-create-bookmarks.js b/dist/migrations/20190204152147-create-bookmarks.js new file mode 100644 index 0000000..f671c56 --- /dev/null +++ b/dist/migrations/20190204152147-create-bookmarks.js @@ -0,0 +1,37 @@ +"use strict"; + +module.exports = { + up: (queryInterface, Sequelize) => queryInterface.createTable('Bookmark', { + id: { + allowNull: false, + primaryKey: true, + type: Sequelize.UUID, + defaultValue: Sequelize.literal('uuid_generate_v4()') + }, + articleId: { + allowNull: false, + type: Sequelize.UUID, + onDelete: 'CASCADE', + references: { + model: 'Article', + key: 'id', + as: 'articleId' + } + }, + userId: { + allowNull: false, + type: Sequelize.UUID, + onDelete: 'CASCADE', + references: { + model: 'User', + key: 'id', + as: 'userId' + } + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE + } + }), + down: queryInterface => queryInterface.dropTable('Bookmark') +}; \ No newline at end of file diff --git a/dist/migrations/20190204152533-create-comments.js b/dist/migrations/20190204152533-create-comments.js new file mode 100644 index 0000000..7011bf1 --- /dev/null +++ b/dist/migrations/20190204152533-create-comments.js @@ -0,0 +1,47 @@ +"use strict"; + +module.exports = { + up: (queryInterface, Sequelize) => queryInterface.createTable('Comment', { + id: { + allowNull: false, + primaryKey: true, + type: Sequelize.UUID, + defaultValue: Sequelize.literal('uuid_generate_v4()') + }, + userId: { + allowNull: false, + type: Sequelize.UUID, + onDelete: 'CASCADE', + references: { + model: 'User', + key: 'id' + } + }, + articleId: { + allowNull: false, + type: Sequelize.UUID, + onDelete: 'CASCADE', + references: { + model: 'Article', + key: 'id', + as: 'articleId' + } + }, + commentBody: { + allowNull: { + args: false, + msg: 'Please write your comment?' + }, + type: Sequelize.TEXT + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE + } + }), + down: queryInterface => queryInterface.dropTable('Comment') +}; \ No newline at end of file diff --git a/dist/migrations/20190204152543-create-comment-history.js b/dist/migrations/20190204152543-create-comment-history.js new file mode 100644 index 0000000..a3937da --- /dev/null +++ b/dist/migrations/20190204152543-create-comment-history.js @@ -0,0 +1,31 @@ +"use strict"; + +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.createTable('CommentHistory', { + id: { + allowNull: false, + autoIncrement: true, + primaryKey: true, + type: Sequelize.INTEGER + }, + commentId: { + allowNull: false, + type: Sequelize.UUID, + references: { + model: 'Comment', + key: 'id' + } + }, + commentBody: { + allowNull: false, + type: Sequelize.TEXT + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE + } + }); + }, + down: queryInterface => queryInterface.dropTable('CommentHistory') +}; \ No newline at end of file diff --git a/dist/migrations/20190204153144-create-tags.js b/dist/migrations/20190204153144-create-tags.js new file mode 100644 index 0000000..8e2ea3e --- /dev/null +++ b/dist/migrations/20190204153144-create-tags.js @@ -0,0 +1,25 @@ +"use strict"; + +module.exports = { + up: (queryInterface, Sequelize) => queryInterface.createTable('Tag', { + id: { + allowNull: false, + primaryKey: true, + type: Sequelize.UUID, + defaultValue: Sequelize.literal('uuid_generate_v4()') + }, + tagText: { + allowNull: false, + type: Sequelize.TEXT + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE + } + }), + down: queryInterface => queryInterface.dropTable('Tag') +}; \ No newline at end of file diff --git a/dist/migrations/20190204153340-create-tags-article.js b/dist/migrations/20190204153340-create-tags-article.js new file mode 100644 index 0000000..21e0cf7 --- /dev/null +++ b/dist/migrations/20190204153340-create-tags-article.js @@ -0,0 +1,23 @@ +"use strict"; + +module.exports = { + up: (queryInterface, Sequelize) => queryInterface.createTable('TagArticle', { + tagId: { + allowNull: false, + type: Sequelize.UUID, + references: { + model: 'Tag', + key: 'id' + } + }, + articleId: { + allowNull: false, + type: Sequelize.UUID, + references: { + model: 'Article', + key: 'id' + } + } + }), + down: queryInterface => queryInterface.dropTable('TagArticle') +}; \ No newline at end of file diff --git a/dist/migrations/20190204154114-create-like-dislike.js b/dist/migrations/20190204154114-create-like-dislike.js new file mode 100644 index 0000000..d77863f --- /dev/null +++ b/dist/migrations/20190204154114-create-like-dislike.js @@ -0,0 +1,51 @@ +"use strict"; + +module.exports = { + up: (queryInterface, Sequelize) => queryInterface.createTable('LikeDislike', { + id: { + allowNull: false, + primaryKey: true, + type: Sequelize.UUID, + defaultValue: Sequelize.literal('uuid_generate_v4()') + }, + userId: { + allowNull: false, + type: Sequelize.UUID, + onDelete: 'CASCADE', + references: { + model: 'User', + key: 'id', + as: 'userId' + } + }, + articleId: { + allowNull: false, + type: Sequelize.UUID, + onDelete: 'CASCADE', + references: { + model: 'Article', + key: 'id', + as: 'articleId' + } + }, + like: { + allowNull: false, + defaultValue: false, + type: Sequelize.BOOLEAN + }, + dislike: { + allowNull: false, + defaultValue: false, + type: Sequelize.BOOLEAN + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE + } + }), + down: queryInterface => queryInterface.dropTable('LikeDislike') +}; \ No newline at end of file diff --git a/dist/migrations/20190204154705-create-ratings.js b/dist/migrations/20190204154705-create-ratings.js new file mode 100644 index 0000000..a8c876f --- /dev/null +++ b/dist/migrations/20190204154705-create-ratings.js @@ -0,0 +1,35 @@ +"use strict"; + +module.exports = { + up: (queryInterface, Sequelize) => queryInterface.createTable('Rating', { + id: { + allowNull: false, + primaryKey: true, + type: Sequelize.UUID, + defaultValue: Sequelize.literal('uuid_generate_v4()') + }, + userId: { + allowNull: false, + type: Sequelize.UUID, + onDelete: 'CASCADE', + references: { + model: 'User', + key: 'id' + } + }, + articleId: { + allowNull: false, + type: Sequelize.UUID, + onDelete: 'CASCADE', + references: { + model: 'Article', + key: 'id' + } + }, + rating: { + allowNull: false, + type: Sequelize.INTEGER + } + }), + down: queryInterface => queryInterface.dropTable('Rating') +}; \ No newline at end of file diff --git a/dist/migrations/20190211145144-create-highlight-comment.js b/dist/migrations/20190211145144-create-highlight-comment.js new file mode 100644 index 0000000..8c8b3f6 --- /dev/null +++ b/dist/migrations/20190211145144-create-highlight-comment.js @@ -0,0 +1,47 @@ +"use strict"; + +module.exports = { + up: (queryInterface, Sequelize) => queryInterface.createTable('HighlightComment', { + id: { + allowNull: false, + autoIncrement: true, + primaryKey: true, + type: Sequelize.INTEGER + }, + readerId: { + allowNull: false, + type: Sequelize.UUID, + onDelete: 'CASCADE', + references: { + model: 'User', + key: 'id', + as: 'readerId' + } + }, + articleId: { + allowNull: false, + type: Sequelize.UUID, + onDelete: 'CASCADE', + references: { + model: 'Article', + key: 'id', + as: 'articleId' + } + }, + highlight: { + type: Sequelize.TEXT + }, + comment: { + type: Sequelize.TEXT + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE + } + }), + down: queryInterface => queryInterface.dropTable('HighlightComment') +}; \ No newline at end of file diff --git a/dist/models/articles.js b/dist/models/articles.js new file mode 100644 index 0000000..466340f --- /dev/null +++ b/dist/models/articles.js @@ -0,0 +1,63 @@ +"use strict"; + +module.exports = (sequelize, DataTypes) => { + const Article = sequelize.define('Article', { + slug: { + allowNull: false, + type: DataTypes.TEXT + }, + body: { + allowNull: false, + type: DataTypes.TEXT + }, + title: { + allowNull: false, + type: DataTypes.TEXT + }, + description: { + allowNull: false, + type: DataTypes.TEXT + }, + authorId: { + allowNull: false, + type: DataTypes.UUID + }, + references: DataTypes.ARRAY(DataTypes.STRING), + categoryId: { + allowNull: false, + type: DataTypes.INTEGER + } + }); + + Article.associate = models => { + Article.belongsTo(models.User, { + foreignKey: 'authorId' + }); + Article.hasMany(models.Comment, { + foreignKey: 'articleId', + as: 'comments' + }); + Article.hasMany(models.LikeDislike, { + foreignKey: 'articleId', + as: 'LikeDislikes' + }); + Article.hasMany(models.Rating, { + foreignKey: 'articleId', + as: 'articleRatings' + }); + Article.hasMany(models.Bookmark, { + foreignKey: 'articleId', + as: 'articleBookmarks' + }); + Article.belongsToMany(models.Tag, { + foreignKey: 'articleId', + otherKey: 'tagId', + through: 'TagArticle', + timestamps: false, + as: 'tags', + onDelete: 'CASCADE' + }); + }; + + return Article; +}; \ No newline at end of file diff --git a/dist/models/bookmarks.js b/dist/models/bookmarks.js new file mode 100644 index 0000000..c816387 --- /dev/null +++ b/dist/models/bookmarks.js @@ -0,0 +1,9 @@ +"use strict"; + +module.exports = (sequelize, DataTypes) => { + const Bookmark = sequelize.define('Bookmark', { + articleId: DataTypes.UUID, + userId: DataTypes.UUID + }); + return Bookmark; +}; \ No newline at end of file diff --git a/dist/models/category.js b/dist/models/category.js new file mode 100644 index 0000000..d680eed --- /dev/null +++ b/dist/models/category.js @@ -0,0 +1,21 @@ +"use strict"; + +module.exports = (sequelize, DataTypes) => { + const Category = sequelize.define('Category', { + categoryName: { + allowNull: false, + type: DataTypes.TEXT + } + }, { + timestamps: false + }); + + Category.associate = models => { + Category.hasMany(models.Article, { + foreignKey: 'categoryId', + as: 'articles' + }); + }; + + return Category; +}; \ No newline at end of file diff --git a/dist/models/commenthistory.js b/dist/models/commenthistory.js new file mode 100644 index 0000000..f877052 --- /dev/null +++ b/dist/models/commenthistory.js @@ -0,0 +1,11 @@ +"use strict"; + +module.exports = (sequelize, DataTypes) => { + const CommentHistory = sequelize.define('CommentHistory', { + commentId: DataTypes.UUID, + commentBody: DataTypes.TEXT + }, { + updatedAt: false + }); + return CommentHistory; +}; \ No newline at end of file diff --git a/dist/models/comments.js b/dist/models/comments.js new file mode 100644 index 0000000..b6d3da1 --- /dev/null +++ b/dist/models/comments.js @@ -0,0 +1,19 @@ +"use strict"; + +module.exports = (sequelize, DataTypes) => { + const Comment = sequelize.define('Comment', { + commentBody: { + allowNull: false, + type: DataTypes.TEXT + } + }); + + Comment.associate = models => { + Comment.hasMany(models.CommentHistory, { + foreignKey: 'commentId', + as: 'commentHistories' + }); + }; + + return Comment; +}; \ No newline at end of file diff --git a/dist/models/highlightcomment.js b/dist/models/highlightcomment.js new file mode 100644 index 0000000..b6ce017 --- /dev/null +++ b/dist/models/highlightcomment.js @@ -0,0 +1,17 @@ +"use strict"; + +module.exports = (sequelize, DataTypes) => { + const HighlightComment = sequelize.define('HighlightComment', { + highlight: { + allowNull: false, + type: DataTypes.TEXT + }, + comment: { + allowNull: false, + type: DataTypes.TEXT + }, + articleId: DataTypes.UUID, + readerId: DataTypes.UUID + }); + return HighlightComment; +}; \ No newline at end of file diff --git a/dist/models/index.js b/dist/models/index.js new file mode 100644 index 0000000..5c1a8a7 --- /dev/null +++ b/dist/models/index.js @@ -0,0 +1,35 @@ +"use strict"; + +const fs = require('fs'); + +const path = require('path'); + +const Sequelize = require('sequelize'); + +const dotenv = require('dotenv'); + +dotenv.config(); +const basename = path.basename(__filename); +const env = process.env.NODE_ENV || 'development'; + +const config = require('../config/config.js')[env]; + +const db = {}; +const opts = { + define: { + freezeTableName: true + } +}; +const sequelize = new Sequelize(config.url, opts); +fs.readdirSync(__dirname).filter(file => file.indexOf('.') !== 0 && file !== basename && file.slice(-3) === '.js').forEach(file => { + const model = sequelize.import(path.join(__dirname, file)); + db[model.name] = model; +}); +Object.keys(db).forEach(modelName => { + if (db[modelName].associate) { + db[modelName].associate(db); + } +}); +db.sequelize = sequelize; +db.Sequelize = Sequelize; +module.exports = db; \ No newline at end of file diff --git a/dist/models/likedislike.js b/dist/models/likedislike.js new file mode 100644 index 0000000..e6b817e --- /dev/null +++ b/dist/models/likedislike.js @@ -0,0 +1,24 @@ +"use strict"; + +module.exports = (sequelize, DataTypes) => { + const LikeDislike = sequelize.define('LikeDislike', { + like: { + allowNull: false, + defaultValue: false, + type: DataTypes.BOOLEAN + }, + dislike: { + allowNull: false, + defaultValue: false, + type: DataTypes.BOOLEAN + } + }); + + LikeDislike.associate = models => { + LikeDislike.belongsTo(models.Article, { + foreignKey: 'articleId' + }); + }; + + return LikeDislike; +}; \ No newline at end of file diff --git a/dist/models/notifications.js b/dist/models/notifications.js new file mode 100644 index 0000000..e337971 --- /dev/null +++ b/dist/models/notifications.js @@ -0,0 +1,15 @@ +"use strict"; + +module.exports = (sequelize, DataTypes) => { + const Notification = sequelize.define('Notification', { + notificationBody: { + allowNull: false, + type: DataTypes.TEXT + }, + seen: { + allowNull: false, + type: DataTypes.BOOLEAN + } + }); + return Notification; +}; \ No newline at end of file diff --git a/dist/models/ratings.js b/dist/models/ratings.js new file mode 100644 index 0000000..37b7a00 --- /dev/null +++ b/dist/models/ratings.js @@ -0,0 +1,13 @@ +"use strict"; + +module.exports = (sequelize, DataTypes) => { + const Rating = sequelize.define('Rating', { + rating: { + allowNull: false, + type: DataTypes.INTEGER + } + }, { + timestamps: false + }); + return Rating; +}; \ No newline at end of file diff --git a/dist/models/roles.js b/dist/models/roles.js new file mode 100644 index 0000000..f7e4c64 --- /dev/null +++ b/dist/models/roles.js @@ -0,0 +1,20 @@ +"use strict"; + +module.exports = (sequelize, DataTypes) => { + const Roles = sequelize.define('Roles', { + name: { + allowNull: false, + type: DataTypes.TEXT + } + }, { + timestamps: false + }); + + Roles.associate = models => { + Roles.hasMany(models.User, { + foreignKey: 'role' + }); + }; + + return Roles; +}; \ No newline at end of file diff --git a/dist/models/tags.js b/dist/models/tags.js new file mode 100644 index 0000000..f2570a0 --- /dev/null +++ b/dist/models/tags.js @@ -0,0 +1,22 @@ +"use strict"; + +module.exports = (sequelize, DataTypes) => { + const Tag = sequelize.define('Tag', { + tagText: DataTypes.TEXT + }, { + timestamps: false + }); + + Tag.associate = models => { + Tag.belongsToMany(models.Article, { + foreignKey: 'tagId', + otherKey: 'articleId', + through: 'TagArticle', + timestamps: false, + as: 'articles', + onDelete: 'CASCADE' + }); + }; + + return Tag; +}; \ No newline at end of file diff --git a/dist/models/users.js b/dist/models/users.js new file mode 100644 index 0000000..102bea3 --- /dev/null +++ b/dist/models/users.js @@ -0,0 +1,82 @@ +"use strict"; + +var _bcrypt = _interopRequireDefault(require("bcrypt")); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +module.exports = (sequelize, DataTypes) => { + const User = sequelize.define('User', { + firstname: { + allowNull: false, + type: DataTypes.STRING + }, + lastname: { + allowNull: true, + type: DataTypes.STRING + }, + username: { + allowNull: false, + type: DataTypes.STRING + }, + email: { + allowNull: false, + type: DataTypes.STRING + }, + password: { + allowNull: false, + type: DataTypes.STRING + }, + role: { + allowNull: false, + type: DataTypes.STRING, + defaultValue: 'author' + }, + bio: DataTypes.TEXT, + imageUrl: DataTypes.STRING, + isVerified: { + defaultValue: false, + allowNull: false, + type: DataTypes.BOOLEAN + } + }); + User.beforeCreate(async user => { + const salt = await _bcrypt.default.genSaltSync(); + user.password = await _bcrypt.default.hashSync(user.password, salt); + }); + + User.associate = models => { + User.hasMany(models.Article, { + foreignKey: 'authorId', + as: 'userArticles' + }); + User.belongsToMany(models.User, { + foreignKey: 'userId', + otherKey: 'followerId', + through: 'UserFollower', + as: 'followers', + timestamps: false + }); + User.hasMany(models.Notification, { + foreignKey: 'userId', + as: 'userNotifications' + }); + User.hasMany(models.Bookmark, { + foreignKey: 'userId', + as: 'userBookmarks' + }); + User.hasMany(models.Comment, { + foreignKey: 'userId', + as: 'userComments' + }); + User.hasMany(models.LikeDislike, { + foreignKey: 'userId' + }); + User.hasMany(models.Rating, { + foreignKey: 'userId' + }); + }; + + User.passwordMatch = (encodedPassword, password) => _bcrypt.default.compareSync(password, encodedPassword); + + return User; +}; \ No newline at end of file diff --git a/dist/seeders/20190208185301-roles.js b/dist/seeders/20190208185301-roles.js new file mode 100644 index 0000000..af5a162 --- /dev/null +++ b/dist/seeders/20190208185301-roles.js @@ -0,0 +1,14 @@ +"use strict"; + +module.exports = { + up: queryInterface => { + return queryInterface.bulkInsert('Roles', [{ + name: 'admin' + }, { + name: 'author' + }], {}); + }, + down: queryInterface => { + return queryInterface.bulkDelete('Roles', null, {}); + } +}; \ No newline at end of file diff --git a/dist/seeders/20190208185302-demo-user.js b/dist/seeders/20190208185302-demo-user.js new file mode 100644 index 0000000..5c90184 --- /dev/null +++ b/dist/seeders/20190208185302-demo-user.js @@ -0,0 +1,27 @@ +"use strict"; + +module.exports = { + up: queryInterface => { + return queryInterface.bulkInsert('User', [{ + firstname: 'Adanne1', + lastname: 'Egbuna2', + username: 'testuser3', + email: 'princess63@example.com', + password: 'password', + createdAt: new Date(), + updatedAt: new Date() + }], {}).then(() => queryInterface.bulkInsert('User', [{ + firstname: 'Chubi', + lastname: 'Best', + username: 'testuser1', + email: 'chubi.best@example.com', + password: 'password', + role: 'admin', + createdAt: new Date(), + updatedAt: new Date() + }], {})); + }, + down: queryInterface => { + return queryInterface.bulkDelete('User', null, {}); + } +}; \ No newline at end of file diff --git a/dist/seeders/20190210162858-demo-category.js b/dist/seeders/20190210162858-demo-category.js new file mode 100644 index 0000000..d1a26f3 --- /dev/null +++ b/dist/seeders/20190210162858-demo-category.js @@ -0,0 +1,12 @@ +"use strict"; + +module.exports = { + up: queryInterface => { + return queryInterface.bulkInsert('Category', [{ + categoryName: 'tech' + }], {}); + }, + down: queryInterface => { + return queryInterface.bulkDelete('Category', null, {}); + } +}; \ No newline at end of file diff --git a/dist/seeds/article-seeds.js b/dist/seeds/article-seeds.js new file mode 100644 index 0000000..dccd4ef --- /dev/null +++ b/dist/seeds/article-seeds.js @@ -0,0 +1,93 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.seedArticles = void 0; + +var _models = require("../models"); + +// Seed Articles +// Seed Categories +// Seed Ratings +// Seed Tags +const seedArticles = async users => { + const user = users[0]; + const user1 = users[1]; + const user2 = users[2]; + const category = await _models.Category.create({ + categoryName: 'Tech' + }); + const tag1 = await _models.Tag.create({ + tagText: 'andela' + }); + const tag2 = await _models.Tag.create({ + tagText: 'bootcamp' + }); + const tag3 = await _models.Tag.create({ + tagText: 'epic values' + }); + const dummyArticles = [{ + slug: 'introduction-to-writing', + body: `Our original read time calculation was geared toward “slow” images, like comics, where you would really want to sit down and invest in the image. This resulted in articles with crazy big read times. For instance, this article containing 140 images was clocking in at a whopping 87 minute read. So we amended our read time calculation to count 12 seconds for the first image, 11 for the second, and minus an additional second for each subsequent image. Any images after the tenth image are counted at three seconds. + You might see this change reflected across the site. + + + + + Keep in mind that our estimated read time is just that: an estimation. You might finish a story faster or slower depending on various factors such as how many children or cats you have, your caffeine/alcohol intake, or if you’re a time-traveler from the future and already read that story. We just want to give you a ballpark figure so you can decide whether you have time to read one more story before the bus comes, or if you should bookmark it for later.Our original read time calculation was geared toward “slow” images, like comics, where you would really want to sit down and invest in the image. This resulted in articles with crazy big read times. For instance, this article containing 140 images was clocking in at a whopping 87 minute read. So we amended our read time calculation to count 12 seconds for the first image, 11 for the second, and minus an additional second for each subsequent image. Any images after the tenth image are counted at three seconds. + You might see this change reflected across the site. Keep in mind that our estimated read time is just that: an estimation. You might finish a story faster or slower depending on various factors such as how many children or cats you have, your caffeine/alcohol intake, or if you’re a time-traveler from the future and already read that story. We just want to give you a ballpark figure so you can decide whether you have time to read one more story before the bus comes, or if you should bookmark it for later + .`, + description: 'Introduction to writing', + // authorId: user.id, + references: ['reference1.com', 'reference2.com', 'reference3.com'], + categoryId: category.id + }, { + slug: 'health-tips-you-must-know', + body: `Our original read time calculation was geared toward “slow” images, like comics, where you would really want to sit down and invest in the image. This resulted in articles with crazy big read times. For instance, this article containing 140 images was clocking in at a whopping 87 minute read. So we amended our read time calculation to count 12 seconds for the first image, 11 for the second, and minus an additional second for each subsequent image. Any images after the tenth image are counted at three seconds. + You might see this change reflected across the site. Keep in mind that our estimated read time is just that: an estimation. You might finish a story faster or slower depending on various factors such as how many children or cats you have, your caffeine/alcohol intake, or if you’re a time-traveler from the future and already read that story. We just want to give you a ballpark figure so you can decide whether you have time to read one more story before the bus comes, or if you should bookmark it for later.`, + description: 'Health tips you must know', + // authorId: user.id, + references: ['reference1.com', 'reference2.com'], + categoryId: category.id + }]; + const createdArticles = dummyArticles.map(async articleEntry => { + const newArticle = await user.createArticle(articleEntry); + await newArticle.createArticleRating({ + ratings: 4, + userId: user.id, + articleId: newArticle.id + }); + await newArticle.createArticleRating({ + ratings: 4, + userId: user1.id, + articleId: newArticle.id + }); + await newArticle.createArticleRating({ + ratings: 5, + userId: user2.id, + articleId: newArticle.id + }); + await newArticle.addTags([tag1, tag2, tag3]); // last version of the comment + + const newComment = await newArticle.createComment({ + articleId: newArticle.id, + commentBody: 'Article Comment' + }); // 1st version of comment + + await newComment.createCommentHistory({ + commentBody: 'Article comme', + commentId: newComment.id + }); // 2nd version of comment + + await newComment.createCommentHistory({ + commentBody: 'Article commen', + commentId: newComment.id + }); + return newArticle; + }); + const allCreatedArticles = await Promise.all(createdArticles); + console.log(allCreatedArticles.length, '>>>Created Articles'); +}; + +exports.seedArticles = seedArticles; \ No newline at end of file diff --git a/dist/seeds/index.js b/dist/seeds/index.js new file mode 100644 index 0000000..122d5e9 --- /dev/null +++ b/dist/seeds/index.js @@ -0,0 +1,12 @@ +"use strict"; + +var _articleSeeds = require("./article-seeds"); + +var _userSeeds = require("./user-seeds"); + +const seedDatabase = async () => { + const users = await (0, _userSeeds.seedUsers)(); + await (0, _articleSeeds.seedArticles)(users); +}; + +seedDatabase(); \ No newline at end of file diff --git a/dist/seeds/user-seeds.js b/dist/seeds/user-seeds.js new file mode 100644 index 0000000..a260a5d --- /dev/null +++ b/dist/seeds/user-seeds.js @@ -0,0 +1,42 @@ +"use strict"; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.seedUsers = void 0; + +var _models = require("../models"); + +// Seed Users +const seedUsers = async () => { + const usersData = [{ + firstname: 'John', + lastname: 'Doe', + username: 'johndoe', + email: 'johndoe@example.test', + password: 'secretpass', + isVerified: true + }, { + firstname: 'John', + lastname: 'James', + username: 'johnjames', + email: 'johnjames@example.test', + password: 'secretpass', + isVerified: true + }, { + firstname: 'Janeth', + lastname: 'Jack', + username: 'janethjack', + email: 'janethjack@example.test', + password: 'secretpass', + isVerified: true + }]; + let usersCreated = usersData.map(async userData => { + const user = await _models.User.create(userData); + return user; + }); + usersCreated = await Promise.all(usersCreated); + return usersCreated; +}; + +exports.seedUsers = seedUsers; \ No newline at end of file diff --git a/package.json b/package.json index 79b28cf..dc91ac5 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "undo:migration": "./node_modules/.bin/babel-node ./node_modules/.bin/sequelize db:migrate:undo:all", "migration": "npm run undo:migration && npm run migrate", "seed": "node_modules/.bin/sequelize db:seed:all", - "undo:seed": "node_modules/.bin/sequelize db:seed:undo:all" + "undo:seed": "node_modules/.bin/sequelize db:seed:undo:all", + "seed:dev": "export NODE_ENV=development&& babel-node ./server/seeds/index.js --preset-env" }, "author": "Andela Simulations Programme", "license": "MIT", diff --git a/server/api/controllers/article.js b/server/api/controllers/article.js index 1174350..2ef0082 100644 --- a/server/api/controllers/article.js +++ b/server/api/controllers/article.js @@ -56,17 +56,45 @@ const insertTag = async (tagArray) => { }; /** - * @export - * @function createArticle - * @param {Object} req - request received - * @param {Object} res - response object - * @returns {Object} JSON object (JSend format) - */ +* @export +* @function getReadTime +* @param {String} articleBody - article.body +* @returns {String} - Read Time eg '2 minutes' +*/ +export const getReadTime = (articleBody) => { + // Read time is based on the average reading speed of an adult (roughly 275 WPM). + // We take the total word count of a post and translate it into minutes. + // Then, we add 12 seconds for each inline image. + // TIME = DISTANCE / SPEED + const words = articleBody.split(' '); + const wordCount = words.length; + + const readTimeInMinutes = wordCount / 275; + let readTimeInSeconds = readTimeInMinutes * 60; + + const imagesFound = articleBody.split(' { + readTimeInSeconds += 12; // add 12 seconds for each inline image + }); + + let readTime = Math.ceil(readTimeInSeconds / 60); // convert back to minutes + readTime += readTime > 1 ? ' minutes' : ' minute'; + return readTime; +}; + +/** +* @export +* @function createArticle +* @param {Object} req - request received +* @param {Object} res - response object +* @returns {Object} JSON object (JSend format) +*/ export const createArticle = async (req, res) => { try { const { body: { - slug, + title, description, body, references, @@ -76,8 +104,12 @@ export const createArticle = async (req, res) => { id: userId } } = req; - const newArticle = await Article.create({ + // create the slug from the title by replacing spaces with hyphen + // eg. "Introduction to writing" becomes "introduction-to-writing" + const slug = title.replace(/\s+/g, '-').toLowerCase(); + let newArticle = await Article.create({ slug, + title, description, body, references, @@ -94,11 +126,15 @@ export const createArticle = async (req, res) => { }); } + newArticle = newArticle.toJSON(); + newArticle.readTime = getReadTime(newArticle.body); + return res.status(201).send({ status: 'Success', data: newArticle }); } catch (error) { + console.log(error); return res.status(502).send({ status: 'Error', message: 'OOPS! an error occurred while trying to create your article, you do not seem to be logged in or signed up, log in and try again!' @@ -372,7 +408,7 @@ export const getHighlights = async (req, res) => { /** * @export - * @function ArticleRating + * @function rateArticle * @param {Object} req - request received * @param {Object} res - response object * @returns {Object} JSON object (JSend format) @@ -481,3 +517,66 @@ export const rateArticle = async (req, res) => { }); } }; + +/** +* @export +* @function getAllArticles +* @param {Object} req - request received +* @param {Object} res - response object +* @returns {Object} JSON object (JSend format) +*/ +export const getAllArticles = async (req, res) => { + try { + const articles = await Article.findAll(); + + const allArticles = articles.map((article) => { + article = article.toJSON(); + article.readTime = getReadTime(article.body); + return article; + }); + + return res.status(200).send({ + status: 'success', + data: allArticles + }); + } catch (err) { + return res.status(500).send({ + status: 'error', + message: 'Internal server error' + }); + } +}; + +/** +* @export +* @function getArticle +* @param {Object} req - request received +* @param {Object} res - response object +* @returns {Object} JSON object (JSend format) +*/ +export const getArticle = async (req, res) => { + const { params: { id: articleId } } = req; + try { + let foundArticle = await Article.findByPk(articleId); + + if (!foundArticle) { + return res.status(404).send({ + status: 'fail', + message: 'Resource not found' + }); + } + + foundArticle = foundArticle.toJSON(); + foundArticle.readTime = getReadTime(foundArticle.body); + + return res.status(200).send({ + status: 'success', + data: foundArticle + }); + } catch (err) { + return res.status(500).send({ + status: 'error', + message: 'Internal server error' + }); + } +}; diff --git a/server/api/controllers/comments.js b/server/api/controllers/comments.js index daf8b45..fef8add 100644 --- a/server/api/controllers/comments.js +++ b/server/api/controllers/comments.js @@ -1,8 +1,13 @@ -import { Comment } from '../../models'; +import { + Comment, Article, Sequelize +} from '../../models'; -export const postComment = async ({ params: { articleId }, body: { commentBody } }, res) => { +const { Op } = Sequelize; + +export const postComment = async (req, res) => { + const { params: { articleId }, body: { commentBody }, user: { id: userId } } = req; try { - const newComment = await Comment.create({ commentBody, articleId }); + const newComment = await Comment.create({ commentBody, articleId, userId }); return res.status(201).send({ status: 'success', data: newComment }); } catch (error) { return res.status(500).send({ @@ -12,3 +17,49 @@ export const postComment = async ({ params: { articleId }, body: { commentBody } }); } }; + +export const updateComment = async (req, res) => { + const { params: { articleId, commentId }, body: { commentBody } } = req; + try { + const foundArticle = await Article.findByPk(articleId); + if (!foundArticle) { + res.status(404).send({ + status: 'fail', + message: 'Article not found' + }); + } + const foundComments = await foundArticle.getComments({ where: { id: { [Op.eq]: commentId } } }); + const foundComment = foundComments[0]; + + if (!foundComment) { + res.status(404).send({ + status: 'fail', + message: 'Comment not found under this article' + }); + } + // update the comment + const updatedComment = await foundComment.update({ commentBody }, + { returning: true, plain: true }); + + // save the old version of the comment in the comment history + await updatedComment.createCommentHistory({ + commentId: foundComment.id, + commentBody: foundComment.commentBody + }); + + const oldComments = await updatedComment.getCommentHistories(); + const comment = updatedComment.toJSON(); + comment.oldComments = oldComments; + + res.status(200).send({ + status: 'success', + message: 'Comment updated', + data: comment + }); + } catch (error) { + return res.status(500).send({ + status: 'error', + message: 'Error updating comment' + }); + } +}; diff --git a/server/api/controllers/user.js b/server/api/controllers/user.js index aeedc0b..6e1aa61 100644 --- a/server/api/controllers/user.js +++ b/server/api/controllers/user.js @@ -1,9 +1,12 @@ +import dotenv from 'dotenv'; import Sequelize from 'sequelize'; import { createLogger, format, transports } from 'winston'; import { signToken, verifyToken } from '../helpers/tokenization/tokenize'; import { User } from '../../models'; import { sendVerificationMail, resetPasswordVerificationMail } from '../helpers/mailer/mailer'; +dotenv.config(); + const logger = createLogger({ level: 'debug', format: format.simple(), @@ -43,10 +46,12 @@ export const registerUser = async (req, res) => { }); const link = `${req.protocol}://${req.headers.host}/api/users/${createdUser.id}/verify`; - try { - await sendVerificationMail(username, email, link); - } catch (error) { - logger.debug('Email Error::', error); + if (process.env.NODE_ENV === 'production') { + try { + await sendVerificationMail(username, email, link); + } catch (error) { + logger.debug('Email Error::', error); + } } return res.status(201).send({ @@ -273,10 +278,12 @@ export const resetPasswordVerification = async (req, res) => { username } = foundUser; - try { - await resetPasswordVerificationMail(username, foundUser.email, token); - } catch (error) { - logger.debug('Email Error::', error); + if (process.env.NODE_ENV === 'production') { + try { + await resetPasswordVerificationMail(username, foundUser.email, token); + } catch (error) { + logger.debug('Email Error::', error); + } } return res.status(200).send({ diff --git a/server/api/middlewares/validation/comment.js b/server/api/middlewares/validation/comment.js index 5306216..be1f7de 100644 --- a/server/api/middlewares/validation/comment.js +++ b/server/api/middlewares/validation/comment.js @@ -1,11 +1,24 @@ import Joi from 'joi'; -import commentSchema from './schemas/comment'; +import { newCommentSchema, updateCommentSchema } from './schemas/comment'; export const newCommentValidator = ( { params: { articleId }, body: { commentBody } }, res, next ) => { const payload = { articleId, commentBody }; - Joi.validate(payload, commentSchema) + Joi.validate(payload, newCommentSchema) + .then(() => { + next(); + }) + .catch((error) => { + res.status(422).send({ status: 'fail', message: error }); + }); +}; + +export const updateCommentValidator = ( + { params: { articleId, commentId }, body: { commentBody } }, res, next +) => { + const payload = { articleId, commentId, commentBody }; + Joi.validate(payload, updateCommentSchema) .then(() => { next(); }) diff --git a/server/api/middlewares/validation/schemas/article.js b/server/api/middlewares/validation/schemas/article.js index 59704ca..927de9a 100644 --- a/server/api/middlewares/validation/schemas/article.js +++ b/server/api/middlewares/validation/schemas/article.js @@ -1,6 +1,6 @@ import Joi from 'joi'; -const slug = Joi.string().min(5).required(); +const title = Joi.string().min(10).required(); const description = Joi.string().min(10).required(); const body = Joi.string().min(20).required(); const references = Joi.array().items(Joi.string()); @@ -12,7 +12,7 @@ const rating = Joi.number().integer().min(1).max(5) .required(); export const newArticleSchema = { - slug, + title, description, body, references, diff --git a/server/api/middlewares/validation/schemas/comment.js b/server/api/middlewares/validation/schemas/comment.js index d3582c9..32822d4 100644 --- a/server/api/middlewares/validation/schemas/comment.js +++ b/server/api/middlewares/validation/schemas/comment.js @@ -1,7 +1,9 @@ import Joi from 'joi'; const articleId = Joi.string().trim().min(1).required(); +const commentId = Joi.string().trim().min(1).required(); const commentBody = Joi.string().trim().min(1).max(140) .required(); -export default { commentBody, articleId }; +export const newCommentSchema = { commentBody, articleId }; +export const updateCommentSchema = { commentBody, articleId, commentId }; diff --git a/server/api/middlewares/validation/schemas/user.js~HEAD b/server/api/middlewares/validation/schemas/user.js~HEAD deleted file mode 100644 index cb92f31..0000000 --- a/server/api/middlewares/validation/schemas/user.js~HEAD +++ /dev/null @@ -1,31 +0,0 @@ -import Joi from 'joi'; - -const firstname = Joi.string().trim().strict().min(3) - .required(); - -const lastname = Joi.string().trim().strict().min(3) - .required(); - -const username = Joi.string().trim().alphanum().min(3) - .max(30) - .required(); - -const email = Joi.string().trim().strict().min(10) - .max(100) - .email() - .required(); - -const password = Joi.string().trim().strict().alphanum() - .min(8) - .max(40) - .required(); - -const registrationSchema = { - firstname, - lastname, - username, - email, - password, -}; - -export default registrationSchema; diff --git a/server/api/routes/article.js b/server/api/routes/article.js index 4a05dcd..d9d5253 100644 --- a/server/api/routes/article.js +++ b/server/api/routes/article.js @@ -1,9 +1,13 @@ import { Router } from 'express'; import passport from 'passport'; -import { newArticleValidator, createHighlightValidator, ratingValidator } from '../middlewares/validation/article'; import { - createArticle, likeArticle, dislikeArticle, createHighlight, getHighlights, rateArticle + newArticleValidator, createHighlightValidator, ratingValidator +} from '../middlewares/validation/article'; +import { + createArticle, likeArticle, dislikeArticle, createHighlight, getHighlights, rateArticle, + getAllArticles, getArticle } from '../controllers/article'; + import commentsRouter from './comment'; const articlesRouter = Router(); @@ -19,5 +23,7 @@ articlesRouter.get('/:id/highlights', passport.authenticate('jwt', { session: fa articlesRouter.post('/:articleId/ratings', passport.authenticate('jwt', { session: false }), ratingValidator, rateArticle); articlesRouter.use('/:articleId/comments', commentsRouter); +articlesRouter.get('/', getAllArticles); +articlesRouter.get('/:id', getArticle); export default articlesRouter; diff --git a/server/api/routes/comment.js b/server/api/routes/comment.js index be99ca0..d1cd81f 100644 --- a/server/api/routes/comment.js +++ b/server/api/routes/comment.js @@ -1,11 +1,13 @@ import { Router } from 'express'; import passport from 'passport'; import { newCommentValidator } from '../middlewares/validation/comment'; -import { postComment } from '../controllers/comments'; +import { postComment, updateComment } from '../controllers/comments'; const commentsRouter = Router({ mergeParams: true }); commentsRouter.post('/', passport.authenticate('jwt', { session: false }), newCommentValidator, postComment); +commentsRouter.patch('/:commentId', passport.authenticate('jwt', { session: false }), + newCommentValidator, updateComment); export default commentsRouter; diff --git a/server/config/config.js b/server/config/config.js index e55fc66..1954f44 100644 --- a/server/config/config.js +++ b/server/config/config.js @@ -4,7 +4,7 @@ dotenv.config(); module.exports = { development: { - url: process.env.DATABASE_URL, + url: process.env.DATABASE_URL_DEV, dialect: 'postgres', operatorsAliases: false, logging: false diff --git a/server/config/session.js b/server/config/session.js index 56c1b1b..7116ae5 100644 --- a/server/config/session.js +++ b/server/config/session.js @@ -10,7 +10,6 @@ const { const conString = env === 'development' ? DATABASE_URL_DEV : DATABASE_URL; - const sessionManagementConfig = (app) => { session.Session.prototype.login = function (user, cb) { const { req } = this; diff --git a/server/index.js b/server/index.js new file mode 100644 index 0000000..6546575 --- /dev/null +++ b/server/index.js @@ -0,0 +1,51 @@ +import express from 'express'; +import cors from 'cors'; +import dotenv from 'dotenv'; +import chalk from 'chalk'; +import { createLogger, format, transports } from 'winston'; +import sessionManagement from './server/config/session'; + +import auth from './server/api/middlewares/authentication/authenticate'; +import userRoute from './server/api/routes/user'; +import articleRoute from './server/api/routes/article'; + +dotenv.config(); + +const logger = createLogger({ + level: 'debug', + format: format.simple(), + transports: [new transports.Console()] +}); + +const port = process.env.PORT || process.env.LOCAL_PORT; +// Create global app object +const app = express(); + +app.use(cors()); + +// Normal express config defaults +app.use(require('morgan')('dev')); + +app.use(express.urlencoded({ extended: true })); +app.use(express.json()); +sessionManagement(app); + +app.use(express.static(`${__dirname}/public`)); +app.use(auth.initialize()); +app.use('/api/users', userRoute); +app.use('/api/articles', articleRoute); + +app.get('/', (req, res) => res.status(200).send({ + status: 'connection successful', + message: 'Welcome to Author Haven!', +})); + +app.get('*', (req, res) => res.status(200).send({ + status: 'fail', + message: 'Route not found', +})); + +app.listen(5000, function serverListner() { + logger.debug(`Server running on port ${chalk.blue(port)}`); +}); +export default app; diff --git a/server/migrations/20190204145037-create-articles.js b/server/migrations/20190204145037-create-articles.js index c4f29ce..6e11e43 100644 --- a/server/migrations/20190204145037-create-articles.js +++ b/server/migrations/20190204145037-create-articles.js @@ -20,6 +20,13 @@ module.exports = { }, type: Sequelize.TEXT }, + title: { + allowNull: { + args: false, + msg: 'Please give title' + }, + type: Sequelize.TEXT + }, description: { allowNull: { args: false, diff --git a/server/migrations/20190204152533-create-comments.js b/server/migrations/20190204152533-create-comments.js index 224059a..a39ead6 100644 --- a/server/migrations/20190204152533-create-comments.js +++ b/server/migrations/20190204152533-create-comments.js @@ -8,6 +8,15 @@ module.exports = { type: Sequelize.UUID, defaultValue: Sequelize.literal('uuid_generate_v4()'), }, + userId: { + allowNull: false, + type: Sequelize.UUID, + onDelete: 'CASCADE', + references: { + model: 'User', + key: 'id', + }, + }, articleId: { allowNull: false, type: Sequelize.UUID, diff --git a/server/migrations/20190204152543-create-comment-history.js b/server/migrations/20190204152543-create-comment-history.js new file mode 100644 index 0000000..ee02b19 --- /dev/null +++ b/server/migrations/20190204152543-create-comment-history.js @@ -0,0 +1,29 @@ +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.createTable('CommentHistory', { + id: { + allowNull: false, + autoIncrement: true, + primaryKey: true, + type: Sequelize.INTEGER + }, + commentId: { + allowNull: false, + type: Sequelize.UUID, + references: { + model: 'Comment', + key: 'id', + }, + }, + commentBody: { + allowNull: false, + type: Sequelize.TEXT + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE + }, + }); + }, + down: queryInterface => queryInterface.dropTable('CommentHistory') +}; diff --git a/server/migrations/20190204154705-create-ratings.js b/server/migrations/20190204154705-create-ratings.js index f30e138..89e32c8 100644 --- a/server/migrations/20190204154705-create-ratings.js +++ b/server/migrations/20190204154705-create-ratings.js @@ -13,7 +13,6 @@ module.exports = { references: { model: 'User', key: 'id', - as: 'userId' }, }, articleId: { @@ -23,7 +22,6 @@ module.exports = { references: { model: 'Article', key: 'id', - as: 'articleId' }, }, rating: { diff --git a/server/models/articles.js b/server/models/articles.js index 1d7dcdb..7a96d4a 100644 --- a/server/models/articles.js +++ b/server/models/articles.js @@ -8,6 +8,10 @@ module.exports = (sequelize, DataTypes) => { allowNull: false, type: DataTypes.TEXT }, + title: { + allowNull: false, + type: DataTypes.TEXT + }, description: { allowNull: false, type: DataTypes.TEXT @@ -24,7 +28,7 @@ module.exports = (sequelize, DataTypes) => { }); Article.associate = (models) => { Article.belongsTo(models.User, { - foreignKey: 'id' + foreignKey: 'authorId' }); Article.hasMany(models.Comment, { foreignKey: 'articleId', @@ -46,8 +50,9 @@ module.exports = (sequelize, DataTypes) => { foreignKey: 'articleId', otherKey: 'tagId', through: 'TagArticle', - as: 'tags', timestamps: false, + as: 'tags', + onDelete: 'CASCADE' }); }; return Article; diff --git a/server/models/category.js b/server/models/category.js index 026bc84..1af38e6 100644 --- a/server/models/category.js +++ b/server/models/category.js @@ -10,7 +10,8 @@ module.exports = (sequelize, DataTypes) => { }); Category.associate = (models) => { Category.hasMany(models.Article, { - foreignKey: 'categoryId' + foreignKey: 'categoryId', + as: 'articles' }); }; return Category; diff --git a/server/models/commenthistory.js b/server/models/commenthistory.js new file mode 100644 index 0000000..903a88f --- /dev/null +++ b/server/models/commenthistory.js @@ -0,0 +1,9 @@ +module.exports = (sequelize, DataTypes) => { + const CommentHistory = sequelize.define('CommentHistory', { + commentId: DataTypes.UUID, + commentBody: DataTypes.TEXT + }, { + updatedAt: false + }); + return CommentHistory; +}; diff --git a/server/models/comments.js b/server/models/comments.js index fc89467..35ccf8a 100644 --- a/server/models/comments.js +++ b/server/models/comments.js @@ -5,5 +5,11 @@ module.exports = (sequelize, DataTypes) => { type: DataTypes.TEXT }, }); + Comment.associate = (models) => { + Comment.hasMany(models.CommentHistory, { + foreignKey: 'commentId', + as: 'commentHistories' + }); + }; return Comment; }; diff --git a/server/models/ratings.js b/server/models/ratings.js index 6811271..0c05c35 100644 --- a/server/models/ratings.js +++ b/server/models/ratings.js @@ -4,8 +4,7 @@ module.exports = (sequelize, DataTypes) => { allowNull: false, type: DataTypes.INTEGER }, - }, - { + }, { timestamps: false }); return Rating; diff --git a/server/models/tags.js b/server/models/tags.js index 104e2d6..6ace50a 100644 --- a/server/models/tags.js +++ b/server/models/tags.js @@ -1,6 +1,19 @@ module.exports = (sequelize, DataTypes) => { const Tag = sequelize.define('Tag', { tagText: DataTypes.TEXT, + }, { + timestamps: false }); + + Tag.associate = (models) => { + Tag.belongsToMany(models.Article, { + foreignKey: 'tagId', + otherKey: 'articleId', + through: 'TagArticle', + timestamps: false, + as: 'articles', + onDelete: 'CASCADE' + }); + }; return Tag; }; diff --git a/server/seeds/article-seeds.js b/server/seeds/article-seeds.js new file mode 100644 index 0000000..a3764df --- /dev/null +++ b/server/seeds/article-seeds.js @@ -0,0 +1,86 @@ +import { + Category, Tag +} from '../models'; + +// Seed Articles +// Seed Categories +// Seed Ratings +// Seed Tags +export const seedArticles = async (users) => { + const user = users[0]; + const user1 = users[1]; + const user2 = users[2]; + + const category = await Category.create({ categoryName: 'Tech' }); + + const tag1 = await Tag.create({ tagText: 'andela' }); + const tag2 = await Tag.create({ tagText: 'bootcamp' }); + const tag3 = await Tag.create({ tagText: 'epic values' }); + + const dummyArticles = [{ + slug: 'introduction-to-writing', + body: `Our original read time calculation was geared toward “slow” images, like comics, where you would really want to sit down and invest in the image. This resulted in articles with crazy big read times. For instance, this article containing 140 images was clocking in at a whopping 87 minute read. So we amended our read time calculation to count 12 seconds for the first image, 11 for the second, and minus an additional second for each subsequent image. Any images after the tenth image are counted at three seconds. + You might see this change reflected across the site. + + + + + Keep in mind that our estimated read time is just that: an estimation. You might finish a story faster or slower depending on various factors such as how many children or cats you have, your caffeine/alcohol intake, or if you’re a time-traveler from the future and already read that story. We just want to give you a ballpark figure so you can decide whether you have time to read one more story before the bus comes, or if you should bookmark it for later.Our original read time calculation was geared toward “slow” images, like comics, where you would really want to sit down and invest in the image. This resulted in articles with crazy big read times. For instance, this article containing 140 images was clocking in at a whopping 87 minute read. So we amended our read time calculation to count 12 seconds for the first image, 11 for the second, and minus an additional second for each subsequent image. Any images after the tenth image are counted at three seconds. + You might see this change reflected across the site. Keep in mind that our estimated read time is just that: an estimation. You might finish a story faster or slower depending on various factors such as how many children or cats you have, your caffeine/alcohol intake, or if you’re a time-traveler from the future and already read that story. We just want to give you a ballpark figure so you can decide whether you have time to read one more story before the bus comes, or if you should bookmark it for later + .`, + description: 'Introduction to writing', + // authorId: user.id, + references: ['reference1.com', 'reference2.com', 'reference3.com'], + categoryId: category.id, + }, + { + slug: 'health-tips-you-must-know', + body: `Our original read time calculation was geared toward “slow” images, like comics, where you would really want to sit down and invest in the image. This resulted in articles with crazy big read times. For instance, this article containing 140 images was clocking in at a whopping 87 minute read. So we amended our read time calculation to count 12 seconds for the first image, 11 for the second, and minus an additional second for each subsequent image. Any images after the tenth image are counted at three seconds. + You might see this change reflected across the site. Keep in mind that our estimated read time is just that: an estimation. You might finish a story faster or slower depending on various factors such as how many children or cats you have, your caffeine/alcohol intake, or if you’re a time-traveler from the future and already read that story. We just want to give you a ballpark figure so you can decide whether you have time to read one more story before the bus comes, or if you should bookmark it for later.`, + description: 'Health tips you must know', + // authorId: user.id, + references: ['reference1.com', 'reference2.com'], + categoryId: category.id, + }]; + + const createdArticles = dummyArticles.map(async (articleEntry) => { + const newArticle = await user.createArticle(articleEntry); + await newArticle.createArticleRating({ + ratings: 4, + userId: user.id, + articleId: newArticle.id + }); + await newArticle.createArticleRating({ + ratings: 4, + userId: user1.id, + articleId: newArticle.id + }); + await newArticle.createArticleRating({ + ratings: 5, + userId: user2.id, + articleId: newArticle.id + }); + await newArticle.addTags([tag1, tag2, tag3]); + + // last version of the comment + const newComment = await newArticle.createComment({ + articleId: newArticle.id, + commentBody: 'Article Comment', + }); + // 1st version of comment + await newComment.createCommentHistory({ + commentBody: 'Article comme', + commentId: newComment.id + }); + // 2nd version of comment + await newComment.createCommentHistory({ + commentBody: 'Article commen', + commentId: newComment.id + }); + + return newArticle; + }); + + const allCreatedArticles = await Promise.all(createdArticles); + console.log(allCreatedArticles.length, '>>>Created Articles'); +}; diff --git a/server/seeds/index.js b/server/seeds/index.js new file mode 100644 index 0000000..26f313a --- /dev/null +++ b/server/seeds/index.js @@ -0,0 +1,9 @@ +import { seedArticles } from './article-seeds'; +import { seedUsers } from './user-seeds'; + +const seedDatabase = async () => { + const users = await seedUsers(); + await seedArticles(users); +}; + +seedDatabase(); diff --git a/server/seeds/user-seeds.js b/server/seeds/user-seeds.js new file mode 100644 index 0000000..46db2b9 --- /dev/null +++ b/server/seeds/user-seeds.js @@ -0,0 +1,37 @@ +import { User } from '../models'; + +// Seed Users +export const seedUsers = async () => { + const usersData = [{ + firstname: 'John', + lastname: 'Doe', + username: 'johndoe', + email: 'johndoe@example.test', + password: 'secretpass', + isVerified: true, + }, + { + firstname: 'John', + lastname: 'James', + username: 'johnjames', + email: 'johnjames@example.test', + password: 'secretpass', + isVerified: true, + }, + { + firstname: 'Janeth', + lastname: 'Jack', + username: 'janethjack', + email: 'janethjack@example.test', + password: 'secretpass', + isVerified: true, + }]; + + let usersCreated = usersData.map(async (userData) => { + const user = await User.create(userData); + return user; + }); + + usersCreated = await Promise.all(usersCreated); + return usersCreated; +}; diff --git a/tests/article/article.js b/tests/article/article.js index bc9f9ea..f20f857 100644 --- a/tests/article/article.js +++ b/tests/article/article.js @@ -2,7 +2,9 @@ import chai, { expect } from 'chai'; import chaiHttp from 'chai-http'; import app from '../../index'; import models, { sequelize } from '../../server/models'; -import { user1, user2 } from '../mocks/mockUsers'; +import { + user1, user2, user3, user4 +} from '../mocks/mockUsers'; import { mockArticle, invalidArticle, mockHighlight, InvalidHighlight } from '../mocks/mockArticle'; @@ -65,6 +67,7 @@ describe('Tests for article resource', () => { .send(mockArticle); expect(res).to.have.status(201); expect(res.body.status).to.equal('Success'); + expect(res.body.data.readTime).to.not.equal(undefined); }); }); @@ -336,6 +339,7 @@ describe('Tests for article resource', () => { expect(status).to.eql('fail'); }); }); + describe('Tests for Rating Articles', () => { let token; let token2; @@ -457,4 +461,140 @@ describe('Tests for article resource', () => { expect(message).to.eql('OOPS! an error occurred while trying to rate article. log in and try again!'); }); }); + + describe('Tests for get an article', () => { + let articleId; + let userToken; + const fakeArticleId = 'd8725ebc-826b-4262-aa1b-24bdf110a01f'; + const wrongArticleIdDataType = 1; + + before(async () => { + await models.Category.bulkCreate(mockCategory); + + const { body: { data: { link } } } = await chai.request(app) + .post('/api/users') + .send(user3.signUp); + + const { body: { data: { user: { token } } } } = await chai.request(app) + .patch(link.slice(22)); + + userToken = token; + + // create an article + const res = await chai.request(app) + .post('/api/articles') + .set('Authorization', `Bearer ${userToken}`) + .send(mockArticle); + articleId = res.body.data.id; + }); + + it('should return all articles', async () => { + const res = await chai.request(app) + .get('/api/articles'); + expect(res).to.have.status(200); + expect(res.body.status).to.equal('success'); + expect(res.body.data.length > 0).to.equal(true); + }); + + it('should fail to return wrong article', async () => { + const res = await chai.request(app) + .get(`/api/articles/${fakeArticleId}`); + expect(res).to.have.status(404); + expect(res.body.status).to.equal('fail'); + expect(res.body.message).to.equal('Resource not found'); + }); + + it('should give a server error when wrong articleId expression is supplied', async () => { + const res = await chai.request(app) + .get(`/api/articles/${wrongArticleIdDataType}`); + expect(res).to.have.status(500); + expect(res.body.status).to.equal('error'); + expect(res.body.message).to.equal('Internal server error'); + }); + + it('should return a given article', async () => { + const res = await chai.request(app) + .get(`/api/articles/${articleId}`); + expect(res).to.have.status(200); + expect(res.body.status).to.equal('success'); + expect(res.body.data.id).to.equal(articleId); + expect(res.body.data.readTime).to.not.equal(undefined); + }); + }); + + describe('Tests for comment edit history', () => { + let articleId; + let article2Id; + let commentId; + let userToken; + const fakeArticleId = 'd8725ebc-826b-4262-aa1b-24bdf110a01f'; + + before(async () => { + const { body: { data: { link } } } = await chai.request(app) + .post('/api/users') + .send(user4.signUp); + + const { body: { data: { user: { token } } } } = await chai.request(app) + .patch(link.slice(22)); + userToken = token; + + let res = await chai.request(app) + .post('/api/articles') + .set('Authorization', `Bearer ${userToken}`) + .send(mockArticle); + articleId = res.body.data.id; + + res = await chai.request(app) + .post('/api/articles') + .set('Authorization', `Bearer ${userToken}`) + .send(mockArticle); + article2Id = res.body.data.id; + + res = await chai.request(app) + .post(`/api/articles/${articleId}/comments`) + .set('Authorization', `Bearer ${userToken}`) + .send(comment); + commentId = res.body.data.id; + }); + + it('should update a comment', async () => { + const res = await chai.request(app) + .patch(`/api/articles/${articleId}/comments/${commentId}`) + .set('Authorization', `Bearer ${userToken}`) + .send(comment); + expect(res).to.have.status(200); + expect(res.body.status).to.equal('success'); + expect(res.body.data.id).to.equal(commentId); + expect(res.body.data.oldComments.length > 0).to.equal(true); + }); + + it('should fail to update comment when wrong input is supplied', async () => { + const res = await chai.request(app) + .patch(`/api/articles/${articleId}/comments/${commentId}`) + .set('Authorization', `Bearer ${userToken}`) + .send({}); + expect(res).to.have.status(422); + expect(res.body.status).to.equal('fail'); + }); + + it('should fail to update when wrong article is supplied', async () => { + const res = await chai.request(app) + .patch(`/api/articles/${fakeArticleId}/comments/${commentId}`) + .set('Authorization', `Bearer ${userToken}`) + .send(comment); + expect(res).to.have.status(404); + expect(res.body.status).to.equal('fail'); + expect(res.body.message).to.equal('Article not found'); + }); + + it('should update a comment', async () => { + const res = await chai.request(app) + .patch(`/api/articles/${article2Id}/comments/${commentId}`) + .set('Authorization', `Bearer ${userToken}`) + .send(comment); + expect(res).to.have.status(404); + expect(res.body.status).to.equal('fail'); + expect(res.body.message).to.equal('Comment not found under this article'); + }); + }); }); diff --git a/tests/article/articleUnits.js b/tests/article/articleUnits.js new file mode 100644 index 0000000..148e712 --- /dev/null +++ b/tests/article/articleUnits.js @@ -0,0 +1,21 @@ +import { expect } from 'chai'; +import { getReadTime } from '../../server/api/controllers/article'; + +describe('Tests for article read time utility', () => { + const dummyArticleBody = `Our original read time calculation was geared toward “slow” images, like comics, where you would really want to sit down and invest in the image. This resulted in articles with crazy big read times. For instance, this article containing 140 images was clocking in at a whopping 87 minute read. So we amended our read time calculation to count 12 seconds for the first image, 11 for the second, and minus an additional second for each subsequent image. Any images after the tenth image are counted at three seconds. + You might see this change reflected across the site. Keep in mind that our estimated read time is just that: an estimation. You might finish a story faster or slower depending on various factors such as how many children or cats you have, your caffeine/alcohol intake, or if you’re a time-traveler from the future and already read that story. We just want to give you a ballpark figure so you can decide whether you have time to read one more story before the bus comes, or if you should bookmark it for later.`; + const readTime = getReadTime(dummyArticleBody); + + const dummyArticleBodyWithImages = `Our original read time calculation was geared toward “slow” images, like comics, where you would really want to sit down and invest in the image. This resulted in articles with crazy big read times. For instance, this article containing 140 images was clocking in at a whopping 87 minute read. So we amended our read time calculation to count 12 seconds for the first image, 11 for the second, and minus an additional second for each subsequent image. Any images after the tenth image are counted at three seconds. + You might see this change reflected across the site. + + + + + Keep in mind that our estimated read time is just that: an estimation. You might finish a story faster or slower depending on various factors such as how many children or cats you have, your caffeine/alcohol intake, or if you’re a time-traveler from the future and already read that story. We just want to give you a ballpark figure so you can decide whether you have time to read one more story before the bus comes, or if you should bookmark it for later.Our original read time calculation was geared toward “slow” images, like comics, where you would really want to sit down and invest in the image. This resulted in articles with crazy big read times. For instance, this article containing 140 images was clocking in at a whopping 87 minute read. So we amended our read time calculation to count 12 seconds for the first image, 11 for the second, and minus an additional second for each subsequent image. Any images after the tenth image are counted at three seconds. + You might see this change reflected across the site. Keep in mind that our estimated read time is just that: an estimation. You might finish a story faster or slower depending on various factors such as how many children or cats you have, your caffeine/alcohol intake, or if you’re a time-traveler from the future and already read that story. We just want to give you a ballpark figure so you can decide whether you have time to read one more story before the bus comes, or if you should bookmark it for later + .`; + const readTime2 = getReadTime(dummyArticleBodyWithImages); + expect(readTime).to.not.equal(undefined); + expect(readTime2).to.equal('3 minutes'); +}); diff --git a/tests/article/index.js b/tests/article/index.js index 2c0185d..875d0fa 100644 --- a/tests/article/index.js +++ b/tests/article/index.js @@ -1 +1,2 @@ import './article'; +import './articleUnits'; diff --git a/tests/mocks/mockArticle.js b/tests/mocks/mockArticle.js index 1c78eb3..04601d4 100644 --- a/tests/mocks/mockArticle.js +++ b/tests/mocks/mockArticle.js @@ -1,13 +1,16 @@ export const mockArticle = { - slug: 'The girl named Princess', + title: 'The girl named Princess', description: 'The girl named Princess fell from the skies and just disappeared', body: 'When Princess was a little girl, she liked to read books and ...', references: ['princess.com', 'example.com'], categoryId: 1 }; +export const mockComment = { + commentBody: 'I comment by reserve' +}; + export const invalidArticle = { - slug: 'The girl named Princess', description: 'The girl named Princess fell from the skies and just disappeared', references: ['princess.com', 'example.com'], };