diff options
37 files changed, 3723 insertions, 2170 deletions
diff --git a/models/accountSettings.js b/models/accountSettings.js index 6dfbac5d..c4240f84 100644 --- a/models/accountSettings.js +++ b/models/accountSettings.js @@ -1,18 +1,44 @@ AccountSettings = new Mongo.Collection('accountSettings'); -AccountSettings.attachSchema(new SimpleSchema({ - _id: { - type: String, - }, - booleanValue: { - type: Boolean, - optional: true, - }, - sort: { - type: Number, - decimal: true, - }, -})); +AccountSettings.attachSchema( + new SimpleSchema({ + _id: { + type: String, + }, + booleanValue: { + type: Boolean, + optional: true, + }, + sort: { + type: Number, + decimal: true, + }, + createdAt: { + type: Date, + optional: true, + // eslint-disable-next-line consistent-return + autoValue() { + if (this.isInsert) { + return new Date(); + } else { + this.unset(); + } + }, + }, + modifiedAt: { + type: Date, + denyUpdate: false, + // eslint-disable-next-line consistent-return + autoValue() { + if (this.isInsert || this.isUpsert || this.isUpdate) { + return new Date(); + } else { + this.unset(); + } + }, + }, + }) +); AccountSettings.allow({ update(userId) { @@ -21,19 +47,33 @@ AccountSettings.allow({ }, }); +AccountSettings.before.update((userId, doc, fieldNames, modifier, options) => { + modifier.$set = modifier.$set || {}; + modifier.$set.modifiedAt = Date.now(); +}); + if (Meteor.isServer) { Meteor.startup(() => { - AccountSettings.upsert({_id: 'accounts-allowEmailChange'}, { - $setOnInsert: { - booleanValue: false, - sort: 0, - }, - }); - AccountSettings.upsert({_id: 'accounts-allowUserNameChange'}, { - $setOnInsert: { - booleanValue: false, - sort: 1, - }, - }); + AccountSettings._collection._ensureIndex({ modifiedAt: -1 }); + AccountSettings.upsert( + { _id: 'accounts-allowEmailChange' }, + { + $setOnInsert: { + booleanValue: false, + sort: 0, + }, + } + ); + AccountSettings.upsert( + { _id: 'accounts-allowUserNameChange' }, + { + $setOnInsert: { + booleanValue: false, + sort: 1, + }, + } + ); }); } + +export default AccountSettings; diff --git a/models/actions.js b/models/actions.js index 0430b044..8ac764aa 100644 --- a/models/actions.js +++ b/models/actions.js @@ -1,3 +1,5 @@ +import { Meteor } from 'meteor/meteor'; + Actions = new Mongo.Collection('actions'); Actions.allow({ @@ -17,3 +19,16 @@ Actions.helpers({ return this.desc; }, }); + +Actions.before.update((userId, doc, fieldNames, modifier, options) => { + modifier.$set = modifier.$set || {}; + modifier.$set.modifiedAt = Date.now(); +}); + +if (Meteor.isServer) { + Meteor.startup(() => { + Actions._collection._ensureIndex({ modifiedAt: -1 }); + }); +} + +export default Actions; diff --git a/models/activities.js b/models/activities.js index 908d4b14..0e158802 100644 --- a/models/activities.js +++ b/models/activities.js @@ -69,7 +69,11 @@ Activities.before.insert((userId, doc) => { Activities.after.insert((userId, doc) => { const activity = Activities._transform(doc); RulesHelper.executeRules(activity); +}); +Activities.before.update((userId, doc, fieldNames, modifier, options) => { + modifier.$set = modifier.$set || {}; + modifier.$set.modifiedAt = Date.now(); }); if (Meteor.isServer) { @@ -78,11 +82,21 @@ if (Meteor.isServer) { // are largely used in the App. See #524. Meteor.startup(() => { Activities._collection._ensureIndex({ createdAt: -1 }); + Activities._collection._ensureIndex({ modifiedAt: -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._collection._ensureIndex({ customFieldId: 1 }, { partialFilterExpression: { customFieldId: { $exists: true } } }); + Activities._collection._ensureIndex( + { commentId: 1 }, + { partialFilterExpression: { commentId: { $exists: true } } } + ); + Activities._collection._ensureIndex( + { attachmentId: 1 }, + { partialFilterExpression: { attachmentId: { $exists: true } } } + ); + Activities._collection._ensureIndex( + { customFieldId: 1 }, + { partialFilterExpression: { customFieldId: { $exists: true } } } + ); // Label activity did not work yet, unable to edit labels when tried this. //Activities._collection._dropIndex({ labelId: 1 }, { "indexKey": -1 }); //Activities._collection._dropIndex({ labelId: 1 }, { partialFilterExpression: { labelId: { $exists: true } } }); @@ -189,18 +203,35 @@ if (Meteor.isServer) { // params.labelId = activity.labelId; //} if (board) { - const watchingUsers = _.pluck(_.where(board.watchers, {level: 'watching'}), 'userId'); - const trackingUsers = _.pluck(_.where(board.watchers, {level: 'tracking'}), 'userId'); - watchers = _.union(watchers, watchingUsers, _.intersection(participants, trackingUsers)); + const watchingUsers = _.pluck( + _.where(board.watchers, { level: 'watching' }), + 'userId' + ); + const trackingUsers = _.pluck( + _.where(board.watchers, { level: 'tracking' }), + 'userId' + ); + watchers = _.union( + watchers, + watchingUsers, + _.intersection(participants, trackingUsers) + ); } Notifications.getUsers(watchers).forEach((user) => { Notifications.notify(user, title, description, params); }); - const integrations = Integrations.find({ boardId: board._id, type: 'outgoing-webhooks', enabled: true, activities: { '$in': [description, 'all'] } }).fetch(); + const integrations = Integrations.find({ + boardId: board._id, + type: 'outgoing-webhooks', + enabled: true, + activities: { $in: [description, 'all'] }, + }).fetch(); if (integrations.length > 0) { Meteor.call('outgoingWebhooks', integrations, description, params); } }); } + +export default Activities; diff --git a/models/announcements.js b/models/announcements.js index 2cb1e1b7..f3a62244 100644 --- a/models/announcements.js +++ b/models/announcements.js @@ -1,23 +1,49 @@ Announcements = new Mongo.Collection('announcements'); -Announcements.attachSchema(new SimpleSchema({ - enabled: { - type: Boolean, - defaultValue: false, - }, - title: { - type: String, - optional: true, - }, - body: { - type: String, - optional: true, - }, - sort: { - type: Number, - decimal: true, - }, -})); +Announcements.attachSchema( + new SimpleSchema({ + enabled: { + type: Boolean, + defaultValue: false, + }, + title: { + type: String, + optional: true, + }, + body: { + type: String, + optional: true, + }, + sort: { + type: Number, + decimal: true, + }, + createdAt: { + type: Date, + optional: true, + // eslint-disable-next-line consistent-return + autoValue() { + if (this.isInsert) { + return new Date(); + } else { + this.unset(); + } + }, + }, + modifiedAt: { + type: Date, + denyUpdate: false, + // eslint-disable-next-line consistent-return + autoValue() { + if (this.isInsert || this.isUpsert || this.isUpdate) { + return new Date(); + } else { + this.unset(); + } + }, + }, + }) +); Announcements.allow({ update(userId) { @@ -26,11 +52,19 @@ Announcements.allow({ }, }); +Announcements.before.update((userId, doc, fieldNames, modifier, options) => { + modifier.$set = modifier.$set || {}; + modifier.$set.modifiedAt = Date.now(); +}); + if (Meteor.isServer) { Meteor.startup(() => { + Announcements._collection._ensureIndex({ modifiedAt: -1 }); const announcements = Announcements.findOne({}); - if(!announcements){ - Announcements.insert({enabled: false, sort: 0}); + if (!announcements) { + Announcements.insert({ enabled: false, sort: 0 }); } }); } + +export default Announcements; diff --git a/models/attachments.js b/models/attachments.js index 71b30eee..893b5aca 100644 --- a/models/attachments.js +++ b/models/attachments.js @@ -1,6 +1,5 @@ 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', { @@ -25,7 +24,6 @@ Attachments = new FS.Collection('attachments', { ], }); - if (Meteor.isServer) { Meteor.startup(() => { Attachments.files._ensureIndex({ cardId: 1 }); @@ -78,13 +76,16 @@ if (Meteor.isServer) { } else { // Don't add activity about adding the attachment as the activity // be imported and delete source field - Attachments.update({ - _id: doc._id, - }, { - $unset: { - source: '', + Attachments.update( + { + _id: doc._id, }, - }); + { + $unset: { + source: '', + }, + } + ); } }); @@ -107,3 +108,5 @@ if (Meteor.isServer) { }); }); } + +export default Attachments; diff --git a/models/avatars.js b/models/avatars.js index 53924ffb..2fda031d 100644 --- a/models/avatars.js +++ b/models/avatars.js @@ -1,7 +1,5 @@ Avatars = new FS.Collection('avatars', { - stores: [ - new FS.Store.GridFS('avatars'), - ], + stores: [new FS.Store.GridFS('avatars')], filter: { maxSize: 72000, allow: { @@ -18,10 +16,14 @@ Avatars.allow({ insert: isOwner, update: isOwner, remove: isOwner, - download() { return true; }, + download() { + return true; + }, fetch: ['userId'], }); Avatars.files.before.insert((userId, doc) => { doc.userId = userId; }); + +export default Avatars; diff --git a/models/boards.js b/models/boards.js index 396d90fb..2792f80a 100644 --- a/models/boards.js +++ b/models/boards.js @@ -3,316 +3,347 @@ Boards = new Mongo.Collection('boards'); /** * This is a Board. */ -Boards.attachSchema(new SimpleSchema({ - title: { - /** - * The title of the board - */ - type: String, - }, - slug: { - /** - * The title slugified. - */ - type: String, - autoValue() { // eslint-disable-line consistent-return - // 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. - if (this.isInsert && !this.isSet) { - let slug = 'board'; - const title = this.field('title'); - if (title.isSet) { - slug = getSlug(title.value) || slug; +Boards.attachSchema( + new SimpleSchema({ + title: { + /** + * The title of the board + */ + type: String, + }, + slug: { + /** + * The title slugified. + */ + type: String, + // eslint-disable-next-line consistent-return + autoValue() { + // 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. + if (this.isInsert && !this.isSet) { + let slug = 'board'; + const title = this.field('title'); + if (title.isSet) { + slug = getSlug(title.value) || slug; + } + return slug; } - return slug; - } + }, }, - }, - archived: { - /** - * Is the board archived? - */ - type: Boolean, - autoValue() { // eslint-disable-line consistent-return - if (this.isInsert && !this.isSet) { - return false; - } + archived: { + /** + * Is the board archived? + */ + type: Boolean, + // eslint-disable-next-line consistent-return + autoValue() { + if (this.isInsert && !this.isSet) { + return false; + } + }, }, - }, - createdAt: { - /** - * Creation time of the board - */ - type: Date, - autoValue() { // eslint-disable-line consistent-return - if (this.isInsert) { - return new Date(); - } else { - this.unset(); - } + createdAt: { + /** + * Creation time of the board + */ + type: Date, + // eslint-disable-next-line consistent-return + autoValue() { + if (this.isInsert) { + return new Date(); + } else { + this.unset(); + } + }, }, - }, - // XXX Inconsistent field naming - modifiedAt: { - /** - * Last modification time of the board - */ - type: Date, - optional: true, - autoValue() { // eslint-disable-line consistent-return - if (this.isUpdate) { - return new Date(); - } else { - this.unset(); - } + // XXX Inconsistent field naming + modifiedAt: { + /** + * Last modification time of the board + */ + type: Date, + optional: true, + // eslint-disable-next-line consistent-return + autoValue() { + if (this.isInsert || this.isUpsert || this.isUpdate) { + return new Date(); + } else { + this.unset(); + } + }, }, - }, - // De-normalized number of users that have starred this board - stars: { - /** - * How many stars the board has - */ - type: Number, - autoValue() { // eslint-disable-line consistent-return - if (this.isInsert) { - return 0; - } + // De-normalized number of users that have starred this board + stars: { + /** + * How many stars the board has + */ + type: Number, + // eslint-disable-next-line consistent-return + autoValue() { + if (this.isInsert) { + return 0; + } + }, }, - }, - // De-normalized label system - 'labels': { - /** - * List of labels attached to a board - */ - type: [Object], - autoValue() { // eslint-disable-line consistent-return - if (this.isInsert && !this.isSet) { - const colors = Boards.simpleSchema()._schema['labels.$.color'].allowedValues; - const defaultLabelsColors = _.clone(colors).splice(0, 6); - return defaultLabelsColors.map((color) => ({ - color, - _id: Random.id(6), - name: '', - })); - } + // De-normalized label system + labels: { + /** + * List of labels attached to a board + */ + type: [Object], + // eslint-disable-next-line consistent-return + autoValue() { + if (this.isInsert && !this.isSet) { + const colors = Boards.simpleSchema()._schema['labels.$.color'] + .allowedValues; + const defaultLabelsColors = _.clone(colors).splice(0, 6); + return defaultLabelsColors.map((color) => ({ + color, + _id: Random.id(6), + name: '', + })); + } + }, }, - }, - 'labels.$._id': { - /** - * Unique id of a label - */ - // 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': { - /** - * Name of a label - */ - type: String, - optional: true, - }, - 'labels.$.color': { - /** - * color of a label. - * - * Can be amongst `green`, `yellow`, `orange`, `red`, `purple`, - * `blue`, `sky`, `lime`, `pink`, `black`, - * `silver`, `peachpuff`, `crimson`, `plum`, `darkgreen`, - * `slateblue`, `magenta`, `gold`, `navy`, `gray`, - * `saddlebrown`, `paleturquoise`, `mistyrose`, `indigo` - */ - type: String, - allowedValues: [ - 'green', 'yellow', 'orange', 'red', 'purple', - 'blue', 'sky', 'lime', 'pink', 'black', - 'silver', 'peachpuff', 'crimson', 'plum', 'darkgreen', - 'slateblue', 'magenta', 'gold', 'navy', 'gray', - 'saddlebrown', 'paleturquoise', 'mistyrose', 'indigo', - ], - }, - // 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': { - /** - * List of members of a board - */ - type: [Object], - autoValue() { // eslint-disable-line consistent-return - if (this.isInsert && !this.isSet) { - return [{ - userId: this.userId, - isAdmin: true, - isActive: true, - isNoComments: false, - isCommentOnly: false, - }]; - } + 'labels.$._id': { + /** + * Unique id of a label + */ + // 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, }, - }, - 'members.$.userId': { - /** - * The uniq ID of the member - */ - type: String, - }, - 'members.$.isAdmin': { - /** - * Is the member an admin of the board? - */ - type: Boolean, - }, - 'members.$.isActive': { - /** - * Is the member active? - */ - type: Boolean, - }, - 'members.$.isNoComments': { - /** - * Is the member not allowed to make comments - */ - type: Boolean, - optional: true, - }, - 'members.$.isCommentOnly': { - /** - * Is the member only allowed to comment on the board - */ - type: Boolean, - optional: true, - }, - permission: { - /** - * visibility of the board - */ - type: String, - allowedValues: ['public', 'private'], - }, - color: { - /** - * The color of the board. - */ - type: String, - allowedValues: [ - 'belize', - 'nephritis', - 'pomegranate', - 'pumpkin', - 'wisteria', - 'midnight', - ], - autoValue() { // eslint-disable-line consistent-return - if (this.isInsert && !this.isSet) { - return Boards.simpleSchema()._schema.color.allowedValues[0]; - } + 'labels.$.name': { + /** + * Name of a label + */ + type: String, + optional: true, }, - }, - description: { - /** - * The description of the board - */ - type: String, - optional: true, - }, - subtasksDefaultBoardId: { - /** - * The default board ID assigned to subtasks. - */ - type: String, - optional: true, - defaultValue: null, - }, - subtasksDefaultListId: { - /** - * The default List ID assigned to subtasks. - */ - type: String, - optional: true, - defaultValue: null, - }, - allowsSubtasks: { - /** - * Does the board allows subtasks? - */ - type: Boolean, - defaultValue: true, - }, - presentParentTask: { - /** - * Controls how to present the parent task: - * - * - `prefix-with-full-path`: add a prefix with the full path - * - `prefix-with-parent`: add a prefisx with the parent name - * - `subtext-with-full-path`: add a subtext with the full path - * - `subtext-with-parent`: add a subtext with the parent name - * - `no-parent`: does not show the parent at all - */ - type: String, - allowedValues: [ - 'prefix-with-full-path', - 'prefix-with-parent', - 'subtext-with-full-path', - 'subtext-with-parent', - 'no-parent', - ], - optional: true, - defaultValue: 'no-parent', - }, - startAt: { - /** - * Starting date of the board. - */ - type: Date, - optional: true, - }, - dueAt: { - /** - * Due date of the board. - */ - type: Date, - optional: true, - }, - endAt: { - /** - * End date of the board. - */ - type: Date, - optional: true, - }, - spentTime: { - /** - * Time spent in the board. - */ - type: Number, - decimal: true, - optional: true, - }, - isOvertime: { - /** - * Is the board overtimed? - */ - type: Boolean, - defaultValue: false, - optional: true, - }, - type: { - /** - * The type of board - */ - type: String, - defaultValue: 'board', - }, -})); - + 'labels.$.color': { + /** + * color of a label. + * + * Can be amongst `green`, `yellow`, `orange`, `red`, `purple`, + * `blue`, `sky`, `lime`, `pink`, `black`, + * `silver`, `peachpuff`, `crimson`, `plum`, `darkgreen`, + * `slateblue`, `magenta`, `gold`, `navy`, `gray`, + * `saddlebrown`, `paleturquoise`, `mistyrose`, `indigo` + */ + type: String, + allowedValues: [ + 'green', + 'yellow', + 'orange', + 'red', + 'purple', + 'blue', + 'sky', + 'lime', + 'pink', + 'black', + 'silver', + 'peachpuff', + 'crimson', + 'plum', + 'darkgreen', + 'slateblue', + 'magenta', + 'gold', + 'navy', + 'gray', + 'saddlebrown', + 'paleturquoise', + 'mistyrose', + 'indigo', + ], + }, + // 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: { + /** + * List of members of a board + */ + type: [Object], + // eslint-disable-next-line consistent-return + autoValue() { + if (this.isInsert && !this.isSet) { + return [ + { + userId: this.userId, + isAdmin: true, + isActive: true, + isNoComments: false, + isCommentOnly: false, + }, + ]; + } + }, + }, + 'members.$.userId': { + /** + * The uniq ID of the member + */ + type: String, + }, + 'members.$.isAdmin': { + /** + * Is the member an admin of the board? + */ + type: Boolean, + }, + 'members.$.isActive': { + /** + * Is the member active? + */ + type: Boolean, + }, + 'members.$.isNoComments': { + /** + * Is the member not allowed to make comments + */ + type: Boolean, + optional: true, + }, + 'members.$.isCommentOnly': { + /** + * Is the member only allowed to comment on the board + */ + type: Boolean, + optional: true, + }, + permission: { + /** + * visibility of the board + */ + type: String, + allowedValues: ['public', 'private'], + }, + color: { + /** + * The color of the board. + */ + type: String, + allowedValues: [ + 'belize', + 'nephritis', + 'pomegranate', + 'pumpkin', + 'wisteria', + 'midnight', + ], + // eslint-disable-next-line consistent-return + autoValue() { + if (this.isInsert && !this.isSet) { + return Boards.simpleSchema()._schema.color.allowedValues[0]; + } + }, + }, + description: { + /** + * The description of the board + */ + type: String, + optional: true, + }, + subtasksDefaultBoardId: { + /** + * The default board ID assigned to subtasks. + */ + type: String, + optional: true, + defaultValue: null, + }, + subtasksDefaultListId: { + /** + * The default List ID assigned to subtasks. + */ + type: String, + optional: true, + defaultValue: null, + }, + allowsSubtasks: { + /** + * Does the board allows subtasks? + */ + type: Boolean, + defaultValue: true, + }, + presentParentTask: { + /** + * Controls how to present the parent task: + * + * - `prefix-with-full-path`: add a prefix with the full path + * - `prefix-with-parent`: add a prefisx with the parent name + * - `subtext-with-full-path`: add a subtext with the full path + * - `subtext-with-parent`: add a subtext with the parent name + * - `no-parent`: does not show the parent at all + */ + type: String, + allowedValues: [ + 'prefix-with-full-path', + 'prefix-with-parent', + 'subtext-with-full-path', + 'subtext-with-parent', + 'no-parent', + ], + optional: true, + defaultValue: 'no-parent', + }, + startAt: { + /** + * Starting date of the board. + */ + type: Date, + optional: true, + }, + dueAt: { + /** + * Due date of the board. + */ + type: Date, + optional: true, + }, + endAt: { + /** + * End date of the board. + */ + type: Date, + optional: true, + }, + spentTime: { + /** + * Time spent in the board. + */ + type: Number, + decimal: true, + optional: true, + }, + isOvertime: { + /** + * Is the board overtimed? + */ + type: Boolean, + defaultValue: false, + optional: true, + }, + type: { + /** + * The type of board + */ + type: String, + defaultValue: 'board', + }, + }) +); Boards.helpers({ copy() { @@ -350,7 +381,9 @@ Boards.helpers({ */ isActiveMember(userId) { if (userId) { - return this.members.find((member) => (member.userId === userId && member.isActive)); + return this.members.find( + (member) => member.userId === userId && member.isActive + ); } else { return false; } @@ -361,11 +394,17 @@ Boards.helpers({ }, cards() { - return Cards.find({ boardId: this._id, archived: false }, { sort: { title: 1 } }); + return Cards.find( + { boardId: this._id, archived: false }, + { sort: { title: 1 } } + ); }, lists() { - return Lists.find({ boardId: this._id, archived: false }, { sort: { sort: 1 } }); + return Lists.find( + { boardId: this._id, archived: false }, + { sort: { sort: 1 } } + ); }, nullSortLists() { @@ -377,18 +416,24 @@ Boards.helpers({ }, swimlanes() { - return Swimlanes.find({ boardId: this._id, archived: false }, { sort: { sort: 1 } }); + return Swimlanes.find( + { boardId: this._id, archived: false }, + { sort: { sort: 1 } } + ); }, nextSwimlane(swimlane) { - return Swimlanes.findOne({ - boardId: this._id, - archived: false, - sort: { $gte: swimlane.sort }, - _id: { $ne: swimlane._id }, - }, { - sort: { sort: 1 }, - }); + return Swimlanes.findOne( + { + boardId: this._id, + archived: false, + sort: { $gte: swimlane.sort }, + _id: { $ne: swimlane._id }, + }, + { + sort: { sort: 1 }, + } + ); }, nullSortSwimlanes() { @@ -399,13 +444,21 @@ Boards.helpers({ }); }, - hasOvertimeCards(){ - const card = Cards.findOne({isOvertime: true, boardId: this._id, archived: false} ); + hasOvertimeCards() { + const card = Cards.findOne({ + isOvertime: true, + boardId: this._id, + archived: false, + }); return card !== undefined; }, - hasSpentTimeCards(){ - const card = Cards.findOne({spentTime: { $gt: 0 }, boardId: this._id, archived: false} ); + hasSpentTimeCards() { + const card = Cards.findOne({ + spentTime: { $gt: 0 }, + boardId: this._id, + archived: false, + }); return card !== undefined; }, @@ -429,7 +482,7 @@ Boards.helpers({ return _.findWhere(this.labels, { name, color }); }, - getLabelById(labelId){ + getLabelById(labelId) { return _.findWhere(this.labels, { _id: labelId }); }, @@ -446,15 +499,29 @@ Boards.helpers({ }, hasAdmin(memberId) { - return !!_.findWhere(this.members, { userId: memberId, isActive: true, isAdmin: true }); + return !!_.findWhere(this.members, { + userId: memberId, + isActive: true, + isAdmin: true, + }); }, hasNoComments(memberId) { - return !!_.findWhere(this.members, { userId: memberId, isActive: true, isAdmin: false, isNoComments: true }); + return !!_.findWhere(this.members, { + userId: memberId, + isActive: true, + isAdmin: false, + isNoComments: true, + }); }, hasCommentOnly(memberId) { - return !!_.findWhere(this.members, { userId: memberId, isActive: true, isAdmin: false, isCommentOnly: true }); + return !!_.findWhere(this.members, { + userId: memberId, + isActive: true, + isAdmin: false, + isCommentOnly: true, + }); }, absoluteUrl() { @@ -466,7 +533,10 @@ Boards.helpers({ }, customFields() { - return CustomFields.find({ boardIds: {$in: [this._id]} }, { sort: { name: 1 } }); + return CustomFields.find( + { boardIds: { $in: [this._id] } }, + { sort: { name: 1 } } + ); }, // XXX currently mutations return no value so we have an issue when using addLabel in import @@ -489,10 +559,7 @@ Boards.helpers({ if (term) { const regex = new RegExp(term, 'i'); - query.$or = [ - { title: regex }, - { description: regex }, - ]; + query.$or = [{ title: regex }, { description: regex }]; } return Cards.find(query, projection); @@ -506,17 +573,14 @@ Boards.helpers({ query.type = 'template-swimlane'; query.archived = false; } else { - query.type = {$nin: ['template-swimlane']}; + query.type = { $nin: ['template-swimlane'] }; } const projection = { limit: 10, sort: { createdAt: -1 } }; if (term) { const regex = new RegExp(term, 'i'); - query.$or = [ - { title: regex }, - { description: regex }, - ]; + query.$or = [{ title: regex }, { description: regex }]; } return Swimlanes.find(query, projection); @@ -530,17 +594,14 @@ Boards.helpers({ query.type = 'template-list'; query.archived = false; } else { - query.type = {$nin: ['template-list']}; + query.type = { $nin: ['template-list'] }; } const projection = { limit: 10, sort: { createdAt: -1 } }; if (term) { const regex = new RegExp(term, 'i'); - query.$or = [ - { title: regex }, - { description: regex }, - ]; + query.$or = [{ title: regex }, { description: regex }]; } return Lists.find(query, projection); @@ -557,17 +618,14 @@ Boards.helpers({ query.type = 'template-card'; query.archived = false; } else { - query.type = {$nin: ['template-card']}; + query.type = { $nin: ['template-card'] }; } const projection = { limit: 10, sort: { createdAt: -1 } }; if (term) { const regex = new RegExp(term, 'i'); - query.$or = [ - { title: regex }, - { description: regex }, - ]; + query.$or = [{ title: regex }, { description: regex }]; } return Cards.find(query, projection); @@ -575,22 +633,29 @@ Boards.helpers({ // A board alwasy has another board where it deposits subtasks of thasks // that belong to itself. getDefaultSubtasksBoardId() { - if ((this.subtasksDefaultBoardId === null) || (this.subtasksDefaultBoardId === undefined)) { + if ( + this.subtasksDefaultBoardId === null || + this.subtasksDefaultBoardId === undefined + ) { this.subtasksDefaultBoardId = Boards.insert({ title: `^${this.title}^`, permission: this.permission, members: this.members, color: this.color, - description: TAPi18n.__('default-subtasks-board', {board: this.title}), + description: TAPi18n.__('default-subtasks-board', { + board: this.title, + }), }); Swimlanes.insert({ title: TAPi18n.__('default'), boardId: this.subtasksDefaultBoardId, }); - Boards.update(this._id, {$set: { - subtasksDefaultBoardId: this.subtasksDefaultBoardId, - }}); + Boards.update(this._id, { + $set: { + subtasksDefaultBoardId: this.subtasksDefaultBoardId, + }, + }); } return this.subtasksDefaultBoardId; }, @@ -600,7 +665,10 @@ Boards.helpers({ }, getDefaultSubtasksListId() { - if ((this.subtasksDefaultListId === null) || (this.subtasksDefaultListId === undefined)) { + if ( + this.subtasksDefaultListId === null || + this.subtasksDefaultListId === undefined + ) { this.subtasksDefaultListId = Lists.insert({ title: TAPi18n.__('queue'), boardId: this._id, @@ -615,13 +683,13 @@ Boards.helpers({ }, getDefaultSwimline() { - let result = Swimlanes.findOne({boardId: this._id}); + let result = Swimlanes.findOne({ boardId: this._id }); if (result === undefined) { Swimlanes.insert({ title: TAPi18n.__('default'), boardId: this._id, }); - result = Swimlanes.findOne({boardId: this._id}); + result = Swimlanes.findOne({ boardId: this._id }); } return result; }, @@ -633,19 +701,24 @@ Boards.helpers({ { startAt: { $lte: start, - }, endAt: { + }, + endAt: { $gte: start, }, - }, { + }, + { startAt: { $lte: end, - }, endAt: { + }, + endAt: { $gte: end, }, - }, { + }, + { startAt: { $gte: start, - }, endAt: { + }, + endAt: { $lte: end, }, }, @@ -662,7 +735,6 @@ Boards.helpers({ }, }); - Boards.mutations({ archive() { return { $set: { archived: true } }; @@ -753,7 +825,8 @@ Boards.mutations({ const memberIndex = this.memberIndex(memberId); // we do not allow the only one admin to be removed - const allowRemove = (!this.members[memberIndex].isAdmin) || (this.activeAdmins().length > 1); + const allowRemove = + !this.members[memberIndex].isAdmin || this.activeAdmins().length > 1; if (!allowRemove) { return { $set: { @@ -770,7 +843,13 @@ Boards.mutations({ }; }, - setMemberPermission(memberId, isAdmin, isNoComments, isCommentOnly, currentUserId = Meteor.userId()) { + setMemberPermission( + memberId, + isAdmin, + isNoComments, + isCommentOnly, + currentUserId = Meteor.userId() + ) { const memberIndex = this.memberIndex(memberId); // do not allow change permission of self if (memberId === currentUserId) { @@ -804,12 +883,13 @@ Boards.mutations({ }); function boardRemover(userId, doc) { - [Cards, Lists, Swimlanes, Integrations, Rules, Activities].forEach((element) => { - element.remove({ boardId: doc._id }); - }); + [Cards, Lists, Swimlanes, Integrations, Rules, Activities].forEach( + (element) => { + element.remove({ boardId: doc._id }); + } + ); } - if (Meteor.isServer) { Boards.allow({ insert: Meteor.userId, @@ -830,25 +910,25 @@ if (Meteor.isServer) { // 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; + 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 (!_.isObject(modifier.$pull && modifier.$pull.members)) 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; - if (nbAdmins > 1) - return false; + const nbAdmins = _.where(doc.members, { isActive: true, isAdmin: true }) + .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, - })); + return Boolean( + _.findWhere(doc.members, { + userId: removedMemberId, + isAdmin: true, + }) + ); }, fetch: ['members'], }); @@ -882,16 +962,19 @@ if (Meteor.isServer) { } else throw new Meteor.Error('error-board-doesNotExist'); }, }); - } 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 }); + Boards._collection._ensureIndex({ modifiedAt: -1 }); + Boards._collection._ensureIndex( + { + _id: 1, + 'members.userId': 1, + }, + { unique: true } + ); Boards._collection._ensureIndex({ 'members.userId': 1 }); }); @@ -909,10 +992,12 @@ if (Meteor.isServer) { // 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') || + if ( + !_.contains(fieldNames, 'labels') || !modifier.$pull || !modifier.$pull.labels || - !modifier.$pull.labels._id) { + !modifier.$pull.labels._id + ) { return; } @@ -935,12 +1020,21 @@ if (Meteor.isServer) { } const parts = set.split('.'); - if (parts.length === 3 && parts[0] === 'members' && parts[2] === 'isActive') { + if ( + parts.length === 3 && + parts[0] === 'members' && + parts[2] === 'isActive' + ) { callback(doc.members[parts[1]].userId); } }); }; + Boards.before.update((userId, doc, fieldNames, modifier, options) => { + modifier.$set = modifier.$set || {}; + modifier.$set.modifiedAt = Date.now(); + }); + // Remove a member from all objects of the board before leaving the board Boards.before.update((userId, doc, fieldNames, modifier) => { if (!_.contains(fieldNames, 'members')) { @@ -976,14 +1070,11 @@ if (Meteor.isServer) { // Remove board from users starred list if (!board.isPublic()) { - Users.update( - memberId, - { - $pull: { - 'profile.starredBoards': boardId, - }, - } - ); + Users.update(memberId, { + $pull: { + 'profile.starredBoards': boardId, + }, + }); } }); } @@ -1044,29 +1135,34 @@ if (Meteor.isServer) { * @return_type [{_id: string, title: string}] */ - JsonRoutes.add('GET', '/api/users/:userId/boards', function (req, res) { + JsonRoutes.add('GET', '/api/users/:userId/boards', function(req, res) { try { 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); + Authentication.checkAdminOrCondition( + req.userId, + req.userId === paramUserId + ); - const data = Boards.find({ - archived: false, - 'members.userId': paramUserId, - }, { - sort: ['title'], - }).map(function(board) { + const data = Boards.find( + { + archived: false, + 'members.userId': paramUserId, + }, + { + sort: ['title'], + } + ).map(function(board) { return { _id: board._id, title: board.title, }; }); - JsonRoutes.sendResult(res, {code: 200, data}); - } - catch (error) { + JsonRoutes.sendResult(res, { code: 200, data }); + } catch (error) { JsonRoutes.sendResult(res, { code: 200, data: error, @@ -1081,20 +1177,19 @@ if (Meteor.isServer) { * @return_type [{_id: string, title: string}] */ - JsonRoutes.add('GET', '/api/boards', function (req, res) { + JsonRoutes.add('GET', '/api/boards', function(req, res) { try { Authentication.checkUserId(req.userId); JsonRoutes.sendResult(res, { code: 200, - data: Boards.find({ permission: 'public' }).map(function (doc) { + data: Boards.find({ permission: 'public' }).map(function(doc) { return { _id: doc._id, title: doc.title, }; }), }); - } - catch (error) { + } catch (error) { JsonRoutes.sendResult(res, { code: 200, data: error, @@ -1109,7 +1204,7 @@ if (Meteor.isServer) { * @param {string} boardId the ID of the board to retrieve the data * @return_type Boards */ - JsonRoutes.add('GET', '/api/boards/:boardId', function (req, res) { + JsonRoutes.add('GET', '/api/boards/:boardId', function(req, res) { try { const id = req.params.boardId; Authentication.checkBoardAccess(req.userId, id); @@ -1118,8 +1213,7 @@ if (Meteor.isServer) { code: 200, data: Boards.findOne({ _id: id }), }); - } - catch (error) { + } catch (error) { JsonRoutes.sendResult(res, { code: 200, data: error, @@ -1152,7 +1246,7 @@ if (Meteor.isServer) { * @return_type {_id: string, defaultSwimlaneId: string} */ - JsonRoutes.add('POST', '/api/boards', function (req, res) { + JsonRoutes.add('POST', '/api/boards', function(req, res) { try { Authentication.checkUserId(req.userId); const id = Boards.insert({ @@ -1180,8 +1274,7 @@ if (Meteor.isServer) { defaultSwimlaneId: swimlaneId, }, }); - } - catch (error) { + } catch (error) { JsonRoutes.sendResult(res, { code: 200, data: error, @@ -1195,19 +1288,18 @@ if (Meteor.isServer) { * * @param {string} boardId the ID of the board */ - JsonRoutes.add('DELETE', '/api/boards/:boardId', function (req, res) { + JsonRoutes.add('DELETE', '/api/boards/:boardId', function(req, res) { try { Authentication.checkUserId(req.userId); const id = req.params.boardId; Boards.remove({ _id: id }); JsonRoutes.sendResult(res, { code: 200, - data:{ + data: { _id: id, }, }); - } - catch (error) { + } catch (error) { JsonRoutes.sendResult(res, { code: 200, data: error, @@ -1228,7 +1320,7 @@ if (Meteor.isServer) { * * @return_type string */ - JsonRoutes.add('PUT', '/api/boards/:boardId/labels', function (req, res) { + JsonRoutes.add('PUT', '/api/boards/:boardId/labels', function(req, res) { Authentication.checkUserId(req.userId); const id = req.params.boardId; try { @@ -1238,7 +1330,10 @@ if (Meteor.isServer) { const name = req.body.label.name; const labelId = Random.id(6); if (!board.getLabel(name, color)) { - Boards.direct.update({ _id: id }, { $push: { labels: { _id: labelId, name, color } } }); + Boards.direct.update( + { _id: id }, + { $push: { labels: { _id: labelId, name, color } } } + ); JsonRoutes.sendResult(res, { code: 200, data: labelId, @@ -1249,8 +1344,7 @@ if (Meteor.isServer) { }); } } - } - catch (error) { + } catch (error) { JsonRoutes.sendResult(res, { data: error, }); @@ -1268,29 +1362,36 @@ if (Meteor.isServer) { * @param {boolean} isNoComments NoComments capability * @param {boolean} isCommentOnly CommentsOnly capability */ - JsonRoutes.add('POST', '/api/boards/:boardId/members/:memberId', function (req, res) { + JsonRoutes.add('POST', '/api/boards/:boardId/members/:memberId', function( + req, + res + ) { try { const boardId = req.params.boardId; const memberId = req.params.memberId; - const {isAdmin, isNoComments, isCommentOnly} = req.body; + const { isAdmin, isNoComments, isCommentOnly } = req.body; Authentication.checkBoardAccess(req.userId, boardId); const board = Boards.findOne({ _id: boardId }); - function isTrue(data){ + function isTrue(data) { try { return data.toLowerCase() === 'true'; - } - catch (error) { + } catch (error) { return data; } } - const query = board.setMemberPermission(memberId, isTrue(isAdmin), isTrue(isNoComments), isTrue(isCommentOnly), req.userId); + const query = board.setMemberPermission( + memberId, + isTrue(isAdmin), + isTrue(isNoComments), + isTrue(isCommentOnly), + req.userId + ); JsonRoutes.sendResult(res, { code: 200, data: query, }); - } - catch (error) { + } catch (error) { JsonRoutes.sendResult(res, { code: 200, data: error, @@ -1298,3 +1399,5 @@ if (Meteor.isServer) { } }); } + +export default Boards; diff --git a/models/cardComments.js b/models/cardComments.js index a823066c..8f727aa0 100644 --- a/models/cardComments.js +++ b/models/cardComments.js @@ -3,55 +3,69 @@ CardComments = new Mongo.Collection('card_comments'); /** * A comment on a card */ -CardComments.attachSchema(new SimpleSchema({ - boardId: { - /** - * the board ID - */ - type: String, - }, - cardId: { - /** - * the card ID - */ - type: String, - }, - // XXX Rename in `content`? `text` is a bit vague... - text: { - /** - * the text of the comment - */ - type: String, - }, - // XXX We probably don't need this information here, since we already have it - // in the associated comment creation activity - createdAt: { - /** - * when was the comment created - */ - type: Date, - denyUpdate: false, - autoValue() { // eslint-disable-line consistent-return - if (this.isInsert) { - return new Date(); - } else { - this.unset(); - } +CardComments.attachSchema( + new SimpleSchema({ + boardId: { + /** + * the board ID + */ + type: String, }, - }, - // XXX Should probably be called `authorId` - userId: { - /** - * the author ID of the comment - */ - type: String, - autoValue() { // eslint-disable-line consistent-return - if (this.isInsert && !this.isSet) { - return this.userId; - } + cardId: { + /** + * the card ID + */ + type: String, }, - }, -})); + // XXX Rename in `content`? `text` is a bit vague... + text: { + /** + * the text of the comment + */ + type: String, + }, + createdAt: { + /** + * when was the comment created + */ + type: Date, + denyUpdate: false, + // eslint-disable-next-line consistent-return + autoValue() { + if (this.isInsert) { + return new Date(); + } else { + this.unset(); + } + }, + }, + modifiedAt: { + type: Date, + denyUpdate: false, + // eslint-disable-next-line consistent-return + autoValue() { + if (this.isInsert || this.isUpsert || this.isUpdate) { + return new Date(); + } else { + this.unset(); + } + }, + }, + // XXX Should probably be called `authorId` + userId: { + /** + * the author ID of the comment + */ + type: String, + // eslint-disable-next-line consistent-return + autoValue() { + if (this.isInsert && !this.isSet) { + return this.userId; + } + }, + }, + }) +); CardComments.allow({ insert(userId, doc) { @@ -80,7 +94,7 @@ CardComments.helpers({ CardComments.hookOptions.after.update = { fetchPrevious: false }; -function commentCreation(userId, doc){ +function commentCreation(userId, doc) { const card = Cards.findOne(doc.cardId); Activities.insert({ userId, @@ -93,10 +107,16 @@ function commentCreation(userId, doc){ }); } +CardComments.before.update((userId, doc, fieldNames, modifier, options) => { + modifier.$set = modifier.$set || {}; + modifier.$set.modifiedAt = Date.now(); +}); + if (Meteor.isServer) { // Comments are often fetched within a card, so we create an index to make these // queries more efficient. Meteor.startup(() => { + CardComments._collection._ensureIndex({ modifiedAt: -1 }); CardComments._collection._ensureIndex({ cardId: 1, createdAt: -1 }); }); @@ -152,14 +172,20 @@ if (Meteor.isServer) { * comment: string, * authorId: string}] */ - JsonRoutes.add('GET', '/api/boards/:boardId/cards/:cardId/comments', function (req, res) { + JsonRoutes.add('GET', '/api/boards/:boardId/cards/:cardId/comments', function( + req, + res + ) { try { - Authentication.checkUserId( req.userId); + 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) { + data: CardComments.find({ + boardId: paramBoardId, + cardId: paramCardId, + }).map(function(doc) { return { _id: doc._id, comment: doc.text, @@ -167,8 +193,7 @@ if (Meteor.isServer) { }; }), }); - } - catch (error) { + } catch (error) { JsonRoutes.sendResult(res, { code: 200, data: error, @@ -185,24 +210,31 @@ if (Meteor.isServer) { * @param {string} commentId the ID of the comment to retrieve * @return_type CardComments */ - JsonRoutes.add('GET', '/api/boards/:boardId/cards/:cardId/comments/:commentId', function (req, res) { - try { - 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 }), - }); - } - catch (error) { - JsonRoutes.sendResult(res, { - code: 200, - data: error, - }); + JsonRoutes.add( + 'GET', + '/api/boards/:boardId/cards/:cardId/comments/:commentId', + function(req, res) { + try { + 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, + }), + }); + } catch (error) { + JsonRoutes.sendResult(res, { + code: 200, + data: error, + }); + } } - }); + ); /** * @operation new_comment @@ -214,35 +246,42 @@ if (Meteor.isServer) { * @param {string} text the content of the comment * @return_type {_id: string} */ - JsonRoutes.add('POST', '/api/boards/:boardId/cards/:cardId/comments', function (req, res) { - try { - Authentication.checkUserId( req.userId); - const paramBoardId = req.params.boardId; - const paramCardId = req.params.cardId; - const id = CardComments.direct.insert({ - userId: req.body.authorId, - text: req.body.comment, - cardId: paramCardId, - boardId: paramBoardId, - }); + JsonRoutes.add( + 'POST', + '/api/boards/:boardId/cards/:cardId/comments', + function(req, res) { + try { + Authentication.checkUserId(req.userId); + const paramBoardId = req.params.boardId; + const paramCardId = req.params.cardId; + const id = CardComments.direct.insert({ + userId: req.body.authorId, + text: req.body.comment, + cardId: paramCardId, + boardId: paramBoardId, + }); - JsonRoutes.sendResult(res, { - code: 200, - data: { - _id: id, - }, - }); + JsonRoutes.sendResult(res, { + code: 200, + data: { + _id: id, + }, + }); - const cardComment = CardComments.findOne({_id: id, cardId:paramCardId, boardId: paramBoardId }); - commentCreation(req.body.authorId, cardComment); - } - catch (error) { - JsonRoutes.sendResult(res, { - code: 200, - data: error, - }); + const cardComment = CardComments.findOne({ + _id: id, + cardId: paramCardId, + boardId: paramBoardId, + }); + commentCreation(req.body.authorId, cardComment); + } catch (error) { + JsonRoutes.sendResult(res, { + code: 200, + data: error, + }); + } } - }); + ); /** * @operation delete_comment @@ -253,25 +292,34 @@ if (Meteor.isServer) { * @param {string} commentId the ID of the comment to delete * @return_type {_id: string} */ - JsonRoutes.add('DELETE', '/api/boards/:boardId/cards/:cardId/comments/:commentId', function (req, res) { - try { - 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, - }, - }); - } - catch (error) { - JsonRoutes.sendResult(res, { - code: 200, - data: error, - }); + JsonRoutes.add( + 'DELETE', + '/api/boards/:boardId/cards/:cardId/comments/:commentId', + function(req, res) { + try { + 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, + }, + }); + } catch (error) { + JsonRoutes.sendResult(res, { + code: 200, + data: error, + }); + } } - }); + ); } + +export default CardComments; diff --git a/models/cards.js b/models/cards.js index fdb7deb3..b873c086 100644 --- a/models/cards.js +++ b/models/cards.js @@ -81,7 +81,8 @@ Cards.attachSchema(new SimpleSchema({ * creation date */ type: Date, - autoValue() { // eslint-disable-line consistent-return + // eslint-disable-next-line consistent-return + autoValue() { if (this.isInsert) { return new Date(); } else { @@ -89,6 +90,18 @@ Cards.attachSchema(new SimpleSchema({ } }, }, + modifiedAt: { + type: Date, + denyUpdate: false, + // eslint-disable-next-line consistent-return + autoValue() { + if (this.isInsert || this.isUpsert || this.isUpdate) { + return new Date(); + } else { + this.unset(); + } + }, + }, customFields: { /** * list of custom fields @@ -1539,7 +1552,8 @@ 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, createdAt: -1}); + Cards._collection._ensureIndex({ modifiedAt: -1 }); + Cards._collection._ensureIndex({ boardId: 1, createdAt: -1 }); // https://github.com/wekan/wekan/issues/1863 // Swimlane added a new field in the cards collection of mongodb named parentId. // When loading a board, mongodb is searching for every cards, the id of the parent (in the swinglanes collection). @@ -1581,6 +1595,11 @@ if (Meteor.isServer) { cardCustomFields(userId, doc, fieldNames, modifier); }); + Cards.before.update((userId, doc, fieldNames, modifier, options) => { + modifier.$set = modifier.$set || {}; + modifier.$set.modifiedAt = Date.now(); + }); + // Remove all activities associated with a card if we remove the card // Remove also card_comments / checklists / attachments Cards.before.remove((userId, doc) => { @@ -1980,3 +1999,5 @@ if (Meteor.isServer) { }); } + +export default Cards; diff --git a/models/checklistItems.js b/models/checklistItems.js index df56c475..d548e681 100644 --- a/models/checklistItems.js +++ b/models/checklistItems.js @@ -3,40 +3,66 @@ ChecklistItems = new Mongo.Collection('checklistItems'); /** * An item in a checklist */ -ChecklistItems.attachSchema(new SimpleSchema({ - title: { - /** - * the text of the item - */ - type: String, - }, - sort: { - /** - * the sorting field of the item - */ - type: Number, - decimal: true, - }, - isFinished: { - /** - * Is the item checked? - */ - type: Boolean, - defaultValue: false, - }, - checklistId: { - /** - * the checklist ID the item is attached to - */ - type: String, - }, - cardId: { - /** - * the card ID the item is attached to - */ - type: String, - }, -})); +ChecklistItems.attachSchema( + new SimpleSchema({ + title: { + /** + * the text of the item + */ + type: String, + }, + sort: { + /** + * the sorting field of the item + */ + type: Number, + decimal: true, + }, + isFinished: { + /** + * Is the item checked? + */ + type: Boolean, + defaultValue: false, + }, + checklistId: { + /** + * the checklist ID the item is attached to + */ + type: String, + }, + cardId: { + /** + * the card ID the item is attached to + */ + type: String, + }, + createdAt: { + type: Date, + optional: true, + // eslint-disable-next-line consistent-return + autoValue() { + if (this.isInsert) { + return new Date(); + } else { + this.unset(); + } + }, + }, + modifiedAt: { + type: Date, + denyUpdate: false, + // eslint-disable-next-line consistent-return + autoValue() { + if (this.isInsert || this.isUpsert || this.isUpdate) { + return new Date(); + } else { + this.unset(); + } + }, + }, + }) +); ChecklistItems.allow({ insert(userId, doc) { @@ -62,10 +88,10 @@ ChecklistItems.mutations({ setTitle(title) { return { $set: { title } }; }, - check(){ + check() { return { $set: { isFinished: true } }; }, - uncheck(){ + uncheck() { return { $set: { isFinished: false } }; }, toggleItem() { @@ -79,7 +105,7 @@ ChecklistItems.mutations({ sort: sortIndex, }; - return {$set: mutatedFields}; + return { $set: mutatedFields }; }, }); @@ -106,13 +132,13 @@ function itemRemover(userId, doc) { }); } -function publishCheckActivity(userId, doc){ +function publishCheckActivity(userId, doc) { const card = Cards.findOne(doc.cardId); const boardId = card.boardId; let activityType; - if(doc.isFinished){ + if (doc.isFinished) { activityType = 'checkedItem'; - }else{ + } else { activityType = 'uncheckedItem'; } const act = { @@ -122,19 +148,19 @@ function publishCheckActivity(userId, doc){ boardId, checklistId: doc.checklistId, checklistItemId: doc._id, - checklistItemName:doc.title, + checklistItemName: doc.title, listId: card.listId, swimlaneId: card.swimlaneId, }; Activities.insert(act); } -function publishChekListCompleted(userId, doc){ +function publishChekListCompleted(userId, doc) { const card = Cards.findOne(doc.cardId); const boardId = card.boardId; const checklistId = doc.checklistId; - const checkList = Checklists.findOne({_id:checklistId}); - if(checkList.isFinished()){ + const checkList = Checklists.findOne({ _id: checklistId }); + if (checkList.isFinished()) { const act = { userId, activityType: 'completeChecklist', @@ -149,11 +175,11 @@ function publishChekListCompleted(userId, doc){ } } -function publishChekListUncompleted(userId, doc){ +function publishChekListUncompleted(userId, doc) { const card = Cards.findOne(doc.cardId); const boardId = card.boardId; const checklistId = doc.checklistId; - const checkList = Checklists.findOne({_id:checklistId}); + const checkList = Checklists.findOne({ _id: checklistId }); // BUGS in IFTTT Rules: https://github.com/wekan/wekan/issues/1972 // Currently in checklist all are set as uncompleted/not checked, // IFTTT Rule does not move card to other list. @@ -167,7 +193,7 @@ function publishChekListUncompleted(userId, doc){ // find . | xargs grep 'count' -sl | grep -v .meteor | grep -v node_modules | grep -v .build // Maybe something related here? // wekan/client/components/rules/triggers/checklistTriggers.js - if(checkList.isFinished()){ + if (checkList.isFinished()) { const act = { userId, activityType: 'uncompleteChecklist', @@ -185,6 +211,7 @@ function publishChekListUncompleted(userId, doc){ // Activities if (Meteor.isServer) { Meteor.startup(() => { + ChecklistItems._collection._ensureIndex({ modifiedAt: -1 }); ChecklistItems._collection._ensureIndex({ checklistId: 1 }); ChecklistItems._collection._ensureIndex({ cardId: 1 }); }); @@ -198,6 +225,10 @@ if (Meteor.isServer) { publishChekListUncompleted(userId, doc, fieldNames); }); + ChecklistItems.before.update((userId, doc, fieldNames, modifier, options) => { + modifier.$set = modifier.$set || {}; + modifier.$set.modifiedAt = Date.now(); + }); ChecklistItems.after.insert((userId, doc) => { itemCreation(userId, doc); @@ -214,7 +245,7 @@ if (Meteor.isServer) { boardId, checklistId: doc.checklistId, checklistItemId: doc._id, - checklistItemName:doc.title, + checklistItemName: doc.title, listId: card.listId, swimlaneId: card.swimlaneId, }); @@ -233,21 +264,25 @@ if (Meteor.isServer) { * @param {string} itemId the ID of the item * @return_type ChecklistItems */ - JsonRoutes.add('GET', '/api/boards/:boardId/cards/:cardId/checklists/:checklistId/items/:itemId', function (req, res) { - Authentication.checkUserId( req.userId); - const paramItemId = req.params.itemId; - const checklistItem = ChecklistItems.findOne({ _id: paramItemId }); - if (checklistItem) { - JsonRoutes.sendResult(res, { - code: 200, - data: checklistItem, - }); - } else { - JsonRoutes.sendResult(res, { - code: 500, - }); + JsonRoutes.add( + 'GET', + '/api/boards/:boardId/cards/:cardId/checklists/:checklistId/items/:itemId', + function(req, res) { + Authentication.checkUserId(req.userId); + const paramItemId = req.params.itemId; + const checklistItem = ChecklistItems.findOne({ _id: paramItemId }); + if (checklistItem) { + JsonRoutes.sendResult(res, { + code: 200, + data: checklistItem, + }); + } else { + JsonRoutes.sendResult(res, { + code: 500, + }); + } } - }); + ); /** * @operation edit_checklist_item @@ -262,25 +297,35 @@ if (Meteor.isServer) { * @param {string} [title] the new text of the item * @return_type {_id: string} */ - JsonRoutes.add('PUT', '/api/boards/:boardId/cards/:cardId/checklists/:checklistId/items/:itemId', function (req, res) { - Authentication.checkUserId( req.userId); + JsonRoutes.add( + 'PUT', + '/api/boards/:boardId/cards/:cardId/checklists/:checklistId/items/:itemId', + function(req, res) { + Authentication.checkUserId(req.userId); - const paramItemId = req.params.itemId; + const paramItemId = req.params.itemId; - if (req.body.hasOwnProperty('isFinished')) { - ChecklistItems.direct.update({_id: paramItemId}, {$set: {isFinished: req.body.isFinished}}); - } - if (req.body.hasOwnProperty('title')) { - ChecklistItems.direct.update({_id: paramItemId}, {$set: {title: req.body.title}}); - } + if (req.body.hasOwnProperty('isFinished')) { + ChecklistItems.direct.update( + { _id: paramItemId }, + { $set: { isFinished: req.body.isFinished } } + ); + } + if (req.body.hasOwnProperty('title')) { + ChecklistItems.direct.update( + { _id: paramItemId }, + { $set: { title: req.body.title } } + ); + } - JsonRoutes.sendResult(res, { - code: 200, - data: { - _id: paramItemId, - }, - }); - }); + JsonRoutes.sendResult(res, { + code: 200, + data: { + _id: paramItemId, + }, + }); + } + ); /** * @operation delete_checklist_item @@ -295,15 +340,21 @@ if (Meteor.isServer) { * @param {string} itemId the ID of the item to be removed * @return_type {_id: string} */ - JsonRoutes.add('DELETE', '/api/boards/:boardId/cards/:cardId/checklists/:checklistId/items/:itemId', function (req, res) { - Authentication.checkUserId( req.userId); - const paramItemId = req.params.itemId; - ChecklistItems.direct.remove({ _id: paramItemId }); - JsonRoutes.sendResult(res, { - code: 200, - data: { - _id: paramItemId, - }, - }); - }); + JsonRoutes.add( + 'DELETE', + '/api/boards/:boardId/cards/:cardId/checklists/:checklistId/items/:itemId', + function(req, res) { + Authentication.checkUserId(req.userId); + const paramItemId = req.params.itemId; + ChecklistItems.direct.remove({ _id: paramItemId }); + JsonRoutes.sendResult(res, { + code: 200, + data: { + _id: paramItemId, + }, + }); + } + ); } + +export default ChecklistItems; diff --git a/models/checklists.js b/models/checklists.js index 653fed4d..6fd22702 100644 --- a/models/checklists.js +++ b/models/checklists.js @@ -3,49 +3,64 @@ Checklists = new Mongo.Collection('checklists'); /** * A Checklist */ -Checklists.attachSchema(new SimpleSchema({ - cardId: { - /** - * The ID of the card the checklist is in - */ - type: String, - }, - title: { - /** - * the title of the checklist - */ - type: String, - defaultValue: 'Checklist', - }, - finishedAt: { - /** - * When was the checklist finished - */ - type: Date, - optional: true, - }, - createdAt: { - /** - * Creation date of the checklist - */ - type: Date, - denyUpdate: false, - autoValue() { // eslint-disable-line consistent-return - if (this.isInsert) { - return new Date(); - } else { - this.unset(); - } +Checklists.attachSchema( + new SimpleSchema({ + cardId: { + /** + * The ID of the card the checklist is in + */ + type: String, }, - }, - sort: { - /** - * sorting value of the checklist - */ - type: Number, - decimal: true, - }, -})); + title: { + /** + * the title of the checklist + */ + type: String, + defaultValue: 'Checklist', + }, + finishedAt: { + /** + * When was the checklist finished + */ + type: Date, + optional: true, + }, + createdAt: { + /** + * Creation date of the checklist + */ + type: Date, + denyUpdate: false, + // eslint-disable-next-line consistent-return + autoValue() { + if (this.isInsert) { + return new Date(); + } else { + this.unset(); + } + }, + }, + modifiedAt: { + type: Date, + denyUpdate: false, + // eslint-disable-next-line consistent-return + autoValue() { + if (this.isInsert || this.isUpsert || this.isUpdate) { + return new Date(); + } else { + this.unset(); + } + }, + }, + sort: { + /** + * sorting value of the checklist + */ + type: Number, + decimal: true, + }, + }) +); Checklists.helpers({ copy(newCardId) { @@ -53,7 +68,7 @@ Checklists.helpers({ this._id = null; this.cardId = newCardId; const newChecklistId = Checklists.insert(this); - ChecklistItems.find({checklistId: oldChecklistId}).forEach((item) => { + ChecklistItems.find({ checklistId: oldChecklistId }).forEach((item) => { item._id = null; item.checklistId = newChecklistId; item.cardId = newCardId; @@ -65,9 +80,12 @@ Checklists.helpers({ return ChecklistItems.find({ checklistId: this._id }).count(); }, items() { - return ChecklistItems.find({ - checklistId: this._id, - }, { sort: ['sort'] }); + return ChecklistItems.find( + { + checklistId: this._id, + }, + { sort: ['sort'] } + ); }, finishedCount() { return ChecklistItems.find({ @@ -78,20 +96,20 @@ Checklists.helpers({ isFinished() { return 0 !== this.itemCount() && this.itemCount() === this.finishedCount(); }, - checkAllItems(){ - const checkItems = ChecklistItems.find({checklistId: this._id}); - checkItems.forEach(function(item){ + checkAllItems() { + const checkItems = ChecklistItems.find({ checklistId: this._id }); + checkItems.forEach(function(item) { item.check(); }); }, - uncheckAllItems(){ - const checkItems = ChecklistItems.find({checklistId: this._id}); - checkItems.forEach(function(item){ + uncheckAllItems() { + const checkItems = ChecklistItems.find({ checklistId: this._id }); + checkItems.forEach(function(item) { item.uncheck(); }); }, itemIndex(itemId) { - const items = self.findOne({_id : this._id}).items; + const items = self.findOne({ _id: this._id }).items; return _.pluck(items, '_id').indexOf(itemId); }, }); @@ -124,6 +142,7 @@ Checklists.mutations({ if (Meteor.isServer) { Meteor.startup(() => { + Checklists._collection._ensureIndex({ modifiedAt: -1 }); Checklists._collection._ensureIndex({ cardId: 1, createdAt: 1 }); }); @@ -135,12 +154,17 @@ if (Meteor.isServer) { cardId: doc.cardId, boardId: card.boardId, checklistId: doc._id, - checklistName:doc.title, + checklistName: doc.title, listId: card.listId, swimlaneId: card.swimlaneId, }); }); + Checklists.before.update((userId, doc, fieldNames, modifier, options) => { + modifier.$set = modifier.$set || {}; + modifier.$set.modifiedAt = Date.now(); + }); + Checklists.before.remove((userId, doc) => { const activities = Activities.find({ checklistId: doc._id }); const card = Cards.findOne(doc.cardId); @@ -155,7 +179,7 @@ if (Meteor.isServer) { cardId: doc.cardId, boardId: Cards.findOne(doc.cardId).boardId, checklistId: doc._id, - checklistName:doc.title, + checklistName: doc.title, listId: card.listId, swimlaneId: card.swimlaneId, }); @@ -172,26 +196,32 @@ if (Meteor.isServer) { * @return_type [{_id: string, * title: string}] */ - JsonRoutes.add('GET', '/api/boards/:boardId/cards/:cardId/checklists', function (req, res) { - Authentication.checkUserId( req.userId); - const paramCardId = req.params.cardId; - const checklists = Checklists.find({ cardId: paramCardId }).map(function (doc) { - return { - _id: doc._id, - title: doc.title, - }; - }); - if (checklists) { - JsonRoutes.sendResult(res, { - code: 200, - data: checklists, - }); - } else { - JsonRoutes.sendResult(res, { - code: 500, + JsonRoutes.add( + 'GET', + '/api/boards/:boardId/cards/:cardId/checklists', + function(req, res) { + Authentication.checkUserId(req.userId); + const paramCardId = req.params.cardId; + const checklists = Checklists.find({ cardId: paramCardId }).map(function( + doc + ) { + return { + _id: doc._id, + title: doc.title, + }; }); + if (checklists) { + JsonRoutes.sendResult(res, { + code: 200, + data: checklists, + }); + } else { + JsonRoutes.sendResult(res, { + code: 500, + }); + } } - }); + ); /** * @operation get_checklist @@ -209,29 +239,38 @@ if (Meteor.isServer) { * title: string, * isFinished: boolean}]} */ - JsonRoutes.add('GET', '/api/boards/:boardId/cards/:cardId/checklists/:checklistId', function (req, res) { - Authentication.checkUserId( req.userId); - const paramChecklistId = req.params.checklistId; - const paramCardId = req.params.cardId; - const checklist = Checklists.findOne({ _id: paramChecklistId, cardId: paramCardId }); - if (checklist) { - checklist.items = ChecklistItems.find({checklistId: checklist._id}).map(function (doc) { - return { - _id: doc._id, - title: doc.title, - isFinished: doc.isFinished, - }; - }); - JsonRoutes.sendResult(res, { - code: 200, - data: checklist, - }); - } else { - JsonRoutes.sendResult(res, { - code: 500, + JsonRoutes.add( + 'GET', + '/api/boards/:boardId/cards/:cardId/checklists/:checklistId', + function(req, res) { + Authentication.checkUserId(req.userId); + const paramChecklistId = req.params.checklistId; + const paramCardId = req.params.cardId; + const checklist = Checklists.findOne({ + _id: paramChecklistId, + cardId: paramCardId, }); + if (checklist) { + checklist.items = ChecklistItems.find({ + checklistId: checklist._id, + }).map(function(doc) { + return { + _id: doc._id, + title: doc.title, + isFinished: doc.isFinished, + }; + }); + JsonRoutes.sendResult(res, { + code: 200, + data: checklist, + }); + } else { + JsonRoutes.sendResult(res, { + code: 500, + }); + } } - }); + ); /** * @operation new_checklist @@ -242,36 +281,40 @@ if (Meteor.isServer) { * @param {string} title the title of the new checklist * @return_type {_id: string} */ - JsonRoutes.add('POST', '/api/boards/:boardId/cards/:cardId/checklists', function (req, res) { - Authentication.checkUserId( req.userId); + JsonRoutes.add( + 'POST', + '/api/boards/:boardId/cards/:cardId/checklists', + function(req, res) { + Authentication.checkUserId(req.userId); - const paramCardId = req.params.cardId; - const id = Checklists.insert({ - title: req.body.title, - cardId: paramCardId, - sort: 0, - }); - if (id) { - req.body.items.forEach(function (item, idx) { - ChecklistItems.insert({ - cardId: paramCardId, - checklistId: id, - title: item.title, - sort: idx, - }); - }); - JsonRoutes.sendResult(res, { - code: 200, - data: { - _id: id, - }, - }); - } else { - JsonRoutes.sendResult(res, { - code: 400, + const paramCardId = req.params.cardId; + const id = Checklists.insert({ + title: req.body.title, + cardId: paramCardId, + sort: 0, }); + if (id) { + req.body.items.forEach(function(item, idx) { + ChecklistItems.insert({ + cardId: paramCardId, + checklistId: id, + title: item.title, + sort: idx, + }); + }); + JsonRoutes.sendResult(res, { + code: 200, + data: { + _id: id, + }, + }); + } else { + JsonRoutes.sendResult(res, { + code: 400, + }); + } } - }); + ); /** * @operation delete_checklist @@ -284,15 +327,21 @@ if (Meteor.isServer) { * @param {string} checklistId the ID of the checklist to remove * @return_type {_id: string} */ - JsonRoutes.add('DELETE', '/api/boards/:boardId/cards/:cardId/checklists/:checklistId', function (req, res) { - Authentication.checkUserId( req.userId); - const paramChecklistId = req.params.checklistId; - Checklists.remove({ _id: paramChecklistId }); - JsonRoutes.sendResult(res, { - code: 200, - data: { - _id: paramChecklistId, - }, - }); - }); + JsonRoutes.add( + 'DELETE', + '/api/boards/:boardId/cards/:cardId/checklists/:checklistId', + function(req, res) { + Authentication.checkUserId(req.userId); + const paramChecklistId = req.params.checklistId; + Checklists.remove({ _id: paramChecklistId }); + JsonRoutes.sendResult(res, { + code: 200, + data: { + _id: paramChecklistId, + }, + }); + } + ); } + +export default Checklists; diff --git a/models/customFields.js b/models/customFields.js index 83033cb4..8b51c0a3 100644 --- a/models/customFields.js +++ b/models/customFields.js @@ -3,74 +3,100 @@ CustomFields = new Mongo.Collection('customFields'); /** * A custom field on a card in the board */ -CustomFields.attachSchema(new SimpleSchema({ - boardIds: { - /** - * the ID of the board - */ - type: [String], - }, - name: { - /** - * name of the custom field - */ - type: String, - }, - type: { - /** - * type of the custom field - */ - type: String, - allowedValues: ['text', 'number', 'date', 'dropdown'], - }, - settings: { - /** - * settings of the custom field - */ - type: Object, - }, - 'settings.dropdownItems': { - /** - * list of drop down items objects - */ - type: [Object], - optional: true, - }, - 'settings.dropdownItems.$': { - type: new SimpleSchema({ - _id: { - /** - * ID of the drop down item - */ - type: String, +CustomFields.attachSchema( + new SimpleSchema({ + boardIds: { + /** + * the ID of the board + */ + type: [String], + }, + name: { + /** + * name of the custom field + */ + type: String, + }, + type: { + /** + * type of the custom field + */ + type: String, + allowedValues: ['text', 'number', 'date', 'dropdown'], + }, + settings: { + /** + * settings of the custom field + */ + type: Object, + }, + 'settings.dropdownItems': { + /** + * list of drop down items objects + */ + type: [Object], + optional: true, + }, + 'settings.dropdownItems.$': { + type: new SimpleSchema({ + _id: { + /** + * ID of the drop down item + */ + type: String, + }, + name: { + /** + * name of the drop down item + */ + type: String, + }, + }), + }, + showOnCard: { + /** + * should we show on the cards this custom field + */ + type: Boolean, + }, + automaticallyOnCard: { + /** + * should the custom fields automatically be added on cards? + */ + type: Boolean, + }, + showLabelOnMiniCard: { + /** + * should the label of the custom field be shown on minicards? + */ + type: Boolean, + }, + createdAt: { + type: Date, + optional: true, + // eslint-disable-next-line consistent-return + autoValue() { + if (this.isInsert) { + return new Date(); + } else { + this.unset(); + } }, - name: { - /** - * name of the drop down item - */ - type: String, + }, + modifiedAt: { + type: Date, + denyUpdate: false, + // eslint-disable-next-line consistent-return + autoValue() { + if (this.isInsert || this.isUpsert || this.isUpdate) { + return new Date(); + } else { + this.unset(); + } }, - }), - }, - showOnCard: { - /** - * should we show on the cards this custom field - */ - type: Boolean, - }, - automaticallyOnCard: { - /** - * should the custom fields automatically be added on cards? - */ - type: Boolean, - }, - showLabelOnMiniCard: { - /** - * should the label of the custom field be shown on minicards? - */ - type: Boolean, - }, -})); + }, + }) +); CustomFields.mutations({ addBoard(boardId) { @@ -88,19 +114,28 @@ CustomFields.mutations({ CustomFields.allow({ insert(userId, doc) { - return allowIsAnyBoardMember(userId, Boards.find({ - _id: {$in: doc.boardIds}, - }).fetch()); + return allowIsAnyBoardMember( + userId, + Boards.find({ + _id: { $in: doc.boardIds }, + }).fetch() + ); }, update(userId, doc) { - return allowIsAnyBoardMember(userId, Boards.find({ - _id: {$in: doc.boardIds}, - }).fetch()); + return allowIsAnyBoardMember( + userId, + Boards.find({ + _id: { $in: doc.boardIds }, + }).fetch() + ); }, remove(userId, doc) { - return allowIsAnyBoardMember(userId, Boards.find({ - _id: {$in: doc.boardIds}, - }).fetch()); + return allowIsAnyBoardMember( + userId, + Boards.find({ + _id: { $in: doc.boardIds }, + }).fetch() + ); }, fetch: ['userId', 'boardIds'], }); @@ -108,7 +143,7 @@ CustomFields.allow({ // not sure if we need this? //CustomFields.hookOptions.after.update = { fetchPrevious: false }; -function customFieldCreation(userId, doc){ +function customFieldCreation(userId, doc) { Activities.insert({ userId, activityType: 'createCustomField', @@ -142,6 +177,7 @@ function customFieldEdit(userId, doc){ if (Meteor.isServer) { Meteor.startup(() => { + CustomFields._collection._ensureIndex({ modifiedAt: -1 }); CustomFields._collection._ensureIndex({ boardIds: 1 }); }); @@ -149,12 +185,17 @@ if (Meteor.isServer) { customFieldCreation(userId, doc); }); + CustomFields.before.update((userId, doc, fieldNames, modifier, options) => { + modifier.$set = modifier.$set || {}; + modifier.$set.modifiedAt = Date.now(); + }); + CustomFields.before.update((userId, doc, fieldNames, modifier) => { if (_.contains(fieldNames, 'boardIds') && modifier.$pull) { Cards.update( - {boardId: modifier.$pull.boardIds, 'customFields._id': doc._id}, - {$pull: {'customFields': {'_id': doc._id}}}, - {multi: true} + { boardId: modifier.$pull.boardIds, 'customFields._id': doc._id }, + { $pull: { customFields: { _id: doc._id } } }, + { multi: true } ); customFieldEdit(userId, doc); Activities.remove({ @@ -180,9 +221,9 @@ if (Meteor.isServer) { }); Cards.update( - {boardId: {$in: doc.boardIds}, 'customFields._id': doc._id}, - {$pull: {'customFields': {'_id': doc._id}}}, - {multi: true} + { boardId: { $in: doc.boardIds }, 'customFields._id': doc._id }, + { $pull: { customFields: { _id: doc._id } } }, + { multi: true } ); }); } @@ -198,18 +239,23 @@ if (Meteor.isServer) { * name: string, * type: string}] */ - JsonRoutes.add('GET', '/api/boards/:boardId/custom-fields', function (req, res) { - Authentication.checkUserId( req.userId); + JsonRoutes.add('GET', '/api/boards/:boardId/custom-fields', function( + req, + res + ) { + Authentication.checkUserId(req.userId); const paramBoardId = req.params.boardId; JsonRoutes.sendResult(res, { code: 200, - data: CustomFields.find({ boardIds: {$in: [paramBoardId]} }).map(function (cf) { - return { - _id: cf._id, - name: cf.name, - type: cf.type, - }; - }), + data: CustomFields.find({ boardIds: { $in: [paramBoardId] } }).map( + function(cf) { + return { + _id: cf._id, + name: cf.name, + type: cf.type, + }; + } + ), }); }); @@ -221,15 +267,22 @@ if (Meteor.isServer) { * @param {string} customFieldId the ID of the custom field * @return_type CustomFields */ - JsonRoutes.add('GET', '/api/boards/:boardId/custom-fields/:customFieldId', function (req, res) { - Authentication.checkUserId( req.userId); - const paramBoardId = req.params.boardId; - const paramCustomFieldId = req.params.customFieldId; - JsonRoutes.sendResult(res, { - code: 200, - data: CustomFields.findOne({ _id: paramCustomFieldId, boardIds: {$in: [paramBoardId]} }), - }); - }); + JsonRoutes.add( + 'GET', + '/api/boards/:boardId/custom-fields/:customFieldId', + function(req, res) { + Authentication.checkUserId(req.userId); + const paramBoardId = req.params.boardId; + const paramCustomFieldId = req.params.customFieldId; + JsonRoutes.sendResult(res, { + code: 200, + data: CustomFields.findOne({ + _id: paramCustomFieldId, + boardIds: { $in: [paramBoardId] }, + }), + }); + } + ); /** * @operation new_custom_field @@ -244,8 +297,11 @@ if (Meteor.isServer) { * @param {boolean} showLabelOnMiniCard should the label of the custom field be shown on minicards? * @return_type {_id: string} */ - JsonRoutes.add('POST', '/api/boards/:boardId/custom-fields', function (req, res) { - Authentication.checkUserId( req.userId); + JsonRoutes.add('POST', '/api/boards/:boardId/custom-fields', function( + req, + res + ) { + Authentication.checkUserId(req.userId); const paramBoardId = req.params.boardId; const id = CustomFields.direct.insert({ name: req.body.name, @@ -254,10 +310,13 @@ if (Meteor.isServer) { showOnCard: req.body.showOnCard, automaticallyOnCard: req.body.automaticallyOnCard, showLabelOnMiniCard: req.body.showLabelOnMiniCard, - boardIds: {$in: [paramBoardId]}, + boardIds: { $in: [paramBoardId] }, }); - const customField = CustomFields.findOne({_id: id, boardIds: {$in: [paramBoardId]} }); + const customField = CustomFields.findOne({ + _id: id, + boardIds: { $in: [paramBoardId] }, + }); customFieldCreation(req.body.authorId, customField); JsonRoutes.sendResult(res, { @@ -278,16 +337,22 @@ if (Meteor.isServer) { * @param {string} customFieldId the ID of the custom field * @return_type {_id: string} */ - JsonRoutes.add('DELETE', '/api/boards/:boardId/custom-fields/:customFieldId', function (req, res) { - Authentication.checkUserId( req.userId); - const paramBoardId = req.params.boardId; - const id = req.params.customFieldId; - CustomFields.remove({ _id: id, boardIds: {$in: [paramBoardId]} }); - JsonRoutes.sendResult(res, { - code: 200, - data: { - _id: id, - }, - }); - }); + JsonRoutes.add( + 'DELETE', + '/api/boards/:boardId/custom-fields/:customFieldId', + function(req, res) { + Authentication.checkUserId(req.userId); + const paramBoardId = req.params.boardId; + const id = req.params.customFieldId; + CustomFields.remove({ _id: id, boardIds: { $in: [paramBoardId] } }); + JsonRoutes.sendResult(res, { + code: 200, + data: { + _id: id, + }, + }); + } + ); } + +export default CustomFields; diff --git a/models/integrations.js b/models/integrations.js index 65a7af63..bb36d6e8 100644 --- a/models/integrations.js +++ b/models/integrations.js @@ -3,75 +3,96 @@ Integrations = new Mongo.Collection('integrations'); /** * Integration with third-party applications */ -Integrations.attachSchema(new SimpleSchema({ - enabled: { - /** - * is the integration enabled? - */ - type: Boolean, - defaultValue: true, - }, - title: { - /** - * name of the integration - */ - type: String, - optional: true, - }, - type: { - /** - * type of the integratation (Default to 'outgoing-webhooks') - */ - type: String, - defaultValue: 'outgoing-webhooks', - }, - activities: { - /** - * activities the integration gets triggered (list) - */ - type: [String], - defaultValue: ['all'], - }, - url: { // URL validation regex (https://mathiasbynens.be/demo/url-regex) - /** - * URL validation regex (https://mathiasbynens.be/demo/url-regex) - */ - type: String, - }, - token: { - /** - * token of the integration - */ - type: String, - optional: true, - }, - boardId: { - /** - * Board ID of the integration - */ - type: String, - }, - createdAt: { - /** - * Creation date of the integration - */ - type: Date, - denyUpdate: false, - autoValue() { // eslint-disable-line consistent-return - if (this.isInsert) { - return new Date(); - } else { - this.unset(); - } +Integrations.attachSchema( + new SimpleSchema({ + enabled: { + /** + * is the integration enabled? + */ + type: Boolean, + defaultValue: true, }, - }, - userId: { - /** - * user ID who created the interation - */ - type: String, - }, -})); + title: { + /** + * name of the integration + */ + type: String, + optional: true, + }, + type: { + /** + * type of the integratation (Default to 'outgoing-webhooks') + */ + type: String, + defaultValue: 'outgoing-webhooks', + }, + activities: { + /** + * activities the integration gets triggered (list) + */ + type: [String], + defaultValue: ['all'], + }, + url: { + // URL validation regex (https://mathiasbynens.be/demo/url-regex) + /** + * URL validation regex (https://mathiasbynens.be/demo/url-regex) + */ + type: String, + }, + token: { + /** + * token of the integration + */ + type: String, + optional: true, + }, + boardId: { + /** + * Board ID of the integration + */ + type: String, + }, + createdAt: { + /** + * Creation date of the integration + */ + type: Date, + denyUpdate: false, + // eslint-disable-next-line consistent-return + autoValue() { + if (this.isInsert) { + return new Date(); + } else { + this.unset(); + } + }, + }, + modifiedAt: { + type: Date, + denyUpdate: false, + // eslint-disable-next-line consistent-return + autoValue() { + if (this.isInsert || this.isUpsert || this.isUpdate) { + return new Date(); + } else { + this.unset(); + } + }, + }, + userId: { + /** + * user ID who created the interation + */ + type: String, + }, + }) +); + +Integrations.before.update((userId, doc, fieldNames, modifier, options) => { + modifier.$set = modifier.$set || {}; + modifier.$set.modifiedAt = Date.now(); +}); Integrations.allow({ insert(userId, doc) { @@ -89,6 +110,7 @@ Integrations.allow({ //INTEGRATIONS REST API if (Meteor.isServer) { Meteor.startup(() => { + Integrations._collection._ensureIndex({ modifiedAt: -1 }); Integrations._collection._ensureIndex({ boardId: 1 }); }); @@ -99,18 +121,23 @@ if (Meteor.isServer) { * @param {string} boardId the board ID * @return_type [Integrations] */ - JsonRoutes.add('GET', '/api/boards/:boardId/integrations', function(req, res) { + JsonRoutes.add('GET', '/api/boards/:boardId/integrations', function( + req, + res + ) { try { const paramBoardId = req.params.boardId; Authentication.checkBoardAccess(req.userId, paramBoardId); - const data = Integrations.find({ boardId: paramBoardId }, { fields: { token: 0 } }).map(function(doc) { + const data = Integrations.find( + { boardId: paramBoardId }, + { fields: { token: 0 } } + ).map(function(doc) { return doc; }); - JsonRoutes.sendResult(res, {code: 200, data}); - } - catch (error) { + JsonRoutes.sendResult(res, { code: 200, data }); + } catch (error) { JsonRoutes.sendResult(res, { code: 200, data: error, @@ -126,7 +153,10 @@ if (Meteor.isServer) { * @param {string} intId the integration ID * @return_type Integrations */ - JsonRoutes.add('GET', '/api/boards/:boardId/integrations/:intId', function(req, res) { + JsonRoutes.add('GET', '/api/boards/:boardId/integrations/:intId', function( + req, + res + ) { try { const paramBoardId = req.params.boardId; const paramIntId = req.params.intId; @@ -134,10 +164,12 @@ if (Meteor.isServer) { JsonRoutes.sendResult(res, { code: 200, - data: Integrations.findOne({ _id: paramIntId, boardId: paramBoardId }, { fields: { token: 0 } }), + data: Integrations.findOne( + { _id: paramIntId, boardId: paramBoardId }, + { fields: { token: 0 } } + ), }); - } - catch (error) { + } catch (error) { JsonRoutes.sendResult(res, { code: 200, data: error, @@ -153,7 +185,10 @@ if (Meteor.isServer) { * @param {string} url the URL of the integration * @return_type {_id: string} */ - JsonRoutes.add('POST', '/api/boards/:boardId/integrations', function(req, res) { + JsonRoutes.add('POST', '/api/boards/:boardId/integrations', function( + req, + res + ) { try { const paramBoardId = req.params.boardId; Authentication.checkBoardAccess(req.userId, paramBoardId); @@ -170,8 +205,7 @@ if (Meteor.isServer) { _id: id, }, }); - } - catch (error) { + } catch (error) { JsonRoutes.sendResult(res, { code: 200, data: error, @@ -192,7 +226,10 @@ if (Meteor.isServer) { * @param {string} [activities] new list of activities of the integration * @return_type {_id: string} */ - JsonRoutes.add('PUT', '/api/boards/:boardId/integrations/:intId', function (req, res) { + JsonRoutes.add('PUT', '/api/boards/:boardId/integrations/:intId', function( + req, + res + ) { try { const paramBoardId = req.params.boardId; const paramIntId = req.params.intId; @@ -200,28 +237,38 @@ if (Meteor.isServer) { if (req.body.hasOwnProperty('enabled')) { const newEnabled = req.body.enabled; - Integrations.direct.update({_id: paramIntId, boardId: paramBoardId}, - {$set: {enabled: newEnabled}}); + Integrations.direct.update( + { _id: paramIntId, boardId: paramBoardId }, + { $set: { enabled: newEnabled } } + ); } if (req.body.hasOwnProperty('title')) { const newTitle = req.body.title; - Integrations.direct.update({_id: paramIntId, boardId: paramBoardId}, - {$set: {title: newTitle}}); + Integrations.direct.update( + { _id: paramIntId, boardId: paramBoardId }, + { $set: { title: newTitle } } + ); } if (req.body.hasOwnProperty('url')) { const newUrl = req.body.url; - Integrations.direct.update({_id: paramIntId, boardId: paramBoardId}, - {$set: {url: newUrl}}); + Integrations.direct.update( + { _id: paramIntId, boardId: paramBoardId }, + { $set: { url: newUrl } } + ); } if (req.body.hasOwnProperty('token')) { const newToken = req.body.token; - Integrations.direct.update({_id: paramIntId, boardId: paramBoardId}, - {$set: {token: newToken}}); + Integrations.direct.update( + { _id: paramIntId, boardId: paramBoardId }, + { $set: { token: newToken } } + ); } if (req.body.hasOwnProperty('activities')) { const newActivities = req.body.activities; - Integrations.direct.update({_id: paramIntId, boardId: paramBoardId}, - {$set: {activities: newActivities}}); + Integrations.direct.update( + { _id: paramIntId, boardId: paramBoardId }, + { $set: { activities: newActivities } } + ); } JsonRoutes.sendResult(res, { @@ -230,8 +277,7 @@ if (Meteor.isServer) { _id: paramIntId, }, }); - } - catch (error) { + } catch (error) { JsonRoutes.sendResult(res, { code: 200, data: error, @@ -248,28 +294,36 @@ if (Meteor.isServer) { * @param {string} newActivities the activities to remove from the integration * @return_type Integrations */ - JsonRoutes.add('DELETE', '/api/boards/:boardId/integrations/:intId/activities', function (req, res) { - try { - const paramBoardId = req.params.boardId; - const paramIntId = req.params.intId; - const newActivities = req.body.activities; - Authentication.checkBoardAccess(req.userId, paramBoardId); + JsonRoutes.add( + 'DELETE', + '/api/boards/:boardId/integrations/:intId/activities', + function(req, res) { + try { + const paramBoardId = req.params.boardId; + const paramIntId = req.params.intId; + const newActivities = req.body.activities; + Authentication.checkBoardAccess(req.userId, paramBoardId); - Integrations.direct.update({_id: paramIntId, boardId: paramBoardId}, - {$pullAll: {activities: newActivities}}); + Integrations.direct.update( + { _id: paramIntId, boardId: paramBoardId }, + { $pullAll: { activities: newActivities } } + ); - JsonRoutes.sendResult(res, { - code: 200, - data: Integrations.findOne({_id: paramIntId, boardId: paramBoardId}, { fields: {_id: 1, activities: 1}}), - }); - } - catch (error) { - JsonRoutes.sendResult(res, { - code: 200, - data: error, - }); + JsonRoutes.sendResult(res, { + code: 200, + data: Integrations.findOne( + { _id: paramIntId, boardId: paramBoardId }, + { fields: { _id: 1, activities: 1 } } + ), + }); + } catch (error) { + JsonRoutes.sendResult(res, { + code: 200, + data: error, + }); + } } - }); + ); /** * @operation new_integration_activities @@ -280,28 +334,36 @@ if (Meteor.isServer) { * @param {string} newActivities the activities to add to the integration * @return_type Integrations */ - JsonRoutes.add('POST', '/api/boards/:boardId/integrations/:intId/activities', function (req, res) { - try { - const paramBoardId = req.params.boardId; - const paramIntId = req.params.intId; - const newActivities = req.body.activities; - Authentication.checkBoardAccess(req.userId, paramBoardId); + JsonRoutes.add( + 'POST', + '/api/boards/:boardId/integrations/:intId/activities', + function(req, res) { + try { + const paramBoardId = req.params.boardId; + const paramIntId = req.params.intId; + const newActivities = req.body.activities; + Authentication.checkBoardAccess(req.userId, paramBoardId); - Integrations.direct.update({_id: paramIntId, boardId: paramBoardId}, - {$addToSet: {activities: { $each: newActivities}}}); + Integrations.direct.update( + { _id: paramIntId, boardId: paramBoardId }, + { $addToSet: { activities: { $each: newActivities } } } + ); - JsonRoutes.sendResult(res, { - code: 200, - data: Integrations.findOne({_id: paramIntId, boardId: paramBoardId}, { fields: {_id: 1, activities: 1}}), - }); - } - catch (error) { - JsonRoutes.sendResult(res, { - code: 200, - data: error, - }); + JsonRoutes.sendResult(res, { + code: 200, + data: Integrations.findOne( + { _id: paramIntId, boardId: paramBoardId }, + { fields: { _id: 1, activities: 1 } } + ), + }); + } catch (error) { + JsonRoutes.sendResult(res, { + code: 200, + data: error, + }); + } } - }); + ); /** * @operation delete_integration @@ -311,21 +373,23 @@ if (Meteor.isServer) { * @param {string} intId the integration ID * @return_type {_id: string} */ - JsonRoutes.add('DELETE', '/api/boards/:boardId/integrations/:intId', function (req, res) { + JsonRoutes.add('DELETE', '/api/boards/:boardId/integrations/:intId', function( + req, + res + ) { try { const paramBoardId = req.params.boardId; const paramIntId = req.params.intId; Authentication.checkBoardAccess(req.userId, paramBoardId); - Integrations.direct.remove({_id: paramIntId, boardId: paramBoardId}); + Integrations.direct.remove({ _id: paramIntId, boardId: paramBoardId }); JsonRoutes.sendResult(res, { code: 200, data: { _id: paramIntId, }, }); - } - catch (error) { + } catch (error) { JsonRoutes.sendResult(res, { code: 200, data: error, @@ -333,3 +397,5 @@ if (Meteor.isServer) { } }); } + +export default Integrations; diff --git a/models/invitationCodes.js b/models/invitationCodes.js index 53163f06..5cdfc744 100644 --- a/models/invitationCodes.js +++ b/models/invitationCodes.js @@ -1,45 +1,78 @@ 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.attachSchema( + new SimpleSchema({ + code: { + type: String, + }, + email: { + type: String, + unique: true, + regEx: SimpleSchema.RegEx.Email, + }, + createdAt: { + type: Date, + denyUpdate: false, + optional: true, + // eslint-disable-next-line consistent-return + autoValue() { + if (this.isInsert) { + return new Date(); + } else { + this.unset(); + } + }, + }, + modifiedAt: { + type: Date, + denyUpdate: false, + // eslint-disable-next-line consistent-return + autoValue() { + if (this.isInsert || this.isUpsert || this.isUpdate) { + return new Date(); + } else { + this.unset(); + } + }, + }, + // always be the admin if only one admin + authorId: { + type: String, + }, + boardsToBeInvited: { + type: [String], + optional: true, + }, + valid: { + type: Boolean, + defaultValue: true, + }, + }) +); InvitationCodes.helpers({ - author(){ + author() { return Users.findOne(this.authorId); }, }); +InvitationCodes.before.update((userId, doc, fieldNames, modifier, options) => { + modifier.$set = modifier.$set || {}; + modifier.$set.modifiedAt = Date.now(); +}); + // InvitationCodes.before.insert((userId, doc) => { // doc.createdAt = new Date(); // doc.authorId = userId; // }); if (Meteor.isServer) { + Meteor.startup(() => { + InvitationCodes._collection._ensureIndex({ modifiedAt: -1 }); + }); Boards.deny({ fetch: ['members'], }); } + +export default InvitationCodes; diff --git a/models/lists.js b/models/lists.js index 1a0910c2..6d77d7aa 100644 --- a/models/lists.js +++ b/models/lists.js @@ -3,125 +3,161 @@ Lists = new Mongo.Collection('lists'); /** * A list (column) in the Wekan board. */ -Lists.attachSchema(new SimpleSchema({ - title: { - /** - * the title of the list - */ - type: String, - }, - archived: { - /** - * is the list archived - */ - type: Boolean, - autoValue() { // eslint-disable-line consistent-return - if (this.isInsert && !this.isSet) { - return false; - } +Lists.attachSchema( + new SimpleSchema({ + title: { + /** + * the title of the list + */ + type: String, }, - }, - boardId: { - /** - * the board associated to this list - */ - type: String, - }, - swimlaneId: { - /** - * the swimlane associated to this list. Used for templates - */ - type: String, - defaultValue: '', - }, - createdAt: { - /** - * creation date - */ - type: Date, - autoValue() { // eslint-disable-line consistent-return - if (this.isInsert) { - return new Date(); - } else { - this.unset(); - } + archived: { + /** + * is the list archived + */ + type: Boolean, + // eslint-disable-next-line consistent-return + autoValue() { + if (this.isInsert && !this.isSet) { + return false; + } + }, }, - }, - sort: { - /** - * is the list sorted - */ - type: Number, - decimal: true, - // XXX We should probably provide a default - optional: true, - }, - updatedAt: { - /** - * last update of the list - */ - type: Date, - optional: true, - autoValue() { // eslint-disable-line consistent-return - if (this.isUpdate) { - return new Date(); - } else { - this.unset(); - } + boardId: { + /** + * the board associated to this list + */ + type: String, }, - }, - wipLimit: { - /** - * WIP object, see below - */ - type: Object, - optional: true, - }, - 'wipLimit.value': { - /** - * value of the WIP - */ - type: Number, - decimal: false, - defaultValue: 1, - }, - 'wipLimit.enabled': { - /** - * is the WIP enabled - */ - type: Boolean, - defaultValue: false, - }, - 'wipLimit.soft': { - /** - * is the WIP a soft or hard requirement - */ - type: Boolean, - defaultValue: false, - }, - color: { - /** - * the color of the list - */ - type: String, - optional: true, - // silver is the default, so it is left out - allowedValues: [ - 'white', 'green', 'yellow', 'orange', 'red', 'purple', - 'blue', 'sky', 'lime', 'pink', 'black', - 'peachpuff', 'crimson', 'plum', 'darkgreen', - 'slateblue', 'magenta', 'gold', 'navy', 'gray', - 'saddlebrown', 'paleturquoise', 'mistyrose', 'indigo', - ], - }, - type: { - /** - * The type of list - */ - type: String, - defaultValue: 'list', - }, -})); + swimlaneId: { + /** + * the swimlane associated to this list. Used for templates + */ + type: String, + defaultValue: '', + }, + createdAt: { + /** + * creation date + */ + type: Date, + // eslint-disable-next-line consistent-return + autoValue() { + if (this.isInsert) { + return new Date(); + } else { + this.unset(); + } + }, + }, + sort: { + /** + * is the list sorted + */ + type: Number, + decimal: true, + // XXX We should probably provide a default + optional: true, + }, + updatedAt: { + /** + * last update of the list + */ + type: Date, + optional: true, + // eslint-disable-next-line consistent-return + autoValue() { + if (this.isUpdate || this.isUpsert || this.isInsert) { + return new Date(); + } else { + this.unset(); + } + }, + }, + modifiedAt: { + type: Date, + denyUpdate: false, + // eslint-disable-next-line consistent-return + autoValue() { + if (this.isInsert || this.isUpsert || this.isUpdate) { + return new Date(); + } else { + this.unset(); + } + }, + }, + wipLimit: { + /** + * WIP object, see below + */ + type: Object, + optional: true, + }, + 'wipLimit.value': { + /** + * value of the WIP + */ + type: Number, + decimal: false, + defaultValue: 1, + }, + 'wipLimit.enabled': { + /** + * is the WIP enabled + */ + type: Boolean, + defaultValue: false, + }, + 'wipLimit.soft': { + /** + * is the WIP a soft or hard requirement + */ + type: Boolean, + defaultValue: false, + }, + color: { + /** + * the color of the list + */ + type: String, + optional: true, + // silver is the default, so it is left out + allowedValues: [ + 'white', + 'green', + 'yellow', + 'orange', + 'red', + 'purple', + 'blue', + 'sky', + 'lime', + 'pink', + 'black', + 'peachpuff', + 'crimson', + 'plum', + 'darkgreen', + 'slateblue', + 'magenta', + 'gold', + 'navy', + 'gray', + 'saddlebrown', + 'paleturquoise', + 'mistyrose', + 'indigo', + ], + }, + type: { + /** + * The type of list + */ + type: String, + defaultValue: 'list', + }, + }) +); Lists.allow({ insert(userId, doc) { @@ -172,10 +208,8 @@ Lists.helpers({ listId: this._id, archived: false, }; - if (swimlaneId) - selector.swimlaneId = swimlaneId; - return Cards.find(Filter.mongoSelector(selector), - { sort: ['sort'] }); + if (swimlaneId) selector.swimlaneId = swimlaneId; + return Cards.find(Filter.mongoSelector(selector), { sort: ['sort'] }); }, cardsUnfiltered(swimlaneId) { @@ -183,10 +217,8 @@ Lists.helpers({ listId: this._id, archived: false, }; - if (swimlaneId) - selector.swimlaneId = swimlaneId; - return Cards.find(selector, - { sort: ['sort'] }); + if (swimlaneId) selector.swimlaneId = swimlaneId; + return Cards.find(selector, { sort: ['sort'] }); }, allCards() { @@ -197,11 +229,12 @@ Lists.helpers({ return Boards.findOne(this.boardId); }, - getWipLimit(option){ + getWipLimit(option) { const list = Lists.findOne({ _id: this._id }); - if(!list.wipLimit) { // Necessary check to avoid exceptions for the case where the doc doesn't have the wipLimit field yet set + if (!list.wipLimit) { + // Necessary check to avoid exceptions for the case where the doc doesn't have the wipLimit field yet set return 0; - } else if(!option) { + } else if (!option) { return list.wipLimit; } else { return list.wipLimit[option] ? list.wipLimit[option] : 0; // Necessary check to avoid exceptions for the case where the doc doesn't have the wipLimit field yet set @@ -209,8 +242,7 @@ Lists.helpers({ }, colorClass() { - if (this.color) - return this.color; + if (this.color) return this.color; return ''; }, @@ -219,7 +251,7 @@ Lists.helpers({ }, remove() { - Lists.remove({ _id: this._id}); + Lists.remove({ _id: this._id }); }, }); @@ -271,10 +303,10 @@ Lists.mutations({ }); Meteor.methods({ - applyWipLimit(listId, limit){ + applyWipLimit(listId, limit) { check(listId, String); check(limit, Number); - if(limit === 0){ + if (limit === 0) { limit = 1; } Lists.findOne({ _id: listId }).setWipLimit(limit); @@ -283,7 +315,7 @@ Meteor.methods({ enableWipLimit(listId) { check(listId, String); const list = Lists.findOne({ _id: listId }); - if(list.getWipLimit('value') === 0){ + if (list.getWipLimit('value') === 0) { list.setWipLimit(1); } list.toggleWipLimit(!list.getWipLimit('enabled')); @@ -300,6 +332,7 @@ Lists.hookOptions.after.update = { fetchPrevious: false }; if (Meteor.isServer) { Meteor.startup(() => { + Lists._collection._ensureIndex({ modifiedAt: -1 }); Lists._collection._ensureIndex({ boardId: 1 }); }); @@ -313,6 +346,11 @@ if (Meteor.isServer) { }); }); + Lists.before.update((userId, doc, fieldNames, modifier, options) => { + modifier.$set = modifier.$set || {}; + modifier.$set.modifiedAt = Date.now(); + }); + Lists.before.remove((userId, doc) => { const cards = Cards.find({ listId: doc._id }); if (cards) { @@ -353,22 +391,23 @@ if (Meteor.isServer) { * @return_type [{_id: string, * title: string}] */ - JsonRoutes.add('GET', '/api/boards/:boardId/lists', function (req, res) { + JsonRoutes.add('GET', '/api/boards/:boardId/lists', function(req, res) { try { const paramBoardId = req.params.boardId; - Authentication.checkBoardAccess( req.userId, paramBoardId); + 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, - }; - }), + data: Lists.find({ boardId: paramBoardId, archived: false }).map( + function(doc) { + return { + _id: doc._id, + title: doc.title, + }; + } + ), }); - } - catch (error) { + } catch (error) { JsonRoutes.sendResult(res, { code: 200, data: error, @@ -384,17 +423,23 @@ if (Meteor.isServer) { * @param {string} listId the List ID * @return_type Lists */ - JsonRoutes.add('GET', '/api/boards/:boardId/lists/:listId', function (req, res) { + JsonRoutes.add('GET', '/api/boards/:boardId/lists/:listId', function( + req, + res + ) { try { const paramBoardId = req.params.boardId; const paramListId = req.params.listId; - Authentication.checkBoardAccess( req.userId, paramBoardId); + Authentication.checkBoardAccess(req.userId, paramBoardId); JsonRoutes.sendResult(res, { code: 200, - data: Lists.findOne({ _id: paramListId, boardId: paramBoardId, archived: false }), + data: Lists.findOne({ + _id: paramListId, + boardId: paramBoardId, + archived: false, + }), }); - } - catch (error) { + } catch (error) { JsonRoutes.sendResult(res, { code: 200, data: error, @@ -410,9 +455,9 @@ if (Meteor.isServer) { * @param {string} title the title of the List * @return_type {_id: string} */ - JsonRoutes.add('POST', '/api/boards/:boardId/lists', function (req, res) { + JsonRoutes.add('POST', '/api/boards/:boardId/lists', function(req, res) { try { - Authentication.checkUserId( req.userId); + Authentication.checkUserId(req.userId); const paramBoardId = req.params.boardId; const board = Boards.findOne(paramBoardId); const id = Lists.insert({ @@ -426,8 +471,7 @@ if (Meteor.isServer) { _id: id, }, }); - } - catch (error) { + } catch (error) { JsonRoutes.sendResult(res, { code: 200, data: error, @@ -446,9 +490,12 @@ if (Meteor.isServer) { * @param {string} listId the ID of the list to remove * @return_type {_id: string} */ - JsonRoutes.add('DELETE', '/api/boards/:boardId/lists/:listId', function (req, res) { + JsonRoutes.add('DELETE', '/api/boards/:boardId/lists/:listId', function( + req, + res + ) { try { - Authentication.checkUserId( req.userId); + Authentication.checkUserId(req.userId); const paramBoardId = req.params.boardId; const paramListId = req.params.listId; Lists.remove({ _id: paramListId, boardId: paramBoardId }); @@ -458,13 +505,13 @@ if (Meteor.isServer) { _id: paramListId, }, }); - } - catch (error) { + } catch (error) { JsonRoutes.sendResult(res, { code: 200, data: error, }); } }); - } + +export default Lists; diff --git a/models/rules.js b/models/rules.js index 7d971980..76170b33 100644 --- a/models/rules.js +++ b/models/rules.js @@ -1,23 +1,51 @@ +import { Meteor } from 'meteor/meteor'; + Rules = new Mongo.Collection('rules'); -Rules.attachSchema(new SimpleSchema({ - title: { - type: String, - optional: false, - }, - triggerId: { - type: String, - optional: false, - }, - actionId: { - type: String, - optional: false, - }, - boardId: { - type: String, - optional: false, - }, -})); +Rules.attachSchema( + new SimpleSchema({ + title: { + type: String, + optional: false, + }, + triggerId: { + type: String, + optional: false, + }, + actionId: { + type: String, + optional: false, + }, + boardId: { + type: String, + optional: false, + }, + createdAt: { + type: Date, + optional: true, + // eslint-disable-next-line consistent-return + autoValue() { + if (this.isInsert) { + return new Date(); + } else { + this.unset(); + } + }, + }, + modifiedAt: { + type: Date, + denyUpdate: false, + // eslint-disable-next-line consistent-return + autoValue() { + if (this.isInsert || this.isUpsert || this.isUpdate) { + return new Date(); + } else { + this.unset(); + } + }, + }, + }) +); Rules.mutations({ rename(description) { @@ -26,15 +54,14 @@ Rules.mutations({ }); Rules.helpers({ - getAction(){ - return Actions.findOne({_id:this.actionId}); + getAction() { + return Actions.findOne({ _id: this.actionId }); }, - getTrigger(){ - return Triggers.findOne({_id:this.triggerId}); + getTrigger() { + return Triggers.findOne({ _id: this.triggerId }); }, }); - Rules.allow({ insert(userId, doc) { return allowIsBoardAdmin(userId, Boards.findOne(doc.boardId)); @@ -46,3 +73,16 @@ Rules.allow({ return allowIsBoardAdmin(userId, Boards.findOne(doc.boardId)); }, }); + +Rules.before.update((userId, doc, fieldNames, modifier, options) => { + modifier.$set = modifier.$set || {}; + modifier.$set.modifiedAt = Date.now(); +}); + +if (Meteor.isServer) { + Meteor.startup(() => { + Rules._collection._ensureIndex({ modifiedAt: -1 }); + }); +} + +export default Rules; diff --git a/models/settings.js b/models/settings.js index e0f94fca..b97961eb 100644 --- a/models/settings.js +++ b/models/settings.js @@ -1,67 +1,85 @@ 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, - }, - productName: { - type: String, - optional: true, - }, - customHTMLafterBodyStart: { - type: String, - optional: true, - }, - customHTMLbeforeBodyEnd: { - type: String, - optional: true, - }, - displayAuthenticationMethod: { - type: Boolean, - optional: true, - }, - defaultAuthenticationMethod: { - type: String, - optional: false, - }, - hideLogo: { - type: Boolean, - optional: true, - }, - createdAt: { - type: Date, - denyUpdate: true, - }, - modifiedAt: { - type: Date, - }, -})); +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, + }, + productName: { + type: String, + optional: true, + }, + customHTMLafterBodyStart: { + type: String, + optional: true, + }, + customHTMLbeforeBodyEnd: { + type: String, + optional: true, + }, + displayAuthenticationMethod: { + type: Boolean, + optional: true, + }, + defaultAuthenticationMethod: { + type: String, + optional: false, + }, + hideLogo: { + type: Boolean, + optional: true, + }, + createdAt: { + type: Date, + denyUpdate: true, + // eslint-disable-next-line consistent-return + autoValue() { + if (this.isInsert) { + return new Date(); + } else { + this.unset(); + } + }, + }, + modifiedAt: { + type: Date, + // eslint-disable-next-line consistent-return + autoValue() { + if (this.isInsert || this.isUpsert || this.isUpdate) { + return new Date(); + } else { + this.unset(); + } + }, + }, + }) +); Settings.helpers({ - mailUrl () { + mailUrl() { if (!this.mailServer.host) { return null; } @@ -69,7 +87,9 @@ Settings.helpers({ if (!this.mailServer.username && !this.mailServer.password) { return `${protocol}${this.mailServer.host}:${this.mailServer.port}/`; } - return `${protocol}${this.mailServer.username}:${encodeURIComponent(this.mailServer.password)}@${this.mailServer.host}:${this.mailServer.port}/`; + return `${protocol}${this.mailServer.username}:${encodeURIComponent( + this.mailServer.password + )}@${this.mailServer.host}:${this.mailServer.port}/`; }, }); Settings.allow({ @@ -86,50 +106,75 @@ Settings.before.update((userId, doc, fieldNames, modifier) => { if (Meteor.isServer) { Meteor.startup(() => { + Settings._collection._ensureIndex({ modifiedAt: -1 }); const setting = Settings.findOne({}); - if(!setting){ + if (!setting) { const now = new Date(); - const domain = process.env.ROOT_URL.match(/\/\/(?:www\.)?(.*)?(?:\/)?/)[1]; + const domain = process.env.ROOT_URL.match( + /\/\/(?:www\.)?(.*)?(?:\/)?/ + )[1]; const from = `Boards Support <support@${domain}>`; - const defaultSetting = {disableRegistration: false, mailServer: { - username: '', password: '', host: '', port: '', enableTLS: false, from, - }, createdAt: now, modifiedAt: now, displayAuthenticationMethod: true, - defaultAuthenticationMethod: 'password'}; + const defaultSetting = { + disableRegistration: false, + mailServer: { + username: '', + password: '', + host: '', + port: '', + enableTLS: false, + from, + }, + createdAt: now, + modifiedAt: now, + displayAuthenticationMethod: true, + defaultAuthenticationMethod: 'password', + }; 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; + 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}/`; + process.env.MAIL_URL = `${protocol}${doc.mailServer.host}:${ + doc.mailServer.port + }/`; } else { - process.env.MAIL_URL = `${protocol}${doc.mailServer.username}:${encodeURIComponent(doc.mailServer.password)}@${doc.mailServer.host}:${doc.mailServer.port}/`; + process.env.MAIL_URL = `${protocol}${ + doc.mailServer.username + }:${encodeURIComponent(doc.mailServer.password)}@${ + doc.mailServer.host + }:${doc.mailServer.port}/`; } Accounts.emailTemplates.from = doc.mailServer.from; } }); - function getRandomNum (min, max) { + function getRandomNum(min, max) { const range = max - min; const rand = Math.random(); - return (min + Math.round(rand * range)); + return min + Math.round(rand * range); } - function getEnvVar(name){ + function getEnvVar(name) { const value = process.env[name]; - if (value){ + if (value) { return value; } - throw new Meteor.Error(['var-not-exist', `The environment variable ${name} does not exist`]); + throw new Meteor.Error([ + 'var-not-exist', + `The environment variable ${name} does not exist`, + ]); } - function sendInvitationEmail (_id){ + function sendInvitationEmail(_id) { const icode = InvitationCodes.findOne(_id); const author = Users.findOne(Meteor.userId()); try { @@ -172,30 +217,47 @@ if (Meteor.isServer) { check(boards, [String]); const user = Users.findOne(Meteor.userId()); - if(!user.isAdmin){ + if (!user.isAdmin) { throw new Meteor.Error('not-allowed'); } emails.forEach((email) => { if (email && SimpleSchema.RegEx.Email.test(email)) { // Checks if the email is already link to an account. - const userExist = Users.findOne({email}); - if (userExist){ - throw new Meteor.Error('user-exist', `The user with the email ${email} has already an account.`); + const userExist = Users.findOne({ email }); + if (userExist) { + throw new Meteor.Error( + 'user-exist', + `The user with the email ${email} has already an account.` + ); } // Checks if the email is already link to an invitation. - const invitation = InvitationCodes.findOne({email}); - if (invitation){ - InvitationCodes.update(invitation, {$set : {boardsToBeInvited: boards}}); + const invitation = InvitationCodes.findOne({ email }); + if (invitation) { + InvitationCodes.update(invitation, { + $set: { boardsToBeInvited: boards }, + }); sendInvitationEmail(invitation._id); - }else { + } else { 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); + 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 + ); + } } - }); + ); } } }); @@ -215,11 +277,15 @@ if (Meteor.isServer) { Email.send({ to: user.emails[0].address, from: Accounts.emailTemplates.from, - subject: TAPi18n.__('email-smtp-test-subject', {lng: lang}), - text: TAPi18n.__('email-smtp-test-text', {lng: lang}), + subject: TAPi18n.__('email-smtp-test-subject', { lng: lang }), + text: TAPi18n.__('email-smtp-test-text', { lng: lang }), }); - } catch ({message}) { - throw new Meteor.Error('email-fail', `${TAPi18n.__('email-fail-text', {lng: lang})}: ${ message }`, message); + } catch ({ message }) { + throw new Meteor.Error( + 'email-fail', + `${TAPi18n.__('email-fail-text', { lng: lang })}: ${message}`, + message + ); } return { message: 'email-sent', @@ -227,7 +293,7 @@ if (Meteor.isServer) { }; }, - getCustomUI(){ + getCustomUI() { const setting = Settings.findOne({}); if (!setting.productName) { return { @@ -240,7 +306,7 @@ if (Meteor.isServer) { } }, - getMatomoConf(){ + getMatomoConf() { return { address: getEnvVar('MATOMO_ADDRESS'), siteId: getEnvVar('MATOMO_SITE_ID'), @@ -275,3 +341,5 @@ if (Meteor.isServer) { }, }); } + +export default Settings; diff --git a/models/swimlanes.js b/models/swimlanes.js index 9a53d116..82f73f79 100644 --- a/models/swimlanes.js +++ b/models/swimlanes.js @@ -3,89 +3,125 @@ Swimlanes = new Mongo.Collection('swimlanes'); /** * A swimlane is an line in the kaban board. */ -Swimlanes.attachSchema(new SimpleSchema({ - title: { - /** - * the title of the swimlane - */ - type: String, - }, - archived: { - /** - * is the swimlane archived? - */ - type: Boolean, - autoValue() { // eslint-disable-line consistent-return - if (this.isInsert && !this.isSet) { - return false; - } +Swimlanes.attachSchema( + new SimpleSchema({ + title: { + /** + * the title of the swimlane + */ + type: String, }, - }, - boardId: { - /** - * the ID of the board the swimlane is attached to - */ - type: String, - }, - createdAt: { - /** - * creation date of the swimlane - */ - type: Date, - autoValue() { // eslint-disable-line consistent-return - if (this.isInsert) { - return new Date(); - } else { - this.unset(); - } + archived: { + /** + * is the swimlane archived? + */ + type: Boolean, + // eslint-disable-next-line consistent-return + autoValue() { + if (this.isInsert && !this.isSet) { + return false; + } + }, }, - }, - sort: { - /** - * the sort value of the swimlane - */ - type: Number, - decimal: true, - // XXX We should probably provide a default - optional: true, - }, - color: { - /** - * the color of the swimlane - */ - type: String, - optional: true, - // silver is the default, so it is left out - allowedValues: [ - 'white', 'green', 'yellow', 'orange', 'red', 'purple', - 'blue', 'sky', 'lime', 'pink', 'black', - 'peachpuff', 'crimson', 'plum', 'darkgreen', - 'slateblue', 'magenta', 'gold', 'navy', 'gray', - 'saddlebrown', 'paleturquoise', 'mistyrose', 'indigo', - ], - }, - updatedAt: { - /** - * when was the swimlane last edited - */ - type: Date, - optional: true, - autoValue() { // eslint-disable-line consistent-return - if (this.isUpdate) { - return new Date(); - } else { - this.unset(); - } + boardId: { + /** + * the ID of the board the swimlane is attached to + */ + type: String, }, - }, - type: { - /** - * The type of swimlane - */ - type: String, - defaultValue: 'swimlane', - }, -})); + createdAt: { + /** + * creation date of the swimlane + */ + type: Date, + // eslint-disable-next-line consistent-return + autoValue() { + if (this.isInsert) { + return new Date(); + } else { + this.unset(); + } + }, + }, + sort: { + /** + * the sort value of the swimlane + */ + type: Number, + decimal: true, + // XXX We should probably provide a default + optional: true, + }, + color: { + /** + * the color of the swimlane + */ + type: String, + optional: true, + // silver is the default, so it is left out + allowedValues: [ + 'white', + 'green', + 'yellow', + 'orange', + 'red', + 'purple', + 'blue', + 'sky', + 'lime', + 'pink', + 'black', + 'peachpuff', + 'crimson', + 'plum', + 'darkgreen', + 'slateblue', + 'magenta', + 'gold', + 'navy', + 'gray', + 'saddlebrown', + 'paleturquoise', + 'mistyrose', + 'indigo', + ], + }, + updatedAt: { + /** + * when was the swimlane last edited + */ + type: Date, + optional: true, + // eslint-disable-next-line consistent-return + autoValue() { + if (this.isUpdate || this.isUpsert || this.isInsert) { + return new Date(); + } else { + this.unset(); + } + }, + }, + modifiedAt: { + type: Date, + denyUpdate: false, + // eslint-disable-next-line consistent-return + autoValue() { + if (this.isInsert || this.isUpsert || this.isUpdate) { + return new Date(); + } else { + this.unset(); + } + }, + }, + type: { + /** + * The type of swimlane + */ + type: String, + defaultValue: 'swimlane', + }, + }) +); Swimlanes.allow({ insert(userId, doc) { @@ -109,7 +145,7 @@ Swimlanes.helpers({ const _id = Swimlanes.insert(this); const query = { - swimlaneId: {$in: [oldId, '']}, + swimlaneId: { $in: [oldId, ''] }, archived: false, }; if (oldBoardId) { @@ -126,18 +162,24 @@ Swimlanes.helpers({ }, cards() { - return Cards.find(Filter.mongoSelector({ - swimlaneId: this._id, - archived: false, - }), { sort: ['sort'] }); + return Cards.find( + Filter.mongoSelector({ + swimlaneId: this._id, + archived: false, + }), + { sort: ['sort'] } + ); }, lists() { - return Lists.find({ - boardId: this.boardId, - swimlaneId: {$in: [this._id, '']}, - archived: false, - }, { sort: ['sort'] }); + return Lists.find( + { + boardId: this.boardId, + swimlaneId: { $in: [this._id, ''] }, + archived: false, + }, + { sort: ['sort'] } + ); }, myLists() { @@ -153,8 +195,7 @@ Swimlanes.helpers({ }, colorClass() { - if (this.color) - return this.color; + if (this.color) return this.color; return ''; }, @@ -182,7 +223,7 @@ Swimlanes.helpers({ }, remove() { - Swimlanes.remove({ _id: this._id}); + Swimlanes.remove({ _id: this._id }); }, }); @@ -221,10 +262,16 @@ Swimlanes.mutations({ }, }); +Swimlanes.before.update((userId, doc, fieldNames, modifier, options) => { + modifier.$set = modifier.$set || {}; + modifier.$set.modifiedAt = Date.now(); +}); + Swimlanes.hookOptions.after.update = { fetchPrevious: false }; if (Meteor.isServer) { Meteor.startup(() => { + Swimlanes._collection._ensureIndex({ modifiedAt: -1 }); Swimlanes._collection._ensureIndex({ boardId: 1 }); }); @@ -239,18 +286,21 @@ if (Meteor.isServer) { }); Swimlanes.before.remove(function(userId, doc) { - const lists = Lists.find({ - boardId: doc.boardId, - swimlaneId: {$in: [doc._id, '']}, - archived: false, - }, { sort: ['sort'] }); + const lists = Lists.find( + { + boardId: doc.boardId, + swimlaneId: { $in: [doc._id, ''] }, + archived: false, + }, + { sort: ['sort'] } + ); if (lists.count() < 2) { lists.forEach((list) => { list.remove(); }); } else { - Cards.remove({swimlaneId: doc._id}); + Cards.remove({ swimlaneId: doc._id }); } Activities.insert({ @@ -287,22 +337,23 @@ if (Meteor.isServer) { * @return_type [{_id: string, * title: string}] */ - JsonRoutes.add('GET', '/api/boards/:boardId/swimlanes', function (req, res) { + JsonRoutes.add('GET', '/api/boards/:boardId/swimlanes', function(req, res) { try { const paramBoardId = req.params.boardId; - Authentication.checkBoardAccess( req.userId, paramBoardId); + Authentication.checkBoardAccess(req.userId, paramBoardId); JsonRoutes.sendResult(res, { code: 200, - data: Swimlanes.find({ boardId: paramBoardId, archived: false }).map(function (doc) { - return { - _id: doc._id, - title: doc.title, - }; - }), + data: Swimlanes.find({ boardId: paramBoardId, archived: false }).map( + function(doc) { + return { + _id: doc._id, + title: doc.title, + }; + } + ), }); - } - catch (error) { + } catch (error) { JsonRoutes.sendResult(res, { code: 200, data: error, @@ -319,17 +370,23 @@ if (Meteor.isServer) { * @param {string} swimlaneId the ID of the swimlane * @return_type Swimlanes */ - JsonRoutes.add('GET', '/api/boards/:boardId/swimlanes/:swimlaneId', function (req, res) { + JsonRoutes.add('GET', '/api/boards/:boardId/swimlanes/:swimlaneId', function( + req, + res + ) { try { const paramBoardId = req.params.boardId; const paramSwimlaneId = req.params.swimlaneId; - Authentication.checkBoardAccess( req.userId, paramBoardId); + Authentication.checkBoardAccess(req.userId, paramBoardId); JsonRoutes.sendResult(res, { code: 200, - data: Swimlanes.findOne({ _id: paramSwimlaneId, boardId: paramBoardId, archived: false }), + data: Swimlanes.findOne({ + _id: paramSwimlaneId, + boardId: paramBoardId, + archived: false, + }), }); - } - catch (error) { + } catch (error) { JsonRoutes.sendResult(res, { code: 200, data: error, @@ -346,9 +403,9 @@ if (Meteor.isServer) { * @param {string} title the new title of the swimlane * @return_type {_id: string} */ - JsonRoutes.add('POST', '/api/boards/:boardId/swimlanes', function (req, res) { + JsonRoutes.add('POST', '/api/boards/:boardId/swimlanes', function(req, res) { try { - Authentication.checkUserId( req.userId); + Authentication.checkUserId(req.userId); const paramBoardId = req.params.boardId; const board = Boards.findOne(paramBoardId); const id = Swimlanes.insert({ @@ -362,8 +419,7 @@ if (Meteor.isServer) { _id: id, }, }); - } - catch (error) { + } catch (error) { JsonRoutes.sendResult(res, { code: 200, data: error, @@ -382,25 +438,29 @@ if (Meteor.isServer) { * @param {string} swimlaneId the ID of the swimlane * @return_type {_id: string} */ - JsonRoutes.add('DELETE', '/api/boards/:boardId/swimlanes/:swimlaneId', function (req, res) { - try { - Authentication.checkUserId( req.userId); - const paramBoardId = req.params.boardId; - const paramSwimlaneId = req.params.swimlaneId; - Swimlanes.remove({ _id: paramSwimlaneId, boardId: paramBoardId }); - JsonRoutes.sendResult(res, { - code: 200, - data: { - _id: paramSwimlaneId, - }, - }); - } - catch (error) { - JsonRoutes.sendResult(res, { - code: 200, - data: error, - }); + JsonRoutes.add( + 'DELETE', + '/api/boards/:boardId/swimlanes/:swimlaneId', + function(req, res) { + try { + Authentication.checkUserId(req.userId); + const paramBoardId = req.params.boardId; + const paramSwimlaneId = req.params.swimlaneId; + Swimlanes.remove({ _id: paramSwimlaneId, boardId: paramBoardId }); + JsonRoutes.sendResult(res, { + code: 200, + data: { + _id: paramSwimlaneId, + }, + }); + } catch (error) { + JsonRoutes.sendResult(res, { + code: 200, + data: error, + }); + } } - }); - + ); } + +export default Swimlanes; diff --git a/models/triggers.js b/models/triggers.js index 15982b6e..8f2448c4 100644 --- a/models/triggers.js +++ b/models/triggers.js @@ -1,3 +1,5 @@ +import { Meteor } from 'meteor/meteor'; + Triggers = new Mongo.Collection('triggers'); Triggers.mutations({ @@ -23,7 +25,6 @@ Triggers.allow({ }); Triggers.helpers({ - description() { return this.desc; }, @@ -56,3 +57,16 @@ Triggers.helpers({ return cardLabels; }, }); + +Triggers.before.update((userId, doc, fieldNames, modifier, options) => { + modifier.$set = modifier.$set || {}; + modifier.$set.modifiedAt = Date.now(); +}); + +if (Meteor.isServer) { + Meteor.startup(() => { + Triggers._collection._ensureIndex({ modifiedAt: -1 }); + }); +} + +export default Triggers; diff --git a/models/unsavedEdits.js b/models/unsavedEdits.js index d4f3616a..122b2cd2 100644 --- a/models/unsavedEdits.js +++ b/models/unsavedEdits.js @@ -2,31 +2,66 @@ // `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, - autoValue() { // eslint-disable-line consistent-return - if (this.isInsert && !this.isSet) { - return this.userId; - } +UnsavedEditCollection.attachSchema( + new SimpleSchema({ + fieldName: { + type: String, }, - }, -})); + docId: { + type: String, + }, + value: { + type: String, + }, + userId: { + type: String, + // eslint-disable-next-line consistent-return + autoValue() { + if (this.isInsert && !this.isSet) { + return this.userId; + } + }, + }, + createdAt: { + type: Date, + optional: true, + // eslint-disable-next-line consistent-return + autoValue() { + if (this.isInsert) { + return new Date(); + } else { + this.unset(); + } + }, + }, + modifiedAt: { + type: Date, + denyUpdate: false, + // eslint-disable-next-line consistent-return + autoValue() { + if (this.isInsert || this.isUpsert || this.isUpdate) { + return new Date(); + } else { + this.unset(); + } + }, + }, + }) +); + +UnsavedEditCollection.before.update( + (userId, doc, fieldNames, modifier, options) => { + modifier.$set = modifier.$set || {}; + modifier.$set.modifiedAt = Date.now(); + } +); if (Meteor.isServer) { function isAuthor(userId, doc, fieldNames = []) { return userId === doc.userId && fieldNames.indexOf('userId') === -1; } Meteor.startup(() => { + UnsavedEditCollection._collection._ensureIndex({ modifiedAt: -1 }); UnsavedEditCollection._collection._ensureIndex({ userId: 1 }); }); UnsavedEditCollection.allow({ @@ -36,3 +71,5 @@ if (Meteor.isServer) { fetch: ['userId'], }); } + +export default UnsavedEditCollection; diff --git a/models/users.js b/models/users.js index 5f949c80..306193aa 100644 --- a/models/users.js +++ b/models/users.js @@ -1,237 +1,254 @@ // 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; +const isSandstorm = + Meteor.settings && Meteor.settings.public && Meteor.settings.public.sandstorm; Users = Meteor.users; /** * A User in wekan */ -Users.attachSchema(new SimpleSchema({ - username: { - /** - * the username of the user - */ - type: String, - optional: true, - autoValue() { // eslint-disable-line consistent-return - if (this.isInsert && !this.isSet) { - const name = this.field('profile.fullname'); - if (name.isSet) { - return name.value.toLowerCase().replace(/\s/g, ''); +Users.attachSchema( + new SimpleSchema({ + username: { + /** + * the username of the user + */ + type: String, + optional: true, + // eslint-disable-next-line consistent-return + autoValue() { + if (this.isInsert && !this.isSet) { + const name = this.field('profile.fullname'); + if (name.isSet) { + return name.value.toLowerCase().replace(/\s/g, ''); + } } - } + }, }, - }, - emails: { - /** - * the list of emails attached to a user - */ - type: [Object], - optional: true, - }, - 'emails.$.address': { - /** - * The email address - */ - type: String, - regEx: SimpleSchema.RegEx.Email, - }, - 'emails.$.verified': { - /** - * Has the email been verified - */ - type: Boolean, - }, - createdAt: { - /** - * creation date of the user - */ - type: Date, - autoValue() { // eslint-disable-line consistent-return - if (this.isInsert) { - return new Date(); - } else { - this.unset(); - } + emails: { + /** + * the list of emails attached to a user + */ + type: [Object], + optional: true, }, - }, - profile: { - /** - * profile settings - */ - type: Object, - optional: true, - autoValue() { // eslint-disable-line consistent-return - if (this.isInsert && !this.isSet) { - return { - boardView: 'board-view-lists', - }; - } + 'emails.$.address': { + /** + * The email address + */ + type: String, + regEx: SimpleSchema.RegEx.Email, }, - }, - 'profile.avatarUrl': { - /** - * URL of the avatar of the user - */ - type: String, - optional: true, - }, - 'profile.emailBuffer': { - /** - * list of email buffers of the user - */ - type: [String], - optional: true, - }, - 'profile.fullname': { - /** - * full name of the user - */ - type: String, - optional: true, - }, - 'profile.hiddenSystemMessages': { - /** - * does the user wants to hide system messages? - */ - type: Boolean, - optional: true, - }, - 'profile.initials': { - /** - * initials of the user - */ - type: String, - optional: true, - }, - 'profile.invitedBoards': { - /** - * board IDs the user has been invited to - */ - type: [String], - optional: true, - }, - 'profile.language': { - /** - * language of the user - */ - type: String, - optional: true, - }, - 'profile.notifications': { - /** - * enabled notifications for the user - */ - type: [String], - optional: true, - }, - 'profile.showCardsCountAt': { - /** - * showCardCountAt field of the user - */ - type: Number, - optional: true, - }, - 'profile.starredBoards': { - /** - * list of starred board IDs - */ - type: [String], - optional: true, - }, - 'profile.icode': { - /** - * icode - */ - type: String, - optional: true, - }, - 'profile.boardView': { - /** - * boardView field of the user - */ - type: String, - optional: true, - allowedValues: [ - 'board-view-lists', - 'board-view-swimlanes', - 'board-view-cal', - ], - }, - 'profile.templatesBoardId': { - /** - * Reference to the templates board - */ - type: String, - defaultValue: '', - }, - 'profile.cardTemplatesSwimlaneId': { - /** - * Reference to the card templates swimlane Id - */ - type: String, - defaultValue: '', - }, - 'profile.listTemplatesSwimlaneId': { - /** - * Reference to the list templates swimlane Id - */ - type: String, - defaultValue: '', - }, - 'profile.boardTemplatesSwimlaneId': { - /** - * Reference to the board templates swimlane Id - */ - type: String, - defaultValue: '', - }, - services: { - /** - * services field of the user - */ - type: Object, - optional: true, - blackbox: true, - }, - heartbeat: { - /** - * last time the user has been seen - */ - type: Date, - optional: true, - }, - isAdmin: { - /** - * is the user an admin of the board? - */ - type: Boolean, - optional: true, - }, - createdThroughApi: { - /** - * was the user created through the API? - */ - type: Boolean, - optional: true, - }, - loginDisabled: { - /** - * loginDisabled field of the user - */ - type: Boolean, - optional: true, - }, - 'authenticationMethod': { - /** - * authentication method of the user - */ - type: String, - optional: false, - defaultValue: 'password', - }, -})); + 'emails.$.verified': { + /** + * Has the email been verified + */ + type: Boolean, + }, + createdAt: { + /** + * creation date of the user + */ + type: Date, + // eslint-disable-next-line consistent-return + autoValue() { + if (this.isInsert) { + return new Date(); + } else { + this.unset(); + } + }, + }, + modifiedAt: { + type: Date, + denyUpdate: false, + // eslint-disable-next-line consistent-return + autoValue() { + if (this.isInsert || this.isUpsert || this.isUpdate) { + return new Date(); + } else { + this.unset(); + } + }, + }, + profile: { + /** + * profile settings + */ + type: Object, + optional: true, + // eslint-disable-next-line consistent-return + autoValue() { + if (this.isInsert && !this.isSet) { + return { + boardView: 'board-view-lists', + }; + } + }, + }, + 'profile.avatarUrl': { + /** + * URL of the avatar of the user + */ + type: String, + optional: true, + }, + 'profile.emailBuffer': { + /** + * list of email buffers of the user + */ + type: [String], + optional: true, + }, + 'profile.fullname': { + /** + * full name of the user + */ + type: String, + optional: true, + }, + 'profile.hiddenSystemMessages': { + /** + * does the user wants to hide system messages? + */ + type: Boolean, + optional: true, + }, + 'profile.initials': { + /** + * initials of the user + */ + type: String, + optional: true, + }, + 'profile.invitedBoards': { + /** + * board IDs the user has been invited to + */ + type: [String], + optional: true, + }, + 'profile.language': { + /** + * language of the user + */ + type: String, + optional: true, + }, + 'profile.notifications': { + /** + * enabled notifications for the user + */ + type: [String], + optional: true, + }, + 'profile.showCardsCountAt': { + /** + * showCardCountAt field of the user + */ + type: Number, + optional: true, + }, + 'profile.starredBoards': { + /** + * list of starred board IDs + */ + type: [String], + optional: true, + }, + 'profile.icode': { + /** + * icode + */ + type: String, + optional: true, + }, + 'profile.boardView': { + /** + * boardView field of the user + */ + type: String, + optional: true, + allowedValues: [ + 'board-view-lists', + 'board-view-swimlanes', + 'board-view-cal', + ], + }, + 'profile.templatesBoardId': { + /** + * Reference to the templates board + */ + type: String, + defaultValue: '', + }, + 'profile.cardTemplatesSwimlaneId': { + /** + * Reference to the card templates swimlane Id + */ + type: String, + defaultValue: '', + }, + 'profile.listTemplatesSwimlaneId': { + /** + * Reference to the list templates swimlane Id + */ + type: String, + defaultValue: '', + }, + 'profile.boardTemplatesSwimlaneId': { + /** + * Reference to the board templates swimlane Id + */ + type: String, + defaultValue: '', + }, + services: { + /** + * services field of the user + */ + type: Object, + optional: true, + blackbox: true, + }, + heartbeat: { + /** + * last time the user has been seen + */ + type: Date, + optional: true, + }, + isAdmin: { + /** + * is the user an admin of the board? + */ + type: Boolean, + optional: true, + }, + createdThroughApi: { + /** + * was the user created through the API? + */ + type: Boolean, + optional: true, + }, + loginDisabled: { + /** + * loginDisabled field of the user + */ + type: Boolean, + optional: true, + }, + authenticationMethod: { + /** + * authentication method of the user + */ + type: String, + optional: false, + defaultValue: 'password', + }, + }) +); Users.allow({ update(userId) { @@ -240,7 +257,10 @@ Users.allow({ }, remove(userId, doc) { const adminsNumber = Users.find({ isAdmin: true }).count(); - const { isAdmin } = Users.findOne({ _id: userId }, { fields: { 'isAdmin': 1 } }); + const { isAdmin } = Users.findOne( + { _id: userId }, + { fields: { isAdmin: 1 } } + ); // Prevents remove of the only one administrator if (adminsNumber === 1 && isAdmin && userId === doc._id) { @@ -270,7 +290,9 @@ if (Meteor.isClient) { isNotNoComments() { const board = Boards.findOne(Session.get('currentBoard')); - return board && board.hasMember(this._id) && !board.hasNoComments(this._id); + return ( + board && board.hasMember(this._id) && !board.hasNoComments(this._id) + ); }, isNoComments() { @@ -280,7 +302,9 @@ if (Meteor.isClient) { isNotCommentOnly() { const board = Boards.findOne(Session.get('currentBoard')); - return board && board.hasMember(this._id) && !board.hasCommentOnly(this._id); + return ( + board && board.hasMember(this._id) && !board.hasCommentOnly(this._id) + ); }, isCommentOnly() { @@ -301,32 +325,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); }, @@ -336,20 +360,20 @@ Users.helpers({ }, getEmailBuffer() { - const {emailBuffer = []} = this.profile || {}; + const { emailBuffer = [] } = this.profile || {}; return emailBuffer; }, getInitials() { const profile = this.profile || {}; - if (profile.initials) - return profile.initials; - + if (profile.initials) return profile.initials; else if (profile.fullname) { - return profile.fullname.split(/\s+/).reduce((memo, word) => { - return memo + word[0]; - }, '').toUpperCase(); - + return profile.fullname + .split(/\s+/) + .reduce((memo, word) => { + return memo + word[0]; + }, '') + .toUpperCase(); } else { return this.username[0].toUpperCase(); } @@ -379,7 +403,7 @@ Users.helpers({ }, remove() { - User.remove({ _id: this._id}); + User.remove({ _id: this._id }); }, }); @@ -426,10 +450,8 @@ Users.mutations({ }, toggleTag(tag) { - if (this.hasTag(tag)) - this.removeTag(tag); - else - this.addTag(tag); + if (this.hasTag(tag)) this.removeTag(tag); + else this.addTag(tag); }, toggleSystem(value = false) { @@ -473,16 +495,16 @@ Users.mutations({ }, setAvatarUrl(avatarUrl) { - return {$set: {'profile.avatarUrl': avatarUrl}}; + return { $set: { 'profile.avatarUrl': avatarUrl } }; }, setShowCardsCountAt(limit) { - return {$set: {'profile.showCardsCountAt': limit}}; + return { $set: { 'profile.showCardsCountAt': limit } }; }, setBoardView(view) { return { - $set : { + $set: { 'profile.boardView': view, }, }; @@ -492,11 +514,11 @@ Users.mutations({ Meteor.methods({ setUsername(username, userId) { check(username, String); - const nUsersWithUsername = Users.find({username}).count(); + const nUsersWithUsername = Users.find({ username }).count(); if (nUsersWithUsername > 0) { throw new Meteor.Error('username-already-taken'); } else { - Users.update(userId, {$set: {username}}); + Users.update(userId, { $set: { username } }); } }, toggleSystemMessages() { @@ -509,16 +531,21 @@ Meteor.methods({ }, setEmail(email, userId) { check(email, String); - const existingUser = Users.findOne({'emails.address': email}, {fields: {_id: 1}}); + const existingUser = Users.findOne( + { 'emails.address': email }, + { fields: { _id: 1 } } + ); if (existingUser) { throw new Meteor.Error('email-already-taken'); } else { Users.update(userId, { $set: { - emails: [{ - address: email, - verified: false, - }], + emails: [ + { + address: email, + verified: false, + }, + ], }, }); } @@ -533,7 +560,7 @@ Meteor.methods({ setPassword(newPassword, userId) { check(userId, String); check(newPassword, String); - if(Meteor.user().isAdmin){ + if (Meteor.user().isAdmin) { Accounts.setPassword(userId, newPassword); } }, @@ -548,12 +575,13 @@ if (Meteor.isServer) { const inviter = Meteor.user(); const board = Boards.findOne(boardId); - const allowInvite = inviter && + 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; + _.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(); @@ -561,19 +589,21 @@ if (Meteor.isServer) { const posAt = username.indexOf('@'); let user = null; if (posAt >= 0) { - user = Users.findOne({emails: {$elemMatch: {address: username}}}); + user = Users.findOne({ emails: { $elemMatch: { address: username } } }); } else { - user = Users.findOne(username) || Users.findOne({username}); + user = Users.findOne(username) || Users.findOne({ username }); } if (user) { - if (user._id === inviter._id) throw new Meteor.Error('error-user-notAllowSelf'); + if (user._id === inviter._id) + throw new Meteor.Error('error-user-notAllowSelf'); } else { if (posAt <= 0) throw new Meteor.Error('error-user-doesNotExist'); - if (Settings.findOne().disableRegistration) throw new Meteor.Error('error-user-notCreated'); + 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}); + const newUserId = Accounts.createUser({ username, email }); if (!newUserId) throw new Meteor.Error('error-user-notCreated'); // assume new user speak same language with inviter if (inviter.profile && inviter.profile.language) { @@ -607,7 +637,7 @@ if (Meteor.isServer) { } catch (e) { throw new Meteor.Error('email-fail', e.message); } - return {username: user.username, email: user.emails[0].address}; + return { username: user.username, email: user.emails[0].address }; }, }); Accounts.onCreateUser((options, user) => { @@ -621,14 +651,22 @@ if (Meteor.isServer) { const email = user.services.oidc.email.toLowerCase(); user.username = user.services.oidc.username; user.emails = [{ address: email, verified: true }]; - const initials = user.services.oidc.fullname.match(/\b[a-zA-Z]/g).join('').toUpperCase(); - user.profile = { initials, fullname: user.services.oidc.fullname, boardView: 'board-view-lists' }; + const initials = user.services.oidc.fullname + .match(/\b[a-zA-Z]/g) + .join('') + .toUpperCase(); + user.profile = { + initials, + fullname: user.services.oidc.fullname, + boardView: 'board-view-lists', + }; user.authenticationMethod = 'oauth2'; // see if any existing user has this email address or username, otherwise create new - const existingUser = Meteor.users.findOne({$or: [{'emails.address': email}, {'username':user.username}]}); - if (!existingUser) - return user; + const existingUser = Meteor.users.findOne({ + $or: [{ 'emails.address': email }, { username: user.username }], + }); + if (!existingUser) return user; // copy across new service info const service = _.keys(user.services)[0]; @@ -638,7 +676,7 @@ if (Meteor.isServer) { existingUser.profile = user.profile; existingUser.authenticationMethod = user.authenticationMethod; - Meteor.users.remove({_id: existingUser._id}); // remove existing record + Meteor.users.remove({ _id: existingUser._id }); // remove existing record return existingUser; } @@ -660,7 +698,10 @@ if (Meteor.isServer) { } if (!options || !options.profile) { - throw new Meteor.Error('error-invitation-code-blank', 'The invitation code is required'); + throw new Meteor.Error( + 'error-invitation-code-blank', + 'The invitation code is required' + ); } const invitationCode = InvitationCodes.findOne({ code: options.profile.invitationcode, @@ -668,26 +709,41 @@ if (Meteor.isServer) { valid: true, }); if (!invitationCode) { - throw new Meteor.Error('error-invitation-code-not-exist', 'The invitation code doesn\'t exist'); + throw new Meteor.Error( + 'error-invitation-code-not-exist', + 'The invitation code doesn\'t exist' + ); } else { - user.profile = {icode: options.profile.invitationcode}; + user.profile = { icode: options.profile.invitationcode }; user.profile.boardView = 'board-view-lists'; // Deletes the invitation code after the user was created successfully. - setTimeout(Meteor.bindEnvironment(() => { - InvitationCodes.remove({'_id': invitationCode._id}); - }), 200); + setTimeout( + Meteor.bindEnvironment(() => { + InvitationCodes.remove({ _id: invitationCode._id }); + }), + 200 + ); return user; } }); } +Users.before.update((userId, doc, fieldNames, modifier, options) => { + modifier.$set = modifier.$set || {}; + modifier.$set.modifiedAt = Date.now(); +}); + if (Meteor.isServer) { // Let mongoDB ensure username unicity Meteor.startup(() => { - Users._collection._ensureIndex({ - username: 1, - }, {unique: true}); + Users._collection._ensureIndex({ modifiedAt: -1 }); + Users._collection._ensureIndex( + { + username: 1, + }, + { unique: true } + ); }); // OLD WAY THIS CODE DID WORK: When user is last admin of board, @@ -712,11 +768,10 @@ 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')) - return; + if (!_.contains(fieldNames, 'profile')) return; // To calculate a diff of board starred ids, we get both the previous // and the newly board ids list @@ -732,7 +787,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 } }); }); } @@ -754,7 +809,7 @@ if (Meteor.isServer) { }; fakeUserId.withValue(doc._id, () => { - /* + /* // Insert the Welcome Board Boards.insert({ title: TAPi18n.__('welcome-board'), @@ -773,57 +828,76 @@ if (Meteor.isServer) { }); */ - Boards.insert({ - title: TAPi18n.__('templates'), - permission: 'private', - type: 'template-container', - }, fakeUser, (err, boardId) => { - - // Insert the reference to our templates board - Users.update(fakeUserId.get(), {$set: {'profile.templatesBoardId': boardId}}); - - // Insert the card templates swimlane - Swimlanes.insert({ - title: TAPi18n.__('card-templates-swimlane'), - boardId, - sort: 1, - type: 'template-container', - }, fakeUser, (err, swimlaneId) => { - - // Insert the reference to out card templates swimlane - Users.update(fakeUserId.get(), {$set: {'profile.cardTemplatesSwimlaneId': swimlaneId}}); - }); - - // Insert the list templates swimlane - Swimlanes.insert({ - title: TAPi18n.__('list-templates-swimlane'), - boardId, - sort: 2, - type: 'template-container', - }, fakeUser, (err, swimlaneId) => { - - // Insert the reference to out list templates swimlane - Users.update(fakeUserId.get(), {$set: {'profile.listTemplatesSwimlaneId': swimlaneId}}); - }); - - // Insert the board templates swimlane - Swimlanes.insert({ - title: TAPi18n.__('board-templates-swimlane'), - boardId, - sort: 3, + Boards.insert( + { + title: TAPi18n.__('templates'), + permission: 'private', type: 'template-container', - }, fakeUser, (err, swimlaneId) => { - - // Insert the reference to out board templates swimlane - Users.update(fakeUserId.get(), {$set: {'profile.boardTemplatesSwimlaneId': swimlaneId}}); - }); - }); + }, + fakeUser, + (err, boardId) => { + // Insert the reference to our templates board + Users.update(fakeUserId.get(), { + $set: { 'profile.templatesBoardId': boardId }, + }); + + // Insert the card templates swimlane + Swimlanes.insert( + { + title: TAPi18n.__('card-templates-swimlane'), + boardId, + sort: 1, + type: 'template-container', + }, + fakeUser, + (err, swimlaneId) => { + // Insert the reference to out card templates swimlane + Users.update(fakeUserId.get(), { + $set: { 'profile.cardTemplatesSwimlaneId': swimlaneId }, + }); + } + ); + + // Insert the list templates swimlane + Swimlanes.insert( + { + title: TAPi18n.__('list-templates-swimlane'), + boardId, + sort: 2, + type: 'template-container', + }, + fakeUser, + (err, swimlaneId) => { + // Insert the reference to out list templates swimlane + Users.update(fakeUserId.get(), { + $set: { 'profile.listTemplatesSwimlaneId': swimlaneId }, + }); + } + ); + + // Insert the board templates swimlane + Swimlanes.insert( + { + title: TAPi18n.__('board-templates-swimlane'), + boardId, + sort: 3, + type: 'template-container', + }, + fakeUser, + (err, swimlaneId) => { + // Insert the reference to out board templates swimlane + Users.update(fakeUserId.get(), { + $set: { 'profile.boardTemplatesSwimlaneId': swimlaneId }, + }); + } + ); + } + ); }); }); } Users.after.insert((userId, doc) => { - if (doc.createdThroughApi) { // The admin user should be able to create a user despite disabling registration because // it is two different things (registration and creation). @@ -831,7 +905,7 @@ if (Meteor.isServer) { // the disableRegistration check. // Issue : https://github.com/wekan/wekan/issues/1232 // PR : https://github.com/wekan/wekan/pull/1251 - Users.update(doc._id, {$set: {createdThroughApi: ''}}); + Users.update(doc._id, { $set: { createdThroughApi: '' } }); return; } @@ -840,7 +914,10 @@ if (Meteor.isServer) { // If ldap, bypass the inviation code if the self registration isn't allowed. // TODO : pay attention if ldap field in the user model change to another content ex : ldap field to connection_type if (doc.authenticationMethod !== 'ldap' && disableRegistration) { - const invitationCode = InvitationCodes.findOne({code: doc.profile.icode, valid: true}); + const invitationCode = InvitationCodes.findOne({ + code: doc.profile.icode, + valid: true, + }); if (!invitationCode) { throw new Meteor.Error('error-invitation-code-not-exist'); } else { @@ -852,8 +929,8 @@ if (Meteor.isServer) { doc.profile = {}; } doc.profile.invitedBoards = invitationCode.boardsToBeInvited; - Users.update(doc._id, {$set: {profile: doc.profile}}); - InvitationCodes.update(invitationCode._id, {$set: {valid: false}}); + Users.update(doc._id, { $set: { profile: doc.profile } }); + InvitationCodes.update(invitationCode._id, { $set: { valid: false } }); } } }); @@ -862,13 +939,12 @@ if (Meteor.isServer) { // USERS REST API if (Meteor.isServer) { // Middleware which checks that API is enabled. - JsonRoutes.Middleware.use(function (req, res, next) { + JsonRoutes.Middleware.use(function(req, res, next) { const api = req.url.search('api'); - if (api === 1 && process.env.WITH_API === 'true' || api === -1){ + if ((api === 1 && process.env.WITH_API === 'true') || api === -1) { return next(); - } - else { - res.writeHead(301, {Location: '/'}); + } else { + res.writeHead(301, { Location: '/' }); return res.end(); } }); @@ -882,14 +958,13 @@ if (Meteor.isServer) { JsonRoutes.add('GET', '/api/user', function(req, res) { try { Authentication.checkLoggedIn(req.userId); - const data = Meteor.users.findOne({ _id: req.userId}); + const data = Meteor.users.findOne({ _id: req.userId }); delete data.services; JsonRoutes.sendResult(res, { code: 200, data, }); - } - catch (error) { + } catch (error) { JsonRoutes.sendResult(res, { code: 200, data: error, @@ -906,17 +981,16 @@ if (Meteor.isServer) { * @return_type [{ _id: string, * username: string}] */ - JsonRoutes.add('GET', '/api/users', function (req, res) { + JsonRoutes.add('GET', '/api/users', function(req, res) { try { Authentication.checkUserId(req.userId); JsonRoutes.sendResult(res, { code: 200, - data: Meteor.users.find({}).map(function (doc) { + data: Meteor.users.find({}).map(function(doc) { return { _id: doc._id, username: doc.username }; }), }); - } - catch (error) { + } catch (error) { JsonRoutes.sendResult(res, { code: 200, data: error, @@ -934,7 +1008,7 @@ if (Meteor.isServer) { * @param {string} userId the user ID * @return_type Users */ - JsonRoutes.add('GET', '/api/users/:userId', function (req, res) { + JsonRoutes.add('GET', '/api/users/:userId', function(req, res) { try { Authentication.checkUserId(req.userId); const id = req.params.userId; @@ -942,8 +1016,7 @@ if (Meteor.isServer) { code: 200, data: Meteor.users.findOne({ _id: id }), }); - } - catch (error) { + } catch (error) { JsonRoutes.sendResult(res, { code: 200, data: error, @@ -968,7 +1041,7 @@ if (Meteor.isServer) { * @return_type {_id: string, * title: string} */ - JsonRoutes.add('PUT', '/api/users/:userId', function (req, res) { + JsonRoutes.add('PUT', '/api/users/:userId', function(req, res) { try { Authentication.checkUserId(req.userId); const id = req.params.userId; @@ -990,8 +1063,16 @@ if (Meteor.isServer) { }; }); } else { - if ((action === 'disableLogin') && (id !== req.userId)) { - Users.update({ _id: id }, { $set: { loginDisabled: true, 'services.resume.loginTokens': '' } }); + if (action === 'disableLogin' && id !== req.userId) { + Users.update( + { _id: id }, + { + $set: { + loginDisabled: true, + 'services.resume.loginTokens': '', + }, + } + ); } else if (action === 'enableLogin') { Users.update({ _id: id }, { $set: { loginDisabled: '' } }); } @@ -1002,8 +1083,7 @@ if (Meteor.isServer) { code: 200, data, }); - } - catch (error) { + } catch (error) { JsonRoutes.sendResult(res, { code: 200, data: error, @@ -1030,13 +1110,16 @@ if (Meteor.isServer) { * @return_type {_id: string, * title: string} */ - JsonRoutes.add('POST', '/api/boards/:boardId/members/:userId/add', function (req, res) { + JsonRoutes.add('POST', '/api/boards/:boardId/members/:userId/add', function( + req, + res + ) { try { Authentication.checkUserId(req.userId); const userId = req.params.userId; const boardId = req.params.boardId; const action = req.body.action; - const {isAdmin, isNoComments, isCommentOnly} = req.body; + const { isAdmin, isNoComments, isCommentOnly } = req.body; let data = Meteor.users.findOne({ _id: userId }); if (data !== undefined) { if (action === 'add') { @@ -1045,10 +1128,16 @@ if (Meteor.isServer) { }).map(function(board) { if (!board.hasMember(userId)) { board.addMember(userId); - function isTrue(data){ + function isTrue(data) { return data.toLowerCase() === 'true'; } - board.setMemberPermission(userId, isTrue(isAdmin), isTrue(isNoComments), isTrue(isCommentOnly), userId); + board.setMemberPermission( + userId, + isTrue(isAdmin), + isTrue(isNoComments), + isTrue(isCommentOnly), + userId + ); } return { _id: board._id, @@ -1061,8 +1150,7 @@ if (Meteor.isServer) { code: 200, data: query, }); - } - catch (error) { + } catch (error) { JsonRoutes.sendResult(res, { code: 200, data: error, @@ -1084,40 +1172,43 @@ if (Meteor.isServer) { * @return_type {_id: string, * title: string} */ - JsonRoutes.add('POST', '/api/boards/:boardId/members/:userId/remove', function (req, res) { - try { - Authentication.checkUserId(req.userId); - const userId = req.params.userId; - const boardId = req.params.boardId; - const action = req.body.action; - let data = Meteor.users.findOne({ _id: userId }); - if (data !== undefined) { - if (action === 'remove') { - data = Boards.find({ - _id: boardId, - }).map(function(board) { - if (board.hasMember(userId)) { - board.removeMember(userId); - } - return { - _id: board._id, - title: board.title, - }; - }); + JsonRoutes.add( + 'POST', + '/api/boards/:boardId/members/:userId/remove', + function(req, res) { + try { + Authentication.checkUserId(req.userId); + const userId = req.params.userId; + const boardId = req.params.boardId; + const action = req.body.action; + let data = Meteor.users.findOne({ _id: userId }); + if (data !== undefined) { + if (action === 'remove') { + data = Boards.find({ + _id: boardId, + }).map(function(board) { + if (board.hasMember(userId)) { + board.removeMember(userId); + } + return { + _id: board._id, + title: board.title, + }; + }); + } } + JsonRoutes.sendResult(res, { + code: 200, + data: query, + }); + } catch (error) { + JsonRoutes.sendResult(res, { + code: 200, + data: error, + }); } - JsonRoutes.sendResult(res, { - code: 200, - data: query, - }); } - catch (error) { - JsonRoutes.sendResult(res, { - code: 200, - data: error, - }); - } - }); + ); /** * @operation new_user @@ -1131,7 +1222,7 @@ if (Meteor.isServer) { * @param {string} password the password of the new user * @return_type {_id: string} */ - JsonRoutes.add('POST', '/api/users/', function (req, res) { + JsonRoutes.add('POST', '/api/users/', function(req, res) { try { Authentication.checkUserId(req.userId); const id = Accounts.createUser({ @@ -1146,8 +1237,7 @@ if (Meteor.isServer) { _id: id, }, }); - } - catch (error) { + } catch (error) { JsonRoutes.sendResult(res, { code: 200, data: error, @@ -1165,7 +1255,7 @@ if (Meteor.isServer) { * @param {string} userId the ID of the user to delete * @return_type {_id: string} */ - JsonRoutes.add('DELETE', '/api/users/:userId', function (req, res) { + JsonRoutes.add('DELETE', '/api/users/:userId', function(req, res) { try { Authentication.checkUserId(req.userId); const id = req.params.userId; @@ -1176,8 +1266,7 @@ if (Meteor.isServer) { _id: id, }, }); - } - catch (error) { + } catch (error) { JsonRoutes.sendResult(res, { code: 200, data: error, @@ -1185,3 +1274,5 @@ if (Meteor.isServer) { } }); } + +export default Users; diff --git a/packages/wekan-iframe b/packages/wekan-iframe new file mode 160000 +Subproject e105dcc9c3424beee0ff0a9db9ca543a6d4b7f8 diff --git a/packages/wekan_accounts-oidc/.gitignore b/packages/wekan_accounts-oidc/.gitignore new file mode 100644 index 00000000..5379d4c3 --- /dev/null +++ b/packages/wekan_accounts-oidc/.gitignore @@ -0,0 +1 @@ +.versions diff --git a/packages/wekan_accounts-oidc/LICENSE.txt b/packages/wekan_accounts-oidc/LICENSE.txt new file mode 100644 index 00000000..c7be3264 --- /dev/null +++ b/packages/wekan_accounts-oidc/LICENSE.txt @@ -0,0 +1,14 @@ +Copyright (C) 2016 SWITCH + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + diff --git a/packages/wekan_accounts-oidc/README.md b/packages/wekan_accounts-oidc/README.md new file mode 100644 index 00000000..ce0b5738 --- /dev/null +++ b/packages/wekan_accounts-oidc/README.md @@ -0,0 +1,75 @@ +# salleman:accounts-oidc package + +A Meteor login service for OpenID Connect (OIDC). + +## Installation + + meteor add salleman:accounts-oidc + +## Usage + +`Meteor.loginWithOidc(options, callback)` +* `options` - object containing options, see below (optional) +* `callback` - callback function (optional) + +#### Example + +```js +Template.myTemplateName.events({ + 'click #login-button': function() { + Meteor.loginWithOidc(); + } +); +``` + + +## Options + +These options override service configuration stored in the database. + +* `loginStyle`: `redirect` or `popup` +* `redirectUrl`: Where to redirect after successful login. Only used if `loginStyle` is set to `redirect` + +## Manual Configuration Setup + +You can manually configure this package by upserting the service configuration on startup. First, add the `service-configuration` package: + + meteor add service-configuration + +### Service Configuration + +The following service configuration are available: + +* `clientId`: OIDC client identifier +* `secret`: OIDC client shared secret +* `serverUrl`: URL of the OIDC server. e.g. `https://openid.example.org:8443` +* `authorizationEndpoint`: Endpoint of the OIDC authorization service, e.g. `/oidc/authorize` +* `tokenEndpoint`: Endpoint of the OIDC token service, e.g. `/oidc/token` +* `userinfoEndpoint`: Endpoint of the OIDC userinfo service, e.g. `/oidc/userinfo` +* `idTokenWhitelistFields`: A list of fields from IDToken to be added to Meteor.user().services.oidc object + +### Project Configuration + +Then in your project: + +```js +if (Meteor.isServer) { + Meteor.startup(function () { + ServiceConfiguration.configurations.upsert( + { service: 'oidc' }, + { + $set: { + loginStyle: 'redirect', + clientId: 'my-client-id-registered-with-the-oidc-server', + secret: 'my-client-shared-secret', + serverUrl: 'https://openid.example.org', + authorizationEndpoint: '/oidc/authorize', + tokenEndpoint: '/oidc/token', + userinfoEndpoint: '/oidc/userinfo', + idTokenWhitelistFields: [] + } + } + ); + }); +} +``` diff --git a/packages/wekan_accounts-oidc/oidc.js b/packages/wekan_accounts-oidc/oidc.js new file mode 100644 index 00000000..75cd89ae --- /dev/null +++ b/packages/wekan_accounts-oidc/oidc.js @@ -0,0 +1,22 @@ +Accounts.oauth.registerService('oidc'); + +if (Meteor.isClient) { + Meteor.loginWithOidc = function(options, callback) { + // support a callback without options + if (! callback && typeof options === "function") { + callback = options; + options = null; + } + + var credentialRequestCompleteCallback = Accounts.oauth.credentialRequestCompleteHandler(callback); + Oidc.requestCredential(options, credentialRequestCompleteCallback); + }; +} else { + Accounts.addAutopublishFields({ + // not sure whether the OIDC api can be used from the browser, + // thus not sure if we should be sending access tokens; but we do it + // for all other oauth2 providers, and it may come in handy. + forLoggedInUser: ['services.oidc'], + forOtherUsers: ['services.oidc.id'] + }); +} diff --git a/packages/wekan_accounts-oidc/oidc_login_button.css b/packages/wekan_accounts-oidc/oidc_login_button.css new file mode 100644 index 00000000..da42120b --- /dev/null +++ b/packages/wekan_accounts-oidc/oidc_login_button.css @@ -0,0 +1,3 @@ +#login-buttons-image-oidc { + background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAACQUlEQVQ4T5WTy2taQRTGv4kSXw0IoYIihCjFmhhfhUqW9a+o0I2LInTRRbtw05V2I9KQuuimi24KXQqChIhQQcQgGGNz0YpvMCG1yL1tGqvBZsKMIIXcjQcOcznnfL+ZOecOEUVx4/Ly8mQ6neqxhKlUKmltbc1Nut2uqJ/bEnJAkiTmEhEEgVqtViiVyjuAP70j/Pj6Htbglzu52WyGdrsNUq1Wqc1mk939+9sHPP7wTVM232g0QMrlMrXb7bIFndgcbAk3ZPP1eh2kVCrRra2tRcFoNEK1WoXf78fg3Rxsfl3H3t4e3G43dnd3wWrMZjNqtRpIsVhcAFKpFPL5PBfF43H8TDj49/2XAvb393F2dgaNRgNKKaLR6ByQz+epw+HAwcEBisUijEYjgsEg1Go1pA9ODtC/+MZFDCKKIo9FIhEIggCSy+Xozs4OYrEY2ChDoRAIIVww/ujhxdrnFTSbTX6Cfr+Pi4sLhMNhnJ6egmSzWepyuZBIJGAwGBAIBLiY2ezTI74qg2UoFIqFr6ys4OrqiveKHB4eckAmk8FgMMD29jZ8Ph8XKj4/5uu/ZyXZKXBAOp2mHo+H/0isD6zDOp0Om5ubsAuvcA+/8ffpkSygUqmApFIp6vV6+b2ZsNfrodVqYTgcwqXtwul04pfhiSzg+PgYJJlMUovFwhvIbHV1lTs70c3NDSaTCa6vr+8A2FvodDr8CmwuepPJtIDIbvdfkInPz89ZRCKFQmFjNBqdjMfjpZ6jVquV1tfX3bcYegI7CyIWlgAAAABJRU5ErkJggg=='); +} diff --git a/packages/wekan_accounts-oidc/package.js b/packages/wekan_accounts-oidc/package.js new file mode 100644 index 00000000..251fb265 --- /dev/null +++ b/packages/wekan_accounts-oidc/package.js @@ -0,0 +1,19 @@ +Package.describe({ + summary: "OpenID Connect (OIDC) for Meteor accounts", + version: "1.0.10", + name: "wekan-accounts-oidc", + git: "https://github.com/wekan/meteor-accounts-oidc.git", + +}); + +Package.onUse(function(api) { + api.use('accounts-base@1.2.0', ['client', 'server']); + // Export Accounts (etc) to packages using this one. + api.imply('accounts-base', ['client', 'server']); + api.use('accounts-oauth@1.1.0', ['client', 'server']); + api.use('wekan-oidc@1.0.10', ['client', 'server']); + + api.addFiles('oidc_login_button.css', 'client'); + + api.addFiles('oidc.js'); +}); diff --git a/packages/wekan_oidc/.gitignore b/packages/wekan_oidc/.gitignore new file mode 100644 index 00000000..5379d4c3 --- /dev/null +++ b/packages/wekan_oidc/.gitignore @@ -0,0 +1 @@ +.versions diff --git a/packages/wekan_oidc/LICENSE.txt b/packages/wekan_oidc/LICENSE.txt new file mode 100644 index 00000000..c7be3264 --- /dev/null +++ b/packages/wekan_oidc/LICENSE.txt @@ -0,0 +1,14 @@ +Copyright (C) 2016 SWITCH + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + diff --git a/packages/wekan_oidc/README.md b/packages/wekan_oidc/README.md new file mode 100644 index 00000000..8948971c --- /dev/null +++ b/packages/wekan_oidc/README.md @@ -0,0 +1,7 @@ +# salleman:oidc package + +A Meteor implementation of OpenID Connect Login flow + +## Usage and Documentation + +Look at the `salleman:accounts-oidc` package for the documentation about using OpenID Connect with Meteor. diff --git a/packages/wekan_oidc/oidc_client.js b/packages/wekan_oidc/oidc_client.js new file mode 100644 index 00000000..744bd841 --- /dev/null +++ b/packages/wekan_oidc/oidc_client.js @@ -0,0 +1,68 @@ +Oidc = {}; + +// Request OpenID Connect credentials for the user +// @param options {optional} +// @param credentialRequestCompleteCallback {Function} Callback function to call on +// completion. Takes one argument, credentialToken on success, or Error on +// error. +Oidc.requestCredential = function (options, credentialRequestCompleteCallback) { + // support both (options, callback) and (callback). + if (!credentialRequestCompleteCallback && typeof options === 'function') { + credentialRequestCompleteCallback = options; + options = {}; + } + + var config = ServiceConfiguration.configurations.findOne({service: 'oidc'}); + if (!config) { + credentialRequestCompleteCallback && credentialRequestCompleteCallback( + new ServiceConfiguration.ConfigError('Service oidc not configured.')); + return; + } + + var credentialToken = Random.secret(); + var loginStyle = OAuth._loginStyle('oidc', config, options); + var scope = config.requestPermissions || ['openid', 'profile', 'email']; + + // options + options = options || {}; + options.client_id = config.clientId; + options.response_type = options.response_type || 'code'; + options.redirect_uri = OAuth._redirectUri('oidc', config); + options.state = OAuth._stateParam(loginStyle, credentialToken, options.redirectUrl); + options.scope = scope.join(' '); + + if (config.loginStyle && config.loginStyle == 'popup') { + options.display = 'popup'; + } + + var loginUrl = config.serverUrl + config.authorizationEndpoint; + // check if the loginUrl already contains a "?" + var first = loginUrl.indexOf('?') === -1; + for (var k in options) { + if (first) { + loginUrl += '?'; + first = false; + } + else { + loginUrl += '&' + } + loginUrl += encodeURIComponent(k) + '=' + encodeURIComponent(options[k]); + } + + //console.log('XXX: loginURL: ' + loginUrl) + + options.popupOptions = options.popupOptions || {}; + var popupOptions = { + width: options.popupOptions.width || 320, + height: options.popupOptions.height || 450 + }; + + OAuth.launchLogin({ + loginService: 'oidc', + loginStyle: loginStyle, + loginUrl: loginUrl, + credentialRequestCompleteCallback: credentialRequestCompleteCallback, + credentialToken: credentialToken, + popupOptions: popupOptions, + }); +}; diff --git a/packages/wekan_oidc/oidc_configure.html b/packages/wekan_oidc/oidc_configure.html new file mode 100644 index 00000000..49282fc1 --- /dev/null +++ b/packages/wekan_oidc/oidc_configure.html @@ -0,0 +1,6 @@ +<template name="configureLoginServiceDialogForOidc"> + <p> + You'll need to create an OpenID Connect client configuration with your provider. + Set App Callbacks URLs to: <span class="url">{{siteUrl}}_oauth/oidc</span> + </p> +</template> diff --git a/packages/wekan_oidc/oidc_configure.js b/packages/wekan_oidc/oidc_configure.js new file mode 100644 index 00000000..5eedaa04 --- /dev/null +++ b/packages/wekan_oidc/oidc_configure.js @@ -0,0 +1,17 @@ +Template.configureLoginServiceDialogForOidc.helpers({ + siteUrl: function () { + return Meteor.absoluteUrl(); + } +}); + +Template.configureLoginServiceDialogForOidc.fields = function () { + return [ + { property: 'clientId', label: 'Client ID'}, + { property: 'secret', label: 'Client Secret'}, + { property: 'serverUrl', label: 'OIDC Server URL'}, + { property: 'authorizationEndpoint', label: 'Authorization Endpoint'}, + { property: 'tokenEndpoint', label: 'Token Endpoint'}, + { property: 'userinfoEndpoint', label: 'Userinfo Endpoint'}, + { property: 'idTokenWhitelistFields', label: 'Id Token Fields'} + ]; +}; diff --git a/packages/wekan_oidc/oidc_server.js b/packages/wekan_oidc/oidc_server.js new file mode 100644 index 00000000..fb948c52 --- /dev/null +++ b/packages/wekan_oidc/oidc_server.js @@ -0,0 +1,143 @@ +Oidc = {}; + +OAuth.registerService('oidc', 2, null, function (query) { + + var debug = process.env.DEBUG || false; + var token = getToken(query); + if (debug) console.log('XXX: register token:', token); + + var accessToken = token.access_token || token.id_token; + var expiresAt = (+new Date) + (1000 * parseInt(token.expires_in, 10)); + + var userinfo = getUserInfo(accessToken); + if (debug) console.log('XXX: userinfo:', userinfo); + + var serviceData = {}; + serviceData.id = userinfo[process.env.OAUTH2_ID_MAP] || userinfo[id]; + serviceData.username = userinfo[process.env.OAUTH2_USERNAME_MAP] || userinfo[uid]; + serviceData.fullname = userinfo[process.env.OAUTH2_FULLNAME_MAP] || userinfo[displayName]; + serviceData.accessToken = accessToken; + serviceData.expiresAt = expiresAt; + serviceData.email = userinfo[process.env.OAUTH2_EMAIL_MAP] || userinfo[email]; + + if (accessToken) { + var tokenContent = getTokenContent(accessToken); + var fields = _.pick(tokenContent, getConfiguration().idTokenWhitelistFields); + _.extend(serviceData, fields); + } + + if (token.refresh_token) + serviceData.refreshToken = token.refresh_token; + if (debug) console.log('XXX: serviceData:', serviceData); + + var profile = {}; + profile.name = userinfo[process.env.OAUTH2_FULLNAME_MAP] || userinfo[displayName]; + profile.email = userinfo[process.env.OAUTH2_EMAIL_MAP] || userinfo[email]; + if (debug) console.log('XXX: profile:', profile); + + return { + serviceData: serviceData, + options: { profile: profile } + }; +}); + +var userAgent = "Meteor"; +if (Meteor.release) { + userAgent += "/" + Meteor.release; +} + +var getToken = function (query) { + var debug = process.env.DEBUG || false; + var config = getConfiguration(); + var serverTokenEndpoint = config.serverUrl + config.tokenEndpoint; + var response; + + try { + response = HTTP.post( + serverTokenEndpoint, + { + headers: { + Accept: 'application/json', + "User-Agent": userAgent + }, + params: { + code: query.code, + client_id: config.clientId, + client_secret: OAuth.openSecret(config.secret), + redirect_uri: OAuth._redirectUri('oidc', config), + grant_type: 'authorization_code', + state: query.state + } + } + ); + } catch (err) { + throw _.extend(new Error("Failed to get token from OIDC " + serverTokenEndpoint + ": " + err.message), + { response: err.response }); + } + if (response.data.error) { + // if the http response was a json object with an error attribute + throw new Error("Failed to complete handshake with OIDC " + serverTokenEndpoint + ": " + response.data.error); + } else { + if (debug) console.log('XXX: getToken response: ', response.data); + return response.data; + } +}; + +var getUserInfo = function (accessToken) { + var debug = process.env.DEBUG || false; + var config = getConfiguration(); + // Some userinfo endpoints use a different base URL than the authorization or token endpoints. + // This logic allows the end user to override the setting by providing the full URL to userinfo in their config. + if (config.userinfoEndpoint.includes("https://")) { + var serverUserinfoEndpoint = config.userinfoEndpoint; + } else { + var serverUserinfoEndpoint = config.serverUrl + config.userinfoEndpoint; + } + var response; + try { + response = HTTP.get( + serverUserinfoEndpoint, + { + headers: { + "User-Agent": userAgent, + "Authorization": "Bearer " + accessToken + } + } + ); + } catch (err) { + throw _.extend(new Error("Failed to fetch userinfo from OIDC " + serverUserinfoEndpoint + ": " + err.message), + {response: err.response}); + } + if (debug) console.log('XXX: getUserInfo response: ', response.data); + return response.data; +}; + +var getConfiguration = function () { + var config = ServiceConfiguration.configurations.findOne({ service: 'oidc' }); + if (!config) { + throw new ServiceConfiguration.ConfigError('Service oidc not configured.'); + } + return config; +}; + +var getTokenContent = function (token) { + var content = null; + if (token) { + try { + var parts = token.split('.'); + var header = JSON.parse(new Buffer(parts[0], 'base64').toString()); + content = JSON.parse(new Buffer(parts[1], 'base64').toString()); + var signature = new Buffer(parts[2], 'base64'); + var signed = parts[0] + '.' + parts[1]; + } catch (err) { + this.content = { + exp: 0 + }; + } + } + return content; +} + +Oidc.retrieveCredential = function (credentialToken, credentialSecret) { + return OAuth.retrieveCredential(credentialToken, credentialSecret); +}; diff --git a/packages/wekan_oidc/package.js b/packages/wekan_oidc/package.js new file mode 100644 index 00000000..faf4a68d --- /dev/null +++ b/packages/wekan_oidc/package.js @@ -0,0 +1,23 @@ +Package.describe({ + summary: "OpenID Connect (OIDC) flow for Meteor", + version: "1.0.12", + name: "wekan-oidc", + git: "https://github.com/wekan/meteor-accounts-oidc.git", +}); + +Package.onUse(function(api) { + api.use('oauth2@1.1.0', ['client', 'server']); + api.use('oauth@1.1.0', ['client', 'server']); + api.use('http@1.1.0', ['server']); + api.use('underscore@1.0.0', 'client'); + api.use('templating@1.1.0', 'client'); + api.use('random@1.0.0', 'client'); + api.use('service-configuration@1.0.0', ['client', 'server']); + + api.export('Oidc'); + + api.addFiles(['oidc_configure.html', 'oidc_configure.js'], 'client'); + + api.addFiles('oidc_server.js', 'server'); + api.addFiles('oidc_client.js', 'client'); +}); diff --git a/server/migrations.js b/server/migrations.js index 09852495..eefda9c8 100644 --- a/server/migrations.js +++ b/server/migrations.js @@ -1,3 +1,23 @@ +import AccountSettings from '../models/accountSettings'; +import Actions from '../models/actions'; +import Activities from '../models/activities'; +import Announcements from '../models/announcements'; +import Boards from '../models/boards'; +import CardComments from '../models/cardComments'; +import Cards from '../models/cards'; +import ChecklistItems from '../models/checklistItems'; +import Checklists from '../models/checklists'; +import CustomFields from '../models/customFields'; +import Integrations from '../models/integrations'; +import InvitationCodes from '../models/invitationCodes'; +import Lists from '../models/lists'; +import Rules from '../models/rules'; +import Settings from '../models/settings'; +import Swimlanes from '../models/swimlanes'; +import Triggers from '../models/triggers'; +import UnsavedEdits from '../models/unsavedEdits'; +import Users from '../models/users'; + // Anytime you change the schema of one of the collection in a non-backward // compatible way you have to write a migration in this file using the following // API: @@ -28,18 +48,22 @@ const noValidateMulti = { ...noValidate, multi: true }; Migrations.add('board-background-color', () => { const defaultColor = '#16A085'; - Boards.update({ - background: { - $exists: false, - }, - }, { - $set: { + Boards.update( + { background: { - type: 'color', - color: defaultColor, + $exists: false, }, }, - }, noValidateMulti); + { + $set: { + background: { + type: 'color', + color: defaultColor, + }, + }, + }, + noValidateMulti + ); }); Migrations.add('lowercase-board-permission', () => { @@ -57,24 +81,28 @@ Migrations.add('change-attachments-type-for-non-images', () => { const newTypeForNonImage = 'application/octet-stream'; Attachments.find().forEach((file) => { if (!file.isImage()) { - Attachments.update(file._id, { - $set: { - 'original.type': newTypeForNonImage, - 'copies.attachments.type': newTypeForNonImage, + Attachments.update( + file._id, + { + $set: { + 'original.type': newTypeForNonImage, + 'copies.attachments.type': newTypeForNonImage, + }, }, - }, noValidate); + noValidate + ); } }); }); Migrations.add('card-covers', () => { Cards.find().forEach((card) => { - const cover = Attachments.findOne({ cardId: card._id, cover: true }); + const cover = Attachments.findOne({ cardId: card._id, cover: true }); if (cover) { - Cards.update(card._id, {$set: {coverId: cover._id}}, noValidate); + Cards.update(card._id, { $set: { coverId: cover._id } }, noValidate); } }); - Attachments.update({}, {$unset: {cover: ''}}, noValidateMulti); + Attachments.update({}, { $unset: { cover: '' } }, noValidateMulti); }); Migrations.add('use-css-class-for-boards-colors', () => { @@ -89,26 +117,31 @@ Migrations.add('use-css-class-for-boards-colors', () => { Boards.find().forEach((board) => { const oldBoardColor = board.background.color; const newBoardColor = associationTable[oldBoardColor]; - Boards.update(board._id, { - $set: { color: newBoardColor }, - $unset: { background: '' }, - }, noValidate); + Boards.update( + board._id, + { + $set: { color: newBoardColor }, + $unset: { background: '' }, + }, + noValidate + ); }); }); Migrations.add('denormalize-star-number-per-board', () => { Boards.find().forEach((board) => { - const nStars = Users.find({'profile.starredBoards': board._id}).count(); - Boards.update(board._id, {$set: {stars: nStars}}, noValidate); + const nStars = Users.find({ 'profile.starredBoards': board._id }).count(); + Boards.update(board._id, { $set: { stars: nStars } }, noValidate); }); }); // We want to keep a trace of former members so we can efficiently publish their // infos in the general board publication. Migrations.add('add-member-isactive-field', () => { - Boards.find({}, {fields: {members: 1}}).forEach((board) => { + Boards.find({}, { fields: { members: 1 } }).forEach((board) => { const allUsersWithSomeActivity = _.chain( - Activities.find({ boardId: board._id }, { fields:{ userId:1 }}).fetch()) + Activities.find({ boardId: board._id }, { fields: { userId: 1 } }).fetch() + ) .pluck('userId') .uniq() .value(); @@ -127,7 +160,7 @@ Migrations.add('add-member-isactive-field', () => { isActive: false, }); }); - Boards.update(board._id, {$set: {members: newMemberSet}}, noValidate); + Boards.update(board._id, { $set: { members: newMemberSet } }, noValidate); }); }); @@ -184,7 +217,7 @@ Migrations.add('add-checklist-items', () => { // Create new items _.sortBy(checklist.items, 'sort').forEach((item, index) => { ChecklistItems.direct.insert({ - title: (item.title ? item.title : 'Checklist'), + title: item.title ? item.title : 'Checklist', sort: index, isFinished: item.isFinished, checklistId: checklist._id, @@ -193,8 +226,9 @@ Migrations.add('add-checklist-items', () => { }); // Delete old ones - Checklists.direct.update({ _id: checklist._id }, - { $unset: { items : 1 } }, + Checklists.direct.update( + { _id: checklist._id }, + { $unset: { items: 1 } }, noValidate ); }); @@ -217,324 +251,512 @@ Migrations.add('add-card-types', () => { Cards.find().forEach((card) => { Cards.direct.update( { _id: card._id }, - { $set: { - type: 'cardType-card', - linkedId: null } }, + { + $set: { + type: 'cardType-card', + linkedId: null, + }, + }, noValidate ); }); }); Migrations.add('add-custom-fields-to-cards', () => { - Cards.update({ - customFields: { - $exists: false, + Cards.update( + { + customFields: { + $exists: false, + }, }, - }, { - $set: { - customFields:[], + { + $set: { + customFields: [], + }, }, - }, noValidateMulti); + noValidateMulti + ); }); Migrations.add('add-requester-field', () => { - Cards.update({ - requestedBy: { - $exists: false, + Cards.update( + { + requestedBy: { + $exists: false, + }, }, - }, { - $set: { - requestedBy:'', + { + $set: { + requestedBy: '', + }, }, - }, noValidateMulti); + noValidateMulti + ); }); Migrations.add('add-assigner-field', () => { - Cards.update({ - assignedBy: { - $exists: false, + Cards.update( + { + assignedBy: { + $exists: false, + }, }, - }, { - $set: { - assignedBy:'', + { + $set: { + assignedBy: '', + }, }, - }, noValidateMulti); + noValidateMulti + ); }); Migrations.add('add-parent-field-to-cards', () => { - Cards.update({ - parentId: { - $exists: false, + Cards.update( + { + parentId: { + $exists: false, + }, }, - }, { - $set: { - parentId:'', + { + $set: { + parentId: '', + }, }, - }, noValidateMulti); + noValidateMulti + ); }); Migrations.add('add-subtasks-boards', () => { - Boards.update({ - subtasksDefaultBoardId: { - $exists: false, + Boards.update( + { + subtasksDefaultBoardId: { + $exists: false, + }, }, - }, { - $set: { - subtasksDefaultBoardId: null, - subtasksDefaultListId: null, + { + $set: { + subtasksDefaultBoardId: null, + subtasksDefaultListId: null, + }, }, - }, noValidateMulti); + noValidateMulti + ); }); Migrations.add('add-subtasks-sort', () => { - Boards.update({ - subtaskSort: { - $exists: false, + Boards.update( + { + subtaskSort: { + $exists: false, + }, }, - }, { - $set: { - subtaskSort: -1, + { + $set: { + subtaskSort: -1, + }, }, - }, noValidateMulti); + noValidateMulti + ); }); Migrations.add('add-subtasks-allowed', () => { - Boards.update({ - allowsSubtasks: { - $exists: false, + Boards.update( + { + allowsSubtasks: { + $exists: false, + }, }, - }, { - $set: { - allowsSubtasks: true, + { + $set: { + allowsSubtasks: true, + }, }, - }, noValidateMulti); + noValidateMulti + ); }); Migrations.add('add-subtasks-allowed', () => { - Boards.update({ - presentParentTask: { - $exists: false, + Boards.update( + { + presentParentTask: { + $exists: false, + }, }, - }, { - $set: { - presentParentTask: 'no-parent', + { + $set: { + presentParentTask: 'no-parent', + }, }, - }, noValidateMulti); + noValidateMulti + ); }); Migrations.add('add-authenticationMethod', () => { - Users.update({ - 'authenticationMethod': { - $exists: false, + Users.update( + { + authenticationMethod: { + $exists: false, + }, }, - }, { - $set: { - 'authenticationMethod': 'password', + { + $set: { + authenticationMethod: 'password', + }, }, - }, noValidateMulti); + noValidateMulti + ); }); Migrations.add('remove-tag', () => { - Users.update({ - }, { - $unset: { - 'profile.tags':1, + Users.update( + {}, + { + $unset: { + 'profile.tags': 1, + }, }, - }, noValidateMulti); + noValidateMulti + ); }); Migrations.add('remove-customFields-references-broken', () => { - Cards.update({'customFields.$value': null}, - { $pull: { - customFields: {value: null}, + Cards.update( + { 'customFields.$value': null }, + { + $pull: { + customFields: { value: null }, + }, }, - }, noValidateMulti); + noValidateMulti + ); }); Migrations.add('add-product-name', () => { - Settings.update({ - productName: { - $exists: false, + Settings.update( + { + productName: { + $exists: false, + }, }, - }, { - $set: { - productName:'', + { + $set: { + productName: '', + }, }, - }, noValidateMulti); + noValidateMulti + ); }); Migrations.add('add-hide-logo', () => { - Settings.update({ - hideLogo: { - $exists: false, + Settings.update( + { + hideLogo: { + $exists: false, + }, }, - }, { - $set: { - hideLogo: false, + { + $set: { + hideLogo: false, + }, }, - }, noValidateMulti); + noValidateMulti + ); }); Migrations.add('add-custom-html-after-body-start', () => { - Settings.update({ - customHTMLafterBodyStart: { - $exists: false, + Settings.update( + { + customHTMLafterBodyStart: { + $exists: false, + }, }, - }, { - $set: { - customHTMLafterBodyStart:'', + { + $set: { + customHTMLafterBodyStart: '', + }, }, - }, noValidateMulti); + noValidateMulti + ); }); Migrations.add('add-custom-html-before-body-end', () => { - Settings.update({ - customHTMLbeforeBodyEnd: { - $exists: false, + Settings.update( + { + customHTMLbeforeBodyEnd: { + $exists: false, + }, }, - }, { - $set: { - customHTMLbeforeBodyEnd:'', + { + $set: { + customHTMLbeforeBodyEnd: '', + }, }, - }, noValidateMulti); + noValidateMulti + ); }); Migrations.add('add-displayAuthenticationMethod', () => { - Settings.update({ - displayAuthenticationMethod: { - $exists: false, + Settings.update( + { + displayAuthenticationMethod: { + $exists: false, + }, }, - }, { - $set: { - displayAuthenticationMethod: true, + { + $set: { + displayAuthenticationMethod: true, + }, }, - }, noValidateMulti); + noValidateMulti + ); }); Migrations.add('add-defaultAuthenticationMethod', () => { - Settings.update({ - defaultAuthenticationMethod: { - $exists: false, + Settings.update( + { + defaultAuthenticationMethod: { + $exists: false, + }, }, - }, { - $set: { - defaultAuthenticationMethod: 'password', + { + $set: { + defaultAuthenticationMethod: 'password', + }, }, - }, noValidateMulti); + noValidateMulti + ); }); Migrations.add('add-templates', () => { - Boards.update({ - type: { - $exists: false, - }, - }, { - $set: { - type: 'board', + Boards.update( + { + type: { + $exists: false, + }, }, - }, noValidateMulti); - Swimlanes.update({ - type: { - $exists: false, + { + $set: { + type: 'board', + }, }, - }, { - $set: { - type: 'swimlane', + noValidateMulti + ); + Swimlanes.update( + { + type: { + $exists: false, + }, }, - }, noValidateMulti); - Lists.update({ - type: { - $exists: false, + { + $set: { + type: 'swimlane', + }, }, - swimlaneId: { - $exists: false, + noValidateMulti + ); + Lists.update( + { + type: { + $exists: false, + }, + swimlaneId: { + $exists: false, + }, }, - }, { - $set: { - type: 'list', - swimlaneId: '', + { + $set: { + type: 'list', + swimlaneId: '', + }, }, - }, noValidateMulti); + noValidateMulti + ); Users.find({ 'profile.templatesBoardId': { $exists: false, }, }).forEach((user) => { // Create board and swimlanes - Boards.insert({ - title: TAPi18n.__('templates'), - permission: 'private', - type: 'template-container', - members: [ - { - userId: user._id, - isAdmin: true, - isActive: true, - isNoComments: false, - isCommentOnly: false, - }, - ], - }, (err, boardId) => { - - // Insert the reference to our templates board - Users.update(user._id, {$set: {'profile.templatesBoardId': boardId}}); - - // Insert the card templates swimlane - Swimlanes.insert({ - title: TAPi18n.__('card-templates-swimlane'), - boardId, - sort: 1, - type: 'template-container', - }, (err, swimlaneId) => { - - // Insert the reference to out card templates swimlane - Users.update(user._id, {$set: {'profile.cardTemplatesSwimlaneId': swimlaneId}}); - }); - - // Insert the list templates swimlane - Swimlanes.insert({ - title: TAPi18n.__('list-templates-swimlane'), - boardId, - sort: 2, + Boards.insert( + { + title: TAPi18n.__('templates'), + permission: 'private', type: 'template-container', - }, (err, swimlaneId) => { - - // Insert the reference to out list templates swimlane - Users.update(user._id, {$set: {'profile.listTemplatesSwimlaneId': swimlaneId}}); - }); + members: [ + { + userId: user._id, + isAdmin: true, + isActive: true, + isNoComments: false, + isCommentOnly: false, + }, + ], + }, + (err, boardId) => { + // Insert the reference to our templates board + Users.update(user._id, { + $set: { 'profile.templatesBoardId': boardId }, + }); + + // Insert the card templates swimlane + Swimlanes.insert( + { + title: TAPi18n.__('card-templates-swimlane'), + boardId, + sort: 1, + type: 'template-container', + }, + (err, swimlaneId) => { + // Insert the reference to out card templates swimlane + Users.update(user._id, { + $set: { 'profile.cardTemplatesSwimlaneId': swimlaneId }, + }); + } + ); - // Insert the board templates swimlane - Swimlanes.insert({ - title: TAPi18n.__('board-templates-swimlane'), - boardId, - sort: 3, - type: 'template-container', - }, (err, swimlaneId) => { + // Insert the list templates swimlane + Swimlanes.insert( + { + title: TAPi18n.__('list-templates-swimlane'), + boardId, + sort: 2, + type: 'template-container', + }, + (err, swimlaneId) => { + // Insert the reference to out list templates swimlane + Users.update(user._id, { + $set: { 'profile.listTemplatesSwimlaneId': swimlaneId }, + }); + } + ); - // Insert the reference to out board templates swimlane - Users.update(user._id, {$set: {'profile.boardTemplatesSwimlaneId': swimlaneId}}); - }); - }); + // Insert the board templates swimlane + Swimlanes.insert( + { + title: TAPi18n.__('board-templates-swimlane'), + boardId, + sort: 3, + type: 'template-container', + }, + (err, swimlaneId) => { + // Insert the reference to out board templates swimlane + Users.update(user._id, { + $set: { 'profile.boardTemplatesSwimlaneId': swimlaneId }, + }); + } + ); + } + ); }); }); Migrations.add('fix-circular-reference_', () => { Cards.find().forEach((card) => { if (card.parentId === card._id) { - Cards.update(card._id, {$set: {parentId: ''}}, noValidateMulti); + Cards.update(card._id, { $set: { parentId: '' } }, noValidateMulti); } }); }); Migrations.add('mutate-boardIds-in-customfields', () => { CustomFields.find().forEach((cf) => { - CustomFields.update(cf, { - $set: { - boardIds: [cf.boardId], - }, - $unset: { - boardId: '', + CustomFields.update( + cf, + { + $set: { + boardIds: [cf.boardId], + }, + $unset: { + boardId: '', + }, }, - }, noValidateMulti); + noValidateMulti + ); }); }); + +const firstBatchOfDbsToAddCreatedAndUpdated = [ + AccountSettings, + Actions, + Activities, + Announcements, + Boards, + CardComments, + Cards, + ChecklistItems, + Checklists, + CustomFields, + Integrations, + InvitationCodes, + Lists, + Rules, + Settings, + Swimlanes, + Triggers, + UnsavedEdits, +]; + +firstBatchOfDbsToAddCreatedAndUpdated.forEach((db) => { + db.before.insert((userId, doc) => { + doc.createdAt = Date.now(); + doc.updatedAt = doc.createdAt; + }); + + db.before.update((userId, doc, fieldNames, modifier, options) => { + modifier.$set = modifier.$set || {}; + modifier.$set.updatedAt = new Date(); + }); +}); + +const modifiedAtTables = [ + AccountSettings, + Actions, + Activities, + Announcements, + Boards, + CardComments, + Cards, + ChecklistItems, + Checklists, + CustomFields, + Integrations, + InvitationCodes, + Lists, + Rules, + Settings, + Swimlanes, + Triggers, + UnsavedEdits, + Users, +]; + +Migrations.add('add-missing-created-and-modified', () => { + Promise.all( + modifiedAtTables.map((db) => + db + .rawCollection() + .update( + { modifiedAt: { $exists: false } }, + { $set: { modifiedAt: new Date() } }, + { multi: true } + ) + .then(() => + db + .rawCollection() + .update( + { createdAt: { $exists: false } }, + { $set: { createdAt: new Date() } }, + { multi: true } + ) + ) + ) + ) + .then(() => { + // eslint-disable-next-line no-console + console.info('Successfully added createdAt and updatedAt to all tables'); + }) + .catch((e) => { + // eslint-disable-next-line no-console + console.error(e); + }); +}); |