mirror of
https://github.com/wekan/wekan.git
synced 2026-01-23 09:46:09 +01:00
Fixed Add member and @mentions.
Thanks to xet7 ! Fixes #6076, fixes #6077
This commit is contained in:
parent
603a65ef17
commit
ad511bd137
14 changed files with 413 additions and 149 deletions
|
|
@ -909,10 +909,11 @@ template(name="cardMembersPopup")
|
|||
each members
|
||||
li.item(class="{{#if isCardMember}}active{{/if}}")
|
||||
a.name.js-select-member(href="#")
|
||||
+userAvatar(userId=user._id)
|
||||
+userAvatar(userId=userId)
|
||||
span.full-name
|
||||
= user.profile.fullname
|
||||
| (<span class="username">{{ user.username }}</span>)
|
||||
= userData.profile.fullname
|
||||
if userData.username
|
||||
| (#{userData.username})
|
||||
if isCardMember
|
||||
| ✅
|
||||
|
||||
|
|
@ -923,10 +924,11 @@ template(name="cardAssigneesPopup")
|
|||
each members
|
||||
li.item(class="{{#if isCardAssignee}}active{{/if}}")
|
||||
a.name.js-select-assignee(href="#")
|
||||
+userAvatar(userId=user._id)
|
||||
+userAvatar(userId=userId)
|
||||
span.full-name
|
||||
= user.profile.fullname
|
||||
| (<span class="username">{{ user.username }}</span>)
|
||||
= userData.profile.fullname
|
||||
if userData.username
|
||||
| (#{userData.username})
|
||||
if isCardAssignee
|
||||
| ✅
|
||||
if currentUser.isWorker
|
||||
|
|
@ -936,7 +938,8 @@ template(name="cardAssigneesPopup")
|
|||
+userAvatar(userId=currentUser._id)
|
||||
span.full-name
|
||||
= currentUser.profile.fullname
|
||||
| (<span class="username">{{ currentUser.username }}</span>)
|
||||
if currentUser.username
|
||||
| (#{currentUser.username})
|
||||
if currentUser.isCardAssignee
|
||||
| ✅
|
||||
|
||||
|
|
|
|||
|
|
@ -928,6 +928,12 @@ Template.cardMembersPopup.onCreated(function () {
|
|||
});
|
||||
|
||||
Template.cardMembersPopup.events({
|
||||
'click .js-select-member'(event) {
|
||||
const card = Utils.getCurrentCard();
|
||||
const memberId = this.userId;
|
||||
card.toggleMember(memberId);
|
||||
event.preventDefault();
|
||||
},
|
||||
'keyup .card-members-filter'(event) {
|
||||
const members = filterMembers(event.target.value);
|
||||
Template.instance().members.set(members);
|
||||
|
|
@ -935,8 +941,23 @@ Template.cardMembersPopup.events({
|
|||
});
|
||||
|
||||
Template.cardMembersPopup.helpers({
|
||||
isCardMember() {
|
||||
const card = Template.parentData();
|
||||
const cardMembers = card.getMembers();
|
||||
|
||||
return _.contains(cardMembers, this.userId);
|
||||
},
|
||||
|
||||
members() {
|
||||
return _.sortBy(Template.instance().members.get(),'fullname');
|
||||
const members = Template.instance().members.get();
|
||||
const uniqueMembers = _.uniq(members, 'userId');
|
||||
return _.sortBy(uniqueMembers, member => {
|
||||
const user = ReactiveCache.getUser(member.userId);
|
||||
return user ? user.profile.fullname : '';
|
||||
});
|
||||
},
|
||||
userData() {
|
||||
return ReactiveCache.getUser(this.userId);
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -1910,10 +1931,15 @@ Template.cardAssigneesPopup.helpers({
|
|||
},
|
||||
|
||||
members() {
|
||||
return _.sortBy(Template.instance().members.get(),'fullname');
|
||||
const members = Template.instance().members.get();
|
||||
const uniqueMembers = _.uniq(members, 'userId');
|
||||
return _.sortBy(uniqueMembers, member => {
|
||||
const user = ReactiveCache.getUser(member.userId);
|
||||
return user ? user.profile.fullname : '';
|
||||
});
|
||||
},
|
||||
|
||||
user() {
|
||||
userData() {
|
||||
return ReactiveCache.getUser(this.userId);
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,7 +4,17 @@ var converter = require('@wekanteam/html-to-markdown');
|
|||
|
||||
const specialHandles = [
|
||||
{userId: 'board_members', username: 'board_members'},
|
||||
{userId: 'card_members', username: 'card_members'}
|
||||
{userId: 'card_members', username: 'card_members'},
|
||||
{userId: 'board_assignees', username: 'board_assignees'},
|
||||
{userId: 'card_assignees', username: 'card_assignees'}
|
||||
];
|
||||
const cardSpecialHandles = [
|
||||
{userId: 'card_members', username: 'card_members'},
|
||||
{userId: 'card_assignees', username: 'card_assignees'}
|
||||
];
|
||||
const boardSpecialHandles = [
|
||||
{userId: 'board_members', username: 'board_members'},
|
||||
{userId: 'board_assignees', username: 'board_assignees'}
|
||||
];
|
||||
const specialHandleNames = specialHandles.map(m => m.username);
|
||||
|
||||
|
|
@ -46,23 +56,26 @@ BlazeComponent.extendComponent({
|
|||
search(term, callback) {
|
||||
const currentBoard = Utils.getCurrentBoard();
|
||||
const searchTerm = term.toLowerCase();
|
||||
callback(
|
||||
_.union(
|
||||
currentBoard
|
||||
.activeMembers()
|
||||
.map(member => {
|
||||
const user = ReactiveCache.getUser(member.userId);
|
||||
const username = user.username.toLowerCase();
|
||||
const fullName = user.profile && user.profile !== undefined && user.profile.fullname ? user.profile.fullname.toLowerCase() : "";
|
||||
return username.includes(searchTerm) || fullName.includes(searchTerm) ? user : null;
|
||||
})
|
||||
.filter(Boolean), [...specialHandles])
|
||||
);
|
||||
const users = currentBoard
|
||||
.activeMembers()
|
||||
.map(member => {
|
||||
const user = ReactiveCache.getUser(member.userId);
|
||||
const username = user.username.toLowerCase();
|
||||
const fullName = user.profile && user.profile !== undefined && user.profile.fullname ? user.profile.fullname.toLowerCase() : "";
|
||||
return username.includes(searchTerm) || fullName.includes(searchTerm) ? user : null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
// Order: 1. Users, 2. Card-specific options, 3. Board-wide options
|
||||
callback(_.union(users, cardSpecialHandles, boardSpecialHandles));
|
||||
},
|
||||
template(user) {
|
||||
if (user.profile && user.profile.fullname) {
|
||||
return (user.profile.fullname + " (" + user.username + ")");
|
||||
}
|
||||
// Translate special group mentions
|
||||
if (specialHandleNames.includes(user.username)) {
|
||||
return TAPi18n.__(user.username);
|
||||
}
|
||||
return user.username;
|
||||
},
|
||||
replace(user) {
|
||||
|
|
@ -397,6 +410,14 @@ Blaze.Template.registerHelper(
|
|||
if (knowedUser.userId === Meteor.userId()) {
|
||||
linkClass += ' me';
|
||||
}
|
||||
|
||||
// For special group mentions, display translated text
|
||||
let displayText = knowedUser.username;
|
||||
if (specialHandleNames.includes(knowedUser.username)) {
|
||||
displayText = TAPi18n.__(knowedUser.username);
|
||||
linkClass = 'atMention'; // Remove js-open-member for special handles
|
||||
}
|
||||
|
||||
// This @user mention link generation did open same Wekan
|
||||
// window in new tab, so now A is changed to U so it's
|
||||
// underlined and there is no link popup. This way also
|
||||
|
|
@ -411,7 +432,7 @@ Blaze.Template.registerHelper(
|
|||
// using a data attribute.
|
||||
'data-userId': knowedUser.userId,
|
||||
},
|
||||
linkValue,
|
||||
[' ', at, displayText],
|
||||
);
|
||||
|
||||
content = content.replace(fullMention, Blaze.toHTML(link));
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
|
||||
|
||||
Template.settingHeaderBar.helpers({
|
||||
isSettingsActive() {
|
||||
return FlowRouter.getRouteName() === 'setting' ? 'active' : '';
|
||||
|
|
|
|||
|
|
@ -754,7 +754,6 @@ template(name="removeBoardTeamPopup")
|
|||
template(name="addMemberPopup")
|
||||
.js-search-member
|
||||
input.js-search-member-input(type="text" placeholder="{{_ 'email-address'}}")
|
||||
|
||||
if loading.get
|
||||
+spinner
|
||||
else if error.get
|
||||
|
|
@ -762,21 +761,12 @@ template(name="addMemberPopup")
|
|||
else
|
||||
ul.pop-over-list
|
||||
each searchResults
|
||||
li.item.js-member-item(class="{{#if isBoardMember}}disabled{{/if}}")
|
||||
li.item.js-member-item
|
||||
a.name.js-select-member(title="{{profile.fullname}} ({{username}})")
|
||||
+userAvatar(userId=_id)
|
||||
span.full-name
|
||||
= profile.fullname
|
||||
| (<span class="username">{{username}}</span>)
|
||||
if isBoardMember
|
||||
.quiet ({{_ 'joined'}})
|
||||
|
||||
if searching.get
|
||||
+spinner
|
||||
|
||||
if noResults.get
|
||||
.manage-member-section
|
||||
p.quiet {{_ 'no-results'}}
|
||||
button.js-email-invite.primary.full {{_ 'email-invite'}}
|
||||
|
||||
|
||||
|
|
@ -831,6 +821,12 @@ template(name="changePermissionsPopup")
|
|||
if isCommentAssignedOnly
|
||||
| ✅
|
||||
span.sub-name {{_ 'comment-assigned-only-desc'}}
|
||||
li
|
||||
a(class="{{#if isLastAdmin}}disabled{{else}}js-set-worker{{/if}}")
|
||||
| {{_ 'worker'}}
|
||||
if isWorker
|
||||
| ✅
|
||||
span.sub-name {{_ 'worker-desc'}}
|
||||
li
|
||||
a(class="{{#if isLastAdmin}}disabled{{else}}js-set-read-only{{/if}}")
|
||||
| {{_ 'read-only'}}
|
||||
|
|
@ -843,12 +839,6 @@ template(name="changePermissionsPopup")
|
|||
if isReadAssignedOnly
|
||||
| ✅
|
||||
span.sub-name {{_ 'read-assigned-only-desc'}}
|
||||
li
|
||||
a(class="{{#if isLastAdmin}}disabled{{else}}js-set-worker{{/if}}")
|
||||
| {{_ 'worker'}}
|
||||
if isWorker
|
||||
| ✅
|
||||
span.sub-name {{_ 'worker-desc'}}
|
||||
if isLastAdmin
|
||||
hr
|
||||
p.quiet.bottom {{_ 'last-admin-desc'}}
|
||||
|
|
|
|||
|
|
@ -1474,8 +1474,9 @@ BlazeComponent.extendComponent({
|
|||
|
||||
isBoardMember() {
|
||||
const userId = this.currentData()._id;
|
||||
const user = ReactiveCache.getUser(userId);
|
||||
return user && user.isBoardMember();
|
||||
const boardId = Session.get('currentBoard');
|
||||
const board = ReactiveCache.getBoard(boardId);
|
||||
return board && board.hasMember(userId);
|
||||
},
|
||||
|
||||
isValidEmail(email) {
|
||||
|
|
@ -1504,14 +1505,20 @@ BlazeComponent.extendComponent({
|
|||
Session.set('addMemberPopup.searching', true);
|
||||
Session.set('addMemberPopup.noResults', false);
|
||||
|
||||
// Use the fallback search
|
||||
const results = UserSearchIndex.search(query, { limit: 20 }).fetch();
|
||||
Session.set('addMemberPopup.searchResults', results);
|
||||
Session.set('addMemberPopup.searching', false);
|
||||
|
||||
if (results.length === 0) {
|
||||
Session.set('addMemberPopup.noResults', true);
|
||||
}
|
||||
const boardId = Session.get('currentBoard');
|
||||
Meteor.call('searchUsers', query, boardId, (error, results) => {
|
||||
Session.set('addMemberPopup.searching', false);
|
||||
if (error) {
|
||||
console.error('Search error:', error);
|
||||
Session.set('addMemberPopup.searchResults', []);
|
||||
Session.set('addMemberPopup.noResults', true);
|
||||
} else {
|
||||
Session.set('addMemberPopup.searchResults', results);
|
||||
if (results.length === 0) {
|
||||
Session.set('addMemberPopup.noResults', true);
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
inviteUser(idNameEmail) {
|
||||
|
|
@ -1520,9 +1527,11 @@ BlazeComponent.extendComponent({
|
|||
const self = this;
|
||||
Meteor.call('inviteUserToBoard', idNameEmail, boardId, (err, ret) => {
|
||||
self.setLoading(false);
|
||||
if (err) self.setError(err.error);
|
||||
else if (ret.email) self.setError('email-sent');
|
||||
else Popup.back();
|
||||
if (err) {
|
||||
self.setError(err.error);
|
||||
} else {
|
||||
Popup.back();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
|
|
@ -1530,9 +1539,8 @@ BlazeComponent.extendComponent({
|
|||
return [
|
||||
{
|
||||
'keyup .js-search-member-input'(event) {
|
||||
this.setError('');
|
||||
Session.set('addMemberPopup.error', '');
|
||||
const query = event.target.value.trim();
|
||||
this.searchQuery.set(query);
|
||||
|
||||
// Clear previous timeout
|
||||
if (this.searchTimeout) {
|
||||
|
|
@ -1546,16 +1554,13 @@ BlazeComponent.extendComponent({
|
|||
},
|
||||
'click .js-select-member'() {
|
||||
const userId = this.currentData()._id;
|
||||
const currentBoard = Utils.getCurrentBoard();
|
||||
if (!currentBoard.hasMember(userId)) {
|
||||
this.inviteUser(userId);
|
||||
}
|
||||
this.inviteUser(userId);
|
||||
},
|
||||
'click .js-email-invite'() {
|
||||
const idNameEmail = $('.js-search-member-input').val();
|
||||
if (idNameEmail.indexOf('@') < 0 || this.isValidEmail(idNameEmail)) {
|
||||
this.inviteUser(idNameEmail);
|
||||
} else this.setError('email-invalid');
|
||||
} else Session.set('addMemberPopup.error', 'email-invalid');
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
@ -1565,7 +1570,6 @@ BlazeComponent.extendComponent({
|
|||
Template.addMemberPopup.helpers({
|
||||
searchResults() {
|
||||
const results = Session.get('addMemberPopup.searchResults');
|
||||
console.log('searchResults helper called, returning:', results);
|
||||
return results;
|
||||
},
|
||||
searching() {
|
||||
|
|
@ -1582,14 +1586,14 @@ Template.addMemberPopup.helpers({
|
|||
},
|
||||
isBoardMember() {
|
||||
const userId = this._id;
|
||||
const user = ReactiveCache.getUser(userId);
|
||||
return user && user.isBoardMember();
|
||||
const boardId = Session.get('currentBoard');
|
||||
const board = ReactiveCache.getBoard(boardId);
|
||||
return board && board.hasMember(userId);
|
||||
}
|
||||
})
|
||||
|
||||
Template.addMemberPopupTest.helpers({
|
||||
searchResults() {
|
||||
console.log('addMemberPopupTest searchResults helper called');
|
||||
return Session.get('addMemberPopup.searchResults') || [];
|
||||
}
|
||||
})
|
||||
|
|
@ -1675,8 +1679,15 @@ BlazeComponent.extendComponent({
|
|||
|
||||
this.page = new ReactiveVar(1);
|
||||
this.autorun(() => {
|
||||
const limitOrgs = this.page.get() * Number.MAX_SAFE_INTEGER;
|
||||
this.subscribe('org', this.findOrgsOptions.get(), limitOrgs, () => {});
|
||||
const limitTeams = this.page.get() * Number.MAX_SAFE_INTEGER;
|
||||
this.subscribe('team', this.findOrgsOptions.get(), limitTeams, () => {});
|
||||
});
|
||||
|
||||
this.findUsersOptions = new ReactiveVar({});
|
||||
this.userPage = new ReactiveVar(1);
|
||||
this.autorun(() => {
|
||||
const limitUsers = this.userPage.get() * Number.MAX_SAFE_INTEGER;
|
||||
this.subscribe('people', this.findUsersOptions.get(), limitUsers, () => {});
|
||||
});
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
template(name="userAvatar")
|
||||
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="{{avatarUrl}}")
|
||||
a.member(class="js-{{#if assignee}}assignee{{else}}member{{/if}}" title="{{#if userData}}{{userData.profile.fullname}} ({{userData.username}}) {{_ memberType}}{{/if}}")
|
||||
if userData
|
||||
if userData.profile.avatarUrl
|
||||
img.avatar.avatar-image(src="{{avatarUrl}}")
|
||||
else
|
||||
+userAvatarInitials(userId=userData._id)
|
||||
else
|
||||
+userAvatarInitials(userId=userData._id)
|
||||
+userAvatarInitials(userId=this.userId)
|
||||
|
||||
if showStatus
|
||||
span.member-presence-status(class=presenceStatusClassName)
|
||||
|
|
|
|||
|
|
@ -7,12 +7,13 @@ import Team from '/models/team';
|
|||
|
||||
Template.userAvatar.helpers({
|
||||
userData() {
|
||||
return ReactiveCache.getUser(this.userId, {
|
||||
const user = ReactiveCache.getUser(this.userId, {
|
||||
fields: {
|
||||
profile: 1,
|
||||
username: 1,
|
||||
},
|
||||
});
|
||||
return user;
|
||||
},
|
||||
|
||||
avatarUrl() {
|
||||
|
|
@ -32,7 +33,21 @@ Template.userAvatar.helpers({
|
|||
|
||||
memberType() {
|
||||
const user = ReactiveCache.getUser(this.userId);
|
||||
return user && user.isBoardAdmin() ? 'admin' : 'normal';
|
||||
if (!user) return '';
|
||||
|
||||
const board = Utils.getCurrentBoard();
|
||||
if (!board) return '';
|
||||
|
||||
// Return role in priority order: Admin, Normal, NormalAssignedOnly, NoComments, CommentOnly, CommentAssignedOnly, Worker, ReadOnly, ReadAssignedOnly
|
||||
if (user.isBoardAdmin()) return 'admin';
|
||||
if (board.hasReadAssignedOnly(user._id)) return 'read-assigned-only';
|
||||
if (board.hasReadOnly(user._id)) return 'read-only';
|
||||
if (board.hasWorker(user._id)) return 'worker';
|
||||
if (board.hasCommentAssignedOnly(user._id)) return 'comment-assigned-only';
|
||||
if (board.hasCommentOnly(user._id)) return 'comment-only';
|
||||
if (board.hasNoComments(user._id)) return 'no-comments';
|
||||
if (board.hasNormalAssignedOnly(user._id)) return 'normal-assigned-only';
|
||||
return 'normal';
|
||||
},
|
||||
|
||||
/*
|
||||
|
|
|
|||
|
|
@ -121,7 +121,7 @@
|
|||
"add-after-list": "Add After List",
|
||||
"add-members": "Add Members",
|
||||
"added": "Added",
|
||||
"addMemberPopup-title": "Members",
|
||||
"addMemberPopup-title": "Add Members",
|
||||
"memberPopup-title": "Member Settings",
|
||||
"admin": "Admin",
|
||||
"admin-desc": "Can view and edit cards, remove members, and change settings for the board. Can view activities.",
|
||||
|
|
@ -172,6 +172,10 @@
|
|||
"boardInfoOnMyBoards-title": "All Boards Settings",
|
||||
"show-card-counter-per-list": "Show card count per list",
|
||||
"show-board_members-avatar": "Show Board members avatars",
|
||||
"board_members": "All board members",
|
||||
"card_members": "All members of current card at this board",
|
||||
"board_assignees": "All assignees of all cards at this board",
|
||||
"card_assignees": "All assignees of current card at this board",
|
||||
"board-nb-stars": "%s stars",
|
||||
"board-not-found": "Board not found",
|
||||
"board-private-info": "This board will be <strong>private</strong>.",
|
||||
|
|
@ -975,7 +979,7 @@
|
|||
"act-almostdue": "was reminding the current due (__timeValue__) of __card__ is approaching",
|
||||
"act-pastdue": "was reminding the current due (__timeValue__) of __card__ is past",
|
||||
"act-duenow": "was reminding the current due (__timeValue__) of __card__ is now",
|
||||
"act-atUserComment": "You were mentioned in [__board__] __list__/__card__",
|
||||
"act-atUserComment": "mentioned you on card __card__: __comment__ at list __list__ at swimlane __swimlane__ at board __board__",
|
||||
"delete-user-confirm-popup": "Are you sure you want to delete this account? There is no undo.",
|
||||
"delete-team-confirm-popup": "Are you sure you want to delete this team? There is no undo.",
|
||||
"delete-org-confirm-popup": "Are you sure you want to delete this organization? There is no undo.",
|
||||
|
|
|
|||
|
|
@ -200,6 +200,7 @@ if (Meteor.isServer) {
|
|||
if (activity.commentId) {
|
||||
const comment = activity.comment();
|
||||
params.comment = comment.text;
|
||||
let hasMentions = false; // Track if comment has @mentions
|
||||
if (board) {
|
||||
const comment = params.comment;
|
||||
const knownUsers = board.members.map((member) => {
|
||||
|
|
@ -210,7 +211,9 @@ if (Meteor.isServer) {
|
|||
}
|
||||
return member;
|
||||
});
|
||||
const mentionRegex = /\B@(?:(?:"([\w.\s-]*)")|([\w.-]+))/gi; // including space in username
|
||||
// Match @mentions including usernames with @ symbols (like email addresses)
|
||||
// Pattern matches: @username, @user@example.com, @"quoted username"
|
||||
const mentionRegex = /\B@(?:(?:"([\w.\s-]*)")|([\w.@-]+))/gi;
|
||||
let currentMention;
|
||||
|
||||
while ((currentMention = mentionRegex.exec(comment)) !== null) {
|
||||
|
|
@ -222,14 +225,59 @@ if (Meteor.isServer) {
|
|||
|
||||
if (activity.boardId && username === 'board_members') {
|
||||
// mentions all board members
|
||||
const knownUids = knownUsers.map((u) => u.userId);
|
||||
watchers = _.union(watchers, [...knownUids]);
|
||||
const validUserIds = knownUsers
|
||||
.map((u) => u.userId)
|
||||
.filter((userId) => {
|
||||
const user = ReactiveCache.getUser(userId);
|
||||
return user && user._id;
|
||||
});
|
||||
watchers = _.union(watchers, validUserIds);
|
||||
title = 'act-atUserComment';
|
||||
hasMentions = true;
|
||||
} else if (activity.boardId && username === 'board_assignees') {
|
||||
// mentions all assignees of all cards on the board
|
||||
const allCards = ReactiveCache.getCards({ boardId: activity.boardId });
|
||||
const assigneeIds = [];
|
||||
allCards.forEach((card) => {
|
||||
if (card.assignees && card.assignees.length > 0) {
|
||||
card.assignees.forEach((assigneeId) => {
|
||||
// Only add if the user exists and is a board member
|
||||
const user = ReactiveCache.getUser(assigneeId);
|
||||
if (user && _.findWhere(knownUsers, { userId: assigneeId })) {
|
||||
assigneeIds.push(assigneeId);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
watchers = _.union(watchers, assigneeIds);
|
||||
title = 'act-atUserComment';
|
||||
hasMentions = true;
|
||||
} else if (activity.cardId && username === 'card_members') {
|
||||
// mentions all card members if assigned
|
||||
const card = activity.card();
|
||||
watchers = _.union(watchers, [...card.members]);
|
||||
if (card && card.members && card.members.length > 0) {
|
||||
// Filter to only valid users who are board members
|
||||
const validMembers = card.members.filter((memberId) => {
|
||||
const user = ReactiveCache.getUser(memberId);
|
||||
return user && user._id && _.findWhere(knownUsers, { userId: memberId });
|
||||
});
|
||||
watchers = _.union(watchers, validMembers);
|
||||
}
|
||||
title = 'act-atUserComment';
|
||||
hasMentions = true;
|
||||
} else if (activity.cardId && username === 'card_assignees') {
|
||||
// mentions all assignees of the current card
|
||||
const card = activity.card();
|
||||
if (card && card.assignees && card.assignees.length > 0) {
|
||||
// Filter to only valid users who are board members
|
||||
const validAssignees = card.assignees.filter((assigneeId) => {
|
||||
const user = ReactiveCache.getUser(assigneeId);
|
||||
return user && user._id && _.findWhere(knownUsers, { userId: assigneeId });
|
||||
});
|
||||
watchers = _.union(watchers, validAssignees);
|
||||
}
|
||||
title = 'act-atUserComment';
|
||||
hasMentions = true;
|
||||
} else {
|
||||
const atUser = _.findWhere(knownUsers, { username });
|
||||
if (!atUser) {
|
||||
|
|
@ -241,10 +289,12 @@ if (Meteor.isServer) {
|
|||
params.atEmails = atUser.emails;
|
||||
title = 'act-atUserComment';
|
||||
watchers = _.union(watchers, [uid]);
|
||||
hasMentions = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
params.commentId = comment._id;
|
||||
params.hasMentions = hasMentions; // Store for later use
|
||||
}
|
||||
if (activity.attachmentId) {
|
||||
params.attachment = activity.attachmentName;
|
||||
|
|
@ -327,13 +377,20 @@ if (Meteor.isServer) {
|
|||
_.where(board.watchers, { level: 'tracking' }),
|
||||
'userId',
|
||||
);
|
||||
watchers = _.union(
|
||||
watchers,
|
||||
watchingUsers,
|
||||
_.intersection(participants, trackingUsers),
|
||||
);
|
||||
// Only add board watchers if there were no @mentions in the comment
|
||||
// When users are explicitly @mentioned, only notify those users
|
||||
if (!params.hasMentions) {
|
||||
watchers = _.union(
|
||||
watchers,
|
||||
watchingUsers,
|
||||
_.intersection(participants, trackingUsers),
|
||||
);
|
||||
}
|
||||
}
|
||||
Notifications.getUsers(watchers).forEach((user) => {
|
||||
// Skip if user is undefined or doesn't have an _id (e.g., deleted user or invalid ID)
|
||||
if (!user || !user._id) return;
|
||||
|
||||
// Don't notify a user of their own behavior, EXCEPT for self-mentions
|
||||
const isSelfMention = (user._id === userId && title === 'act-atUserComment');
|
||||
if (user._id !== userId || isSelfMention) {
|
||||
|
|
|
|||
|
|
@ -932,8 +932,41 @@ Boards.helpers({
|
|||
},
|
||||
|
||||
activeMembers(){
|
||||
const members = _.where(this.members, { isActive: true });
|
||||
return _.sortBy(members, 'username');
|
||||
// Depend on the users collection for reactivity when users are loaded
|
||||
const memberUserIds = _.pluck(this.members, 'userId');
|
||||
const dummy = Meteor.users.find({ _id: { $in: memberUserIds } }).count();
|
||||
const members = _.filter(this.members, m => m.isActive === true);
|
||||
// Group by userId to handle duplicates
|
||||
const grouped = _.groupBy(members, 'userId');
|
||||
const uniqueMembers = _.values(grouped).map(group => {
|
||||
// Prefer admin member if exists, otherwise take the first
|
||||
const selected = _.find(group, m => m.isAdmin) || group[0];
|
||||
return selected;
|
||||
});
|
||||
// Filter out members where user is not loaded
|
||||
const filteredMembers = uniqueMembers.filter(member => {
|
||||
const user = ReactiveCache.getUser(member.userId);
|
||||
return user !== undefined;
|
||||
});
|
||||
|
||||
// Sort by role priority first (admin, normal, normal-assigned, no-comments, comment-only, comment-assigned, worker, read-only, read-assigned), then by fullname
|
||||
return _.sortBy(filteredMembers, member => {
|
||||
const user = ReactiveCache.getUser(member.userId);
|
||||
let rolePriority = 8; // Default for normal
|
||||
|
||||
if (member.isAdmin) rolePriority = 0;
|
||||
else if (member.isReadAssignedOnly) rolePriority = 8;
|
||||
else if (member.isReadOnly) rolePriority = 7;
|
||||
else if (member.isWorker) rolePriority = 6;
|
||||
else if (member.isCommentAssignedOnly) rolePriority = 5;
|
||||
else if (member.isCommentOnly) rolePriority = 4;
|
||||
else if (member.isNoComments) rolePriority = 3;
|
||||
else if (member.isNormalAssignedOnly) rolePriority = 2;
|
||||
else rolePriority = 1; // Normal
|
||||
|
||||
const fullname = user ? user.profile.fullname : '';
|
||||
return rolePriority + '-' + fullname;
|
||||
});
|
||||
},
|
||||
|
||||
activeOrgs() {
|
||||
|
|
@ -1900,6 +1933,17 @@ if (Meteor.isServer) {
|
|||
'profile.invitedBoards': boardId,
|
||||
},
|
||||
});
|
||||
|
||||
// Ensure the user is active on the board
|
||||
Boards.update({
|
||||
_id: boardId,
|
||||
'members.userId': Meteor.userId()
|
||||
}, {
|
||||
$set: {
|
||||
'members.$.isActive': true,
|
||||
modifiedAt: new Date()
|
||||
}
|
||||
});
|
||||
},
|
||||
myLabelNames() {
|
||||
let names = [];
|
||||
|
|
|
|||
197
models/users.js
197
models/users.js
|
|
@ -2533,14 +2533,9 @@ if (Meteor.isServer) {
|
|||
}
|
||||
const inviter = ReactiveCache.getCurrentUser();
|
||||
const board = ReactiveCache.getBoard(boardId);
|
||||
const allowInvite =
|
||||
inviter &&
|
||||
board &&
|
||||
board.members &&
|
||||
_.contains(_.pluck(board.members, 'userId'), inviter._id) &&
|
||||
_.where(board.members, {
|
||||
userId: inviter._id,
|
||||
})[0].isActive;
|
||||
const member = _.find(board.members, function(member) { return member.userId === inviter._id; });
|
||||
if (!member) throw new Meteor.Error('error-board-notAMember');
|
||||
const allowInvite = member.isActive;
|
||||
// GitHub issue 2060
|
||||
//_.where(board.members, { userId: inviter._id })[0].isAdmin;
|
||||
if (!allowInvite) throw new Meteor.Error('error-board-notAMember');
|
||||
|
|
@ -2596,16 +2591,26 @@ if (Meteor.isServer) {
|
|||
user = ReactiveCache.getUser(newUserId);
|
||||
}
|
||||
|
||||
board.addMember(user._id);
|
||||
user.addInvite(boardId);
|
||||
const memberIndex = board.members.findIndex(m => m.userId === user._id);
|
||||
if (memberIndex >= 0) {
|
||||
Boards.update(boardId, { $set: { [`members.${memberIndex}.isActive`]: true, modifiedAt: new Date() } });
|
||||
} else {
|
||||
Boards.update(boardId, { $push: { members: { userId: user._id, isAdmin: false, isActive: true, isNoComments: false, isCommentOnly: false, isWorker: false, isNormalAssignedOnly: false, isCommentAssignedOnly: false, isReadOnly: false, isReadAssignedOnly: false } }, $set: { modifiedAt: new Date() } });
|
||||
}
|
||||
Users.update(user._id, { $push: { 'profile.invitedBoards': boardId } });
|
||||
|
||||
//Check if there is a subtasks board
|
||||
if (board.subtasksDefaultBoardId) {
|
||||
const subBoard = ReactiveCache.getBoard(board.subtasksDefaultBoardId);
|
||||
//If there is, also add user to that board
|
||||
if (subBoard) {
|
||||
subBoard.addMember(user._id);
|
||||
user.addInvite(subBoard._id);
|
||||
const subMemberIndex = subBoard.members.findIndex(m => m.userId === user._id);
|
||||
if (subMemberIndex >= 0) {
|
||||
Boards.update(board.subtasksDefaultBoardId, { $set: { [`members.${subMemberIndex}.isActive`]: true, modifiedAt: new Date() } });
|
||||
} else {
|
||||
Boards.update(board.subtasksDefaultBoardId, { $push: { members: { userId: user._id, isAdmin: false, isActive: true, isNoComments: false, isCommentOnly: false, isWorker: false, isNormalAssignedOnly: false, isCommentAssignedOnly: false, isReadOnly: false, isReadAssignedOnly: false } }, $set: { modifiedAt: new Date() } });
|
||||
}
|
||||
Users.update(user._id, { $push: { 'profile.invitedBoards': subBoard._id } });
|
||||
}
|
||||
} try {
|
||||
const fullName =
|
||||
|
|
@ -3144,7 +3149,12 @@ if (Meteor.isServer) {
|
|||
} else {
|
||||
invitationCode.boardsToBeInvited.forEach((boardId) => {
|
||||
const board = ReactiveCache.getBoard(boardId);
|
||||
board.addMember(doc._id);
|
||||
const memberIndex = board.members.findIndex(m => m.userId === doc._id);
|
||||
if (memberIndex >= 0) {
|
||||
Boards.update(boardId, { $set: { [`members.${memberIndex}.isActive`]: true } });
|
||||
} else {
|
||||
Boards.update(boardId, { $push: { members: { userId: doc._id, isAdmin: false, isActive: true, isNoComments: false, isCommentOnly: false, isWorker: false, isNormalAssignedOnly: false, isCommentAssignedOnly: false, isReadOnly: false, isReadAssignedOnly: false } } });
|
||||
}
|
||||
});
|
||||
if (!doc.profile) {
|
||||
doc.profile = {};
|
||||
|
|
@ -3441,24 +3451,33 @@ if (Meteor.isServer) {
|
|||
data = ReactiveCache.getBoards({
|
||||
_id: boardId,
|
||||
}).map(function (board) {
|
||||
if (!board.hasMember(userId)) {
|
||||
board.addMember(userId);
|
||||
const hasMember = board.members.some(m => m.userId === userId && m.isActive);
|
||||
if (!hasMember) {
|
||||
const memberIndex = board.members.findIndex(m => m.userId === userId);
|
||||
if (memberIndex >= 0) {
|
||||
Boards.update(boardId, { $set: { [`members.${memberIndex}.isActive`]: true } });
|
||||
} else {
|
||||
Boards.update(boardId, { $push: { members: { userId: userId, isAdmin: false, isActive: true, isNoComments: false, isCommentOnly: false, isWorker: false, isNormalAssignedOnly: false, isCommentAssignedOnly: false, isReadOnly: false, isReadAssignedOnly: false } } });
|
||||
}
|
||||
|
||||
function isTrue(data) {
|
||||
return data.toLowerCase() === 'true';
|
||||
}
|
||||
board.setMemberPermission(
|
||||
userId,
|
||||
isTrue(isAdmin),
|
||||
isTrue(isNoComments),
|
||||
isTrue(isCommentOnly),
|
||||
isTrue(isWorker),
|
||||
isTrue(isNormalAssignedOnly),
|
||||
isTrue(isCommentAssignedOnly),
|
||||
isTrue(isReadOnly),
|
||||
isTrue(isReadAssignedOnly),
|
||||
userId,
|
||||
);
|
||||
const memberIndex2 = board.members.findIndex(m => m.userId === userId);
|
||||
if (memberIndex2 >= 0) {
|
||||
Boards.update(boardId, {
|
||||
$set: {
|
||||
[`members.${memberIndex2}.isAdmin`]: isTrue(isAdmin),
|
||||
[`members.${memberIndex2}.isNoComments`]: isTrue(isNoComments),
|
||||
[`members.${memberIndex2}.isCommentOnly`]: isTrue(isCommentOnly),
|
||||
[`members.${memberIndex2}.isWorker`]: isTrue(isWorker),
|
||||
[`members.${memberIndex2}.isNormalAssignedOnly`]: isTrue(isNormalAssignedOnly),
|
||||
[`members.${memberIndex2}.isCommentAssignedOnly`]: isTrue(isCommentAssignedOnly),
|
||||
[`members.${memberIndex2}.isReadOnly`]: isTrue(isReadOnly),
|
||||
[`members.${memberIndex2}.isReadAssignedOnly`]: isTrue(isReadAssignedOnly),
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
_id: board._id,
|
||||
|
|
@ -3506,8 +3525,19 @@ if (Meteor.isServer) {
|
|||
data = ReactiveCache.getBoards({
|
||||
_id: boardId,
|
||||
}).map(function (board) {
|
||||
if (board.hasMember(userId)) {
|
||||
board.removeMember(userId);
|
||||
const hasMember = board.members.some(m => m.userId === userId && m.isActive);
|
||||
if (hasMember) {
|
||||
const memberIndex = board.members.findIndex(m => m.userId === userId);
|
||||
if (memberIndex >= 0) {
|
||||
const member = board.members[memberIndex];
|
||||
const activeAdmins = board.members.filter(m => m.isActive && m.isAdmin);
|
||||
const allowRemove = !member.isAdmin || activeAdmins.length > 1;
|
||||
if (!allowRemove) {
|
||||
Boards.update(boardId, { $set: { [`members.${memberIndex}.isActive`]: true } });
|
||||
} else {
|
||||
Boards.update(boardId, { $set: { [`members.${memberIndex}.isActive`]: false, [`members.${memberIndex}.isAdmin`]: false } });
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
_id: board._id,
|
||||
|
|
@ -3684,47 +3714,92 @@ if (Meteor.isServer) {
|
|||
});
|
||||
|
||||
// Server-side method to sanitize user data for search results
|
||||
const sanitizeUserForSearch = (userData) => {
|
||||
// Only allow safe fields for user search
|
||||
const safeFields = {
|
||||
_id: 1,
|
||||
username: 1,
|
||||
'profile.fullname': 1,
|
||||
'profile.avatarUrl': 1,
|
||||
'profile.initials': 1,
|
||||
'emails.address': 1,
|
||||
'emails.verified': 1,
|
||||
authenticationMethod: 1,
|
||||
isAdmin: 1,
|
||||
loginDisabled: 1,
|
||||
teams: 1,
|
||||
orgs: 1,
|
||||
};
|
||||
|
||||
const sanitized = {};
|
||||
for (const field of Object.keys(safeFields)) {
|
||||
if (userData[field] !== undefined) {
|
||||
sanitized[field] = userData[field];
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure sensitive fields are never included
|
||||
delete sanitized.services;
|
||||
delete sanitized.resume;
|
||||
delete sanitized.email;
|
||||
delete sanitized.createdAt;
|
||||
delete sanitized.modifiedAt;
|
||||
delete sanitized.sessionData;
|
||||
delete sanitized.importUsernames;
|
||||
|
||||
if (process.env.DEBUG === 'true') {
|
||||
console.log('Sanitized user data for search:', Object.keys(sanitized));
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
};
|
||||
|
||||
Meteor.methods({
|
||||
sanitizeUserForSearch(userData) {
|
||||
check(userData, Object);
|
||||
return sanitizeUserForSearch(userData);
|
||||
},
|
||||
searchUsers(query, boardId) {
|
||||
check(query, String);
|
||||
check(boardId, String);
|
||||
|
||||
// Only allow safe fields for user search
|
||||
const safeFields = {
|
||||
_id: 1,
|
||||
username: 1,
|
||||
'profile.fullname': 1,
|
||||
'profile.avatarUrl': 1,
|
||||
'profile.initials': 1,
|
||||
'emails.address': 1,
|
||||
'emails.verified': 1,
|
||||
authenticationMethod: 1,
|
||||
isAdmin: 1,
|
||||
loginDisabled: 1,
|
||||
teams: 1,
|
||||
orgs: 1,
|
||||
};
|
||||
|
||||
const sanitized = {};
|
||||
for (const field of Object.keys(safeFields)) {
|
||||
if (userData[field] !== undefined) {
|
||||
sanitized[field] = userData[field];
|
||||
}
|
||||
if (!this.userId) {
|
||||
throw new Meteor.Error('not-logged-in', 'User must be logged in');
|
||||
}
|
||||
|
||||
// Ensure sensitive fields are never included
|
||||
delete sanitized.services;
|
||||
delete sanitized.resume;
|
||||
delete sanitized.email;
|
||||
delete sanitized.createdAt;
|
||||
delete sanitized.modifiedAt;
|
||||
delete sanitized.sessionData;
|
||||
delete sanitized.importUsernames;
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
const board = ReactiveCache.getBoard(boardId);
|
||||
|
||||
if (process.env.DEBUG === 'true') {
|
||||
console.log('Sanitized user data for search:', Object.keys(sanitized));
|
||||
// Check if current user is a member of the board
|
||||
const member = _.find(board.members, function(member) { return member.userId === currentUser._id; });
|
||||
if (!member || !member.isActive) {
|
||||
throw new Meteor.Error('not-authorized', 'User is not a member of this board');
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
if (query.length < 2) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const searchRegex = new RegExp(query, 'i');
|
||||
const users = ReactiveCache.getUsers({
|
||||
$or: [
|
||||
{ username: searchRegex },
|
||||
{ 'profile.fullname': searchRegex },
|
||||
{ 'emails.address': searchRegex }
|
||||
]
|
||||
}, {
|
||||
fields: {
|
||||
_id: 1,
|
||||
username: 1,
|
||||
'profile.fullname': 1,
|
||||
'profile.avatarUrl': 1,
|
||||
'profile.initials': 1,
|
||||
'emails.address': 1
|
||||
},
|
||||
limit: 5
|
||||
});
|
||||
|
||||
return users.map(user => sanitizeUserForSearch(user));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,12 +23,15 @@ Notifications = {
|
|||
const users = [];
|
||||
watchers.forEach(userId => {
|
||||
const user = ReactiveCache.getUser(userId);
|
||||
if (user) users.push(user);
|
||||
if (user && user._id) users.push(user);
|
||||
});
|
||||
return users;
|
||||
},
|
||||
|
||||
notify: (user, title, description, params) => {
|
||||
// Skip if user is invalid
|
||||
if (!user || !user._id) return;
|
||||
|
||||
for (const k in notifyServices) {
|
||||
const notifyImpl = notifyServices[k];
|
||||
if (notifyImpl && typeof notifyImpl === 'function')
|
||||
|
|
|
|||
|
|
@ -1,5 +1,15 @@
|
|||
Meteor.startup(() => {
|
||||
Notifications.subscribe('profile', (user, title, description, params) => {
|
||||
user.addNotification(params.activityId);
|
||||
try {
|
||||
// Validate user object before processing
|
||||
if (!user || !user._id) {
|
||||
console.error('Invalid user object in notification:', { user, title, params });
|
||||
return;
|
||||
}
|
||||
const modifier = user.addNotification(params.activityId);
|
||||
Users.direct.update(user._id, modifier);
|
||||
} catch (error) {
|
||||
console.error('Error adding notification:', error);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue