Skip to content
This repository has been archived by the owner on Jun 5, 2019. It is now read-only.

Activity Stream #79

Open
wants to merge 11 commits into
base: master
Choose a base branch
from
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,6 @@ node_modules
tmp
docs/api
builds

# ide
.idea
8 changes: 7 additions & 1 deletion client/source/controllers/buckets_controller.coffee
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'
Expand Down
22 changes: 22 additions & 0 deletions client/source/helpers.coffee
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,25 @@ Handlebars.registerHelper 'startsWith', (string1, string2, options) ->
options.fn @
else
options.inverse @

Handlebars.registerHelper 'activityResourceLink', (resource) ->
escapeExpression = (expression) ->
Handlebars.Utils.escapeExpression expression
id = escapeExpression resource.id
type = escapeExpression resource.type
name = escapeExpression resource.name
email = escapeExpression resource.email
bucketSlug = escapeExpression resource.bucket?.slug
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is all the escaping necessary? Since we're supplying the resource ourself, and it goes through SafeString below-

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't like what I was doing here (or what I had to do here) either. In general I was hoping to have some link and url helpers available to do something like: link_to admin_user_path user. More on that in response to your other comment.
Regarding the escaping and using SafeString, I understood that because I'm using SafeString, I need to do the escaping (since SafeString expects a safe string).
See the link helper example on http://handlebarsjs.com/, where it says:

Handlebars will not escape a Handlebars.SafeString. If you write a helper that generates its own HTML, you will usually want to return a new Handlebars.SafeString(result). In such a circumstance, you will want to manually escape parameters.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, sorry, you're right. In that case, though, let's just escape name since I think it's the only one susceptible to invalid user input-


