2
0
Эх сурвалжийг харах

Merge branch 'jrsupplee-admin-reports'

Lauri Ojansivu 4 жил өмнө
parent
commit
6c8fae944c
35 өөрчлөгдсөн 695 нэмэгдсэн , 509 устгасан
  1. 1 1
      client/components/activities/comments.jade
  2. 11 3
      client/components/cards/cardCustomFields.js
  3. 2 119
      client/components/cards/cardDate.js
  4. 14 26
      client/components/cards/cardDetails.jade
  5. 19 132
      client/components/cards/cardDetails.js
  6. 1 0
      client/components/cards/cardDetails.styl
  7. 4 0
      client/components/cards/minicard.jade
  8. 15 1
      client/components/cards/minicard.js
  9. 6 2
      client/components/cards/minicard.styl
  10. 5 5
      client/components/main/globalSearch.jade
  11. 4 3
      client/components/main/globalSearch.js
  12. 111 0
      client/components/settings/adminReports.jade
  13. 156 0
      client/components/settings/adminReports.js
  14. 4 0
      client/components/settings/settingHeader.jade
  15. 8 0
      client/components/sidebar/sidebar.jade
  16. 21 0
      client/components/sidebar/sidebar.js
  17. 5 4
      client/components/users/userAvatar.jade
  18. 4 4
      client/components/users/userAvatar.js
  19. 0 4
      client/components/users/userHeader.jade
  20. 4 1
      client/lib/cardSearch.js
  21. 17 17
      client/lib/datepicker.js
  22. 51 0
      config/const.js
  23. 3 0
      config/query-classes.js
  24. 22 0
      config/router.js
  25. 3 2
      config/search-const.js
  26. 7 1
      i18n/en.i18n.json
  27. 5 0
      models/attachments.js
  28. 40 59
      models/boards.js
  29. 10 28
      models/cards.js
  30. 3 26
      models/lists.js
  31. 9 0
      models/rules.js
  32. 3 26
      models/swimlanes.js
  33. 60 0
      server/publications/attachments.js
  34. 42 45
      server/publications/cards.js
  35. 25 0
      server/publications/rules.js

+ 1 - 1
client/components/activities/comments.jade

@@ -1,7 +1,7 @@
 template(name="commentForm")
   .new-comment.js-new-comment(
     class="{{#if commentFormIsOpen}}is-open{{/if}}")
-    +userAvatar(userId=currentUser._id)
+    +userAvatar(userId=currentUser._id noRemove=true)
     form.js-new-comment-form
       +editor(class="js-new-comment-input")
         | {{getUnsavedValue 'cardComment' currentCard._id}}

+ 11 - 3
client/components/cards/cardCustomFields.js

