-
Notifications
You must be signed in to change notification settings - Fork 7
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add the 'tag articles feature' Tasks to be accomplished - Implement article Tagging - Add article search by tag - Add orpahn and article tag modification - Add tag deletion - Add creation of individual tags - Add fetching all tags of a given article - Add deletion of a specific tag of a specific article How to manually test On postman, send the following requests using the appropriate base url as http://localhost:port/api/v1 for local testing and https://https://codepirates-ah-backend.herokuapp.com/api/v1 for remote testing - Tag creation: post/baseUrl/api/v1/tags request with a body {name: Your tag name} - Tagging an article: post/baseUrl/articles/:articleId/:your-tag-name - Updating a tag: patch/base-url/tags/:old tag name with a body {update: new tag name} - Updating a specific tag of an article: patch/articles/:articleId/:tagName with a body {name: tagName} - Getting all articles with a given tag: get/tags/:tagName/articles - Getting all tags of a given article: get/articles/:articleId/tags - Getting all tags: get/tags - Deleting a tag: delete/tags/:tagName - Deleting a specific tag of a specific article delete/articles/:articleId/:tagName PT story: [#167313411](https://www.pivotaltracker.com/story/show/167313411)
- Loading branch information
Showing
19 changed files
with
661 additions
and
3 deletions.
There are no files selected for viewing
This file was deleted.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,211 @@ | ||
/* eslint-disable require-jsdoc */ | ||
// import isEmpty from 'utils'; | ||
import models from '../models'; | ||
import Util from '../helpers/util'; | ||
|
||
const util = new Util(); | ||
const internalError = () => { | ||
util.setError(500, 'Internal Error'); | ||
return util; | ||
}; | ||
|
||
const notFound = (msg) => { | ||
util.setError(404, `${msg} not found`); | ||
return util; | ||
}; | ||
|
||
const { | ||
Tag, Article, ArticleTag | ||
} = models; | ||
|
||
class TagController { | ||
static async createTag(req, res) { | ||
// create orphan tag | ||
const { name } = req.body; | ||
Tag.findOrCreate({ | ||
where: { name } | ||
}).then((tag) => { | ||
if (tag[1]) { | ||
return res.status(201).json({ msg: `tag ${name} created`, data: tag[0] }); | ||
} | ||
res.status(200).json({ msg: `tag ${name} exists` }); | ||
}).catch(() => { | ||
internalError.send(res); | ||
}); | ||
} | ||
|
||
static async createArticleTag(req, res) { | ||
const article = await Article.findOne({ | ||
where: { id: req.params.articleId }, | ||
}); | ||
const { name } = req.body; | ||
Tag.findOrCreate({ | ||
where: { name }, | ||
raw: true | ||
}).then((tag) => { | ||
if (tag[0]) { | ||
article.addTag(tag[0]); | ||
return res.status(201).json({ msg: `tag ${name} added to article`, data: article }); | ||
} | ||
}).catch(() => internalError().send(res)); | ||
} | ||
|
||
static async editTag(req, res) { | ||
// changes the name of a given tag if new name still unique or merges tags | ||
// with all its relationships if new name exists. | ||
// must confirm merger from user | ||
const { name } = req.params; | ||
const newName = req.body.update; | ||
Tag.update({ name: newName }, { | ||
where: { name }, | ||
returning: true, | ||
}).then((updated) => { | ||
if (updated[0] !== 0) { | ||
return res.status(200).json({ | ||
msg: `tag ${name} updated to ${newName}`, data: updated[1] | ||
}); | ||
} | ||
notFound(`tag ${name}`).send(res); | ||
}).catch(() => internalError().send(res)); | ||
} | ||
|
||
static async editArticleTag(req, res) { | ||
const newTag = await Tag.findOrCreate({ | ||
where: { name: req.body.name } | ||
}); | ||
const oldTag = await Tag.findOne({ | ||
where: { name: req.params.tagName } | ||
}); | ||
ArticleTag.update( | ||
{ tagId: newTag.id, articleId: req.params.articleId }, | ||
{ | ||
where: { | ||
tagId: oldTag.id, articleId: req.params.articleId | ||
}, | ||
returning: true, | ||
raw: true | ||
} | ||
).then((updated) => { | ||
if (updated[0] !== 0) { | ||
res.status(200).json({ msg: 'update successful' }); | ||
} | ||
notFound(oldTag.name).send(res); | ||
}) | ||
.catch(() => internalError()); | ||
} | ||
|
||
static async getTags(req, res) { | ||
// gets and returns an array of objects having tags and the number of | ||
// articles for each tags | ||
Tag.findAll() | ||
.then((tags) => { | ||
if (tags.length === 0) { | ||
notFound('tags').send(res);// not tested | ||
} | ||
// reduce tags list to give article numbers only | ||
res.status(200).json({ tags }); | ||
}).catch(() => internalError().send(res)); | ||
} | ||
|
||
static getTag(req, res) { | ||
// gets and returns a given tag and the number of articles with this tag | ||
Tag.findOne({ | ||
where: { name: req.params.name }, | ||
include: ['articles'] | ||
}).then((tag) => { | ||
if (tag) { | ||
return res.status(200).json({ | ||
tag: { | ||
id: tag.id, name: tag.name, articleCount: tag.articles.length | ||
} | ||
}); | ||
} | ||
notFound('tag').send(res); | ||
}).catch(() => internalError().send(res)); | ||
} | ||
|
||
static getTagArticles(req, res) { | ||
// gets an array of all articles with the given tag | ||
Tag.findAll({ | ||
// alternatively use tag.getArticles() | ||
where: { name: req.params.name }, | ||
include: ['articles'], | ||
}).then((articles) => { | ||
if (articles[0].articles.length === 0) { | ||
return notFound(`articles about ${req.params.name}`).send(res); | ||
} | ||
res.status(200).json({ | ||
articles: articles.map(element => element.articles) | ||
}); | ||
// res.status(404).json({ msg: `no articles about ${req.params.name}` }); | ||
notFound(`articles about ${req.params.name}`).send(res); | ||
}).catch(() => internalError().send(res)); | ||
} | ||
|
||
static async getArticleTags(req, res) { | ||
// returns an array of all tags for a given article | ||
Article.findOne({ | ||
where: { id: req.params.articleId }, | ||
include: ['tags'] | ||
}).then((article) => { | ||
if (!article) { | ||
return res.status(404).json({ error: 'article not found' }); // untested | ||
} | ||
if (article.tags.length === 0) { | ||
return res.status(404).json({ msg: 'article not tagged' }); | ||
} | ||
res.status(200).json({ tags: article.tags.map(tag => tag.name) }); | ||
}).catch(() => internalError().send(res)); | ||
} | ||
|
||
static async deleteArticleTag(req, res) { | ||
// deletes a given tag of a given article technically the tag and article | ||
// remain undeleted, only the association in the ArticleTags table is | ||
// deleted | ||
ArticleTag.destroy({ | ||
// alternatively, use article.removeTag(tag) | ||
where: { articleId: req.params.articleId, tagId: req.params.tagId } | ||
}).then((deleted) => { | ||
if (deleted) { | ||
return res.status(200).json({ | ||
message: `tag with Id ${req.params.tagId} removed from article` | ||
}); | ||
} | ||
res.status(404).json({ | ||
message: `this article has no tag ${req.params.tagId}` | ||
}); | ||
}).catch(() => internalError().send(res)); | ||
} | ||
|
||
static async deleteTagArticles(req, res) { | ||
// deletes all articles about a particular item/tag | ||
Tag.findOne({ | ||
where: { name: req.params.name }, | ||
include: ['articles'] | ||
}).then((tag) => { | ||
const failed = []; | ||
if (tag.articles.length === 0) { | ||
return res.status(404).json({ | ||
error: `no articles about ${req.params.name}` | ||
}); | ||
} | ||
tag.articles.forEach((article) => { | ||
article.destroy((success) => { | ||
if (success !== {}) { | ||
failed.push(article.id); | ||
} | ||
}); | ||
}); | ||
if (failed.length !== 0) { | ||
return res.status(500).json({ | ||
error: `articles with Ids ${failed.join(', ')} not deleted` | ||
}); | ||
} | ||
res.status(200).json({ | ||
msg: `all articles about ${req.params.name} deleted` | ||
}); | ||
}).catch(() => internalError().send(res)); | ||
} | ||
} | ||
|
||
export default TagController; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
import isAlnum from './is.alphanumeric'; | ||
|
||
const validateReq = (req, res, next) => { | ||
if (req.params.articleId && !/^\d+$/.test(req.params.articleId)) { | ||
res.status(400).json({ error: 'articleId must be an integer' }); | ||
} else if (req.params.name && !isAlnum(req.params.name)) { | ||
res.status(400).json({ error: 'tagName invalid' }); | ||
} else if (req.body.name && !isAlnum(req.body.name)) { | ||
res.status(400).json({ error: 'tagName invalid' }); | ||
} else { next(); } | ||
}; | ||
|
||
export default validateReq; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,28 @@ | ||
'use strict'; | ||
module.exports = { | ||
up: (queryInterface, Sequelize) => { | ||
return queryInterface.createTable('Tags', { | ||
id: { | ||
allowNull: false, | ||
autoIncrement: true, | ||
primaryKey: true, | ||
type: Sequelize.INTEGER | ||
}, | ||
name: { | ||
type: Sequelize.STRING, | ||
primaryKey: true | ||
}, | ||
createdAt: { | ||
allowNull: false, | ||
type: Sequelize.DATE | ||
}, | ||
updatedAt: { | ||
allowNull: false, | ||
type: Sequelize.DATE | ||
} | ||
}); | ||
}, | ||
down: (queryInterface, Sequelize) => { | ||
return queryInterface.dropTable('Tags'); | ||
} | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,24 @@ | ||
'use strict'; | ||
module.exports = { | ||
up: (queryInterface, Sequelize) => { | ||
return queryInterface.createTable('ArticleTags', { | ||
articleId: { | ||
type: Sequelize.INTEGER | ||
}, | ||
tagId: { | ||
type: Sequelize.INTEGER | ||
}, | ||
createdAt: { | ||
allowNull: false, | ||
type: Sequelize.DATE | ||
}, | ||
updatedAt: { | ||
allowNull: false, | ||
type: Sequelize.DATE | ||
} | ||
}); | ||
}, | ||
down: (queryInterface, Sequelize) => { | ||
return queryInterface.dropTable('ArticleTags'); | ||
} | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,12 @@ | ||
'use strict'; | ||
module.exports = (sequelize, DataTypes) => { | ||
const ArticleTag = sequelize.define('ArticleTag', { | ||
articleId: DataTypes.INTEGER, | ||
tagId: DataTypes.INTEGER | ||
}, {}); | ||
ArticleTag.associate = function (models) { | ||
ArticleTag.belongsTo(models.Article, { foreignKey: 'articleId' }) | ||
ArticleTag.belongsTo(models.Tag, { foreignKey: 'tagId' }) | ||
}; | ||
return ArticleTag; | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
module.exports = (sequelize, DataTypes) => { | ||
const Tag = sequelize.define('Tag', { | ||
name: DataTypes.STRING, | ||
}, {}); | ||
Tag.associate = function (models) { | ||
Tag.belongsToMany(models.Article, { | ||
through: 'ArticleTags', | ||
foreignKey: 'tagId', | ||
as: 'articles' | ||
}) | ||
}; | ||
return Tag; | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,16 @@ | ||
import express from 'express'; | ||
import TagController from '../../../controllers/tag'; | ||
import auth from '../../../middlewares/auth'; | ||
import validateReq from '../../../middlewares/validators/validateTags'; | ||
|
||
|
||
const router = express.Router(); | ||
|
||
router.post('/tags', auth, TagController.createTag); | ||
router.get('/tags', TagController.getTags); | ||
router.get('/tags/:name', TagController.getTag); | ||
router.patch('/tags/:name', auth, TagController.editTag); | ||
router.get('/tags/:name/articles', validateReq, TagController.getTagArticles); | ||
router.delete('/tags/:name/articles', auth, TagController.deleteTagArticles); | ||
|
||
export default router; |
Oops, something went wrong.