diff --git a/.gitignore b/.gitignore index 6566f59..d8b44f5 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,6 @@ node_modules tmp docs/api builds + +# ide +.idea diff --git a/Gruntfile.coffee b/Gruntfile.coffee index de46ef8..7fc730b 100644 --- a/Gruntfile.coffee +++ b/Gruntfile.coffee @@ -90,7 +90,7 @@ module.exports = (grunt) -> shell: mocha: - command: 'NODE_ENV=test ./node_modules/mocha/bin/mocha --compilers coffee:coffee-script/register --recursive test/server -b' + command: 'NODE_ENV=test ./node_modules/mocha/bin/mocha --compilers coffee:coffee-script/register --recursive test/server -b --timeout 5000' # 5000ms timeout to prevent timeout on older/slow? machines cov: command: 'NODE_ENV=test ./node_modules/mocha/bin/mocha --compilers coffee:coffee-script/register --recursive test/server --require blanket --reporter html-cov > coverage.html' publish: diff --git a/client/source/controllers/buckets_controller.coffee b/client/source/controllers/buckets_controller.coffee index 1ff6e4a..2f2932e 100644 --- a/client/source/controllers/buckets_controller.coffee +++ b/client/source/controllers/buckets_controller.coffee @@ -7,6 +7,8 @@ DashboardView = require 'views/buckets/dashboard' EntriesBrowser = require 'views/entries/browser' EntryEditView = require 'views/entries/edit' +Activity = require 'models/activity' +Activities = require 'models/activities' Bucket = require 'models/bucket' Buckets = require 'models/buckets' Fields = require 'models/fields' @@ -20,7 +22,11 @@ mediator = require('chaplin').mediator module.exports = class BucketsController extends Controller dashboard: -> - @view = new DashboardView + @activities = new Activities + + @activities.fetch().done => + @view = new DashboardView + collection: @activities add: -> @adjustTitle 'New Bucket' diff --git a/client/source/models/activities.coffee b/client/source/models/activities.coffee new file mode 100644 index 0000000..bf9545a --- /dev/null +++ b/client/source/models/activities.coffee @@ -0,0 +1,6 @@ +Collection = require 'lib/collection' +Activity = require 'models/activity' + +module.exports = class Activities extends Collection + url: '/api/activities' + model: Activity \ No newline at end of file diff --git a/client/source/models/activity.coffee b/client/source/models/activity.coffee new file mode 100644 index 0000000..5a81d47 --- /dev/null +++ b/client/source/models/activity.coffee @@ -0,0 +1,7 @@ +Model = require 'lib/model' + +module.exports = class Activity extends Model + urlRoot: '/api/activities' + + hello: -> + 'test' \ No newline at end of file diff --git a/client/source/templates/buckets/dashboard.hbs b/client/source/templates/buckets/dashboard.hbs index 1c5ebba..8276b18 100644 --- a/client/source/templates/buckets/dashboard.hbs +++ b/client/source/templates/buckets/dashboard.hbs @@ -1,11 +1,37 @@

Buckets

-
+
-

Buckets is an open-source CMS, built in Node.js, which is being actively developed by the community at Assembly.

+

Buckets is an open-source CMS, built in Node.js, which is being actively developed by the community at Assembly.

+ +

Recent Activity

