diff --git a/app.js b/app.js index 839067a..34e0bcd 100644 --- a/app.js +++ b/app.js @@ -75,6 +75,9 @@ app.use('/ideas', require('./routes/ideas')); app.use('/ideas', require('./routes/votes')); app.use('/comments', require('./routes/votes')); +// care about ideas +app.use('/ideas', require('./routes/cares')); + // following are route factories // they need to know what is the primary object (i.e. idea, comment, etc.) app.use('/ideas', require('./routes/primary-comments')('idea')); diff --git a/collections.js b/collections.js index b460560..3fc2ccf 100644 --- a/collections.js +++ b/collections.js @@ -130,6 +130,19 @@ module.exports = { unique: true } ] + }, + + cares: { + type: 'edge', + from: ['users'], + to: ['ideas'], + indexes: [ + { + type: 'hash', + fields: ['_from', '_to'], + unique: true + } + ] } }; diff --git a/controllers/cares.js b/controllers/cares.js new file mode 100644 index 0000000..6e2aa8c --- /dev/null +++ b/controllers/cares.js @@ -0,0 +1,86 @@ +const path = require('path'), + models = require(path.resolve('./models')), + serializers = require(path.resolve('./serializers')); + +/** + * Middleware to add a care to idea. + */ +async function post(req, res, next) { + // read data from request + const { id } = req.params; + const { username } = req.auth; + + const primarys = req.baseUrl.substring(1); + const primary = primarys.slice(0, -1); + + try { + const care = await models.care.create({ from: username, to: { type: primarys, id } }); + + const serializedCare = serializers.serialize.care(care); + return res.status(201).json(serializedCare); + } catch (e) { + // handle errors + switch (e.code) { + // duplicate care + case 409: { + return res.status(409).json({ + errors: [{ + status: 409, + detail: 'duplicate care' + }] + }); + } + // missing idea + case 404: { + return res.status(404).json({ + errors: [{ + status: 404, + detail: `${primary} doesn't exist` + }] + }); + } + default: { + return next(e); + } + } + } +} + +/** + * Middleware to DELETE a care from an idea (and other objects in the future). + */ +async function del(req, res, next) { + + // read data from request + const { id } = req.params; + const { username } = req.auth; + + // what is the type of the object we care for (i.e. ideas, comments, ...) + const primarys = req.baseUrl.substring(1); + const primary = primarys.slice(0, -1); + + try { + // remove the care from database + await models.care.remove({ from: username, to: { type: primarys, id } }); + // respond + return res.status(204).end(); + } catch (e) { + // handle errors + switch (e.code) { + // primary object or care doesn't exist + case 404: { + return res.status(404).json({ + errors: [{ + status: 404, + detail: `care or ${primary} doesn't exist` + }] + }); + } + default: { + return next(e); + } + } + } +} + +module.exports = { del, post }; diff --git a/controllers/validators/cares.js b/controllers/validators/cares.js new file mode 100644 index 0000000..9c1749d --- /dev/null +++ b/controllers/validators/cares.js @@ -0,0 +1,8 @@ +'use strict'; + +const validate = require('./validate-by-schema'); + +const del = validate('deleteCare'); +const post = validate('postCares'); + +module.exports = { del, post }; diff --git a/controllers/validators/index.js b/controllers/validators/index.js index 1aa179e..eb05a12 100644 --- a/controllers/validators/index.js +++ b/controllers/validators/index.js @@ -2,6 +2,7 @@ exports.authenticate = require('./authenticate'); exports.avatar = require('./avatar'); +exports.cares = require('./cares'); exports.comments = require('./comments'); exports.contacts = require('./contacts'); exports.messages = require('./messages'); diff --git a/controllers/validators/schema/cares.js b/controllers/validators/schema/cares.js new file mode 100644 index 0000000..f44df28 --- /dev/null +++ b/controllers/validators/schema/cares.js @@ -0,0 +1,30 @@ +'use strict'; + +const { id } = require('./paths'); + +const postCares = { + properties: { + params: { + properties: { id }, + required: ['id'], + additionalProperties: false + }, + body: { + additionalProperties: false + }, + required: ['body', 'params'] + } +}; + +const deleteCare = { + properties: { + params: { + properties: { id }, + required: ['id'], + additionalProperties: false + }, + required: ['params'] + } +}; + +module.exports = { deleteCare, postCares }; diff --git a/controllers/validators/schema/index.js b/controllers/validators/schema/index.js index a681495..c80bc4a 100644 --- a/controllers/validators/schema/index.js +++ b/controllers/validators/schema/index.js @@ -3,6 +3,7 @@ const account = require('./account'), authenticate = require('./authenticate'), avatar = require('./avatar'), + cares = require('./cares'), comments = require('./comments'), contacts = require('./contacts'), definitions = require('./definitions'), @@ -16,5 +17,5 @@ const account = require('./account'), votes = require('./votes'); -module.exports = Object.assign({ definitions }, account, authenticate, avatar, +module.exports = Object.assign({ definitions }, account, authenticate, avatar, cares, comments, contacts, ideas, ideaTags, messages, params, tags, users, userTags, votes); diff --git a/models/care/index.js b/models/care/index.js new file mode 100644 index 0000000..1fd297e --- /dev/null +++ b/models/care/index.js @@ -0,0 +1,94 @@ +'use strict'; + +const path = require('path'); + +const Model = require(path.resolve('./models/model')), + schema = require('./schema'); + +class Watch extends Model { + + /** + * Create a care. + * @param {string} from - username of the care giver + * @param {object} to - receiver object of the care + * @param {string} to.type - type of the receiver (collection name, i.e. 'ideas') + * @param {string} to.id - id of the receiver + * @returns Promise - the saved care + */ + static async create({ from: username, to: { type, id } }) { + // generate the care + const care = schema({ }); + + const query = ` + FOR u IN users FILTER u.username == @username + FOR i IN @@type FILTER i._key == @id + LET care = MERGE(@care, { _from: u._id, _to: i._id }) + INSERT care INTO cares + + LET from = MERGE(KEEP(u, 'username'), u.profile) + LET to = MERGE(KEEP(i, 'title', 'detail', 'created'), { id: i._key }) + LET savedWatch = MERGE(KEEP(NEW, 'created'), { id: NEW._key }, { from }, { to }) + RETURN savedWatch`; + const params = { username, '@type': type, id, care }; + const cursor = await this.db.query(query, params); + const out = await cursor.all(); + + // when nothing was created, throw error + if (out.length === 0) { + const e = new Error('not found'); + e.code = 404; + throw e; + } + + // what is the type of the object the care was given to (important for serialization) + out[0].to.type = type; + + return out[0]; + } + + /** + * Read a care from user to idea or something. + * @param {string} from - username of the care giver + * @param {object} to - receiver object of the care + * @param {string} to.type - type of the receiver (collection name, i.e. 'ideas') + * @param {string} to.id - id of the receiver + * @returns Promise - the found care or undefined + */ + static async read({ from: username, to: { type, id } }) { + const query = ` + FOR u IN users FILTER u.username == @username + FOR i IN @@type FILTER i._key == @id + FOR w IN cares FILTER w._from == u._id && w._to == i._id + RETURN w`; + const params = { username, '@type': type, id }; + const cursor = await this.db.query(query, params); + + return (await cursor.all())[0]; + } + + /** + * Remove a care. + * @param {string} from - username of the care giver + * @param {object} to - receiver object of the care + * @param {string} to.type - type of the receiver (collection name, i.e. 'ideas') + * @param {string} to.id - id of the receiver + * @returns Promise + */ + static async remove({ from: username, to: { type, id } }) { + const query = ` + FOR u IN users FILTER u.username == @username + FOR i IN @@type FILTER i._key == @id + FOR v IN cares FILTER v._from == u._id AND v._to == i._id + REMOVE v IN cares`; + const params = { username, '@type': type, id }; + const cursor = await this.db.query(query, params); + + if (cursor.extra.stats.writesExecuted === 0) { + const e = new Error('primary or care not found'); + e.code = 404; + throw e; + } + } +} + +module.exports = Watch; diff --git a/models/care/schema.js b/models/care/schema.js new file mode 100644 index 0000000..d963502 --- /dev/null +++ b/models/care/schema.js @@ -0,0 +1,5 @@ +'use strict'; + +module.exports = function ({ created = Date.now() }) { + return { created }; +}; diff --git a/models/index.js b/models/index.js index 58013cc..97e269a 100644 --- a/models/index.js +++ b/models/index.js @@ -1,6 +1,7 @@ 'use strict'; -const comment = require('./comment'), +const care = require('./care'), + comment = require('./comment'), contact = require('./contact'), idea = require('./idea'), ideaTag = require('./idea-tag'), @@ -22,4 +23,4 @@ const models = { } }; -module.exports = Object.assign(models, { comment, contact, idea, ideaTag, message, model, tag, user, userTag, vote }); +module.exports = Object.assign(models, { care, comment, contact, idea, ideaTag, message, model, tag, user, userTag, vote }); diff --git a/routes/cares.js b/routes/cares.js new file mode 100644 index 0000000..d0951b1 --- /dev/null +++ b/routes/cares.js @@ -0,0 +1,18 @@ +'use strict'; + +const express = require('express'), + path = require('path'); + +const authorize = require(path.resolve('./controllers/authorize')), + careControllers = require(path.resolve('./controllers/cares')), + careValidators = require(path.resolve('./controllers/validators/cares')); + +const router = express.Router(); + +router.route('/:id/cares') + .post(authorize.onlyLogged, careValidators.post, careControllers.post); + +router.route('/:id/cares/care') + .delete(authorize.onlyLogged, careValidators.del, careControllers.del); + +module.exports = router; diff --git a/serializers/cares.js b/serializers/cares.js new file mode 100644 index 0000000..8892fd2 --- /dev/null +++ b/serializers/cares.js @@ -0,0 +1,27 @@ +'use strict'; + +const Serializer = require('jsonapi-serializer').Serializer; + +const voteSerializer = new Serializer('cares', { + id: 'id', + attributes: ['created', 'from', 'to'], + keyForAttribute: 'camelCase', + typeForAttribute(attribute, doc) { + if (attribute === 'from') return 'users'; + if (attribute === 'to') return doc.type; + }, + from: { + ref: 'username', + type: 'users' + }, + to: { + ref: 'id', + type: 'ideas' + } +}); + +function care(data) { + return voteSerializer.serialize(data); +} + +module.exports = { care }; diff --git a/serializers/index.js b/serializers/index.js index dc8fdd8..3bc24ef 100644 --- a/serializers/index.js +++ b/serializers/index.js @@ -2,7 +2,8 @@ const Deserializer = require('jsonapi-serializer').Deserializer; -const comments = require('./comments'), +const cares = require('./cares'), + comments = require('./comments'), contacts = require('./contacts'), ideas = require('./ideas'), ideaTags = require('./idea-tags'), @@ -44,6 +45,6 @@ function deserialize(req, res, next) { } module.exports = { - serialize: Object.assign({ }, comments, contacts, ideas, ideaTags, messages, tags, users, votes), + serialize: Object.assign({ }, cares, comments, contacts, ideas, ideaTags, messages, tags, users, votes), deserialize }; diff --git a/serializers/votes.js b/serializers/votes.js index 776350a..139dcf8 100644 --- a/serializers/votes.js +++ b/serializers/votes.js @@ -4,7 +4,7 @@ const Serializer = require('jsonapi-serializer').Serializer; const voteSerializer = new Serializer('votes', { id: 'id', - attributes: ['title', 'detail', 'created', 'value', 'from', 'to'], + attributes: ['created', 'value', 'from', 'to'], keyForAttribute: 'camelCase', typeForAttribute(attribute, doc) { if (attribute === 'from') return 'users'; diff --git a/test/cares.js b/test/cares.js new file mode 100644 index 0000000..f8302e1 --- /dev/null +++ b/test/cares.js @@ -0,0 +1,202 @@ +'use strict'; + +const path = require('path'), + should = require('should'); + +const agentFactory = require('./agent'), + dbHandle = require('./handle-database'), + models = require(path.resolve('./models')); + +describe('Expressing interest in ideas (caring about ideas).', () => { + let agent, + dbData, + loggedUser; + + afterEach(async () => { + await dbHandle.clear(); + }); + + beforeEach(() => { + agent = agentFactory(); + }); + + describe('Start caring about idea `POST /ideas/:id/cares`', () => { + + let idea0, + requestBody; + + beforeEach(async () => { + const data = { + users: 1, // how many users to make + verifiedUsers: [0], // which users to make verified + ideas: [[{}, 0]] + }; + // create data in database + dbData = await dbHandle.fill(data); + + loggedUser = dbData.users[0]; + idea0 = dbData.ideas[0]; + }); + + beforeEach(() => { + requestBody = { + data: { + type: 'cares' + } + }; + }); + + context('logged in', () => { + + beforeEach(() => { + agent = agentFactory.logged(loggedUser); + }); + + context('valid request', () => { + + it('Success 201', async () => { + const response = await agent.post(`/ideas/${idea0.id}/cares`) + .send(requestBody) + .expect(201); + + // saved into database? + const dbCare = await models.care.read({ from: loggedUser.username, to: { type: 'ideas', id: idea0.id } }); + should(dbCare).ok(); + + // correct response? + should(response.body).match({ + data: { + type: 'cares', + relationships: { + from: { data: { type: 'users', id: loggedUser.username } }, + to: { data: { type: 'ideas', id: idea0.id } } + } + } + }); + }); + + it('Duplicate 409', async () => { + await agent.post(`/ideas/${idea0.id}/cares`) + .send(requestBody) + .expect(201); + + await agent.post(`/ideas/${idea0.id}/cares`) + .send(requestBody) + .expect(409); + }); + + it('Nonexistent idea 404', async () => { + await agent.post('/ideas/11111111/cares') + .send(requestBody) + .expect(404); + }); + }); + + context('invalid request', () => { + it('[invalid idea id] 400', async () => { + await agent.post('/ideas/invalid--id/cares') + .send(requestBody) + .expect(400); + }); + }); + }); + + context('not logged in', () => { + it('403', async () => { + await agent.post(`/ideas/${idea0.id}/cares`) + .send(requestBody) + .expect(403); + }); + }); + }); + + describe('Stop caring about idea `DELETE /ideas/:id/cares/care`', () => { + + let idea0; + + beforeEach(async () => { + const data = { + users: 2, // how many users to make + verifiedUsers: [0, 1], // which users to make verified + ideas: [[{}, 0], [{}, 0]], + cares: [ + [0, ['ideas', 0]], + [0, ['ideas', 1]], + [1, ['ideas', 0]] + ] + }; + // create data in database + dbData = await dbHandle.fill(data); + + loggedUser = dbData.users[0]; + idea0 = dbData.ideas[0]; + }); + + context('logged', () => { + + beforeEach(() => { + agent = agentFactory.logged(loggedUser); + }); + + context('valid', () => { + + it('[care exists] 204 and remove from database', async () => { + + // first care should exist + const dbCareBefore = await models.care.read({ from: loggedUser.username, to: { type: 'ideas', id: idea0.id } }); + should(dbCareBefore).ok(); + + await agent.delete(`/ideas/${idea0.id}/cares/care`) + .expect(204); + + // then care shouldn't exist + const dbCareAfter = await models.care.read({ from: loggedUser.username, to: { type: 'ideas', id: idea0.id } }); + should(dbCareAfter).not.ok(); + }); + + it('[care doesn\'t exist] 404', async () => { + await agent.delete(`/ideas/${idea0.id}/cares/care`) + .expect(204); + + await agent.delete(`/ideas/${idea0.id}/cares/care`) + .expect(404); + }); + + it('[idea doesn\'t exist] 404', async () => { + await agent.delete('/ideas/0000000/cares/care') + .expect(404); + }); + }); + + context('invalid', () => { + it('[invalid idea id] 400', async () => { + await agent.delete('/ideas/invalid/cares/care') + .expect(400); + }); + }); + }); + + context('not logged', () => { + it('403', async () => { + await agent.delete(`/ideas/${idea0.id}/cares/care`) + .expect(403); + }); + }); + }); + + describe('See who cares about the idea `GET /ideas/:id/cares`', () => { + it('todo'); + }); + + describe('Include info whether I care about the idea when reading it', () => { + it('todo'); + }); + + describe('See ideas cared for by given user(s) `GET /ideas/:id?filter[caring]=username1,username2`', () => { + it('todo'); + }); + + describe('How many users care about the idea?', () => { + it('todo'); + }); +}); diff --git a/test/handle-database.js b/test/handle-database.js index b0fb873..c99a378 100644 --- a/test/handle-database.js +++ b/test/handle-database.js @@ -25,7 +25,8 @@ exports.fill = async function (data) { ideaTags: [], ideaComments: [], reactions: [], - votes: [] + votes: [], + cares: [] }; data = _.defaults(data, def); @@ -138,6 +139,16 @@ exports.fill = async function (data) { vote.id = newVote.id; } + for (const care of processed.cares) { + const from = care.from.username; + const to = { type: care._to.type, id: care.to.id }; + + const newCare = await models.care.create({ from, to }); + + // save the care's id + care.id = newCare.id; + } + return processed; }; @@ -338,5 +349,15 @@ function processData(data) { }; }); + output.cares = data.cares.map(([from, to]) => { + const [type, id] = to; + return { + _from: from, + _to: { type, id }, + get from() { return output.users[this._from]; }, + get to() { return output[this._to.type][this._to.id]; } + }; + }); + return output; }