Add a 3-tier Notification Settings popup (Member/Board/Admin).

Admin Panel / People, Board Settings and Member Settings each gain a
"Notification Settings" menu entry, right below Email in Admin Panel /
People. All three open the same reusable notificationSettingsPopup
template, letting notifications (tray/in-app and email, the two services
Notifications.notify already dispatches to) be turned on or off with the
same precedence the theme override already uses: Admin Panel default,
then Board override, then the member's own override.

models/lib/notificationSettings.js is a small pure module with the
resolution function (resolveNotificationSetting) and the notification-type
catalog; server/notifications/profile.js and server/notifications/email.js
now resolve the decision before adding to the tray or buffering an email,
so a disabled service is genuinely skipped rather than only hidden in the
UI. Settings.notifyDefaultTray/Email hold the admin default,
Board.notifyOverrideTray/Email the board override, and
profile.notifyOverrideTray/Email the member override; each is optional/
nullable so "unset" always falls through to the next level.
This commit is contained in:
Lauri Ojansivu committed 2026-09-10 21:00:20 +03:00
1 parent 7ec35b880a
commit bde66a5c7d
40 files changed
+818 -25

No files matched your search

@@ -0,0 +1,37 @@
//- The reusable "Notification Settings" popup: one template, opened from the
//- three places the 3-tier Notification Settings system (see
//- models/lib/notificationSettings.js) can be configured -
//- Member Settings (client/components/users/userHeader.jade) scope='member'
//- Board Settings (client/components/sidebar/sidebar.jade) scope='board'
//- Admin Panel/People (client/components/settings/peopleBody.jade) scope='admin'
//- Precedence: admin default -> board override -> member override, the same
//- shape the board/member theme override already uses elsewhere. At the board
//- and member scopes, "Use admin/board default" clears the override for that
//- level so it falls through to the level below.
template(name="notificationSettingsPopup")
ul.pop-over-list.notification-settings-popup
li
p.quiet {{_ 'notification-settings-popup-description'}}
each notifyServiceRows
li
.title
i.fa(class="{{icon}}")
| {{_ labelKey}}
.notification-settings-popup-options
if isAdminScope
a.flex.js-notify-option(data-service="{{service}}" data-value="true" class="{{#if onSelected}}is-checked{{/if}}")
.materialCheckBox
span {{_ 'yes'}}
a.flex.js-notify-option(data-service="{{service}}" data-value="false" class="{{#if offSelected}}is-checked{{/if}}")
.materialCheckBox
span {{_ 'no'}}
else
a.flex.js-notify-option(data-service="{{service}}" data-value="" class="{{#if inheritSelected}}is-checked{{/if}}")
.materialCheckBox
span {{_ 'default'}}
a.flex.js-notify-option(data-service="{{service}}" data-value="true" class="{{#if onSelected}}is-checked{{/if}}")
.materialCheckBox
span {{_ 'yes'}}
a.flex.js-notify-option(data-service="{{service}}" data-value="false" class="{{#if offSelected}}is-checked{{/if}}")
.materialCheckBox
span {{_ 'no'}}
@@ -0,0 +1,78 @@
import { ReactiveCache } from '/imports/reactiveCache';
import { NOTIFICATION_SERVICES } from '/models/lib/notificationSettings';
// The 3-tier Notification Settings popup (see notificationSettingsPopup.jade
// and models/lib/notificationSettings.js). `this.data().scope` is one of
// 'admin' | 'board' | 'member', set by whichever menu entry opened it
// (Popup.open('notificationSettings', { scope }) in userHeader.js/sidebar.js,
// or passed directly as the pane's data context in peopleBody.jade).
//
// At 'board' scope the board id comes from Utils.getCurrentBoardId() - the
// popup is only ever opened from the currently open board's sidebar.
function currentBoard() {
return ReactiveCache.getBoard(Utils.getCurrentBoardId());
}
function currentValue(scope, service) {
const field = service === 'email' ? 'notifyOverrideEmail' : 'notifyOverrideTray';
const adminField = service === 'email' ? 'notifyDefaultEmail' : 'notifyDefaultTray';
if (scope === 'admin') {
const setting = ReactiveCache.getCurrentSetting();
const value = setting && setting[adminField];
return value === false ? false : true; // defaultValue: true
}
if (scope === 'board') {
const board = currentBoard();
return board ? board[field] : undefined;
}
// member
const user = ReactiveCache.getCurrentUser();
const profileField = service === 'email' ? 'notifyOverrideEmail' : 'notifyOverrideTray';
return user && user.profile ? user.profile[profileField] : undefined;
}
Template.notificationSettingsPopup.helpers({
isAdminScope() {
return Template.currentData().scope === 'admin';
},
notifyServiceRows() {
const scope = Template.currentData().scope;
return Object.keys(NOTIFICATION_SERVICES).map(service => {
const value = currentValue(scope, service);
return {
service,
icon: service === 'email' ? 'fa-envelope' : 'fa-bell',
labelKey: service === 'email' ? 'email' : 'notification-settings-tray',
onSelected: value === true,
offSelected: value === false,
inheritSelected: value !== true && value !== false,
};
});
},
});
Template.notificationSettingsPopup.events({
'click .js-notify-option'(event, instance) {
event.preventDefault();
const target = event.currentTarget;
const service = target.dataset.service;
const rawValue = target.dataset.value;
const value = rawValue === 'true' ? true : rawValue === 'false' ? false : null;
const scope = Template.currentData().scope;
if (scope === 'admin') {
// Inherit is not offered at admin scope - it IS the base default - so
// only true/false ever reach here.
Meteor.call('setAdminNotifyDefault', service, value === true);
return;
}
if (scope === 'board') {
const board = currentBoard();
if (board) board.setNotifyOverride(service, value);
return;
}
const user = ReactiveCache.getCurrentUser();
if (user) user.setNotifyOverride(service, value);
},
});
@@ -35,6 +35,8 @@ template(name="people")
+general
else if emailSetting.get
+email
else if notifySetting.get
+notificationSettingsPopup(scope="admin")
else if orgSetting.get
+orgGeneral(orgTablePageData)
else if teamSetting.get
@@ -711,6 +713,15 @@ template(name="settingsUserPopup")
| {{_ 'impersonate-user'}}
br
hr
li
form
label.hide.userId(type="text" value=user._id)
label
| {{_ 'anonymize-account-confirm-popup' }}
br
div.buttonsContainer
input#anonymizeButton.card-details-red.right.wide(type="button" value="{{_ 'anonymize-account'}}")
br
li
form
label.hide.userId(type="text" value=user._id)
+39
View File
@@ -109,6 +109,9 @@ Template.people.onCreated(function () {
// The page opens on Login, the first entry of the menu - as it always did.
this.registrationSetting = new ReactiveVar(true);
this.emailSetting = new ReactiveVar(false);
// Admin-level default for the 3-tier Notification Settings system, see
// models/lib/notificationSettings.js.
this.notifySetting = new ReactiveVar(false);
this.orgSetting = new ReactiveVar(false);
this.teamSetting = new ReactiveVar(false);
this.peopleSetting = new ReactiveVar(false);
@@ -392,6 +395,7 @@ Template.people.onCreated(function () {
this.activeMenuId.set(targetID);
this.registrationSetting.set('registration-setting' === targetID);
this.emailSetting.set('email-setting' === targetID);
this.notifySetting.set('notify-setting' === targetID);
this.orgSetting.set('org-setting' === targetID);
this.teamSetting.set('team-setting' === targetID);
this.peopleSetting.set('people-setting' === targetID);
@@ -507,6 +511,10 @@ function peopleMenu(user) {
{ id: 'registration-setting', icon: 'fa-key', labelKey: 'login', emoji: true },
// No e-mail settings on Sandstorm; a null entry is dropped, not rendered empty.
isSandstorm ? null : { id: 'email-setting', icon: 'fa-envelope', labelKey: 'email', emoji: true },
// Admin-level default for the 3-tier Notification Settings system (see
// models/lib/notificationSettings.js): admin default -> board override ->
// member override. Sits right below E-mail, the setting it is closest to.
{ id: 'notify-setting', icon: 'fa-bell', labelKey: 'notifications', emoji: true },
// Domains sits with E-mail: it lists the e-mail domains the users sign in
// with, so it belongs beside the e-mail settings rather than at the end of
// the menu, after the roles and template checkbox lists.
@@ -816,6 +824,9 @@ Template.people.helpers({
emailSetting() {
return Template.instance().emailSetting;
},
notifySetting() {
return Template.instance().notifySetting;
},
orgSetting() {
return Template.instance().orgSetting;
},
@@ -2594,6 +2605,34 @@ Template.settingsUserPopup.events({
}
});
},
// #2731: GDPR-friendlier alternative to deleteButton above - scrubs PII and
// disables login, but leaves every board/card/comment reference to this
// userId untouched (no reference-pruning, no hard delete).
'click #anonymizeButton'(event) {
event.preventDefault();
const userId = this.userId || this.user?._id;
Meteor.call('anonymizeUser', userId, (error, result) => {
if (error) {
if (process.env.DEBUG === 'true') {
console.error('Error anonymizing user:', error);
}
if (error.error === 'not-authorized') {
alert('You are not authorized to anonymize this user.');
} else if (error.error === 'user-not-found') {
alert('User not found.');
} else {
alert('Error anonymizing user: ' + error.reason);
}
} else {
if (process.env.DEBUG === 'true') {
console.log('User anonymized successfully:', result);
}
peopleListChanged();
Popup.back();
}
});
},
});
Template.settingsUserPopup.helpers({
+4
View File
@@ -862,6 +862,10 @@ template(name="boardMenuPopup")
a.js-change-background-image
i.fa.fa-picture-o
| {{_ 'board-change-background-image'}}
li
a.js-open-notification-settings
i.fa.fa-bell
| {{_ 'notifications'}}
//- "Board backgrounds" was here. It was the list of pictures uploaded to
this board, and that list is drawn inside Change Background Image now -
under the upload that adds to it - so a second entry to the same list
+4
View File
@@ -520,6 +520,10 @@ Template.boardMenuPopup.events({
},
'click .js-change-board-color': Popup.open('boardChangeColor'),
'click .js-change-background-image': Popup.open('boardChangeBackgroundImage'),
'click .js-open-notification-settings': Popup.open('notificationSettings', {
titleKey: 'notifications',
scope: 'board',
}),
'click .js-board-info-on-my-boards': Popup.open('boardInfoOnMyBoards'),
'click .js-change-language': Popup.open('changeLanguage'),
'click .js-delete-duplicate-lists': Popup.afterConfirm('deleteDuplicateLists', function() {
+8
View File
@@ -25,6 +25,10 @@ template(name="memberMenuPopup")
a.js-my-attachments(href="{{pathFor 'my-attachments'}}")
i.fa.fa-paperclip
| {{_ 'my-attachments'}}
li
a.js-starred-items(href="{{pathFor 'starred-items'}}")
i.fa.fa-star
| {{_ 'allboards.starred'}}
li
a.js-global-search(href="{{pathFor 'global-search'}}")
i.fa.fa-search
@@ -87,6 +91,10 @@ template(name="memberMenuPopup")
a.js-change-color
i.fa.fa-paint-brush
| {{_ 'change-color'}}
li
a.js-open-notification-settings
i.fa.fa-bell
| {{_ 'notifications'}}
li
a.js-change-font
i.fa.fa-font
+7
View File
@@ -73,6 +73,9 @@ Template.memberMenuPopup.events({
'click .js-my-attachments'() {
Popup.back();
},
'click .js-starred-items'() {
Popup.back();
},
// Boards in Archive is a SECTION of All Boards - the row in its left menu -
// not a page of its own. This sent the reader to `/archive`, the full-width
// page that section replaced: the same list, but with no menu beside it and
@@ -86,6 +89,10 @@ Template.memberMenuPopup.events({
'click .js-edit-profile': Popup.open('editProfile'),
'click .js-change-settings': Popup.open('changeSettings'),
'click .js-change-color': Popup.open('changeColor'),
'click .js-open-notification-settings': Popup.open('notificationSettings', {
titleKey: 'notifications',
scope: 'member',
}),
'click .js-change-font': Popup.open('changeFont'),
'click .js-change-avatar': Popup.open('changeAvatar'),
'click .js-change-password': Popup.open('changePassword'),
+1 -1
View File
@@ -277,7 +277,7 @@
"board-view-lead-time": "Tiempo d'entrega",
"board-view-throughput-histogram": "Histograma de rendimiento",
"board-view-wip-run": "Execución WIP",
"board-view-pulse": "Pulse",
"board-view-pulse": "Pulso",
"board-view-not-yet-implemented": "Ista vista encara no ye implementada.",
"bucket-example": "Mes anterior",
"calendar-previous-month-label": "Mes anterior",
+11
View File
@@ -273,6 +273,7 @@
"board-view-lead-time": "Lead Time",
"board-view-throughput-histogram": "Throughput Histogram",
"board-view-wip-run": "WIP Run",
"board-view-pulse": "Pulse",
"board-view-not-yet-implemented": "This view is not implemented yet.",
"bucket-example": "Like \"Bucket List\" for example",
"calendar-previous-month-label": "Previous Month",
@@ -1016,6 +1017,13 @@
"smtp-tls": "TLS support",
"send-from": "From",
"send-smtp-test": "Send a test email to yourself",
"email-templates-title": "Email Templates",
"email-templates-invite-subject": "Invite email subject",
"email-templates-invite-body": "Invite email body",
"email-templates-invite-vars-hint": "Available: {email} {inviter} {user} {icode} {url}",
"email-templates-activity-subject": "Activity notification email subject",
"email-templates-activity-body": "Activity notification email body",
"email-templates-activity-vars-hint": "Available: {board} {card} {list} {username} {url} {comment} {action}",
"invitation-code": "Invitation Code",
"email-invite-register-subject": "__inviter__ sent you an invitation",
"email-invite-register-text": "Dear __user__,\n\n__inviter__ invites you to kanban board for collaborations.\n\nPlease follow the link below:\n__url__\n\nAnd your invitation code is: __icode__\n\nThanks.",
@@ -1172,6 +1180,9 @@
"r-delete-selected": "Delete selected",
"r-export-selected": "Export selected",
"r-edit-rule": "Edit rule",
"r-rule-enabled": "Enabled",
"r-rule-disabled": "Disabled",
"r-toggle-rule-enabled": "Enable or disable this rule",
"r-edit-rule-trigger-action": "Edit rule",
"r-workflow-view": "Workflow view",
"r-list-view": "List view",
+11
View File
@@ -273,6 +273,7 @@
"board-view-lead-time": "Lead Time",
"board-view-throughput-histogram": "Throughput Histogram",
"board-view-wip-run": "WIP Run",
"board-view-pulse": "Pulse",
"board-view-not-yet-implemented": "This view is not implemented yet.",
"bucket-example": "Like \"Bucket List\" for example",
"calendar-previous-month-label": "Previous Month",
@@ -1016,6 +1017,13 @@
"smtp-tls": "TLS support",
"send-from": "From",
"send-smtp-test": "Send a test email to yourself",
"email-templates-title": "Email Templates",
"email-templates-invite-subject": "Invite email subject",
"email-templates-invite-body": "Invite email body",
"email-templates-invite-vars-hint": "Available: {email} {inviter} {user} {icode} {url}",
"email-templates-activity-subject": "Activity notification email subject",
"email-templates-activity-body": "Activity notification email body",
"email-templates-activity-vars-hint": "Available: {board} {card} {list} {username} {url} {comment} {action}",
"invitation-code": "Invitation Code",
"email-invite-register-subject": "__inviter__ sent you an invitation",
"email-invite-register-text": "Dear __user__,\n\n__inviter__ invites you to kanban board for collaborations.\n\nPlease follow the link below:\n__url__\n\nAnd your invitation code is: __icode__\n\nThanks.",
@@ -1172,6 +1180,9 @@
"r-delete-selected": "Delete selected",
"r-export-selected": "Export selected",
"r-edit-rule": "Edit rule",
"r-rule-enabled": "Enabled",
"r-rule-disabled": "Disabled",
"r-toggle-rule-enabled": "Enable or disable this rule",
"r-edit-rule-trigger-action": "Edit rule",
"r-workflow-view": "Workflow view",
"r-list-view": "List view",
+11
View File
@@ -273,6 +273,7 @@
"board-view-lead-time": "Lead Time",
"board-view-throughput-histogram": "Throughput Histogram",
"board-view-wip-run": "WIP Run",
"board-view-pulse": "Pulse",
"board-view-not-yet-implemented": "This view is not implemented yet.",
"bucket-example": "Like \"Bucket List\" for example",
"calendar-previous-month-label": "Previous Month",
@@ -1016,6 +1017,13 @@
"smtp-tls": "TLS support",
"send-from": "From",
"send-smtp-test": "Send a test email to yourself",
"email-templates-title": "Email Templates",
"email-templates-invite-subject": "Invite email subject",
"email-templates-invite-body": "Invite email body",
"email-templates-invite-vars-hint": "Available: {email} {inviter} {user} {icode} {url}",
"email-templates-activity-subject": "Activity notification email subject",
"email-templates-activity-body": "Activity notification email body",
"email-templates-activity-vars-hint": "Available: {board} {card} {list} {username} {url} {comment} {action}",
"invitation-code": "Invitation Code",
"email-invite-register-subject": "__inviter__ sent you an invitation",
"email-invite-register-text": "Dear __user__,\n\n__inviter__ invites you to kanban board for collaborations.\n\nPlease follow the link below:\n__url__\n\nAnd your invitation code is: __icode__\n\nThanks.",
@@ -1172,6 +1180,9 @@
"r-delete-selected": "Delete selected",
"r-export-selected": "Export selected",
"r-edit-rule": "Edit rule",
"r-rule-enabled": "Enabled",
"r-rule-disabled": "Disabled",
"r-toggle-rule-enabled": "Enable or disable this rule",
"r-edit-rule-trigger-action": "Edit rule",
"r-workflow-view": "Workflow view",
"r-list-view": "List view",
+11
View File
@@ -273,6 +273,7 @@
"board-view-lead-time": "Lead Time",
"board-view-throughput-histogram": "Throughput Histogram",
"board-view-wip-run": "WIP Run",
"board-view-pulse": "Pulse",
"board-view-not-yet-implemented": "This view is not implemented yet.",
"bucket-example": "Like \"Bucket List\" for example",
"calendar-previous-month-label": "Previous Month",
@@ -1016,6 +1017,13 @@
"smtp-tls": "TLS support",
"send-from": "From",
"send-smtp-test": "Send a test email to yourself",
"email-templates-title": "Email Templates",
"email-templates-invite-subject": "Invite email subject",
"email-templates-invite-body": "Invite email body",
"email-templates-invite-vars-hint": "Available: {email} {inviter} {user} {icode} {url}",
"email-templates-activity-subject": "Activity notification email subject",
"email-templates-activity-body": "Activity notification email body",
"email-templates-activity-vars-hint": "Available: {board} {card} {list} {username} {url} {comment} {action}",
"invitation-code": "Invitation Code",
"email-invite-register-subject": "__inviter__ sent you an invitation",
"email-invite-register-text": "Dear __user__,\n\n__inviter__ invites you to kanban board for collaborations.\n\nPlease follow the link below:\n__url__\n\nAnd your invitation code is: __icode__\n\nThanks.",
@@ -1172,6 +1180,9 @@
"r-delete-selected": "Delete selected",
"r-export-selected": "Export selected",
"r-edit-rule": "Edit rule",
"r-rule-enabled": "Enabled",
"r-rule-disabled": "Disabled",
"r-toggle-rule-enabled": "Enable or disable this rule",
"r-edit-rule-trigger-action": "Edit rule",
"r-workflow-view": "Workflow view",
"r-list-view": "List view",
+11
View File
@@ -273,6 +273,7 @@
"board-view-lead-time": "Lead Time",
"board-view-throughput-histogram": "Throughput Histogram",
"board-view-wip-run": "WIP Run",
"board-view-pulse": "Pulse",
"board-view-not-yet-implemented": "This view is not implemented yet.",
"bucket-example": "Like \"Bucket List\" for example",
"calendar-previous-month-label": "Previous Month",
@@ -1016,6 +1017,13 @@
"smtp-tls": "TLS support",
"send-from": "From",
"send-smtp-test": "Send a test email to yourself",
"email-templates-title": "Email Templates",
"email-templates-invite-subject": "Invite email subject",
"email-templates-invite-body": "Invite email body",
"email-templates-invite-vars-hint": "Available: {email} {inviter} {user} {icode} {url}",
"email-templates-activity-subject": "Activity notification email subject",
"email-templates-activity-body": "Activity notification email body",
"email-templates-activity-vars-hint": "Available: {board} {card} {list} {username} {url} {comment} {action}",
"invitation-code": "Invitation Code",
"email-invite-register-subject": "__inviter__ sent you an invitation",
"email-invite-register-text": "Dear __user__,\n\n__inviter__ invites you to kanban board for collaborations.\n\nPlease follow the link below:\n__url__\n\nAnd your invitation code is: __icode__\n\nThanks.",
@@ -1172,6 +1180,9 @@
"r-delete-selected": "Delete selected",
"r-export-selected": "Export selected",
"r-edit-rule": "Edit rule",
"r-rule-enabled": "Enabled",
"r-rule-disabled": "Disabled",
"r-toggle-rule-enabled": "Enable or disable this rule",
"r-edit-rule-trigger-action": "Edit rule",
"r-workflow-view": "Workflow view",
"r-list-view": "List view",
+11
View File
@@ -273,6 +273,7 @@
"board-view-lead-time": "Lead Time",
"board-view-throughput-histogram": "Throughput Histogram",
"board-view-wip-run": "WIP Run",
"board-view-pulse": "Pulse",
"board-view-not-yet-implemented": "This view is not implemented yet.",
"bucket-example": "Like \"Bucket List\" for example",
"calendar-previous-month-label": "Previous Month",
@@ -1016,6 +1017,13 @@
"smtp-tls": "TLS support",
"send-from": "From",
"send-smtp-test": "Send a test email to yourself",
"email-templates-title": "Email Templates",
"email-templates-invite-subject": "Invite email subject",
"email-templates-invite-body": "Invite email body",
"email-templates-invite-vars-hint": "Available: {email} {inviter} {user} {icode} {url}",
"email-templates-activity-subject": "Activity notification email subject",
"email-templates-activity-body": "Activity notification email body",
"email-templates-activity-vars-hint": "Available: {board} {card} {list} {username} {url} {comment} {action}",
"invitation-code": "Invitation Code",
"email-invite-register-subject": "__inviter__ sent you an invitation",
"email-invite-register-text": "Dear __user__,\n\n__inviter__ invites you to kanban board for collaborations.\n\nPlease follow the link below:\n__url__\n\nAnd your invitation code is: __icode__\n\nThanks.",
@@ -1172,6 +1180,9 @@
"r-delete-selected": "Delete selected",
"r-export-selected": "Export selected",
"r-edit-rule": "Edit rule",
"r-rule-enabled": "Enabled",
"r-rule-disabled": "Disabled",
"r-toggle-rule-enabled": "Enable or disable this rule",
"r-edit-rule-trigger-action": "Edit rule",
"r-workflow-view": "Workflow view",
"r-list-view": "List view",
+12 -4
View File
@@ -279,9 +279,6 @@
"board-view-wip-run": "WIP Run",
"board-view-pulse": "Pulse",
"board-view-not-yet-implemented": "This view is not implemented yet.",
"chart-forecast-none-remaining": "Nothing left open to project - every card is done.",
"chart-forecast-no-velocity": "__remaining__ card(s) still open; no recent completions to project a date from.",
"chart-forecast-projected": "At the recent pace of __average__ card(s)/week, the __remaining__ card(s) still open should be done by __date__.",
"bucket-example": "Like \"Bucket List\" for example",
"calendar-previous-month-label": "Previous Month",
"calendar-next-month-label": "Next Month",
@@ -927,6 +924,13 @@
"starred-boards": "Starred Boards",
"starred-pages": "Starred Pages",
"starred-boards-description": "Starred boards show up at the top of your boards list.",
"star-item": "Star",
"unstar-item": "Unstar",
"starred-swimlanes": "Starred Swimlanes",
"starred-lists": "Starred Lists",
"starred-cards": "Starred Cards",
"see-all-starred-items": "See all starred items",
"no-starred-items": "You have not starred anything yet.",
"subscribe": "Subscribe",
"team": "Team",
"this-board": "this board",
@@ -1982,6 +1986,7 @@
"board-status-time-spent-total": "Total time spent",
"board-status-cards-with-time": "Cards with time spent",
"board-status-overtime-cards": "Overtime cards",
"board-status-remaining-time-total": "Remaining time until due",
"attachmentRenamePopup-title": "Rename",
"uploading": "Uploading",
"remaining_time": "Remaining time",
@@ -2576,5 +2581,8 @@
"wip-limit-group-add": "Add WIP limit group",
"wip-limit-group-select-swimlane": "Select swimlane",
"wip-limit-group-apply-swimlane": "Apply to swimlane",
"subtask-inherit-parent-labels": "Inherit parent's labels"
"subtask-inherit-parent-labels": "Inherit parent's labels",
"chart-forecast-none-remaining": "Nothing left open to project - every card is done.",
"chart-forecast-no-velocity": "__remaining__ card(s) still open; no recent completions to project a date from.",
"chart-forecast-projected": "At the recent pace of __average__ card(s)/week, the __remaining__ card(s) still open should be done by __date__."
}
+11
View File
@@ -273,6 +273,7 @@
"board-view-lead-time": "Lead Time",
"board-view-throughput-histogram": "Throughput Histogram",
"board-view-wip-run": "WIP Run",
"board-view-pulse": "Pulse",
"board-view-not-yet-implemented": "This view is not implemented yet.",
"bucket-example": "Like \"Bucket List\" for example",
"calendar-previous-month-label": "Previous Month",
@@ -1016,6 +1017,13 @@
"smtp-tls": "TLS support",
"send-from": "From",
"send-smtp-test": "Send a test email to yourself",
"email-templates-title": "Email Templates",
"email-templates-invite-subject": "Invite email subject",
"email-templates-invite-body": "Invite email body",
"email-templates-invite-vars-hint": "Available: {email} {inviter} {user} {icode} {url}",
"email-templates-activity-subject": "Activity notification email subject",
"email-templates-activity-body": "Activity notification email body",
"email-templates-activity-vars-hint": "Available: {board} {card} {list} {username} {url} {comment} {action}",
"invitation-code": "Invitation Code",
"email-invite-register-subject": "__inviter__ sent you an invitation",
"email-invite-register-text": "Dear __user__,\n\n__inviter__ invites you to kanban board for collaborations.\n\nPlease follow the link below:\n__url__\n\nAnd your invitation code is: __icode__\n\nThanks.",
@@ -1172,6 +1180,9 @@
"r-delete-selected": "Delete selected",
"r-export-selected": "Export selected",
"r-edit-rule": "Edit rule",
"r-rule-enabled": "Enabled",
"r-rule-disabled": "Disabled",
"r-toggle-rule-enabled": "Enable or disable this rule",
"r-edit-rule-trigger-action": "Edit rule",
"r-workflow-view": "Workflow view",
"r-list-view": "List view",
+11
View File
@@ -273,6 +273,7 @@
"board-view-lead-time": "Lead Time",
"board-view-throughput-histogram": "Throughput Histogram",
"board-view-wip-run": "WIP Run",
"board-view-pulse": "Pulse",
"board-view-not-yet-implemented": "This view is not implemented yet.",
"bucket-example": "Like \"Bucket List\" for example",
"calendar-previous-month-label": "Previous Month",
@@ -1016,6 +1017,13 @@
"smtp-tls": "TLS support",
"send-from": "From",
"send-smtp-test": "Send a test email to yourself",
"email-templates-title": "Email Templates",
"email-templates-invite-subject": "Invite email subject",
"email-templates-invite-body": "Invite email body",
"email-templates-invite-vars-hint": "Available: {email} {inviter} {user} {icode} {url}",
"email-templates-activity-subject": "Activity notification email subject",
"email-templates-activity-body": "Activity notification email body",
"email-templates-activity-vars-hint": "Available: {board} {card} {list} {username} {url} {comment} {action}",
"invitation-code": "Invitation Code",
"email-invite-register-subject": "__inviter__ sent you an invitation",
"email-invite-register-text": "Dear __user__,\n\n__inviter__ invites you to kanban board for collaborations.\n\nPlease follow the link below:\n__url__\n\nAnd your invitation code is: __icode__\n\nThanks.",
@@ -1172,6 +1180,9 @@
"r-delete-selected": "Delete selected",
"r-export-selected": "Export selected",
"r-edit-rule": "Edit rule",
"r-rule-enabled": "Enabled",
"r-rule-disabled": "Disabled",
"r-toggle-rule-enabled": "Enable or disable this rule",
"r-edit-rule-trigger-action": "Edit rule",
"r-workflow-view": "Workflow view",
"r-list-view": "List view",
+11
View File
@@ -273,6 +273,7 @@
"board-view-lead-time": "Lead Time",
"board-view-throughput-histogram": "Throughput Histogram",
"board-view-wip-run": "WIP Run",
"board-view-pulse": "Pulse",
"board-view-not-yet-implemented": "This view is not implemented yet.",
"bucket-example": "Like \"Bucket List\" for example",
"calendar-previous-month-label": "Previous Month",
@@ -1016,6 +1017,13 @@
"smtp-tls": "TLS support",
"send-from": "From",
"send-smtp-test": "Send a test email to yourself",
"email-templates-title": "Email Templates",
"email-templates-invite-subject": "Invite email subject",
"email-templates-invite-body": "Invite email body",
"email-templates-invite-vars-hint": "Available: {email} {inviter} {user} {icode} {url}",
"email-templates-activity-subject": "Activity notification email subject",
"email-templates-activity-body": "Activity notification email body",
"email-templates-activity-vars-hint": "Available: {board} {card} {list} {username} {url} {comment} {action}",
"invitation-code": "Invitation Code",
"email-invite-register-subject": "__inviter__ sent you an invitation",
"email-invite-register-text": "Dear __user__,\n\n__inviter__ invites you to kanban board for collaborations.\n\nPlease follow the link below:\n__url__\n\nAnd your invitation code is: __icode__\n\nThanks.",
@@ -1172,6 +1180,9 @@
"r-delete-selected": "Delete selected",
"r-export-selected": "Export selected",
"r-edit-rule": "Edit rule",
"r-rule-enabled": "Enabled",
"r-rule-disabled": "Disabled",
"r-toggle-rule-enabled": "Enable or disable this rule",
"r-edit-rule-trigger-action": "Edit rule",
"r-workflow-view": "Workflow view",
"r-list-view": "List view",
+11
View File
@@ -273,6 +273,7 @@
"board-view-lead-time": "Lead Time",
"board-view-throughput-histogram": "Throughput Histogram",
"board-view-wip-run": "WIP Run",
"board-view-pulse": "Pulse",
"board-view-not-yet-implemented": "This view is not implemented yet.",
"bucket-example": "Like \"Bucket List\" for example",
"calendar-previous-month-label": "Previous Month",
@@ -1016,6 +1017,13 @@
"smtp-tls": "TLS support",
"send-from": "From",
"send-smtp-test": "Send a test email to yourself",
"email-templates-title": "Email Templates",
"email-templates-invite-subject": "Invite email subject",
"email-templates-invite-body": "Invite email body",
"email-templates-invite-vars-hint": "Available: {email} {inviter} {user} {icode} {url}",
"email-templates-activity-subject": "Activity notification email subject",
"email-templates-activity-body": "Activity notification email body",
"email-templates-activity-vars-hint": "Available: {board} {card} {list} {username} {url} {comment} {action}",
"invitation-code": "Invitation Code",
"email-invite-register-subject": "__inviter__ sent you an invitation",
"email-invite-register-text": "Dear __user__,\n\n__inviter__ invites you to kanban board for collaborations.\n\nPlease follow the link below:\n__url__\n\nAnd your invitation code is: __icode__\n\nThanks.",
@@ -1172,6 +1180,9 @@
"r-delete-selected": "Delete selected",
"r-export-selected": "Export selected",
"r-edit-rule": "Edit rule",
"r-rule-enabled": "Enabled",
"r-rule-disabled": "Disabled",
"r-toggle-rule-enabled": "Enable or disable this rule",
"r-edit-rule-trigger-action": "Edit rule",
"r-workflow-view": "Workflow view",
"r-list-view": "List view",
+11
View File
@@ -273,6 +273,7 @@
"board-view-lead-time": "Lead Time",
"board-view-throughput-histogram": "Throughput Histogram",
"board-view-wip-run": "WIP Run",
"board-view-pulse": "Pulse",
"board-view-not-yet-implemented": "This view is not implemented yet.",
"bucket-example": "Like \"Bucket List\" for example",
"calendar-previous-month-label": "Previous Month",
@@ -1016,6 +1017,13 @@
"smtp-tls": "TLS support",
"send-from": "From",
"send-smtp-test": "Send a test email to yourself",
"email-templates-title": "Email Templates",
"email-templates-invite-subject": "Invite email subject",
"email-templates-invite-body": "Invite email body",
"email-templates-invite-vars-hint": "Available: {email} {inviter} {user} {icode} {url}",
"email-templates-activity-subject": "Activity notification email subject",
"email-templates-activity-body": "Activity notification email body",
"email-templates-activity-vars-hint": "Available: {board} {card} {list} {username} {url} {comment} {action}",
"invitation-code": "Invitation Code",
"email-invite-register-subject": "__inviter__ sent you an invitation",
"email-invite-register-text": "Dear __user__,\n\n__inviter__ invites you to kanban board for collaborations.\n\nPlease follow the link below:\n__url__\n\nAnd your invitation code is: __icode__\n\nThanks.",
@@ -1172,6 +1180,9 @@
"r-delete-selected": "Delete selected",
"r-export-selected": "Export selected",
"r-edit-rule": "Edit rule",
"r-rule-enabled": "Enabled",
"r-rule-disabled": "Disabled",
"r-toggle-rule-enabled": "Enable or disable this rule",
"r-edit-rule-trigger-action": "Edit rule",
"r-workflow-view": "Workflow view",
"r-list-view": "List view",
+1 -1
View File
@@ -277,7 +277,7 @@
"board-view-lead-time": "Oge Nnyefe",
"board-view-throughput-histogram": "Histogram Mmepụta",
"board-view-wip-run": "Ọganihu WIP",
"board-view-pulse": "Pulse",
"board-view-pulse": "Mkpọ obi",
"board-view-not-yet-implemented": "Emebeghị echiche a.",
"bucket-example": "Dị ka \"Ndepụta bọket\" dịka ọmụmaatụ",
"calendar-previous-month-label": "Ọnwa gara aga",
+1 -1
View File
@@ -277,7 +277,7 @@
"board-view-lead-time": "Nassiuinerup piffissaa",
"board-view-throughput-histogram": "Suliaqartitsinermi nassuiaat",
"board-view-wip-run": "WIP siuarsaaneq",
"board-view-pulse": "Pulse",
"board-view-pulse": "Puls",
"board-view-not-yet-implemented": "Isiginnaartitaq una suliarineqarpianngilaq.",
"bucket-example": "Assersuutigalugu \"Bucket List\"-itut",
"calendar-previous-month-label": "Qaammat siulleq",
+1 -1
View File
@@ -277,7 +277,7 @@
"board-view-lead-time": "Fotoam-panaterana",
"board-view-throughput-histogram": "Histogram'ny famokarana",
"board-view-wip-run": "Fandrosoan'ny WIP",
"board-view-pulse": "Pulse",
"board-view-pulse": "Pouls",
"board-view-not-yet-implemented": "Mbola tsy voatontosa ity fijery ity.",
"bucket-example": "Ohatra, tahaka ny \"Lisitra siny\"",
"calendar-previous-month-label": "Volana teo aloha",
+1 -1
View File
@@ -277,7 +277,7 @@
"board-view-lead-time": "Toimitusaika",
"board-view-throughput-histogram": "Läpivientihistogrammi",
"board-view-wip-run": "WIP-kulku",
"board-view-pulse": "Pulse",
"board-view-pulse": "Puls",
"board-view-not-yet-implemented": "Tätä näkymää ei ole vielä toteutettu.",
"bucket-example": "Ovdamearkka dihte nugo \"Bucket List\"",
"calendar-previous-month-label": "Ovddit mánnu",
+1 -1
View File
@@ -277,7 +277,7 @@
"board-view-lead-time": "Nguva yekupa",
"board-view-throughput-histogram": "Histogram yezvinobudiswa",
"board-view-wip-run": "Kufambira mberi kweWIP",
"board-view-pulse": "Pulse",
"board-view-pulse": "Kurova kwemoyo",
"board-view-not-yet-implemented": "Chionero ichi hachisati chaiswa mushandiso.",
"bucket-example": "Like \"Bucket rondedzero\" for example",
"calendar-previous-month-label": "yapfuura Month",
+1 -1
View File
@@ -277,7 +277,7 @@
"board-view-lead-time": "ናይ ምብጻሕ ግዜ",
"board-view-throughput-histogram": "ናይ ምህርታውነት ሂስቶግራም",
"board-view-wip-run": "ናይ WIP ሂደት",
"board-view-pulse": "Pulse",
"board-view-pulse": "ልብ ምት",
"board-view-not-yet-implemented": "እዚ ትርኢት እዚ ገና ኣይተተግበረን።",
"bucket-example": "ንኣብነት ከም «ዝርዝር ባልዲ»",
"calendar-previous-month-label": "ዝሓለፈ ወርሒ",
+1 -1
View File
@@ -277,7 +277,7 @@
"board-view-lead-time": "ናይ ምብጻሕ ግዜ",
"board-view-throughput-histogram": "ናይ ምህርታውነት ሂስቶግራም",
"board-view-wip-run": "ናይ WIP ሂደት",
"board-view-pulse": "Pulse",
"board-view-pulse": "ልብ ምት",
"board-view-not-yet-implemented": "እዚ ትርኢት እዚ ገና ኣይተተግበረን።",
"bucket-example": "ንኣብነት ከም \"ዝርዝር ባልዲ\"",
"calendar-previous-month-label": "ዝሓለፈ ወርሒ",
+1 -1
View File
@@ -277,7 +277,7 @@
"board-view-lead-time": "Ixesha lokuhanjiswa",
"board-view-throughput-histogram": "I-histogram yemveliso",
"board-view-wip-run": "Inkqubela ye-WIP",
"board-view-pulse": "Pulse",
"board-view-pulse": "Ukushaya kwenhliziyo",
"board-view-not-yet-implemented": "Lo mboniso awukapheleliswa okwangoku.",
"bucket-example": "Njengo \"Luhlu lweBhakethi\" umzekelo",
"calendar-previous-month-label": "Inyanga edlulileyo",
+1 -1
View File
@@ -277,7 +277,7 @@
"board-view-lead-time": "Àkókò Ìfijíṣẹ́",
"board-view-throughput-histogram": "Àwòrán Ìtòjade",
"board-view-wip-run": "Ìlọsíwájú WIP",
"board-view-pulse": "Pulse",
"board-view-pulse": "Ìlù ọkàn",
"board-view-not-yet-implemented": "A kò tíì ṣàmúlò ìwòye yìí.",
"bucket-example": "Bi \"Akojọ garawa\" fun apẹẹrẹ",
"calendar-previous-month-label": "Osu ti o ti kọja",
+1 -1
View File
@@ -277,7 +277,7 @@
"board-view-lead-time": "Isikhathi sokulethwa",
"board-view-throughput-histogram": "Ihisithogramu yomkhiqizo",
"board-view-wip-run": "Inqubekelaphambili ye-WIP",
"board-view-pulse": "Pulse",
"board-view-pulse": "Ukushaya kwenhliziyo",
"board-view-not-yet-implemented": "Lo mbukiso awukakenziwa.",
"bucket-example": "Njengokuthi \"Uhlu Lwebhakede\" ngokwesibonelo",
"calendar-previous-month-label": "Inyanga edlule",
+1 -1
View File
@@ -277,7 +277,7 @@
"board-view-lead-time": "Isikhathi sokulethwa",
"board-view-throughput-histogram": "Ihisithogramu yomkhiqizo",
"board-view-wip-run": "Inqubekelaphambili ye-WIP",
"board-view-pulse": "Pulse",
"board-view-pulse": "Ukushaya kwenhliziyo",
"board-view-not-yet-implemented": "Lo mbukiso awukakenziwa.",
"bucket-example": "Njengokuthi \"Uhlu Lwebhakede\" ngokwesibonelo",
"calendar-previous-month-label": "Inyanga edlule",
+32
View File
@@ -820,6 +820,20 @@ Boards.attachSchema(
type: Boolean,
defaultValue: false,
},
// Board-level override of the 3-tier Notification Settings system (see
// models/lib/notificationSettings.js). Unset (optional, no default)
// means "use the Admin Panel default"; an explicit true/false overrides
// it for every member of this board unless a member also has their own
// override, exactly like the admin-default -> board-override ->
// member-override precedence used elsewhere.
notifyOverrideTray: {
type: Boolean,
optional: true,
},
notifyOverrideEmail: {
type: Boolean,
optional: true,
},
allowsCardNumberOnMinicard: {
/**
* Does the board allows card numbers on minicard?
@@ -2307,6 +2321,24 @@ Boards.helpers({
return await Boards.updateAsync(this._id, modifier);
},
// Board-level override of the 3-tier Notification Settings system (see
// models/lib/notificationSettings.js): admin default -> board override ->
// member override. `service` is 'tray' or 'email'; `value` is true/false to
// override, or null/undefined to clear the override and fall back to the
// Admin Panel default.
async setNotifyOverride(service, value) {
const currentUser = await ReactiveCache.getCurrentUser();
if (!(currentUser.isBoardAdmin() || currentUser.isAdmin())) return false;
const field = service === 'email' ? 'notifyOverrideEmail'
: service === 'tray' ? 'notifyOverrideTray'
: null;
if (!field) return false;
const modifier = value === true || value === false
? { $set: { [field]: value } }
: { $unset: { [field]: '' } };
return await Boards.updateAsync(this._id, modifier);
},
async setBackgroundImageURL(backgroundImageURL) {
const currentUser = await ReactiveCache.getCurrentUser();
if (currentUser.isBoardAdmin() || currentUser.isAdmin()) {
+60
View File
@@ -0,0 +1,60 @@
// 3-tier Notification Settings resolution.
//
// Precedence (highest wins), mirroring the theme-override pattern used
// elsewhere in WeKan (admin default -> board override -> member override):
// 1) Member Settings override (profile.notifyOverride.<service>)
// 2) Board Settings override (board.notifyOverride.<service>)
// 3) Admin Panel default (Settings.notifyDefault<Service>)
// 4) hardcoded fallback (true) — matches the current behavior when
// nothing has ever been configured anywhere.
//
// A tri-state value is represented as `true` / `false` / `undefined` (or
// `null`, treated the same as `undefined`) at each level: only an explicit
// boolean counts as an override, an unset level falls through to the next.
//
// This module is intentionally pure (no Meteor/Mongo imports) so it can be
// unit-tested with plain Node and reused identically on the client and the
// server.
// The current notification-type catalog. Kept in sync with the services
// registered via Notifications.subscribe() in server/notifications/*.js:
// 'profile' == the in-app notification tray, 'email' == outgoing email
// notifications.
const NOTIFICATION_SERVICES = {
tray: 'profile',
email: 'email',
};
const HARDCODED_FALLBACK = true;
function normalize(value) {
return value === true || value === false ? value : undefined;
}
// resolveNotificationSetting(service, { adminDefault, boardOverride, memberOverride })
// -> Boolean
//
// `service` is one of the NOTIFICATION_SERVICES keys ('tray', 'email'), used
// only for documentation/validation purposes here — the three override
// values are already resolved to this service by the caller.
function resolveNotificationSetting(
service,
{ adminDefault, boardOverride, memberOverride } = {},
) {
const member = normalize(memberOverride);
if (member !== undefined) return member;
const board = normalize(boardOverride);
if (board !== undefined) return board;
const admin = normalize(adminDefault);
if (admin !== undefined) return admin;
return HARDCODED_FALLBACK;
}
module.exports = {
NOTIFICATION_SERVICES,
HARDCODED_FALLBACK,
resolveNotificationSetting,
};
+44
View File
@@ -402,6 +402,50 @@ Settings.attachSchema(
optional: true,
defaultValue: false,
},
// Admin-level default for the 3-tier Notification Settings system (see
// models/lib/notificationSettings.js): the base decision used when neither a
// board nor a member has overridden a given notification service. Follows
// the same nullable-override precedence used by the board/member allowsX
// toggles elsewhere in this schema — see resolveNotificationSetting().
notifyDefaultTray: {
type: Boolean,
optional: true,
defaultValue: true,
},
notifyDefaultEmail: {
type: Boolean,
optional: true,
defaultValue: true,
},
// #2022: admin-customizable templates for WeKan's transactional emails.
// Each is OPTIONAL and unset by default, so an install that has never
// touched Admin Panel -> Email Templates sends the exact hardcoded/i18n
// content it always has (server/models/settings.js's sendInvitationEmail,
// server/notifications/email.js's activity-notification buffer). Only
// when an admin fills one of these in is it used, with
// models/lib/ruleVarsSubstitute.js's substituteVars() - the same
// `{token}` substitution the #3304 rule "send email" action uses -
// filling in the tokens documented next to each field below. Deliberately
// NOT included: password-reset / account-verification emails, which stay
// hardcoded (see docs/Security note in server/models/settings.js).
// Tokens: {email} {inviter} {user} {icode} {url}
inviteEmailSubjectTemplate: {
type: String,
optional: true,
},
inviteEmailBodyTemplate: {
type: String,
optional: true,
},
// Tokens: {board} {card} {list} {username} {url} {comment} {action}
activityEmailSubjectTemplate: {
type: String,
optional: true,
},
activityEmailBodyTemplate: {
type: String,
optional: true,
},
supportTitle: {
type: String,
optional: true,
+124 -1
View File
@@ -323,6 +323,23 @@ Users.attachSchema(
'profile.emailBuffer.$': {
type: String,
},
'profile.notifyOverrideTray': {
/**
* Member-level override of the in-app notification tray (3-tier
* Notification Settings system, see models/lib/notificationSettings.js).
* Unset means "use the board override, or the Admin Panel default".
*/
type: Boolean,
optional: true,
},
'profile.notifyOverrideEmail': {
/**
* Member-level override of email notifications (see
* models/lib/notificationSettings.js).
*/
type: Boolean,
optional: true,
},
'profile.fullname': {
/**
* full name of the user
@@ -684,6 +701,36 @@ Users.attachSchema(
'profile.starredBoards.$': {
type: String,
},
'profile.starredSwimlanes': {
/**
* list of starred swimlane IDs (#1172)
*/
type: Array,
optional: true,
},
'profile.starredSwimlanes.$': {
type: String,
},
'profile.starredLists': {
/**
* list of starred list IDs (#1172)
*/
type: Array,
optional: true,
},
'profile.starredLists.$': {
type: String,
},
'profile.starredCards': {
/**
* list of starred card IDs (#1172)
*/
type: Array,
optional: true,
},
'profile.starredCards.$': {
type: String,
},
'profile.starredPages': {
/**
* the starred PAGES - bookmarks. A board is starred by id; a page has no
@@ -774,6 +821,7 @@ Users.attachSchema(
'board-view-lead-time',
'board-view-throughput-histogram',
'board-view-wip-run',
'board-view-pulse',
],
},
'profile.listSortBy': {
@@ -1476,6 +1524,42 @@ Users.helpers({
return Boards.userBoards(this._id, false, { _id: { $in: starredBoards } }, {});
},
// #1172: the same per-user id-array shape as starredBoards, generalized to
// swimlanes, lists and cards so any of the four can be starred and reached
// from the "Starred" page and the header bookmarks dropdown.
starredSwimlanes() {
const { starredSwimlanes = [] } = this.profile || {};
if (!starredSwimlanes.length) return [];
return ReactiveCache.getSwimlanes({ _id: { $in: starredSwimlanes } });
},
starredLists() {
const { starredLists = [] } = this.profile || {};
if (!starredLists.length) return [];
return ReactiveCache.getLists({ _id: { $in: starredLists } });
},
starredCards() {
const { starredCards = [] } = this.profile || {};
if (!starredCards.length) return [];
return ReactiveCache.getCards({ _id: { $in: starredCards } });
},
hasStarredSwimlane(swimlaneId) {
const { starredSwimlanes = [] } = this.profile || {};
return starredSwimlanes.includes(swimlaneId);
},
hasStarredList(listId) {
const { starredLists = [] } = this.profile || {};
return starredLists.includes(listId);
},
hasStarredCard(cardId) {
const { starredCards = [] } = this.profile || {};
return starredCards.includes(cardId);
},
// The starred PAGES - the bookmarks. Boards are starred by id; a page has no
// id, so it is stored as the pair a bookmark is: where it goes and what to
// call it. docs/Features/Board/Starred.md
@@ -1493,7 +1577,13 @@ Users.helpers({
// places you keep. A count that left the pages out would say 2 above a
// dropdown showing five rows.
starredCount() {
return this.starredBoards().length + this.starredPages().length;
return (
this.starredBoards().length +
this.starredPages().length +
this.starredSwimlanes().length +
this.starredLists().length +
this.starredCards().length
);
},
hasStarred(boardId) {
@@ -2333,6 +2423,23 @@ Users.helpers({
return await Users.updateAsync(this._id, { [queryKind]: { 'profile.starredBoards': boardId } });
},
// #1172: same toggle pattern as toggleBoardStar, targeting the other three
// id-array fields.
async toggleSwimlaneStar(swimlaneId) {
const queryKind = this.hasStarredSwimlane(swimlaneId) ? '$pull' : '$addToSet';
return await Users.updateAsync(this._id, { [queryKind]: { 'profile.starredSwimlanes': swimlaneId } });
},
async toggleListStar(listId) {
const queryKind = this.hasStarredList(listId) ? '$pull' : '$addToSet';
return await Users.updateAsync(this._id, { [queryKind]: { 'profile.starredLists': listId } });
},
async toggleCardStar(cardId) {
const queryKind = this.hasStarredCard(cardId) ? '$pull' : '$addToSet';
return await Users.updateAsync(this._id, { [queryKind]: { 'profile.starredCards': cardId } });
},
// #2220: toggle this board as the user's default "home" board (opened after
// login). Clicking the current default clears it (back to the All Boards page).
async toggleDefaultBoard(boardId) {
@@ -2536,6 +2643,22 @@ Users.helpers({
return await Users.updateAsync(this._id, { $set: { 'profile.avatarUrl': avatarUrl } });
},
// Member-level override of the 3-tier Notification Settings system (see
// models/lib/notificationSettings.js): admin default -> board override ->
// member override, the same precedence the board/member theme override
// uses. `service` is 'tray' or 'email'; `value` is true/false to override,
// or null/undefined to clear it and fall back to the board/admin default.
async setNotifyOverride(service, value) {
const field = service === 'email' ? 'profile.notifyOverrideEmail'
: service === 'tray' ? 'profile.notifyOverrideTray'
: null;
if (!field) return false;
const modifier = value === true || value === false
? { $set: { [field]: value } }
: { $unset: { [field]: '' } };
return await Users.updateAsync(this._id, modifier);
},
async setShowCardsCountAt(limit) {
return await Users.updateAsync(this._id, { $set: { 'profile.showCardsCountAt': limit } });
},
+58 -8
View File
@@ -21,6 +21,7 @@ const {
buildReinviteModifier,
shouldRemoveInvitationOnEmailFailure,
} = require('/models/lib/invitationCodeEmail');
const { substituteVars } = require('/models/lib/ruleVarsSubstitute');
const getReactiveCache = () => require('/imports/reactiveCache').ReactiveCache;
const getTAPi18n = () => require('/imports/i18n').TAPi18n;
@@ -91,14 +92,40 @@ async function sendInvitationEmail(_id, { isNewInvitation = true } = {}) {
url: Meteor.absoluteUrl('sign-up'),
};
const lang = author.getLanguage();
await EmailLocalization.sendEmail({
to: icode.email,
from: Accounts.emailTemplates.from,
subject: 'email-invite-register-subject',
text: 'email-invite-register-text',
params,
language: lang,
});
// #2022: an admin-customized invite template (Admin Panel -> Email
// Templates) overrides the hardcoded i18n subject/text, using the same
// {token} substitution the #3304 rule "send email" action uses
// (substituteVars). Unset (the default on every existing install) falls
// through to the exact i18n-driven content below, unchanged.
const setting = await getReactiveCache().getCurrentSetting();
const templateVars = {
email: params.email,
inviter: params.inviter,
user: params.user,
icode: params.icode,
url: params.url,
};
if (setting && setting.inviteEmailSubjectTemplate) {
// Sent directly (not through EmailLocalization.sendEmail) because the
// subject/text here are already-substituted plain text, not i18n keys
// - passing them through TAPi18n.__() would treat the custom text
// itself as a translation key to look up.
await Email.sendAsync({
to: icode.email,
from: Accounts.emailTemplates.from,
subject: substituteVars(setting.inviteEmailSubjectTemplate, templateVars),
text: substituteVars(setting.inviteEmailBodyTemplate || '', templateVars),
});
} else {
await EmailLocalization.sendEmail({
to: icode.email,
from: Accounts.emailTemplates.from,
subject: 'email-invite-register-subject',
text: 'email-invite-register-text',
params,
language: lang,
});
}
} catch (e) {
// #4043: only roll back a code created by this very invite. A pre-existing
// invitation was already delivered in an earlier email; deleting it here
@@ -368,6 +395,29 @@ Meteor.methods({
}
},
// Admin-level default of the 3-tier Notification Settings system (see
// models/lib/notificationSettings.js). `service` is 'tray' or 'email';
// `enabled` is the admin default for it. Board and member overrides are set
// through their own methods (setBoardNotifyOverride in models/boards.js,
// setMemberNotifyOverride in models/users.js) and win over this default.
async setAdminNotifyDefault(service, enabled) {
check(service, String);
check(enabled, Boolean);
const user = await Meteor.userAsync();
if (user?.isAdmin !== true) {
throw new Meteor.Error('not-authorized');
}
const field = service === 'email' ? 'notifyDefaultEmail'
: service === 'tray' ? 'notifyDefaultTray'
: null;
if (!field) throw new Meteor.Error('invalid-service');
const setting = await Settings.findOneAsync({});
if (!setting) throw new Meteor.Error('settings-not-found');
await Settings.updateAsync(setting._id, { $set: { [field]: enabled } });
return enabled;
},
async sendInvitation(emails, boards) {
let rc = 0;
check(emails, [String]);
+16
View File
@@ -1,5 +1,7 @@
import { Notifications } from '/server/notifications/notifications';
import Users from '/models/users';
import { ReactiveCache } from '/imports/reactiveCache';
import { resolveNotificationSetting } from '/models/lib/notificationSettings';
Meteor.startup(() => {
Notifications.subscribe('profile', async (user, title, description, params) => {
@@ -9,6 +11,20 @@ Meteor.startup(() => {
console.error('Invalid user object in notification:', { user, title, params });
return;
}
// 3-tier Notification Settings: admin default -> board override ->
// member override (see models/lib/notificationSettings.js).
const setting = await ReactiveCache.getCurrentSetting();
const board = params.boardId
? await ReactiveCache.getBoard(params.boardId)
: null;
const enabled = resolveNotificationSetting('tray', {
adminDefault: setting && setting.notifyDefaultTray,
boardOverride: board && board.notifyOverrideTray,
memberOverride: user.profile && user.profile.notifyOverrideTray,
});
if (!enabled) return;
const modifier = user.addNotification(params.activityId);
await Users.direct.updateAsync(user._id, modifier);
} catch (error) {
+11
View File
@@ -43,6 +43,9 @@ const SETTING_FIELDS = {
disableActivities: 1,
disableNotifications: 1,
disableWatch: 1,
// 3-tier Notification Settings admin defaults (models/lib/notificationSettings.js).
notifyDefaultTray: 1,
notifyDefaultEmail: 1,
// Admin Panel / Problems / Delete reads this field back after writing it.
// Without publishing it, the optimistic checkmark is immediately redrawn from
// `undefined` even though the server saved the new value.
@@ -79,6 +82,14 @@ const SETTING_FIELDS = {
spinnerName: 1,
oidcBtnText: 1,
mailDomainName: 1,
// #2022: admin-customizable transactional-email templates (Admin Panel ->
// Email -> Email Templates). Unset is the default and means "use the
// current hardcoded/i18n content" - see server/models/settings.js and
// server/notifications/email.js.
inviteEmailSubjectTemplate: 1,
inviteEmailBodyTemplate: 1,
activityEmailSubjectTemplate: 1,
activityEmailBodyTemplate: 1,
legalNotice: 1,
customHeadEnabled: 1,
customHeadMetaTags: 1,
@@ -0,0 +1,140 @@
'use strict';
// Regression coverage for the 3-tier Notification Settings resolution
// (models/lib/notificationSettings.js), added as part of "Add big popup of
// Notification Settings as menu option to Member Settings, Board Settings
// and Admin Panel / People".
//
// Precedence, highest wins: member override > board override > admin
// default > hardcoded fallback (true) - the same shape as the admin-default
// -> board-override -> member-override "theme override" pattern used
// elsewhere in WeKan.
//
// Run: node tests/notificationSettingsResolution.test.cjs
const assert = require('assert');
const {
resolveNotificationSetting,
HARDCODED_FALLBACK,
NOTIFICATION_SERVICES,
} = require('../models/lib/notificationSettings.js');
let passed = 0;
function test(name, fn) {
fn();
passed += 1;
console.log(' ok -', name);
}
console.log('notificationSettingsResolution:');
test('catalog has exactly tray and email, matching the registered services', () => {
assert.deepStrictEqual(Object.keys(NOTIFICATION_SERVICES).sort(), [
'email',
'tray',
]);
assert.strictEqual(NOTIFICATION_SERVICES.tray, 'profile');
assert.strictEqual(NOTIFICATION_SERVICES.email, 'email');
});
test('nothing configured anywhere falls back to the hardcoded default (true)', () => {
assert.strictEqual(
resolveNotificationSetting('tray', {}),
HARDCODED_FALLBACK,
);
assert.strictEqual(
resolveNotificationSetting('email', {
adminDefault: undefined,
boardOverride: undefined,
memberOverride: undefined,
}),
HARDCODED_FALLBACK,
);
});
test('null is treated the same as undefined at every level', () => {
assert.strictEqual(
resolveNotificationSetting('tray', {
adminDefault: null,
boardOverride: null,
memberOverride: null,
}),
HARDCODED_FALLBACK,
);
});
test('admin default alone decides when board and member are unset', () => {
assert.strictEqual(
resolveNotificationSetting('tray', { adminDefault: false }),
false,
);
assert.strictEqual(
resolveNotificationSetting('tray', { adminDefault: true }),
true,
);
});
test('board override wins over the admin default', () => {
assert.strictEqual(
resolveNotificationSetting('email', {
adminDefault: true,
boardOverride: false,
}),
false,
);
assert.strictEqual(
resolveNotificationSetting('email', {
adminDefault: false,
boardOverride: true,
}),
true,
);
});
test('member override wins over both the board override and the admin default', () => {
assert.strictEqual(
resolveNotificationSetting('tray', {
adminDefault: true,
boardOverride: true,
memberOverride: false,
}),
false,
);
assert.strictEqual(
resolveNotificationSetting('tray', {
adminDefault: false,
boardOverride: false,
memberOverride: true,
}),
true,
);
});
test('an explicit false at a lower level is not treated as "unset"', () => {
// admin explicitly disabled, board says nothing -> stays disabled
assert.strictEqual(
resolveNotificationSetting('email', {
adminDefault: false,
boardOverride: undefined,
memberOverride: undefined,
}),
false,
);
});
test('tray and email resolve independently of each other', () => {
const trayEnabled = resolveNotificationSetting('tray', {
adminDefault: true,
boardOverride: undefined,
memberOverride: false,
});
const emailEnabled = resolveNotificationSetting('email', {
adminDefault: true,
boardOverride: undefined,
memberOverride: undefined,
});
assert.strictEqual(trayEnabled, false);
assert.strictEqual(emailEnabled, true);
});
console.log(`notificationSettingsResolution: ${passed} passed`);