瀏覽代碼

Add createdAt and modifiedAt to all collections

Justin Reynolds 6 年之前
父節點
當前提交
c60e80d25b

+ 65 - 25
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;

+ 15 - 0
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;

+ 38 - 7
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;

+ 54 - 20
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;

+ 11 - 8
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;

+ 6 - 4
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;

文件差異過大導致無法顯示
+ 441 - 361
models/boards.js


+ 164 - 116
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;

+ 23 - 2
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;

+ 140 - 89
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;

+ 181 - 132
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;

+ 181 - 116
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;

+ 201 - 135
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;

+ 60 - 27
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;

+ 204 - 157
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;

+ 63 - 23
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;

+ 165 - 97
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;

+ 198 - 138
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;

+ 15 - 1
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;

+ 55 - 18
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;

文件差異過大導致無法顯示
+ 411 - 334
models/users.js


+ 1 - 0
packages/wekan-iframe

@@ -0,0 +1 @@
+Subproject commit e105dcc9c3424beee0ff0a9db9ca543a6d4b7f85

+ 1 - 0
packages/wekan_accounts-oidc/.gitignore

@@ -0,0 +1 @@
+.versions

+ 14 - 0
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.
+

+ 75 - 0
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: []
+        }
+      }
+    );
+  });
+}
+```

+ 22 - 0
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']
+  });
+}

+ 3 - 0
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==');
+}

+ 19 - 0
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');
+});

+ 1 - 0
packages/wekan_oidc/.gitignore

@@ -0,0 +1 @@
+.versions

+ 14 - 0
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.
+

+ 7 - 0
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.

+ 68 - 0
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,
+  });
+};

+ 6 - 0
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>

+ 17 - 0
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'}
+  ];
+};

+ 143 - 0
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);
+};

+ 23 - 0
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');
+});

+ 453 - 231
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);
+    });
+});

部分文件因文件數量過多而無法顯示