PluginProbe
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration / 2.2.1
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration v2.2.1
2.2.1 2.2.0 2.1.0 2.0.15 2.0.12 2.0.10 2.0.4 2.0.1 2.0.0 1.95.3 1.95.2 1.95 1.91.6 trunk 1.11 1.12 1.13 1.20 1.21 1.22 1.23 1.30 1.31 1.32 1.35 All 44 releases
← All changes | app/Services/NotificationService.php +331 -32 1.21 → 2.2.1 View file →
@@ -1,25 +1,38 @@
1 1 <?php
2 2
3 3 namespace FluentBoards\App\Services;
4 4
5 +use FluentBoards\App\Models\Board;
5 6 use FluentBoards\App\Models\Meta;
6 7 use FluentBoards\App\Models\Notification;
7 8 use FluentBoards\App\Models\NotificationUser;
8 9 use FluentBoards\App\Models\Relation;
9 10 use FluentBoards\App\Models\Task;
11 +use FluentBoards\App\Models\User;
10 12
11 13 class NotificationService
12 14 {
13 - public function getAllNotifications($per_page, $page)
15 + public function getAllNotifications($per_page, $page, $action = 'all')
14 16 {
17 + $per_page = Sanitize::perPage($per_page);
18 + $page = max(1, (int) $page);
15 19 $user = wp_get_current_user();
16 20
17 - $notifications = Notification::where('object_type', Constant::OBJECT_TYPE_BOARD_NOTIFICATION)
21 + $query = $this->getAccessibleNotificationsQuery($user->ID)
18 22 ->whereHas('users', function($q) use ($user) {
19 23 $q->where('user_id', $user->ID);
20 - })->with('activitist', 'task')->orderBy('created_at', 'desc')->paginate($per_page, ['*'], 'page', $page);
24 + });
21 25
26 + // Add action filter if not 'all'
27 + if ($action !== 'all') {
28 + $query->where('action', $action);
29 + }
30 +
31 + $notifications = $query->with('activitist', 'task')
32 + ->orderBy('created_at', 'desc')
33 + ->paginate($per_page, ['*'], 'page', $page);
34 +
22 35 foreach ($notifications as $notification){
23 36 $notification->read = $notification->checkReadOrNot();
24 37 }
25 38
@@ -30,13 +43,15 @@
30 43 * @throws \Exception
31 44 */
32 45 public function getAllUnreadNotifications($per_page, $page)
33 46 {
47 + $per_page = Sanitize::perPage($per_page);
48 + $page = max(1, (int) $page);
34 49 $userId = get_current_user_id();
35 50 if (!$userId) {
36 - throw new \Exception('You are not allowed to do that', 403);
51 + throw new \Exception(esc_html__('You are not allowed to do that', 'fluent-boards'), 403);
37 52 }
38 - $unreadNotifications = Notification::where('object_type', Constant::OBJECT_TYPE_BOARD_NOTIFICATION)
53 + $unreadNotifications = $this->getAccessibleNotificationsQuery($userId)
39 54 ->whereHas('users', function($q) use ($userId) {
40 55 $q->where('user_id', $userId)
41 56 ->whereNull('marked_read_at');
42 57 })->with('activitist', 'task')->orderBy('created_at', 'desc')->paginate($per_page, ['*'], 'page', $page);
@@ -64,8 +79,12 @@
64 79
65 80 $notification = NotificationUser::where('user_id', $user->ID)
66 81 ->where('notification_id', $notificationId)
67 82 ->first();
83 + if (!$notification) {
84 + throw new \Exception(esc_html__('Notification could not be found', 'fluent-boards'), 404);
85 + }
86 +
68 87 $notification->marked_read_at = current_time('mysql');
69 88 $notification->save();
70 89 return $notification;
71 90 }
@@ -73,9 +92,9 @@
73 92 public function newNotificationNumber()
74 93 {
75 94 $user = wp_get_current_user();
76 95
77 - $unread_notifications = Notification::query()->where('object_type', Constant::OBJECT_TYPE_BOARD_NOTIFICATION)
96 + $unread_notifications = $this->getAccessibleNotificationsQuery($user->ID)
78 97 ->whereHas('users', function($q) use ($user) {
79 98 $q->where('user_id', $user->ID)
80 99 ->whereNull('marked_read_at');
81 100 });
@@ -82,8 +101,41 @@
82 101
83 102 return $unread_notifications->count();
84 103 }
85 104
105 + /**
106 + * Historical recipients must still have access to the source board and current task.
107 + * Keep legacy subtasks readable through their parent when their board_id is null.
108 + */
109 + private function getAccessibleNotificationsQuery($userId)
110 + {
111 + $query = Notification::where('object_type', Constant::OBJECT_TYPE_BOARD_NOTIFICATION);
112 + if (!$userId) {
113 + return $query->where('id', 0);
114 + }
115 +
116 + $boardIds = Board::select('id');
117 + if (!PermissionManager::isAdmin($userId)) {
118 + $boardIds->whereIn('id', Relation::select('object_id')
119 + ->where('object_type', Constant::OBJECT_TYPE_BOARD_USER)
120 + ->where('foreign_id', $userId));
121 + }
122 +
123 + return $query->whereIn('object_id', $boardIds)
124 + ->where(function ($notifications) use ($boardIds) {
125 + $notifications->whereNull('task_id')
126 + ->orWhereHas('task', function ($tasks) use ($boardIds) {
127 + $tasks->where(function ($ownedTasks) use ($boardIds) {
128 + $ownedTasks->whereIn('board_id', $boardIds)
129 + ->orWhere(function ($legacySubtasks) use ($boardIds) {
130 + $legacySubtasks->whereNull('board_id')
131 + ->whereIn('parent_id', Task::select('id')->whereIn('board_id', $boardIds));
132 + });
133 + });
134 + });
135 + });
136 + }
137 +
86 138 public function isCurrentUserObservingTask($task)
87 139 {
88 140 $currentUserId = get_current_user_id();
89 141 $observers = $task->watchers()->get()->pluck('ID');
@@ -115,8 +167,91 @@
115 167
116 168 return $settings;
117 169 }
118 170
171 + /**
172 + * Return board notification preferences with defaults and global watch fallbacks applied.
173 + */
174 + public function getBoardNotificationSettingsWithDefaults($boardId, $userId)
175 + {
176 + $settings = $this->getDefaultBoardNotificationSettings($userId);
177 +
178 + $boardSettings = $this->getBoardNotificationSettingsOfUser($boardId, $userId);
179 + if ($boardSettings && $boardSettings->preferences) {
180 + $preferences = maybe_unserialize($boardSettings->preferences);
181 + if (is_array($preferences)) {
182 + $settings = array_merge($settings, array_intersect_key($preferences, $settings));
183 + }
184 + }
185 +
186 + foreach ($settings as $key => $value) {
187 + $settings[$key] = $this->normalizePreferenceValue($value);
188 + }
189 +
190 + return $settings;
191 + }
192 +
193 + /**
194 + * Check if a board-scoped auto-watch preference is enabled for a user.
195 + */
196 + public function isBoardAutoWatchEnabled($boardId, $userId, $preferenceKey)
197 + {
198 + $settings = $this->getBoardNotificationSettingsWithDefaults($boardId, $userId);
199 +
200 + return array_key_exists($preferenceKey, $settings) && $this->normalizePreferenceValue($settings[$preferenceKey]);
201 + }
202 +
203 + /**
204 + * Get global watch preferences for fallback when a board has no saved watch keys.
205 + */
206 + private function getGlobalWatchNotificationSettings($userId)
207 + {
208 + $watchSettings = $this->getWatchNotificationDefaults();
209 +
210 + $globalSettings = $this->getGlobalNotificationSettingsOfUser($userId);
211 + if (!$globalSettings || !$globalSettings->value) {
212 + return $watchSettings;
213 + }
214 +
215 + $preferences = maybe_unserialize($globalSettings->value);
216 + if (!is_array($preferences)) {
217 + return $watchSettings;
218 + }
219 +
220 + foreach (array_keys($watchSettings) as $key) {
221 + if (array_key_exists($key, $preferences)) {
222 + $watchSettings[$key] = $this->normalizePreferenceValue($preferences[$key]);
223 + }
224 + }
225 +
226 + return $watchSettings;
227 + }
228 +
229 + private function getDefaultBoardNotificationSettings($userId)
230 + {
231 + return array_merge(
232 + Constant::BOARD_NOTIFICATION_TYPES,
233 + $this->getGlobalWatchNotificationSettings($userId)
234 + );
235 + }
236 +
237 + private function getWatchNotificationDefaults()
238 + {
239 + return [
240 + Constant::GLOBAL_EMAIL_NOTIFICATION_CREATING_TASK => true,
241 + Constant::GLOBAL_EMAIL_NOTIFICATION_COMMENTING => true,
242 + Constant::GLOBAL_EMAIL_NOTIFICATION_ASSIGNING => true,
243 + ];
244 + }
245 +
246 + /**
247 + * Convert stored preference values into strict booleans.
248 + */
249 + private function normalizePreferenceValue($value)
250 + {
251 + return true === $value || 1 === $value || '1' === $value || 'true' === $value;
252 + }
253 +
119 254 public function updateBoardNotificationSettings($newSettings, $id)
120 255 {
121 256 $userId = get_current_user_id();
122 257 $boardSettings = $this->getBoardNotificationSettingsOfUser($id, $userId);
@@ -122,13 +257,23 @@
122 257 $boardSettings = $this->getBoardNotificationSettingsOfUser($id, $userId);
123 258 if(empty($boardSettings)){
124 259 return;
125 260 }
126 - foreach ($newSettings as $index => $setting)
127 - {
128 - $newSettings[$index] = $setting == 'true' ? true : false;
261 +
262 + $allowedSettings = array_merge(
263 + Constant::BOARD_NOTIFICATION_TYPES,
264 + $this->getWatchNotificationDefaults()
265 + );
266 + $filteredSettings = [];
267 + foreach ($newSettings as $index => $setting) {
268 + if (!array_key_exists($index, $allowedSettings)) {
269 + continue;
270 + }
271 +
272 + $filteredSettings[$index] = $this->normalizePreferenceValue($setting);
129 273 }
130 - $boardSettings->preferences = $newSettings;
274 +
275 + $boardSettings->preferences = $filteredSettings;
131 276 $boardSettings->save();
132 277
133 278 }
134 279
@@ -136,23 +281,48 @@
136 281 $task = Task::findOrFail($taskId);
137 282 $watchers = $task->watchers;
138 283 $currentUserId = get_current_user_id();
139 284
140 - $wathersToSendEmail = array();
285 + $watcherIds = array_diff($watchers->pluck('ID')->toArray(), [$currentUserId]);
286 + $eligibleIds = $this->getEmailEnabledUserIds($task->board_id, $watcherIds, $emailPurpose);
287 + $eligibleLookup = array_fill_keys($eligibleIds, true);
288 + $watchersToSendEmail = [];
141 289
142 - foreach ($watchers as $watcher){
143 - if($watcher->ID != $currentUserId)
144 - {
145 - if($this->checkIfEmailEnable($watcher->ID, $emailPurpose, $task->board_id))
146 - {
147 - $wathersToSendEmail[] = $watcher->user_email;
148 - }
290 + foreach ($watchers as $watcher) {
291 + if (isset($eligibleLookup[$watcher->ID])) {
292 + $watchersToSendEmail[] = $watcher->user_email;
149 293 }
150 294 }
151 295
152 - return $wathersToSendEmail;
296 + return $watchersToSendEmail;
153 297 }
154 298
299 + /**
300 + * Get comment notification recipients as revocable user IDs for async delivery.
301 + *
302 + * @param int $taskId
303 + * @return array
304 + */
305 + public function getCommentRecipientUserIds($taskId)
306 + {
307 + $task = Task::findOrFail(absint($taskId));
308 + $currentUserId = get_current_user_id();
309 + $watchers = $task->watchers;
310 + $watcherIds = array_diff($watchers->pluck('ID')->toArray(), [$currentUserId]);
311 + $eligibleIds = $this->getEmailEnabledUserIds($task->board_id, $watcherIds, Constant::BOARD_EMAIL_COMMENT);
312 + $eligibleLookup = array_fill_keys($eligibleIds, true);
313 + $recipientUserIds = [];
314 +
315 + foreach ($watchers as $watcher) {
316 + $watcherId = absint($watcher->ID);
317 + if (isset($eligibleLookup[$watcherId])) {
318 + $recipientUserIds[] = $watcherId;
319 + }
320 + }
321 +
322 + return array_values(array_unique($recipientUserIds));
323 + }
324 +
155 325 public function checkIfEmailEnable($userId, $emailPurpose, $boardId)
156 326 {
157 327 if(
158 328 $this->checkIfEmailEnabled($boardId, $userId, $emailPurpose)
@@ -162,8 +332,38 @@
162 332 return false;
163 333 }
164 334 }
165 335
336 + /**
337 + * Resolve board email recipients in one query; missing preference keys default to enabled without writes.
338 + */
339 + public function getEmailEnabledUserIds($boardId, $userIds, $purpose)
340 + {
341 + $boardId = absint($boardId);
342 + $userIds = array_values(array_unique(array_filter(array_map('intval', (array) $userIds))));
343 + if (!$boardId || !$userIds) {
344 + return [];
345 + }
346 +
347 + // Match the first membership used by individual checks, including old duplicates.
348 + $relations = Relation::where('object_type', Constant::OBJECT_TYPE_BOARD_USER)
349 + ->where('object_id', $boardId)
350 + ->whereIn('foreign_id', $userIds)
351 + ->orderBy('id', 'ASC')
352 + ->get(['foreign_id', 'preferences'])
353 + ->unique('foreign_id');
354 +
355 + $enabledUserIds = [];
356 + foreach ($relations as $relation) {
357 + $preferences = (array) maybe_unserialize($relation->preferences);
358 + if (!array_key_exists($purpose, $preferences) || $preferences[$purpose]) {
359 + $enabledUserIds[] = (int) $relation->foreign_id;
360 + }
361 + }
362 +
363 + return array_values(array_unique($enabledUserIds));
364 + }
365 +
166 366 public function checkIfEmailEnabled($boardId, $userId, $purpose)
167 367 {
168 368 $boardSettings = $this->getBoardNotificationSettingsOfUser($boardId, $userId);
169 369
@@ -180,24 +380,123 @@
180 380
181 381 return false;
182 382 }
183 383
184 - public function checkIfEmailEnabledGlobally($userId, $purpose)
384 + /**
385 + * Return the supplied user IDs that currently belong to a board.
386 + *
387 + * Mention recipients are board-scoped because comment content is private to
388 + * the board, even when a caller supplies a valid WordPress user ID.
389 + *
390 + * @param int $boardId
391 + * @param array $mentionedUserIds
392 + * @return array
393 + */
394 + public function resolveBoardMentionUserIds($boardId, $mentionedUserIds)
185 395 {
186 - $globalSettings = $this->getGlobalNotificationSettingsOfUser($userId);
396 + $boardId = absint($boardId);
397 + $mentionedUserIds = array_values(array_unique(array_filter(array_map('absint', (array) $mentionedUserIds))));
187 398
188 - if($globalSettings){
189 - $preferences = maybe_unserialize($globalSettings['value']);
190 - if(!array_key_exists($purpose, $preferences)){
191 - $preferences[$purpose] = true;
192 - $globalSettings->preferences = $preferences;
193 - $globalSettings->save();
194 - return true;
195 - }
196 - return $preferences[$purpose];
399 + if (!$boardId || !$mentionedUserIds) {
400 + return [];
197 401 }
198 402
199 - return true;
403 + $boardMemberIdLookup = [];
404 + $boardRelations = Relation::where('object_type', Constant::OBJECT_TYPE_BOARD_USER)
405 + ->where('object_id', $boardId)
406 + ->whereIn('foreign_id', $mentionedUserIds)
407 + ->get(['foreign_id']);
408 +
409 + foreach ($boardRelations as $boardRelation) {
410 + $boardMemberIdLookup[absint($boardRelation->foreign_id)] = true;
411 + }
412 +
413 + return array_values(array_filter($mentionedUserIds, function ($mentionedUserId) use ($boardMemberIdLookup) {
414 + return isset($boardMemberIdLookup[$mentionedUserId]);
415 + }));
200 416 }
201 -}
202 417
418 + public function mentionInComment($comment, $mentionedUserIds)
419 + {
420 + $currentUserId = get_current_user_id();
421 + $uniqueIds = array_values(array_unique(array_filter(array_map('absint', (array) $mentionedUserIds))));
422 + $uniqueIds = array_values(array_filter($uniqueIds, function ($mentionedUserId) use ($currentUserId) {
423 + return $mentionedUserId !== $currentUserId;
424 + }));
203 425
426 + if (!$uniqueIds) {
427 + return;
428 + }
429 +
430 + // Queued delivery revalidates membership and preferences before sending.
431 + $this->sendMailAfterMention($comment->id, $uniqueIds);
432 +
433 + //sending desktop notifications
434 + do_action('fluent_boards/mention_comment_notification', $comment, $uniqueIds);
435 + }
436 +
437 + public function sendMailAfterMention($commentId, $recipientUserIds)
438 + {
439 + if (empty($recipientUserIds)) {
440 + return;
441 + }
442 +
443 + $current_user_id = get_current_user_id();
444 +
445 + /* this will run in background as soon as possible */
446 + /* sending Model or Model Instance won't work here */
447 + as_enqueue_async_action('fluent_boards/one_time_schedule_send_email_for_mention', [$commentId, $recipientUserIds, $current_user_id], 'fluent-boards');
448 + }
449 +
450 + public function getUnreadNotificationsOfTasks($task)
451 + {
452 + $userId = get_current_user_id();
453 + if (!$userId) {
454 + throw new \Exception(esc_html__('You are not allowed to do that', 'fluent-boards'), 403);
455 + }
456 + $unreadNotifications = $this->getAccessibleNotificationsQuery($userId)
457 + ->whereHas('users', function($q) use ($userId) {
458 + $q->where('user_id', $userId)
459 + ->whereNull('marked_read_at');
460 + })->whereHas('task', function($q) use ($task) {
461 + $q->where('id', $task->id);
462 + })
463 + ->with('activitist')->orderBy('created_at', 'desc')->count();
464 + return $unreadNotifications;
465 + }
466 +
467 + public function getUnreadNotificationCountsByTaskIds($taskIds)
468 + {
469 + $userId = get_current_user_id();
470 + if (!$userId) {
471 + throw new \Exception(esc_html__('You are not allowed to do that', 'fluent-boards'), 403);
472 + }
473 +
474 + $taskIds = array_values(array_filter(array_map('intval', (array) $taskIds)));
475 + if (!$taskIds) {
476 + return [];
477 + }
478 +
479 + $rows = $this->getAccessibleNotificationsQuery($userId)
480 + // Board/list views can return many tasks at once, so unread counts are
481 + // grouped in one query instead of issuing a count query per task.
482 + ->selectRaw('task_id, COUNT(*) as unread_count')
483 + ->join(
484 + (new NotificationUser())->getTable(),
485 + 'fbs_notifications.id',
486 + '=',
487 + 'fbs_notification_users.notification_id'
488 + )
489 + ->where('fbs_notification_users.user_id', $userId)
490 + ->whereNull('fbs_notification_users.marked_read_at')
491 + ->whereIn('task_id', $taskIds)
492 + ->groupBy('task_id')
493 + ->get();
494 +
495 + $notificationCounts = [];
496 + foreach ($rows as $row) {
497 + $notificationCounts[(int) $row->task_id] = (int) $row->unread_count;
498 + }
499 +
500 + return $notificationCounts;
501 + }
502 +}