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 +901 -259 1.31 → 2.2.0 View file →
@@ -1,25 +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;
15 -use FluentRoadmap\App\Models\Idea;
19 +use FluentBoards\App\Services\DescriptionMarkdownConverter;
20 +use FluentBoardsPro\App\Models\CustomField;
16 21
17 22 class BoardService
18 23 {
24 + private const LEGACY_BOARD_ASSOCIATED_CRM_CONTACT = 'crm_contact';
25 +
19 26 public function getBoardsByType($type)
20 27 {
21 - 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();
22 33 }
23 34
24 35 public function deleteBoard($boardId)
25 36 {
@@ -34,9 +45,47 @@
34 45 $taskRelatedRelations = Relation::whereIn('object_id', $allTaskIdsInBoard);
35 46 $taskRelatedRelations->delete();
36 47 TaskMeta::whereIn('task_id', $allTaskIdsInBoard)->delete();
37 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 +
38 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();
39 88
40 89 //removing all Board Settings
41 90 $board->boardUserEmailNotificationSettings()->detach();
42 91 $board->boardUserNotificationSettings()->detach();
@@ -48,15 +97,25 @@
48 97
49 98 //removing add board labels
50 99 $board->labels()->delete();
51 100
52 - //removing add board comment
53 - $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 + }
54 111
112 +
55 113 foreach ($board->notifications as $notification) {
56 114 $notification->users()->detach();
57 115 }
58 116 $board->notifications()->delete();
117 + $board->removeBoardFromFolder();
59 118
60 119 //delete board related meta
61 120 $this->deleteBoardMeta($boardId);
62 121
@@ -62,13 +121,34 @@
62 121
63 122 //delete from recently viewed
64 123 $this->deleteFromRecentlyViewed($boardId);
65 124
125 + //delete webhook data
126 + $this->deleteWebhookData($boardId);
127 +
66 128 $board->delete();
67 - if(!defined('FLUENT_BOARDS_PRO')){
68 - 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 + }
69 150 }
70 -// do_action('fluent_boards/board_deleted', $board);
71 151 }
72 152
73 153 public function fetchBoardMeta($boardId)
74 154 {
@@ -123,12 +203,13 @@
123 203 public function createBoard($boardData)
124 204 {
125 205 $boardData = [
126 206 'title' => $boardData['title'],
127 - 'type' => $boardData['type'] ? $boardData['type'] : 'todo',
128 - 'description' => $boardData['description'],
207 + 'type' => $boardData['type'] ? $boardData['type'] : 'to-do',
208 + 'description' => DescriptionMarkdownConverter::normalize($boardData['description'] ?? ''),
129 209 'currency' => isset($boardData['currency']) ? $boardData['currency'] : 'USD',
130 210 'background' => isset($boardData['background']) ? $boardData['background'] : '',
211 + 'created_by' => isset($boardData['created_by']) ? $boardData['created_by'] : get_current_user_id()
131 212 ];
132 213
133 214 $boardData = apply_filters('fluent_boards/before_create_board', $boardData);
134 215
@@ -138,12 +219,23 @@
138 219
139 220 return $board;
140 221 }
141 222
142 - 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)
143 230 {
231 + $creatorId = absint($board->created_by);
232 + if (!$creatorId) {
233 + return;
234 + }
235 +
144 236 $board->users()->attach(
145 - get_current_user_id(),
237 + $creatorId,
146 238 [
147 239 'object_type' => Constant::OBJECT_TYPE_BOARD_USER,
148 240 'settings' => maybe_serialize([
149 241 Constant::IS_BOARD_ADMIN => true
@@ -168,11 +260,22 @@
168 260
169 261 $user->tasks()->detach($taskIdsToDetach);
170 262 $user->watchingTasks()->detach($taskIdsToDetach);
171 263
172 -// do_action('fluent_boards/board_member_removed', $boardId, $user->display_name);
173 264 }
174 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 +
175 278 public function removeFromRecentlyOpened($boardId, $userId)
176 279 {
177 280 $recentlyOpened = Meta::where('object_id', $userId)
178 281 ->where('object_type', Constant::OBJECT_TYPE_USER)
@@ -180,9 +283,18 @@
180 283 ->first();
181 284 if ($recentlyOpened) {
182 285 $recentBoardIds = $recentlyOpened->value;
183 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 +
184 292 $index = array_search($boardId, $recentBoardIds);
293 + if ($index === false) {
294 + return;
295 + }
296 +
185 297 array_splice($recentBoardIds, $index, 1);
186 298
187 299 $recentlyOpened->value = $recentBoardIds;
188 300 $recentlyOpened->save();
@@ -194,12 +306,12 @@
194 306 {
195 307 if ($data['title']) {
196 308 $data['title'] = $data['title'];
197 309 } else {
198 - throw new \Exception('Title cannot be empty');
310 + throw new \Exception(esc_html__('Title cannot be empty', 'fluent-boards'));
199 311 }
200 312 if (isset($data['description'])) {
201 - $data['description'] = $data['description'];
313 + $data['description'] = DescriptionMarkdownConverter::normalize($data['description']);
202 314 }
203 315 $board->fill($data);
204 316 $board->save();
205 317 // do_action('fluent_boards/board_updated', $board);
@@ -225,50 +337,10 @@
225 337
226 338 return serialize($this->processStages($stages));
227 339 }
228 340
229 - public function changePositionOfStage($boardId, $changeData)
230 - {
231 - $allStages = Stage::where('board_id', $boardId)
232 - ->where('is_archived', 0)
233 - ->where('type', 'stage')
234 - ->orderBy('position', 'asc')
235 - ->get();
236 341
237 - $changedStage = null;
238 -
239 - foreach ($allStages as $stage) {
240 - if ($changeData['fromPosition'] < $changeData['toPosition']) {
241 - if ($stage['position'] == $changeData['fromPosition']) {
242 - $stage['position'] = (int)$changeData['toPosition'];
243 - $stage->save();
244 - $changedStage = $stage;
245 - } elseif (
246 - $stage['position'] > $changeData['fromPosition'] &&
247 - $stage['position'] <= $changeData['toPosition']
248 - ) {
249 - $stage['position'] = $stage->position - 1;
250 - $stage->save();
251 - }
252 - } else {
253 - if (
254 - $stage['position'] >= $changeData['toPosition'] &&
255 - $stage['position'] < $changeData['fromPosition']
256 - ) {
257 - $stage['position'] = $stage->position + 1;
258 - $stage->save();
259 - } elseif ($stage['position'] == $changeData['fromPosition']) {
260 - $stage['position'] = (int)$changeData['toPosition'];
261 - $stage->save();
262 - $changedStage = $stage;
263 - }
264 - }
265 - }
266 -
267 - do_action('fluent_boards/board_stage_dragged', $changedStage);
268 - }
269 -
270 - public function rePositionStages($boardId, $incomingList)
342 + public function repositionStages($boardId, $incomingList)
271 343 {
272 344 $oldList = Stage::where('board_id', $boardId)->where('type', 'stage')->whereNull('archived_at')->orderBy('position')->pluck('id');
273 345
274 346 foreach ($incomingList as $key => $stage_id) {
@@ -296,36 +368,56 @@
296 368 }
297 369 return $processedStages;
298 370 }
299 371
372 + /**
373 + * Archive a stage and persist the user who archived it for future archive-list metadata.
374 + */
300 375 public function archiveStage($boardId, $stage)
301 376 {
377 + $settings = $stage->settings ?: [];
378 + $settings['archived_by_id'] = absint(get_current_user_id()) ?: null;
379 +
302 380 $stage->archived_at = current_time('mysql');
303 381 $stage->position = 0;
382 + $stage->settings = $settings;
304 383 $stage->save();
305 384
306 - do_action('fluent_boards/stage_archived', $boardId, $stage);
385 + do_action('fluent_boards/stage_archived', $boardId, $stage); // Old hook
386 + do_action('fluent_boards/stage_archived_with_tasks', $boardId, $stage); // New hook
307 387 return $stage;
308 388 }
309 389
390 + /**
391 + * Restore an archived stage and clear stale archived-by metadata.
392 + */
310 393 public function restoreStage($boardId, $stage)
311 394 {
312 395 $stageService = new StageService();
313 396 $lastStagePosition = $stageService->getLastPositionOfStagesOfBoard($stage->board_id);
397 + $settings = $stage->settings ?: [];
398 + $settings['archived_by_id'] = null;
399 +
314 400 $stage->archived_at = null;
315 401 $stage->position = $lastStagePosition ? $lastStagePosition->position + 1 : 1;
402 + $stage->settings = $settings;
316 403 $stage->save();
317 - 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
318 406 return $stage;
319 407 }
320 408
321 409 public function getActivities($id, $data)
322 410 {
323 - $per_page = isset($data['per_page']) ? $data['per_page'] : 40;
324 - $page = isset($data['page']) ? $data['page'] : 1;
325 - 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'])
326 414 ->orderBy('id', 'DESC')
327 415 ->paginate($per_page, ['*'], 'page', $page);
416 +
417 + Helper::translateActivities($activities);
418 +
419 + return $activities;
328 420 }
329 421
330 422 public function isAlreadyMember($boardId, $memberId)
331 423 {
@@ -335,26 +427,55 @@
335 427
336 428 return $isAlreadyMember ?? false;
337 429 }
338 430
339 - 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)
340 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 +
341 447 $board = Board::find($boardId);
448 + $boardMember = User::find($memberId);
342 449
343 - if (!$board) {
450 + if (!$board || !$boardMember) {
451 + return null;
452 + }
453 + $isAlreadyMember = $this->isAlreadyMember($boardId, $memberId);
454 + if($isAlreadyMember) {
344 455 return false;
345 456 }
457 + $settings = Constant::BOARD_USER_SETTINGS;
346 458
459 + if($isViewerOnly === 'yes') {
460 + $settings = Constant::BOARD_USER_VIEWER_ONLY_SETTINGS;
461 + }
462 +
463 +
464 +
347 465 $board->users()->attach(
348 466 $memberId,
349 467 [
350 468 'object_type' => Constant::OBJECT_TYPE_BOARD_USER,
351 - 'settings' => maybe_serialize(Constant::BOARD_USER_SETTINGS),
469 + 'settings' => maybe_serialize($settings),
352 470 'preferences' => maybe_serialize(Constant::BOARD_NOTIFICATION_TYPES)
353 471 ]
354 472 );
355 - $boardMember = User::find($memberId);
356 - 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 + }
357 478 return $boardMember;
358 479 }
359 480
360 481 public function makeAdminOfBoard($boardId, $userId)
@@ -391,8 +512,75 @@
391 512 $user['is_board_admin'] = false;
392 513 return $user;
393 514 }
394 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 +
395 583 public function getUsersOfBoards()
396 584 {
397 585 $userBoards = Relation::whereNotNull('board_id')
398 586 ->where('user_id', get_current_user_id())
@@ -401,35 +589,68 @@
401 589 return $userBoards;
402 590 }
403 591
404 592 /**
405 - * change board background
406 - * @param mixed $backgroundData
407 - * @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
408 602 */
409 -
410 603 public function setBoardBackground($backgroundData, $board_id)
411 604 {
412 - $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 +
413 622 $background = $board->background;
414 -
415 - // if board background has color
416 - if (isset($backgroundData['color'])) {
417 - $background['color'] = $backgroundData['color'];
418 - $background['image_url'] = null;
419 - $background['is_image'] = false;
623 + if (!is_array($background)) {
624 + $background = [];
420 625 }
421 626
422 - // if board background has image
627 + // Resolve image metadata from the board-owned attachment, never from the client URL.
423 628 if (isset($backgroundData['image_url'])) {
424 - $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);
425 641 $background['is_image'] = true;
426 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;
427 648 }
428 - $background['id'] = $backgroundData['id'];
429 649
430 650 $board->background = $background;
431 651 $board->save();
652 + do_action('fluent_boards/board_background_updated', $boardId, $oldBackground);
432 653
433 654 return $board->background;
434 655 }
435 656
@@ -439,25 +660,85 @@
439 660 * @param mixed $board_id
440 661 * @param mixed $stage_slug
441 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
442 663 */
443 - public function getStageTaskAvailablePositions($board_id, $stage_id)
664 + public function getStageTaskAvailablePositions($board_id, $stage_id, $task_id = null)
444 665 {
445 - $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()
446 673 ->where('board_id', $board_id)
447 674 ->where('parent_id', null)
448 675 ->where('stage_id', $stage_id)
449 676 ->whereNull('archived_at')
450 677 ->orderBy('position', 'asc')
451 - ->get()
452 - ->pluck('position')->toArray();
678 + ->get(['id', 'position']);
453 679
454 - $totalPosition = count($availablePositions);
455 - $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 + }
456 685
457 - 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 + ];
458 720 }
459 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 +
460 741 public function getAssigneesByBoard($board_id, $search = '')
461 742 {
462 743 $assignees = [];
463 744 $boardUsers = [];
@@ -517,10 +798,10 @@
517 798 }
518 799
519 800 // Check if the board is already in the list
520 801 if (!in_array($boardId, $recentBoardIds)) {
521 - // If there are already 3 boards, remove the last one
522 - if (count($recentBoardIds) >= 3) {
802 + // Keep the 4 most recently opened boards for the dashboard view.
803 + if (count($recentBoardIds) >= 4) {
523 804 array_pop($recentBoardIds);
524 805 }
525 806 } else {
526 807 // Remove the existing board id to move it to the front
@@ -556,8 +837,12 @@
556 837 if (!$recentBoardIds) {
557 838 return [];
558 839 }
559 840
841 + if (!is_array($recentBoardIds)) {
842 + $recentBoardIds = [];
843 + }
844 +
560 845 $currentUser = User::find($userId);
561 846
562 847 if (!PermissionManager::isAdmin($userId)){
563 848 $recentBoardIds = array_intersect($recentBoardIds, $currentUser->whichBoards->pluck('id')->toArray());
@@ -562,8 +847,10 @@
562 847 if (!PermissionManager::isAdmin($userId)){
563 848 $recentBoardIds = array_intersect($recentBoardIds, $currentUser->whichBoards->pluck('id')->toArray());
564 849 }
565 850
851 + $recentBoardIds = array_values(array_slice($recentBoardIds, 0, 4));
852 +
566 853 // This is for checking if that board is exists
567 854 // TODO: we will remove this code in future version
568 855 if (!$this->recentBoardBackwardCompatibilityCheck()) {
569 856 foreach ($recentBoardIds as $index => $boardId) {
@@ -576,9 +863,15 @@
576 863
577 864 $this->updateRecentBoardCheckMeta();
578 865 }
579 866
580 - 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();
581 874 }
582 875
583 876 public function getRecentBoardCheckMeta($userId = null){
584 877 if (!$userId) {
@@ -676,10 +969,15 @@
676 969
677 970 $contactOfBoard->delete();
678 971 }
679 972
680 - public function sendInvitationToBoard($boardId, $email)
973 + public function sendInvitationToBoard($boardId, $email, $role = 'member')
681 974 {
975 + $role = sanitize_text_field($role);
976 + if (!in_array($role, ['manager', 'member', 'viewer'], true)) {
977 + $role = 'member';
978 + }
979 +
682 980 $user = User::query()->where('user_email', $email)->first();
683 981
684 982 if ($user) {
685 983 return $user;
@@ -686,9 +984,9 @@
686 984 }
687 985
688 986 $current_user_id = get_current_user_id();
689 987
690 - do_action('fluent_boards/send_invitation', $boardId, $email, $current_user_id);
988 + do_action('fluent_boards/send_invitation', $boardId, $email, $current_user_id, $role);
691 989
692 990 return;
693 991
694 992 }
@@ -700,65 +998,140 @@
700 998 ->where('key', Constant::BOARD_INVITATION)
701 999 ->get();
702 1000 }
703 1001
704 - 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)
705 1009 {
706 - 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();
707 1029 }
708 1030
709 - public function hasDataChanged($boardId)
1031 + public function hasDataChanged($boardId, $includeArchived = false, $since = null)
710 1032 {
711 1033 $stages = [];
712 1034 $labels = [];
713 1035 $tasks = [];
714 - $labels = [];
715 - $taskDeleted = false;
716 - $stageDeleted = false;
717 - $labelDeleted = false;
718 - $oneMinuteAgoTimestamp = current_time('timestamp') - 60; // Get the current timestamp and subtract 60 seconds
719 - $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;
720 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 +
721 1048 $board = Board::find($boardId);
722 1049 if (!$board) {
723 - throw new \Exception("Board doesn't exists");
1050 + throw new \Exception(esc_html__("Board doesn't exists", 'fluent-boards'));
724 1051 }
725 - // if stage in this board has been deleted
726 - $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;
727 1054
728 - if ($stageDeleted) {
729 - $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();
730 1071 } else {
731 - $stages = (new StageService())->getLastOneMinuteUpdatedStages($boardId);
1072 + $stages = (new StageService())->getLastOneMinuteUpdatedStages($boardId, $lastUpdated, $includeArchived);
732 1073 }
733 1074
734 - $labelDeleted = Activity::where('object_id', $boardId)->where('updated_at', '>=', $oneMinuteAgo)->where('action', 'deleted')->where('column', 'label')->exists();
735 - if ($labelDeleted) {
736 - $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();
737 1087 } else {
738 - $labels = (new LabelService())->getLastOneMinuteUpdatedLabels($boardId);
1088 + $labels = (new LabelService())->getLastOneMinuteUpdatedLabels($boardId, $lastUpdated, $includeArchived);
739 1089 }
740 1090
741 - // if task in this board has been deleted
742 - $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();
743 1094
744 - if ($taskDeletedOrMovedFormBoard) {
745 - $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()
746 1108 ->where([
747 1109 'board_id' => $boardId,
748 1110 'parent_id' => null,
749 - 'archived_at' => null
750 1111 ])
751 - ->with(['assignees', 'labels', 'watchers', 'customFields'])
752 - ->orderBy('due_at', 'ASC')
753 - ->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();
754 1123 } else {
755 - $tasks = (new TaskService())->getLastOneMinuteUpdatedTasks($boardId);
1124 + $tasks = (new TaskService())->getLastOneMinuteUpdatedTasks($boardId, $lastUpdated, $includeArchived);
756 1125 }
757 1126
758 1127 foreach ($tasks as $task) {
759 1128 $task->isOverdue = $task->isOverdue();
760 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);
761 1134 }
762 1135
763 1136 $board->background = \maybe_unserialize($board->background);
764 1137 if(!!defined('FLUENT_BOARDS_PRO_VERSION')) {
@@ -764,27 +1137,125 @@
764 1137 if(!!defined('FLUENT_BOARDS_PRO_VERSION')) {
765 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,179 +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 - $board = Board::findOrFail($board_id);
810 - $taskQuery = Task::where('board_id', $board_id)
811 - ->whereNull('parent_id')
812 - ->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();
813 1292
814 - if($board->type == 'roadmap') {
815 - $pendingStage = $this->getNewIdeaStage($board->id);
816 - return $this->getIdeaReports($taskQuery, $pendingStage);
817 - } else {
818 - 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;
819 1326 }
820 1327 }
821 1328
822 - private function getNewIdeaStage($boardId)
1329 + /**
1330 + * Copy Pro custom field definitions and return an old-to-new ID map.
1331 + */
1332 + public function copyCustomFieldsOfBoard($fromBoardId, $board)
823 1333 {
824 - return Stage::where('board_id', $boardId)
825 - ->where('type', 'stage')
826 - ->where('archived_at', null)
827 - ->orderBy('position', 'ASC')
828 - ->first();
829 - }
1334 + if (!defined('FLUENT_BOARDS_PRO') || !class_exists(CustomField::class)) {
1335 + return [];
1336 + }
830 1337
831 - public function getAllBoardReports(){
832 - $userId = get_current_user_id();
1338 + $fromBoardId = absint($fromBoardId);
833 1339
834 - $taskQuery = Task::whereNull('parent_id')
1340 + if (!$fromBoardId || !$board) {
1341 + return [];
1342 + }
1343 +
1344 + $customFieldMap = [];
1345 + $customFields = CustomField::where('board_id', $fromBoardId)
835 1346 ->whereNull('archived_at')
836 - ->whereHas('board', function ($query) {
837 - $query->where('type', 'to-do');
838 - });
1347 + ->orderBy('position', 'asc')
1348 + ->get();
839 1349
840 - if (!PermissionManager::isAdmin($userId))
841 - {
842 - $currentUser = User::find($userId);
843 - $relatedBoardIds = $currentUser->whichBoards->where('type', 'to-do')->pluck('id');
844 - $taskQuery->whereIn('board_id', $relatedBoardIds);
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;
845 1356 }
846 1357
847 - return $this->getTaskReports($taskQuery);
1358 + return $customFieldMap;
848 1359 }
849 1360
850 - private function getTaskReports($taskQuery)
1361 + public function archiveBoard($boardId)
851 1362 {
852 - $totalTasksQuery = clone $taskQuery;
853 - $completedTaskQuery = clone $taskQuery;
854 - $openTaskQuery = clone $taskQuery;
855 - $overDueTaskQuery = clone $taskQuery;
1363 + $board = Board::findOrFail($boardId);
1364 + $board->archived_at = current_time('mysql');
1365 + $board->save();
856 1366
857 - $completedTaskCount = $completedTaskQuery->where('status', 'closed')->count();
858 - $openTaskCount = $openTaskQuery->where('status', 'open')->count();
859 - $overDueTasks = $overDueTaskQuery->overdue(true)->count();
860 - $totalTasks = $totalTasksQuery->count();
1367 + do_action('fluent_boards/board_archived', $board);
1368 + return $board;
1369 + }
861 1370
862 - $taskQuery->where('status', 'open');
1371 + public function restoreBoard($boardId)
1372 + {
1373 + $board = Board::findOrFail($boardId);
1374 + $board->archived_at = null;
1375 + $board->save();
863 1376
864 - $highQuery = clone $taskQuery;
865 - $mediumQuery = clone $taskQuery;
866 - $lowQuery = clone $taskQuery;
1377 + do_action('fluent_boards/board_restored', $board);
1378 + return $board;
1379 + }
867 1380
868 - $high = $highQuery->where('priority', 'high')->count();
869 - $low = $mediumQuery->where('priority', 'low')->count();
870 - $medium = $lowQuery->where('priority', 'medium')->count();
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();
871 1386
872 - $reportData = [
873 - 'completion' => [
874 - 'completed' => $completedTaskCount,
875 - 'incomplete' => $openTaskCount,
876 - 'overdue' => $overDueTasks,
877 - 'total' => $totalTasks
878 - ],
879 - 'priority' => [
880 - 'high' => $high,
881 - 'medium' => $medium,
882 - 'low' => $low
883 - ]
1387 + $boardUser->settings = [
1388 + 'is_admin' => false,
1389 + 'is_viewer_only' => false
1390 + ];
884 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
885 1409 ];
886 - return $reportData;
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;
887 1417 }
888 1418
889 - private function getIdeaReports($taskQuery, $pendingStage)
1419 + private function getUserWisePinnedBoards()
890 1420 {
891 - $pendingIdeaQuery = clone $taskQuery;
892 - $completedIdeaQuery = clone $taskQuery;
893 - $openIdeaQueryPage = clone $taskQuery;
894 - $openIdeaQueryWeb = clone $taskQuery;
1421 + $userId = get_current_user_id();
895 1422
896 - $pendingIdeaCount = $pendingIdeaQuery->where('status', 'open')->where('stage_id', $pendingStage->id)->count();
897 - $completedIdeaCount = $completedIdeaQuery->where('status', 'closed')->count();
898 - $openIdeaCountPage = $openIdeaQueryPage->where('status', 'open')->where('source', 'page')->count();
899 - $openIdeaCountWeb = $openIdeaQueryWeb->where('status', 'open')->where('source', 'web')->count();
900 - $totalIdeas = $openIdeaCountPage + $openIdeaCountWeb;
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();
901 1427
902 - $taskQuery->where('status', 'open');
1428 + return $pinnedBoardMeta;
1429 + }
903 1430
904 - $highQuery = clone $taskQuery;
905 - $mediumQuery = clone $taskQuery;
906 - $lowQuery = clone $taskQuery;
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();
907 1445
908 - $high = $highQuery->where('priority', 'high')->count();
909 - $low = $mediumQuery->where('priority', 'low')->count();
910 - $medium = $lowQuery->where('priority', 'medium')->count();
1446 + $counts = [
1447 + 'all' => (clone $baseQuery)->whereNull('archived_at')->count(),
1448 + 'pinned' => 0,
1449 + 'archived' => (clone $baseQuery)->whereNotNull('archived_at')->count()
1450 + ];
911 1451
912 - $reportData = [
913 - 'completion' => [
914 - 'pending' => $pendingIdeaCount,
915 - 'completed' => $completedIdeaCount,
916 - 'ideaFromPage' => $openIdeaCountPage,
917 - 'total' => $totalIdeas
918 - ],
919 - 'priority' => [
920 - 'high' => $high,
921 - 'medium' => $medium,
922 - 'low' => $low
923 - ]
924 - ];
925 - return $reportData;
1452 + $pinnedIds = $this->getPinnedBoardIds();
1453 +
1454 + if ($pinnedIds) {
1455 + $counts['pinned'] = (clone $baseQuery)->whereNull('archived_at')
1456 + ->whereIn('id', $pinnedIds)
1457 + ->count();
1458 + }
1459 +
1460 + return $counts;
926 1461 }
927 1462
928 - public function getStageWiseBoardReports($board_id)
1463 + /**
1464 + * @return array board ids the current user has pinned
1465 + */
1466 + public function getPinnedBoardIds()
929 1467 {
930 - $stages = Stage::where('board_id', $board_id)
931 - ->where('type', 'stage')
932 - ->whereNull('archived_at')
933 - ->get();
1468 + $pinnedBoardMeta = $this->getUserWisePinnedBoards();
934 1469
935 - foreach ($stages as $stage) {
936 - $completedTaskCount = Task::where('stage_id', $stage->id)
937 - ->where('status', 'closed')
938 - ->count();
1470 + if (!$pinnedBoardMeta) {
1471 + return [];
1472 + }
939 1473
940 - $openTaskCount = Task::where('stage_id', $stage->id)
941 - ->whereNull('due_at')
942 - ->where('status', 'open')
943 - ->count();
1474 + return array_map('intval', (array) $pinnedBoardMeta->value);
1475 + }
944 1476
945 - $overDue = Task::where('stage_id', $stage->id)
946 - ->whereNotNull('due_at')
947 - ->where('status', 'open')
948 - ->overdue(true)
949 - ->count();
1477 + public function getPinnedBoards()
1478 + {
1479 + $pinnedBoardMeta = $this->getUserWisePinnedBoards();
950 1480
951 - $stage->report = [
952 - 'completed' => $completedTaskCount,
953 - 'incomplete' => $openTaskCount,
954 - 'overdue' => $overDue
955 - ];
1481 + if (!$pinnedBoardMeta) {
1482 + return [];
1483 + } else {
1484 + $ids = $pinnedBoardMeta->value;
1485 +
1486 + // Convert to array of integers
1487 + $intIds = array_map('intval', $ids);
1488 +
1489 + return Board::whereIn('id', $intIds)
1490 + ->whereNull('archived_at')
1491 + ->byAccessUser(get_current_user_id())
1492 + ->get();
956 1493 }
1494 + }
957 1495
958 - return $stages;
1496 + public function pinBoard($boardId)
1497 + {
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 + }
959 1519 }
960 1520
961 - public function archiveBoard($boardId)
1521 + /**
1522 + * @param $boardId
1523 + * @return bool
1524 + */
1525 + public function unpinBoard($boardId)
962 1526 {
963 - $board = Board::findOrFail($boardId);
964 - $board->archived_at = current_time('mysql');
965 - $board->save();
1527 + $pinnedBoardMeta = $this->getUserWisePinnedBoards();
966 1528
967 - do_action('fluent_boards/board_archived', $board);
968 - return $board;
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;
969 1543 }
970 1544
971 - public function restoreBoard($boardId)
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)
972 1551 {
973 - $board = Board::findOrFail($boardId);
974 - $board->archived_at = null;
975 - $board->save();
1552 + $pinnedBoardMeta = $this->getUserWisePinnedBoards();
976 1553
977 - do_action('fluent_boards/board_restored', $board);
978 - return $board;
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;
979 1564 }
980 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)
1583 + ->get();
1584 +
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)
1590 + ->count();
1591 +
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;
1600 +
1601 + if (isset($value['board_id'])) {
1602 + $boards = $value['board_id'];
1603 +
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();
1621 + }
1622 + }
981 1623 }