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
-
+
+
+
Recent Activity
+
+
+
+ {{#if activities}}
+ {{#each activities}}
+
+ {{/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"