diff options
Diffstat (limited to 'models')
-rw-r--r-- | models/activities.js | 51 | ||||
-rw-r--r-- | models/attachments.js | 79 | ||||
-rw-r--r-- | models/avatars.js | 27 | ||||
-rw-r--r-- | models/boards.js | 348 | ||||
-rw-r--r-- | models/cardComments.js | 69 | ||||
-rw-r--r-- | models/cards.js | 291 | ||||
-rw-r--r-- | models/lists.js | 110 | ||||
-rw-r--r-- | models/unsavedEdits.js | 34 | ||||
-rw-r--r-- | models/users.js | 157 |
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); + }); + }); + }); +} |