From 83978acc1de55c3320bc7e77c0a82785998cc71a Mon Sep 17 00:00:00 2001 From: Andrew Nicolaou Date: Mon, 8 Jul 2019 12:06:13 +0200 Subject: [PATCH] Adds Collections model and Editor API to manage collections - List any user's collections - Create new collection - Modify collection metadata - Delete collection - Add/remove any project to/from a collection --- .../addProjectToCollection.js | 74 +++++++++++++++++++ .../collection.controller/createCollection.js | 47 ++++++++++++ .../collection.controller/index.js | 6 ++ .../collection.controller/listCollections.js | 48 ++++++++++++ .../collection.controller/removeCollection.js | 34 +++++++++ .../removeProjectFromCollection.js | 62 ++++++++++++++++ .../collection.controller/updateCollection.js | 54 ++++++++++++++ server/models/collection.js | 48 ++++++++++++ server/routes/collection.routes.js | 20 +++++ server/server.js | 2 + 10 files changed, 395 insertions(+) create mode 100644 server/controllers/collection.controller/addProjectToCollection.js create mode 100644 server/controllers/collection.controller/createCollection.js create mode 100644 server/controllers/collection.controller/index.js create mode 100644 server/controllers/collection.controller/listCollections.js create mode 100644 server/controllers/collection.controller/removeCollection.js create mode 100644 server/controllers/collection.controller/removeProjectFromCollection.js create mode 100644 server/controllers/collection.controller/updateCollection.js create mode 100644 server/models/collection.js create mode 100644 server/routes/collection.routes.js diff --git a/server/controllers/collection.controller/addProjectToCollection.js b/server/controllers/collection.controller/addProjectToCollection.js new file mode 100644 index 00000000..6abf08bf --- /dev/null +++ b/server/controllers/collection.controller/addProjectToCollection.js @@ -0,0 +1,74 @@ +import Collection from '../../models/collection'; +import Project from '../../models/project'; + +export default function addProjectToCollection(req, res) { + const owner = req.user._id; + const { id: collectionId, projectId } = req.params; + + const collectionPromise = Collection.findById(collectionId).populate('items.project', '_id'); + const projectPromise = Project.findById(projectId); + + function sendFailure(code, message) { + res.status(code).json({ success: false, message }); + } + + function sendSuccess(collection) { + res.status(200).json(collection); + } + + function updateCollection([collection, project]) { + if (collection == null) { + sendFailure(404, 'Collection not found'); + return null; + } + + if (project == null) { + sendFailure(404, 'Project not found'); + return null; + } + + if (!collection.owner.equals(owner)) { + sendFailure(403, 'User does not own this collection'); + return null; + } + + const projectInCollection = collection.items.find(p => p.project._id === project._id); + + if (projectInCollection) { + sendFailure(404, 'Project already in collection'); + return null; + } + + try { + collection.items.push({ project }); + + return collection.save(); + } catch (error) { + console.error(error); + sendFailure(500, error.message); + return null; + } + } + + function populateReferences(collection) { + return Collection.populate( + collection, + [ + { path: 'owner', select: ['id', 'username'] }, + { + path: 'items.project', + select: ['id', 'name', 'slug'], + populate: { + path: 'user', select: ['username'] + } + } + ] + ); + } + + return Promise.all([collectionPromise, projectPromise]) + .then(updateCollection) + .then(populateReferences) + .then(sendSuccess) + .catch(sendFailure); +} diff --git a/server/controllers/collection.controller/createCollection.js b/server/controllers/collection.controller/createCollection.js new file mode 100644 index 00000000..9fd97be3 --- /dev/null +++ b/server/controllers/collection.controller/createCollection.js @@ -0,0 +1,47 @@ +import Collection from '../../models/collection'; + +export default function createCollection(req, res) { + const owner = req.user._id; + const { name, description, slug } = req.body; + + const values = { + owner, + name, + description, + slug + }; + + function sendFailure({ code = 500, message = 'Something went wrong' }) { + res.status(code).json({ success: false, message }); + } + + function sendSuccess(newCollection) { + res.json(newCollection); + } + + function populateReferences(newCollection) { + return Collection.populate( + newCollection, + [ + { path: 'owner', select: ['id', 'username'] }, + { + path: 'items.project', + select: ['id', 'name', 'slug'], + populate: { + path: 'user', select: ['username'] + } + } + ] + ); + } + + if (owner == null) { + sendFailure({ code: 404, message: 'No user specified' }); + return null; + } + + return Collection.create(values) + .then(populateReferences) + .then(sendSuccess) + .catch(sendFailure); +} diff --git a/server/controllers/collection.controller/index.js b/server/controllers/collection.controller/index.js new file mode 100644 index 00000000..b09db3f0 --- /dev/null +++ b/server/controllers/collection.controller/index.js @@ -0,0 +1,6 @@ +export { default as addProjectToCollection } from './addProjectToCollection'; +export { default as createCollection } from './createCollection'; +export { default as listCollections } from './listCollections'; +export { default as removeCollection } from './removeCollection'; +export { default as removeProjectFromCollection } from './removeProjectFromCollection'; +export { default as updateCollection } from './updateCollection'; diff --git a/server/controllers/collection.controller/listCollections.js b/server/controllers/collection.controller/listCollections.js new file mode 100644 index 00000000..c71041b3 --- /dev/null +++ b/server/controllers/collection.controller/listCollections.js @@ -0,0 +1,48 @@ +import Collection from '../../models/collection'; +import User from '../../models/user'; + +async function getOwnerUserId(req) { + if (req.params.username) { + const user = await User.findOne({ username: req.params.username }); + if (user && user._id) { + return user._id; + } + } else if (req.user._id) { + return req.user._id; + } + + return null; +} + +export default function listCollections(req, res) { + function sendFailure({ code = 500, message = 'Something went wrong' }) { + res.status(code).json({ success: false, message }); + } + + function sendSuccess(collections) { + res.status(200).json(collections); + } + + function findCollections(owner) { + if (owner == null) { + sendFailure({ code: 404, message: 'User not found' }); + } + + return Collection.find({ owner }) + .populate([ + { path: 'owner', select: ['id', 'username'] }, + { + path: 'items.project', + select: ['id', 'name', 'slug'], + populate: { + path: 'user', select: ['username'] + } + } + ]); + } + + return getOwnerUserId(req) + .then(findCollections) + .then(sendSuccess) + .catch(sendFailure); +} diff --git a/server/controllers/collection.controller/removeCollection.js b/server/controllers/collection.controller/removeCollection.js new file mode 100644 index 00000000..dffff876 --- /dev/null +++ b/server/controllers/collection.controller/removeCollection.js @@ -0,0 +1,34 @@ +import Collection from '../../models/collection'; + + +export default function createCollection(req, res) { + const { id: collectionId } = req.params; + const owner = req.user._id; + + function sendFailure({ code = 500, message = 'Something went wrong' }) { + res.status(code).json({ success: false, message }); + } + + function sendSuccess() { + res.status(200).json({ success: true }); + } + + function removeCollection(collection) { + if (collection == null) { + sendFailure({ code: 404, message: 'Not found, or you user does not own this collection' }); + return null; + } + + return collection.remove(); + } + + function findCollection() { + // Only returned if owner matches current user + return Collection.findOne({ _id: collectionId, owner }); + } + + return findCollection() + .then(removeCollection) + .then(sendSuccess) + .catch(sendFailure); +} diff --git a/server/controllers/collection.controller/removeProjectFromCollection.js b/server/controllers/collection.controller/removeProjectFromCollection.js new file mode 100644 index 00000000..561a9ce7 --- /dev/null +++ b/server/controllers/collection.controller/removeProjectFromCollection.js @@ -0,0 +1,62 @@ +import Collection from '../../models/collection'; +import Project from '../../models/project'; + +export default function addProjectToCollection(req, res) { + const owner = req.user._id; + const { id: collectionId, projectId } = req.params; + + function sendFailure({ code = 500, message = 'Something went wrong' }) { + res.status(code).json({ success: false, message }); + } + + function sendSuccess(collection) { + res.status(200).json(collection); + } + + function updateCollection(collection) { + if (collection == null) { + sendFailure({ code: 404, message: 'Collection not found' }); + return null; + } + + if (!collection.owner.equals(owner)) { + sendFailure({ code: 403, message: 'User does not own this collection' }); + return null; + } + + const project = collection.items.find(p => p.project._id === projectId); + + if (project != null) { + project.remove(); + return collection.save(); + } + + const error = new Error('not found'); + error.code = 404; + + throw error; + } + + function populateReferences(collection) { + return Collection.populate( + collection, + [ + { path: 'owner', select: ['id', 'username'] }, + { + path: 'items.project', + select: ['id', 'name', 'slug'], + populate: { + path: 'user', select: ['username'] + } + } + ] + ); + } + + return Collection.findById(collectionId) + .populate('items.project', '_id') + .then(updateCollection) + .then(populateReferences) + .then(sendSuccess) + .catch(sendFailure); +} diff --git a/server/controllers/collection.controller/updateCollection.js b/server/controllers/collection.controller/updateCollection.js new file mode 100644 index 00000000..365b8d24 --- /dev/null +++ b/server/controllers/collection.controller/updateCollection.js @@ -0,0 +1,54 @@ +import omitBy from 'lodash/omitBy'; +import isUndefined from 'lodash/isUndefined'; +import Collection from '../../models/collection'; + +function removeUndefined(obj) { + return omitBy(obj, isUndefined); +} + +export default function createCollection(req, res) { + const { id: collectionId } = req.params; + const owner = req.user._id; + const { name, description, slug } = req.body; + + const values = removeUndefined({ + name, + description, + slug + }); + + function sendFailure({ code = 500, message = 'Something went wrong' }) { + res.status(code).json({ success: false, message }); + } + + function sendSuccess(collection) { + if (collection == null) { + sendFailure({ code: 404, message: 'Not found, or you user does not own this collection' }); + return; + } + + res.json(collection); + } + + async function findAndUpdateCollection() { + // Only update if owner matches current user + return Collection.findOneAndUpdate( + { _id: collectionId, owner }, + values, + { new: true, runValidators: true, setDefaultsOnInsert: true } + ).populate([ + { path: 'owner', select: ['id', 'username'] }, + { + path: 'items.project', + select: ['id', 'name', 'slug'], + populate: { + path: 'user', select: ['username'] + } + } + ]).exec(); + } + + return findAndUpdateCollection() + .then(sendSuccess) + .catch(sendFailure); +} diff --git a/server/models/collection.js b/server/models/collection.js new file mode 100644 index 00000000..2753496b --- /dev/null +++ b/server/models/collection.js @@ -0,0 +1,48 @@ +import mongoose from 'mongoose'; +import shortid from 'shortid'; +import slugify from 'slugify'; + +const { Schema } = mongoose; + +const collectedProjectSchema = new Schema( + { + project: { type: Schema.Types.ObjectId, ref: 'Project' }, + }, + { timestamps: true, _id: true, usePushEach: true } +); + +collectedProjectSchema.virtual('id').get(function getId() { + return this._id.toHexString(); +}); + +collectedProjectSchema.set('toJSON', { + virtuals: true +}); + +const collectionSchema = new Schema( + { + _id: { type: String, default: shortid.generate }, + name: { type: String, default: 'My collection' }, + description: { type: String }, + slug: { type: String }, + owner: { type: Schema.Types.ObjectId, ref: 'User' }, + items: { type: [collectedProjectSchema] } + }, + { timestamps: true, usePushEach: true } +); + +collectionSchema.virtual('id').get(function getId() { + return this._id; +}); + +collectionSchema.set('toJSON', { + virtuals: true +}); + +collectionSchema.pre('save', function generateSlug(next) { + const collection = this; + collection.slug = slugify(collection.name, '_'); + return next(); +}); + +export default mongoose.model('Collection', collectionSchema); diff --git a/server/routes/collection.routes.js b/server/routes/collection.routes.js new file mode 100644 index 00000000..d4f7ff9a --- /dev/null +++ b/server/routes/collection.routes.js @@ -0,0 +1,20 @@ +import { Router } from 'express'; +import * as CollectionController from '../controllers/collection.controller'; +import isAuthenticated from '../utils/isAuthenticated'; + +const router = new Router(); + +// List collections +router.get('/collections', isAuthenticated, CollectionController.listCollections); +router.get('/:username/collections', CollectionController.listCollections); + +// Create, modify, delete collection +router.post('/collections', isAuthenticated, CollectionController.createCollection); +router.patch('/collections/:id', isAuthenticated, CollectionController.updateCollection); +router.delete('/collections/:id', isAuthenticated, CollectionController.removeCollection); + +// Add and remove projects to collection +router.post('/collections/:id/:projectId', isAuthenticated, CollectionController.addProjectToCollection); +router.delete('/collections/:id/:projectId', isAuthenticated, CollectionController.removeProjectFromCollection); + +export default router; diff --git a/server/server.js b/server/server.js index a7acc387..de850a23 100644 --- a/server/server.js +++ b/server/server.js @@ -21,6 +21,7 @@ import users from './routes/user.routes'; import sessions from './routes/session.routes'; import projects from './routes/project.routes'; import files from './routes/file.routes'; +import collections from './routes/collection.routes'; import aws from './routes/aws.routes'; import serverRoutes from './routes/server.routes'; import embedRoutes from './routes/embed.routes'; @@ -102,6 +103,7 @@ app.use('/editor', requestsOfTypeJSON(), sessions); app.use('/editor', requestsOfTypeJSON(), files); app.use('/editor', requestsOfTypeJSON(), projects); app.use('/editor', requestsOfTypeJSON(), aws); +app.use('/editor', requestsOfTypeJSON(), collections); // This is a temporary way to test access via Personal Access Tokens // Sending a valid username: combination will