PluginProbe
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration / 2.2.0
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration v2.2.0
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 1.40 All 43 releases
← All changes | app/Services/BoardService.php +924 -204 1.23 → 2.2.0 View file →
@@ -1,24 +1,36 @@
1 1 <?php
2 2
3 3 namespace FluentBoards\App\Services;
4 4
5 +use FluentBoards\App\Hooks\Handlers\FileHandler;
5 6 use FluentBoards\App\Models\Activity;
6 -use FluentBoards\App\Models\Stage;
7 -use FluentBoards\App\Models\User;
7 +use FluentBoards\App\Models\Attachment;
8 8 use FluentBoards\App\Models\Board;
9 +use FluentBoards\App\Models\Comment;
10 +use FluentBoards\App\Models\Folder;
11 +use FluentBoards\App\Models\Label;
12 +use FluentBoards\App\Models\Meta;
9 13 use FluentBoards\App\Models\Relation;
14 +use FluentBoards\App\Models\Stage;
10 15 use FluentBoards\App\Models\Task;
11 -use FluentBoards\App\Models\Meta;
12 16 use FluentBoards\App\Models\TaskMeta;
17 +use FluentBoards\App\Models\User;
13 18 use FluentBoards\App\Services\Libs\FileSystem;
14 -use FluentBoards\Framework\Support\Arr;
19 +use FluentBoards\App\Services\DescriptionMarkdownConverter;
20 +use FluentBoardsPro\App\Models\CustomField;
15 21
16 22 class BoardService
17 23 {
24 + private const LEGACY_BOARD_ASSOCIATED_CRM_CONTACT = 'crm_contact';
25 +
18 26 public function getBoardsByType($type)
19 27 {
20 - return Board::where('type', $type)->whereNull('archived_at')->orderBy('created_at', 'ASC')->get();
28 + return Board::where('type', sanitize_text_field($type))
29 + ->whereNull('archived_at')
30 + ->byAccessUser(get_current_user_id())
31 + ->orderBy('created_at', 'ASC')
32 + ->get();
21 33 }
22 34
23 35 public function deleteBoard($boardId)
24 36 {
@@ -33,9 +45,47 @@
33 45 $taskRelatedRelations = Relation::whereIn('object_id', $allTaskIdsInBoard);
34 46 $taskRelatedRelations->delete();
35 47 TaskMeta::whereIn('task_id', $allTaskIdsInBoard)->delete();
36 48
49 + // Delete time tracking records for all tasks in the board
50 + (new TaskService())->deleteTimeTrackingRecords($allTaskIdsInBoard->toArray());
51 +
52 + // Task files, task description images and the background image are keyed by
53 + // task or board id. Their local files go with the board_<id> directory below;
54 + // files on a cloud driver are queued for deletion once the rows are gone.
55 + $boardAttachments = function () use ($allTaskIdsInBoard, $boardId) {
56 + // One group, so filters chained on the result apply to both branches.
57 + return Attachment::where(function ($owned) use ($allTaskIdsInBoard, $boardId) {
58 + $owned->where(function ($query) use ($allTaskIdsInBoard) {
59 + $query->whereIn('object_id', $allTaskIdsInBoard)
60 + ->whereIn('object_type', [Constant::TASK_ATTACHMENT, Constant::TASK_DESCRIPTION]);
61 + })->orWhere(function ($query) use ($boardId) {
62 + $query->where('object_id', $boardId)
63 + ->where('object_type', Constant::BOARD_BACKGROUND_IMAGE);
64 + });
65 + });
66 + };
67 + $remoteFiles = $boardAttachments()
68 + ->where('attachment_type', '!=', 'url')
69 + ->whereNotNull('driver')
70 + ->where('driver', '!=', 'local')
71 + ->get(['file_path', 'driver'])
72 + ->filter(function ($attachment) {
73 + return !empty($attachment->file_path);
74 + })
75 + ->map(function ($attachment) {
76 + return ['file_path' => $attachment->file_path, 'driver' => $attachment->driver];
77 + })
78 + ->values()
79 + ->toArray();
80 + $boardAttachments()->delete();
81 + $this->queueRemoteAttachmentFileDeletes($remoteFiles);
82 +
37 83 Task::whereIn('id', $allTaskIdsInBoard)->delete();
84 +
85 + // delete all activities
86 + Activity::whereIn('object_id', $allTaskIdsInBoard)->where('object_type', Constant::ACTIVITY_TASK)->delete();
87 + $board->activities()->delete();
38 88
39 89 //removing all Board Settings
40 90 $board->boardUserEmailNotificationSettings()->detach();
41 91 $board->boardUserNotificationSettings()->detach();
@@ -47,15 +97,25 @@
47 97
48 98 //removing add board labels
49 99 $board->labels()->delete();
50 100
51 - //removing add board comment
52 - $board->comments()->delete();
101 + //removing all board comments (delete individually to fire model events and clean up images)
102 + $comments = $board->comments()->get();
103 + foreach ($comments as $comment) {
104 + $comment->delete();
105 + }
106 +
107 + //removing add board custom fields
108 + if (defined('FLUENT_BOARDS_PRO')) {
109 + $board->customFields()->delete();
110 + }
53 111
112 +
54 113 foreach ($board->notifications as $notification) {
55 114 $notification->users()->detach();
56 115 }
57 116 $board->notifications()->delete();
117 + $board->removeBoardFromFolder();
58 118
59 119 //delete board related meta
60 120 $this->deleteBoardMeta($boardId);
61 121
@@ -61,13 +121,34 @@
61 121
62 122 //delete from recently viewed
63 123 $this->deleteFromRecentlyViewed($boardId);
64 124
125 + //delete webhook data
126 + $this->deleteWebhookData($boardId);
127 +
65 128 $board->delete();
66 - if(!defined('FLUENT_BOARDS_PRO')){
67 - FileSystem::deleteDir('board_'.$boardId);
129 + FileSystem::deleteDir('board_'.$boardId);
130 + }
131 +
132 + /**
133 + * Hand remote attachment files to the storage add-on in the background.
134 + *
135 + * A board can hold hundreds of cloud files, one HTTP request each, so they
136 + * are deleted by Action Scheduler in batches instead of inside this request.
137 + * Without Action Scheduler they are deleted right away.
138 + *
139 + * @param array $files List of ['file_path' => string, 'driver' => string].
140 + * @return void
141 + */
142 + private function queueRemoteAttachmentFileDeletes($files)
143 + {
144 + foreach (array_chunk($files, 50) as $batch) {
145 + if (function_exists('as_enqueue_async_action')) {
146 + as_enqueue_async_action('fluent_boards/delete_remote_attachment_files', [$batch], 'fluent-boards');
147 + } else {
148 + (new FileHandler())->deleteRemoteAttachmentFiles($batch);
149 + }
68 150 }
69 -// do_action('fluent_boards/board_deleted', $board);
70 151 }
71 152
72 153 public function fetchBoardMeta($boardId)
73 154 {
@@ -122,12 +203,13 @@
122 203 public function createBoard($boardData)
123 204 {
124 205 $boardData = [
125 206 'title' => $boardData['title'],
126 - 'type' => $boardData['type'] ? $boardData['type'] : 'todo',
127 - 'description' => $boardData['description'],
207 + 'type' => $boardData['type'] ? $boardData['type'] : 'to-do',
208 + 'description' => DescriptionMarkdownConverter::normalize($boardData['description'] ?? ''),
128 209 'currency' => isset($boardData['currency']) ? $boardData['currency'] : 'USD',
129 210 'background' => isset($boardData['background']) ? $boardData['background'] : '',
211 + 'created_by' => isset($boardData['created_by']) ? $boardData['created_by'] : get_current_user_id()
130 212 ];
131 213
132 214 $boardData = apply_filters('fluent_boards/before_create_board', $boardData);
133 215
@@ -137,12 +219,23 @@
137 219
138 220 return $board;
139 221 }
140 222
141 - private function setCurrentUserPreferencesOnBoardCreate($board)
223 + /**
224 + * Attach a user-owned board to its creator with Board Admin preferences.
225 + *
226 + * @param Board $board
227 + * @return void
228 + */
229 + public function setCurrentUserPreferencesOnBoardCreate($board)
142 230 {
231 + $creatorId = absint($board->created_by);
232 + if (!$creatorId) {
233 + return;
234 + }
235 +
143 236 $board->users()->attach(
144 - get_current_user_id(),
237 + $creatorId,
145 238 [
146 239 'object_type' => Constant::OBJECT_TYPE_BOARD_USER,
147 240 'settings' => maybe_serialize([
148 241 Constant::IS_BOARD_ADMIN => true
@@ -167,11 +260,22 @@
167 260
168 261 $user->tasks()->detach($taskIdsToDetach);
169 262 $user->watchingTasks()->detach($taskIdsToDetach);
170 263
171 -// do_action('fluent_boards/board_member_removed', $boardId, $user->display_name);
172 264 }
173 265
266 + private function removeFromDefaultAssignee($boardId, $user)
267 + {
268 + $stages = Stage::where('board_id', $boardId)->get();
269 + foreach ($stages as $stage) {
270 + if (isset($stage->settings['default_task_assignees'])) {
271 + if (($key = array_search($user, $stage->settings['default_task_assignees'])) !== false) {
272 + unset($stage->settings['default_task_assignees'][$key]);
273 + }
274 + }
275 + }
276 + }
277 +
174 278 public function removeFromRecentlyOpened($boardId, $userId)
175 279 {
176 280 $recentlyOpened = Meta::where('object_id', $userId)
177 281 ->where('object_type', Constant::OBJECT_TYPE_USER)
@@ -179,9 +283,18 @@
179 283 ->first();
180 284 if ($recentlyOpened) {
181 285 $recentBoardIds = $recentlyOpened->value;
182 286
287 + // Recently opened meta can be empty or legacy-shaped; only splice a usable board ID list.
288 + if (!is_array($recentBoardIds)) {
289 + return;
290 + }
291 +
183 292 $index = array_search($boardId, $recentBoardIds);
293 + if ($index === false) {
294 + return;
295 + }
296 +
184 297 array_splice($recentBoardIds, $index, 1);
185 298
186 299 $recentlyOpened->value = $recentBoardIds;
187 300 $recentlyOpened->save();
@@ -193,12 +306,12 @@
193 306 {
194 307 if ($data['title']) {
195 308 $data['title'] = $data['title'];
196 309 } else {
197 - throw new \Exception('Title cannot be empty');
310 + throw new \Exception(esc_html__('Title cannot be empty', 'fluent-boards'));
198 311 }
199 312 if (isset($data['description'])) {
200 - $data['description'] = $data['description'];
313 + $data['description'] = DescriptionMarkdownConverter::normalize($data['description']);
201 314 }
202 315 $board->fill($data);
203 316 $board->save();
204 317 // do_action('fluent_boards/board_updated', $board);
@@ -224,50 +337,10 @@
224 337
225 338 return serialize($this->processStages($stages));
226 339 }
227 340
228 - public function changePositionOfStage($boardId, $changeData)
229 - {
230 - $allStages = Stage::where('board_id', $boardId)
231 - ->where('is_archived', 0)
232 - ->where('type', 'stage')
233 - ->orderBy('position', 'asc')
234 - ->get();
235 341
236 - $changedStage = null;
237 -
238 - foreach ($allStages as $stage) {
239 - if ($changeData['fromPosition'] < $changeData['toPosition']) {
240 - if ($stage['position'] == $changeData['fromPosition']) {
241 - $stage['position'] = (int)$changeData['toPosition'];
242 - $stage->save();
243 - $changedStage = $stage;
244 - } elseif (
245 - $stage['position'] > $changeData['fromPosition'] &&
246 - $stage['position'] <= $changeData['toPosition']
247 - ) {
248 - $stage['position'] = $stage->position - 1;
249 - $stage->save();
250 - }
251 - } else {
252 - if (
253 - $stage['position'] >= $changeData['toPosition'] &&
254 - $stage['position'] < $changeData['fromPosition']
255 - ) {
256 - $stage['position'] = $stage->position + 1;
257 - $stage->save();
258 - } elseif ($stage['position'] == $changeData['fromPosition']) {
259 - $stage['position'] = (int)$changeData['toPosition'];
260 - $stage->save();
261 - $changedStage = $stage;
262 - }
263 - }
264 - }
265 -
266 - do_action('fluent_boards/board_stage_dragged', $changedStage);
267 - }
268 -
269 - public function rePositionStages($boardId, $incomingList)
342 + public function repositionStages($boardId, $incomingList)
270 343 {
271 344 $oldList = Stage::where('board_id', $boardId)->where('type', 'stage')->whereNull('archived_at')->orderBy('position')->pluck('id');
272 345
273 346 foreach ($incomingList as $key => $stage_id) {
@@ -295,36 +368,56 @@
295 368 }
296 369 return $processedStages;
297 370 }
298 371
372 + /**
373 + * Archive a stage and persist the user who archived it for future archive-list metadata.
374 + */
299 375 public function archiveStage($boardId, $stage)
300 376 {
377 + $settings = $stage->settings ?: [];
378 + $settings['archived_by_id'] = absint(get_current_user_id()) ?: null;
379 +
301 380 $stage->archived_at = current_time('mysql');
302 381 $stage->position = 0;
382 + $stage->settings = $settings;
303 383 $stage->save();
304 384
305 - do_action('fluent_boards/board_stage_archived', $boardId, $stage->title);
385 + do_action('fluent_boards/stage_archived', $boardId, $stage); // Old hook
386 + do_action('fluent_boards/stage_archived_with_tasks', $boardId, $stage); // New hook
306 387 return $stage;
307 388 }
308 389
390 + /**
391 + * Restore an archived stage and clear stale archived-by metadata.
392 + */
309 393 public function restoreStage($boardId, $stage)
310 394 {
311 395 $stageService = new StageService();
312 396 $lastStagePosition = $stageService->getLastPositionOfStagesOfBoard($stage->board_id);
397 + $settings = $stage->settings ?: [];
398 + $settings['archived_by_id'] = null;
399 +
313 400 $stage->archived_at = null;
314 401 $stage->position = $lastStagePosition ? $lastStagePosition->position + 1 : 1;
402 + $stage->settings = $settings;
315 403 $stage->save();
316 - do_action('fluent_boards/board_stage_restored', $boardId, $stage->title);
404 + do_action('fluent_boards/board_stage_restored', $boardId, $stage->title); // Old hook
405 + do_action('fluent_boards/stage_restored_with_tasks', $boardId, $stage); // New hook
317 406 return $stage;
318 407 }
319 408
320 409 public function getActivities($id, $data)
321 410 {
322 - $per_page = isset($data['per_page']) ? $data['per_page'] : 40;
323 - $page = isset($data['page']) ? $data['page'] : 1;
324 - return Activity::where('object_id', $id)->where('object_type', Constant::ACTIVITY_BOARD)->with(['user'])
411 + $per_page = Sanitize::perPage($data['per_page'] ?? 40);
412 + $page = max(1, (int) ($data['page'] ?? 1));
413 + $activities = Activity::where('object_id', $id)->where('object_type', Constant::ACTIVITY_BOARD)->with(['user'])
325 414 ->orderBy('id', 'DESC')
326 415 ->paginate($per_page, ['*'], 'page', $page);
416 +
417 + Helper::translateActivities($activities);
418 +
419 + return $activities;
327 420 }
328 421
329 422 public function isAlreadyMember($boardId, $memberId)
330 423 {
@@ -334,26 +427,55 @@
334 427
335 428 return $isAlreadyMember ?? false;
336 429 }
337 430
338 - public function addMembersInBoard($boardId, $memberId)
431 + /**
432 + * Add a WordPress user to a board.
433 + *
434 + * @return User|false|null User on success, false for an existing relation,
435 + * or null when the board/user does not exist.
436 + */
437 + public function addMembersInBoard($boardId, $memberId, $isViewerOnly = null)
339 438 {
439 + $boardId = intval($boardId);
440 + $memberId = intval($memberId);
441 + $isViewerOnly = sanitize_text_field((string)$isViewerOnly);
442 +
443 + if ($boardId <= 0 || $memberId <= 0) {
444 + return null;
445 + }
446 +
340 447 $board = Board::find($boardId);
448 + $boardMember = User::find($memberId);
341 449
342 - if (!$board) {
450 + if (!$board || !$boardMember) {
451 + return null;
452 + }
453 + $isAlreadyMember = $this->isAlreadyMember($boardId, $memberId);
454 + if($isAlreadyMember) {
343 455 return false;
344 456 }
457 + $settings = Constant::BOARD_USER_SETTINGS;
345 458
459 + if($isViewerOnly === 'yes') {
460 + $settings = Constant::BOARD_USER_VIEWER_ONLY_SETTINGS;
461 + }
462 +
463 +
464 +
346 465 $board->users()->attach(
347 466 $memberId,
348 467 [
349 468 'object_type' => Constant::OBJECT_TYPE_BOARD_USER,
350 - 'settings' => maybe_serialize(Constant::BOARD_USER_SETTINGS),
469 + 'settings' => maybe_serialize($settings),
351 470 'preferences' => maybe_serialize(Constant::BOARD_NOTIFICATION_TYPES)
352 471 ]
353 472 );
354 - $boardMember = User::find($memberId);
355 - do_action('fluent_boards/board_member_added', $boardId, $boardMember);
473 + if(!$isViewerOnly) {
474 + do_action('fluent_boards/board_member_added', $boardId, $boardMember);
475 + } else {
476 + do_action('fluent_boards/board_viewer_added', $boardId, $boardMember);
477 + }
356 478 return $boardMember;
357 479 }
358 480
359 481 public function makeAdminOfBoard($boardId, $userId)
@@ -390,8 +512,75 @@
390 512 $user['is_board_admin'] = false;
391 513 return $user;
392 514 }
393 515
516 + /**
517 + * Create or update a board access relation with the selected member role.
518 + */
519 + public function syncBoardUserRole($boardId, $userId, $role)
520 + {
521 + $boardId = absint($boardId);
522 + $userId = absint($userId);
523 + $role = sanitize_text_field($role);
524 +
525 + if (!$boardId || !$userId || !in_array($role, ['admin', 'member', 'viewer'], true)) {
526 + return false;
527 + }
528 +
529 + $board = Board::find($boardId);
530 + $user = User::find($userId);
531 +
532 + if (!$board || !$user) {
533 + return false;
534 + }
535 +
536 + $boardUser = Relation::where('object_id', $boardId)
537 + ->where('object_type', Constant::OBJECT_TYPE_BOARD_USER)
538 + ->where('foreign_id', $userId)
539 + ->first();
540 +
541 + $previousSettings = $boardUser ? (array)$boardUser->settings : [];
542 +
543 + // Board roles live as flags on the board_user relation; member access means both flags stay false.
544 + $settings = [
545 + 'is_admin' => 'admin' === $role,
546 + 'is_viewer_only' => 'viewer' === $role,
547 + ];
548 +
549 + if ($boardUser) {
550 + $boardUser->settings = $settings;
551 + $boardUser->save();
552 + } else {
553 + // New access should get the same default notification preferences as the normal add-member flow.
554 + $board->users()->attach(
555 + $userId,
556 + [
557 + 'object_type' => Constant::OBJECT_TYPE_BOARD_USER,
558 + 'settings' => maybe_serialize($settings),
559 + 'preferences' => maybe_serialize(Constant::BOARD_NOTIFICATION_TYPES)
560 + ]
561 + );
562 + }
563 +
564 + // Only emit admin transition hooks when the role actually changes.
565 + if ('admin' === $role && empty($previousSettings['is_admin'])) {
566 + do_action('fluent_boards/board_admin_added', $boardId, $userId);
567 + } elseif (!empty($previousSettings['is_admin'])) {
568 + do_action('fluent_boards/board_admin_removed', $boardId, $userId);
569 + }
570 +
571 + if ('viewer' === $role) {
572 + do_action('fluent_boards/board_viewer_added', $boardId, $user);
573 + } elseif ('member' === $role) {
574 + do_action('fluent_boards/board_member_added', $boardId, $user);
575 + }
576 +
577 + $user['is_admin'] = 'admin' === $role;
578 + $user['is_board_admin'] = 'admin' === $role;
579 +
580 + return $user;
581 + }
582 +
394 583 public function getUsersOfBoards()
395 584 {
396 585 $userBoards = Relation::whereNotNull('board_id')
397 586 ->where('user_id', get_current_user_id())
@@ -400,35 +589,68 @@
400 589 return $userBoards;
401 590 }
402 591
403 592 /**
404 - * change board background
405 - * @param mixed $backgroundData
406 - * @return string
593 + * Change or clear the board background.
594 + *
595 + * Image attachments must belong to the target board and use the board
596 + * background attachment type before their identifiers can be persisted.
597 + *
598 + * @param array $backgroundData
599 + * @param int $board_id
600 + * @return array|string
601 + * @throws \Exception
407 602 */
408 -
409 603 public function setBoardBackground($backgroundData, $board_id)
410 604 {
411 - $board = Board::find($board_id);
605 + $boardId = absint($board_id);
606 + $board = Board::find($boardId);
607 +
608 + if (!$board) {
609 + throw new \Exception(esc_html__('Board not found.', 'fluent-boards'));
610 + }
611 +
612 + $oldBackground = $board->background;
613 +
614 + if (!empty($backgroundData['reset'])) {
615 + $board->background = '';
616 + $board->save();
617 + do_action('fluent_boards/board_background_updated', $boardId, $oldBackground);
618 +
619 + return $board->background;
620 + }
621 +
412 622 $background = $board->background;
413 -
414 - // if board background has color
415 - if (isset($backgroundData['color'])) {
416 - $background['color'] = $backgroundData['color'];
417 - $background['image_url'] = null;
418 - $background['is_image'] = false;
623 + if (!is_array($background)) {
624 + $background = [];
419 625 }
420 626
421 - // if board background has image
627 + // Resolve image metadata from the board-owned attachment, never from the client URL.
422 628 if (isset($backgroundData['image_url'])) {
423 - $background['image_url'] = $backgroundData['image_url'];
629 + $attachmentId = absint($backgroundData['id'] ?? 0);
630 + $attachment = Attachment::where('id', $attachmentId)
631 + ->where('object_id', $boardId)
632 + ->where('object_type', Constant::BOARD_BACKGROUND_IMAGE)
633 + ->first();
634 +
635 + if (!$attachment) {
636 + throw new \Exception(esc_html__('Background image not found.', 'fluent-boards'));
637 + }
638 +
639 + $background['id'] = (int) $attachment->id;
640 + $background['image_url'] = (new CommentService())->createPublicUrl($attachment, $boardId);
424 641 $background['is_image'] = true;
425 642 $background['color'] = null;
643 + } elseif (isset($backgroundData['color'])) {
644 + $background['id'] = $backgroundData['id'];
645 + $background['color'] = $backgroundData['color'];
646 + $background['image_url'] = null;
647 + $background['is_image'] = false;
426 648 }
427 - $background['id'] = $backgroundData['id'];
428 649
429 650 $board->background = $background;
430 651 $board->save();
652 + do_action('fluent_boards/board_background_updated', $boardId, $oldBackground);
431 653
432 654 return $board->background;
433 655 }
434 656
@@ -438,25 +660,85 @@
438 660 * @param mixed $board_id
439 661 * @param mixed $stage_slug
440 662 * @return array of available positions of the stage with one increased value because if the stage has 10 tasks than it will have 10 position and +1 as last position of the stage
441 663 */
442 - public function getStageTaskAvailablePositions($board_id, $stage_id)
664 + public function getStageTaskAvailablePositions($board_id, $stage_id, $task_id = null)
443 665 {
444 - $availablePositions = Task::query()
666 + $task_id = absint($task_id);
667 + $task = $task_id ? Task::find($task_id) : null;
668 + $isCurrentStage = $task
669 + && (int) $task->board_id === (int) $board_id
670 + && (int) $task->stage_id === (int) $stage_id;
671 +
672 + $stageTasks = Task::query()
445 673 ->where('board_id', $board_id)
446 674 ->where('parent_id', null)
447 675 ->where('stage_id', $stage_id)
448 676 ->whereNull('archived_at')
449 677 ->orderBy('position', 'asc')
450 - ->get()
451 - ->pluck('position')->toArray();
678 + ->get(['id', 'position']);
452 679
453 - $totalPosition = count($availablePositions);
454 - $availablePositions[$totalPosition] = $totalPosition + 1;
680 + if ($isCurrentStage) {
681 + $stageTasks = $stageTasks->filter(function ($stageTask) use ($task_id) {
682 + return (int) $stageTask->id !== $task_id;
683 + })->values();
684 + }
455 685
456 - return $availablePositions;
686 + $availablePositions = [];
687 + $moveTargets = [];
688 + $currentMoveTargetKey = null;
689 + $totalSlots = $stageTasks->count() + 1;
690 + $currentSlot = $this->getCurrentStageSlotIndex($task, $stageTasks, $isCurrentStage);
691 +
692 + for ($slotIndex = 0; $slotIndex < $totalSlots; $slotIndex++) {
693 + $slotNumber = $slotIndex + 1;
694 + // Each slot represents a drop target between two ordered tasks, so the
695 + // modal can send exact neighbour ids instead of a fragile display index.
696 + $prevTask = $slotIndex > 0 ? $stageTasks->get($slotIndex - 1) : null;
697 + $nextTask = $slotIndex < $stageTasks->count() ? $stageTasks->get($slotIndex) : null;
698 + $slotKey = 'slot_' . $slotNumber;
699 +
700 + $availablePositions[] = $slotNumber;
701 + $moveTargets[] = [
702 + 'key' => $slotKey,
703 + 'label' => $slotNumber,
704 + 'prevTaskId' => $prevTask ? (int) $prevTask->id : null,
705 + 'nextTaskId' => $nextTask ? (int) $nextTask->id : null,
706 + 'isCurrent' => $isCurrentStage && $currentSlot === $slotNumber,
707 + ];
708 +
709 + if ($isCurrentStage && $currentSlot === $slotNumber) {
710 + $currentMoveTargetKey = $slotKey;
711 + }
712 + }
713 +
714 + return [
715 + 'availablePositions' => $availablePositions,
716 + 'moveTargets' => $moveTargets,
717 + 'currentMoveTargetKey' => $currentMoveTargetKey,
718 + 'defaultMoveTargetKey' => 'slot_' . $totalSlots,
719 + ];
457 720 }
458 721
722 + private function getCurrentStageSlotIndex($task, $stageTasks, $isCurrentStage)
723 + {
724 + if (!$isCurrentStage || !$task) {
725 + return null;
726 + }
727 +
728 + $slotNumber = 1;
729 + foreach ($stageTasks as $stageTask) {
730 + if ((float) $task->position > (float) $stageTask->position) {
731 + $slotNumber++;
732 + continue;
733 + }
734 +
735 + break;
736 + }
737 +
738 + return $slotNumber;
739 + }
740 +
459 741 public function getAssigneesByBoard($board_id, $search = '')
460 742 {
461 743 $assignees = [];
462 744 $boardUsers = [];
@@ -516,10 +798,10 @@
516 798 }
517 799
518 800 // Check if the board is already in the list
519 801 if (!in_array($boardId, $recentBoardIds)) {
520 - // If there are already 3 boards, remove the last one
521 - if (count($recentBoardIds) >= 3) {
802 + // Keep the 4 most recently opened boards for the dashboard view.
803 + if (count($recentBoardIds) >= 4) {
522 804 array_pop($recentBoardIds);
523 805 }
524 806 } else {
525 807 // Remove the existing board id to move it to the front
@@ -555,8 +837,12 @@
555 837 if (!$recentBoardIds) {
556 838 return [];
557 839 }
558 840
841 + if (!is_array($recentBoardIds)) {
842 + $recentBoardIds = [];
843 + }
844 +
559 845 $currentUser = User::find($userId);
560 846
561 847 if (!PermissionManager::isAdmin($userId)){
562 848 $recentBoardIds = array_intersect($recentBoardIds, $currentUser->whichBoards->pluck('id')->toArray());
@@ -561,8 +847,10 @@
561 847 if (!PermissionManager::isAdmin($userId)){
562 848 $recentBoardIds = array_intersect($recentBoardIds, $currentUser->whichBoards->pluck('id')->toArray());
563 849 }
564 850
851 + $recentBoardIds = array_values(array_slice($recentBoardIds, 0, 4));
852 +
565 853 // This is for checking if that board is exists
566 854 // TODO: we will remove this code in future version
567 855 if (!$this->recentBoardBackwardCompatibilityCheck()) {
568 856 foreach ($recentBoardIds as $index => $boardId) {
@@ -575,9 +863,15 @@
575 863
576 864 $this->updateRecentBoardCheckMeta();
577 865 }
578 866
579 - return Board::whereIn('id', $recentBoardIds)->withCount('completedTasks')->with(['stages', 'users'])->get();
867 + return Board::whereIn('id', $recentBoardIds)
868 + ->whereNull('archived_at')
869 + ->excludeTemplates()
870 + ->availableInCurrentInstall()
871 + ->withCount('completedTasks')
872 + ->with(['stages', 'users'])
873 + ->get();
580 874 }
581 875
582 876 public function getRecentBoardCheckMeta($userId = null){
583 877 if (!$userId) {
@@ -675,10 +969,15 @@
675 969
676 970 $contactOfBoard->delete();
677 971 }
678 972
679 - public function sendInvitationToBoard($boardId, $email)
973 + public function sendInvitationToBoard($boardId, $email, $role = 'member')
680 974 {
975 + $role = sanitize_text_field($role);
976 + if (!in_array($role, ['manager', 'member', 'viewer'], true)) {
977 + $role = 'member';
978 + }
979 +
681 980 $user = User::query()->where('user_email', $email)->first();
682 981
683 982 if ($user) {
684 983 return $user;
@@ -685,9 +984,9 @@
685 984 }
686 985
687 986 $current_user_id = get_current_user_id();
688 987
689 - do_action('fluent_boards/send_invitation', $boardId, $email, $current_user_id);
988 + do_action('fluent_boards/send_invitation', $boardId, $email, $current_user_id, $role);
690 989
691 990 return;
692 991
693 992 }
@@ -699,92 +998,264 @@
699 998 ->where('key', Constant::BOARD_INVITATION)
700 999 ->get();
701 1000 }
702 1001
703 - public function deleteInvitation($invitationId)
1002 + /**
1003 + * Delete an invitation only when it belongs to the supplied board.
1004 + *
1005 + * The optional second argument lets older Pro releases receive a controlled
1006 + * error instead of reporting a successful deletion that never happened.
1007 + */
1008 + public function deleteInvitation($boardId, $invitationId = null)
704 1009 {
705 - Meta::findOrFail($invitationId)->delete();
1010 + if ($invitationId === null) {
1011 + throw new \Exception(
1012 + __('A board ID is required to delete an invitation.', 'fluent-boards')
1013 + );
1014 + }
1015 +
1016 + $boardId = intval($boardId);
1017 + $invitationId = intval($invitationId);
1018 +
1019 + if ($boardId <= 0 || $invitationId <= 0) {
1020 + return false;
1021 + }
1022 +
1023 + return (bool) Meta::query()
1024 + ->where('id', $invitationId)
1025 + ->where('object_id', $boardId)
1026 + ->where('object_type', Constant::OBJECT_TYPE_BOARD)
1027 + ->where('key', Constant::BOARD_INVITATION)
1028 + ->delete();
706 1029 }
707 1030
708 - public function hasDataChanged($boardId)
1031 + public function hasDataChanged($boardId, $includeArchived = false, $since = null)
709 1032 {
710 1033 $stages = [];
711 1034 $labels = [];
712 1035 $tasks = [];
713 - $labels = [];
714 - $taskDeleted = false;
715 - $stageDeleted = false;
716 - $labelDeleted = false;
717 - $oneMinuteAgoTimestamp = current_time('timestamp') - 60; // Get the current timestamp and subtract 60 seconds
718 - $oneMinuteAgo = date_i18n('Y-m-d H:i:s', $oneMinuteAgoTimestamp); // Format the timestamp into the desired format in GMT
1036 + $syncStartedAt = current_time('mysql');
1037 + $isCursorRequest = !empty($since);
1038 + $forceFullSync = false;
719 1039
1040 + if ($isCursorRequest) {
1041 + $lastUpdated = $this->normalizeSyncCursor($since, $syncStartedAt);
1042 + $forceFullSync = !$lastUpdated;
1043 + } else {
1044 + $oneMinuteAgoTimestamp = current_time('timestamp') - 60;
1045 + $lastUpdated = date_i18n('Y-m-d H:i:s', $oneMinuteAgoTimestamp);
1046 + }
1047 +
720 1048 $board = Board::find($boardId);
721 1049 if (!$board) {
722 - throw new \Exception("Board doesn't exists");
1050 + throw new \Exception(esc_html__("Board doesn't exists", 'fluent-boards'));
723 1051 }
724 - // if stage in this board has been deleted
725 - $stageDeleted = Activity::where('object_id', $boardId)->where('updated_at', '>=', $oneMinuteAgo)->where('action', 'deleted')->where('column', 'stage')->exists();
1052 + $boardUpdatedAt = $this->formatSyncTimestamp($board->updated_at);
1053 + $boardChanged = !$isCursorRequest || $forceFullSync || $boardUpdatedAt >= $lastUpdated;
726 1054
727 - if ($stageDeleted) {
728 - $stages = $board->stages;
1055 + // Reset the local list if a change can remove an item from the user's current view.
1056 + $stageActivityQuery = Activity::where('object_id', $boardId)
1057 + ->where('object_type', Constant::ACTIVITY_BOARD)
1058 + ->where('updated_at', '>=', $lastUpdated)
1059 + ->where('column', 'stage');
1060 +
1061 + $stageResetRequired = $forceFullSync || (clone $stageActivityQuery)
1062 + ->whereIn('action', ['deleted', 'archived', 'restored'])
1063 + ->exists();
1064 +
1065 + if ($stageResetRequired) {
1066 + $stagesQuery = Stage::where('board_id', $boardId)->orderBy('position', 'asc');
1067 + if (!$includeArchived) {
1068 + $stagesQuery->whereNull('archived_at');
1069 + }
1070 + $stages = $stagesQuery->get();
729 1071 } else {
730 - $stages = (new StageService())->getLastOneMinuteUpdatedStages($boardId);
1072 + $stages = (new StageService())->getLastOneMinuteUpdatedStages($boardId, $lastUpdated, $includeArchived);
731 1073 }
732 1074
733 - $labelDeleted = Activity::where('object_id', $boardId)->where('updated_at', '>=', $oneMinuteAgo)->where('action', 'deleted')->where('column', 'label')->exists();
734 - if ($labelDeleted) {
735 - $labels = $board->labels;
1075 + $labelResetRequired = $forceFullSync || Activity::where('object_id', $boardId)
1076 + ->where('object_type', Constant::ACTIVITY_BOARD)
1077 + ->where('updated_at', '>=', $lastUpdated)
1078 + ->where('action', 'deleted')
1079 + ->where('column', 'label')
1080 + ->exists();
1081 + if ($labelResetRequired) {
1082 + $labelsQuery = Label::where('board_id', $boardId)->orderBy('position', 'asc');
1083 + if (!$includeArchived) {
1084 + $labelsQuery->whereNull('archived_at');
1085 + }
1086 + $labels = $labelsQuery->get();
736 1087 } else {
737 - $labels = (new LabelService())->getLastOneMinuteUpdatedLabels($boardId);
1088 + $labels = (new LabelService())->getLastOneMinuteUpdatedLabels($boardId, $lastUpdated, $includeArchived);
738 1089 }
739 1090
740 - // if task in this board has been deleted
741 - $taskDeletedOrMovedFormBoard = Activity::where('object_id', $boardId)->where('updated_at', '>=', $oneMinuteAgo)->where('action', 'deleted')->orWhere('action', 'moved')->where('column', 'task')->exists();
1091 + $stageArchiveRestored = !$forceFullSync && (clone $stageActivityQuery)
1092 + ->whereIn('action', ['archived', 'restored'])
1093 + ->exists();
742 1094
743 - if ($taskDeletedOrMovedFormBoard) {
744 - $tasks = Task::query()
1095 + $taskResetRequired = $forceFullSync || $stageArchiveRestored || Activity::where('object_id', $boardId)
1096 + ->where('object_type', Constant::ACTIVITY_BOARD)
1097 + ->where('updated_at', '>=', $lastUpdated)
1098 + ->where(function($query) {
1099 + $query->where('action', 'deleted')
1100 + ->orWhere('action', 'moved')
1101 + ->orWhere('action', 'archived')
1102 + ->orWhere('action', 'restored');
1103 + })
1104 + ->where('column', 'task')
1105 + ->exists();
1106 + if ($taskResetRequired) {
1107 + $tasksQuery = Task::query()
745 1108 ->where([
746 1109 'board_id' => $boardId,
747 1110 'parent_id' => null,
748 - 'archived_at' => null
749 1111 ])
750 - ->with(['assignees', 'labels'])
751 - ->orderBy('due_at', 'ASC')
752 - ->get();
1112 + ->with(['assignees', 'labels', 'watchers']);
1113 +
1114 + if (!$includeArchived) {
1115 + $tasksQuery->whereNull('archived_at');
1116 + }
1117 +
1118 + if (!!defined('FLUENT_BOARDS_PRO_VERSION')) {
1119 + $tasksQuery->with('customFields');
1120 + }
1121 +
1122 + $tasks = $tasksQuery->orderBy('due_at', 'ASC')->get();
753 1123 } else {
754 - $tasks = (new TaskService())->getLastOneMinuteUpdatedTasks($boardId);
1124 + $tasks = (new TaskService())->getLastOneMinuteUpdatedTasks($boardId, $lastUpdated, $includeArchived);
755 1125 }
756 1126
757 1127 foreach ($tasks as $task) {
758 1128 $task->isOverdue = $task->isOverdue();
759 1129 $task->isUpcoming = $task->upcoming();
1130 + $task->is_watching = $task->isWatching();
1131 + $task->contact = Helper::crm_contact($task->crm_contact_id);
1132 + $task->assignees = Helper::sanitizeUserCollections($task->assignees);
1133 + $task->watchers = Helper::sanitizeUserCollections($task->watchers);
760 1134 }
761 1135
762 - if ($board->updated_at >= $oneMinuteAgo) {
763 - $board->background = \maybe_unserialize($board->background);
764 - } else {
765 - $board = [];
1136 + $board->background = \maybe_unserialize($board->background);
1137 + if(!!defined('FLUENT_BOARDS_PRO_VERSION')) {
1138 + $board->custom_fields = $board->customFields;
766 1139 }
767 1140
1141 + $boardPayload = $boardChanged ? $board : (object) [];
1142 + $hasChanges = $boardChanged
1143 + || $stageResetRequired
1144 + || $labelResetRequired
1145 + || $taskResetRequired
1146 + || count($stages)
1147 + || count($labels)
1148 + || count($tasks);
1149 +
768 1150 return [
769 - 'board' => $board,
770 - 'stages' => $stages,
771 - 'labels' => $labels,
772 - 'tasks' => $tasks,
773 - 'taskDeleted' => $taskDeletedOrMovedFormBoard,
774 - 'stageDeleted' => $stageDeleted,
1151 + 'board' => $boardPayload,
1152 + 'stages' => $stages,
1153 + 'labels' => $labels,
1154 + 'tasks' => $tasks,
1155 + 'taskDeleted' => $taskResetRequired,
1156 + 'stageDeleted' => $stageResetRequired,
1157 + 'labelDeleted' => $labelResetRequired,
1158 + 'taskResetRequired' => $taskResetRequired,
1159 + 'stageResetRequired' => $stageResetRequired,
1160 + 'labelResetRequired' => $labelResetRequired,
1161 + 'has_changes' => (bool) $hasChanges,
1162 + 'synced_at' => $syncStartedAt,
1163 + 'sync_reset' => $forceFullSync,
775 1164 ];
776 1165 }
777 1166
778 - public function getAssociatedBoards($associatedId)
1167 + private function normalizeSyncCursor($since, $syncStartedAt)
779 1168 {
1169 + if (!is_string($since)) {
1170 + return null;
1171 + }
780 1172
781 - $boardIds = Meta::query()->where('value', $associatedId)
1173 + $since = trim($since);
1174 + if (!preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $since)) {
1175 + return null;
1176 + }
1177 +
1178 + if ($since > $syncStartedAt) {
1179 + return null;
1180 + }
1181 +
1182 + if (strtotime($since) < strtotime('-24 hours', strtotime($syncStartedAt))) {
1183 + return null;
1184 + }
1185 +
1186 + return $since;
1187 + }
1188 +
1189 + private function formatSyncTimestamp($timestamp)
1190 + {
1191 + if ($timestamp instanceof \DateTimeInterface) {
1192 + return $timestamp->format('Y-m-d H:i:s');
1193 + }
1194 +
1195 + return (string) $timestamp;
1196 + }
1197 +
1198 + /**
1199 + * Get CRM-associated boards from non-archived task links and board-level contact metadata.
1200 + *
1201 + * @param int $associatedId CRM contact/subscriber id.
1202 + * @param int|null $userId WordPress user id used for board access checks.
1203 + * @return \FluentBoards\Framework\Database\Orm\Collection|array
1204 + */
1205 + public function getAssociatedBoards($associatedId, $userId = null)
1206 + {
1207 + $associatedId = absint($associatedId);
1208 + $userId = $userId ?: get_current_user_id();
1209 +
1210 + if (!$associatedId || !$userId) {
1211 + return [];
1212 + }
1213 +
1214 + $taskBoardIds = Task::query()
1215 + ->select('board_id')
1216 + ->where('crm_contact_id', $associatedId)
1217 + ->whereNull('archived_at')
1218 + ->whereNotNull('board_id')
1219 + ->distinct()
1220 + ->pluck('board_id')
1221 + ->toArray();
1222 +
1223 + $metaBoardIds = Meta::query()
1224 + ->select('object_id')
1225 + ->where('value', $associatedId)
782 1226 ->where('object_type', Constant::OBJECT_TYPE_BOARD)
783 - ->where('key', Constant::BOARD_ASSOCIATED_CRM_CONTACT)
784 - ->pluck('object_id');
1227 + ->whereIn('key', [
1228 + Constant::BOARD_ASSOCIATED_CRM_CONTACT,
1229 + self::LEGACY_BOARD_ASSOCIATED_CRM_CONTACT,
1230 + ])
1231 + ->distinct()
1232 + ->pluck('object_id')
1233 + ->toArray();
785 1234
786 - return Board::query()->whereIn('id', $boardIds)->with('stages', 'users')->get();
1235 + $boardIds = array_values(array_unique(array_filter(array_map('intval', array_merge(
1236 + $taskBoardIds,
1237 + $metaBoardIds
1238 + )))));
1239 +
1240 + if (!$boardIds) {
1241 + return [];
1242 + }
1243 +
1244 + $boards = Board::query()
1245 + ->whereIn('id', $boardIds)
1246 + ->whereNull('archived_at')
1247 + ->byAccessUser($userId)
1248 + ->withCount('completedTasks')
1249 + ->with(['stages', 'users'])
1250 + ->orderBy('created_at', 'DESC')
1251 + ->get();
1252 +
1253 + foreach ($boards as $board) {
1254 + $board->users = Helper::sanitizeUserCollections($board->users);
1255 + }
1256 +
1257 + return $boards;
787 1258 }
788 1259
789 1260 private function deleteBoardMeta($boardId)
790 1261 {
@@ -794,8 +1265,13 @@
794 1265 }
795 1266
796 1267 public function copyBoard($boardData)
797 1268 {
1269 + $sourceBoard = Board::findOrFail($boardData['source_board_id']);
1270 + $boardData['background'] = $sourceBoard->background;
1271 + if (isset($boardData['description'])) {
1272 + $boardData['description'] = DescriptionMarkdownConverter::normalize($boardData['description']);
1273 + }
798 1274 $boardData = apply_filters('fluent_boards/before_create_board', $boardData);
799 1275
800 1276 $board = Board::create($boardData);
801 1277
@@ -803,101 +1279,345 @@
803 1279
804 1280 return $board;
805 1281 }
806 1282
807 - public function getBoardReports($board_id)
1283 + /**
1284 + * Copy board member relations and their role preferences to a duplicated board.
1285 + */
1286 + public function copyMembersOfBoard($sourceBoardId, Board $newBoard)
808 1287 {
809 - $taskQuery = Task::where('board_id', $board_id)
810 - ->whereNull('parent_id')
811 - ->whereNull('archived_at');
1288 + $existingMemberIds = Relation::where('object_id', $newBoard->id)
1289 + ->where('object_type', Constant::OBJECT_TYPE_BOARD_USER)
1290 + ->pluck('foreign_id')
1291 + ->toArray();
812 1292
813 - return $this->getTaskReports($taskQuery);
1293 + $sourceMembers = Relation::where('object_id', $sourceBoardId)
1294 + ->where('object_type', Constant::OBJECT_TYPE_BOARD_USER)
1295 + ->get();
1296 +
1297 + foreach ($sourceMembers as $sourceMember) {
1298 + if (in_array($sourceMember->foreign_id, $existingMemberIds)) {
1299 + continue;
1300 + }
1301 +
1302 + Relation::create([
1303 + 'object_id' => $newBoard->id,
1304 + 'foreign_id' => $sourceMember->foreign_id,
1305 + 'object_type' => Constant::OBJECT_TYPE_BOARD_USER,
1306 + 'settings' => $sourceMember->settings,
1307 + 'preferences' => $sourceMember->preferences
1308 + ]);
1309 +
1310 + $boardMember = User::find($sourceMember->foreign_id);
1311 + $settings = is_array($sourceMember->settings) ? $sourceMember->settings : [];
1312 +
1313 + if ($boardMember) {
1314 + if (!empty($settings['is_viewer_only'])) {
1315 + do_action('fluent_boards/board_viewer_added', $newBoard->id, $boardMember);
1316 + } else {
1317 + do_action('fluent_boards/board_member_added', $newBoard->id, $boardMember);
1318 + }
1319 +
1320 + if (!empty($settings[Constant::IS_BOARD_ADMIN])) {
1321 + do_action('fluent_boards/board_admin_added', $newBoard->id, $sourceMember->foreign_id);
1322 + }
1323 + }
1324 +
1325 + $existingMemberIds[] = $sourceMember->foreign_id;
1326 + }
814 1327 }
815 1328
816 - public function getAllBoardReports(){
1329 + /**
1330 + * Copy Pro custom field definitions and return an old-to-new ID map.
1331 + */
1332 + public function copyCustomFieldsOfBoard($fromBoardId, $board)
1333 + {
1334 + if (!defined('FLUENT_BOARDS_PRO') || !class_exists(CustomField::class)) {
1335 + return [];
1336 + }
1337 +
1338 + $fromBoardId = absint($fromBoardId);
1339 +
1340 + if (!$fromBoardId || !$board) {
1341 + return [];
1342 + }
1343 +
1344 + $customFieldMap = [];
1345 + $customFields = CustomField::where('board_id', $fromBoardId)
1346 + ->whereNull('archived_at')
1347 + ->orderBy('position', 'asc')
1348 + ->get();
1349 +
1350 + foreach ($customFields as $customField) {
1351 + $newCustomField = $customField->replicate();
1352 + $newCustomField->board_id = $board->id;
1353 + $newCustomField->save();
1354 +
1355 + $customFieldMap[(int) $customField->id] = $newCustomField->id;
1356 + }
1357 +
1358 + return $customFieldMap;
1359 + }
1360 +
1361 + public function archiveBoard($boardId)
1362 + {
1363 + $board = Board::findOrFail($boardId);
1364 + $board->archived_at = current_time('mysql');
1365 + $board->save();
1366 +
1367 + do_action('fluent_boards/board_archived', $board);
1368 + return $board;
1369 + }
1370 +
1371 + public function restoreBoard($boardId)
1372 + {
1373 + $board = Board::findOrFail($boardId);
1374 + $board->archived_at = null;
1375 + $board->save();
1376 +
1377 + do_action('fluent_boards/board_restored', $board);
1378 + return $board;
1379 + }
1380 +
1381 + public function makeMember($boardId, $userId)
1382 + {
1383 + $boardUser = Relation::where('object_id', $boardId)
1384 + ->where('object_type', Constant::OBJECT_TYPE_BOARD_USER)
1385 + ->where('foreign_id', $userId)->first();
1386 +
1387 + $boardUser->settings = [
1388 + 'is_admin' => false,
1389 + 'is_viewer_only' => false
1390 + ];
1391 +
1392 + $boardUser->save();
1393 + $user = User::findOrFail($userId);
1394 + do_action('fluent_boards/board_member_added', $boardId, $boardUser);
1395 + $user['is_admin'] = false;
1396 + $user['is_board_admin'] = false;
1397 + return $user;
1398 + }
1399 +
1400 + public function makeViewer($boardId, $userId)
1401 + {
1402 + $boardUser = Relation::where('object_id', $boardId)
1403 + ->where('object_type', Constant::OBJECT_TYPE_BOARD_USER)
1404 + ->where('foreign_id', $userId)->first();
1405 +
1406 + $boardUser->settings = [
1407 + 'is_admin' => false,
1408 + 'is_viewer_only' => true
1409 + ];
1410 +
1411 + $boardUser->save();
1412 + $user = User::findOrFail($userId);
1413 + do_action('fluent_boards/board_viewer_added', $boardId, $boardUser);
1414 + $user['is_admin'] = false;
1415 + $user['is_board_admin'] = false;
1416 + return $user;
1417 + }
1418 +
1419 + private function getUserWisePinnedBoards()
1420 + {
817 1421 $userId = get_current_user_id();
818 1422
819 - $taskQuery = Task::whereNull('parent_id')
820 - ->whereNull('archived_at');
1423 + $pinnedBoardMeta = Meta::query()->where('object_id', $userId)
1424 + ->where('object_type', Constant::OBJECT_TYPE_USER)
1425 + ->where('key', Constant::USER_PINNED_BOARDS)
1426 + ->first();
821 1427
822 - if (!PermissionManager::isAdmin($userId))
823 - {
824 - $currentUser = User::find($userId);
825 - $relatedBoardIds = $currentUser->whichBoards->where('type', 'to-do')->pluck('id');
826 - $taskQuery->whereIn('board_id', $relatedBoardIds);
1428 + return $pinnedBoardMeta;
1429 + }
1430 +
1431 + /**
1432 + * Sidebar counts cover every board the user can access, so they are counted
1433 + * with their own queries rather than derived from the filtered/paginated list.
1434 + *
1435 + * byAccessUser() re-reads the user's accessible board ids from the database on
1436 + * every call, so the access scope is resolved once and cloned per count.
1437 + *
1438 + * @return array{all: int, pinned: int, archived: int}
1439 + */
1440 + public function getBoardCounts($userId)
1441 + {
1442 + $baseQuery = Board::byAccessUser($userId)
1443 + ->excludeTemplates()
1444 + ->availableInCurrentInstall();
1445 +
1446 + $counts = [
1447 + 'all' => (clone $baseQuery)->whereNull('archived_at')->count(),
1448 + 'pinned' => 0,
1449 + 'archived' => (clone $baseQuery)->whereNotNull('archived_at')->count()
1450 + ];
1451 +
1452 + $pinnedIds = $this->getPinnedBoardIds();
1453 +
1454 + if ($pinnedIds) {
1455 + $counts['pinned'] = (clone $baseQuery)->whereNull('archived_at')
1456 + ->whereIn('id', $pinnedIds)
1457 + ->count();
827 1458 }
828 1459
829 - return $this->getTaskReports($taskQuery);
1460 + return $counts;
830 1461 }
831 1462
832 - private function getTaskReports($taskQuery)
1463 + /**
1464 + * @return array board ids the current user has pinned
1465 + */
1466 + public function getPinnedBoardIds()
833 1467 {
834 - $totalTasksQuery = clone $taskQuery;
835 - $completedTaskQuery = clone $taskQuery;
836 - $openTaskQuery = clone $taskQuery;
837 - $overDueTaskQuery = clone $taskQuery;
1468 + $pinnedBoardMeta = $this->getUserWisePinnedBoards();
838 1469
839 - $completedTaskCount = $completedTaskQuery->where('status', 'closed')->count();
840 - $openTaskCount = $openTaskQuery->where('status', 'open')->count();
841 - $overDueTasks = $overDueTaskQuery->overdue(true)->count();
842 - $totalTasks = $totalTasksQuery->count();
1470 + if (!$pinnedBoardMeta) {
1471 + return [];
1472 + }
843 1473
844 - $taskQuery->where('status', 'open');
1474 + return array_map('intval', (array) $pinnedBoardMeta->value);
1475 + }
845 1476
846 - $highQuery = clone $taskQuery;
847 - $mediumQuery = clone $taskQuery;
848 - $lowQuery = clone $taskQuery;
1477 + public function getPinnedBoards()
1478 + {
1479 + $pinnedBoardMeta = $this->getUserWisePinnedBoards();
849 1480
850 - $high = $highQuery->where('priority', 'high')->count();
851 - $low = $mediumQuery->where('priority', 'low')->count();
852 - $medium = $lowQuery->where('priority', 'medium')->count();
1481 + if (!$pinnedBoardMeta) {
1482 + return [];
1483 + } else {
1484 + $ids = $pinnedBoardMeta->value;
853 1485
854 - $reportData = [
855 - 'completion' => [
856 - 'completed' => $completedTaskCount,
857 - 'incomplete' => $openTaskCount,
858 - 'overdue' => $overDueTasks,
859 - 'total' => $totalTasks
860 - ],
861 - 'priority' => [
862 - 'high' => $high,
863 - 'medium' => $medium,
864 - 'low' => $low
865 - ]
1486 + // Convert to array of integers
1487 + $intIds = array_map('intval', $ids);
866 1488
867 - ];
868 - return $reportData;
1489 + return Board::whereIn('id', $intIds)
1490 + ->whereNull('archived_at')
1491 + ->byAccessUser(get_current_user_id())
1492 + ->get();
1493 + }
869 1494 }
870 1495
871 - public function getStageWiseBoardReports($board_id)
1496 + public function pinBoard($boardId)
872 1497 {
873 - $stages = Stage::where('board_id', $board_id)
874 - ->where('type', 'stage')
875 - ->whereNull('archived_at')
1498 + $pinnedBoardMeta = $this->getUserWisePinnedBoards();
1499 +
1500 + if ($pinnedBoardMeta) {
1501 + $currentPinnedBoards = $pinnedBoardMeta->value;
1502 + if (!in_array($boardId, $currentPinnedBoards)) {
1503 + $currentPinnedBoards[] = $boardId;
1504 + $pinnedBoardMeta->value = $currentPinnedBoards;
1505 + $pinnedBoardMeta->save();
1506 + }
1507 + } else {
1508 + // Create an empty array
1509 + $boardIds = [];
1510 + $boardIds[] = $boardId;
1511 +
1512 + $meta = new Meta();
1513 + $meta->object_id = get_current_user_id();
1514 + $meta->object_type = Constant::OBJECT_TYPE_USER;
1515 + $meta->key = Constant::USER_PINNED_BOARDS;
1516 + $meta->value = $boardIds;
1517 + $meta->save();
1518 + }
1519 + }
1520 +
1521 + /**
1522 + * @param $boardId
1523 + * @return bool
1524 + */
1525 + public function unpinBoard($boardId)
1526 + {
1527 + $pinnedBoardMeta = $this->getUserWisePinnedBoards();
1528 +
1529 + if (!$pinnedBoardMeta) {
1530 + return false;
1531 + }
1532 +
1533 + $currentPinnedBoards = $pinnedBoardMeta->value;
1534 + if (in_array($boardId, $currentPinnedBoards)) {
1535 + $index = array_search($boardId, $currentPinnedBoards);
1536 + array_splice($currentPinnedBoards, $index, 1);
1537 + $pinnedBoardMeta->value = $currentPinnedBoards;
1538 + $pinnedBoardMeta->save();
1539 + return true;
1540 + }
1541 +
1542 + return false;
1543 + }
1544 +
1545 + /**
1546 + * @param $boardId
1547 + * @return bool
1548 + * If board id is in user's current pinned boards list
1549 + */
1550 + public function isPinned($boardId)
1551 + {
1552 + $pinnedBoardMeta = $this->getUserWisePinnedBoards();
1553 +
1554 + if (!$pinnedBoardMeta) {
1555 + return false;
1556 + }
1557 +
1558 + $currentPinnedBoards = $pinnedBoardMeta->value;
1559 + if (in_array($boardId, $currentPinnedBoards)) {
1560 + return true;
1561 + }
1562 +
1563 + return false;
1564 + }
1565 +
1566 + public function getBoardFolder($boardId)
1567 + {
1568 + $relation = Relation::where('object_type', Constant::OBJECT_TYPE_FOLDER_BOARD)
1569 + ->where('foreign_id', $boardId)
1570 + ->first();
1571 +
1572 + if (!$relation) {
1573 + return null;
1574 + }
1575 +
1576 + return Folder::find($relation->object_id);
1577 + }
1578 +
1579 + public function deleteWebhookData($boardId)
1580 + {
1581 + $outgoingRelations = Relation::where('object_type', 'outgoing_webhook_board')
1582 + ->where('foreign_id', $boardId)
876 1583 ->get();
877 1584
878 - foreach ($stages as $stage) {
879 - $completedTaskCount = Task::where('stage_id', $stage->id)
880 - ->where('status', 'closed')
1585 + foreach ($outgoingRelations as $relation) {
1586 + $webhookMetaId = (int) $relation->object_id;
1587 +
1588 + $linkedCount = Relation::where('object_type', 'outgoing_webhook_board')
1589 + ->where('object_id', $webhookMetaId)
881 1590 ->count();
882 1591
883 - $openTaskCount = Task::where('stage_id', $stage->id)
884 - ->whereNull('due_at')
885 - ->where('status', 'open')
886 - ->count();
1592 + if ($linkedCount === 1) {
1593 + Meta::where('id', $webhookMetaId)
1594 + ->where('object_type', 'outgoing_webhook')
1595 + ->delete();
1596 + } else if ($linkedCount > 1) {
1597 + $meta = Meta::find($webhookMetaId);
1598 + if ($meta && $meta->object_type === 'outgoing_webhook') {
1599 + $value = $meta->value;
887 1600
888 - $overDue = Task::where('stage_id', $stage->id)
889 - ->whereNotNull('due_at')
890 - ->where('status', 'open')
891 - ->overdue(true)
892 - ->count();
1601 + if (isset($value['board_id'])) {
1602 + $boards = $value['board_id'];
893 1603
894 - $stage->report = [
895 - 'completed' => $completedTaskCount,
896 - 'incomplete' => $openTaskCount,
897 - 'overdue' => $overDue
898 - ];
1604 + if (is_array($boards)) {
1605 + $boards = array_values(array_filter($boards, function ($id) use ($boardId) {
1606 + return intval($id) !== intval($boardId);
1607 + }));
1608 + $value['board_id'] = $boards;
1609 + } else {
1610 + if ($boards !== null && intval($boards) === intval($boardId)) {
1611 + $value['board_id'] = [];
1612 + }
1613 + }
1614 +
1615 + $meta->value = $value;
1616 + $meta->save();
1617 + }
1618 + }
1619 + }
1620 + $relation->delete();
899 1621 }
900 -
901 - return $stages;
902 1622 }
903 1623 }