summaryrefslogtreecommitdiffstats
path: root/models
diff options
context:
space:
mode:
authorMaxime Quandalle <maxime@quandalle.com>2015-09-08 20:19:42 +0200
committerMaxime Quandalle <maxime@quandalle.com>2015-09-08 20:19:42 +0200
commit45b662a1ddb46a0f17fab7b2383c82aa1e1620ef (patch)
treecc7be215c7e7ebffd2597df70cf271b3dd435e1a /models
parentc04341f1ea5efe082bf7318cf9eb0e99b9b8374a (diff)
downloadwekan-45b662a1ddb46a0f17fab7b2383c82aa1e1620ef.tar.gz
wekan-45b662a1ddb46a0f17fab7b2383c82aa1e1620ef.tar.bz2
wekan-45b662a1ddb46a0f17fab7b2383c82aa1e1620ef.zip
Centralize all mutations at the model level
This commit uses a new package that I need to document. It tries to solve the long-standing debate in the Meteor community about allow/deny rules versus methods (RPC). This approach gives us both the centralized security rules of allow/deny and the white-list of allowed mutations similarly to Meteor methods. The idea to have static mutation descriptions is also inspired by Facebook's Relay/GraphQL. This will allow the development of a REST API using the high-level methods instead of the MongoDB queries to do the mapping between the HTTP requests and our collections.
Diffstat (limited to 'models')
-rw-r--r--models/activities.js51
-rw-r--r--models/attachments.js79
-rw-r--r--models/avatars.js27
-rw-r--r--models/boards.js348
-rw-r--r--models/cardComments.js69
-rw-r--r--models/cards.js291
-rw-r--r--models/lists.js110
-rw-r--r--models/unsavedEdits.js34
-rw-r--r--models/users.js157
9 files changed, 1166 insertions, 0 deletions
diff --git a/models/activities.js b/models/activities.js
new file mode 100644
index 00000000..5de07ee5
--- /dev/null
+++ b/models/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() {
+ return Boards.findOne(this.boardId);
+ },
+ user() {
+ return Users.findOne(this.userId);
+ },
+ member() {
+ return Users.findOne(this.memberId);
+ },
+ list() {
+ return Lists.findOne(this.listId);
+ },
+ oldList() {
+ return Lists.findOne(this.oldListId);
+ },
+ card() {
+ return Cards.findOne(this.cardId);
+ },
+ comment() {
+ return CardComments.findOne(this.commentId);
+ },
+ attachment() {
+ return Attachments.findOne(this.attachmentId);
+ },
+});
+
+Activities.before.insert((userId, doc) => {
+ doc.createdAt = new Date();
+});
+
+// For efficiency create an index on the date of creation.
+if (Meteor.isServer) {
+ Meteor.startup(() => {
+ Activities._collection._ensureIndex({
+ createdAt: -1,
+ });
+ });
+}
diff --git a/models/attachments.js b/models/attachments.js
new file mode 100644
index 00000000..8ef0fef0
--- /dev/null
+++ b/models/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(userId, doc) {
+ return allowIsBoardMember(userId, Boards.findOne(doc.boardId));
+ },
+ update(userId, doc) {
+ return allowIsBoardMember(userId, Boards.findOne(doc.boardId));
+ },
+ remove(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(userId, doc) {
+ const query = {
+ $or: [
+ { 'members.userId': userId },
+ { permission: 'public' },
+ ],
+ };
+ return Boolean(Boards.findOne(doc.boardId, query));
+ },
+
+ fetch: ['boardId'],
+ });
+}
+
+// XXX Enforce a schema for the Attachments CollectionFS
+
+Attachments.files.before.insert((userId, doc) => {
+ const 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((userId, doc) => {
+ Activities.insert({
+ userId,
+ type: 'card',
+ activityType: 'addAttachment',
+ attachmentId: doc._id,
+ boardId: doc.boardId,
+ cardId: doc.cardId,
+ });
+ });
+
+ Attachments.files.after.remove((userId, doc) => {
+ Activities.remove({
+ attachmentId: doc._id,
+ });
+ });
+}
diff --git a/models/avatars.js b/models/avatars.js
new file mode 100644
index 00000000..53924ffb
--- /dev/null
+++ b/models/avatars.js
@@ -0,0 +1,27 @@
+Avatars = new FS.Collection('avatars', {
+ stores: [
+ new FS.Store.GridFS('avatars'),
+ ],
+ filter: {
+ maxSize: 72000,
+ allow: {
+ contentTypes: ['image/*'],
+ },
+ },
+});
+
+function isOwner(userId, file) {
+ return userId && userId === file.userId;
+}
+
+Avatars.allow({
+ insert: isOwner,
+ update: isOwner,
+ remove: isOwner,
+ download() { return true; },
+ fetch: ['userId'],
+});
+
+Avatars.files.before.insert((userId, doc) => {
+ doc.userId = userId;
+});
diff --git a/models/boards.js b/models/boards.js
new file mode 100644
index 00000000..4baec280
--- /dev/null
+++ b/models/boards.js
@@ -0,0 +1,348 @@
+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: [
+ 'belize',
+ 'nephritis',
+ 'pomegranate',
+ 'pumpkin',
+ 'wisteria',
+ 'midnight',
+ ],
+ },
+}));
+
+
+Boards.helpers({
+ isPublic() {
+ return this.permission === 'public';
+ },
+
+ lists() {
+ return Lists.find({ boardId: this._id, archived: false },
+ { sort: { sort: 1 }});
+ },
+
+ activities() {
+ return Activities.find({ boardId: this._id }, { sort: { createdAt: -1 }});
+ },
+
+ activeMembers() {
+ return _.where(this.members, {isActive: true});
+ },
+
+ labelIndex(labelId) {
+ return _.indexOf(_.pluck(this.labels, '_id'), labelId);
+ },
+
+ memberIndex(memberId) {
+ return _.indexOf(_.pluck(this.members, 'userId'), memberId);
+ },
+
+ absoluteUrl() {
+ return FlowRouter.path('board', { id: this._id, slug: this.slug });
+ },
+
+ colorClass() {
+ return `board-color-${this.color}`;
+ },
+});
+
+Boards.mutations({
+ archive() {
+ return { $set: { archived: true }};
+ },
+
+ restore() {
+ return { $set: { archived: false }};
+ },
+
+ rename(title) {
+ return { $set: { title }};
+ },
+
+ setColor(color) {
+ return { $set: { color }};
+ },
+
+ setVisibility(visibility) {
+ return { $set: { permission: visibility }};
+ },
+
+ addLabel(name, color) {
+ const _id = Random.id(6);
+ return { $push: {labels: { _id, name, color }}};
+ },
+
+ editLabel(labelId, name, color) {
+ const labelIndex = this.labelIndex(labelId);
+ return {
+ $set: {
+ [`labels.${labelIndex}.name`]: name,
+ [`labels.${labelIndex}.color`]: color,
+ },
+ };
+ },
+
+ removeLabel(labelId) {
+ return { $pull: { labels: { _id: labelId }}};
+ },
+
+ addMember(memberId) {
+ const memberIndex = this.memberIndex(memberId);
+ if (memberIndex === -1) {
+ return {
+ $push: {
+ members: {
+ userId: memberId,
+ isAdmin: false,
+ isActive: true,
+ },
+ },
+ };
+ } else {
+ return {
+ $set: {
+ [`members.${memberIndex}.isActive`]: true,
+ [`members.${memberIndex}.isAdmin`]: false,
+ },
+ };
+ }
+ },
+
+ removeMember(memberId) {
+ const memberIndex = this.memberIndex(memberId);
+
+ return {
+ $set: {
+ [`members.${memberIndex}.isActive`]: false,
+ },
+ };
+ },
+
+ setMemberPermission(memberId, isAdmin) {
+ const memberIndex = this.memberIndex(memberId);
+
+ return {
+ $set: {
+ [`members.${memberIndex}.isAdmin`]: isAdmin,
+ },
+ };
+ },
+});
+
+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(userId, board, fieldNames) {
+ return _.contains(fieldNames, 'stars');
+ },
+ fetch: [],
+ });
+
+ // We can't remove a member if it is the last administrator
+ Boards.deny({
+ update(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
+ const nbAdmins = _.filter(doc.members, (member) => {
+ return member.isAdmin;
+ }).length;
+ if (nbAdmins > 1)
+ return false;
+
+ // If all the previous conditions were verified, we can't remove
+ // a user if it's an admin
+ const removedMemberId = modifier.$pull.members.userId;
+ return Boolean(_.findWhere(doc.members, {
+ userId: removedMemberId,
+ isAdmin: true,
+ }));
+ },
+ fetch: ['members'],
+ });
+}
+
+Boards.before.insert((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 = doc.slug || getSlug(doc.title) || 'board';
+ doc.createdAt = new Date();
+ doc.archived = false;
+ doc.members = doc.members || [{
+ userId,
+ isAdmin: true,
+ isActive: true,
+ }];
+ doc.stars = 0;
+ doc.color = Boards.simpleSchema()._schema.color.allowedValues[0];
+
+ // Handle labels
+ const colors = Boards.simpleSchema()._schema['labels.$.color'].allowedValues;
+ const defaultLabelsColors = _.clone(colors).splice(0, 6);
+ doc.labels = _.map(defaultLabelsColors, (color) => {
+ return {
+ color,
+ _id: Random.id(6),
+ name: '',
+ };
+ });
+});
+
+Boards.before.update((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(() => {
+ Boards._collection._ensureIndex({
+ _id: 1,
+ 'members.userId': 1,
+ }, { unique: true });
+ });
+
+ // Genesis: the first activity of the newly created board
+ Boards.after.insert((userId, doc) => {
+ Activities.insert({
+ userId,
+ type: 'board',
+ activityTypeId: doc._id,
+ activityType: 'createBoard',
+ boardId: doc._id,
+ });
+ });
+
+ // 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((userId, doc, fieldNames, modifier) => {
+ if (!_.contains(fieldNames, 'labels') ||
+ !modifier.$pull ||
+ !modifier.$pull.labels ||
+ !modifier.$pull.labels._id)
+ return;
+
+ const 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((userId, doc, fieldNames, modifier) => {
+ if (!_.contains(fieldNames, 'members'))
+ return;
+
+ let memberId;
+
+ // Say hello to the new member
+ if (modifier.$push && modifier.$push.members) {
+ memberId = modifier.$push.members.userId;
+ Activities.insert({
+ userId,
+ memberId,
+ type: 'member',
+ activityType: 'addBoardMember',
+ boardId: doc._id,
+ });
+ }
+
+ // Say goodbye to the former member
+ if (modifier.$pull && modifier.$pull.members) {
+ memberId = modifier.$pull.members.userId;
+ Activities.insert({
+ userId,
+ memberId,
+ type: 'member',
+ activityType: 'removeBoardMember',
+ boardId: doc._id,
+ });
+ }
+ });
+}
diff --git a/models/cardComments.js b/models/cardComments.js
new file mode 100644
index 00000000..224deb03
--- /dev/null
+++ b/models/cardComments.js
@@ -0,0 +1,69 @@
+CardComments = new Mongo.Collection('card_comments');
+
+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,
+ },
+}));
+
+CardComments.allow({
+ insert(userId, doc) {
+ return allowIsBoardMember(userId, Boards.findOne(doc.boardId));
+ },
+ update(userId, doc) {
+ return userId === doc.userId;
+ },
+ remove(userId, doc) {
+ return userId === doc.userId;
+ },
+ fetch: ['userId', 'boardId'],
+});
+
+CardComments.helpers({
+ user() {
+ return Users.findOne(this.userId);
+ },
+});
+
+CardComments.hookOptions.after.update = { fetchPrevious: false };
+
+CardComments.before.insert((userId, doc) => {
+ doc.createdAt = new Date();
+ doc.userId = userId;
+});
+
+if (Meteor.isServer) {
+ CardComments.after.insert((userId, doc) => {
+ Activities.insert({
+ userId,
+ activityType: 'addComment',
+ boardId: doc.boardId,
+ cardId: doc.cardId,
+ commentId: doc._id,
+ });
+ });
+
+ CardComments.after.remove((userId, doc) => {
+ const activity = Activities.findOne({ commentId: doc._id });
+ if (activity) {
+ Activities.remove(activity._id);
+ }
+ });
+}
diff --git a/models/cards.js b/models/cards.js
new file mode 100644
index 00000000..95943ae2
--- /dev/null
+++ b/models/cards.js
@@ -0,0 +1,291 @@
+Cards = new Mongo.Collection('cards');
+
+// 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,
+ },
+}));
+
+Cards.allow({
+ insert(userId, doc) {
+ return allowIsBoardMember(userId, Boards.findOne(doc.boardId));
+ },
+ update(userId, doc) {
+ return allowIsBoardMember(userId, Boards.findOne(doc.boardId));
+ },
+ remove(userId, doc) {
+ return allowIsBoardMember(userId, Boards.findOne(doc.boardId));
+ },
+ fetch: ['boardId'],
+});
+
+Cards.helpers({
+ list() {
+ return Lists.findOne(this.listId);
+ },
+
+ board() {
+ return Boards.findOne(this.boardId);
+ },
+
+ labels() {
+ const boardLabels = this.board().labels;
+ const cardLabels = _.filter(boardLabels, (label) => {
+ return _.contains(this.labelIds, label._id);
+ });
+ return cardLabels;
+ },
+
+ hasLabel(labelId) {
+ return _.contains(this.labelIds, labelId);
+ },
+
+ user() {
+ return Users.findOne(this.userId);
+ },
+
+ isAssigned(memberId) {
+ return _.contains(this.members, memberId);
+ },
+
+ activities() {
+ return Activities.find({ cardId: this._id }, { sort: { createdAt: -1 }});
+ },
+
+ comments() {
+ return CardComments.find({ cardId: this._id }, { sort: { createdAt: -1 }});
+ },
+
+ attachments() {
+ return Attachments.find({ cardId: this._id }, { sort: { uploadedAt: -1 }});
+ },
+
+ cover() {
+ return Attachments.findOne(this.coverId);
+ },
+
+ absoluteUrl() {
+ const board = this.board();
+ return FlowRouter.path('card', {
+ boardId: board._id,
+ slug: board.slug,
+ cardId: this._id,
+ });
+ },
+
+ rootUrl() {
+ return Meteor.absoluteUrl(this.absoluteUrl().replace('/', ''));
+ },
+});
+
+Cards.mutations({
+ archive() {
+ return { $set: { archived: true }};
+ },
+
+ restore() {
+ return { $set: { archived: false }};
+ },
+
+ setTitle(title) {
+ return { $set: { title }};
+ },
+
+ setDescription(description) {
+ return { $set: { description }};
+ },
+
+ move(listId, sortIndex) {
+ const mutatedFields = { listId };
+ if (sortIndex) {
+ mutatedFields.sort = sortIndex;
+ }
+ return { $set: mutatedFields };
+ },
+
+ addLabel(labelId) {
+ return { $addToSet: { labelIds: labelId }};
+ },
+
+ removeLabel(labelId) {
+ return { $pull: { labelIds: labelId }};
+ },
+
+ toggleLabel(labelId) {
+ if (this.labelIds && this.labelIds.indexOf(labelId) > -1) {
+ return this.removeLabel(labelId);
+ } else {
+ return this.addLabel(labelId);
+ }
+ },
+
+ assignMember(memberId) {
+ return { $addToSet: { members: memberId }};
+ },
+
+ unassignMember(memberId) {
+ return { $pull: { members: memberId }};
+ },
+
+ toggleMember(memberId) {
+ if (this.members && this.members.indexOf(memberId) > -1) {
+ return this.unassignMember(memberId);
+ } else {
+ return this.assignMember(memberId);
+ }
+ },
+
+ setCover(coverId) {
+ return { $set: { coverId }};
+ },
+
+ unsetCover() {
+ return { $unset: { coverId: '' }};
+ },
+});
+
+Cards.before.insert((userId, doc) => {
+ doc.createdAt = new Date();
+ doc.dateLastActivity = new Date();
+ doc.archived = false;
+
+ if (!doc.userId) {
+ doc.userId = userId;
+ }
+});
+
+if (Meteor.isServer) {
+ Cards.after.insert((userId, doc) => {
+ Activities.insert({
+ userId,
+ activityType: 'createCard',
+ boardId: doc.boardId,
+ listId: doc.listId,
+ cardId: doc._id,
+ });
+ });
+
+ // New activity for card (un)archivage
+ Cards.after.update((userId, doc, fieldNames) => {
+ if (_.contains(fieldNames, 'archived')) {
+ if (doc.archived) {
+ Activities.insert({
+ userId,
+ activityType: 'archivedCard',
+ boardId: doc.boardId,
+ listId: doc.listId,
+ cardId: doc._id,
+ });
+ } else {
+ Activities.insert({
+ userId,
+ activityType: 'restoredCard',
+ boardId: doc.boardId,
+ listId: doc.listId,
+ cardId: doc._id,
+ });
+ }
+ }
+ });
+
+ // New activity for card moves
+ Cards.after.update(function(userId, doc, fieldNames) {
+ const oldListId = this.previous.listId;
+ if (_.contains(fieldNames, 'listId') && doc.listId !== oldListId) {
+ Activities.insert({
+ userId,
+ oldListId,
+ activityType: 'moveCard',
+ listId: doc.listId,
+ boardId: doc.boardId,
+ cardId: doc._id,
+ });
+ }
+ });
+
+ // Add a new activity if we add or remove a member to the card
+ Cards.before.update((userId, doc, fieldNames, modifier) => {
+ if (!_.contains(fieldNames, 'members'))
+ return;
+ let memberId;
+ // Say hello to the new member
+ if (modifier.$addToSet && modifier.$addToSet.members) {
+ memberId = modifier.$addToSet.members;
+ if (!_.contains(doc.members, memberId)) {
+ Activities.insert({
+ userId,
+ memberId,
+ activityType: 'joinMember',
+ boardId: doc.boardId,
+ cardId: doc._id,
+ });
+ }
+ }
+
+ // Say goodbye to the former member
+ if (modifier.$pull && modifier.$pull.members) {
+ memberId = modifier.$pull.members;
+ Activities.insert({
+ userId,
+ memberId,
+ activityType: 'unjoinMember',
+ boardId: doc.boardId,
+ cardId: doc._id,
+ });
+ }
+ });
+
+ // Remove all activities associated with a card if we remove the card
+ Cards.after.remove((userId, doc) => {
+ Activities.remove({
+ cardId: doc._id,
+ });
+ });
+}
diff --git a/models/lists.js b/models/lists.js
new file mode 100644
index 00000000..4e4a1134
--- /dev/null
+++ b/models/lists.js
@@ -0,0 +1,110 @@
+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,
+ },
+}));
+
+Lists.allow({
+ insert(userId, doc) {
+ return allowIsBoardMember(userId, Boards.findOne(doc.boardId));
+ },
+ update(userId, doc) {
+ return allowIsBoardMember(userId, Boards.findOne(doc.boardId));
+ },
+ remove(userId, doc) {
+ return allowIsBoardMember(userId, Boards.findOne(doc.boardId));
+ },
+ fetch: ['boardId'],
+});
+
+Lists.helpers({
+ cards() {
+ return Cards.find(Filter.mongoSelector({
+ listId: this._id,
+ archived: false,
+ }), { sort: ['sort'] });
+ },
+
+ allCards() {
+ return Cards.find({ listId: this._id });
+ },
+
+ board() {
+ return Boards.findOne(this.boardId);
+ },
+});
+
+Lists.mutations({
+ rename(title) {
+ return { $set: { title }};
+ },
+
+ archive() {
+ return { $set: { archived: true }};
+ },
+
+ restore() {
+ return { $set: { archived: false }};
+ },
+});
+
+Lists.hookOptions.after.update = { fetchPrevious: false };
+
+Lists.before.insert((userId, doc) => {
+ doc.createdAt = new Date();
+ doc.archived = false;
+ if (!doc.userId)
+ doc.userId = userId;
+});
+
+Lists.before.update((userId, doc, fieldNames, modifier) => {
+ modifier.$set = modifier.$set || {};
+ modifier.$set.modifiedAt = new Date();
+});
+
+if (Meteor.isServer) {
+ Lists.after.insert((userId, doc) => {
+ Activities.insert({
+ userId,
+ type: 'list',
+ activityType: 'createList',
+ boardId: doc.boardId,
+ listId: doc._id,
+ });
+ });
+
+ Lists.after.update((userId, doc) => {
+ if (doc.archived) {
+ Activities.insert({
+ userId,
+ type: 'list',
+ activityType: 'archivedList',
+ listId: doc._id,
+ boardId: doc.boardId,
+ });
+ }
+ });
+}
diff --git a/models/unsavedEdits.js b/models/unsavedEdits.js
new file mode 100644
index 00000000..87a70e22
--- /dev/null
+++ b/models/unsavedEdits.js
@@ -0,0 +1,34 @@
+// This collection shouldn't be manipulated directly by instead throw the
+// `UnsavedEdits` API on the client.
+UnsavedEditCollection = new Mongo.Collection('unsaved-edits');
+
+UnsavedEditCollection.attachSchema(new SimpleSchema({
+ fieldName: {
+ type: String,
+ },
+ docId: {
+ type: String,
+ },
+ value: {
+ type: String,
+ },
+ userId: {
+ type: String,
+ },
+}));
+
+if (Meteor.isServer) {
+ function isAuthor(userId, doc, fieldNames = []) {
+ return userId === doc.userId && fieldNames.indexOf('userId') === -1;
+ }
+ UnsavedEditCollection.allow({
+ insert: isAuthor,
+ update: isAuthor,
+ remove: isAuthor,
+ fetch: ['userId'],
+ });
+}
+
+UnsavedEditCollection.before.insert((userId, doc) => {
+ doc.userId = userId;
+});
diff --git a/models/users.js b/models/users.js
new file mode 100644
index 00000000..4260dc56
--- /dev/null
+++ b/models/users.js
@@ -0,0 +1,157 @@
+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.
+const searchInFields = ['username', 'profile.name'];
+Users.initEasySearch(searchInFields, {
+ use: 'mongo-db',
+ returnFields: [...searchInFields, 'profile.avatarUrl'],
+});
+
+Users.helpers({
+ boards() {
+ return Boards.find({ userId: this._id });
+ },
+
+ starredBoards() {
+ const starredBoardIds = this.profile.starredBoards || [];
+ return Boards.find({archived: false, _id: {$in: starredBoardIds}});
+ },
+
+ hasStarred(boardId) {
+ const starredBoardIds = this.profile.starredBoards || [];
+ return _.contains(starredBoardIds, boardId);
+ },
+
+ isBoardMember() {
+ const board = Boards.findOne(Session.get('currentBoard'));
+ return board && _.contains(_.pluck(board.members, 'userId'), this._id) &&
+ _.where(board.members, {userId: this._id})[0].isActive;
+ },
+
+ isBoardAdmin() {
+ const board = Boards.findOne(Session.get('currentBoard'));
+ return board && this.isBoardMember(board) &&
+ _.where(board.members, {userId: this._id})[0].isAdmin;
+ },
+
+ getInitials() {
+ const profile = this.profile || {};
+ if (profile.initials)
+ return profile.initials;
+
+ else if (profile.fullname) {
+ return _.reduce(profile.fullname.split(/\s+/), (memo, word) => {
+ return memo + word[0];
+ }, '').toUpperCase();
+
+ } else {
+ return this.username[0].toUpperCase();
+ }
+ },
+});
+
+Users.mutations({
+ toggleBoardStar(boardId) {
+ const queryKind = this.hasStarred(boardId) ? '$pull' : '$addToSet';
+ return {
+ [queryKind]: {
+ 'profile.starredBoards': boardId,
+ },
+ };
+ },
+
+ setAvatarUrl(avatarUrl) {
+ return { $set: { 'profile.avatarUrl': avatarUrl }};
+ },
+});
+
+Meteor.methods({
+ setUsername(username) {
+ check(username, String);
+ const nUsersWithUsername = Users.find({ username }).count();
+ if (nUsersWithUsername > 0) {
+ throw new Meteor.Error('username-already-taken');
+ } else {
+ Users.update(this.userId, {$set: { username }});
+ }
+ },
+});
+
+Users.before.insert((userId, doc) => {
+ doc.profile = doc.profile || {};
+
+ if (!doc.username && doc.profile.name) {
+ doc.username = doc.profile.name.toLowerCase().replace(/\s/g, '');
+ }
+});
+
+if (Meteor.isServer) {
+ // Let mongoDB ensure username unicity
+ Meteor.startup(() => {
+ Users._collection._ensureIndex({
+ username: 1,
+ }, { unique: true });
+ });
+
+ // 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
+ function getStarredBoardsIds(doc) {
+ return doc.profile && doc.profile.starredBoards;
+ }
+ const oldIds = getStarredBoardsIds(this.previous);
+ const 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.
+ function incrementBoards(boardsIds, inc) {
+ _.forEach(boardsIds, (boardId) => {
+ Boards.update(boardId, {$inc: {stars: inc}});
+ });
+ }
+ incrementBoards(_.difference(oldIds, newIds), -1);
+ incrementBoards(_.difference(newIds, oldIds), +1);
+ });
+
+ // XXX i18n
+ Users.after.insert((userId, doc) => {
+ const ExampleBoard = {
+ title: 'Welcome Board',
+ userId: doc._id,
+ permission: 'private',
+ };
+
+ // Insert the Welcome Board
+ Boards.insert(ExampleBoard, (err, boardId) => {
+
+ _.forEach(['Basics', 'Advanced'], (title) => {
+ const list = {
+ title,
+ 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);
+ });
+ });
+ });
+}