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 +747 -259 1.312.1.0 View file →
@@ -2,24 +2,33 @@
2 2
3 3 namespace FluentBoards\App\Services;
4 4
5 5 use FluentBoards\App\Models\Activity;
6 -use FluentBoards\App\Models\Stage;
7 -use FluentBoards\App\Models\User;
6 +use FluentBoards\App\Models\Attachment;
8 7 use FluentBoards\App\Models\Board;
8 +use FluentBoards\App\Models\Comment;
9 +use FluentBoards\App\Models\Folder;
10 +use FluentBoards\App\Models\Label;
11 +use FluentBoards\App\Models\Meta;
9 12 use FluentBoards\App\Models\Relation;
13 +use FluentBoards\App\Models\Stage;
10 14 use FluentBoards\App\Models\Task;
11 -use FluentBoards\App\Models\Meta;
12 15 use FluentBoards\App\Models\TaskMeta;
16 +use FluentBoards\App\Models\User;
13 17 use FluentBoards\App\Services\Libs\FileSystem;
14 -use FluentBoards\Framework\Support\Arr;
15 -use FluentRoadmap\App\Models\Idea;
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 {
@@ -34,9 +43,16 @@
34 43 $taskRelatedRelations = Relation::whereIn('object_id', $allTaskIdsInBoard);
35 44 $taskRelatedRelations->delete();
36 45 TaskMeta::whereIn('task_id', $allTaskIdsInBoard)->delete();
37 46
47 + // Delete time tracking records for all tasks in the board
48 + (new TaskService())->deleteTimeTrackingRecords($allTaskIdsInBoard->toArray());
49 +
38 50 Task::whereIn('id', $allTaskIdsInBoard)->delete();
51 +
52 + // delete all activities
53 + Activity::whereIn('object_id', $allTaskIdsInBoard)->where('object_type', Constant::ACTIVITY_TASK)->delete();
54 + $board->activities()->delete();
39 55
40 56 //removing all Board Settings
41 57 $board->boardUserEmailNotificationSettings()->detach();
42 58 $board->boardUserNotificationSettings()->detach();
@@ -48,15 +64,25 @@
48 64
49 65 //removing add board labels
50 66 $board->labels()->delete();
51 67
52 - //removing add board comment
53 - $board->comments()->delete();
68 + //removing all board comments (delete individually to fire model events and clean up images)
69 + $comments = $board->comments()->get();
70 + foreach ($comments as $comment) {
71 + $comment->delete();
72 + }
73 +
74 + //removing add board custom fields
75 + if (defined('FLUENT_BOARDS_PRO')) {
76 + $board->customFields()->delete();
77 + }
54 78
79 +
55 80 foreach ($board->notifications as $notification) {
56 81 $notification->users()->detach();
57 82 }
58 83 $board->notifications()->delete();
84 + $board->removeBoardFromFolder();
59 85
60 86 //delete board related meta
61 87 $this->deleteBoardMeta($boardId);
62 88
@@ -62,13 +88,13 @@
62 88
63 89 //delete from recently viewed
64 90 $this->deleteFromRecentlyViewed($boardId);
65 91
92 + //delete webhook data
93 + $this->deleteWebhookData($boardId);
94 +
66 95 $board->delete();
67 - if(!defined('FLUENT_BOARDS_PRO')){
68 - FileSystem::deleteDir('board_'.$boardId);
69 - }
70 -// do_action('fluent_boards/board_deleted', $board);
96 + FileSystem::deleteDir('board_'.$boardId);
71 97 }
72 98
73 99 public function fetchBoardMeta($boardId)
74 100 {
@@ -123,12 +149,13 @@
123 149 public function createBoard($boardData)
124 150 {
125 151 $boardData = [
126 152 'title' => $boardData['title'],
127 - 'type' => $boardData['type'] ? $boardData['type'] : 'todo',
128 - 'description' => $boardData['description'],
153 + 'type' => $boardData['type'] ? $boardData['type'] : 'to-do',
154 + 'description' => DescriptionMarkdownConverter::normalize($boardData['description'] ?? ''),
129 155 'currency' => isset($boardData['currency']) ? $boardData['currency'] : 'USD',
130 156 'background' => isset($boardData['background']) ? $boardData['background'] : '',
157 + 'created_by' => isset($boardData['created_by']) ? $boardData['created_by'] : get_current_user_id()
131 158 ];
132 159
133 160 $boardData = apply_filters('fluent_boards/before_create_board', $boardData);
134 161
@@ -138,12 +165,23 @@
138 165
139 166 return $board;
140 167 }
141 168
142 - 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)
143 176 {
177 + $creatorId = absint($board->created_by);
178 + if (!$creatorId) {
179 + return;
180 + }
181 +
144 182 $board->users()->attach(
145 - get_current_user_id(),
183 + $creatorId,
146 184 [
147 185 'object_type' => Constant::OBJECT_TYPE_BOARD_USER,
148 186 'settings' => maybe_serialize([
149 187 Constant::IS_BOARD_ADMIN => true
@@ -168,11 +206,22 @@
168 206
169 207 $user->tasks()->detach($taskIdsToDetach);
170 208 $user->watchingTasks()->detach($taskIdsToDetach);
171 209
172 -// do_action('fluent_boards/board_member_removed', $boardId, $user->display_name);
173 210 }
174 211
212 + private function removeFromDefaultAssignee($boardId, $user)
213 + {
214 + $stages = Stage::where('board_id', $boardId)->get();
215 + foreach ($stages as $stage) {
216 + if (isset($stage->settings['default_task_assignees'])) {
217 + if (($key = array_search($user, $stage->settings['default_task_assignees'])) !== false) {
218 + unset($stage->settings['default_task_assignees'][$key]);
219 + }
220 + }
221 + }
222 + }
223 +
175 224 public function removeFromRecentlyOpened($boardId, $userId)
176 225 {
177 226 $recentlyOpened = Meta::where('object_id', $userId)
178 227 ->where('object_type', Constant::OBJECT_TYPE_USER)
@@ -180,9 +229,18 @@
180 229 ->first();
181 230 if ($recentlyOpened) {
182 231 $recentBoardIds = $recentlyOpened->value;
183 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 +
184 238 $index = array_search($boardId, $recentBoardIds);
239 + if ($index === false) {
240 + return;
241 + }
242 +
185 243 array_splice($recentBoardIds, $index, 1);
186 244
187 245 $recentlyOpened->value = $recentBoardIds;
188 246 $recentlyOpened->save();
@@ -194,12 +252,12 @@
194 252 {
195 253 if ($data['title']) {
196 254 $data['title'] = $data['title'];
197 255 } else {
198 - throw new \Exception('Title cannot be empty');
256 + throw new \Exception(esc_html__('Title cannot be empty', 'fluent-boards'));
199 257 }
200 258 if (isset($data['description'])) {
201 - $data['description'] = $data['description'];
259 + $data['description'] = DescriptionMarkdownConverter::normalize($data['description']);
202 260 }
203 261 $board->fill($data);
204 262 $board->save();
205 263 // do_action('fluent_boards/board_updated', $board);
@@ -225,50 +283,10 @@
225 283
226 284 return serialize($this->processStages($stages));
227 285 }
228 286
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 287
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)
288 + public function repositionStages($boardId, $incomingList)
271 289 {
272 290 $oldList = Stage::where('board_id', $boardId)->where('type', 'stage')->whereNull('archived_at')->orderBy('position')->pluck('id');
273 291
274 292 foreach ($incomingList as $key => $stage_id) {
@@ -296,26 +314,42 @@
296 314 }
297 315 return $processedStages;
298 316 }
299 317
318 + /**
319 + * Archive a stage and persist the user who archived it for future archive-list metadata.
320 + */
300 321 public function archiveStage($boardId, $stage)
301 322 {
323 + $settings = $stage->settings ?: [];
324 + $settings['archived_by_id'] = absint(get_current_user_id()) ?: null;
325 +
302 326 $stage->archived_at = current_time('mysql');
303 327 $stage->position = 0;
328 + $stage->settings = $settings;
304 329 $stage->save();
305 330
306 - 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
307 333 return $stage;
308 334 }
309 335
336 + /**
337 + * Restore an archived stage and clear stale archived-by metadata.
338 + */
310 339 public function restoreStage($boardId, $stage)
311 340 {
312 341 $stageService = new StageService();
313 342 $lastStagePosition = $stageService->getLastPositionOfStagesOfBoard($stage->board_id);
343 + $settings = $stage->settings ?: [];
344 + $settings['archived_by_id'] = null;
345 +
314 346 $stage->archived_at = null;
315 347 $stage->position = $lastStagePosition ? $lastStagePosition->position + 1 : 1;
348 + $stage->settings = $settings;
316 349 $stage->save();
317 - 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
318 352 return $stage;
319 353 }
320 354
321 355 public function getActivities($id, $data)
@@ -321,11 +355,15 @@
321 355 public function getActivities($id, $data)
322 356 {
323 357 $per_page = isset($data['per_page']) ? $data['per_page'] : 40;
324 358 $page = isset($data['page']) ? $data['page'] : 1;
325 - 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'])
326 360 ->orderBy('id', 'DESC')
327 361 ->paginate($per_page, ['*'], 'page', $page);
362 +
363 + Helper::translateActivities($activities);
364 +
365 + return $activities;
328 366 }
329 367
330 368 public function isAlreadyMember($boardId, $memberId)
331 369 {
@@ -335,26 +373,55 @@
335 373
336 374 return $isAlreadyMember ?? false;
337 375 }
338 376
339 - public function addMembersInBoard($boardId, $memberId)
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 + */
383 + public function addMembersInBoard($boardId, $memberId, $isViewerOnly = null)
340 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 +
341 393 $board = Board::find($boardId);
394 + $boardMember = User::find($memberId);
342 395
343 - if (!$board) {
396 + if (!$board || !$boardMember) {
397 + return null;
398 + }
399 + $isAlreadyMember = $this->isAlreadyMember($boardId, $memberId);
400 + if($isAlreadyMember) {
344 401 return false;
345 402 }
403 + $settings = Constant::BOARD_USER_SETTINGS;
346 404
405 + if($isViewerOnly === 'yes') {
406 + $settings = Constant::BOARD_USER_VIEWER_ONLY_SETTINGS;
407 + }
408 +
409 +
410 +
347 411 $board->users()->attach(
348 412 $memberId,
349 413 [
350 414 'object_type' => Constant::OBJECT_TYPE_BOARD_USER,
351 - 'settings' => maybe_serialize(Constant::BOARD_USER_SETTINGS),
415 + 'settings' => maybe_serialize($settings),
352 416 'preferences' => maybe_serialize(Constant::BOARD_NOTIFICATION_TYPES)
353 417 ]
354 418 );
355 - $boardMember = User::find($memberId);
356 - do_action('fluent_boards/board_member_added', $boardId, $boardMember);
419 + if(!$isViewerOnly) {
420 + do_action('fluent_boards/board_member_added', $boardId, $boardMember);
421 + } else {
422 + do_action('fluent_boards/board_viewer_added', $boardId, $boardMember);
423 + }
357 424 return $boardMember;
358 425 }
359 426
360 427 public function makeAdminOfBoard($boardId, $userId)
@@ -391,8 +458,75 @@
391 458 $user['is_board_admin'] = false;
392 459 return $user;
393 460 }
394 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 +
395 529 public function getUsersOfBoards()
396 530 {
397 531 $userBoards = Relation::whereNotNull('board_id')
398 532 ->where('user_id', get_current_user_id())
@@ -401,35 +535,68 @@
401 535 return $userBoards;
402 536 }
403 537
404 538 /**
405 - * change board background
406 - * @param mixed $backgroundData
407 - * @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
408 548 */
409 -
410 549 public function setBoardBackground($backgroundData, $board_id)
411 550 {
412 - $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 +
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 +
413 568 $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;
569 + if (!is_array($background)) {
570 + $background = [];
420 571 }
421 572
422 - // if board background has image
573 + // Resolve image metadata from the board-owned attachment, never from the client URL.
423 574 if (isset($backgroundData['image_url'])) {
424 - $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);
425 587 $background['is_image'] = true;
426 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;
427 594 }
428 - $background['id'] = $backgroundData['id'];
429 595
430 596 $board->background = $background;
431 597 $board->save();
598 + do_action('fluent_boards/board_background_updated', $boardId, $oldBackground);
432 599
433 600 return $board->background;
434 601 }
435 602
@@ -439,25 +606,85 @@
439 606 * @param mixed $board_id
440 607 * @param mixed $stage_slug
441 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
442 609 */
443 - public function getStageTaskAvailablePositions($board_id, $stage_id)
610 + public function getStageTaskAvailablePositions($board_id, $stage_id, $task_id = null)
444 611 {
445 - $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()
446 619 ->where('board_id', $board_id)
447 620 ->where('parent_id', null)
448 621 ->where('stage_id', $stage_id)
449 622 ->whereNull('archived_at')
450 623 ->orderBy('position', 'asc')
451 - ->get()
452 - ->pluck('position')->toArray();
624 + ->get(['id', 'position']);
453 625
454 - $totalPosition = count($availablePositions);
455 - $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 + }
456 631
457 - 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 + ];
458 666 }
459 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 +
460 687 public function getAssigneesByBoard($board_id, $search = '')
461 688 {
462 689 $assignees = [];
463 690 $boardUsers = [];
@@ -517,10 +744,10 @@
517 744 }
518 745
519 746 // Check if the board is already in the list
520 747 if (!in_array($boardId, $recentBoardIds)) {
521 - // If there are already 3 boards, remove the last one
522 - if (count($recentBoardIds) >= 3) {
748 + // Keep the 4 most recently opened boards for the dashboard view.
749 + if (count($recentBoardIds) >= 4) {
523 750 array_pop($recentBoardIds);
524 751 }
525 752 } else {
526 753 // Remove the existing board id to move it to the front
@@ -556,8 +783,12 @@
556 783 if (!$recentBoardIds) {
557 784 return [];
558 785 }
559 786
787 + if (!is_array($recentBoardIds)) {
788 + $recentBoardIds = [];
789 + }
790 +
560 791 $currentUser = User::find($userId);
561 792
562 793 if (!PermissionManager::isAdmin($userId)){
563 794 $recentBoardIds = array_intersect($recentBoardIds, $currentUser->whichBoards->pluck('id')->toArray());
@@ -562,8 +793,10 @@
562 793 if (!PermissionManager::isAdmin($userId)){
563 794 $recentBoardIds = array_intersect($recentBoardIds, $currentUser->whichBoards->pluck('id')->toArray());
564 795 }
565 796
797 + $recentBoardIds = array_values(array_slice($recentBoardIds, 0, 4));
798 +
566 799 // This is for checking if that board is exists
567 800 // TODO: we will remove this code in future version
568 801 if (!$this->recentBoardBackwardCompatibilityCheck()) {
569 802 foreach ($recentBoardIds as $index => $boardId) {
@@ -576,9 +809,15 @@
576 809
577 810 $this->updateRecentBoardCheckMeta();
578 811 }
579 812
580 - 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();
581 820 }
582 821
583 822 public function getRecentBoardCheckMeta($userId = null){
584 823 if (!$userId) {
@@ -676,10 +915,15 @@
676 915
677 916 $contactOfBoard->delete();
678 917 }
679 918
680 - public function sendInvitationToBoard($boardId, $email)
919 + public function sendInvitationToBoard($boardId, $email, $role = 'member')
681 920 {
921 + $role = sanitize_text_field($role);
922 + if (!in_array($role, ['manager', 'member', 'viewer'], true)) {
923 + $role = 'member';
924 + }
925 +
682 926 $user = User::query()->where('user_email', $email)->first();
683 927
684 928 if ($user) {
685 929 return $user;
@@ -686,9 +930,9 @@
686 930 }
687 931
688 932 $current_user_id = get_current_user_id();
689 933
690 - do_action('fluent_boards/send_invitation', $boardId, $email, $current_user_id);
934 + do_action('fluent_boards/send_invitation', $boardId, $email, $current_user_id, $role);
691 935
692 936 return;
693 937
694 938 }
@@ -700,65 +944,140 @@
700 944 ->where('key', Constant::BOARD_INVITATION)
701 945 ->get();
702 946 }
703 947
704 - public function deleteInvitation($invitationId)
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)
705 955 {
706 - Meta::findOrFail($invitationId)->delete();
956 + if ($invitationId === null) {
957 + throw new \Exception(
958 + __('A board ID is required to delete an invitation.', 'fluent-boards')
959 + );
960 + }
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();
707 975 }
708 976
709 - public function hasDataChanged($boardId)
977 + public function hasDataChanged($boardId, $includeArchived = false, $since = null)
710 978 {
711 979 $stages = [];
712 980 $labels = [];
713 981 $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
982 + $syncStartedAt = current_time('mysql');
983 + $isCursorRequest = !empty($since);
984 + $forceFullSync = false;
720 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 +
721 994 $board = Board::find($boardId);
722 995 if (!$board) {
723 - throw new \Exception("Board doesn't exists");
996 + throw new \Exception(esc_html__("Board doesn't exists", 'fluent-boards'));
724 997 }
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();
998 + $boardUpdatedAt = $this->formatSyncTimestamp($board->updated_at);
999 + $boardChanged = !$isCursorRequest || $forceFullSync || $boardUpdatedAt >= $lastUpdated;
727 1000
728 - if ($stageDeleted) {
729 - $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();
730 1017 } else {
731 - $stages = (new StageService())->getLastOneMinuteUpdatedStages($boardId);
1018 + $stages = (new StageService())->getLastOneMinuteUpdatedStages($boardId, $lastUpdated, $includeArchived);
732 1019 }
733 1020
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;
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();
737 1033 } else {
738 - $labels = (new LabelService())->getLastOneMinuteUpdatedLabels($boardId);
1034 + $labels = (new LabelService())->getLastOneMinuteUpdatedLabels($boardId, $lastUpdated, $includeArchived);
739 1035 }
740 1036
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();
1037 + $stageArchiveRestored = !$forceFullSync && (clone $stageActivityQuery)
1038 + ->whereIn('action', ['archived', 'restored'])
1039 + ->exists();
743 1040
744 - if ($taskDeletedOrMovedFormBoard) {
745 - $tasks = Task::query()
1041 + $taskResetRequired = $forceFullSync || $stageArchiveRestored || Activity::where('object_id', $boardId)
1042 + ->where('object_type', Constant::ACTIVITY_BOARD)
1043 + ->where('updated_at', '>=', $lastUpdated)
1044 + ->where(function($query) {
1045 + $query->where('action', 'deleted')
1046 + ->orWhere('action', 'moved')
1047 + ->orWhere('action', 'archived')
1048 + ->orWhere('action', 'restored');
1049 + })
1050 + ->where('column', 'task')
1051 + ->exists();
1052 + if ($taskResetRequired) {
1053 + $tasksQuery = Task::query()
746 1054 ->where([
747 1055 'board_id' => $boardId,
748 1056 'parent_id' => null,
749 - 'archived_at' => null
750 1057 ])
751 - ->with(['assignees', 'labels', 'watchers', 'customFields'])
752 - ->orderBy('due_at', 'ASC')
753 - ->get();
1058 + ->with(['assignees', 'labels', 'watchers']);
1059 +
1060 + if (!$includeArchived) {
1061 + $tasksQuery->whereNull('archived_at');
1062 + }
1063 +
1064 + if (!!defined('FLUENT_BOARDS_PRO_VERSION')) {
1065 + $tasksQuery->with('customFields');
1066 + }
1067 +
1068 + $tasks = $tasksQuery->orderBy('due_at', 'ASC')->get();
754 1069 } else {
755 - $tasks = (new TaskService())->getLastOneMinuteUpdatedTasks($boardId);
1070 + $tasks = (new TaskService())->getLastOneMinuteUpdatedTasks($boardId, $lastUpdated, $includeArchived);
756 1071 }
757 1072
758 1073 foreach ($tasks as $task) {
759 1074 $task->isOverdue = $task->isOverdue();
760 1075 $task->isUpcoming = $task->upcoming();
1076 + $task->is_watching = $task->isWatching();
1077 + $task->contact = Helper::crm_contact($task->crm_contact_id);
1078 + $task->assignees = Helper::sanitizeUserCollections($task->assignees);
1079 + $task->watchers = Helper::sanitizeUserCollections($task->watchers);
761 1080 }
762 1081
763 1082 $board->background = \maybe_unserialize($board->background);
764 1083 if(!!defined('FLUENT_BOARDS_PRO_VERSION')) {
@@ -764,27 +1083,103 @@
764 1083 if(!!defined('FLUENT_BOARDS_PRO_VERSION')) {
765 1084 $board->custom_fields = $board->customFields;
766 1085 }
767 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 +
768 1096 return [
769 - 'board' => $board,
770 - 'stages' => $stages,
771 - 'labels' => $labels,
772 - 'tasks' => $tasks,
773 - 'taskDeleted' => $taskDeletedOrMovedFormBoard,
774 - '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,
775 1110 ];
776 1111 }
777 1112
778 - public function getAssociatedBoards($associatedId)
1113 + private function normalizeSyncCursor($since, $syncStartedAt)
779 1114 {
1115 + if (!is_string($since)) {
1116 + return null;
1117 + }
780 1118
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 + }
1123 +
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;
1133 + }
1134 +
1135 + private function formatSyncTimestamp($timestamp)
1136 + {
1137 + if ($timestamp instanceof \DateTimeInterface) {
1138 + return $timestamp->format('Y-m-d H:i:s');
1139 + }
1140 +
1141 + return (string) $timestamp;
1142 + }
1143 +
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)
1152 + {
1153 + $associatedId = absint($associatedId);
1154 + $userId = $userId ?: get_current_user_id();
1155 +
1156 + if (!$associatedId || !$userId) {
1157 + return [];
1158 + }
1159 +
781 1160 $boardIds = Meta::query()->where('value', $associatedId)
782 1161 ->where('object_type', Constant::OBJECT_TYPE_BOARD)
783 - ->where('key', Constant::BOARD_ASSOCIATED_CRM_CONTACT)
1162 + ->whereIn('key', [
1163 + Constant::BOARD_ASSOCIATED_CRM_CONTACT,
1164 + self::LEGACY_BOARD_ASSOCIATED_CRM_CONTACT,
1165 + ])
784 1166 ->pluck('object_id');
785 1167
786 - return Board::query()->whereIn('id', $boardIds)->with('stages', 'users')->get();
1168 + $boards = Board::query()
1169 + ->whereIn('id', array_values(array_unique(array_map('intval', $boardIds->toArray()))))
1170 + ->whereNull('archived_at')
1171 + ->byAccessUser($userId)
1172 + ->withCount('completedTasks')
1173 + ->with(['stages', 'users'])
1174 + ->orderBy('created_at', 'DESC')
1175 + ->get();
1176 +
1177 + foreach ($boards as $board) {
1178 + $board->users = Helper::sanitizeUserCollections($board->users);
1179 + }
1180 +
1181 + return $boards;
787 1182 }
788 1183
789 1184 private function deleteBoardMeta($boardId)
790 1185 {
@@ -794,8 +1189,13 @@
794 1189 }
795 1190
796 1191 public function copyBoard($boardData)
797 1192 {
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 + }
798 1198 $boardData = apply_filters('fluent_boards/before_create_board', $boardData);
799 1199
800 1200 $board = Board::create($boardData);
801 1201
@@ -803,179 +1203,267 @@
803 1203
804 1204 return $board;
805 1205 }
806 1206
807 - public function getBoardReports($board_id)
1207 + public function archiveBoard($boardId)
808 1208 {
809 - $board = Board::findOrFail($board_id);
810 - $taskQuery = Task::where('board_id', $board_id)
811 - ->whereNull('parent_id')
812 - ->whereNull('archived_at');
1209 + $board = Board::findOrFail($boardId);
1210 + $board->archived_at = current_time('mysql');
1211 + $board->save();
813 1212
814 - if($board->type == 'roadmap') {
815 - $pendingStage = $this->getNewIdeaStage($board->id);
816 - return $this->getIdeaReports($taskQuery, $pendingStage);
817 - } else {
818 - return $this->getTaskReports($taskQuery);
819 - }
1213 + do_action('fluent_boards/board_archived', $board);
1214 + return $board;
820 1215 }
821 1216
822 - private function getNewIdeaStage($boardId)
1217 + public function restoreBoard($boardId)
823 1218 {
824 - return Stage::where('board_id', $boardId)
825 - ->where('type', 'stage')
826 - ->where('archived_at', null)
827 - ->orderBy('position', 'ASC')
828 - ->first();
1219 + $board = Board::findOrFail($boardId);
1220 + $board->archived_at = null;
1221 + $board->save();
1222 +
1223 + do_action('fluent_boards/board_restored', $board);
1224 + return $board;
829 1225 }
830 1226
831 - public function getAllBoardReports(){
832 - $userId = get_current_user_id();
1227 + public function makeMember($boardId, $userId)
1228 + {
1229 + $boardUser = Relation::where('object_id', $boardId)
1230 + ->where('object_type', Constant::OBJECT_TYPE_BOARD_USER)
1231 + ->where('foreign_id', $userId)->first();
833 1232
834 - $taskQuery = Task::whereNull('parent_id')
835 - ->whereNull('archived_at')
836 - ->whereHas('board', function ($query) {
837 - $query->where('type', 'to-do');
838 - });
1233 + $boardUser->settings = [
1234 + 'is_admin' => false,
1235 + 'is_viewer_only' => false
1236 + ];
839 1237
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);
845 - }
1238 + $boardUser->save();
1239 + $user = User::findOrFail($userId);
1240 + do_action('fluent_boards/board_member_added', $boardId, $boardUser);
1241 + $user['is_admin'] = false;
1242 + $user['is_board_admin'] = false;
1243 + return $user;
1244 + }
846 1245
847 - return $this->getTaskReports($taskQuery);
1246 + public function makeViewer($boardId, $userId)
1247 + {
1248 + $boardUser = Relation::where('object_id', $boardId)
1249 + ->where('object_type', Constant::OBJECT_TYPE_BOARD_USER)
1250 + ->where('foreign_id', $userId)->first();
1251 +
1252 + $boardUser->settings = [
1253 + 'is_admin' => false,
1254 + 'is_viewer_only' => true
1255 + ];
1256 +
1257 + $boardUser->save();
1258 + $user = User::findOrFail($userId);
1259 + do_action('fluent_boards/board_viewer_added', $boardId, $boardUser);
1260 + $user['is_admin'] = false;
1261 + $user['is_board_admin'] = false;
1262 + return $user;
848 1263 }
849 1264
850 - private function getTaskReports($taskQuery)
1265 + private function getUserWisePinnedBoards()
851 1266 {
852 - $totalTasksQuery = clone $taskQuery;
853 - $completedTaskQuery = clone $taskQuery;
854 - $openTaskQuery = clone $taskQuery;
855 - $overDueTaskQuery = clone $taskQuery;
1267 + $userId = get_current_user_id();
856 1268
857 - $completedTaskCount = $completedTaskQuery->where('status', 'closed')->count();
858 - $openTaskCount = $openTaskQuery->where('status', 'open')->count();
859 - $overDueTasks = $overDueTaskQuery->overdue(true)->count();
860 - $totalTasks = $totalTasksQuery->count();
1269 + $pinnedBoardMeta = Meta::query()->where('object_id', $userId)
1270 + ->where('object_type', Constant::OBJECT_TYPE_USER)
1271 + ->where('key', Constant::USER_PINNED_BOARDS)
1272 + ->first();
861 1273
862 - $taskQuery->where('status', 'open');
1274 + return $pinnedBoardMeta;
1275 + }
863 1276
864 - $highQuery = clone $taskQuery;
865 - $mediumQuery = clone $taskQuery;
866 - $lowQuery = clone $taskQuery;
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();
867 1291
868 - $high = $highQuery->where('priority', 'high')->count();
869 - $low = $mediumQuery->where('priority', 'low')->count();
870 - $medium = $lowQuery->where('priority', 'medium')->count();
1292 + $counts = [
1293 + 'all' => (clone $baseQuery)->whereNull('archived_at')->count(),
1294 + 'pinned' => 0,
1295 + 'archived' => (clone $baseQuery)->whereNotNull('archived_at')->count()
1296 + ];
871 1297
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 - ]
1298 + $pinnedIds = $this->getPinnedBoardIds();
884 1299
885 - ];
886 - return $reportData;
1300 + if ($pinnedIds) {
1301 + $counts['pinned'] = (clone $baseQuery)->whereNull('archived_at')
1302 + ->whereIn('id', $pinnedIds)
1303 + ->count();
1304 + }
1305 +
1306 + return $counts;
887 1307 }
888 1308
889 - private function getIdeaReports($taskQuery, $pendingStage)
1309 + /**
1310 + * @return array board ids the current user has pinned
1311 + */
1312 + public function getPinnedBoardIds()
890 1313 {
891 - $pendingIdeaQuery = clone $taskQuery;
892 - $completedIdeaQuery = clone $taskQuery;
893 - $openIdeaQueryPage = clone $taskQuery;
894 - $openIdeaQueryWeb = clone $taskQuery;
1314 + $pinnedBoardMeta = $this->getUserWisePinnedBoards();
895 1315
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;
1316 + if (!$pinnedBoardMeta) {
1317 + return [];
1318 + }
901 1319
902 - $taskQuery->where('status', 'open');
1320 + return array_map('intval', (array) $pinnedBoardMeta->value);
1321 + }
903 1322
904 - $highQuery = clone $taskQuery;
905 - $mediumQuery = clone $taskQuery;
906 - $lowQuery = clone $taskQuery;
1323 + public function getPinnedBoards()
1324 + {
1325 + $pinnedBoardMeta = $this->getUserWisePinnedBoards();
907 1326
908 - $high = $highQuery->where('priority', 'high')->count();
909 - $low = $mediumQuery->where('priority', 'low')->count();
910 - $medium = $lowQuery->where('priority', 'medium')->count();
1327 + if (!$pinnedBoardMeta) {
1328 + return [];
1329 + } else {
1330 + $ids = $pinnedBoardMeta->value;
911 1331
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;
1332 + // Convert to array of integers
1333 + $intIds = array_map('intval', $ids);
1334 +
1335 + return Board::whereIn('id', $intIds)
1336 + ->whereNull('archived_at')
1337 + ->byAccessUser(get_current_user_id())
1338 + ->get();
1339 + }
926 1340 }
927 1341
928 - public function getStageWiseBoardReports($board_id)
1342 + public function pinBoard($boardId)
929 1343 {
930 - $stages = Stage::where('board_id', $board_id)
931 - ->where('type', 'stage')
932 - ->whereNull('archived_at')
933 - ->get();
1344 + $pinnedBoardMeta = $this->getUserWisePinnedBoards();
934 1345
935 - foreach ($stages as $stage) {
936 - $completedTaskCount = Task::where('stage_id', $stage->id)
937 - ->where('status', 'closed')
938 - ->count();
1346 + if ($pinnedBoardMeta) {
1347 + $currentPinnedBoards = $pinnedBoardMeta->value;
1348 + if (!in_array($boardId, $currentPinnedBoards)) {
1349 + $currentPinnedBoards[] = $boardId;
1350 + $pinnedBoardMeta->value = $currentPinnedBoards;
1351 + $pinnedBoardMeta->save();
1352 + }
1353 + } else {
1354 + // Create an empty array
1355 + $boardIds = [];
1356 + $boardIds[] = $boardId;
939 1357
940 - $openTaskCount = Task::where('stage_id', $stage->id)
941 - ->whereNull('due_at')
942 - ->where('status', 'open')
943 - ->count();
1358 + $meta = new Meta();
1359 + $meta->object_id = get_current_user_id();
1360 + $meta->object_type = Constant::OBJECT_TYPE_USER;
1361 + $meta->key = Constant::USER_PINNED_BOARDS;
1362 + $meta->value = $boardIds;
1363 + $meta->save();
1364 + }
1365 + }
944 1366
945 - $overDue = Task::where('stage_id', $stage->id)
946 - ->whereNotNull('due_at')
947 - ->where('status', 'open')
948 - ->overdue(true)
949 - ->count();
1367 + /**
1368 + * @param $boardId
1369 + * @return bool
1370 + */
1371 + public function unpinBoard($boardId)
1372 + {
1373 + $pinnedBoardMeta = $this->getUserWisePinnedBoards();
950 1374
951 - $stage->report = [
952 - 'completed' => $completedTaskCount,
953 - 'incomplete' => $openTaskCount,
954 - 'overdue' => $overDue
955 - ];
1375 + if (!$pinnedBoardMeta) {
1376 + return false;
956 1377 }
957 1378
958 - return $stages;
1379 + $currentPinnedBoards = $pinnedBoardMeta->value;
1380 + if (in_array($boardId, $currentPinnedBoards)) {
1381 + $index = array_search($boardId, $currentPinnedBoards);
1382 + array_splice($currentPinnedBoards, $index, 1);
1383 + $pinnedBoardMeta->value = $currentPinnedBoards;
1384 + $pinnedBoardMeta->save();
1385 + return true;
1386 + }
1387 +
1388 + return false;
959 1389 }
960 1390
961 - public function archiveBoard($boardId)
1391 + /**
1392 + * @param $boardId
1393 + * @return bool
1394 + * If board id is in user's current pinned boards list
1395 + */
1396 + public function isPinned($boardId)
962 1397 {
963 - $board = Board::findOrFail($boardId);
964 - $board->archived_at = current_time('mysql');
965 - $board->save();
1398 + $pinnedBoardMeta = $this->getUserWisePinnedBoards();
966 1399
967 - do_action('fluent_boards/board_archived', $board);
968 - return $board;
1400 + if (!$pinnedBoardMeta) {
1401 + return false;
1402 + }
1403 +
1404 + $currentPinnedBoards = $pinnedBoardMeta->value;
1405 + if (in_array($boardId, $currentPinnedBoards)) {
1406 + return true;
1407 + }
1408 +
1409 + return false;
969 1410 }
970 1411
971 - public function restoreBoard($boardId)
1412 + public function getBoardFolder($boardId)
972 1413 {
973 - $board = Board::findOrFail($boardId);
974 - $board->archived_at = null;
975 - $board->save();
1414 + $relation = Relation::where('object_type', Constant::OBJECT_TYPE_FOLDER_BOARD)
1415 + ->where('foreign_id', $boardId)
1416 + ->first();
976 1417
977 - do_action('fluent_boards/board_restored', $board);
978 - return $board;
1418 + if (!$relation) {
1419 + return null;
1420 + }
1421 +
1422 + return Folder::find($relation->object_id);
979 1423 }
980 1424
1425 + public function deleteWebhookData($boardId)
1426 + {
1427 + $outgoingRelations = Relation::where('object_type', 'outgoing_webhook_board')
1428 + ->where('foreign_id', $boardId)
1429 + ->get();
1430 +
1431 + foreach ($outgoingRelations as $relation) {
1432 + $webhookMetaId = (int) $relation->object_id;
1433 +
1434 + $linkedCount = Relation::where('object_type', 'outgoing_webhook_board')
1435 + ->where('object_id', $webhookMetaId)
1436 + ->count();
1437 +
1438 + if ($linkedCount === 1) {
1439 + Meta::where('id', $webhookMetaId)
1440 + ->where('object_type', 'outgoing_webhook')
1441 + ->delete();
1442 + } else if ($linkedCount > 1) {
1443 + $meta = Meta::find($webhookMetaId);
1444 + if ($meta && $meta->object_type === 'outgoing_webhook') {
1445 + $value = $meta->value;
1446 +
1447 + if (isset($value['board_id'])) {
1448 + $boards = $value['board_id'];
1449 +
1450 + if (is_array($boards)) {
1451 + $boards = array_values(array_filter($boards, function ($id) use ($boardId) {
1452 + return intval($id) !== intval($boardId);
1453 + }));
1454 + $value['board_id'] = $boards;
1455 + } else {
1456 + if ($boards !== null && intval($boards) === intval($boardId)) {
1457 + $value['board_id'] = [];
1458 + }
1459 + }
1460 +
1461 + $meta->value = $value;
1462 + $meta->save();
1463 + }
1464 + }
1465 + }
1466 + $relation->delete();
1467 + }
1468 + }
981 1469 }