diff --git a/.meteor/packages b/.meteor/packages index 92234d4e6..ca31d56ea 100644 --- a/.meteor/packages +++ b/.meteor/packages @@ -16,7 +16,7 @@ es5-shim@4.8.0 # Collections aldeed:collection2 -cottz:publish-relations +reywood:publish-composite dburles:collection-helpers idmontie:migrations mongo@1.16.8 diff --git a/.meteor/versions b/.meteor/versions index 3487f7653..a1b41eef9 100644 --- a/.meteor/versions +++ b/.meteor/versions @@ -23,7 +23,6 @@ check@1.4.1 coffeescript@2.7.0 coffeescript-compiler@2.4.1 communitypackages:picker@1.1.1 -cottz:publish-relations@2.0.8 dburles:collection-helpers@1.1.0 ddp@1.4.1 ddp-client@2.6.2 @@ -116,6 +115,7 @@ reactive-dict@1.3.1 reactive-var@1.0.12 reload@1.3.1 retry@1.1.0 +reywood:publish-composite@1.9.0 routepolicy@1.1.1 service-configuration@1.3.4 session@1.2.1 diff --git a/server/publications/boards.js b/server/publications/boards.js index 54db5c23d..c070949ba 100644 --- a/server/publications/boards.js +++ b/server/publications/boards.js @@ -3,12 +3,13 @@ // 1. that the user is a member of // 2. the user has starred import { ReactiveCache } from '/imports/reactiveCache'; +import { publishComposite } from 'meteor/reywood:publish-composite'; import Users from "../../models/users"; import Org from "../../models/org"; import Team from "../../models/team"; import Attachments from '../../models/attachments'; -Meteor.publishRelations('boards', function() { +publishComposite('boards', function() { const userId = this.userId; // Ensure that the user is connected. If it is not, we need to return an empty // array to tell the client to remove the previously published docs. @@ -16,95 +17,61 @@ Meteor.publishRelations('boards', function() { return []; } - // Defensive programming to verify that starredBoards has the expected - // format -- since the field is in the `profile` a user can modify it. - // const { starredBoards = [] } = (ReactiveCache.getUser(userId) || {}).profile || {}; - // check(starredBoards, [String]); - - // let currUser = ReactiveCache.getUser(userId); - // let orgIdsUserBelongs = currUser!== 'undefined' && currUser.teams !== 'undefined' ? currUser.orgIdsUserBelongs() : ''; - // let teamIdsUserBelongs = currUser!== 'undefined' && currUser.teams !== 'undefined' ? currUser.teamIdsUserBelongs() : ''; - // let orgsIds = []; - // let teamsIds = []; - // if(orgIdsUserBelongs && orgIdsUserBelongs != ''){ - // orgsIds = orgIdsUserBelongs.split(','); - // } - // if(teamIdsUserBelongs && teamIdsUserBelongs != ''){ - // teamsIds = teamIdsUserBelongs.split(','); - // } - this.cursor(ReactiveCache.getBoards( - { - archived: false, - _id: { $in: Boards.userBoardIds(userId, false) }, - // $or: [ - // { - // // _id: { $in: starredBoards }, // Commented out, to get a list of all public boards - // permission: 'public', - // }, - // { members: { $elemMatch: { userId, isActive: true } } }, - // {'orgs.orgId': {$in : orgsIds}}, - // {'teams.teamId': {$in : teamsIds}}, - // ], + return { + find() { + return ReactiveCache.getBoards( + { + archived: false, + _id: { $in: Boards.userBoardIds(userId, false) }, + }, + { + sort: { sort: 1 /* boards default sorting */ }, + }, + true, + ); }, - { - sort: { sort: 1 /* boards default sorting */ }, - }, - true, - ), - function(boardId, board) { - this.cursor( - ReactiveCache.getLists( - { boardId, archived: false }, - { fields: + children: [ + { + find(board) { + // Publish lists with extended fields for proper sync + // Including swimlaneId, modifiedAt, and _updatedAt for list order changes + return ReactiveCache.getLists( + { boardId: board._id, archived: false }, { - _id: 1, - title: 1, - boardId: 1, - archived: 1, - sort: 1 - } - }, - true, - ) - ); - - // Publish list order changes immediately - // Include swimlaneId and modifiedAt for proper sync - this.cursor( - ReactiveCache.getLists( - { boardId, archived: false }, - { fields: - { - _id: 1, - title: 1, - boardId: 1, - swimlaneId: 1, - archived: 1, - sort: 1, - modifiedAt: 1, - _updatedAt: 1, // Hidden field to trigger updates - } - }, - true, - ) - ); - this.cursor( - ReactiveCache.getCards( - { boardId, archived: false }, - { fields: { - _id: 1, - boardId: 1, - listId: 1, - archived: 1, - sort: 1 - }}, - true, - ) - ); - } - ); - const ret = this.ready(); - return ret; + fields: { + _id: 1, + title: 1, + boardId: 1, + swimlaneId: 1, + archived: 1, + sort: 1, + modifiedAt: 1, + _updatedAt: 1, // Hidden field to trigger updates + } + }, + true, + ); + } + }, + { + find(board) { + return ReactiveCache.getCards( + { boardId: board._id, archived: false }, + { + fields: { + _id: 1, + boardId: 1, + listId: 1, + archived: 1, + sort: 1 + } + }, + true, + ); + } + } + ] + }; }); Meteor.publish('boardsReport', function() { @@ -203,183 +170,232 @@ Meteor.publish('archivedBoards', function() { // If isArchived = false, this will only return board elements which are not archived. // If isArchived = true, this will only return board elements which are archived. -Meteor.publishRelations('board', function(boardId, isArchived) { - this.unblock(); +publishComposite('board', function(boardId, isArchived) { check(boardId, String); check(isArchived, Boolean); + const thisUserId = this.userId; const $or = [{ permission: 'public' }]; - let currUser = (!Match.test(thisUserId, String) || !thisUserId) ? 'undefined' : ReactiveCache.getUser(thisUserId); - let orgIdsUserBelongs = currUser!== 'undefined' && currUser.teams !== 'undefined' ? currUser.orgIdsUserBelongs() : ''; - let teamIdsUserBelongs = currUser!== 'undefined' && currUser.teams !== 'undefined' ? currUser.teamIdsUserBelongs() : ''; + + let currUser = (!Match.test(thisUserId, String) || !thisUserId) ? 'undefined' : ReactiveCache.getUser(thisUserId); + let orgIdsUserBelongs = currUser !== 'undefined' && currUser.teams !== 'undefined' ? currUser.orgIdsUserBelongs() : ''; + let teamIdsUserBelongs = currUser !== 'undefined' && currUser.teams !== 'undefined' ? currUser.teamIdsUserBelongs() : ''; let orgsIds = []; let teamsIds = []; - if(orgIdsUserBelongs && orgIdsUserBelongs != ''){ + + if (orgIdsUserBelongs && orgIdsUserBelongs != '') { orgsIds = orgIdsUserBelongs.split(','); } - if(teamIdsUserBelongs && teamIdsUserBelongs != ''){ + if (teamIdsUserBelongs && teamIdsUserBelongs != '') { teamsIds = teamIdsUserBelongs.split(','); } if (thisUserId) { - $or.push({members: { $elemMatch: { userId: thisUserId, isActive: true } }}); - $or.push({'orgs.orgId': {$in : orgsIds}}); - $or.push({'teams.teamId': {$in : teamsIds}}); + $or.push({ members: { $elemMatch: { userId: thisUserId, isActive: true } } }); + $or.push({ 'orgs.orgId': { $in: orgsIds } }); + $or.push({ 'teams.teamId': { $in: teamsIds } }); } - this.cursor( - ReactiveCache.getBoards( - { - _id: boardId, - archived: false, - // If the board is not public the user has to be a member of it to see - // it. - $or, - // Sort required to ensure oplog usage - }, - { limit: 1, sort: { sort: 1 /* boards default sorting */ } }, - true, - ), - function(boardId, board) { - this.cursor(ReactiveCache.getLists({ boardId, archived: isArchived }, {}, true)); - this.cursor(ReactiveCache.getSwimlanes({ boardId, archived: isArchived }, {}, true)); - this.cursor(ReactiveCache.getIntegrations({ boardId }, {}, true)); - this.cursor(ReactiveCache.getCardCommentReactions({ boardId }, {}, true)); - this.cursor( - ReactiveCache.getCustomFields( - { boardIds: { $in: [boardId] } }, - { sort: { name: 1 } }, - true, - ), + return { + find() { + return ReactiveCache.getBoards( + { + _id: boardId, + archived: false, + // If the board is not public the user has to be a member of it to see it. + $or, + }, + { limit: 1, sort: { sort: 1 /* boards default sorting */ } }, + true, ); + }, + children: [ + // Lists + { + find(board) { + return ReactiveCache.getLists({ boardId: board._id, archived: isArchived }, {}, true); + } + }, + // Swimlanes + { + find(board) { + return ReactiveCache.getSwimlanes({ boardId: board._id, archived: isArchived }, {}, true); + } + }, + // Integrations + { + find(board) { + return ReactiveCache.getIntegrations({ boardId: board._id }, {}, true); + } + }, + // CardCommentReactions at board level + { + find(board) { + return ReactiveCache.getCardCommentReactions({ boardId: board._id }, {}, true); + } + }, + // CustomFields + { + find(board) { + return ReactiveCache.getCustomFields( + { boardIds: { $in: [board._id] } }, + { sort: { name: 1 } }, + true, + ); + } + }, + // Cards and their related data + { + find(board) { + const cardSelector = { + boardId: { $in: [board._id, board.subtasksDefaultBoardId] }, + archived: isArchived, + }; - // Cards and cards comments - // XXX Originally we were publishing the card documents as a child of the - // list publication defined above using the following selector `{ listId: - // list._id }`. But it was causing a race condition in publish-composite, - // that I documented here: - // - // https://github.com/englue/meteor-publish-composite/issues/29 - // - // cottz:publish had a similar problem: - // - // https://github.com/Goluis/cottz-publish/issues/4 - // - // The current state of relational publishing in meteor is a bit sad, - // there are a lot of various packages, with various APIs, some of them - // are unmaintained. Fortunately this is something that will be fixed by - // meteor-core at some point: - // - // https://trello.com/c/BGvIwkEa/48-easy-joins-in-subscriptions - // - // And in the meantime our code below works pretty well -- it's not even a - // hack! + // Check if current user has assigned-only permissions + if (thisUserId && board.members) { + const member = _.findWhere(board.members, { userId: thisUserId, isActive: true }); + if (member && (member.isNormalAssignedOnly || member.isCommentAssignedOnly || member.isReadAssignedOnly)) { + // User with assigned-only permissions should only see cards assigned to them + cardSelector.assignees = { $in: [thisUserId] }; + } + } - // Gather queries and send in bulk - const cardComments = this.join(CardComments); - cardComments.selector = _ids => ({ cardId: _ids }); - const cardCommentsLinkedBoard = this.join(CardComments); - cardCommentsLinkedBoard.selector = _ids => ({ boardId: _ids }); - const cardCommentReactions = this.join(CardCommentReactions); - cardCommentReactions.selector = _ids => ({ cardId: _ids }); - const attachments = this.join(Attachments.collection); - attachments.selector = _ids => ({ 'meta.cardId': _ids }); - const checklists = this.join(Checklists); - checklists.selector = _ids => ({ cardId: _ids }); - const checklistItems = this.join(ChecklistItems); - checklistItems.selector = _ids => ({ cardId: _ids }); - const parentCards = this.join(Cards); - parentCards.selector = _ids => ({ parentId: _ids }); - const boards = this.join(Boards); - const subCards = this.join(Cards); - subCards.selector = _ids => ({ _id: _ids, archived: isArchived }); - const linkedBoardCards = this.join(Cards); - linkedBoardCards.selector = _ids => ({ boardId: _ids }); + return ReactiveCache.getCards(cardSelector, {}, true); + }, + children: [ + // CardComments for each card + { + find(card) { + return CardComments.find({ cardId: card._id }); + } + }, + // CardCommentReactions for each card + { + find(card) { + return CardCommentReactions.find({ cardId: card._id }); + } + }, + // Attachments for each card + { + find(card) { + return Attachments.collection.find({ 'meta.cardId': card._id }); + } + }, + // Checklists for each card + { + find(card) { + return Checklists.find({ cardId: card._id }); + } + }, + // ChecklistItems for each card + { + find(card) { + return ChecklistItems.find({ cardId: card._id }); + } + }, + // Parent cards (cards that have this card as parentId) + { + find(card) { + return Cards.find({ parentId: card._id }); + } + }, + // Linked card data (for cardType-linkedCard) + { + find(card) { + if (card.type === 'cardType-linkedCard' && card.linkedId) { + return Cards.find({ _id: card.linkedId, archived: isArchived }); + } + return null; + }, + children: [ + // Comments for linked card + { + find(linkedCard) { + return CardComments.find({ cardId: linkedCard._id }); + } + }, + // Attachments for linked card + { + find(linkedCard) { + return Attachments.collection.find({ 'meta.cardId': linkedCard._id }); + } + }, + // Checklists for linked card + { + find(linkedCard) { + return Checklists.find({ cardId: linkedCard._id }); + } + }, + // ChecklistItems for linked card + { + find(linkedCard) { + return ChecklistItems.find({ cardId: linkedCard._id }); + } + } + ] + }, + // Linked board (for cardType-linkedBoard) + { + find(card) { + if (card.type === 'cardType-linkedBoard' && card.linkedId) { + return Boards.find({ _id: card.linkedId }); + } + return null; + } + }, + // Cards in linked board (for cardType-linkedBoard) + { + find(card) { + if (card.type === 'cardType-linkedBoard' && card.linkedId) { + return Cards.find({ boardId: card.linkedId }); + } + return null; + } + }, + // Comments for linked board cards (for cardType-linkedBoard) + { + find(card) { + if (card.type === 'cardType-linkedBoard' && card.linkedId) { + return CardComments.find({ boardId: card.linkedId }); + } + return null; + } + } + ] + }, + // Board members/Users + { + find(board) { + if (board.members) { + // Board members. This publication also includes former board members that + // aren't members anymore but may have some activities attached to them in + // the history. + const memberIds = _.pluck(board.members, 'userId'); - // Build card selector based on user's permissions - const cardSelector = { - boardId: { $in: [boardId, board.subtasksDefaultBoardId] }, - archived: isArchived, - }; - - // Check if current user has assigned-only permissions - if (thisUserId && board.members) { - const member = _.findWhere(board.members, { userId: thisUserId, isActive: true }); - if (member && (member.isNormalAssignedOnly || member.isCommentAssignedOnly || member.isReadAssignedOnly)) { - // User with assigned-only permissions should only see cards assigned to them - cardSelector.assignees = { $in: [thisUserId] }; + // We omit the current user because the client should already have that data, + // and sending it triggers a subtle bug: + // https://github.com/wefork/wekan/issues/15 + return ReactiveCache.getUsers( + { + _id: { $in: _.without(memberIds, thisUserId) }, + }, + { + fields: { + username: 1, + 'profile.fullname': 1, + 'profile.avatarUrl': 1, + 'profile.initials': 1, + }, + }, + true, + ); + } + return null; } } - - this.cursor( - ReactiveCache.getCards(cardSelector, {}, true), - function(cardId, card) { - if (card.type === 'cardType-linkedCard') { - const impCardId = card.linkedId; - subCards.push(impCardId); // GitHub issue #2688 and #2693 - cardComments.push(impCardId); - attachments.push(impCardId); - checklists.push(impCardId); - checklistItems.push(impCardId); - } else if (card.type === 'cardType-linkedBoard') { - boards.push(card.linkedId); - linkedBoardCards.push(card.linkedId); - cardCommentsLinkedBoard.push(card.linkedId); - } - cardComments.push(cardId); - attachments.push(cardId); - checklists.push(cardId); - checklistItems.push(cardId); - parentCards.push(cardId); - cardCommentReactions.push(cardId); - }, - ); - - // Send bulk queries for all found ids - subCards.send(); - cardComments.send(); - cardCommentReactions.send(); - attachments.send(); - checklists.send(); - checklistItems.send(); - boards.send(); - parentCards.send(); - linkedBoardCards.send(); - cardCommentsLinkedBoard.send(); - - if (board.members) { - // Board members. This publication also includes former board members that - // aren't members anymore but may have some activities attached to them in - // the history. - const memberIds = _.pluck(board.members, 'userId'); - - // We omit the current user because the client should already have that data, - // and sending it triggers a subtle bug: - // https://github.com/wefork/wekan/issues/15 - this.cursor( - ReactiveCache.getUsers( - { - _id: { $in: _.without(memberIds, thisUserId) }, - }, - { - fields: { - username: 1, - 'profile.fullname': 1, - 'profile.avatarUrl': 1, - 'profile.initials': 1, - }, - }, - true, - ), - ); - - //this.cursor(presences.find({ userId: { $in: memberIds } })); - } - }, - ); - - const ret = this.ready(); - return ret; + ] + }; }); Meteor.methods({ diff --git a/server/publications/cards.js b/server/publications/cards.js index 1d398ccb4..e9d8fcf6e 100644 --- a/server/publications/cards.js +++ b/server/publications/cards.js @@ -1,4 +1,5 @@ import { ReactiveCache } from '/imports/reactiveCache'; +import { publishComposite } from 'meteor/reywood:publish-composite'; import escapeForRegex from 'escape-string-regexp'; import Users from '../../models/users'; import { @@ -110,45 +111,49 @@ Meteor.publish('card', cardId => { /** publish all data which is necessary to display card details as popup * @returns array of cursors */ -Meteor.publishRelations('popupCardData', function(cardId) { +publishComposite('popupCardData', function(cardId) { check(cardId, String); - + const userId = this.userId; const card = ReactiveCache.getCard({ _id: cardId }); - + if (!card || !card.boardId) { - return this.ready(); + return []; } - + const board = ReactiveCache.getBoard({ _id: card.boardId }); if (!board || !board.isVisibleBy(userId)) { - return this.ready(); + return []; } - + // If user has assigned-only permissions, check if they're assigned to this card if (userId && board.members) { const member = _.findWhere(board.members, { userId: userId, isActive: true }); if (member && (member.isNormalAssignedOnly || member.isCommentAssignedOnly || member.isReadAssignedOnly)) { // User with assigned-only permissions can only view cards assigned to them if (!card.assignees || !card.assignees.includes(userId)) { - return this.ready(); // Don't publish if user is not assigned + return []; // Don't publish if user is not assigned } } } - - this.cursor( - ReactiveCache.getCards( - { _id: cardId }, - {}, - true, - ), - function(cardId, card) { - this.cursor(ReactiveCache.getBoards({_id: card.boardId}, {}, true)); - this.cursor(ReactiveCache.getLists({boardId: card.boardId}, {}, true)); + + return { + find() { + return ReactiveCache.getCards({ _id: cardId }, {}, true); }, - ); - const ret = this.ready() - return ret; + children: [ + { + find(card) { + return ReactiveCache.getBoards({ _id: card.boardId }, {}, true); + } + }, + { + find(card) { + return ReactiveCache.getLists({ boardId: card.boardId }, {}, true); + } + } + ] + }; }); Meteor.publish('myCards', function(sessionId) {