From 90a8a87eaf59d40b9c2eb17d3cb78f7a2f7e2e6a Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Mon, 28 Aug 2017 18:57:32 -0600 Subject: [PATCH] Added graph API for assets --- graph/mutators/asset.js | 55 ++++++++++++ graph/mutators/comment.js | 2 +- graph/mutators/index.js | 2 + graph/resolvers/root_mutation.js | 6 ++ graph/typeDefs.graphql | 85 ++++++++++++++++++- perms/constants.js | 2 + perms/mutationReducer.js | 4 + routes/api/assets/index.js | 20 ----- services/assets.js | 65 +++++++++++--- .../graph/mutations/updateAssetSettings.js | 70 +++++++++++++++ .../graph/mutations/updateAssetStatus.js | 67 +++++++++++++++ 11 files changed, 342 insertions(+), 36 deletions(-) create mode 100644 graph/mutators/asset.js create mode 100644 test/server/graph/mutations/updateAssetSettings.js create mode 100644 test/server/graph/mutations/updateAssetStatus.js diff --git a/graph/mutators/asset.js b/graph/mutators/asset.js new file mode 100644 index 000000000..462607f5f --- /dev/null +++ b/graph/mutators/asset.js @@ -0,0 +1,55 @@ +const errors = require('../../errors'); +const { + UPDATE_ASSET_SETTINGS, + UPDATE_ASSET_STATUS, +} = require('../../perms/constants'); + +const AssetsService = require('../../services/assets'); +const AssetModel = require('../../models/asset'); + +/** + * updateSettings will update the settings on an asset. + * + * @param {Object} ctx graphql context + * @param {String} id the asset's id to update + * @param {Object} settings the settings to update on the asset. + */ +const updateSettings = async (ctx, id, settings) => AssetsService.overrideSettings(id, settings); + +/** + * updateStatus will update the status of an asset. + * + * @param {Object} ctx graphql context + * @param {String} id the asset's id to update + * @param {Object} status the status to change on the asset relating to it's + * current state. + */ +const updateStatus = async (ctx, id, {closedAt, closedMessage}) => AssetModel.update({ + id, +}, { + $set: { + closedAt, + closedMessage + } +}); + +module.exports = (ctx) => { + let mutators = { + Asset: { + updateSettings: () => Promise.reject(errors.ErrNotAuthorized), + updateStatus: () => Promise.reject(errors.ErrNotAuthorized) + } + }; + + if (ctx.user) { + if (ctx.user.can(UPDATE_ASSET_SETTINGS)) { + mutators.Asset.updateSettings = (id, settings) => updateSettings(ctx, id, settings); + } + + if (ctx.user.can(UPDATE_ASSET_STATUS)) { + mutators.Asset.updateStatus = (id, status) => updateStatus(ctx, id, status); + } + } + + return mutators; +}; diff --git a/graph/mutators/comment.js b/graph/mutators/comment.js index 3f9e72fc3..4ca044fcf 100644 --- a/graph/mutators/comment.js +++ b/graph/mutators/comment.js @@ -328,7 +328,7 @@ const createPublicComment = async (context, commentInput) => { * @param {String} id identifier of the comment (uuid) * @param {String} status the new status of the comment */ -const setStatus = async ({user, loaders: {Comments}, pubsub}, {id, status}) => { +const setStatus = async ({user, loaders: {Comments}}, {id, status}) => { let comment = await CommentsService.pushStatus(id, status, user ? user.id : null); // If the loaders are present, clear the caches for these values because we diff --git a/graph/mutators/index.js b/graph/mutators/index.js index e7e5df9e7..c48714baf 100644 --- a/graph/mutators/index.js +++ b/graph/mutators/index.js @@ -3,6 +3,7 @@ const debug = require('debug')('talk:graph:mutators'); const Comment = require('./comment'); const Action = require('./action'); +const Asset = require('./asset'); const Tag = require('./tag'); const Token = require('./token'); const User = require('./user'); @@ -14,6 +15,7 @@ let mutators = [ // Load in the core mutators. Comment, Action, + Asset, Tag, Token, User, diff --git a/graph/resolvers/root_mutation.js b/graph/resolvers/root_mutation.js index 685c26a17..9e9249792 100644 --- a/graph/resolvers/root_mutation.js +++ b/graph/resolvers/root_mutation.js @@ -25,6 +25,12 @@ const RootMutation = { rejectUsername(_, {input: {id, message}}, {mutators: {User}}) { return wrapResponse(null)(User.rejectUsername({id, message})); }, + updateAssetSettings(_, {id, input: settings}, {mutators: {Asset}}) { + return wrapResponse(null)(Asset.updateSettings(id, settings)); + }, + updateAssetStatus(_, {id, input: status}, {mutators: {Asset}}) { + return wrapResponse(null)(Asset.updateStatus(id, status)); + }, ignoreUser(_, {id}, {mutators: {User}}) { return wrapResponse(null)(User.ignoreUser({id})); }, diff --git a/graph/typeDefs.graphql b/graph/typeDefs.graphql index 4dff623fd..5b29b2f0e 100644 --- a/graph/typeDefs.graphql +++ b/graph/typeDefs.graphql @@ -143,7 +143,19 @@ input AssetsQuery { # Limit the number of results to be returned limit: Int = 10 + + # open filters assets that are open/closed/all. Not providing this parameter + # will return all the assets, true will return assets that are open, and false + # will return assets that are closed. + open: Boolean + + # sortOrder specifies the order of the sort for the returned Assets. + sortOrder: SORT_ORDER = DESC + + # Skip results from the last created_at timestamp. + cursor: Cursor } + ################################################################################ ## Tags ################################################################################ @@ -627,6 +639,22 @@ type Asset { author: String } +# AssetConnection represents a paginable subset of a asset list. +type AssetConnection { + + # Indicates that there are more assets after this subset. + hasNextPage: Boolean! + + # Cursor of first asset in subset. + startCursor: Date + + # Cursor of last asset in subset. + endCursor: Date + + # Subset of assets. + nodes: [Asset!]! +} + ################################################################################ ## Errors ################################################################################ @@ -714,7 +742,7 @@ type RootQuery { comment(id: ID!): Comment # All assets. Requires the `ADMIN` role. - assets(query: AssetsQuery): [Asset] + assets(query: AssetsQuery): AssetConnection # Find or create an asset by url, or just find with the ID. asset(id: ID, url: String): Asset @@ -884,6 +912,54 @@ input RejectUsernameInput { message: String! } +# Configurable settings that can be overridden for the Asset. +input AssetSettingsInput { + + # premodLinksEnable will put all comments that contain links into premod. + premodLinksEnable: Boolean + + # moderation is the moderation mode for the asset. + moderation: MODERATION_MODE! + + # questionBoxEnable will enable the Question Boxs' content to be visable above + # the comment box. + questionBoxEnable: Boolean + + # questionBoxContent is the content of the Question Box. + questionBoxContent: String + + # questionBoxIcon is the icon for the Question Box. + questionBoxIcon: String +} + +# UpdateAssetStatusInput contains the input to change the status of a comment as +# it relates to being open/closed for commenting. +input UpdateAssetStatusInput { + + # closedAt is the time that the asset will be closed for commenting. If this + # is null or in the future, it will be open for commenting. + closedAt: Date + + # closedMessage is the message to be set on the asset when it is closed. + closedMessage: String +} + +# UpdateAssetStatusResponse is the response returned with possibly some errors +# relating to the update status attempt. +type UpdateAssetStatusResponse implements Response { + + # An array of errors relating to the mutation that occurred. + errors: [UserError!] +} + +# UpdateAssetSettingsResponse is the response returned with possibly some errors +# relating to the update settings attempt. +type UpdateAssetSettingsResponse implements Response { + + # An array of errors relating to the mutation that occurred. + errors: [UserError!] +} + # DeleteActionResponse is the response returned with possibly some errors # relating to the delete action attempt. type DeleteActionResponse implements Response { @@ -1044,6 +1120,13 @@ type RootMutation { # Removes a tag. removeTag(tag: ModifyTagInput!): ModifyTagResponse! + # Updates settings on a given asset. + updateAssetSettings(id: ID!, input: AssetSettingsInput!): UpdateAssetSettingsResponse + + # Updates the status of an asset allowing you to close/reopen an asset for + # commenting. + updateAssetStatus(id: ID!, input: UpdateAssetStatusInput!): UpdateAssetStatusResponse + # Ignore comments by another user ignoreUser(id: ID!): IgnoreUserResponse diff --git a/perms/constants.js b/perms/constants.js index 383f36daf..a139635d9 100644 --- a/perms/constants.js +++ b/perms/constants.js @@ -16,6 +16,8 @@ module.exports = { UPDATE_CONFIG: 'UPDATE_CONFIG', CREATE_TOKEN: 'CREATE_TOKEN', REVOKE_TOKEN: 'REVOKE_TOKEN', + UPDATE_ASSET_SETTINGS: 'UPDATE_ASSET_SETTINGS', + UPDATE_ASSET_STATUS: 'UPDATE_ASSET_STATUS', // queries SEARCH_ASSETS: 'SEARCH_ASSETS', diff --git a/perms/mutationReducer.js b/perms/mutationReducer.js index 897217348..5e0e68fcb 100644 --- a/perms/mutationReducer.js +++ b/perms/mutationReducer.js @@ -33,6 +33,10 @@ module.exports = (user, perm) => { return check(user, ['ADMIN']); case types.REVOKE_TOKEN: return check(user, ['ADMIN']); + case types.UPDATE_ASSET_SETTINGS: + return check(user, ['ADMIN', 'MODERATOR']); + case types.UPDATE_ASSET_STATUS: + return check(user, ['ADMIN', 'MODERATOR']); default: break; } diff --git a/routes/api/assets/index.js b/routes/api/assets/index.js index a306b36b4..87014b86e 100644 --- a/routes/api/assets/index.js +++ b/routes/api/assets/index.js @@ -1,7 +1,6 @@ const express = require('express'); const router = express.Router(); -const scraper = require('../../../services/scraper'); const errors = require('../../../errors'); const AssetsService = require('../../../services/assets'); @@ -88,25 +87,6 @@ router.get('/:asset_id', async (req, res, next) => { } }); -// Adds the asset id to the queue to be scraped. -router.post('/:asset_id/scrape', async (req, res, next) => { - try { - - // Send back the asset. - let asset = await AssetsService.findById(req.params.asset_id); - if (!asset) { - return next(errors.ErrNotFound); - } - - let job = await scraper.create(asset); - - // Send the job back for monitoring. - res.status(201).json(job); - } catch (e) { - return next(e); - } -}); - router.put('/:asset_id/settings', async (req, res, next) => { try { await AssetsService.overrideSettings(req.params.asset_id, req.body); diff --git a/services/assets.js b/services/assets.js index 74bc1aa3a..fd34a6f2b 100644 --- a/services/assets.js +++ b/services/assets.js @@ -108,19 +108,57 @@ module.exports = class AssetsService { * @param {String} value string to search by. * @return {Promise} */ - static search({value, skip, limit} = {}) { - if (!value) { - return AssetsService.all(skip, limit); - } else { - return AssetModel - .find({ - $text: { - $search: value - } - }) - .skip(skip) - .limit(limit); + static search({value, limit, open, sortOrder, cursor} = {}) { + let assets = AssetModel.find({}); + + if (value) { + assets.merge({ + $text: { + $search: value + } + }); } + + if (open != null) { + if (open) { + assets.merge({ + $or: [ + { + closedAt: null + }, + { + closedAt: { + $gt: Date.now() + } + } + ] + }); + } else { + assets.merge({ + closedAt: { + $lt: Date.now() + } + }); + } + } + + if (cursor) { + if (sortOrder === 'DESC') { + assets.merge({ + created_at: { + $lt: cursor, + }, + }); + } else { + assets.merge({ + created_at: { + $gt: cursor, + }, + }); + } + } + + return assets.limit(limit); } /** @@ -185,10 +223,9 @@ module.exports = class AssetsService { // That's it! } - static all(skip = null, limit = null) { + static all(limit = undefined) { return AssetModel .find({}) - .skip(skip) .limit(limit); } }; diff --git a/test/server/graph/mutations/updateAssetSettings.js b/test/server/graph/mutations/updateAssetSettings.js new file mode 100644 index 000000000..3fda0bd6c --- /dev/null +++ b/test/server/graph/mutations/updateAssetSettings.js @@ -0,0 +1,70 @@ +const {graphql} = require('graphql'); + +const schema = require('../../../../graph/schema'); +const Context = require('../../../../graph/context'); +const UserModel = require('../../../../models/user'); +const SettingsService = require('../../../../services/settings'); +const AssetModel = require('../../../../models/asset'); + +const {expect} = require('chai'); + +describe('graph.mutations.updateAssetSettings', () => { + let asset; + beforeEach(async () => { + await SettingsService.init(); + asset = await AssetModel.create({url: 'http://new.test.com/'}); + }); + + const QUERY = ` + mutation UpdateAssetStatus($id: ID!, $settings: AssetSettingsInput!) { + updateAssetSettings(id: $id, input: $settings) { + errors { + translation_key + } + } + } +`; + + describe('context with different user roles', () => { + + [ + {error: 'NOT_AUTHORIZED'}, + {roles: ['ADMIN', 'MODERATOR']}, + {roles: ['MODERATOR']}, + ].forEach(({roles, error}) => { + it(roles ? roles.join(', ') : '', async () => { + const user = new UserModel({roles}); + const ctx = new Context({user}); + + const settings = { + premodLinksEnable: false, + moderation: 'POST', + questionBoxEnable: true, + questionBoxContent: 'Question?', + questionBoxIcon: '', + }; + + const res = await graphql(schema, QUERY, {}, ctx, { + id: asset.id, + settings, + }); + if (res.errors) { + console.error(res.errors); + } + expect(res.errors).to.be.empty; + + if (error) { + expect(res.data.updateAssetSettings.errors).to.not.be.empty; + expect(res.data.updateAssetSettings.errors[0]).to.have.property('translation_key', error); + } else { + expect(res.data.updateAssetSettings.errors).to.be.null; + + const retrievedAsset = await AssetModel.findOne({id: asset.id}); + Object.keys(settings).forEach((key) => { + expect(retrievedAsset.settings).to.have.property(key, settings[key]); + }); + } + }); + }); + }); +}); diff --git a/test/server/graph/mutations/updateAssetStatus.js b/test/server/graph/mutations/updateAssetStatus.js new file mode 100644 index 000000000..b1eb8874c --- /dev/null +++ b/test/server/graph/mutations/updateAssetStatus.js @@ -0,0 +1,67 @@ +const {graphql} = require('graphql'); + +const schema = require('../../../../graph/schema'); +const Context = require('../../../../graph/context'); +const UserModel = require('../../../../models/user'); +const SettingsService = require('../../../../services/settings'); +const AssetModel = require('../../../../models/asset'); + +const {expect} = require('chai'); + +describe('graph.mutations.updateAssetStatus', () => { + let asset; + beforeEach(async () => { + await SettingsService.init(); + asset = await AssetModel.create({url: 'http://new.test.com/'}); + }); + + const QUERY = ` + mutation UpdateAssetStatus($id: ID!, $status: UpdateAssetStatusInput!) { + updateAssetStatus(id: $id, input: $status) { + errors { + translation_key + } + } + } + `; + + describe('context with different user roles', () => { + + [ + {error: 'NOT_AUTHORIZED'}, + {roles: ['ADMIN', 'MODERATOR']}, + {roles: ['MODERATOR']}, + ].forEach(({roles, error}) => { + it(roles ? roles.join(', ') : '', async () => { + const user = new UserModel({roles}); + const ctx = new Context({user}); + + const closedAt = (new Date()).toISOString(); + const closedMessage = 'my closed message!'; + + const res = await graphql(schema, QUERY, {}, ctx, { + id: asset.id, + status: { + closedAt, + closedMessage, + }, + }); + if (res.errors) { + console.error(res.errors); + } + expect(res.errors).to.be.empty; + + if (error) { + expect(res.data.updateAssetStatus.errors).to.not.be.empty; + expect(res.data.updateAssetStatus.errors[0]).to.have.property('translation_key', error); + } else { + expect(res.data.updateAssetStatus.errors).to.be.null; + + const retrievedAsset = await AssetModel.findOne({id: asset.id}); + expect(retrievedAsset.closedAt).to.not.be.null; + expect(retrievedAsset).to.have.property('closedMessage', closedMessage); + } + }); + }); + }); +});