@@ -1,3 +1,7 @@
+import { DatePicker } from '/client/lib/datepicker';
+import moment from 'moment';
+import Cards from '/models/cards';
+
 Template.cardCustomFieldsPopup.helpers({
   hasCustomField() {
     const card = Cards.findOne(Session.get('currentCard'));
@@ -286,8 +290,9 @@ CardCustomField.register('cardCustomField');
               } else {
                 event.target.blur();
 
-                const idx = Array.from(this.findAll('input'))
-                  .indexOf(event.target);
+                const idx = Array.from(this.findAll('input')).indexOf(
+                  event.target,
+                );
                 items.splice(idx + 1, 0, '');
 
                 Tracker.afterFlush(() => {
@@ -303,7 +308,10 @@ CardCustomField.register('cardCustomField');
         },
 
         'blur .js-card-customfield-stringtemplate-item'(event) {
-          if (!event.target.value.trim() || event.target === this.find('input.last')) {
+          if (
+            !event.target.value.trim() ||
+            event.target === this.find('input.last')
+          ) {
             const items = this.getItems();
             this.stringtemplateItems.set(items);
             this.find('input.last').value = '';

+ 2 - 119
client/components/cards/cardDate.js

@@ -1,122 +1,5 @@
-// Helper function to replace HH with H for 24 hours format, because H allows also single-digit hours
-function adjustedTimeFormat() {
-  return moment
-    .localeData()
-    .longDateFormat('LT')
-    .replace(/HH/i, 'H');
-}
-
-// Edit received, start, due & end dates
-BlazeComponent.extendComponent({
-  template() {
-    return 'editCardDate';
-  },
-
-  onCreated() {
-    this.error = new ReactiveVar('');
-    this.card = this.data();
-    this.date = new ReactiveVar(moment.invalid());
-  },
-
-  onRendered() {
-    const $picker = this.$('.js-datepicker')
-      .datepicker({
-        todayHighlight: true,
-        todayBtn: 'linked',
-        language: TAPi18n.getLanguage(),
-      })
-      .on(
-        'changeDate',
-        function(evt) {
-          this.find('#date').value = moment(evt.date).format('L');
-          this.error.set('');
-          this.find('#time').focus();
-        }.bind(this),
-      );
-
-    if (this.date.get().isValid()) {
-      $picker.datepicker('update', this.date.get().toDate());
-    }
-  },
-
-  showDate() {
-    if (this.date.get().isValid()) return this.date.get().format('L');
-    return '';
-  },
-  showTime() {
-    if (this.date.get().isValid()) return this.date.get().format('LT');
-    return '';
-  },
-  dateFormat() {
-    return moment.localeData().longDateFormat('L');
-  },
-  timeFormat() {
-    return moment.localeData().longDateFormat('LT');
-  },
-
-  events() {
-    return [
-      {
-        'keyup .js-date-field'() {
-          // parse for localized date format in strict mode
-          const dateMoment = moment(this.find('#date').value, 'L', true);
-          if (dateMoment.isValid()) {
-            this.error.set('');
-            this.$('.js-datepicker').datepicker('update', dateMoment.toDate());
-          }
-        },
-        'keyup .js-time-field'() {
-          // parse for localized time format in strict mode
-          const dateMoment = moment(
-            this.find('#time').value,
-            adjustedTimeFormat(),
-            true,
-          );
-          if (dateMoment.isValid()) {
-            this.error.set('');
-          }
-        },
-        'submit .edit-date'(evt) {
-          evt.preventDefault();
-
-          // if no time was given, init with 12:00
-          const time =
-            evt.target.time.value ||
-            moment(new Date().setHours(12, 0, 0)).format('LT');
-          const newTime = moment(time, adjustedTimeFormat(), true);
-          const newDate = moment(evt.target.date.value, 'L', true);
-          const dateString = `${evt.target.date.value} ${time}`;
-          const newCompleteDate = moment(
-            dateString,
-            'L ' + adjustedTimeFormat(),
-            true,
-          );
-          if (!newTime.isValid()) {
-            this.error.set('invalid-time');
-            evt.target.time.focus();
-          }
-          if (!newDate.isValid()) {
-            this.error.set('invalid-date');
-            evt.target.date.focus();
-          }
-          if (newCompleteDate.isValid()) {
-            this._storeDate(newCompleteDate.toDate());
-            Popup.close();
-          } else {
-            if (!this.error) {
-              this.error.set('invalid');
-            }
-          }
-        },
-        'click .js-delete-date'(evt) {
-          evt.preventDefault();
-          this._deleteDate();
-          Popup.close();
-        },
-      },
-    ];
-  },
-});
+import moment from 'moment';
+import { DatePicker } from '/client/lib/datepicker';
 
 Template.dateBadge.helpers({
   canModifyCard() {

+ 14 - 26
client/components/cards/cardDetails.jade

@@ -110,15 +110,24 @@ template(name="cardDetails")
                 a.card-label.add-label.js-end-date
                   i.fa.fa-plus
 
+      hr
+      if currentBoard.allowsCreator
+        .card-details-item.card-details-item-creator
+          h3.card-details-item-title
+            i.fa.fa-user
+            | {{_ 'creator'}}
+
+          +userAvatar(userId=userId noRemove=true)
+          | {{! XXX Hack to hide syntaxic coloration /// }}
+
       //.card-details-items
       if currentBoard.allowsMembers
-        hr
         .card-details-item.card-details-item-members
           h3.card-details-item-title
             i.fa.fa-users
             | {{_ 'members'}}
-          each getMembers
-            +userAvatar(userId=this cardId=../_id)
+          each userId in getMembers
+            +userAvatar(userId=userId cardId=_id)
             | {{! XXX Hack to hide syntaxic coloration /// }}
           if canModifyCard
             unless currentUser.isWorker
@@ -131,8 +140,8 @@ template(name="cardDetails")
           h3.card-details-item-title
             i.fa.fa-user
             | {{_ 'assignee'}}
-          each getAssignees
-            +userAvatarAssignee(userId=this cardId=../_id)
+          each userId in getAssignees
+            +userAvatar(userId=userId cardId=_id assignee=true)
             | {{! XXX Hack to hide syntaxic coloration /// }}
           if canModifyCard
             a.assignee.add-assignee.card-details-item-add-button.js-add-assignees(title="{{_ 'assignee'}}")
@@ -488,23 +497,6 @@ template(name="cardAssigneesPopup")
           if currentUser.isCardAssignee
             i.fa.fa-check
 
-template(name="userAvatarAssignee")
-  a.assignee.js-assignee(title="{{userData.profile.fullname}} ({{userData.username}})")
-    if userData.profile.avatarUrl
-      img.avatar.avatar-image(src="{{userData.profile.avatarUrl}}")
-    else
-      +userAvatarAssigneeInitials(userId=userData._id)
-
-    if showStatus
-      span.assignee-presence-status(class=presenceStatusClassName)
-      span.member-type(class=memberType)
-
-    unless isSandstorm
-      if showEdit
-        if $eq currentUser._id userData._id
-          a.edit-avatar.js-change-avatar
-            i.fa.fa-pencil
-
 template(name="cardAssigneePopup")
   .board-assignee-menu
     .mini-profile-info
@@ -522,10 +514,6 @@ template(name="cardAssigneePopup")
           with currentUser
             li: a.js-edit-profile {{_ 'edit-profile'}}
 
-template(name="userAvatarAssigneeInitials")
-  svg.avatar.avatar-assignee-initials(viewBox="0 0 {{viewPortWidth}} 15")
-    text(x="50%" y="13" text-anchor="middle")= initials
-
 template(name="cardMorePopup")
   p.quiet
     span.clearfix

+ 19 - 132
client/components/cards/cardDetails.js

@@ -1,11 +1,18 @@
+import { DatePicker } from '/client/lib/datepicker';
+import Cards from '/models/cards';
+import Boards from '/models/boards';
+import Checklists from '/models/checklists';
+import Integrations from '/models/integrations';
+import Users from '/models/users';
+import Lists from '/models/lists';
+import CardComments from '/models/cardComments';
+import { ALLOWED_COLORS } from '/config/const';
+import moment from 'moment';
+import { UserAvatar } from '../users/userAvatar';
+
 const subManager = new SubsManager();
 const { calculateIndexData } = Utils;
 
-let cardColors;
-Meteor.startup(() => {
-  cardColors = Cards.simpleSchema()._schema.color.allowedValues;
-});
-
 BlazeComponent.extendComponent({
   mixins() {
     return [Mixins.InfiniteScrolling];
@@ -160,9 +167,7 @@ BlazeComponent.extendComponent({
             integration,
             'CardSelected',
             params,
-            () => {
-              return;
-            },
+            () => {},
           );
         });
       }
@@ -290,7 +295,7 @@ BlazeComponent.extendComponent({
           Utils.goBoardId(this.data().boardId);
         },
         'click .js-copy-link'() {
-          StringToCopyElement = document.getElementById('cardURL_copy');
+          const StringToCopyElement = document.getElementById('cardURL_copy');
           StringToCopyElement.value =
             window.location.origin + window.location.pathname;
           StringToCopyElement.select();
@@ -407,122 +412,6 @@ BlazeComponent.extendComponent({
   },
 }).register('cardDetails');
 
-Template.cardDetails.helpers({
-  userData() {
-    // We need to handle a special case for the search results provided by the
-    // `matteodem:easy-search` package. Since these results gets published in a
-    // separate collection, and not in the standard Meteor.Users collection as
-    // expected, we use a component parameter ("property") to distinguish the
-    // two cases.
-    const userCollection = this.esSearch ? ESSearchResults : Users;
-    return userCollection.findOne(this.userId, {
-      fields: {
-        profile: 1,
-        username: 1,
-      },
-    });
-  },
-
-  receivedSelected() {
-    if (this.getReceived().length === 0) {
-      return false;
-    } else {
-      return true;
-    }
-  },
-
-  startSelected() {
-    if (this.getStart().length === 0) {
-      return false;
-    } else {
-      return true;
-    }
-  },
-
-  endSelected() {
-    if (this.getEnd().length === 0) {
-      return false;
-    } else {
-      return true;
-    }
-  },
-
-  dueSelected() {
-    if (this.getDue().length === 0) {
-      return false;
-    } else {
-      return true;
-    }
-  },
-
-  memberSelected() {
-    if (this.getMembers().length === 0) {
-      return false;
-    } else {
-      return true;
-    }
-  },
-
-  labelSelected() {
-    if (this.getLabels().length === 0) {
-      return false;
-    } else {
-      return true;
-    }
-  },
-
-  assigneeSelected() {
-    if (this.getAssignees().length === 0) {
-      return false;
-    } else {
-      return true;
-    }
-  },
-
-  requestBySelected() {
-    if (this.getRequestBy().length === 0) {
-      return false;
-    } else {
-      return true;
-    }
-  },
-
-  assigneeBySelected() {
-    if (this.getAssigneeBy().length === 0) {
-      return false;
-    } else {
-      return true;
-    }
-  },
-
-  memberType() {
-    const user = Users.findOne(this.userId);
-    return user && user.isBoardAdmin() ? 'admin' : 'normal';
-  },
-
-  presenceStatusClassName() {
-    const user = Users.findOne(this.userId);
-    const userPresence = presences.findOne({ userId: this.userId });
-    if (user && user.isInvitedTo(Session.get('currentBoard'))) return 'pending';
-    else if (!userPresence) return 'disconnected';
-    else if (Session.equals('currentBoard', userPresence.state.currentBoardId))
-      return 'active';
-    else return 'idle';
-  },
-});
-
-Template.userAvatarAssigneeInitials.helpers({
-  initials() {
-    const user = Users.findOne(this.userId);
-    return user && user.getInitials();
-  },
-
-  viewPortWidth() {
-    const user = Users.findOne(this.userId);
-    return ((user && user.getInitials().length) || 1) * 12;
-  },
-});
-
 // We extends the normal InlinedForm component to support UnsavedEdits draft
 // feature.
 (class extends InlinedForm {
@@ -697,7 +586,7 @@ BlazeComponent.extendComponent({
   },
 
   boards() {
-    const boards = Boards.find(
+    return Boards.find(
       {
         archived: false,
         'members.userId': Meteor.userId(),
@@ -707,7 +596,6 @@ BlazeComponent.extendComponent({
         sort: { sort: 1 /* boards default sorting */ },
       },
     );
-    return boards;
   },
 
   swimlanes() {
@@ -736,7 +624,7 @@ Template.copyCardPopup.events({
   'click .js-done'() {
     const card = Cards.findOne(Session.get('currentCard'));
     const lSelect = $('.js-select-lists')[0];
-    listId = lSelect.options[lSelect.selectedIndex].value;
+    const listId = lSelect.options[lSelect.selectedIndex].value;
     const slSelect = $('.js-select-swimlanes')[0];
     const swimlaneId = slSelect.options[slSelect.selectedIndex].value;
     const bSelect = $('.js-select-boards')[0];
@@ -801,7 +689,7 @@ Template.copyChecklistToManyCardsPopup.events({
         });
 
         // copy subtasks
-        cursor = Cards.find({ parentId: oldId });
+        const cursor = Cards.find({ parentId: oldId });
         cursor.forEach(function() {
           'use strict';
           const subtask = arguments[0];
@@ -827,7 +715,7 @@ BlazeComponent.extendComponent({
   },
 
   colors() {
-    return cardColors.map(color => ({ color, name: '' }));
+    return ALLOWED_COLORS.map(color => ({ color, name: '' }));
   },
 
   isSelected(color) {
@@ -871,7 +759,7 @@ BlazeComponent.extendComponent({
   },
 
   boards() {
-    const boards = Boards.find(
+    return Boards.find(
       {
         archived: false,
         'members.userId': Meteor.userId(),
@@ -883,7 +771,6 @@ BlazeComponent.extendComponent({
         sort: { sort: 1 /* boards default sorting */ },
       },
     );
-    return boards;
   },
 
   cards() {

+ 1 - 0
client/components/cards/cardDetails.styl

@@ -211,6 +211,7 @@ avatar-radius = 50%
         word-wrap: break-word
         max-width: 36%
         flex-grow: 1
+      &.card-details-item-creator,
       &.card-details-item-received,
       &.card-details-item-start,
       &.card-details-item-due,

+ 4 - 0
client/components/cards/minicard.jade

@@ -99,6 +99,10 @@ template(name="minicard")
         each getMembers
           +userAvatar(userId=this)
 
+    if showCreator
+      .minicard-creator
+        +userAvatar(userId=this.userId noRemove=true)
+
     .badges
       unless currentUser.isNoComments
         if comments.count

+ 15 - 1
client/components/cards/minicard.js

@@ -31,10 +31,24 @@ BlazeComponent.extendComponent({
 
     return customFieldTrueValue
       .filter(value => !!value.trim())
-      .map(value => definition.settings.stringtemplateFormat.replace(/%\{value\}/gi, value))
+      .map(value =>
+        definition.settings.stringtemplateFormat.replace(/%\{value\}/gi, value),
+      )
       .join(definition.settings.stringtemplateSeparator ?? '');
   },
 
+  showCreator() {
+    if (this.data().board()) {
+      return (
+        this.data().board.allowsCreator === null ||
+        this.data().board().allowsCreator === undefined ||
+        this.data().board().allowsCreator
+      );
+      // return this.data().board().allowsCreator;
+    }
+    return false;
+  },
+
   events() {
     return [
       {

+ 6 - 2
client/components/cards/minicard.styl

@@ -89,7 +89,7 @@
       border-radius: 2px
       margin-right: 3px
       margin-bottom: 3px
-      
+
   .minicard-custom-fields
     display:block;
   .minicard-custom-field
@@ -163,7 +163,8 @@
         line-height: 12px
 
   .minicard-members,
-  .minicard-assignees
+  .minicard-assignees,
+  .minicard-creator
     float: right
     margin-left: 5px
     margin-bottom: 4px
@@ -187,6 +188,9 @@
   .minicard-assignees
     border-bottom: 1px solid red
 
+  .minicard-creator
+    border-bottom: 1px solid green
+
   .minicard-members:empty,
   .minicard-assignees:empty
     display: none

+ 5 - 5
client/components/main/globalSearch.jade

@@ -11,9 +11,10 @@ template(name="globalSearchModalTitle")
       | {{_ 'globalSearch-title'}}
 
 template(name="resultsPaged")
-  h1
-    = resultsHeading.get
-    a.fa.fa-link(title="{{_ 'link-to-search' }}" href="{{ getSearchHref }}")
+  if resultsHeading.get
+    h1
+      = resultsHeading.get
+      a.fa.fa-link(title="{{_ 'link-to-search' }}" href="{{ getSearchHref }}")
   each card in results.get
     +resultCard(card)
   table.global-search-footer
@@ -50,8 +51,7 @@ template(name="globalSearch")
               each msg in errorMessages
                 li.global-search-error-messages
                   = msg
-          else
-            +resultsPaged(this)
+          +resultsPaged(this)
       else if serverError.get
         .global-search-page
           .global-search-help

+ 4 - 3
client/components/main/globalSearch.js

@@ -123,6 +123,7 @@ class GlobalSearchComponent extends CardSearchPagedComponent {
       operator_member_abbrev: TAPi18n.__('operator-member-abbrev'),
       operator_assignee: TAPi18n.__('operator-assignee'),
       operator_assignee_abbrev: TAPi18n.__('operator-assignee-abbrev'),
+      operator_creator: TAPi18n.__('operator-creator'),
       operator_due: TAPi18n.__('operator-due'),
       operator_created: TAPi18n.__('operator-created'),
       operator_modified: TAPi18n.__('operator-modified'),
@@ -167,6 +168,7 @@ class GlobalSearchComponent extends CardSearchPagedComponent {
       ['\n* ', 'globalSearch-instructions-operator-at'],
       ['\n* ', 'globalSearch-instructions-operator-member'],
       ['\n* ', 'globalSearch-instructions-operator-assignee'],
+      ['\n* ', 'globalSearch-instructions-operator-creator'],
       ['\n* ', 'globalSearch-instructions-operator-due'],
       ['\n* ', 'globalSearch-instructions-operator-created'],
       ['\n* ', 'globalSearch-instructions-operator-modified'],
@@ -202,9 +204,8 @@ class GlobalSearchComponent extends CardSearchPagedComponent {
   }
 
   events() {
-    return [
+    return super.events().concat([
       {
-        ...super.events()[0],
         'submit .js-search-query-form'(evt) {
           evt.preventDefault();
           this.searchAllBoards(evt.target.searchQuery.value);
@@ -257,7 +258,7 @@ class GlobalSearchComponent extends CardSearchPagedComponent {
           this.hasResults.set(false);
         },
       },
-    ];
+    ]);
   }
 }
 

+ 111 - 0
client/components/settings/adminReports.jade

@@ -0,0 +1,111 @@
+template(name="adminReports")
+  .setting-content
+    unless currentUser.isAdmin
+      | {{_ 'error-notAuthorized'}}
+    else
+      .content-body
+        .side-menu
+          ul
+            li
+              a.js-report-broken(data-id="report-broken")
+                i.fa.fa-chain-broken
+                | {{_ 'broken-cards'}}
+
+            li
+              a.js-report-files(data-id="report-orphaned-files")
+                i.fa.fa-paperclip
+                | {{_ 'orphanedFilesReportTitle'}}
+
+            li
+              a.js-report-files(data-id="report-files")
+                i.fa.fa-paperclip
+                | {{_ 'filesReportTitle'}}
+
+            li
+              a.js-report-rules(data-id="report-rules")
+                i.fa.fa-paperclip
+                | {{_ 'rulesReportTitle'}}
+
+        .main-body
+          if loading.get
+            +spinner
+          else if showBrokenCardsReport.get
+            +brokenCardsReport
+          else if showFilesReport.get
+            +filesReport
+          else if showOrphanedFilesReport.get
+            +orphanedFilesReport
+          else if showRulesReport.get
+            +rulesReport
+
+
+template(name="brokenCardsReport")
+  .global-search-results-list-wrapper
+    h1 {{_ 'broken-cards'}}
+    if resultsCount
+      +resultsPaged(this)
+    else
+      div {{_ 'no-results' }}
+
+template(name="rulesReport")
+  h1 {{_ 'rulesReportTitle'}}
+  if resultsCount
+    table.table
+      tr
+        th Rule Title
+        th Board Title
+        th actionType
+        th activityType
+
+      each rule in rows
+        tr
+          td {{ rule.title }}
+          td {{ rule.boardTitle }}
+          td {{ rule.action.actionType }}
+          td {{ rule.trigger.activityType }}
+  else
+    div {{_ 'no-results' }}
+
+template(name="filesReport")
+  h1 {{_ 'filesReportTitle'}}
+  if resultsCount
+    table.table
+      tr
+        th Filename
+        th.right Size (kB)
+        th MIME Type
+        th.center Usage
+        th MD5 Sum
+        th ID
+
+      each att in attachmentFiles
+        tr
+          td {{ att.filename }}
+          td.right {{fileSize att.length }}
+          td {{ att.contentType }}
+          td.center {{usageCount att._id.toHexString }}
+          td {{ att.md5 }}
+          td {{ att._id.toHexString }}
+  else
+    div {{_ 'no-results' }}
+
+template(name="orphanedFilesReport")
+  h1 {{_ 'orphanedFilesReportTitle'}}
+  if resultsCount
+    table.table
+      tr
+        th Filename
+        th.right Size (kB)
+        th MIME Type
+        th MD5 Sum
+        th ID
+
+      each att in attachmentFiles
+        tr
+          td {{ att.filename }}
+          td.right {{fileSize att.length }}
+          td {{ att.contentType }}
+          td {{ att.md5 }}
+          td {{ att._id.toHexString }}
+  else
+    div {{_ 'no-results' }}

+ 156 - 0
client/components/settings/adminReports.js

@@ -0,0 +1,156 @@
+import { AttachmentStorage } from '/models/attachments';
+import { CardSearchPagedComponent } from '/client/lib/cardSearch';
+import SessionData from '/models/usersessiondata';
+
+BlazeComponent.extendComponent({
+  subscription: null,
+  showFilesReport: new ReactiveVar(false),
+  showBrokenCardsReport: new ReactiveVar(false),
+  showOrphanedFilesReport: new ReactiveVar(false),
+  showRulesReport: new ReactiveVar(false),
+
+  onCreated() {
+    this.error = new ReactiveVar('');
+    this.loading = new ReactiveVar(false);
+  },
+
+  events() {
+    return [
+      {
+        'click a.js-report-broken': this.switchMenu,
+        'click a.js-report-files': this.switchMenu,
+        'click a.js-report-orphaned-files': this.switchMenu,
+        'click a.js-report-rules': this.switchMenu,
+      },
+    ];
+  },
+
+  switchMenu(event) {
+    const target = $(event.target);
+    if (!target.hasClass('active')) {
+      this.loading.set(true);
+      this.showFilesReport.set(false);
+      this.showBrokenCardsReport.set(false);
+      this.showOrphanedFilesReport.set(false);
+      if (this.subscription) {
+        this.subscription.stop();
+      }
+
+      $('.side-menu li.active').removeClass('active');
+      target.parent().addClass('active');
+      const targetID = target.data('id');
+
+      if ('report-broken' === targetID) {
+        this.showBrokenCardsReport.set(true);
+        this.subscription = Meteor.subscribe(
+          'brokenCards',
+          SessionData.getSessionId(),
+          () => {
+            this.loading.set(false);
+          },
+        );
+      } else if ('report-files' === targetID) {
+        this.showFilesReport.set(true);
+        this.subscription = Meteor.subscribe('attachmentsList', () => {
+          this.loading.set(false);
+        });
+      } else if ('report-orphaned-files' === targetID) {
+        this.showOrphanedFilesReport.set(true);
+        this.subscription = Meteor.subscribe('orphanedAttachments', () => {
+          this.loading.set(false);
+        });
+      } else if ('report-rules' === targetID) {
+        this.subscription = Meteor.subscribe('rulesReport', () => {
+          this.showRulesReport.set(true);
+          this.loading.set(false);
+        });
+      }
+    }
+  },
+}).register('adminReports');
+
+Template.filesReport.helpers({
+  attachmentFiles() {
+    // eslint-disable-next-line no-console
+    // console.log('attachments:', AttachmentStorage.find());
+    // console.log('attachments.count:', AttachmentStorage.find().count());
+    return AttachmentStorage.find();
+  },
+
+  rulesReport() {
+    const rules = [];
+
+    Rules.find().forEach(rule => {
+      rules.push({
+        _id: rule._id,
+        title: rule.title,
+        boardId: rule.boardId,
+        boardTitle: rule.board().title,
+        action: rule.action().fetch(),
+        trigger: rule.trigger().fetch(),
+      });
+    });
+
+    return rules;
+  },
+
+  resultsCount() {
+    return AttachmentStorage.find().count();
+  },
+
+  fileSize(size) {
+    return Math.round(size / 1024);
+  },
+
+  usageCount(key) {
+    return Attachments.find({ 'copies.attachments.key': key }).count();
+  },
+});
+
+Template.orphanedFilesReport.helpers({
+  attachmentFiles() {
+    // eslint-disable-next-line no-console
+    // console.log('attachments:', AttachmentStorage.find());
+    // console.log('attachments.count:', AttachmentStorage.find().count());
+    return AttachmentStorage.find();
+  },
+
+  resultsCount() {
+    return AttachmentStorage.find().count();
+  },
+
+  fileSize(size) {
+    return Math.round(size / 1024);
+  },
+});
+
+Template.rulesReport.helpers({
+  rows() {
+    const rules = [];
+
+    Rules.find().forEach(rule => {
+      rules.push({
+        _id: rule._id,
+        title: rule.title,
+        boardId: rule.boardId,
+        boardTitle: rule.board().title,
+        action: rule.action(),
+        trigger: rule.trigger(),
+      });
+    });
+
+    console.log('rows:', rules);
+    return rules;
+  },
+
+  resultsCount() {
+    return Rules.find().count();
+  },
+});
+
+class BrokenCardsComponent extends CardSearchPagedComponent {
+  onCreated() {
+    super.onCreated();
+  }
+}
+BrokenCardsComponent.register('brokenCardsReport');

+ 4 - 0
client/components/settings/settingHeader.jade

@@ -12,6 +12,10 @@ template(name="settingHeaderBar")
         i.fa(class="fa-users")
         span {{_ 'people'}}
 
+      a.setting-header-btn.informations(href="{{pathFor 'admin-reports'}}")
+        i.fa(class="fa-list")
+        span {{_ 'reports'}}
+
       a.setting-header-btn.informations(href="{{pathFor 'information'}}")
         i.fa(class="fa-info-circle")
         span {{_ 'info'}}

+ 8 - 0
client/components/sidebar/sidebar.jade

@@ -105,6 +105,14 @@ template(name="boardCardSettingsPopup")
         span
           i.fa.fa-users
           | {{_ 'members'}}
+
+    div.check-div
+      a.flex.js-field-has-creator(class="{{#if allowsCreator}}is-checked{{/if}}")
+        .materialCheckBox(class="{{#if allowsCreator}}is-checked{{/if}}")
+        span
+          i.fa.fa-user
+          | {{_ 'creator'}}
+
     div.check-div
       a.flex.js-field-has-assignee(class="{{#if allowsAssignee}}is-checked{{/if}}")
         .materialCheckBox(class="{{#if allowsAssignee}}is-checked{{/if}}")

+ 21 - 0
client/components/sidebar/sidebar.js

@@ -730,6 +730,14 @@ BlazeComponent.extendComponent({
     return this.currentBoard.allowsSubtasks;
   },
 
+  allowsCreator() {
+    return (
+      this.currentBoard.allowsCreator === null ||
+      this.currentBoard.allowsCreator === undefined ||
+      this.currentBoard.allowsCreator
+    );
+  },
+
   allowsMembers() {
     return this.currentBoard.allowsMembers;
   },
@@ -889,6 +897,19 @@ BlazeComponent.extendComponent({
             this.currentBoard.allowsSubtasks,
           );
         },
+        'click .js-field-has-creator'(evt) {
+          evt.preventDefault();
+          this.currentBoard.allowsCreator = !this.currentBoard.allowsCreator;
+          this.currentBoard.setAllowsCreator(this.currentBoard.allowsCreator);
+          $(`.js-field-has-creator ${MCB}`).toggleClass(
+            CKCLS,
+            this.currentBoard.allowsCreator,
+          );
+          $('.js-field-has-creator').toggleClass(
+            CKCLS,
+            this.currentBoard.allowsCreator,
+          );
+        },
         'click .js-field-has-members'(evt) {
           evt.preventDefault();
           this.currentBoard.allowsMembers = !this.currentBoard.allowsMembers;

+ 5 - 4
client/components/users/userAvatar.jade

@@ -1,5 +1,5 @@
 template(name="userAvatar")
-  a.member.js-member(title="{{userData.profile.fullname}} ({{userData.username}}) {{_ memberType}}")
+  a.member(class="js-{{#if assignee}}assignee{{else}}member{{/if}}" title="{{userData.profile.fullname}} ({{userData.username}}) {{_ memberType}}")
     if userData.profile.avatarUrl
       img.avatar.avatar-image(src="{{userData.profile.avatarUrl}}")
     else
@@ -72,9 +72,10 @@ template(name="cardMemberPopup")
         h3= user.profile.fullname
         p.quiet @{{ user.username }}
     ul.pop-over-list
-      if currentUser.isNotCommentOnly
-        if currentUser.isNotWorker
-          li: a.js-remove-member {{_ 'remove-member-from-card'}}
+      unless noRemove
+        if currentUser.isNotCommentOnly
+          if currentUser.isNotWorker
+            li: a.js-remove-member {{_ 'remove-member-from-card'}}
 
       if $eq currentUser._id user._id
         with currentUser

+ 4 - 4
client/components/users/userAvatar.js

@@ -1,3 +1,7 @@
+import Cards from '/models/cards';
+import Avatars from '/models/avatars';
+import Users from '/models/users';
+
 Template.userAvatar.helpers({
   userData() {
     // We need to handle a special case for the search results provided by the
@@ -30,10 +34,6 @@ Template.userAvatar.helpers({
   },
 });
 
-Template.userAvatar.events({
-  'click .js-change-avatar': Popup.open('changeAvatar'),
-});
-
 Template.userAvatarInitials.helpers({
   initials() {
     const user = Users.findOne(this.userId);

+ 0 - 4
client/components/users/userHeader.jade

@@ -25,10 +25,6 @@ template(name="memberMenuPopup")
         a.js-global-search(href="{{pathFor 'global-search'}}")
           i.fa.fa-search
           | {{_ 'globalSearch-title'}}
-      li
-        a.js-broken-cards(href="{{pathFor 'broken-cards'}}")
-          i.fa.fa-chain-broken
-          | {{_ 'broken-cards'}}
       li
         a(href="{{pathFor 'home'}}")
           span.fa.fa-home

+ 4 - 1
client/lib/cardSearch.js

@@ -32,8 +32,11 @@ export class CardSearchPagedComponent extends BlazeComponent {
         that.searching.set(false);
         that.hasResults.set(false);
         that.serverError.set(true);
+        // eslint-disable-next-line no-console
         console.log('Error.reason:', error.reason);
+        // eslint-disable-next-line no-console
         console.log('Error.message:', error.message);
+        // eslint-disable-next-line no-console
         console.log('Error.stack:', error.stack);
       },
     };
@@ -72,7 +75,7 @@ export class CardSearchPagedComponent extends BlazeComponent {
     if (this.queryErrors.length) {
       // console.log('queryErrors:', this.queryErrorMessages());
       this.hasQueryErrors.set(true);
-      return null;
+      // return null;
     }
 
     if (cards) {

+ 17 - 17
client/lib/datepicker.js

@@ -1,3 +1,5 @@
+import moment from 'moment';
+
 // Helper function to replace HH with H for 24 hours format, because H allows also single-digit hours
 function adjustedTimeFormat() {
   return moment
@@ -6,17 +8,17 @@ function adjustedTimeFormat() {
     .replace(/HH/i, 'H');
 }
 
-DatePicker = BlazeComponent.extendComponent({
+export class DatePicker extends BlazeComponent {
   template() {
     return 'datepicker';
-  },
+  }
 
   onCreated(defaultTime = '1970-01-01 08:00:00') {
     this.error = new ReactiveVar('');
     this.card = this.data();
     this.date = new ReactiveVar(moment.invalid());
     this.defaultTime = defaultTime;
-  },
+  }
 
   startDayOfWeek() {
     const currentUser = Meteor.user();
@@ -25,7 +27,7 @@ DatePicker = BlazeComponent.extendComponent({
     } else {
       return 1;
     }
-  },
+  }
 
   onRendered() {
     const $picker = this.$('.js-datepicker')
@@ -42,7 +44,7 @@ DatePicker = BlazeComponent.extendComponent({
           this.error.set('');
           const timeInput = this.find('#time');
           timeInput.focus();
-          if (!timeInput.value) {
+          if (!timeInput.value && this.defaultTime) {
             const currentHour = evt.date.getHours();
             const defaultMoment = moment(
               currentHour > 0 ? evt.date : this.defaultTime,
@@ -55,22 +57,22 @@ DatePicker = BlazeComponent.extendComponent({
     if (this.date.get().isValid()) {
       $picker.datepicker('update', this.date.get().toDate());
     }
-  },
+  }
 
   showDate() {
     if (this.date.get().isValid()) return this.date.get().format('L');
     return '';
-  },
+  }
   showTime() {
     if (this.date.get().isValid()) return this.date.get().format('LT');
     return '';
-  },
+  }
   dateFormat() {
     return moment.localeData().longDateFormat('L');
-  },
+  }
   timeFormat() {
     return moment.localeData().longDateFormat('LT');
-  },
+  }
 
   events() {
     return [
@@ -106,7 +108,7 @@ DatePicker = BlazeComponent.extendComponent({
           const dateString = `${evt.target.date.value} ${time}`;
           const newCompleteDate = moment(
             dateString,
-            'L ' + adjustedTimeFormat(),
+            `L ${adjustedTimeFormat()}`,
             true,
           );
           if (!newTime.isValid()) {
@@ -120,10 +122,8 @@ DatePicker = BlazeComponent.extendComponent({
           if (newCompleteDate.isValid()) {
             this._storeDate(newCompleteDate.toDate());
             Popup.close();
-          } else {
-            if (!this.error) {
-              this.error.set('invalid');
-            }
+          } else if (!this.error) {
+            this.error.set('invalid');
           }
         },
         'click .js-delete-date'(evt) {
@@ -133,5 +133,5 @@ DatePicker = BlazeComponent.extendComponent({
         },
       },
     ];
-  },
-});
+  }
+}

+ 51 - 0
config/const.js

@@ -0,0 +1,51 @@
+export const ALLOWED_BOARD_COLORS = [
+  'belize',
+  'nephritis',
+  'pomegranate',
+  'pumpkin',
+  'wisteria',
+  'moderatepink',
+  'strongcyan',
+  'limegreen',
+  'midnight',
+  'dark',
+  'relax',
+  'corteza',
+  'clearblue',
+  'natural',
+  'modern',
+  'moderndark',
+];
+export const ALLOWED_COLORS = [
+  'white',
+  'green',
+  'yellow',
+  'orange',
+  'red',
+  'purple',
+  'blue',
+  'sky',
+  'lime',
+  'pink',
+  'black',
+  'silver',
+  'peachpuff',
+  'crimson',
+  'plum',
+  'darkgreen',
+  'slateblue',
+  'magenta',
+  'gold',
+  'navy',
+  'gray',
+  'saddlebrown',
+  'paleturquoise',
+  'mistyrose',
+  'indigo',
+];
+export const TYPE_BOARD = 'board';
+export const TYPE_CARD = 'cardType-card';
+export const TYPE_LINKED_BOARD = 'cardType-linkedBoard';
+export const TYPE_LINKED_CARD = 'cardType-linkedCard';
+export const TYPE_TEMPLATE_BOARD = 'template-board';
+export const TYPE_TEMPLATE_CONTAINER = 'template-container';

+ 3 - 0
config/query-classes.js

@@ -3,6 +3,7 @@ import {
   OPERATOR_BOARD,
   OPERATOR_COMMENT,
   OPERATOR_CREATED_AT,
+  OPERATOR_CREATOR,
   OPERATOR_DUE,
   OPERATOR_HAS,
   OPERATOR_LABEL,
@@ -107,6 +108,7 @@ export class QueryErrors {
     [OPERATOR_USER, 'user-username-not-found'],
     [OPERATOR_ASSIGNEE, 'user-username-not-found'],
     [OPERATOR_MEMBER, 'user-username-not-found'],
+    [OPERATOR_CREATOR, 'user-username-not-found'],
   ];
 
   constructor() {
@@ -238,6 +240,7 @@ export class Query {
       'operator-member': OPERATOR_MEMBER,
       'operator-member-abbrev': OPERATOR_MEMBER,
       'operator-assignee': OPERATOR_ASSIGNEE,
+      'operator-creator': OPERATOR_CREATOR,
       'operator-assignee-abbrev': OPERATOR_ASSIGNEE,
       'operator-status': OPERATOR_STATUS,
       'operator-due': OPERATOR_DUE,

+ 22 - 0
config/router.js

@@ -291,6 +291,28 @@ FlowRouter.route('/people', {
   },
 });
 
+FlowRouter.route('/admin-reports', {
+  name: 'admin-reports',
+  triggersEnter: [
+    AccountsTemplates.ensureSignedIn,
+    () => {
+      Session.set('currentBoard', null);
+      Session.set('currentList', null);
+      Session.set('currentCard', null);
+
+      Filter.reset();
+      Session.set('sortBy', '');
+      EscapeActions.executeAll();
+    },
+  ],
+  action() {
+    BlazeLayout.render('defaultLayout', {
+      headerBar: 'settingHeaderBar',
+      content: 'adminReports',
+    });
+  },
+});
+
 FlowRouter.notFound = {
   action() {
     BlazeLayout.render('defaultLayout', { content: 'notFound' });

+ 3 - 2
config/search-const.js

@@ -1,14 +1,15 @@
 export const DEFAULT_LIMIT = 25;
-export const OPERATOR_ASSIGNEE = 'assignee';
+export const OPERATOR_ASSIGNEE = 'assignees';
 export const OPERATOR_COMMENT = 'comment';
 export const OPERATOR_CREATED_AT = 'createdAt';
+export const OPERATOR_CREATOR = 'userId';
 export const OPERATOR_DUE = 'dueAt';
 export const OPERATOR_BOARD = 'board';
 export const OPERATOR_HAS = 'has';
 export const OPERATOR_LABEL = 'label';
 export const OPERATOR_LIMIT = 'limit';
 export const OPERATOR_LIST = 'list';
-export const OPERATOR_MEMBER = 'member';
+export const OPERATOR_MEMBER = 'members';
 export const OPERATOR_MODIFIED_AT = 'modifiedAt';
 export const OPERATOR_SORT = 'sort';
 export const OPERATOR_STATUS = 'status';

+ 7 - 1
i18n/en.i18n.json

@@ -901,6 +901,7 @@
   "operator-member-abbrev": "m",
   "operator-assignee": "assignee",
   "operator-assignee-abbrev": "a",
+  "operator-creator": "creator",
   "operator-status": "status",
   "operator-due": "due",
   "operator-created": "created",
@@ -952,6 +953,7 @@
   "globalSearch-instructions-operator-at": "`__operator_user_abbrev__username` - shorthand for `user:<username>`",
   "globalSearch-instructions-operator-member": "`__operator_member__:<username>` - cards where *<username>* is a *member*",
   "globalSearch-instructions-operator-assignee": "`__operator_assignee__:<username>` - cards where *<username>* is an *assignee*",
+  "globalSearch-instructions-operator-creator": "`__operator_creator__:<username>` - cards where *<username>* is the card's creator",
   "globalSearch-instructions-operator-due": "`__operator_due__:<n>` - cards which are due up to *<n>* days from now.  `__operator_due__:__predicate_overdue__ lists all cards past their due date.",
   "globalSearch-instructions-operator-created": "`__operator_created__:<n>` - cards which were created *<n>* days ago or less",
   "globalSearch-instructions-operator-modified": "`__operator_modified__:<n>` - cards which were modified *<n>* days ago or less",
@@ -992,5 +994,9 @@
   "custom-field-stringtemplate": "String Template",
   "custom-field-stringtemplate-format": "Format (use %{value} as placeholder)",
   "custom-field-stringtemplate-separator": "Separator (use &#32; or &nbsp; for a space)",
-  "custom-field-stringtemplate-item-placeholder": "Press enter to add more items"
+  "custom-field-stringtemplate-item-placeholder": "Press enter to add more items",
+  "creator": "Creator",
+  "filesReportTitle": "Files Report",
+  "orphanedFilesReportTitle": "Orphaned Files Report",
+  "reports": "Reports"
 }

+ 5 - 0
models/attachments.js

@@ -1,3 +1,8 @@
+export const AttachmentStorage = new Mongo.Collection(
+  'cfs_gridfs.attachments.files',
+);
+export const AvatarStorage = new Mongo.Collection('cfs_gridfs.avatars.files');
+
 const localFSStore = process.env.ATTACHMENTS_STORE_PATH;
 const storeName = 'attachments';
 const defaultStoreOptions = {

+ 40 - 59
models/boards.js

@@ -1,3 +1,11 @@
+import {
+  ALLOWED_BOARD_COLORS,
+  ALLOWED_COLORS,
+  TYPE_BOARD,
+  TYPE_TEMPLATE_BOARD,
+  TYPE_TEMPLATE_CONTAINER,
+} from '/config/const';
+
 const escapeForRegex = require('escape-string-regexp');
 Boards = new Mongo.Collection('boards');
 
@@ -144,32 +152,7 @@ Boards.attachSchema(
        * `saddlebrown`, `paleturquoise`, `mistyrose`, `indigo`
        */
       type: String,
-      allowedValues: [
-        'green',
-        'yellow',
-        'orange',
-        'red',
-        'purple',
-        'blue',
-        'sky',
-        'lime',
-        'pink',
-        'black',
-        'silver',
-        'peachpuff',
-        'crimson',
-        'plum',
-        'darkgreen',
-        'slateblue',
-        'magenta',
-        'gold',
-        'navy',
-        'gray',
-        'saddlebrown',
-        'paleturquoise',
-        'mistyrose',
-        'indigo',
-      ],
+      allowedValues: ALLOWED_COLORS,
     },
     // XXX We might want to maintain more informations under the member sub-
     // documents like de-normalized meta-data (the date the member joined the
@@ -246,28 +229,11 @@ Boards.attachSchema(
        * The color of the board.
        */
       type: String,
-      allowedValues: [
-        'belize',
-        'nephritis',
-        'pomegranate',
-        'pumpkin',
-        'wisteria',
-        'moderatepink',
-        'strongcyan',
-        'limegreen',
-        'midnight',
-        'dark',
-        'relax',
-        'corteza',
-        'clearblue',
-        'natural',
-        'modern',
-        'moderndark',
-      ],
+      allowedValues: ALLOWED_BOARD_COLORS,
       // eslint-disable-next-line consistent-return
       autoValue() {
         if (this.isInsert && !this.isSet) {
-          return Boards.simpleSchema()._schema.color.allowedValues[0];
+          return ALLOWED_BOARD_COLORS[0];
         }
       },
     },
@@ -372,6 +338,14 @@ Boards.attachSchema(
       defaultValue: true,
     },
 
+    allowsCreator: {
+      /**
+       * Does the board allow creator?
+       */
+      type: Boolean,
+      defaultValue: true,
+    },
+
     allowsAssignee: {
       /**
        * Does the board allows assignee?
@@ -497,9 +471,11 @@ Boards.attachSchema(
     type: {
       /**
        * The type of board
+       * possible values: board, template-board, template-container
        */
       type: String,
-      defaultValue: 'board',
+      defaultValue: TYPE_BOARD,
+      allowedValues: [TYPE_BOARD, TYPE_TEMPLATE_BOARD, TYPE_TEMPLATE_CONTAINER],
     },
     sort: {
       /**
@@ -1187,6 +1163,10 @@ Boards.mutations({
     return { $set: { allowsSubtasks } };
   },
 
+  setAllowsCreator(allowsCreator) {
+    return { $set: { allowsCreator } };
+  },
+
   setAllowsMembers(allowsMembers) {
     return { $set: { allowsMembers } };
   },
@@ -1318,8 +1298,11 @@ Boards.userBoards = (userId, archived = false, selector = {}) => {
   if (typeof archived === 'boolean') {
     selector.archived = archived;
   }
-  selector.$or = [{ permission: 'public' }];
+  if (!selector.type) {
+    selector.type = 'board';
+  }
 
+  selector.$or = [{ permission: 'public' }];
   if (userId) {
     selector.$or.push({ members: { $elemMatch: { userId, isActive: true } } });
   }
@@ -1341,7 +1324,7 @@ Boards.colorMap = () => {
 };
 
 Boards.labelColors = () => {
-  return _.clone(Boards.simpleSchema()._schema['labels.$.color'].allowedValues);
+  return ALLOWED_COLORS;
 };
 
 if (Meteor.isServer) {
@@ -1423,17 +1406,15 @@ if (Meteor.isServer) {
     },
     myLabelNames() {
       let names = [];
-      Boards.userBoards(Meteor.userId(), false, { type: 'board' }).forEach(
-        board => {
-          names = names.concat(
-            board.labels
-              .filter(label => !!label.name)
-              .map(label => {
-                return label.name;
-              }),
-          );
-        },
-      );
+      Boards.userBoards(Meteor.userId()).forEach(board => {
+        names = names.concat(
+          board.labels
+            .filter(label => !!label.name)
+            .map(label => {
+              return label.name;
+            }),
+        );
+      });
       return _.uniq(names).sort();
     },
     myBoardNames() {

+ 10 - 28
models/cards.js

@@ -1,3 +1,10 @@
+import {
+  ALLOWED_COLORS,
+  TYPE_CARD,
+  TYPE_LINKED_BOARD,
+  TYPE_LINKED_CARD,
+} from '../config/const';
+
 Cards = new Mongo.Collection('cards');
 
 // XXX To improve pub/sub performances a card document should include a
@@ -77,33 +84,7 @@ Cards.attachSchema(
     color: {
       type: String,
       optional: true,
-      allowedValues: [
-        'white',
-        'green',
-        'yellow',
-        'orange',
-        'red',
-        'purple',
-        'blue',
-        'sky',
-        'lime',
-        'pink',
-        'black',
-        'silver',
-        'peachpuff',
-        'crimson',
-        'plum',
-        'darkgreen',
-        'slateblue',
-        'magenta',
-        'gold',
-        'navy',
-        'gray',
-        'saddlebrown',
-        'paleturquoise',
-        'mistyrose',
-        'indigo',
-      ],
+      allowedValues: ALLOWED_COLORS,
     },
     createdAt: {
       /**
@@ -305,7 +286,8 @@ Cards.attachSchema(
        * type of the card
        */
       type: String,
-      defaultValue: 'cardType-card',
+      defaultValue: TYPE_CARD,
+      allowedValues: [TYPE_CARD, TYPE_LINKED_CARD, TYPE_LINKED_BOARD],
     },
     linkedId: {
       /**

+ 3 - 26
models/lists.js

@@ -1,3 +1,5 @@
+import { ALLOWED_COLORS } from '/config/const';
+
 Lists = new Mongo.Collection('lists');
 
 /**
@@ -144,32 +146,7 @@ Lists.attachSchema(
       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',
-      ],
+      allowedValues: ALLOWED_COLORS,
     },
     type: {
       /**

+ 9 - 0
models/rules.js

@@ -62,6 +62,15 @@ Rules.helpers({
   getTrigger() {
     return Triggers.findOne({ _id: this.triggerId });
   },
+  board() {
+    return Boards.findOne({ _id: this.boardId });
+  },
+  trigger() {
+    return Triggers.findOne({ _id: this.triggerId });
+  },
+  action() {
+    return Actions.findOne({ _id: this.actionId });
+  },
 });
 
 Rules.allow({

+ 3 - 26
models/swimlanes.js

@@ -1,3 +1,5 @@
+import { ALLOWED_COLORS } from '/config/const';
+
 Swimlanes = new Mongo.Collection('swimlanes');
 
 /**
@@ -68,32 +70,7 @@ Swimlanes.attachSchema(
       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',
-      ],
+      allowedValues: ALLOWED_COLORS,
     },
     updatedAt: {
       /**

+ 60 - 0
server/publications/attachments.js

@@ -0,0 +1,60 @@
+import Attachments, { AttachmentStorage } from '/models/attachments';
+import { ObjectID } from 'bson';
+
+Meteor.publish('attachmentsList', function() {
+  // eslint-disable-next-line no-console
+  // console.log('attachments:', AttachmentStorage.find());
+  const files = AttachmentStorage.find(
+    {},
+    {
+      fields: {
+        _id: 1,
+        filename: 1,
+        md5: 1,
+        length: 1,
+        contentType: 1,
+        metadata: 1,
+      },
+      sort: {
+        filename: 1,
+      },
+      limit: 250,
+    },
+  );
+  const attIds = [];
+  files.forEach(file => {
+    attIds.push(file._id._str);
+  });
+
+  return [
+    files,
+    Attachments.find({ 'copies.attachments.key': { $in: attIds } }),
+  ];
+});
+
+Meteor.publish('orphanedAttachments', function() {
+  let keys = [];
+  Attachments.find({}, { fields: { copies: 1 } }).forEach(att => {
+    keys.push(new ObjectID(att.copies.attachments.key));
+  });
+  keys.sort();
+  keys = _.uniq(keys, true);
+
+  return AttachmentStorage.find(
+    { _id: { $nin: keys } },
+    {
+      fields: {
+        _id: 1,
+        filename: 1,
+        md5: 1,
+        length: 1,
+        contentType: 1,
+        metadata: 1,
+      },
+      sort: {
+        filename: 1,
+      },
+      limit: 250,
+    },
+  );
+});

+ 42 - 45
server/publications/cards.js

@@ -15,12 +15,15 @@ import {
   OPERATOR_ASSIGNEE,
   OPERATOR_BOARD,
   OPERATOR_COMMENT,
+  OPERATOR_CREATED_AT,
+  OPERATOR_CREATOR,
   OPERATOR_DUE,
   OPERATOR_HAS,
   OPERATOR_LABEL,
   OPERATOR_LIMIT,
   OPERATOR_LIST,
   OPERATOR_MEMBER,
+  OPERATOR_MODIFIED_AT,
   OPERATOR_SORT,
   OPERATOR_STATUS,
   OPERATOR_SWIMLANE,
@@ -42,8 +45,8 @@ import {
   PREDICATE_PUBLIC,
   PREDICATE_START_AT,
   PREDICATE_SYSTEM,
-} from '../../config/search-const';
-import { QueryErrors, QueryParams, Query } from '../../config/query-classes';
+} from '/config/search-const';
+import { QueryErrors, QueryParams, Query } from '/config/query-classes';
 
 const escapeForRegex = require('escape-string-regexp');
 
@@ -99,7 +102,7 @@ function buildSelector(queryParams) {
   let selector = {};
 
   // eslint-disable-next-line no-console
-  // console.log('queryParams:', queryParams);
+  console.log('queryParams:', queryParams);
 
   if (queryParams.selector) {
     selector = queryParams.selector;
@@ -163,7 +166,7 @@ function buildSelector(queryParams) {
 
     if (queryParams.hasOperator(OPERATOR_BOARD)) {
       const queryBoards = [];
-      queryParams.hasOperator(OPERATOR_BOARD).forEach(query => {
+      queryParams.getPredicates(OPERATOR_BOARD).forEach(query => {
         const boards = Boards.userSearch(userId, {
           title: new RegExp(escapeForRegex(query), 'i'),
         });
@@ -240,7 +243,7 @@ function buildSelector(queryParams) {
       }
     }
 
-    [OPERATOR_DUE, 'createdAt', 'modifiedAt'].forEach(field => {
+    [OPERATOR_DUE, OPERATOR_CREATED_AT, OPERATOR_MODIFIED_AT].forEach(field => {
       if (queryParams.hasOperator(field)) {
         selector[field] = {};
         const predicate = queryParams.getPredicate(field);
@@ -251,57 +254,46 @@ function buildSelector(queryParams) {
     const queryUsers = {};
     queryUsers[OPERATOR_ASSIGNEE] = [];
     queryUsers[OPERATOR_MEMBER] = [];
+    queryUsers[OPERATOR_CREATOR] = [];
 
     if (queryParams.hasOperator(OPERATOR_USER)) {
+      const users = [];
       queryParams.getPredicates(OPERATOR_USER).forEach(username => {
         const user = Users.findOne({ username });
         if (user) {
-          queryUsers[OPERATOR_MEMBER].push(user._id);
-          queryUsers[OPERATOR_ASSIGNEE].push(user._id);
+          users.push(user._id);
         } else {
           errors.addNotFound(OPERATOR_USER, username);
         }
       });
+      if (users.length) {
+        selector.$and.push({
+          $or: [{ members: { $in: users } }, { assignees: { $in: users } }],
+        });
+      }
     }
 
-    [OPERATOR_MEMBER, OPERATOR_ASSIGNEE].forEach(key => {
+    [OPERATOR_MEMBER, OPERATOR_ASSIGNEE, OPERATOR_CREATOR].forEach(key => {
       if (queryParams.hasOperator(key)) {
-        queryParams.getPredicates(key).forEach(query => {
-          const users = Users.find({
-            username: query,
-          });
-          if (users.count()) {
-            users.forEach(user => {
-              queryUsers[key].push(user._id);
-            });
+        const users = [];
+        queryParams.getPredicates(key).forEach(username => {
+          const user = Users.findOne({ username });
+          if (user) {
+            users.push(user._id);
           } else {
-            errors.addNotFound(key, query);
+            errors.addNotFound(key, username);
           }
         });
+        if (users.length) {
+          selector[key] = { $in: users };
+        }
       }
     });
 
-    if (
-      queryUsers[OPERATOR_MEMBER].length &&
-      queryUsers[OPERATOR_ASSIGNEE].length
-    ) {
-      selector.$and.push({
-        $or: [
-          { members: { $in: queryUsers[OPERATOR_MEMBER] } },
-          { assignees: { $in: queryUsers[OPERATOR_ASSIGNEE] } },
-        ],
-      });
-    } else if (queryUsers[OPERATOR_MEMBER].length) {
-      selector.members = { $in: queryUsers[OPERATOR_MEMBER] };
-    } else if (queryUsers[OPERATOR_ASSIGNEE].length) {
-      selector.assignees = { $in: queryUsers[OPERATOR_ASSIGNEE] };
-    }
-
     if (queryParams.hasOperator(OPERATOR_LABEL)) {
+      const queryLabels = [];
       queryParams.getPredicates(OPERATOR_LABEL).forEach(label => {
-        const queryLabels = [];
-
-        let boards = Boards.userSearch(userId, {
+        let boards = Boards.userBoards(userId, null, {
           labels: { $elemMatch: { color: label.toLowerCase() } },
         });
 
@@ -325,7 +317,7 @@ function buildSelector(queryParams) {
           const reLabel = new RegExp(escapeForRegex(label), 'i');
           // eslint-disable-next-line no-console
           // console.log('reLabel:', reLabel);
-          boards = Boards.userSearch(userId, {
+          boards = Boards.userBoards(userId, null, {
             labels: { $elemMatch: { name: reLabel } },
           });
 
@@ -346,9 +338,12 @@ function buildSelector(queryParams) {
             errors.addNotFound(OPERATOR_LABEL, label);
           }
         }
-
-        selector.labelIds = { $in: _.uniq(queryLabels) };
       });
+      if (queryLabels.length) {
+        // eslint-disable-next-line no-console
+        // console.log('queryLabels:', queryLabels);
+        selector.labelIds = { $in: _.uniq(queryLabels) };
+      }
     }
 
     if (queryParams.hasOperator(OPERATOR_HAS)) {
@@ -443,9 +438,9 @@ function buildSelector(queryParams) {
   }
 
   // eslint-disable-next-line no-console
-  // console.log('selector:', selector);
+  console.log('selector:', selector);
   // eslint-disable-next-line no-console
-  // console.log('selector.$and:', selector.$and);
+  console.log('selector.$and:', selector.$and);
 
   const query = new Query();
   query.selector = selector;
@@ -488,6 +483,7 @@ function buildProjection(query) {
       modifiedAt: 1,
       labelIds: 1,
       customFields: 1,
+      userId: 1,
     },
     sort: {
       boardId: 1,
@@ -602,10 +598,8 @@ function findCards(sessionId, query) {
   // console.log('selector.$and:', query.selector.$and);
   // eslint-disable-next-line no-console
   // console.log('projection:', projection);
-  let cards;
-  if (!query.hasErrors()) {
-    cards = Cards.find(query.selector, query.projection);
-  }
+
+  const cards = Cards.find(query.selector, query.projection);
   // eslint-disable-next-line no-console
   // console.log('count:', cards.count());
 
@@ -658,6 +652,9 @@ function findCards(sessionId, query) {
       if (card.boardId) boards.push(card.boardId);
       if (card.swimlaneId) swimlanes.push(card.swimlaneId);
       if (card.listId) lists.push(card.listId);
+      if (card.userId) {
+        users.push(card.userId);
+      }
       if (card.members) {
         card.members.forEach(userId => {
           users.push(userId);

+ 25 - 0
server/publications/rules.js

@@ -1,3 +1,8 @@
+import Boards from '/models/boards';
+import Actions from '/models/actions';
+import Triggers from '/models/triggers';
+import Rules from '/models/rules';
+
 Meteor.publish('rules', ruleId => {
   check(ruleId, String);
   return Rules.find({
@@ -16,3 +21,23 @@ Meteor.publish('allTriggers', () => {
 Meteor.publish('allActions', () => {
   return Actions.find({});
 });
+
+Meteor.publish('rulesReport', () => {
+  const rules = Rules.find();
+  const actionIds = [];
+  const triggerIds = [];
+  const boardIds = [];
+
+  rules.forEach(rule => {
+    actionIds.push(rule.actionId);
+    triggerIds.push(rule.triggerId);
+    boardIds.push(rule.boardId);
+  });
+
+  return [
+    rules,
+    Actions.find({ _id: { $in: actionIds } }),
+    Triggers.find({ _id: { $in: triggerIds } }),
+    Boards.find({ _id: { $in: boardIds } }, { fields: { title: 1 } }),
+  ];
+});