diff options
Diffstat (limited to 'models')
-rw-r--r-- | models/activities.js | 2 | ||||
-rw-r--r-- | models/boards.js | 136 | ||||
-rw-r--r-- | models/cardComments.js | 62 | ||||
-rw-r--r-- | models/cards.js | 131 | ||||
-rw-r--r-- | models/checklists.js | 104 | ||||
-rw-r--r-- | models/export.js | 80 | ||||
-rw-r--r-- | models/import.js | 44 | ||||
-rw-r--r-- | models/invitationCodes.js | 45 | ||||
-rw-r--r-- | models/lists.js | 69 | ||||
-rw-r--r-- | models/settings.js | 145 | ||||
-rw-r--r-- | models/unsavedEdits.js | 3 | ||||
-rw-r--r-- | models/users.js | 174 |
12 files changed, 861 insertions, 134 deletions
diff --git a/models/activities.js b/models/activities.js index 7d262ec6..9a41d4aa 100644 --- a/models/activities.js +++ b/models/activities.js @@ -52,6 +52,8 @@ if (Meteor.isServer) { Activities._collection._ensureIndex({ createdAt: -1 }); Activities._collection._ensureIndex({ cardId: 1, createdAt: -1 }); Activities._collection._ensureIndex({ boardId: 1, createdAt: -1 }); + Activities._collection._ensureIndex({ commentId: 1 }, { partialFilterExpression: { commentId: { $exists: true } } }); + Activities._collection._ensureIndex({ attachmentId: 1 }, { partialFilterExpression: { attachmentId: { $exists: true } } }); }); Activities.after.insert((userId, doc) => { diff --git a/models/boards.js b/models/boards.js index 14943d61..8a7844e2 100644 --- a/models/boards.js +++ b/models/boards.js @@ -107,6 +107,7 @@ Boards.attachSchema(new SimpleSchema({ userId: this.userId, isAdmin: true, isActive: true, + isCommentOnly: false, }]; } }, @@ -120,6 +121,9 @@ Boards.attachSchema(new SimpleSchema({ 'members.$.isActive': { type: Boolean, }, + 'members.$.isCommentOnly': { + type: Boolean, + }, permission: { type: String, allowedValues: ['public', 'private'], @@ -152,7 +156,7 @@ Boards.helpers({ * Is supplied user authorized to view this board? */ isVisibleBy(user) { - if(this.isPublic()) { + if (this.isPublic()) { // public boards are visible to everyone return true; } else { @@ -168,7 +172,7 @@ Boards.helpers({ * @returns {boolean} the member that matches, or undefined/false */ isActiveMember(userId) { - if(userId) { + if (userId) { return this.members.find((member) => (member.userId === userId && member.isActive)); } else { return false; @@ -180,23 +184,23 @@ Boards.helpers({ }, lists() { - return Lists.find({ boardId: this._id, archived: false }, { sort: { sort: 1 }}); + return Lists.find({ boardId: this._id, archived: false }, { sort: { sort: 1 } }); }, activities() { - return Activities.find({ boardId: this._id }, { sort: { createdAt: -1 }}); + return Activities.find({ boardId: this._id }, { sort: { createdAt: -1 } }); }, activeMembers() { - return _.where(this.members, {isActive: true}); + return _.where(this.members, { isActive: true }); }, activeAdmins() { - return _.where(this.members, {isActive: true, isAdmin: true}); + return _.where(this.members, { isActive: true, isAdmin: true }); }, memberUsers() { - return Users.find({ _id: {$in: _.pluck(this.members, 'userId')} }); + return Users.find({ _id: { $in: _.pluck(this.members, 'userId') } }); }, getLabel(name, color) { @@ -212,11 +216,15 @@ Boards.helpers({ }, hasMember(memberId) { - return !!_.findWhere(this.members, {userId: memberId, isActive: true}); + return !!_.findWhere(this.members, { userId: memberId, isActive: true }); }, hasAdmin(memberId) { - return !!_.findWhere(this.members, {userId: memberId, isActive: true, isAdmin: true}); + return !!_.findWhere(this.members, { userId: memberId, isActive: true, isAdmin: true }); + }, + + hasCommentOnly(memberId) { + return !!_.findWhere(this.members, { userId: memberId, isActive: true, isAdmin: false, isCommentOnly: true }); }, absoluteUrl() { @@ -231,34 +239,34 @@ Boards.helpers({ // XXX waiting on https://github.com/mquandalle/meteor-collection-mutations/issues/1 to remove... pushLabel(name, color) { const _id = Random.id(6); - Boards.direct.update(this._id, { $push: {labels: { _id, name, color }}}); + Boards.direct.update(this._id, { $push: { labels: { _id, name, color } } }); return _id; }, }); Boards.mutations({ archive() { - return { $set: { archived: true }}; + return { $set: { archived: true } }; }, restore() { - return { $set: { archived: false }}; + return { $set: { archived: false } }; }, rename(title) { - return { $set: { title }}; + return { $set: { title } }; }, setDescription(description) { - return { $set: {description} }; + return { $set: { description } }; }, setColor(color) { - return { $set: { color }}; + return { $set: { color } }; }, setVisibility(visibility) { - return { $set: { permission: visibility }}; + return { $set: { permission: visibility } }; }, addLabel(name, color) { @@ -268,7 +276,7 @@ Boards.mutations({ // user). if (!this.getLabel(name, color)) { const _id = Random.id(6); - return { $push: {labels: { _id, name, color }}}; + return { $push: { labels: { _id, name, color } } }; } return {}; }, @@ -287,7 +295,7 @@ Boards.mutations({ }, removeLabel(labelId) { - return { $pull: { labels: { _id: labelId }}}; + return { $pull: { labels: { _id: labelId } } }; }, addMember(memberId) { @@ -306,6 +314,7 @@ Boards.mutations({ userId: memberId, isAdmin: false, isActive: true, + isCommentOnly: false, }, }, }; @@ -332,7 +341,7 @@ Boards.mutations({ }; }, - setMemberPermission(memberId, isAdmin) { + setMemberPermission(memberId, isAdmin, isCommentOnly) { const memberIndex = this.memberIndex(memberId); // do not allow change permission of self @@ -343,6 +352,7 @@ Boards.mutations({ return { $set: { [`members.${memberIndex}.isAdmin`]: isAdmin, + [`members.${memberIndex}.isCommentOnly`]: isCommentOnly, }, }; }, @@ -376,7 +386,7 @@ if (Meteor.isServer) { return false; // If there is more than one admin, it's ok to remove anyone - const nbAdmins = _.where(doc.members, {isActive: true, isAdmin: true}).length; + const nbAdmins = _.where(doc.members, { isActive: true, isAdmin: true }).length; if (nbAdmins > 1) return false; @@ -398,7 +408,7 @@ if (Meteor.isServer) { if (board) { const userId = Meteor.userId(); const index = board.memberIndex(userId); - if (index>=0) { + if (index >= 0) { board.removeMember(userId); return true; } else throw new Meteor.Error('error-board-notAMember'); @@ -414,6 +424,7 @@ if (Meteor.isServer) { _id: 1, 'members.userId': 1, }, { unique: true }); + Boards._collection._ensureIndex({ 'members.userId': 1 }); }); // Genesis: the first activity of the newly created board @@ -542,3 +553,86 @@ if (Meteor.isServer) { } }); } + +//BOARDS REST API +if (Meteor.isServer) { + JsonRoutes.add('GET', '/api/users/:userId/boards', function (req, res, next) { + Authentication.checkLoggedIn(req.userId); + const paramUserId = req.params.userId; + // A normal user should be able to see their own boards, + // admins can access boards of any user + Authentication.checkAdminOrCondition(req.userId, req.userId === paramUserId); + + const data = Boards.find({ + archived: false, + 'members.userId': req.userId, + }, { + sort: ['title'], + }).map(function(board) { + return { + _id: board._id, + title: board.title, + }; + }); + + JsonRoutes.sendResult(res, {code: 200, data}); + }); + + JsonRoutes.add('GET', '/api/boards', function (req, res, next) { + Authentication.checkUserId(req.userId); + JsonRoutes.sendResult(res, { + code: 200, + data: Boards.find({ permission: 'public' }).map(function (doc) { + return { + _id: doc._id, + title: doc.title, + }; + }), + }); + }); + + JsonRoutes.add('GET', '/api/boards/:id', function (req, res, next) { + const id = req.params.id; + Authentication.checkBoardAccess( req.userId, id); + + JsonRoutes.sendResult(res, { + code: 200, + data: Boards.findOne({ _id: id }), + }); + }); + + JsonRoutes.add('POST', '/api/boards', function (req, res, next) { + Authentication.checkUserId( req.userId); + const id = Boards.insert({ + title: req.body.title, + members: [ + { + userId: req.body.owner, + isAdmin: true, + isActive: true, + isCommentOnly: false, + }, + ], + permission: 'public', + color: 'belize', + }); + JsonRoutes.sendResult(res, { + code: 200, + data: { + _id: id, + }, + }); + }); + + JsonRoutes.add('DELETE', '/api/boards/:id', function (req, res, next) { + Authentication.checkUserId( req.userId); + const id = req.params.id; + Boards.remove({ _id: id }); + JsonRoutes.sendResult(res, { + code: 200, + data:{ + _id: id, + }, + }); + }); +} diff --git a/models/cardComments.js b/models/cardComments.js index 070c148e..e51275a4 100644 --- a/models/cardComments.js +++ b/models/cardComments.js @@ -80,3 +80,65 @@ if (Meteor.isServer) { } }); } + +//CARD COMMENT REST API +if (Meteor.isServer) { + JsonRoutes.add('GET', '/api/boards/:boardId/cards/:cardId/comments', function (req, res, next) { + Authentication.checkUserId( req.userId); + const paramBoardId = req.params.boardId; + const paramCardId = req.params.cardId; + JsonRoutes.sendResult(res, { + code: 200, + data: CardComments.find({ boardId: paramBoardId, cardId: paramCardId}).map(function (doc) { + return { + _id: doc._id, + comment: doc.text, + authorId: doc.userId, + }; + }), + }); + }); + + JsonRoutes.add('GET', '/api/boards/:boardId/cards/:cardId/comments/:commentId', function (req, res, next) { + Authentication.checkUserId( req.userId); + const paramBoardId = req.params.boardId; + const paramCommentId = req.params.commentId; + const paramCardId = req.params.cardId; + JsonRoutes.sendResult(res, { + code: 200, + data: CardComments.findOne({ _id: paramCommentId, cardId: paramCardId, boardId: paramBoardId }), + }); + }); + + JsonRoutes.add('POST', '/api/boards/:boardId/cards/:cardId/comments', function (req, res, next) { + Authentication.checkUserId( req.userId); + const paramBoardId = req.params.boardId; + const paramCardId = req.params.cardId; + const id = CardComments.insert({ + userId: req.body.authorId, + text: req.body.comment, + cardId: paramCardId, + boardId: paramBoardId, + }); + JsonRoutes.sendResult(res, { + code: 200, + data: { + _id: id, + }, + }); + }); + + JsonRoutes.add('DELETE', '/api/boards/:boardId/cards/:cardId/comments/:commentId', function (req, res, next) { + Authentication.checkUserId( req.userId); + const paramBoardId = req.params.boardId; + const paramCommentId = req.params.commentId; + const paramCardId = req.params.cardId; + CardComments.remove({ _id: paramCommentId, cardId: paramCardId, boardId: paramBoardId }); + JsonRoutes.sendResult(res, { + code: 200, + data: { + _id: paramCardId, + }, + }); + }); +} diff --git a/models/cards.js b/models/cards.js index f6bd0b06..c48b4845 100644 --- a/models/cards.js +++ b/models/cards.js @@ -123,15 +123,15 @@ Cards.helpers({ }, activities() { - return Activities.find({ cardId: this._id }, { sort: { createdAt: -1 }}); + return Activities.find({ cardId: this._id }, { sort: { createdAt: -1 } }); }, comments() { - return CardComments.find({ cardId: this._id }, { sort: { createdAt: -1 }}); + return CardComments.find({ cardId: this._id }, { sort: { createdAt: -1 } }); }, attachments() { - return Attachments.find({ cardId: this._id }, { sort: { uploadedAt: -1 }}); + return Attachments.find({ cardId: this._id }, { sort: { uploadedAt: -1 } }); }, cover() { @@ -142,7 +142,7 @@ Cards.helpers({ }, checklists() { - return Checklists.find({ cardId: this._id }, { sort: { createdAt: 1 }}); + return Checklists.find({ cardId: this._id }, { sort: { createdAt: 1 } }); }, checklistItemCount() { @@ -183,19 +183,19 @@ Cards.helpers({ Cards.mutations({ archive() { - return { $set: { archived: true }}; + return { $set: { archived: true } }; }, restore() { - return { $set: { archived: false }}; + return { $set: { archived: false } }; }, setTitle(title) { - return { $set: { title }}; + return { $set: { title } }; }, setDescription(description) { - return { $set: { description }}; + return { $set: { description } }; }, move(listId, sortIndex) { @@ -207,11 +207,11 @@ Cards.mutations({ }, addLabel(labelId) { - return { $addToSet: { labelIds: labelId }}; + return { $addToSet: { labelIds: labelId } }; }, removeLabel(labelId) { - return { $pull: { labelIds: labelId }}; + return { $pull: { labelIds: labelId } }; }, toggleLabel(labelId) { @@ -223,11 +223,11 @@ Cards.mutations({ }, assignMember(memberId) { - return { $addToSet: { members: memberId }}; + return { $addToSet: { members: memberId } }; }, unassignMember(memberId) { - return { $pull: { members: memberId }}; + return { $pull: { members: memberId } }; }, toggleMember(memberId) { @@ -239,27 +239,27 @@ Cards.mutations({ }, setCover(coverId) { - return { $set: { coverId }}; + return { $set: { coverId } }; }, unsetCover() { - return { $unset: { coverId: '' }}; + return { $unset: { coverId: '' } }; }, setStart(startAt) { - return { $set: { startAt }}; + return { $set: { startAt } }; }, unsetStart() { - return { $unset: { startAt: '' }}; + return { $unset: { startAt: '' } }; }, setDue(dueAt) { - return { $set: { dueAt }}; + return { $set: { dueAt } }; }, unsetDue() { - return { $unset: { dueAt: '' }}; + return { $unset: { dueAt: '' } }; }, }); @@ -267,7 +267,7 @@ if (Meteor.isServer) { // Cards are often fetched within a board, so we create an index to make these // queries more efficient. Meteor.startup(() => { - Cards._collection._ensureIndex({ boardId: 1 }); + Cards._collection._ensureIndex({ boardId: 1, createdAt: -1 }); }); Cards.after.insert((userId, doc) => { @@ -304,7 +304,7 @@ if (Meteor.isServer) { }); // New activity for card moves - Cards.after.update(function(userId, doc, fieldNames) { + Cards.after.update(function (userId, doc, fieldNames) { const oldListId = this.previous.listId; if (_.contains(fieldNames, 'listId') && doc.listId !== oldListId) { Activities.insert({ @@ -340,20 +340,97 @@ if (Meteor.isServer) { // 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, - }); + // Check that the former member is member of the card + if (_.contains(doc.members, memberId)) { + Activities.insert({ + userId, + memberId, + activityType: 'unjoinMember', + boardId: doc.boardId, + cardId: doc._id, + }); + } } }); // Remove all activities associated with a card if we remove the card + // Remove also card_comments / checklists / attachments Cards.after.remove((userId, doc) => { Activities.remove({ cardId: doc._id, }); + Checklists.remove({ + cardId: doc._id, + }); + CardComments.remove({ + cardId: doc._id, + }); + Attachments.remove({ + cardId: doc._id, + }); + }); +} +//LISTS REST API +if (Meteor.isServer) { + JsonRoutes.add('GET', '/api/boards/:boardId/lists/:listId/cards', function (req, res, next) { + const paramBoardId = req.params.boardId; + const paramListId = req.params.listId; + Authentication.checkBoardAccess( req.userId, paramBoardId); + JsonRoutes.sendResult(res, { + code: 200, + data: Cards.find({ boardId: paramBoardId, listId: paramListId, archived: false }).map(function (doc) { + return { + _id: doc._id, + title: doc.title, + description: doc.description, + }; + }), + }); + }); + + JsonRoutes.add('GET', '/api/boards/:boardId/lists/:listId/cards/:cardId', function (req, res, next) { + const paramBoardId = req.params.boardId; + const paramListId = req.params.listId; + const paramCardId = req.params.cardId; + Authentication.checkBoardAccess( req.userId, paramBoardId); + JsonRoutes.sendResult(res, { + code: 200, + data: Cards.findOne({ _id: paramCardId, listId: paramListId, boardId: paramBoardId, archived: false }), + }); + }); + + JsonRoutes.add('POST', '/api/boards/:boardId/lists/:listId/cards', function (req, res, next) { + Authentication.checkUserId( req.userId); + const paramBoardId = req.params.boardId; + const paramListId = req.params.listId; + const id = Cards.insert({ + title: req.body.title, + boardId: paramBoardId, + listId: paramListId, + description: req.body.description, + userId : req.body.authorId, + sort: 0, + members:[ req.body.authorId ], + }); + JsonRoutes.sendResult(res, { + code: 200, + data: { + _id: id, + }, + }); + }); + + JsonRoutes.add('DELETE', '/api/boards/:boardId/lists/:listId/cards/:cardId', function (req, res, next) { + Authentication.checkUserId( req.userId); + const paramBoardId = req.params.boardId; + const paramListId = req.params.listId; + const paramCardId = req.params.cardId; + Cards.remove({ _id: paramCardId, listId: paramListId, boardId: paramBoardId }); + JsonRoutes.sendResult(res, { + code: 200, + data: { + _id: paramCardId, + }, + }); }); } diff --git a/models/checklists.js b/models/checklists.js index 35be4dcc..537aecb0 100644 --- a/models/checklists.js +++ b/models/checklists.js @@ -28,22 +28,29 @@ Checklists.attachSchema(new SimpleSchema({ createdAt: { type: Date, denyUpdate: false, + autoValue() { // eslint-disable-line consistent-return + if (this.isInsert) { + return new Date(); + } else { + this.unset(); + } + }, }, })); Checklists.helpers({ - itemCount () { + itemCount() { return this.items.length; }, - finishedCount () { + finishedCount() { return this.items.filter((item) => { return item.isFinished; }).length; }, - isFinished () { + isFinished() { return 0 !== this.itemCount() && this.itemCount() === this.finishedCount(); }, - getItem (_id) { + getItem(_id) { return _.findWhere(this.items, { _id }); }, itemIndex(itemId) { @@ -73,17 +80,17 @@ Checklists.before.insert((userId, doc) => { Checklists.mutations({ //for checklist itself - setTitle(title){ - return { $set: { title }}; + setTitle(title) { + return { $set: { title } }; }, //for items in checklist addItem(title) { const itemCount = this.itemCount(); const _id = `${this._id}${itemCount}`; - return { $addToSet: {items: {_id, title, isFinished: false}} }; + return { $addToSet: { items: { _id, title, isFinished: false } } }; }, removeItem(itemId) { - return {$pull: {items: {_id : itemId}}}; + return { $pull: { items: { _id: itemId } } }; }, editItem(itemId, title) { if (this.getItem(itemId)) { @@ -133,6 +140,10 @@ Checklists.mutations({ }); if (Meteor.isServer) { + Meteor.startup(() => { + Checklists._collection._ensureIndex({ cardId: 1, createdAt: 1 }); + }); + Checklists.after.insert((userId, doc) => { Activities.insert({ userId, @@ -146,13 +157,13 @@ if (Meteor.isServer) { //TODO: so there will be no activity for adding item into checklist, maybe will be implemented in the future. // Checklists.after.update((userId, doc) => { // console.log('update:', doc) - // Activities.insert({ - // userId, - // activityType: 'addChecklist', - // boardId: doc.boardId, - // cardId: doc.cardId, - // checklistId: doc._id, - // }); + // Activities.insert({ + // userId, + // activityType: 'addChecklist', + // boardId: doc.boardId, + // cardId: doc.cardId, + // checklistId: doc._id, + // }); // }); Checklists.before.remove((userId, doc) => { @@ -162,3 +173,66 @@ if (Meteor.isServer) { } }); } + +//CARD COMMENT REST API +if (Meteor.isServer) { + JsonRoutes.add('GET', '/api/boards/:boardId/cards/:cardId/checklists', function (req, res, next) { + Authentication.checkUserId( req.userId); + const paramCardId = req.params.cardId; + JsonRoutes.sendResult(res, { + code: 200, + data: Checklists.find({ cardId: paramCardId }).map(function (doc) { + return { + _id: doc._id, + title: doc.title, + }; + }), + }); + }); + + JsonRoutes.add('GET', '/api/boards/:boardId/cards/:cardId/checklists/:checklistId', function (req, res, next) { + Authentication.checkUserId( req.userId); + const paramChecklistId = req.params.checklistId; + const paramCardId = req.params.cardId; + JsonRoutes.sendResult(res, { + code: 200, + data: Checklists.findOne({ _id: paramChecklistId, cardId: paramCardId }), + }); + }); + + JsonRoutes.add('POST', '/api/boards/:boardId/cards/:cardId/checklists', function (req, res, next) { + Authentication.checkUserId( req.userId); + const paramCardId = req.params.cardId; + + const checklistToSend = {}; + checklistToSend.cardId = paramCardId; + checklistToSend.title = req.body.title; + checklistToSend.items = []; + const id = Checklists.insert(checklistToSend); + const checklist = Checklists.findOne({_id: id}); + req.body.items.forEach(function (item) { + checklist.addItem(item); + }, this); + + + JsonRoutes.sendResult(res, { + code: 200, + data: { + _id: id, + }, + }); + }); + + JsonRoutes.add('DELETE', '/api/boards/:boardId/cards/:cardId/checklists/:checklistId', function (req, res, next) { + Authentication.checkUserId( req.userId); + const paramCommentId = req.params.commentId; + const paramCardId = req.params.cardId; + Checklists.remove({ _id: paramCommentId, cardId: paramCardId }); + JsonRoutes.sendResult(res, { + code: 200, + data: { + _id: paramCardId, + }, + }); + }); +} diff --git a/models/export.js b/models/export.js index b774cf15..7a363dd3 100644 --- a/models/export.js +++ b/models/export.js @@ -1,5 +1,5 @@ /* global JsonRoutes */ -if(Meteor.isServer) { +if (Meteor.isServer) { // todo XXX once we have a real API in place, move that route there // todo XXX also share the route definition between the client and the server // so that we could use something like @@ -14,28 +14,28 @@ if(Meteor.isServer) { * See https://blog.kayla.com.au/server-side-route-authentication-in-meteor/ * for detailed explanations */ - JsonRoutes.add('get', '/api/boards/:boardId', function (req, res) { - const boardId = req.params.boardId; - let user = null; - // todo XXX for real API, first look for token in Authentication: header - // then fallback to parameter - const loginToken = req.query.authToken; - if (loginToken) { - const hashToken = Accounts._hashLoginToken(loginToken); - user = Meteor.users.findOne({ - 'services.resume.loginTokens.hashedToken': hashToken, - }); - } + // JsonRoutes.add('get', '/api/boards/:boardId', function (req, res) { + // const boardId = req.params.boardId; + // let user = null; + // // todo XXX for real API, first look for token in Authentication: header + // // then fallback to parameter + // const loginToken = req.query.authToken; + // if (loginToken) { + // const hashToken = Accounts._hashLoginToken(loginToken); + // user = Meteor.users.findOne({ + // 'services.resume.loginTokens.hashedToken': hashToken, + // }); + // } - const exporter = new Exporter(boardId); - if(exporter.canExport(user)) { - JsonRoutes.sendResult(res, 200, exporter.build()); - } else { - // we could send an explicit error message, but on the other hand the only - // way to get there is by hacking the UI so let's keep it raw. - JsonRoutes.sendResult(res, 403); - } - }); + // const exporter = new Exporter(boardId); + // if(exporter.canExport(user)) { + // JsonRoutes.sendResult(res, 200, exporter.build()); + // } else { + // // we could send an explicit error message, but on the other hand the only + // // way to get there is by hacking the UI so let's keep it raw. + // JsonRoutes.sendResult(res, 403); + // } + // }); } class Exporter { @@ -44,13 +44,13 @@ class Exporter { } build() { - const byBoard = {boardId: this._boardId}; + const byBoard = { boardId: this._boardId }; // we do not want to retrieve boardId in related elements - const noBoardId = {fields: {boardId: 0}}; + const noBoardId = { fields: { boardId: 0 } }; const result = { _format: 'wekan-board-1.0.0', }; - _.extend(result, Boards.findOne(this._boardId, {fields: {stars: 0}})); + _.extend(result, Boards.findOne(this._boardId, { fields: { stars: 0 } })); result.lists = Lists.find(byBoard, noBoardId).fetch(); result.cards = Cards.find(byBoard, noBoardId).fetch(); result.comments = CardComments.find(byBoard, noBoardId).fetch(); @@ -69,29 +69,31 @@ class Exporter { // 1- only exports users that are linked somehow to that board // 2- do not export any sensitive information const users = {}; - result.members.forEach((member) => {users[member.userId] = true;}); - result.lists.forEach((list) => {users[list.userId] = true;}); + result.members.forEach((member) => { users[member.userId] = true; }); + result.lists.forEach((list) => { users[list.userId] = true; }); result.cards.forEach((card) => { users[card.userId] = true; if (card.members) { - card.members.forEach((memberId) => {users[memberId] = true;}); + card.members.forEach((memberId) => { users[memberId] = true; }); } }); - result.comments.forEach((comment) => {users[comment.userId] = true;}); - result.activities.forEach((activity) => {users[activity.userId] = true;}); - const byUserIds = {_id: {$in: Object.getOwnPropertyNames(users)}}; + result.comments.forEach((comment) => { users[comment.userId] = true; }); + result.activities.forEach((activity) => { users[activity.userId] = true; }); + const byUserIds = { _id: { $in: Object.getOwnPropertyNames(users) } }; // we use whitelist to be sure we do not expose inadvertently // some secret fields that gets added to User later. - const userFields = {fields: { - _id: 1, - username: 1, - 'profile.fullname': 1, - 'profile.initials': 1, - 'profile.avatarUrl': 1, - }}; + const userFields = { + fields: { + _id: 1, + username: 1, + 'profile.fullname': 1, + 'profile.initials': 1, + 'profile.avatarUrl': 1, + }, + }; result.users = Users.find(byUserIds, userFields).fetch().map((user) => { // user avatar is stored as a relative url, we export absolute - if(user.profile.avatarUrl) { + if (user.profile.avatarUrl) { user.profile.avatarUrl = FlowRouter.url(user.profile.avatarUrl); } return user; diff --git a/models/import.js b/models/import.js index 86ef75b3..4e78c53a 100644 --- a/models/import.js +++ b/models/import.js @@ -25,6 +25,8 @@ class TrelloCreator { this.labels = {}; // Map of lists Trello ID => Wekan ID this.lists = {}; + // Map of cards Trello ID => Wekan ID + this.cards = {}; // The comments, indexed by Trello card id (to map when importing cards) this.comments = {}; // the members, indexed by Trello member id => Wekan user ID @@ -119,6 +121,18 @@ class TrelloCreator { })]); } + checkChecklists(trelloChecklists) { + check(trelloChecklists, [Match.ObjectIncluding({ + idBoard: String, + idCard: String, + name: String, + checkItems: [Match.ObjectIncluding({ + state: String, + name: String, + })], + })]); + } + // You must call parseActions before calling this one. createBoardAndLabels(trelloBoard) { const boardToCreate = { @@ -131,6 +145,7 @@ class TrelloCreator { userId: Meteor.userId(), isAdmin: true, isActive: true, + isCommentOnly: false, }], permission: this.getPermission(trelloBoard.prefs.permissionLevel), slug: getSlug(trelloBoard.name) || 'board', @@ -156,6 +171,7 @@ class TrelloCreator { userId: wekanId, isAdmin: this.getAdmin(trelloMembership.memberType), isActive: true, + isCommentOnly: false, }); } } @@ -241,6 +257,8 @@ class TrelloCreator { } // insert card const cardId = Cards.direct.insert(cardToCreate); + // keep track of Trello id => WeKan id + this.cards[card.id] = cardId; // log activity Activities.direct.insert({ activityType: 'importCard', @@ -280,7 +298,7 @@ class TrelloCreator { createdAt: this._now(commentToCreate.createdAt), // we attribute the addComment (not the import) // to the original author - it is needed by some UI elements. - userId: commentToCreate.userId, + userId: this._user(commentToCreate.userId), }); }); } @@ -365,6 +383,28 @@ class TrelloCreator { }); } + createChecklists(trelloChecklists) { + trelloChecklists.forEach((checklist) => { + // Create the checklist + const checklistToCreate = { + cardId: this.cards[checklist.idCard], + title: checklist.name, + createdAt: this._now(), + }; + const checklistId = Checklists.direct.insert(checklistToCreate); + // Now add the items to the checklist + const itemsToCreate = []; + checklist.checkItems.forEach((item) => { + itemsToCreate.push({ + _id: checklistId + itemsToCreate.length, + title: item.name, + isFinished: item.state === 'complete', + }); + }); + Checklists.direct.update(checklistId, {$set: {items: itemsToCreate}}); + }); + } + getAdmin(trelloMemberType) { return trelloMemberType === 'admin'; } @@ -446,6 +486,7 @@ Meteor.methods({ trelloCreator.checkLabels(trelloBoard.labels); trelloCreator.checkLists(trelloBoard.lists); trelloCreator.checkCards(trelloBoard.cards); + trelloCreator.checkChecklists(trelloBoard.checklists); } catch (e) { throw new Meteor.Error('error-json-schema'); } @@ -458,6 +499,7 @@ Meteor.methods({ const boardId = trelloCreator.createBoardAndLabels(trelloBoard); trelloCreator.createLists(trelloBoard.lists, boardId); trelloCreator.createCards(trelloBoard.cards, boardId); + trelloCreator.createChecklists(trelloBoard.checklists); // XXX add members return boardId; }, diff --git a/models/invitationCodes.js b/models/invitationCodes.js new file mode 100644 index 00000000..5761977a --- /dev/null +++ b/models/invitationCodes.js @@ -0,0 +1,45 @@ +InvitationCodes = new Mongo.Collection('invitation_codes'); + +InvitationCodes.attachSchema(new SimpleSchema({ + code: { + type: String, + }, + email: { + type: String, + unique: true, + regEx: SimpleSchema.RegEx.Email, + }, + createdAt: { + type: Date, + denyUpdate: false, + }, + // always be the admin if only one admin + authorId: { + type: String, + }, + boardsToBeInvited: { + type: [String], + optional: true, + }, + valid: { + type: Boolean, + defaultValue: true, + }, +})); + +InvitationCodes.helpers({ + author(){ + return Users.findOne(this.authorId); + }, +}); + +// InvitationCodes.before.insert((userId, doc) => { + // doc.createdAt = new Date(); + // doc.authorId = userId; +// }); + +if (Meteor.isServer) { + Boards.deny({ + fetch: ['members'], + }); +} diff --git a/models/lists.js b/models/lists.js index 682fb096..d9a5b8e2 100644 --- a/models/lists.js +++ b/models/lists.js @@ -46,13 +46,13 @@ Lists.attachSchema(new SimpleSchema({ Lists.allow({ insert(userId, doc) { - return allowIsBoardMember(userId, Boards.findOne(doc.boardId)); + return allowIsBoardMemberNonComment(userId, Boards.findOne(doc.boardId)); }, update(userId, doc) { - return allowIsBoardMember(userId, Boards.findOne(doc.boardId)); + return allowIsBoardMemberNonComment(userId, Boards.findOne(doc.boardId)); }, remove(userId, doc) { - return allowIsBoardMember(userId, Boards.findOne(doc.boardId)); + return allowIsBoardMemberNonComment(userId, Boards.findOne(doc.boardId)); }, fetch: ['boardId'], }); @@ -76,15 +76,15 @@ Lists.helpers({ Lists.mutations({ rename(title) { - return { $set: { title }}; + return { $set: { title } }; }, archive() { - return { $set: { archived: true }}; + return { $set: { archived: true } }; }, restore() { - return { $set: { archived: false }}; + return { $set: { archived: false } }; }, }); @@ -128,3 +128,60 @@ if (Meteor.isServer) { } }); } + +//LISTS REST API +if (Meteor.isServer) { + JsonRoutes.add('GET', '/api/boards/:boardId/lists', function (req, res, next) { + const paramBoardId = req.params.boardId; + Authentication.checkBoardAccess( req.userId, paramBoardId); + + JsonRoutes.sendResult(res, { + code: 200, + data: Lists.find({ boardId: paramBoardId, archived: false }).map(function (doc) { + return { + _id: doc._id, + title: doc.title, + }; + }), + }); + }); + + JsonRoutes.add('GET', '/api/boards/:boardId/lists/:listId', function (req, res, next) { + const paramBoardId = req.params.boardId; + const paramListId = req.params.listId; + Authentication.checkBoardAccess( req.userId, paramBoardId); + JsonRoutes.sendResult(res, { + code: 200, + data: Lists.findOne({ _id: paramListId, boardId: paramBoardId, archived: false }), + }); + }); + + JsonRoutes.add('POST', '/api/boards/:boardId/lists', function (req, res, next) { + Authentication.checkUserId( req.userId); + const paramBoardId = req.params.boardId; + const id = Lists.insert({ + title: req.body.title, + boardId: paramBoardId, + }); + JsonRoutes.sendResult(res, { + code: 200, + data: { + _id: id, + }, + }); + }); + + JsonRoutes.add('DELETE', '/api/boards/:boardId/lists/:listId', function (req, res, next) { + Authentication.checkUserId( req.userId); + const paramBoardId = req.params.boardId; + const paramListId = req.params.listId; + Lists.remove({ _id: paramListId, boardId: paramBoardId }); + JsonRoutes.sendResult(res, { + code: 200, + data: { + _id: paramListId, + }, + }); + }); + +} diff --git a/models/settings.js b/models/settings.js new file mode 100644 index 00000000..e9dce26d --- /dev/null +++ b/models/settings.js @@ -0,0 +1,145 @@ +Settings = new Mongo.Collection('settings'); + +Settings.attachSchema(new SimpleSchema({ + disableRegistration: { + type: Boolean, + }, + 'mailServer.username': { + type: String, + optional: true, + }, + 'mailServer.password': { + type: String, + optional: true, + }, + 'mailServer.host': { + type: String, + optional: true, + }, + 'mailServer.port': { + type: String, + optional: true, + }, + 'mailServer.enableTLS': { + type: Boolean, + optional: true, + }, + 'mailServer.from': { + type: String, + optional: true, + }, + createdAt: { + type: Date, + denyUpdate: true, + }, + modifiedAt: { + type: Date, + }, +})); +Settings.helpers({ + mailUrl () { + if (!this.mailServer.host) { + return null; + } + const protocol = this.mailServer.enableTLS ? 'smtps://' : 'smtp://'; + if (!this.mailServer.username && !this.mailServer.password) { + return `${protocol}${this.mailServer.host}:${this.mailServer.port}/`; + } + return `${protocol}${this.mailServer.username}:${this.mailServer.password}@${this.mailServer.host}:${this.mailServer.port}/`; + }, +}); +Settings.allow({ + update(userId) { + const user = Users.findOne(userId); + return user && user.isAdmin; + }, +}); + +Settings.before.update((userId, doc, fieldNames, modifier) => { + modifier.$set = modifier.$set || {}; + modifier.$set.modifiedAt = new Date(); +}); + +if (Meteor.isServer) { + Meteor.startup(() => { + const setting = Settings.findOne({}); + if(!setting){ + const now = new Date(); + const domain = process.env.ROOT_URL.match(/\/\/(?:www\.)?(.*)?(?:\/)?/)[1]; + const from = `Wekan <wekan@${domain}>`; + const defaultSetting = {disableRegistration: false, mailServer: { + username: '', password: '', host: '', port: '', enableTLS: false, from, + }, createdAt: now, modifiedAt: now}; + Settings.insert(defaultSetting); + } + const newSetting = Settings.findOne(); + if (!process.env.MAIL_URL && newSetting.mailUrl()) + process.env.MAIL_URL = newSetting.mailUrl(); + Accounts.emailTemplates.from = process.env.MAIL_FROM ? process.env.MAIL_FROM : newSetting.mailServer.from; + }); + Settings.after.update((userId, doc, fieldNames) => { + // assign new values to mail-from & MAIL_URL in environment + if (_.contains(fieldNames, 'mailServer') && doc.mailServer.host) { + const protocol = doc.mailServer.enableTLS ? 'smtps://' : 'smtp://'; + if (!doc.mailServer.username && !doc.mailServer.password) { + process.env.MAIL_URL = `${protocol}${doc.mailServer.host}:${doc.mailServer.port}/`; + } else { + process.env.MAIL_URL = `${protocol}${doc.mailServer.username}:${doc.mailServer.password}@${doc.mailServer.host}:${doc.mailServer.port}/`; + } + Accounts.emailTemplates.from = doc.mailServer.from; + } + }); + + function getRandomNum (min, max) { + const range = max - min; + const rand = Math.random(); + return (min + Math.round(rand * range)); + } + + function sendInvitationEmail (_id){ + const icode = InvitationCodes.findOne(_id); + const author = Users.findOne(Meteor.userId()); + try { + const params = { + email: icode.email, + inviter: Users.findOne(icode.authorId).username, + user: icode.email.split('@')[0], + icode: icode.code, + url: FlowRouter.url('sign-up'), + }; + const lang = author.getLanguage(); + Email.send({ + to: icode.email, + from: Accounts.emailTemplates.from, + subject: TAPi18n.__('email-invite-register-subject', params, lang), + text: TAPi18n.__('email-invite-register-text', params, lang), + }); + } catch (e) { + InvitationCodes.remove(_id); + throw new Meteor.Error('email-fail', e.message); + } + } + + Meteor.methods({ + sendInvitation(emails, boards) { + check(emails, [String]); + check(boards, [String]); + const user = Users.findOne(Meteor.userId()); + if(!user.isAdmin){ + throw new Meteor.Error('not-allowed'); + } + emails.forEach((email) => { + if (email && SimpleSchema.RegEx.Email.test(email)) { + const code = getRandomNum(100000, 999999); + InvitationCodes.insert({code, email, boardsToBeInvited: boards, createdAt: new Date(), authorId: Meteor.userId()}, function(err, _id){ + if (!err && _id) { + sendInvitationEmail(_id); + } else { + throw new Meteor.Error('invitation-generated-fail', err.message); + } + }); + } + }); + }, + }); +} diff --git a/models/unsavedEdits.js b/models/unsavedEdits.js index 25952fb5..d4f3616a 100644 --- a/models/unsavedEdits.js +++ b/models/unsavedEdits.js @@ -26,6 +26,9 @@ if (Meteor.isServer) { function isAuthor(userId, doc, fieldNames = []) { return userId === doc.userId && fieldNames.indexOf('userId') === -1; } + Meteor.startup(() => { + UnsavedEditCollection._collection._ensureIndex({ userId: 1 }); + }); UnsavedEditCollection.allow({ insert: isAuthor, update: isAuthor, diff --git a/models/users.js b/models/users.js index 58513231..29504aa8 100644 --- a/models/users.js +++ b/models/users.js @@ -1,7 +1,7 @@ // Sandstorm context is detected using the METEOR_SETTINGS environment variable // in the package definition. const isSandstorm = Meteor.settings && Meteor.settings.public && - Meteor.settings.public.sandstorm; + Meteor.settings.public.sandstorm; Users = Meteor.users; Users.attachSchema(new SimpleSchema({ @@ -91,6 +91,10 @@ Users.attachSchema(new SimpleSchema({ type: [String], optional: true, }, + 'profile.icode': { + type: String, + optional: true, + }, services: { type: Object, optional: true, @@ -100,6 +104,10 @@ Users.attachSchema(new SimpleSchema({ type: Date, optional: true, }, + isAdmin: { + type: Boolean, + optional: true, + }, })); // Search a user in the complete server database by its name or username. This @@ -117,6 +125,16 @@ if (Meteor.isClient) { return board && board.hasMember(this._id); }, + isNotCommentOnly() { + const board = Boards.findOne(Session.get('currentBoard')); + return board && board.hasMember(this._id) && !board.hasCommentOnly(this._id); + }, + + isCommentOnly() { + const board = Boards.findOne(Session.get('currentBoard')); + return board && board.hasCommentOnly(this._id); + }, + isBoardAdmin() { const board = Boards.findOne(Session.get('currentBoard')); return board && board.hasAdmin(this._id); @@ -130,32 +148,32 @@ Users.helpers({ }, starredBoards() { - const {starredBoards = []} = this.profile; - return Boards.find({archived: false, _id: {$in: starredBoards}}); + const { starredBoards = [] } = this.profile; + return Boards.find({ archived: false, _id: { $in: starredBoards } }); }, hasStarred(boardId) { - const {starredBoards = []} = this.profile; + const { starredBoards = [] } = this.profile; return _.contains(starredBoards, boardId); }, invitedBoards() { - const {invitedBoards = []} = this.profile; - return Boards.find({archived: false, _id: {$in: invitedBoards}}); + const { invitedBoards = [] } = this.profile; + return Boards.find({ archived: false, _id: { $in: invitedBoards } }); }, isInvitedTo(boardId) { - const {invitedBoards = []} = this.profile; + const { invitedBoards = [] } = this.profile; return _.contains(invitedBoards, boardId); }, hasTag(tag) { - const {tags = []} = this.profile; + const { tags = [] } = this.profile; return _.contains(tags, tag); }, hasNotification(activityId) { - const {notifications = []} = this.profile; + const { notifications = [] } = this.profile; return _.contains(notifications, activityId); }, @@ -165,7 +183,7 @@ Users.helpers({ }, getEmailBuffer() { - const {emailBuffer = []} = this.profile; + const { emailBuffer = [] } = this.profile; return emailBuffer; }, @@ -290,7 +308,7 @@ Users.mutations({ }, setAvatarUrl(avatarUrl) { - return { $set: { 'profile.avatarUrl': avatarUrl }}; + return { $set: { 'profile.avatarUrl': avatarUrl } }; }, setShowCardsCountAt(limit) { @@ -305,7 +323,7 @@ Meteor.methods({ if (nUsersWithUsername > 0) { throw new Meteor.Error('username-already-taken'); } else { - Users.update(this.userId, {$set: { username }}); + Users.update(this.userId, { $set: { username } }); } }, toggleSystemMessages() { @@ -328,19 +346,19 @@ if (Meteor.isServer) { const inviter = Meteor.user(); const board = Boards.findOne(boardId); const allowInvite = inviter && - board && - board.members && - _.contains(_.pluck(board.members, 'userId'), inviter._id) && - _.where(board.members, {userId: inviter._id})[0].isActive && - _.where(board.members, {userId: inviter._id})[0].isAdmin; + board && + board.members && + _.contains(_.pluck(board.members, 'userId'), inviter._id) && + _.where(board.members, { userId: inviter._id })[0].isActive && + _.where(board.members, { userId: inviter._id })[0].isAdmin; if (!allowInvite) throw new Meteor.Error('error-board-notAMember'); this.unblock(); const posAt = username.indexOf('@'); let user = null; - if (posAt>=0) { - user = Users.findOne({emails: {$elemMatch: {address: username}}}); + if (posAt >= 0) { + user = Users.findOne({ emails: { $elemMatch: { address: username } } }); } else { user = Users.findOne(username) || Users.findOne({ username }); } @@ -348,8 +366,9 @@ if (Meteor.isServer) { if (user._id === inviter._id) throw new Meteor.Error('error-user-notAllowSelf'); } else { if (posAt <= 0) throw new Meteor.Error('error-user-doesNotExist'); - - const email = username; + if (Settings.findOne().disableRegistration) throw new Meteor.Error('error-user-notCreated'); + // Set in lowercase email before creating account + const email = username.toLowerCase(); username = email.substring(0, posAt); const newUserId = Accounts.createUser({ username, email }); if (!newUserId) throw new Meteor.Error('error-user-notCreated'); @@ -377,7 +396,7 @@ if (Meteor.isServer) { }; const lang = user.getLanguage(); Email.send({ - to: user.emails[0].address, + to: user.emails[0].address.toLowerCase(), from: Accounts.emailTemplates.from, subject: TAPi18n.__('email-invite-subject', params, lang), text: TAPi18n.__('email-invite-text', params, lang), @@ -385,10 +404,32 @@ if (Meteor.isServer) { } catch (e) { throw new Meteor.Error('email-fail', e.message); } - return { username: user.username, email: user.emails[0].address }; }, }); + Accounts.onCreateUser((options, user) => { + const userCount = Users.find().count(); + if (!isSandstorm && userCount === 0) { + user.isAdmin = true; + return user; + } + const disableRegistration = Settings.findOne().disableRegistration; + if (!disableRegistration) { + return user; + } + + if (!options || !options.profile) { + throw new Meteor.Error('error-invitation-code-blank', 'The invitation code is required'); + } + const invitationCode = InvitationCodes.findOne({ code: options.profile.invitationcode, email: options.email, valid: true }); + if (!invitationCode) { + throw new Meteor.Error('error-invitation-code-not-exist', 'The invitation code doesn\'t exist'); + } else { + user.profile = { icode: options.profile.invitationcode }; + } + + return user; + }); } if (Meteor.isServer) { @@ -404,7 +445,7 @@ if (Meteor.isServer) { // 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) { + 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')) @@ -423,7 +464,7 @@ if (Meteor.isServer) { // direction and then in the other. function incrementBoards(boardsIds, inc) { boardsIds.forEach((boardId) => { - Boards.update(boardId, {$inc: {stars: inc}}); + Boards.update(boardId, { $inc: { stars: inc } }); }); } incrementBoards(_.difference(oldIds, newIds), -1); @@ -458,4 +499,87 @@ if (Meteor.isServer) { }); }); } + + Users.after.insert((userId, doc) => { + + //invite user to corresponding boards + const disableRegistration = Settings.findOne().disableRegistration; + if (disableRegistration) { + const invitationCode = InvitationCodes.findOne({ code: doc.profile.icode, valid: true }); + if (!invitationCode) { + throw new Meteor.Error('error-invitation-code-not-exist'); + } else { + invitationCode.boardsToBeInvited.forEach((boardId) => { + const board = Boards.findOne(boardId); + board.addMember(doc._id); + }); + if (!doc.profile) { + doc.profile = {}; + } + doc.profile.invitedBoards = invitationCode.boardsToBeInvited; + Users.update(doc._id, { $set: { profile: doc.profile } }); + InvitationCodes.update(invitationCode._id, { $set: { valid: false } }); + } + } + }); } + + +// USERS REST API +if (Meteor.isServer) { + JsonRoutes.add('GET', '/api/user', function(req, res, next) { + Authentication.checkLoggedIn(req.userId); + const data = Meteor.users.findOne({ _id: req.userId}); + delete data.services; + JsonRoutes.sendResult(res, { + code: 200, + data, + }); + }); + + JsonRoutes.add('GET', '/api/users', function (req, res, next) { + Authentication.checkUserId( req.userId); + JsonRoutes.sendResult(res, { + code: 200, + data: Meteor.users.find({}).map(function (doc) { + return { _id: doc._id, username: doc.username }; + }), + }); + }); + JsonRoutes.add('GET', '/api/users/:id', function (req, res, next) { + Authentication.checkUserId( req.userId); + const id = req.params.id; + JsonRoutes.sendResult(res, { + code: 200, + data: Meteor.users.findOne({ _id: id }), + }); + }); + JsonRoutes.add('POST', '/api/users/', function (req, res, next) { + Authentication.checkUserId( req.userId); + const id = Accounts.createUser({ + username: req.body.username, + email: req.body.email, + password: 'default', + }); + + JsonRoutes.sendResult(res, { + code: 200, + data: { + _id: id, + }, + }); + }); + + JsonRoutes.add('DELETE', '/api/users/:id', function (req, res, next) { + Authentication.checkUserId( req.userId); + const id = req.params.id; + Meteor.users.remove({ _id: id }); + JsonRoutes.sendResult(res, { + code: 200, + data: { + _id: id, + }, + }); + }); +} + |