summaryrefslogtreecommitdiffstats
path: root/collections
diff options
context:
space:
mode:
authorMaxime Quandalle <maxime@quandalle.com>2015-05-12 19:20:58 +0200
committerMaxime Quandalle <maxime@quandalle.com>2015-05-12 19:33:50 +0200
commit2dbea30842ec63a68055245fe26633bb7913daf3 (patch)
treee9143893a3d3bf4ad34dd3a97d6f3466561c8756 /collections
downloadwekan-2dbea30842ec63a68055245fe26633bb7913daf3.tar.gz
wekan-2dbea30842ec63a68055245fe26633bb7913daf3.tar.bz2
wekan-2dbea30842ec63a68055245fe26633bb7913daf3.zip
Renaissance
_,,ad8888888888bba,_ ,ad88888I888888888888888ba, ,88888888I88888888888888888888a, ,d888888888I8888888888888888888888b, d88888PP"""" ""YY88888888888888888888b, ,d88"'__,,--------,,,,.;ZZZY8888888888888, ,8IIl'" ;;l"ZZZIII8888888888, ,I88l;' ;lZZZZZ888III8888888, ,II88Zl;. ;llZZZZZ888888I888888, ,II888Zl;. .;;;;;lllZZZ888888I8888b ,II8888Z;; `;;;;;''llZZ8888888I8888, II88888Z;' .;lZZZ8888888I888b II88888Z; _,aaa, .,aaaaa,__.l;llZZZ88888888I888 II88888IZZZZZZZZZ, .ZZZZZZZZZZZZZZ;llZZ88888888I888, II88888IZZ<'(@@>Z| |ZZZ<'(@@>ZZZZ;;llZZ888888888I88I ,II88888; `""" ;| |ZZ; `""" ;;llZ8888888888I888 II888888l `;; .;llZZ8888888888I888, ,II888888Z; ;;; .;;llZZZ8888888888I888I III888888Zl; .., `;; ,;;lllZZZ88888888888I888 II88888888Z;;...;(_ _) ,;;;llZZZZ88888888888I888, II88888888Zl;;;;;' `--'Z;. .,;;;;llZZZZ88888888888I888b ]I888888888Z;;;;' ";llllll;..;;;lllZZZZ88888888888I8888, II888888888Zl.;;"Y88bd888P";;,..;lllZZZZZ88888888888I8888I II8888888888Zl;.; `"PPP";;;,..;lllZZZZZZZ88888888888I88888 II888888888888Zl;;. `;;;l;;;;lllZZZZZZZZW88888888888I88888 `II8888888888888Zl;. ,;;lllZZZZZZZZWMZ88888888888I88888 II8888888888888888ZbaalllZZZZZZZZZWWMZZZ8888888888I888888, `II88888888888888888b"WWZZZZZWWWMMZZZZZZI888888888I888888b `II88888888888888888;ZZMMMMMMZZZZZZZZllI888888888I8888888 `II8888888888888888 `;lZZZZZZZZZZZlllll888888888I8888888, II8888888888888888, `;lllZZZZllllll;;.Y88888888I8888888b, ,II8888888888888888b .;;lllllll;;;.;..88888888I88888888b, II888888888888888PZI;. .`;;;.;;;..; ...88888888I8888888888, II888888888888PZ;;';;. ;. .;. .;. .. Y8888888I88888888888b, ,II888888888PZ;;' `8888888I8888888888888b, II888888888' 888888I8888888888888888 ,II888888888 ,888888I8888888888888888 ,d88888888888 d888888I8888888888ZZZZZZ ,ad888888888888I 8888888I8888ZZZZZZZZZZZZ 888888888888888' 888888IZZZZZZZZZZZZZZZZZ 8888888888P'8P' Y888ZZZZZZZZZZZZZZZZZZZZ 888888888, " ,ZZZZZZZZZZZZZZZZZZZZZZZ 8888888888, ,ZZZZZZZZZZZZZZZZZZZZZZZZZZ 888888888888a, _ ,ZZZZZZZZZZZZZZZZZZZZ88888888 888888888888888ba,_d' ,ZZZZZZZZZZZZZZZZZ8888888888888 8888888888888888888888bbbaaa,,,______,ZZZZZZZZZZZZZZZ88888888888888888 88888888888888888888888888888888888ZZZZZZZZZZZZZZZ88888888888888888888 8888888888888888888888888888888888ZZZZZZZZZZZZZZ8888888888888888888888 888888888888888888888888888888888ZZZZZZZZZZZZZZ88888888888888888888888 8888888888888888888888888888888ZZZZZZZZZZZZZZ8888888888888888888888888 88888888888888888888888888888ZZZZZZZZZZZZZZ888888888888888888888888888 8888888888888888888888888888ZZZZZZZZZZZZZZ88888888888888888 Normand 8 88888888888888888888888888ZZZZZZZZZZZZZZ8888888888888888888 Veilleux 8 8888888888888888888888888ZZZZZZZZZZZZZZ8888888888888888888888888888888
Diffstat (limited to 'collections')
-rw-r--r--collections/activities.js51
-rw-r--r--collections/attachments.js79
-rw-r--r--collections/boards.js251
-rw-r--r--collections/cards.js287
-rw-r--r--collections/lists.js94
-rw-r--r--collections/users.js106
6 files changed, 868 insertions, 0 deletions
diff --git a/collections/activities.js b/collections/activities.js
new file mode 100644
index 00000000..1e24cf7c
--- /dev/null
+++ b/collections/activities.js
@@ -0,0 +1,51 @@
+// Activities don't need a schema because they are always set from the a trusted
+// environment - the server - and there is no risk that a user change the logic
+// we use with this collection. Moreover using a schema for this collection
+// would be difficult (different activities have different fields) and wouldn't
+// bring any direct advantage.
+//
+// XXX The activities API is not so nice and need some functionalities. For
+// instance if a user archive a card, and un-archive it a few seconds later we
+// should remove both activities assuming it was an error the user decided to
+// revert.
+Activities = new Mongo.Collection('activities');
+
+Activities.helpers({
+ board: function() {
+ return Boards.findOne(this.boardId);
+ },
+ user: function() {
+ return Users.findOne(this.userId);
+ },
+ member: function() {
+ return Users.findOne(this.memberId);
+ },
+ list: function() {
+ return Lists.findOne(this.listId);
+ },
+ oldList: function() {
+ return Lists.findOne(this.oldListId);
+ },
+ card: function() {
+ return Cards.findOne(this.cardId);
+ },
+ comment: function() {
+ return CardComments.findOne(this.commentId);
+ },
+ attachment: function() {
+ return Attachments.findOne(this.attachmentId);
+ }
+});
+
+Activities.before.insert(function(userId, doc) {
+ doc.createdAt = new Date();
+});
+
+// For efficiency create an index on the date of creation.
+if (Meteor.isServer) {
+ Meteor.startup(function() {
+ Activities._collection._ensureIndex({
+ createdAt: -1
+ });
+ });
+}
diff --git a/collections/attachments.js b/collections/attachments.js
new file mode 100644
index 00000000..c8fe6b18
--- /dev/null
+++ b/collections/attachments.js
@@ -0,0 +1,79 @@
+Attachments = new FS.Collection('attachments', {
+ stores: [
+
+ // XXX Add a new store for cover thumbnails so we don't load big images in
+ // the general board view
+ new FS.Store.GridFS('attachments')
+ ]
+});
+
+if (Meteor.isServer) {
+ Attachments.allow({
+ insert: function(userId, doc) {
+ return allowIsBoardMember(userId, Boards.findOne(doc.boardId));
+ },
+ update: function(userId, doc) {
+ return allowIsBoardMember(userId, Boards.findOne(doc.boardId));
+ },
+ remove: function(userId, doc) {
+ return allowIsBoardMember(userId, Boards.findOne(doc.boardId));
+ },
+ // We authorize the attachment download either:
+ // - if the board is public, everyone (even unconnected) can download it
+ // - if the board is private, only board members can download it
+ //
+ // XXX We have a bug with the `userId` verification:
+ //
+ // https://github.com/CollectionFS/Meteor-CollectionFS/issues/449
+ //
+ download: function(userId, doc) {
+ var query = {
+ $or: [
+ { 'members.userId': userId },
+ { permission: 'public' }
+ ]
+ };
+ return !! Boards.findOne(doc.boardId, query);
+ },
+
+ fetch: ['boardId']
+ });
+}
+
+// XXX Enforce a schema for the Attachments CollectionFS
+
+Attachments.files.before.insert(function(userId, doc) {
+ var file = new FS.File(doc);
+ doc.userId = userId;
+
+ // If the uploaded document is not an image we need to enforce browser
+ // download instead of execution. This is particularly important for HTML
+ // files that the browser will just execute if we don't serve them with the
+ // appropriate `application/octet-stream` MIME header which can lead to user
+ // data leaks. I imagine other formats (like PDF) can also be attack vectors.
+ // See https://github.com/libreboard/libreboard/issues/99
+ // XXX Should we use `beforeWrite` option of CollectionFS instead of
+ // collection-hooks?
+ if (! file.isImage()) {
+ file.original.type = 'application/octet-stream';
+ }
+});
+
+if (Meteor.isServer) {
+ Attachments.files.after.insert(function(userId, doc) {
+ Activities.insert({
+ type: 'card',
+ activityType: 'addAttachment',
+ attachmentId: doc._id,
+ boardId: doc.boardId,
+ cardId: doc.cardId,
+ userId: userId
+ });
+ });
+
+ Attachments.files.after.remove(function(userId, doc) {
+ Activities.remove({
+ attachmentId: doc._id
+ });
+ });
+}
diff --git a/collections/boards.js b/collections/boards.js
new file mode 100644
index 00000000..e406b10c
--- /dev/null
+++ b/collections/boards.js
@@ -0,0 +1,251 @@
+Boards = new Mongo.Collection('boards');
+
+Boards.attachSchema(new SimpleSchema({
+ title: {
+ type: String
+ },
+ slug: {
+ type: String
+ },
+ archived: {
+ type: Boolean
+ },
+ createdAt: {
+ type: Date,
+ denyUpdate: true
+ },
+ // XXX Inconsistent field naming
+ modifiedAt: {
+ type: Date,
+ denyInsert: true,
+ optional: true
+ },
+ // De-normalized number of users that have starred this board
+ stars: {
+ type: Number
+ },
+ // De-normalized label system
+ 'labels.$._id': {
+ // We don't specify that this field must be unique in the board because that
+ // will cause performance penalties and is not necessary since this field is
+ // always set on the server.
+ // XXX Actually if we create a new label, the `_id` is set on the client
+ // without being overwritten by the server, could it be a problem?
+ type: String
+ },
+ 'labels.$.name': {
+ type: String,
+ optional: true
+ },
+ 'labels.$.color': {
+ type: String,
+ allowedValues: [
+ 'green', 'yellow', 'orange', 'red', 'purple',
+ 'blue', 'sky', 'lime', 'pink', 'black'
+ ]
+ },
+ // XXX We might want to maintain more informations under the member sub-
+ // documents like de-normalized meta-data (the date the member joined the
+ // board, the number of contributions, etc.).
+ 'members.$.userId': {
+ type: String
+ },
+ 'members.$.isAdmin': {
+ type: Boolean
+ },
+ 'members.$.isActive': {
+ type: Boolean
+ },
+ permission: {
+ type: String,
+ allowedValues: ['public', 'private']
+ },
+ color: {
+ type: String,
+ allowedValues: ['nephritis', 'pomegranate', 'belize',
+ 'wisteria', 'midnight', 'pumpkin']
+ }
+}));
+
+if (Meteor.isServer) {
+ Boards.allow({
+ insert: Meteor.userId,
+ update: allowIsBoardAdmin,
+ remove: allowIsBoardAdmin,
+ fetch: ['members']
+ });
+
+ // The number of users that have starred this board is managed by trusted code
+ // and the user is not allowed to update it
+ Boards.deny({
+ update: function(userId, board, fieldNames) {
+ return _.contains(fieldNames, 'stars');
+ },
+ fetch: []
+ });
+
+ // We can't remove a member if it is the last administrator
+ Boards.deny({
+ update: function(userId, doc, fieldNames, modifier) {
+ if (! _.contains(fieldNames, 'members'))
+ return false;
+
+ // We only care in case of a $pull operation, ie remove a member
+ if (! _.isObject(modifier.$pull && modifier.$pull.members))
+ return false;
+
+ // If there is more than one admin, it's ok to remove anyone
+ var nbAdmins = _.filter(doc.members, function(member) {
+ return member.isAdmin;
+ }).length;
+ if (nbAdmins > 1)
+ return false;
+
+ // If all the previous conditions where verified, we can't remove
+ // a user if it's an admin
+ var removedMemberId = modifier.$pull.members.userId;
+ return !! _.findWhere(doc.members, {
+ userId: removedMemberId,
+ isAdmin: true
+ });
+ },
+ fetch: ['members']
+ });
+}
+
+Boards.helpers({
+ isPublic: function() {
+ return this.permission === 'public';
+ },
+ lists: function() {
+ return Lists.find({ boardId: this._id, archived: false },
+ { sort: { sort: 1 }});
+ },
+ activities: function() {
+ return Activities.find({ boardId: this._id }, { sort: { createdAt: -1 }});
+ },
+ absoluteUrl: function() {
+ return Router.path('Board', { boardId: this._id, slug: this.slug });
+ },
+ colorClass: function() {
+ return 'board-color-' + this.color;
+ }
+});
+
+Boards.before.insert(function(userId, doc) {
+ // XXX We need to improve slug management. Only the id should be necessary
+ // to identify a board in the code.
+ // XXX If the board title is updated, the slug should also be updated.
+ // In some cases (Chinese and Japanese for instance) the `getSlug` function
+ // return an empty string. This is causes bugs in our application so we set
+ // a default slug in this case.
+ doc.slug = getSlug(doc.title) || 'board';
+ doc.createdAt = new Date();
+ doc.archived = false;
+ doc.members = [{
+ userId: userId,
+ isAdmin: true,
+ isActive: true
+ }];
+ doc.stars = 0;
+ doc.color = Boards.simpleSchema()._schema.color.allowedValues[0];
+
+ // Handle labels
+ var colors = Boards.simpleSchema()._schema['labels.$.color'].allowedValues;
+ var defaultLabelsColors = _.clone(colors).splice(0, 6);
+ doc.labels = [];
+ _.each(defaultLabelsColors, function(val) {
+ doc.labels.push({
+ _id: Random.id(6),
+ name: '',
+ color: val
+ });
+ });
+
+ // We randomly chose one of the default background colors for the board
+ if (Meteor.isClient) {
+ doc.background = {
+ type: 'color',
+ color: Random.choice(Boards.simpleSchema()._schema.color.allowedValues)
+ };
+ }
+});
+
+Boards.before.update(function(userId, doc, fieldNames, modifier) {
+ modifier.$set = modifier.$set || {};
+ modifier.$set.modifiedAt = new Date();
+});
+
+if (Meteor.isServer) {
+ // Let MongoDB ensure that a member is not included twice in the same board
+ Meteor.startup(function() {
+ Boards._collection._ensureIndex({
+ _id: 1,
+ 'members.userId': 1
+ }, { unique: true });
+ });
+
+ // Genesis: the first activity of the newly created board
+ Boards.after.insert(function(userId, doc) {
+ Activities.insert({
+ type: 'board',
+ activityTypeId: doc._id,
+ activityType: 'createBoard',
+ boardId: doc._id,
+ userId: userId
+ });
+ });
+
+ // If the user remove one label from a board, we cant to remove reference of
+ // this label in any card of this board.
+ Boards.after.update(function(userId, doc, fieldNames, modifier) {
+ if (! _.contains(fieldNames, 'labels') ||
+ ! modifier.$pull ||
+ ! modifier.$pull.labels ||
+ ! modifier.$pull.labels._id)
+ return;
+
+ var removedLabelId = modifier.$pull.labels._id;
+ Cards.update(
+ { boardId: doc._id },
+ {
+ $pull: {
+ labels: removedLabelId
+ }
+ },
+ { multi: true }
+ );
+ });
+
+ // Add a new activity if we add or remove a member to the board
+ Boards.after.update(function(userId, doc, fieldNames, modifier) {
+ if (! _.contains(fieldNames, 'members'))
+ return;
+
+ var memberId;
+
+ // Say hello to the new member
+ if (modifier.$push && modifier.$push.members) {
+ memberId = modifier.$push.members.userId;
+ Activities.insert({
+ type: 'member',
+ activityType: 'addBoardMember',
+ boardId: doc._id,
+ userId: userId,
+ memberId: memberId
+ });
+ }
+
+ // Say goodbye to the former member
+ if (modifier.$pull && modifier.$pull.members) {
+ memberId = modifier.$pull.members.userId;
+ Activities.insert({
+ type: 'member',
+ activityType: 'removeBoardMember',
+ boardId: doc._id,
+ userId: userId,
+ memberId: memberId
+ });
+ }
+ });
+}
diff --git a/collections/cards.js b/collections/cards.js
new file mode 100644
index 00000000..538b6af4
--- /dev/null
+++ b/collections/cards.js
@@ -0,0 +1,287 @@
+Cards = new Mongo.Collection('cards');
+CardComments = new Mongo.Collection('card_comments');
+
+// XXX To improve pub/sub performances a card document should include a
+// de-normalized number of comments so we don't have to publish the whole list
+// of comments just to display the number of them in the board view.
+Cards.attachSchema(new SimpleSchema({
+ title: {
+ type: String
+ },
+ archived: {
+ type: Boolean
+ },
+ listId: {
+ type: String
+ },
+ // The system could work without this `boardId` information (we could deduce
+ // the board identifier from the card), but it would make the system more
+ // difficult to manage and less efficient.
+ boardId: {
+ type: String
+ },
+ coverId: {
+ type: String,
+ optional: true
+ },
+ createdAt: {
+ type: Date,
+ denyUpdate: true
+ },
+ dateLastActivity: {
+ type: Date
+ },
+ description: {
+ type: String,
+ optional: true
+ },
+ labelIds: {
+ type: [String],
+ optional: true
+ },
+ members: {
+ type: [String],
+ optional: true
+ },
+ // XXX Should probably be called `authorId`. Is it even needed since we have
+ // the `members` field?
+ userId: {
+ type: String
+ },
+ sort: {
+ type: Number,
+ decimal: true
+ }
+}));
+
+CardComments.attachSchema(new SimpleSchema({
+ boardId: {
+ type: String
+ },
+ cardId: {
+ type: String
+ },
+ // XXX Rename in `content`? `text` is a bit vague...
+ text: {
+ type: String
+ },
+ // XXX We probably don't need this information here, since we already have it
+ // in the associated comment creation activity
+ createdAt: {
+ type: Date,
+ denyUpdate: false
+ },
+ // XXX Should probably be called `authorId`
+ userId: {
+ type: String
+ }
+}));
+
+if (Meteor.isServer) {
+ Cards.allow({
+ insert: function(userId, doc) {
+ return allowIsBoardMember(userId, Boards.findOne(doc.boardId));
+ },
+ update: function(userId, doc) {
+ return allowIsBoardMember(userId, Boards.findOne(doc.boardId));
+ },
+ remove: function(userId, doc) {
+ return allowIsBoardMember(userId, Boards.findOne(doc.boardId));
+ },
+ fetch: ['boardId']
+ });
+
+ CardComments.allow({
+ insert: function(userId, doc) {
+ return allowIsBoardMember(userId, Boards.findOne(doc.boardId));
+ },
+ update: function(userId, doc) {
+ return userId === doc.userId;
+ },
+ remove: function(userId, doc) {
+ return userId === doc.userId;
+ },
+ fetch: ['userId', 'boardId']
+ });
+}
+
+Cards.helpers({
+ list: function() {
+ return Lists.findOne(this.listId);
+ },
+ board: function() {
+ return Boards.findOne(this.boardId);
+ },
+ labels: function() {
+ var self = this;
+ var boardLabels = self.board().labels;
+ var cardLabels = _.filter(boardLabels, function(label) {
+ return _.contains(self.labelIds, label._id);
+ });
+ return cardLabels;
+ },
+ user: function() {
+ return Users.findOne(this.userId);
+ },
+ activities: function() {
+ return Activities.find({ type: 'card', cardId: this._id },
+ { sort: { createdAt: -1 }});
+ },
+ comments: function() {
+ return CardComments.find({ cardId: this._id }, { sort: { createdAt: -1 }});
+ },
+ attachments: function() {
+ return Attachments.find({ cardId: this._id }, { sort: { uploadedAt: -1 }});
+ },
+ cover: function() {
+ return Attachments.findOne(this.coverId);
+ },
+ absoluteUrl: function() {
+ var board = this.board();
+ return Router.path('Card', {
+ boardId: board._id,
+ slug: board.slug,
+ cardId: this._id
+ });
+ },
+ rootUrl: function() {
+ return Meteor.absoluteUrl(this.absoluteUrl().replace('/', ''));
+ }
+});
+
+CardComments.helpers({
+ user: function() {
+ return Users.findOne(this.userId);
+ }
+});
+
+CardComments.hookOptions.after.update = { fetchPrevious: false };
+Cards.before.insert(function(userId, doc) {
+ doc.createdAt = new Date();
+ doc.dateLastActivity = new Date();
+
+ // defaults
+ doc.archived = false;
+
+ // userId native set.
+ if (! doc.userId)
+ doc.userId = userId;
+});
+
+CardComments.before.insert(function(userId, doc) {
+ doc.createdAt = new Date();
+ doc.userId = userId;
+});
+
+if (Meteor.isServer) {
+ Cards.after.insert(function(userId, doc) {
+ Activities.insert({
+ type: 'card',
+ activityType: 'createCard',
+ boardId: doc.boardId,
+ listId: doc.listId,
+ cardId: doc._id,
+ userId: userId
+ });
+ });
+
+ // New activity for card (un)archivage
+ Cards.after.update(function(userId, doc, fieldNames) {
+ if (_.contains(fieldNames, 'archived')) {
+ if (doc.archived) {
+ Activities.insert({
+ type: 'card',
+ activityType: 'archivedCard',
+ boardId: doc.boardId,
+ listId: doc.listId,
+ cardId: doc._id,
+ userId: userId
+ });
+ } else {
+ Activities.insert({
+ type: 'card',
+ activityType: 'restoredCard',
+ boardId: doc.boardId,
+ listId: doc.listId,
+ cardId: doc._id,
+ userId: userId
+ });
+ }
+ }
+ });
+
+ // New activity for card moves
+ Cards.after.update(function(userId, doc, fieldNames) {
+ var oldListId = this.previous.listId;
+ if (_.contains(fieldNames, 'listId') && doc.listId !== oldListId) {
+ Activities.insert({
+ type: 'card',
+ activityType: 'moveCard',
+ listId: doc.listId,
+ oldListId: oldListId,
+ boardId: doc.boardId,
+ cardId: doc._id,
+ userId: userId
+ });
+ }
+ });
+
+ // Add a new activity if we add or remove a member to the card
+ Cards.before.update(function(userId, doc, fieldNames, modifier) {
+ if (! _.contains(fieldNames, 'members'))
+ return;
+ var memberId;
+ // Say hello to the new member
+ if (modifier.$addToSet && modifier.$addToSet.members) {
+ memberId = modifier.$addToSet.members;
+ if (! _.contains(doc.members, memberId)) {
+ Activities.insert({
+ type: 'card',
+ activityType: 'joinMember',
+ boardId: doc.boardId,
+ cardId: doc._id,
+ userId: userId,
+ memberId: memberId
+ });
+ }
+ }
+
+ // Say goodbye to the former member
+ if (modifier.$pull && modifier.$pull.members) {
+ memberId = modifier.$pull.members;
+ Activities.insert({
+ type: 'card',
+ activityType: 'unjoinMember',
+ boardId: doc.boardId,
+ cardId: doc._id,
+ userId: userId,
+ memberId: memberId
+ });
+ }
+ });
+
+ // Remove all activities associated with a card if we remove the card
+ Cards.after.remove(function(userId, doc) {
+ Activities.remove({
+ cardId: doc._id
+ });
+ });
+
+ CardComments.after.insert(function(userId, doc) {
+ Activities.insert({
+ type: 'comment',
+ activityType: 'addComment',
+ boardId: doc.boardId,
+ cardId: doc.cardId,
+ commentId: doc._id,
+ userId: userId
+ });
+ });
+
+ CardComments.after.remove(function(userId, doc) {
+ var activity = Activities.findOne({ commentId: doc._id });
+ if (activity) {
+ Activities.remove(activity._id);
+ }
+ });
+}
diff --git a/collections/lists.js b/collections/lists.js
new file mode 100644
index 00000000..196477ec
--- /dev/null
+++ b/collections/lists.js
@@ -0,0 +1,94 @@
+Lists = new Mongo.Collection('lists');
+
+Lists.attachSchema(new SimpleSchema({
+ title: {
+ type: String
+ },
+ archived: {
+ type: Boolean
+ },
+ boardId: {
+ type: String
+ },
+ createdAt: {
+ type: Date,
+ denyUpdate: true
+ },
+ sort: {
+ type: Number,
+ decimal: true,
+ // XXX We should probably provide a default
+ optional: true
+ },
+ updatedAt: {
+ type: Date,
+ denyInsert: true,
+ optional: true
+ }
+}));
+
+if (Meteor.isServer) {
+ Lists.allow({
+ insert: function(userId, doc) {
+ return allowIsBoardMember(userId, Boards.findOne(doc.boardId));
+ },
+ update: function(userId, doc) {
+ return allowIsBoardMember(userId, Boards.findOne(doc.boardId));
+ },
+ remove: function(userId, doc) {
+ return allowIsBoardMember(userId, Boards.findOne(doc.boardId));
+ },
+ fetch: ['boardId']
+ });
+}
+
+Lists.helpers({
+ cards: function() {
+ return Cards.find(_.extend(Filter.getMongoSelector(), {
+ listId: this._id,
+ archived: false
+ }), { sort: ['sort'] });
+ },
+ board: function() {
+ return Boards.findOne(this.boardId);
+ }
+});
+
+// HOOKS
+Lists.hookOptions.after.update = { fetchPrevious: false };
+
+Lists.before.insert(function(userId, doc) {
+ doc.createdAt = new Date();
+ doc.archived = false;
+ if (! doc.userId)
+ doc.userId = userId;
+});
+
+Lists.before.update(function(userId, doc, fieldNames, modifier) {
+ modifier.$set = modifier.$set || {};
+ modifier.$set.modifiedAt = new Date();
+});
+
+if (Meteor.isServer) {
+ Lists.after.insert(function(userId, doc) {
+ Activities.insert({
+ type: 'list',
+ activityType: 'createList',
+ boardId: doc.boardId,
+ listId: doc._id,
+ userId: userId
+ });
+ });
+
+ Lists.after.update(function(userId, doc) {
+ if (doc.archived) {
+ Activities.insert({
+ type: 'list',
+ activityType: 'archivedList',
+ listId: doc._id,
+ boardId: doc.boardId,
+ userId: userId
+ });
+ }
+ });
+}
diff --git a/collections/users.js b/collections/users.js
new file mode 100644
index 00000000..1dcccf12
--- /dev/null
+++ b/collections/users.js
@@ -0,0 +1,106 @@
+Users = Meteor.users;
+
+// Search a user in the complete server database by its name or username. This
+// is used for instance to add a new user to a board.
+var searchInFields = ['username', 'profile.name'];
+Users.initEasySearch(searchInFields, {
+ use: 'mongo-db',
+ returnFields: searchInFields
+});
+
+Users.helpers({
+ boards: function() {
+ return Boards.find({ userId: this._id });
+ },
+ starredBoards: function() {
+ var starredBoardIds = this.profile.starredBoards || [];
+ return Boards.find({_id: {$in: starredBoardIds}});
+ },
+ hasStarred: function(boardId) {
+ var starredBoardIds = this.profile.starredBoards || [];
+ return _.contains(starredBoardIds, boardId);
+ },
+ isBoardMember: function() {
+ var board = Boards.findOne(Session.get('currentBoard'));
+ return board && _.contains(_.pluck(board.members, 'userId'), this._id) &&
+ _.where(board.members, {userId: this._id})[0].isActive;
+ },
+ isBoardAdmin: function() {
+ var board = Boards.findOne(Session.get('currentBoard'));
+ if (this.isBoardMember(board))
+ return _.where(board.members, {userId: this._id})[0].isAdmin;
+ }
+});
+
+Users.before.insert(function(userId, doc) {
+ doc.profile = {};
+
+ // connect profile.status default
+ doc.profile.status = 'offline';
+
+ // slugify to username
+ //doc.username = getSlug(doc.profile.name, '');
+});
+
+if (Meteor.isServer) {
+ // Each board document contains the de-normalized number of users that have
+ // starred it. If the user star or unstar a board, we need to update this
+ // counter.
+ // We need to run this code on the server only, otherwise the incrementation
+ // will be done twice.
+ Users.after.update(function(userId, user, fieldNames) {
+ // The `starredBoards` list is hosted on the `profile` field. If this
+ // field hasn't been modificated we don't need to run this hook.
+ if (! _.contains(fieldNames, 'profile'))
+ return;
+
+ // To calculate a diff of board starred ids, we get both the previous
+ // and the newly board ids list
+ var getStarredBoardsIds = function(doc) {
+ return doc.profile && doc.profile.starredBoards;
+ };
+ var oldIds = getStarredBoardsIds(this.previous);
+ var newIds = getStarredBoardsIds(user);
+
+ // The _.difference(a, b) method returns the values from a that are not in
+ // b. We use it to find deleted and newly inserted ids by using it in one
+ // direction and then in the other.
+ var incrementBoards = function(boardsIds, inc) {
+ _.forEach(boardsIds, function(boardId) {
+ Boards.update(boardId, {$inc: {stars: inc}});
+ });
+ };
+ incrementBoards(_.difference(oldIds, newIds), -1);
+ incrementBoards(_.difference(newIds, oldIds), +1);
+ });
+
+ // XXX i18n
+ Users.after.insert(function(userId, doc) {
+ var ExampleBoard = {
+ title: 'Welcome Board',
+ userId: doc._id,
+ permission: 'private'
+ };
+
+ // Insert the Welcome Board
+ Boards.insert(ExampleBoard, function(err, boardId) {
+
+ _.forEach(['Basics', 'Advanced'], function(title) {
+ var list = {
+ title: title,
+ boardId: boardId,
+ userId: ExampleBoard.userId,
+
+ // XXX Not certain this is a bug, but we except these fields get
+ // inserted by the Lists.before.insert collection-hook. Since this
+ // hook is not called in this case, we have to dublicate the logic and
+ // set them here.
+ archived: false,
+ createdAt: new Date()
+ };
+
+ Lists.insert(list);
+ });
+ });
+ });
+}