if resource.id
switch resource.type
when 'entry' then href = '/' + mediator.options.adminSegment + '/buckets/' + bucketSlug + '/' + id
when 'bucket' then href = '/' + mediator.options.adminSegment + '/buckets/' + bucketSlug
when 'user' then href = '/' + mediator.options.adminSegment + '/users/' + email
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On the server-side, you could create a mongoose virtual that just provides an "actionLink," might be nicer for the API anyway (especially if we eventually do email triggers from these activities).

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could do that and I thought about that, too, but Rails taught me that this does not belong into the model. :)
I think we should rather look into a better overall approach for building links that ideally makes use of the routes definitions and that cannot just be used for activities but for all links in the app.
One option would be building our own link_to helper that uses Chaplin.helpers.reverse.
But then you made a very good point where it would be nice to be able to use those helpers in emails as well (and we may not just send emails for activity stream related stuff?).
Emails are just views as well, at least in the Rails world, so that's why you can use view url helpers there just as in any other views. I guess it would be nice if we could follow that philosophy in the Buckets stack as well.
For one I guess for html emails we'll be using something like horseshoe so that we can use handlebars templates?
And then I did some quick googling about reusing Backbone/Chaplin routes on the backend.
If found this post from Airbnb (http://nerds.airbnb.com/weve-launched-our-first-nodejs-app-to-product/) who created https://github.com/rendrjs/rendr which has a ClientRouter and ServerRouter which reuse the same route definitions.
So I think we should also go into that direction, what do you think?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I appreciate the thinking here, but would prefer to just go with the link virtual on the model for now. It would be a few lines of code and do what we need. I've spent a lot of time investigating client-server rendering patterns in JS, and IMO, it's a lot of trouble. If you'd like to create a separate bounty for this and push forward, I'm happy to award, but I think it would present a big blocker on this when it's already close.


link = '<a href="' + href + '">' + name + '</a>'
else
link = name

new Handlebars.SafeString link;

6 changes: 6 additions & 0 deletions client/source/models/activities.coffee
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
Collection = require 'lib/collection'
Activity = require 'models/activity'

module.exports = class Activities extends Collection
url: '/api/activities'
model: Activity
7 changes: 7 additions & 0 deletions client/source/models/activity.coffee
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
Model = require 'lib/model'

module.exports = class Activity extends Model
urlRoot: '/api/activities'

hello: ->
'test'
26 changes: 24 additions & 2 deletions client/source/templates/buckets/dashboard.hbs
Original file line number Diff line number Diff line change
@@ -1,11 +1,33 @@
<div class="row">
<h1 class="page-title color-yellow">Buckets</h1>

<div class="col-md-4 col-sm-6">
<div class="col-md-12 col-sm-6">
<div class="panel">
<div class="panel-body">
<p class="lead">Buckets is an open-source CMS, built in Node.js, which is being actively developed by <a href="https://assembly.com/buckets" target="_blank">the community at Assembly</a>.</p>
<p class="lead">Buckets is an open-source CMS, built in Node.js, which is being actively developed by <a
href="https://assembly.com/buckets" target="_blank">the community at Assembly</a>.</p>
</div>
</div>
</div>

<h1 class="page-title">Recent Activity</h1>

<div class="col-md-12 col-sm-4">
<div class="activities">
{{#if activities}}
{{#each activities}}
<div class="activity">
<a class="link-with-avatar" href="/{{adminSegment}}/users/{{actor.email}}">{{gravatar actor.email_hash}} <span class="actor-name">{{actor.name}}</span></a>
{{action}} {{kind}}
{{activityResourceLink resource}}
{{timeAgo publishDate}}
</div>
{{/each}}
{{else}}
<div class="activity">
There has been no activity yet.
</div>
{{/if}}
</div>
</div>
</div>
5 changes: 5 additions & 0 deletions client/source/views/buckets/dashboard.coffee
Original file line number Diff line number Diff line change
@@ -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()
1 change: 1 addition & 0 deletions client/source/views/entries/browser.coffee
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
13 changes: 13 additions & 0 deletions client/style/_views.styl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
47 changes: 36 additions & 11 deletions server/models/activity.coffee
Original file line number Diff line number Diff line change
Expand Up @@ -4,30 +4,55 @@ db = require '../lib/database'
# 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:
type: mongoose.Schema.Types.ObjectId
ref: 'User'
required: true
action:
type: String
enum: ['created', 'updated', 'deleted']
required: true
resource:
id:
type: mongoose.Schema.Types.ObjectId
ref: 'User'
type:
type: String
required: true
verb:
enum: ['entry', 'bucket', 'user']
name:
type: String
enum: ['post', 'update']
required: true
object:
objectType:
# for users
email:
type: String
required: true
enum: ['entry', 'bucket', 'user']
id:
type: mongoose.Schema.Types.ObjectId
required: true
bucket:
id:
type: mongoose.Schema.Types.ObjectId
slug:
type: String
singular:
type: String
,
autoIndex: no

activitySchema.set 'toJSON', virtuals: true

activitySchema.virtual 'kind'
.get ->
if @resource.type is 'entry' then @resource.bucket.singular.toLowerCase() else @resource.type

activitySchema.statics.createForResource = (resource, action, actor, callback) ->
@model('Activity').create { resource, action, actor }, (err, activity) ->
if err
console.log 'Error creating Activity', activity, err
else
callback(action) if callback

activitySchema.statics.unlinkActivities = (resource) ->
@model('Activity').update { 'resource.id': resource._id }, { $set: { 'resource.id': null }}, { multi: true }, (err) ->
console.log 'Error unlinking Activities', resource, err if err

module.exports = db.model 'Activity', activitySchema
12 changes: 12 additions & 0 deletions server/models/bucket.coffee
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -114,4 +115,15 @@ bucketSchema.methods.getMembers = (callback) ->
resourceId: @_id
, callback

bucketSchema.methods.createActivity = (action, actor, callback) ->
Activity.createForResource
id: @_id
type: 'bucket'
name: @name
bucket:
id: @_id
slug: @slug
singular: @singular
, action, actor, callback

module.exports = db.model 'Bucket', bucketSchema
14 changes: 13 additions & 1 deletion server/models/entry.coffee
Original file line number Diff line number Diff line change
Expand Up @@ -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) ->
Expand Down Expand Up @@ -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.'
Expand All @@ -107,6 +108,17 @@ entrySchema.path 'keywords'
return unless _.isString val
_.compact _.map val.split(','), (val) -> val.trim()

entrySchema.methods.createActivity = (action, actor, callback) ->
Activity.createForResource
id: @_id
type: 'entry'
name: @title
bucket:
id: @bucket._id
slug: @bucket.slug
singular: @bucket.singular
, action, actor, callback

entrySchema.statics.findByParams = (params, callback) ->

settings = _.defaults params,
Expand Down
9 changes: 9 additions & 0 deletions server/models/user.coffee
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -265,6 +266,14 @@ 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
id: @_id
type: 'user'
name: @name
email: @email
, action, actor, callback

userSchema.virtual 'email_hash'
.get ->
crypto.createHash('md5').update(@email).digest('hex') if @email
Expand Down
16 changes: 16 additions & 0 deletions server/routes/api/activities.coffee
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
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').exec (err, activities) ->
if err
res.send err, 400
else if activities
res.send activities
9 changes: 7 additions & 2 deletions server/routes/api/buckets.coffee
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
express = require 'express'

Activity = require '../../models/activity'
Bucket = require '../../models/bucket'
User = require '../../models/user'

Expand Down Expand Up @@ -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) ->
Expand Down Expand Up @@ -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 bucket
res.status(204).end()

.put (req, res) ->
Expand All @@ -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

###
Expand Down
18 changes: 13 additions & 5 deletions server/routes/api/entries.coffee
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
express = require 'express'

Activity = require '../../models/activity'
Bucket = require '../../models/bucket'
Entry = require '../../models/entry'

Expand Down Expand Up @@ -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) ->
Expand Down Expand Up @@ -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 entry

res.status(204).end()
Loading