PluginProbe
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration / 2.1.0
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration v2.1.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 1.41 All 42 releases
← All changes | app/Services/BoardService.php +502 -233 1.91.62.1.0 View file →
@@ -2,10 +2,13 @@
2 2
3 3 namespace FluentBoards\App\Services;
4 4
5 5 use FluentBoards\App\Models\Activity;
6 +use FluentBoards\App\Models\Attachment;
6 7 use FluentBoards\App\Models\Board;
7 8 use FluentBoards\App\Models\Comment;
9 +use FluentBoards\App\Models\Folder;
10 +use FluentBoards\App\Models\Label;
8 11 use FluentBoards\App\Models\Meta;
9 12 use FluentBoards\App\Models\Relation;
10 13 use FluentBoards\App\Models\Stage;
11 14 use FluentBoards\App\Models\Task;
@@ -11,15 +14,21 @@
11 14 use FluentBoards\App\Models\Task;
12 15 use FluentBoards\App\Models\TaskMeta;
13 16 use FluentBoards\App\Models\User;
14 17 use FluentBoards\App\Services\Libs\FileSystem;
15 -use FluentBoardsPro\App\Models\Folder;
18 +use FluentBoards\App\Services\DescriptionMarkdownConverter;
16 19
17 20 class BoardService
18 21 {
22 + private const LEGACY_BOARD_ASSOCIATED_CRM_CONTACT = 'crm_contact';
23 +
19 24 public function getBoardsByType($type)
20 25 {
21 - return Board::where('type', $type)->whereNull('archived_at')->orderBy('created_at', 'ASC')->get();
26 + return Board::where('type', sanitize_text_field($type))
27 + ->whereNull('archived_at')
28 + ->byAccessUser(get_current_user_id())
29 + ->orderBy('created_at', 'ASC')
30 + ->get();
22 31 }
23 32
24 33 public function deleteBoard($boardId)
25 34 {
@@ -84,9 +93,8 @@
84 93 $this->deleteWebhookData($boardId);
85 94
86 95 $board->delete();
87 96 FileSystem::deleteDir('board_'.$boardId);
88 -// do_action('fluent_boards/board_deleted', $board);
89 97 }
90 98
91 99 public function fetchBoardMeta($boardId)
92 100 {
@@ -142,9 +150,9 @@
142 150 {
143 151 $boardData = [
144 152 'title' => $boardData['title'],
145 153 'type' => $boardData['type'] ? $boardData['type'] : 'to-do',
146 - 'description' => $boardData['description'],
154 + 'description' => DescriptionMarkdownConverter::normalize($boardData['description'] ?? ''),
147 155 'currency' => isset($boardData['currency']) ? $boardData['currency'] : 'USD',
148 156 'background' => isset($boardData['background']) ? $boardData['background'] : '',
149 157 'created_by' => isset($boardData['created_by']) ? $boardData['created_by'] : get_current_user_id()
150 158 ];
@@ -157,12 +165,23 @@
157 165
158 166 return $board;
159 167 }
160 168
161 - private function setCurrentUserPreferencesOnBoardCreate($board)
169 + /**
170 + * Attach a user-owned board to its creator with Board Admin preferences.
171 + *
172 + * @param Board $board
173 + * @return void
174 + */
175 + public function setCurrentUserPreferencesOnBoardCreate($board)
162 176 {
177 + $creatorId = absint($board->created_by);
178 + if (!$creatorId) {
179 + return;
180 + }
181 +
163 182 $board->users()->attach(
164 - $board->created_by,
183 + $creatorId,
165 184 [
166 185 'object_type' => Constant::OBJECT_TYPE_BOARD_USER,
167 186 'settings' => maybe_serialize([
168 187 Constant::IS_BOARD_ADMIN => true
@@ -210,9 +229,18 @@
210 229 ->first();
211 230 if ($recentlyOpened) {
212 231 $recentBoardIds = $recentlyOpened->value;
213 232
233 + // Recently opened meta can be empty or legacy-shaped; only splice a usable board ID list.
234 + if (!is_array($recentBoardIds)) {
235 + return;
236 + }
237 +
214 238 $index = array_search($boardId, $recentBoardIds);
239 + if ($index === false) {
240 + return;
241 + }
242 +
215 243 array_splice($recentBoardIds, $index, 1);
216 244
217 245 $recentlyOpened->value = $recentBoardIds;
218 246 $recentlyOpened->save();
@@ -227,9 +255,9 @@
227 255 } else {
228 256 throw new \Exception(esc_html__('Title cannot be empty', 'fluent-boards'));
229 257 }
230 258 if (isset($data['description'])) {
231 - $data['description'] = $data['description'];
259 + $data['description'] = DescriptionMarkdownConverter::normalize($data['description']);
232 260 }
233 261 $board->fill($data);
234 262 $board->save();
235 263 // do_action('fluent_boards/board_updated', $board);
@@ -286,26 +314,42 @@
286 314 }
287 315 return $processedStages;
288 316 }
289 317
318 + /**
319 + * Archive a stage and persist the user who archived it for future archive-list metadata.
320 + */
290 321 public function archiveStage($boardId, $stage)
291 322 {
323 + $settings = $stage->settings ?: [];
324 + $settings['archived_by_id'] = absint(get_current_user_id()) ?: null;
325 +
292 326 $stage->archived_at = current_time('mysql');
293 327 $stage->position = 0;
328 + $stage->settings = $settings;
294 329 $stage->save();
295 330
296 - do_action('fluent_boards/stage_archived', $boardId, $stage);
331 + do_action('fluent_boards/stage_archived', $boardId, $stage); // Old hook
332 + do_action('fluent_boards/stage_archived_with_tasks', $boardId, $stage); // New hook
297 333 return $stage;
298 334 }
299 335
336 + /**
337 + * Restore an archived stage and clear stale archived-by metadata.
338 + */
300 339 public function restoreStage($boardId, $stage)
301 340 {
302 341 $stageService = new StageService();
303 342 $lastStagePosition = $stageService->getLastPositionOfStagesOfBoard($stage->board_id);
343 + $settings = $stage->settings ?: [];
344 + $settings['archived_by_id'] = null;
345 +
304 346 $stage->archived_at = null;
305 347 $stage->position = $lastStagePosition ? $lastStagePosition->position + 1 : 1;
348 + $stage->settings = $settings;
306 349 $stage->save();
307 - do_action('fluent_boards/board_stage_restored', $boardId, $stage->title);
350 + do_action('fluent_boards/board_stage_restored', $boardId, $stage->title); // Old hook
351 + do_action('fluent_boards/stage_restored_with_tasks', $boardId, $stage); // New hook
308 352 return $stage;
309 353 }
310 354
311 355 public function getActivities($id, $data)
@@ -311,11 +355,15 @@
311 355 public function getActivities($id, $data)
312 356 {
313 357 $per_page = isset($data['per_page']) ? $data['per_page'] : 40;
314 358 $page = isset($data['page']) ? $data['page'] : 1;
315 - return Activity::where('object_id', $id)->where('object_type', Constant::ACTIVITY_BOARD)->with(['user'])
359 + $activities = Activity::where('object_id', $id)->where('object_type', Constant::ACTIVITY_BOARD)->with(['user'])
316 360 ->orderBy('id', 'DESC')
317 361 ->paginate($per_page, ['*'], 'page', $page);
362 +
363 + Helper::translateActivities($activities);
364 +
365 + return $activities;
318 366 }
319 367
320 368 public function isAlreadyMember($boardId, $memberId)
321 369 {
@@ -325,14 +373,29 @@
325 373
326 374 return $isAlreadyMember ?? false;
327 375 }
328 376
377 + /**
378 + * Add a WordPress user to a board.
379 + *
380 + * @return User|false|null User on success, false for an existing relation,
381 + * or null when the board/user does not exist.
382 + */
329 383 public function addMembersInBoard($boardId, $memberId, $isViewerOnly = null)
330 384 {
385 + $boardId = intval($boardId);
386 + $memberId = intval($memberId);
387 + $isViewerOnly = sanitize_text_field((string)$isViewerOnly);
388 +
389 + if ($boardId <= 0 || $memberId <= 0) {
390 + return null;
391 + }
392 +
331 393 $board = Board::find($boardId);
394 + $boardMember = User::find($memberId);
332 395
333 - if (!$board) {
334 - return false;
396 + if (!$board || !$boardMember) {
397 + return null;
335 398 }
336 399 $isAlreadyMember = $this->isAlreadyMember($boardId, $memberId);
337 400 if($isAlreadyMember) {
338 401 return false;
@@ -352,13 +415,12 @@
352 415 'settings' => maybe_serialize($settings),
353 416 'preferences' => maybe_serialize(Constant::BOARD_NOTIFICATION_TYPES)
354 417 ]
355 418 );
356 - $boardMember = User::find($memberId);
357 419 if(!$isViewerOnly) {
420 + do_action('fluent_boards/board_member_added', $boardId, $boardMember);
421 + } else {
358 422 do_action('fluent_boards/board_viewer_added', $boardId, $boardMember);
359 - } else {
360 - do_action('fluent_boards/board_member_added', $boardId, $boardMember);
361 423 }
362 424 return $boardMember;
363 425 }
364 426
@@ -396,8 +458,75 @@
396 458 $user['is_board_admin'] = false;
397 459 return $user;
398 460 }
399 461
462 + /**
463 + * Create or update a board access relation with the selected member role.
464 + */
465 + public function syncBoardUserRole($boardId, $userId, $role)
466 + {
467 + $boardId = absint($boardId);
468 + $userId = absint($userId);
469 + $role = sanitize_text_field($role);
470 +
471 + if (!$boardId || !$userId || !in_array($role, ['admin', 'member', 'viewer'], true)) {
472 + return false;
473 + }
474 +
475 + $board = Board::find($boardId);
476 + $user = User::find($userId);
477 +
478 + if (!$board || !$user) {
479 + return false;
480 + }
481 +
482 + $boardUser = Relation::where('object_id', $boardId)
483 + ->where('object_type', Constant::OBJECT_TYPE_BOARD_USER)
484 + ->where('foreign_id', $userId)
485 + ->first();
486 +
487 + $previousSettings = $boardUser ? (array)$boardUser->settings : [];
488 +
489 + // Board roles live as flags on the board_user relation; member access means both flags stay false.
490 + $settings = [
491 + 'is_admin' => 'admin' === $role,
492 + 'is_viewer_only' => 'viewer' === $role,
493 + ];
494 +
495 + if ($boardUser) {
496 + $boardUser->settings = $settings;
497 + $boardUser->save();
498 + } else {
499 + // New access should get the same default notification preferences as the normal add-member flow.
500 + $board->users()->attach(
501 + $userId,
502 + [
503 + 'object_type' => Constant::OBJECT_TYPE_BOARD_USER,
504 + 'settings' => maybe_serialize($settings),
505 + 'preferences' => maybe_serialize(Constant::BOARD_NOTIFICATION_TYPES)
506 + ]
507 + );
508 + }
509 +
510 + // Only emit admin transition hooks when the role actually changes.
511 + if ('admin' === $role && empty($previousSettings['is_admin'])) {
512 + do_action('fluent_boards/board_admin_added', $boardId, $userId);
513 + } elseif (!empty($previousSettings['is_admin'])) {
514 + do_action('fluent_boards/board_admin_removed', $boardId, $userId);
515 + }
516 +
517 + if ('viewer' === $role) {
518 + do_action('fluent_boards/board_viewer_added', $boardId, $user);
519 + } elseif ('member' === $role) {
520 + do_action('fluent_boards/board_member_added', $boardId, $user);
521 + }
522 +
523 + $user['is_admin'] = 'admin' === $role;
524 + $user['is_board_admin'] = 'admin' === $role;
525 +
526 + return $user;
527 + }
528 +
400 529 public function getUsersOfBoards()
401 530 {
402 531 $userBoards = Relation::whereNotNull('board_id')
403 532 ->where('user_id', get_current_user_id())
@@ -406,37 +535,68 @@
406 535 return $userBoards;
407 536 }
408 537
409 538 /**
410 - * change board background
411 - * @param mixed $backgroundData
412 - * @return string
539 + * Change or clear the board background.
540 + *
541 + * Image attachments must belong to the target board and use the board
542 + * background attachment type before their identifiers can be persisted.
543 + *
544 + * @param array $backgroundData
545 + * @param int $board_id
546 + * @return array|string
547 + * @throws \Exception
413 548 */
414 -
415 549 public function setBoardBackground($backgroundData, $board_id)
416 550 {
417 - $board = Board::find($board_id);
551 + $boardId = absint($board_id);
552 + $board = Board::find($boardId);
553 +
554 + if (!$board) {
555 + throw new \Exception(esc_html__('Board not found.', 'fluent-boards'));
556 + }
557 +
418 558 $oldBackground = $board->background;
559 +
560 + if (!empty($backgroundData['reset'])) {
561 + $board->background = '';
562 + $board->save();
563 + do_action('fluent_boards/board_background_updated', $boardId, $oldBackground);
564 +
565 + return $board->background;
566 + }
567 +
419 568 $background = $board->background;
420 -
421 - // if board background has color
422 - if (isset($backgroundData['color'])) {
423 - $background['color'] = $backgroundData['color'];
424 - $background['image_url'] = null;
425 - $background['is_image'] = false;
569 + if (!is_array($background)) {
570 + $background = [];
426 571 }
427 572
428 - // if board background has image
573 + // Resolve image metadata from the board-owned attachment, never from the client URL.
429 574 if (isset($backgroundData['image_url'])) {
430 - $background['image_url'] = $backgroundData['image_url'];
575 + $attachmentId = absint($backgroundData['id'] ?? 0);
576 + $attachment = Attachment::where('id', $attachmentId)
577 + ->where('object_id', $boardId)
578 + ->where('object_type', Constant::BOARD_BACKGROUND_IMAGE)
579 + ->first();
580 +
581 + if (!$attachment) {
582 + throw new \Exception(esc_html__('Background image not found.', 'fluent-boards'));
583 + }
584 +
585 + $background['id'] = (int) $attachment->id;
586 + $background['image_url'] = (new CommentService())->createPublicUrl($attachment, $boardId);
431 587 $background['is_image'] = true;
432 588 $background['color'] = null;
589 + } elseif (isset($backgroundData['color'])) {
590 + $background['id'] = $backgroundData['id'];
591 + $background['color'] = $backgroundData['color'];
592 + $background['image_url'] = null;
593 + $background['is_image'] = false;
433 594 }
434 - $background['id'] = $backgroundData['id'];
435 595
436 596 $board->background = $background;
437 597 $board->save();
438 - do_action('fluent_boards/board_background_updated', $board_id, $oldBackground);
598 + do_action('fluent_boards/board_background_updated', $boardId, $oldBackground);
439 599
440 600 return $board->background;
441 601 }
442 602
@@ -446,25 +606,85 @@
446 606 * @param mixed $board_id
447 607 * @param mixed $stage_slug
448 608 * @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
449 609 */
450 - public function getStageTaskAvailablePositions($board_id, $stage_id)
610 + public function getStageTaskAvailablePositions($board_id, $stage_id, $task_id = null)
451 611 {
452 - $availablePositions = Task::query()
612 + $task_id = absint($task_id);
613 + $task = $task_id ? Task::find($task_id) : null;
614 + $isCurrentStage = $task
615 + && (int) $task->board_id === (int) $board_id
616 + && (int) $task->stage_id === (int) $stage_id;
617 +
618 + $stageTasks = Task::query()
453 619 ->where('board_id', $board_id)
454 620 ->where('parent_id', null)
455 621 ->where('stage_id', $stage_id)
456 622 ->whereNull('archived_at')
457 623 ->orderBy('position', 'asc')
458 - ->get()
459 - ->pluck('position')->toArray();
624 + ->get(['id', 'position']);
460 625
461 - $totalPosition = count($availablePositions);
462 - $availablePositions[$totalPosition] = $totalPosition + 1;
626 + if ($isCurrentStage) {
627 + $stageTasks = $stageTasks->filter(function ($stageTask) use ($task_id) {
628 + return (int) $stageTask->id !== $task_id;
629 + })->values();
630 + }
463 631
464 - return $availablePositions;
632 + $availablePositions = [];
633 + $moveTargets = [];
634 + $currentMoveTargetKey = null;
635 + $totalSlots = $stageTasks->count() + 1;
636 + $currentSlot = $this->getCurrentStageSlotIndex($task, $stageTasks, $isCurrentStage);
637 +
638 + for ($slotIndex = 0; $slotIndex < $totalSlots; $slotIndex++) {
639 + $slotNumber = $slotIndex + 1;
640 + // Each slot represents a drop target between two ordered tasks, so the
641 + // modal can send exact neighbour ids instead of a fragile display index.
642 + $prevTask = $slotIndex > 0 ? $stageTasks->get($slotIndex - 1) : null;
643 + $nextTask = $slotIndex < $stageTasks->count() ? $stageTasks->get($slotIndex) : null;
644 + $slotKey = 'slot_' . $slotNumber;
645 +
646 + $availablePositions[] = $slotNumber;
647 + $moveTargets[] = [
648 + 'key' => $slotKey,
649 + 'label' => $slotNumber,
650 + 'prevTaskId' => $prevTask ? (int) $prevTask->id : null,
651 + 'nextTaskId' => $nextTask ? (int) $nextTask->id : null,
652 + 'isCurrent' => $isCurrentStage && $currentSlot === $slotNumber,
653 + ];
654 +
655 + if ($isCurrentStage && $currentSlot === $slotNumber) {
656 + $currentMoveTargetKey = $slotKey;
657 + }
658 + }
659 +
660 + return [
661 + 'availablePositions' => $availablePositions,
662 + 'moveTargets' => $moveTargets,
663 + 'currentMoveTargetKey' => $currentMoveTargetKey,
664 + 'defaultMoveTargetKey' => 'slot_' . $totalSlots,
665 + ];
465 666 }
466 667
668 + private function getCurrentStageSlotIndex($task, $stageTasks, $isCurrentStage)
669 + {
670 + if (!$isCurrentStage || !$task) {
671 + return null;
672 + }
673 +
674 + $slotNumber = 1;
675 + foreach ($stageTasks as $stageTask) {
676 + if ((float) $task->position > (float) $stageTask->position) {
677 + $slotNumber++;
678 + continue;
679 + }
680 +
681 + break;
682 + }
683 +
684 + return $slotNumber;
685 + }
686 +
467 687 public function getAssigneesByBoard($board_id, $search = '')
468 688 {
469 689 $assignees = [];
470 690 $boardUsers = [];
@@ -524,10 +744,10 @@
524 744 }
525 745
526 746 // Check if the board is already in the list
527 747 if (!in_array($boardId, $recentBoardIds)) {
528 - // If there are already 3 boards, remove the last one
529 - if (count($recentBoardIds) >= 3) {
748 + // Keep the 4 most recently opened boards for the dashboard view.
749 + if (count($recentBoardIds) >= 4) {
530 750 array_pop($recentBoardIds);
531 751 }
532 752 } else {
533 753 // Remove the existing board id to move it to the front
@@ -563,8 +783,12 @@
563 783 if (!$recentBoardIds) {
564 784 return [];
565 785 }
566 786
787 + if (!is_array($recentBoardIds)) {
788 + $recentBoardIds = [];
789 + }
790 +
567 791 $currentUser = User::find($userId);
568 792
569 793 if (!PermissionManager::isAdmin($userId)){
570 794 $recentBoardIds = array_intersect($recentBoardIds, $currentUser->whichBoards->pluck('id')->toArray());
@@ -569,8 +793,10 @@
569 793 if (!PermissionManager::isAdmin($userId)){
570 794 $recentBoardIds = array_intersect($recentBoardIds, $currentUser->whichBoards->pluck('id')->toArray());
571 795 }
572 796
797 + $recentBoardIds = array_values(array_slice($recentBoardIds, 0, 4));
798 +
573 799 // This is for checking if that board is exists
574 800 // TODO: we will remove this code in future version
575 801 if (!$this->recentBoardBackwardCompatibilityCheck()) {
576 802 foreach ($recentBoardIds as $index => $boardId) {
@@ -583,9 +809,15 @@
583 809
584 810 $this->updateRecentBoardCheckMeta();
585 811 }
586 812
587 - return Board::whereIn('id', $recentBoardIds)->withCount('completedTasks')->with(['stages', 'users'])->get();
813 + return Board::whereIn('id', $recentBoardIds)
814 + ->whereNull('archived_at')
815 + ->excludeTemplates()
816 + ->availableInCurrentInstall()
817 + ->withCount('completedTasks')
818 + ->with(['stages', 'users'])
819 + ->get();
588 820 }
589 821
590 822 public function getRecentBoardCheckMeta($userId = null){
591 823 if (!$userId) {
@@ -683,10 +915,15 @@
683 915
684 916 $contactOfBoard->delete();
685 917 }
686 918
687 - public function sendInvitationToBoard($boardId, $email)
919 + public function sendInvitationToBoard($boardId, $email, $role = 'member')
688 920 {
921 + $role = sanitize_text_field($role);
922 + if (!in_array($role, ['manager', 'member', 'viewer'], true)) {
923 + $role = 'member';
924 + }
925 +
689 926 $user = User::query()->where('user_email', $email)->first();
690 927
691 928 if ($user) {
692 929 return $user;
@@ -693,9 +930,9 @@
693 930 }
694 931
695 932 $current_user_id = get_current_user_id();
696 933
697 - do_action('fluent_boards/send_invitation', $boardId, $email, $current_user_id);
934 + do_action('fluent_boards/send_invitation', $boardId, $email, $current_user_id, $role);
698 935
699 936 return;
700 937
701 938 }
@@ -707,67 +944,124 @@
707 944 ->where('key', Constant::BOARD_INVITATION)
708 945 ->get();
709 946 }
710 947
711 - public function deleteInvitation($invitationId, $boardId = null)
948 + /**
949 + * Delete an invitation only when it belongs to the supplied board.
950 + *
951 + * The optional second argument lets older Pro releases receive a controlled
952 + * error instead of reporting a successful deletion that never happened.
953 + */
954 + public function deleteInvitation($boardId, $invitationId = null)
712 955 {
713 - $query = Meta::query();
714 - if ($boardId) {
715 - $query->where('object_id', $boardId)
716 - ->where('object_type', Constant::OBJECT_TYPE_BOARD);
956 + if ($invitationId === null) {
957 + throw new \Exception(
958 + __('A board ID is required to delete an invitation.', 'fluent-boards')
959 + );
717 960 }
718 - $query->findOrFail($invitationId)->delete();
961 +
962 + $boardId = intval($boardId);
963 + $invitationId = intval($invitationId);
964 +
965 + if ($boardId <= 0 || $invitationId <= 0) {
966 + return false;
967 + }
968 +
969 + return (bool) Meta::query()
970 + ->where('id', $invitationId)
971 + ->where('object_id', $boardId)
972 + ->where('object_type', Constant::OBJECT_TYPE_BOARD)
973 + ->where('key', Constant::BOARD_INVITATION)
974 + ->delete();
719 975 }
720 976
721 - public function hasDataChanged($boardId)
977 + public function hasDataChanged($boardId, $includeArchived = false, $since = null)
722 978 {
723 979 $stages = [];
724 980 $labels = [];
725 981 $tasks = [];
726 - $taskDeleted = false;
727 - $stageDeleted = false;
728 - $labelDeleted = false;
729 - $oneMinuteAgoTimestamp = current_time('timestamp') - 60; // Get the current timestamp and subtract 60 seconds
730 - $oneMinuteAgo = date_i18n('Y-m-d H:i:s', $oneMinuteAgoTimestamp); // Format the timestamp into the desired format in GMT
982 + $syncStartedAt = current_time('mysql');
983 + $isCursorRequest = !empty($since);
984 + $forceFullSync = false;
731 985
986 + if ($isCursorRequest) {
987 + $lastUpdated = $this->normalizeSyncCursor($since, $syncStartedAt);
988 + $forceFullSync = !$lastUpdated;
989 + } else {
990 + $oneMinuteAgoTimestamp = current_time('timestamp') - 60;
991 + $lastUpdated = date_i18n('Y-m-d H:i:s', $oneMinuteAgoTimestamp);
992 + }
993 +
732 994 $board = Board::find($boardId);
733 995 if (!$board) {
734 996 throw new \Exception(esc_html__("Board doesn't exists", 'fluent-boards'));
735 997 }
736 - // if stage in this board has been deleted
737 - $stageDeleted = Activity::where('object_id', $boardId)->where('updated_at', '>=', $oneMinuteAgo)->where('action', 'deleted')->where('column', 'stage')->exists();
998 + $boardUpdatedAt = $this->formatSyncTimestamp($board->updated_at);
999 + $boardChanged = !$isCursorRequest || $forceFullSync || $boardUpdatedAt >= $lastUpdated;
738 1000
739 - if ($stageDeleted) {
740 - $stages = $board->stages;
1001 + // Reset the local list if a change can remove an item from the user's current view.
1002 + $stageActivityQuery = Activity::where('object_id', $boardId)
1003 + ->where('object_type', Constant::ACTIVITY_BOARD)
1004 + ->where('updated_at', '>=', $lastUpdated)
1005 + ->where('column', 'stage');
1006 +
1007 + $stageResetRequired = $forceFullSync || (clone $stageActivityQuery)
1008 + ->whereIn('action', ['deleted', 'archived', 'restored'])
1009 + ->exists();
1010 +
1011 + if ($stageResetRequired) {
1012 + $stagesQuery = Stage::where('board_id', $boardId)->orderBy('position', 'asc');
1013 + if (!$includeArchived) {
1014 + $stagesQuery->whereNull('archived_at');
1015 + }
1016 + $stages = $stagesQuery->get();
741 1017 } else {
742 - $stages = (new StageService())->getLastOneMinuteUpdatedStages($boardId);
1018 + $stages = (new StageService())->getLastOneMinuteUpdatedStages($boardId, $lastUpdated, $includeArchived);
743 1019 }
744 1020
745 - $labelDeleted = Activity::where('object_id', $boardId)->where('updated_at', '>=', $oneMinuteAgo)->where('action', 'deleted')->where('column', 'label')->exists();
746 - if ($labelDeleted) {
747 - $labels = $board->labels;
1021 + $labelResetRequired = $forceFullSync || Activity::where('object_id', $boardId)
1022 + ->where('object_type', Constant::ACTIVITY_BOARD)
1023 + ->where('updated_at', '>=', $lastUpdated)
1024 + ->where('action', 'deleted')
1025 + ->where('column', 'label')
1026 + ->exists();
1027 + if ($labelResetRequired) {
1028 + $labelsQuery = Label::where('board_id', $boardId)->orderBy('position', 'asc');
1029 + if (!$includeArchived) {
1030 + $labelsQuery->whereNull('archived_at');
1031 + }
1032 + $labels = $labelsQuery->get();
748 1033 } else {
749 - $labels = (new LabelService())->getLastOneMinuteUpdatedLabels($boardId);
1034 + $labels = (new LabelService())->getLastOneMinuteUpdatedLabels($boardId, $lastUpdated, $includeArchived);
750 1035 }
751 1036
752 - // if task in this board has been deleted
753 - $taskDeletedOrMovedFormBoard = Activity::where('object_id', $boardId)
754 - ->where('updated_at', '>=', $oneMinuteAgo)
1037 + $stageArchiveRestored = !$forceFullSync && (clone $stageActivityQuery)
1038 + ->whereIn('action', ['archived', 'restored'])
1039 + ->exists();
1040 +
1041 + $taskResetRequired = $forceFullSync || $stageArchiveRestored || Activity::where('object_id', $boardId)
1042 + ->where('object_type', Constant::ACTIVITY_BOARD)
1043 + ->where('updated_at', '>=', $lastUpdated)
755 1044 ->where(function($query) {
756 1045 $query->where('action', 'deleted')
757 - ->orWhere('action', 'moved');
1046 + ->orWhere('action', 'moved')
1047 + ->orWhere('action', 'archived')
1048 + ->orWhere('action', 'restored');
758 1049 })
759 1050 ->where('column', 'task')
760 1051 ->exists();
761 - if ($taskDeletedOrMovedFormBoard) {
1052 + if ($taskResetRequired) {
762 1053 $tasksQuery = Task::query()
763 1054 ->where([
764 1055 'board_id' => $boardId,
765 1056 'parent_id' => null,
766 - 'archived_at' => null
767 1057 ])
768 1058 ->with(['assignees', 'labels', 'watchers']);
769 1059
1060 + if (!$includeArchived) {
1061 + $tasksQuery->whereNull('archived_at');
1062 + }
1063 +
770 1064 if (!!defined('FLUENT_BOARDS_PRO_VERSION')) {
771 1065 $tasksQuery->with('customFields');
772 1066 }
773 1067
@@ -772,9 +1066,9 @@
772 1066 }
773 1067
774 1068 $tasks = $tasksQuery->orderBy('due_at', 'ASC')->get();
775 1069 } else {
776 - $tasks = (new TaskService())->getLastOneMinuteUpdatedTasks($boardId);
1070 + $tasks = (new TaskService())->getLastOneMinuteUpdatedTasks($boardId, $lastUpdated, $includeArchived);
777 1071 }
778 1072
779 1073 foreach ($tasks as $task) {
780 1074 $task->isOverdue = $task->isOverdue();
@@ -789,202 +1083,128 @@
789 1083 if(!!defined('FLUENT_BOARDS_PRO_VERSION')) {
790 1084 $board->custom_fields = $board->customFields;
791 1085 }
792 1086
1087 + $boardPayload = $boardChanged ? $board : (object) [];
1088 + $hasChanges = $boardChanged
1089 + || $stageResetRequired
1090 + || $labelResetRequired
1091 + || $taskResetRequired
1092 + || count($stages)
1093 + || count($labels)
1094 + || count($tasks);
1095 +
793 1096 return [
794 - 'board' => $board,
795 - 'stages' => $stages,
796 - 'labels' => $labels,
797 - 'tasks' => $tasks,
798 - 'taskDeleted' => $taskDeletedOrMovedFormBoard,
799 - 'stageDeleted' => $stageDeleted,
1097 + 'board' => $boardPayload,
1098 + 'stages' => $stages,
1099 + 'labels' => $labels,
1100 + 'tasks' => $tasks,
1101 + 'taskDeleted' => $taskResetRequired,
1102 + 'stageDeleted' => $stageResetRequired,
1103 + 'labelDeleted' => $labelResetRequired,
1104 + 'taskResetRequired' => $taskResetRequired,
1105 + 'stageResetRequired' => $stageResetRequired,
1106 + 'labelResetRequired' => $labelResetRequired,
1107 + 'has_changes' => (bool) $hasChanges,
1108 + 'synced_at' => $syncStartedAt,
1109 + 'sync_reset' => $forceFullSync,
800 1110 ];
801 1111 }
802 1112
803 - public function getAssociatedBoards($associatedId)
1113 + private function normalizeSyncCursor($since, $syncStartedAt)
804 1114 {
805 - $boardIds = Meta::query()->where('value', $associatedId)
806 - ->where('object_type', Constant::OBJECT_TYPE_BOARD)
807 - ->where('key', Constant::BOARD_ASSOCIATED_CRM_CONTACT)
808 - ->pluck('object_id');
1115 + if (!is_string($since)) {
1116 + return null;
1117 + }
809 1118
810 - return Board::query()->whereIn('id', $boardIds)->with('stages', 'users')->get();
811 - }
1119 + $since = trim($since);
1120 + if (!preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $since)) {
1121 + return null;
1122 + }
812 1123
813 - private function deleteBoardMeta($boardId)
814 - {
815 - Meta::where('object_id', $boardId)
816 - ->where('object_type', Constant::OBJECT_TYPE_BOARD)
817 - ->delete();
1124 + if ($since > $syncStartedAt) {
1125 + return null;
1126 + }
1127 +
1128 + if (strtotime($since) < strtotime('-24 hours', strtotime($syncStartedAt))) {
1129 + return null;
1130 + }
1131 +
1132 + return $since;
818 1133 }
819 1134
820 - public function copyBoard($boardData)
1135 + private function formatSyncTimestamp($timestamp)
821 1136 {
822 - $sourceBoard = Board::findOrFail($boardData['source_board_id']);
823 - $boardData['background'] = $sourceBoard->background;
824 - $boardData = apply_filters('fluent_boards/before_create_board', $boardData);
1137 + if ($timestamp instanceof \DateTimeInterface) {
1138 + return $timestamp->format('Y-m-d H:i:s');
1139 + }
825 1140
826 - $board = Board::create($boardData);
827 -
828 - $this->setCurrentUserPreferencesOnBoardCreate($board);
829 -
830 - return $board;
1141 + return (string) $timestamp;
831 1142 }
832 1143
833 - public function getBoardReports($board_id)
1144 + /**
1145 + * Get CRM-associated boards that the current user can access.
1146 + *
1147 + * @param int $associatedId CRM contact/subscriber id.
1148 + * @param int|null $userId WordPress user id used for board access checks.
1149 + * @return \FluentBoards\Framework\Database\Orm\Collection|array
1150 + */
1151 + public function getAssociatedBoards($associatedId, $userId = null)
834 1152 {
835 - $board = Board::findOrFail($board_id);
836 - $taskQuery = Task::where('board_id', $board_id)
837 - ->whereNull('parent_id')
838 - ->whereNull('archived_at');
1153 + $associatedId = absint($associatedId);
1154 + $userId = $userId ?: get_current_user_id();
839 1155
840 - if($board->type == 'roadmap') {
841 - $pendingStage = $this->getNewIdeaStage($board->id);
842 - return $this->getIdeaReports($taskQuery, $pendingStage);
843 - } else {
844 - return $this->getTaskReports($taskQuery);
1156 + if (!$associatedId || !$userId) {
1157 + return [];
845 1158 }
846 - }
847 1159
848 - private function getNewIdeaStage($boardId)
849 - {
850 - return Stage::where('board_id', $boardId)
851 - ->where('type', 'stage')
852 - ->where('archived_at', null)
853 - ->orderBy('position', 'ASC')
854 - ->first();
855 - }
1160 + $boardIds = Meta::query()->where('value', $associatedId)
1161 + ->where('object_type', Constant::OBJECT_TYPE_BOARD)
1162 + ->whereIn('key', [
1163 + Constant::BOARD_ASSOCIATED_CRM_CONTACT,
1164 + self::LEGACY_BOARD_ASSOCIATED_CRM_CONTACT,
1165 + ])
1166 + ->pluck('object_id');
856 1167
857 - public function getAllBoardReports(){
858 - $userId = get_current_user_id();
859 -
860 - $taskQuery = Task::whereNull('parent_id')
1168 + $boards = Board::query()
1169 + ->whereIn('id', array_values(array_unique(array_map('intval', $boardIds->toArray()))))
861 1170 ->whereNull('archived_at')
862 - ->whereHas('board', function ($query) {
863 - $query->where('type', 'to-do');
864 - });
1171 + ->byAccessUser($userId)
1172 + ->withCount('completedTasks')
1173 + ->with(['stages', 'users'])
1174 + ->orderBy('created_at', 'DESC')
1175 + ->get();
865 1176
866 - if (!PermissionManager::isAdmin($userId))
867 - {
868 - $currentUser = User::find($userId);
869 - $relatedBoardIds = $currentUser->whichBoards->where('type', 'to-do')->pluck('id');
870 - $taskQuery->whereIn('board_id', $relatedBoardIds);
1177 + foreach ($boards as $board) {
1178 + $board->users = Helper::sanitizeUserCollections($board->users);
871 1179 }
872 1180
873 - return $this->getTaskReports($taskQuery);
1181 + return $boards;
874 1182 }
875 1183
876 - private function getTaskReports($taskQuery)
1184 + private function deleteBoardMeta($boardId)
877 1185 {
878 - $totalTasksQuery = clone $taskQuery;
879 - $completedTaskQuery = clone $taskQuery;
880 - $openTaskQuery = clone $taskQuery;
881 - $overDueTaskQuery = clone $taskQuery;
882 -
883 - $completedTaskCount = $completedTaskQuery->where('status', 'closed')->count();
884 - $openTaskCount = $openTaskQuery->where('status', 'open')->count();
885 - $overDueTasks = $overDueTaskQuery->overdue(true)->count();
886 - $totalTasks = $totalTasksQuery->count();
887 -
888 - $taskQuery->where('status', 'open');
889 -
890 - $highQuery = clone $taskQuery;
891 - $mediumQuery = clone $taskQuery;
892 - $lowQuery = clone $taskQuery;
893 -
894 - $high = $highQuery->where('priority', 'high')->count();
895 - $low = $mediumQuery->where('priority', 'low')->count();
896 - $medium = $lowQuery->where('priority', 'medium')->count();
897 -
898 - $reportData = [
899 - 'completion' => [
900 - 'completed' => $completedTaskCount,
901 - 'incomplete' => $openTaskCount,
902 - 'overdue' => $overDueTasks,
903 - 'total' => $totalTasks
904 - ],
905 - 'priority' => [
906 - 'high' => $high,
907 - 'medium' => $medium,
908 - 'low' => $low
909 - ]
910 -
911 - ];
912 - return $reportData;
1186 + Meta::where('object_id', $boardId)
1187 + ->where('object_type', Constant::OBJECT_TYPE_BOARD)
1188 + ->delete();
913 1189 }
914 1190
915 - private function getIdeaReports($taskQuery, $pendingStage)
1191 + public function copyBoard($boardData)
916 1192 {
917 - $pendingIdeaQuery = clone $taskQuery;
918 - $completedIdeaQuery = clone $taskQuery;
919 - $openIdeaQueryPage = clone $taskQuery;
920 - $openIdeaQueryWeb = clone $taskQuery;
1193 + $sourceBoard = Board::findOrFail($boardData['source_board_id']);
1194 + $boardData['background'] = $sourceBoard->background;
1195 + if (isset($boardData['description'])) {
1196 + $boardData['description'] = DescriptionMarkdownConverter::normalize($boardData['description']);
1197 + }
1198 + $boardData = apply_filters('fluent_boards/before_create_board', $boardData);
921 1199
922 - $pendingIdeaCount = $pendingIdeaQuery->where('status', 'open')->where('stage_id', $pendingStage->id)->count();
923 - $completedIdeaCount = $completedIdeaQuery->where('status', 'closed')->count();
924 - $openIdeaCountPage = $openIdeaQueryPage->where('status', 'open')->where('source', 'page')->count();
925 - $openIdeaCountWeb = $openIdeaQueryWeb->where('status', 'open')->where('source', 'web')->count();
926 - $totalIdeas = $openIdeaCountPage + $openIdeaCountWeb;
1200 + $board = Board::create($boardData);
927 1201
928 - $taskQuery->where('status', 'open');
1202 + $this->setCurrentUserPreferencesOnBoardCreate($board);
929 1203
930 - $highQuery = clone $taskQuery;
931 - $mediumQuery = clone $taskQuery;
932 - $lowQuery = clone $taskQuery;
933 -
934 - $high = $highQuery->where('priority', 'high')->count();
935 - $low = $mediumQuery->where('priority', 'low')->count();
936 - $medium = $lowQuery->where('priority', 'medium')->count();
937 -
938 - $reportData = [
939 - 'completion' => [
940 - 'pending' => $pendingIdeaCount,
941 - 'completed' => $completedIdeaCount,
942 - 'ideaFromPage' => $openIdeaCountPage,
943 - 'total' => $totalIdeas
944 - ],
945 - 'priority' => [
946 - 'high' => $high,
947 - 'medium' => $medium,
948 - 'low' => $low
949 - ]
950 - ];
951 - return $reportData;
1204 + return $board;
952 1205 }
953 1206
954 - public function getStageWiseBoardReports($board_id)
955 - {
956 - $stages = Stage::where('board_id', $board_id)
957 - ->where('type', 'stage')
958 - ->whereNull('archived_at')
959 - ->get();
960 -
961 - foreach ($stages as $stage) {
962 - $completedTaskCount = Task::where('stage_id', $stage->id)
963 - ->where('status', 'closed')
964 - ->count();
965 -
966 - $openTaskCount = Task::where('stage_id', $stage->id)
967 - ->whereNull('due_at')
968 - ->where('status', 'open')
969 - ->count();
970 -
971 - $overDue = Task::where('stage_id', $stage->id)
972 - ->whereNotNull('due_at')
973 - ->where('status', 'open')
974 - ->overdue(true)
975 - ->count();
976 -
977 - $stage->report = [
978 - 'completed' => $completedTaskCount,
979 - 'incomplete' => $openTaskCount,
980 - 'overdue' => $overDue
981 - ];
982 - }
983 -
984 - return $stages;
985 - }
986 -
987 1207 public function archiveBoard($boardId)
988 1208 {
989 1209 $board = Board::findOrFail($boardId);
990 1210 $board->archived_at = current_time('mysql');
@@ -1053,8 +1273,54 @@
1053 1273
1054 1274 return $pinnedBoardMeta;
1055 1275 }
1056 1276
1277 + /**
1278 + * Sidebar counts cover every board the user can access, so they are counted
1279 + * with their own queries rather than derived from the filtered/paginated list.
1280 + *
1281 + * byAccessUser() re-reads the user's accessible board ids from the database on
1282 + * every call, so the access scope is resolved once and cloned per count.
1283 + *
1284 + * @return array{all: int, pinned: int, archived: int}
1285 + */
1286 + public function getBoardCounts($userId)
1287 + {
1288 + $baseQuery = Board::byAccessUser($userId)
1289 + ->excludeTemplates()
1290 + ->availableInCurrentInstall();
1291 +
1292 + $counts = [
1293 + 'all' => (clone $baseQuery)->whereNull('archived_at')->count(),
1294 + 'pinned' => 0,
1295 + 'archived' => (clone $baseQuery)->whereNotNull('archived_at')->count()
1296 + ];
1297 +
1298 + $pinnedIds = $this->getPinnedBoardIds();
1299 +
1300 + if ($pinnedIds) {
1301 + $counts['pinned'] = (clone $baseQuery)->whereNull('archived_at')
1302 + ->whereIn('id', $pinnedIds)
1303 + ->count();
1304 + }
1305 +
1306 + return $counts;
1307 + }
1308 +
1309 + /**
1310 + * @return array board ids the current user has pinned
1311 + */
1312 + public function getPinnedBoardIds()
1313 + {
1314 + $pinnedBoardMeta = $this->getUserWisePinnedBoards();
1315 +
1316 + if (!$pinnedBoardMeta) {
1317 + return [];
1318 + }
1319 +
1320 + return array_map('intval', (array) $pinnedBoardMeta->value);
1321 + }
1322 +
1057 1323 public function getPinnedBoards()
1058 1324 {
1059 1325 $pinnedBoardMeta = $this->getUserWisePinnedBoards();
1060 1326
@@ -1065,9 +1331,12 @@
1065 1331
1066 1332 // Convert to array of integers
1067 1333 $intIds = array_map('intval', $ids);
1068 1334
1069 - return Board::whereIn('id', $intIds)->whereNull('archived_at')->get();
1335 + return Board::whereIn('id', $intIds)
1336 + ->whereNull('archived_at')
1337 + ->byAccessUser(get_current_user_id())
1338 + ->get();
1070 1339 }
1071 1340 }
1072 1341
1073 1342 public function pinBoard($boardId)
@@ -1149,9 +1418,9 @@
1149 1418 if (!$relation) {
1150 1419 return null;
1151 1420 }
1152 1421
1153 - return Folder::findOrFail($relation->object_id);
1422 + return Folder::find($relation->object_id);
1154 1423 }
1155 1424
1156 1425 public function deleteWebhookData($boardId)
1157 1426 {