+ +
+
+ {{#if activities}} + {{#each activities}} +
+ {{gravatar actor.email_hash}} {{actor.name}} + {{action}} {{resource.kind}} + {{#if resource.path}} + {{resource.name}} + {{else}} + {{resource.name}} + {{/if}} + {{timeAgo publishDate}} +
+ {{/each}} + {{else}} +
+ There has been no activity yet. +
+ {{/if}} +
+
diff --git a/client/source/views/buckets/dashboard.coffee b/client/source/views/buckets/dashboard.coffee index 650b3ad..3b885dd 100644 --- a/client/source/views/buckets/dashboard.coffee +++ b/client/source/views/buckets/dashboard.coffee @@ -1,6 +1,11 @@ +_ = require 'underscore' PageView = require 'views/base/page' tpl = require 'templates/buckets/dashboard' module.exports = class DashboardView extends PageView template: tpl + + getTemplateData: -> + _.extend super, + activities: @collection.toJSON() \ No newline at end of file diff --git a/client/source/views/entries/browser.coffee b/client/source/views/entries/browser.coffee index 08fedf3..f7c3b3d 100644 --- a/client/source/views/entries/browser.coffee +++ b/client/source/views/entries/browser.coffee @@ -4,6 +4,7 @@ Chaplin = require 'chaplin' PageView = require 'views/base/page' EntriesList = require 'views/entries/list' +Activity = require 'models/activity' Entry = require 'models/entry' EntryEditView = require 'views/entries/edit' diff --git a/client/style/_views.styl b/client/style/_views.styl index eb2d447..5bfb520 100644 --- a/client/style/_views.styl +++ b/client/style/_views.styl @@ -95,6 +95,19 @@ .handle cursor move +// Activities + +.activities + .activity + padding 15px + + a.link-with-avatar:hover + text-decoration: none + + span + text-decoration: underline + + // Entries .entry-publish diff --git a/docs/user-docs/activities.md b/docs/user-docs/activities.md new file mode 100644 index 0000000..bbc75cf --- /dev/null +++ b/docs/user-docs/activities.md @@ -0,0 +1,24 @@ +# Activities + +Activities allow you to keep track of the changes that have been done to your site. +The admin dashboard displays the 20 most recent activities. + +Find below a list of actions that currently create activities: + + +## For Entries + +When a user adds, updates or deletes an entry, the following activity will be created: +`[USER NAME] [added|updated|deleted] [SINGULAR BUCKET NAME] [ENTRY TITLE]` + +## For Buckets + +When a user adds, updates or deletes a bucket, the following activity will be created: +`[USER NAME] [added|updated|deleted] bucket [BUCKET NAME]` + +## For Users + +When a user adds, updates or deletes a user, the following activity will be created: +`[USER NAME] [added|updated|deleted] user [USER NAME]` + + diff --git a/server/models/activity.coffee b/server/models/activity.coffee index 07c6b75..dd97813 100644 --- a/server/models/activity.coffee +++ b/server/models/activity.coffee @@ -1,33 +1,69 @@ mongoose = require 'mongoose' db = require '../lib/database' +logger = require '../lib/logger' # Conforms, at least somewhat, to the activity stream spec outlined at # http://activitystrea.ms/specs/json/1.0 activitySchema = new mongoose.Schema - published: + publishDate: type: Date default: Date.now actor: - id: - type: mongoose.Schema.Types.ObjectId - ref: 'User' - required: true - verb: - name: + type: mongoose.Schema.Types.ObjectId + ref: 'User' + required: true + action: + type: String + enum: ['created', 'updated', 'deleted'] + required: true + resource: + kind: type: String - enum: ['post', 'update'] - required: true - object: - objectType: + name: type: String required: true - enum: ['entry', 'bucket', 'user'] - id: + entry: type: mongoose.Schema.Types.ObjectId - required: true + ref: 'Entry' + bucket: + type: mongoose.Schema.Types.ObjectId + ref: 'Bucket' + user: + type: mongoose.Schema.Types.ObjectId + ref: 'User' , - autoIndex: no + toJSON: + virtuals: yes + transform: (doc, ret, options) -> + delete ret._id + delete ret.__v + ret + +activitySchema.virtual 'resource.path' + .get -> + if @resource.entry or @resource.bucket or @resource.user + switch @resource.kind + when 'bucket' then "/buckets/#{@resource.bucket.slug}" + when 'user' then "/users/#{@resource.user.email}" + else "/buckets/#{@resource.bucket.slug}/#{@resource.entry.id}" + +activitySchema.statics.createForResource = (resource, action, actor, callback) -> + @create { resource, action, actor }, (err, activity) -> + if err + logger.error 'Error creating Activity', activity, err + else + callback(action) if callback -activitySchema.set 'toJSON', virtuals: true +activitySchema.statics.unlinkActivities = (conditions) -> + @update conditions, + { + $set: + 'resource.entry': null + 'resource.bucket': null + 'resource.user': null + }, + { multi: true }, + (err) -> + logger.error 'Error unlinking Activities', resource, err if err module.exports = db.model 'Activity', activitySchema diff --git a/server/models/bucket.coffee b/server/models/bucket.coffee index b69f0c2..4f1d33a 100644 --- a/server/models/bucket.coffee +++ b/server/models/bucket.coffee @@ -2,6 +2,7 @@ inflection = require 'inflection' mongoose = require 'mongoose' uniqueValidator = require 'mongoose-unique-validator' +Activity = require '../models/activity' Route = require '../models/route' db = require '../lib/database' {Sortable} = require '../lib/mongoose-plugins' @@ -114,4 +115,11 @@ bucketSchema.methods.getMembers = (callback) -> resourceId: @_id , callback +bucketSchema.methods.createActivity = (action, actor, callback) -> + Activity.createForResource + kind: 'bucket' + name: @name + bucket: @ + , action, actor, callback + module.exports = db.model 'Bucket', bucketSchema diff --git a/server/models/entry.coffee b/server/models/entry.coffee index 514be95..9473d7e 100755 --- a/server/models/entry.coffee +++ b/server/models/entry.coffee @@ -6,6 +6,8 @@ getSlug = require 'speakingurl' db = require '../lib/database' +Activity = require '../models/activity' + # Add a parser to Chrono to understand "now" # A bit hacky because Chrono doesn't support ms yet chrono.parsers.NowParser = (text, ref, opt) -> @@ -97,7 +99,6 @@ entrySchema.path('publishDate').set (val='') -> parsed = chrono.parse(val)?[0]?.startDate parsed || Date.now() - entrySchema.path('description').validate (val) -> val?.length < 140 , 'Descriptions must be less than 140 characters.' @@ -107,6 +108,14 @@ entrySchema.path 'keywords' return unless _.isString val _.compact _.map val.split(','), (val) -> val.trim() +entrySchema.methods.createActivity = (action, actor, callback) -> + Activity.createForResource + kind: @bucket.singular.toLowerCase() + name: @title + entry: @ + bucket: @bucket + , action, actor, callback + entrySchema.statics.findByParams = (params, callback) -> settings = _.defaults params, diff --git a/server/models/user.coffee b/server/models/user.coffee index c22f632..143f09f 100755 --- a/server/models/user.coffee +++ b/server/models/user.coffee @@ -8,6 +8,7 @@ async = require 'async' fs = require 'fs-extra' _ = require 'underscore' db = require '../lib/database' +Activity = require '../models/activity' if process.env.DROPBOX_APP_KEY and process.env.DROPBOX_APP_SECRET dbox_app = dbox.app @@ -265,6 +266,13 @@ userSchema.methods.syncDropbox = (host='', reset, callback) -> callback e, written console.log "Saved new Dropbox cursor for User." +userSchema.methods.createActivity = (action, actor, callback) -> + Activity.createForResource + kind: 'user' + name: @name + user: @ + , action, actor, callback + userSchema.virtual 'email_hash' .get -> crypto.createHash('md5').update(@email).digest('hex') if @email diff --git a/server/routes/api/activities.coffee b/server/routes/api/activities.coffee new file mode 100644 index 0000000..88d66e1 --- /dev/null +++ b/server/routes/api/activities.coffee @@ -0,0 +1,23 @@ +express = require 'express' + +Activity = require '../../models/activity' + +module.exports = app = express() + + +app.route('/activities') + .get (req, res) -> + return res.status(401).end() unless req.user + + Activity + .find {} + .sort '-publishDate' + .limit 20 + .populate 'actor resource.user', 'name email' + .populate 'resource.entry', 'id' + .populate 'resource.bucket', 'slug' + .exec (err, activities) -> + if err + res.send err, 400 + else if activities + res.send activities \ No newline at end of file diff --git a/server/routes/api/buckets.coffee b/server/routes/api/buckets.coffee index ff4adef..4ac5701 100644 --- a/server/routes/api/buckets.coffee +++ b/server/routes/api/buckets.coffee @@ -1,5 +1,6 @@ express = require 'express' +Activity = require '../../models/activity' Bucket = require '../../models/bucket' User = require '../../models/user' @@ -159,6 +160,7 @@ app.route('/buckets') if err res.status(400).send err else if bucket + bucket.createActivity 'created', req.user res.status(200).send bucket .get (req, res) -> @@ -197,14 +199,16 @@ app.route('/buckets/:bucketID') .delete (req, res) -> return res.status(401).end() unless req.user?.hasRole ['administrator'] - Bucket.findById req.params.bucketID, (err, bkt) -> + Bucket.findById req.params.bucketID, (err, bucket) -> if err res.send 400, err else - bkt.remove (err) -> + bucket.remove (err) -> if err res.status(400).send err else + bucket.createActivity 'deleted', req.user, -> + Activity.unlinkActivities { 'resource.bucket': bucket } res.status(204).end() .put (req, res) -> @@ -215,6 +219,7 @@ app.route('/buckets/:bucketID') return res.status(400).send e: err if err bucket.set(req.body).save (err, bucket) -> return res.status(400).send err if err + bucket.createActivity 'updated', req.user res.status(200).send bucket ### diff --git a/server/routes/api/entries.coffee b/server/routes/api/entries.coffee index af8cd9c..d64b52a 100644 --- a/server/routes/api/entries.coffee +++ b/server/routes/api/entries.coffee @@ -1,5 +1,6 @@ express = require 'express' +Activity = require '../../models/activity' Bucket = require '../../models/bucket' Entry = require '../../models/entry' @@ -72,6 +73,7 @@ app.route '/entries' res.status(400).send err else entry.populate 'bucket author', -> + entry.createActivity 'created', req.user res.status(200).send entry .get (req, res) -> @@ -161,11 +163,17 @@ app.route('/entries/:entryID') return res.status(400).send err if err entry.populate 'bucket author', -> + entry.createActivity 'updated', req.user res.status(200).send entry .delete (req, res) -> - Entry.findById(req.params.entryID).remove (err) -> - if err - res.status(400).send e: err - else - res.status(204).end() + Entry.findById req.params.entryID, (error, entry) -> + entry.remove (err) -> + if err + res.status(400).send e: err + else + entry.populate 'bucket', -> + entry.createActivity 'deleted', req.user, -> + Activity.unlinkActivities { 'resource.entry': entry } + + res.status(204).end() diff --git a/server/routes/api/index.coffee b/server/routes/api/index.coffee index a3082e6..c6af5d7 100644 --- a/server/routes/api/index.coffee +++ b/server/routes/api/index.coffee @@ -2,6 +2,7 @@ express = require 'express' module.exports = app = express() +app.use require './activities' app.use require './buckets' app.use require './entries' app.use require './install' diff --git a/server/routes/api/users.coffee b/server/routes/api/users.coffee index 5acd94f..0f3771d 100644 --- a/server/routes/api/users.coffee +++ b/server/routes/api/users.coffee @@ -5,6 +5,7 @@ crypto = require 'crypto' mailer = require '../../lib/mailer' config = require '../../lib/config' +Activity = require '../../models/activity' User = require '../../models/user' module.exports = app = express() @@ -97,6 +98,7 @@ app.route('/users') newUser.save (err) -> return res.status(400).send err if err + newUser.createActivity 'created', req.user res.status(200).send newUser .get (req, res) -> @@ -161,9 +163,14 @@ app.route('/users/:userID') .delete (req, res) -> return res.status(401).end() unless req.user?.hasRole ['administrator'] - User.remove _id: req.params.userID, (err) -> - return res.status(400).end() if err - res.status(200).end() + User.findById req.params.userID, (err, user) -> + return res.status(400).end() if err or not user + + user.remove (err) -> + return res.status(400).end() if err + user.createActivity 'deleted', req.user, -> + Activity.unlinkActivities { 'resource.user': user } + res.status(200).end() .put (req, res) -> return res.status(401).end() unless req.user?.hasRole ['administrator'] or req.user?._id is req.params.userID @@ -184,6 +191,7 @@ app.route('/users/:userID') user.set(req.body).save (err, user) -> return res.status(400).send err if err + user.createActivity 'updated', req.user res.status(200).send user ### diff --git a/test/server/integration/builds.coffee b/test/server/integration/builds.coffee index af42bd8..ec8e27d 100644 --- a/test/server/integration/builds.coffee +++ b/test/server/integration/builds.coffee @@ -11,8 +11,6 @@ hbs = require 'hbs' request = require 'supertest' describe 'Integration#Builds', -> - @timeout 5000 - beforeEach (done) -> buckets -> reset.builds -> diff --git a/test/server/models/activity.coffee b/test/server/models/activity.coffee index cd12dc8..d4aafaf 100644 --- a/test/server/models/activity.coffee +++ b/test/server/models/activity.coffee @@ -1,49 +1,102 @@ db = require '../../../server/lib/database' +logger = require '../../../server/lib/logger' +reset = require '../../reset' mongoose = require 'mongoose' Activity = require '../../../server/models/activity' +Bucket = require '../../../server/models/bucket' +Entry = require '../../../server/models/entry' {expect} = require 'chai' sinon = require 'sinon' describe 'Model#Activity', -> + @timeout 3000 - afterEach (done) -> - for _, c of db.connection.collections - c.remove(->) - done() + bucket = null + entry = null - describe 'Validation', -> - it 'requires an actor', (done) -> - Activity.create {verb: {name: 'post'}, object: {objectType: 'entry', id: new mongoose.Types.ObjectId()}}, (e, activity) -> - expect(activity).to.be.undefined - expect(e).to.be.an 'Object' - expect(e.errors).to.have.property 'actor.id' + before reset.db + + beforeEach (done) -> + userId = new mongoose.Types.ObjectId() + + Bucket.create + name: 'Articles' + slug: 'articles' + , (e, _bucket) -> + bucket = _bucket + + Entry.create + title: 'Test Article' + bucket: bucket._id + author: userId + status: 'live' + publishDate: '2 days ago' + , (e, _entry) -> + entry = _entry done() - it 'requires a verb', (done) -> - Activity.create {actor: {id: new mongoose.Types.ObjectId()}, object: {objectType: 'entry', id: new mongoose.Types.ObjectId()}}, (e, activity) -> + afterEach reset.db + + describe 'Validation', -> + it 'requires an actor', (done) -> + Activity.create + verb: 'created' + , (e, activity) -> expect(activity).to.be.undefined expect(e).to.be.an 'Object' - expect(e.errors).to.have.property 'verb.name' + expect(e.errors).to.have.property 'actor' done() - it 'requires an object type', (done) -> - Activity.create {actor: {id: new mongoose.Types.ObjectId()}, verb: {name: 'post'}, object: {id: new mongoose.Types.ObjectId()}}, (e, activity) -> + it 'requires an action', (done) -> + Activity.create + actor: new mongoose.Types.ObjectId() + , (e, activity) -> expect(activity).to.be.undefined expect(e).to.be.an 'Object' - expect(e.errors).to.have.property 'object.objectType' + expect(e.errors).to.have.property 'action' done() - it 'requires an object id', (done) -> - Activity.create {actor: {id: new mongoose.Types.ObjectId()}, verb: {name: 'post'}, object: {objectType: 'entry'}}, (e, activity) -> + it 'requires a resource name', (done) -> + Activity.create + actor: new mongoose.Types.ObjectId() + action: 'created' + , (e, activity) -> expect(activity).to.be.undefined expect(e).to.be.an 'Object' - expect(e.errors).to.have.property 'object.id' + expect(e.errors).to.have.property 'resource.name' done() describe 'Creation', -> it 'automatically populates published date if one is not provided', (done) -> - Activity.create {actor: {id: new mongoose.Types.ObjectId()}, verb: {name: 'post'}, object: {objectType: 'entry', id: new mongoose.Types.ObjectId()}}, (e, activity) -> - expect(activity.published.toISOString()).to.exist + Activity.create + actor: new mongoose.Types.ObjectId() + action: 'created' + resource: + type: 'entry' + name: 'Test Activity' + , (e, activity) -> + expect(activity.publishDate.toISOString()).to.exist done() + + it 'automatically creates a resource.path for an Entry', (done) -> + Activity.create + actor: new mongoose.Types.ObjectId() + action: 'created' + resource: + kind: 'article' + name: 'Test Article' + entry: entry + bucket: bucket + , (e, activity) -> + Activity.populate activity, 'resource.entry resource.bucket', (e, activity) -> + expect(activity.resource.path).to.equal "/buckets/articles/#{entry.id}" + done() + + it 'automatically creates a resource.path for a Bucket' + it 'automatically creates a resource.path for a User' + + describe 'Activity#unlinkActivities', -> + it 'unlinks activities' + describe 'Activity#createForResource', -> diff --git a/test/server/routes/api/users.coffee b/test/server/routes/api/users.coffee index cbb7a8d..d177cd4 100644 --- a/test/server/routes/api/users.coffee +++ b/test/server/routes/api/users.coffee @@ -117,7 +117,7 @@ describe 'REST#Users', -> .expect 400 .end done - it 'returns a 400 if password doesnot have a number', (done) -> + it 'returns a 400 if password doesn’t have a number', (done) -> auth.createAdmin (err, admin) -> admin .post "/#{apiSegment}/users"