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 +888 -221 1.122.1.0 View file →
@@ -2,22 +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;
13 -use FluentBoards\Framework\Support\Arr;
16 +use FluentBoards\App\Models\User;
17 +use FluentBoards\App\Services\Libs\FileSystem;
18 +use FluentBoards\App\Services\DescriptionMarkdownConverter;
14 19
15 20 class BoardService
16 21 {
22 + private const LEGACY_BOARD_ASSOCIATED_CRM_CONTACT = 'crm_contact';
23 +
17 24 public function getBoardsByType($type)
18 25 {
19 - 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();
20 31 }
21 32
22 33 public function deleteBoard($boardId)
23 34 {
@@ -32,10 +43,16 @@
32 43 $taskRelatedRelations = Relation::whereIn('object_id', $allTaskIdsInBoard);
33 44 $taskRelatedRelations->delete();
34 45 TaskMeta::whereIn('task_id', $allTaskIdsInBoard)->delete();
35 46
47 + // Delete time tracking records for all tasks in the board
48 + (new TaskService())->deleteTimeTrackingRecords($allTaskIdsInBoard->toArray());
36 49
37 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();
38 55
39 56 //removing all Board Settings
40 57 $board->boardUserEmailNotificationSettings()->detach();
41 58 $board->boardUserNotificationSettings()->detach();
@@ -47,21 +64,37 @@
47 64
48 65 //removing add board labels
49 66 $board->labels()->delete();
50 67
51 - //removing add board comment
52 - $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 + }
53 78
79 +
54 80 foreach ($board->notifications as $notification) {
55 81 $notification->users()->detach();
56 82 }
57 83 $board->notifications()->delete();
84 + $board->removeBoardFromFolder();
58 85
59 86 //delete board related meta
60 87 $this->deleteBoardMeta($boardId);
61 88
89 + //delete from recently viewed
90 + $this->deleteFromRecentlyViewed($boardId);
91 +
92 + //delete webhook data
93 + $this->deleteWebhookData($boardId);
94 +
62 95 $board->delete();
63 - // do_action('fluent_boards/board_deleted', $board);
96 + FileSystem::deleteDir('board_'.$boardId);
64 97 }
65 98
66 99 public function fetchBoardMeta($boardId)
67 100 {
@@ -116,12 +149,13 @@
116 149 public function createBoard($boardData)
117 150 {
118 151 $boardData = [
119 152 'title' => $boardData['title'],
120 - 'type' => $boardData['type'] ? $boardData['type'] : 'todo',
121 - 'description' => $boardData['description'],
153 + 'type' => $boardData['type'] ? $boardData['type'] : 'to-do',
154 + 'description' => DescriptionMarkdownConverter::normalize($boardData['description'] ?? ''),
122 155 'currency' => isset($boardData['currency']) ? $boardData['currency'] : 'USD',
123 156 'background' => isset($boardData['background']) ? $boardData['background'] : '',
157 + 'created_by' => isset($boardData['created_by']) ? $boardData['created_by'] : get_current_user_id()
124 158 ];
125 159
126 160 $boardData = apply_filters('fluent_boards/before_create_board', $boardData);
127 161
@@ -131,12 +165,23 @@
131 165
132 166 return $board;
133 167 }
134 168
135 - 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)
136 176 {
177 + $creatorId = absint($board->created_by);
178 + if (!$creatorId) {
179 + return;
180 + }
181 +
137 182 $board->users()->attach(
138 - get_current_user_id(),
183 + $creatorId,
139 184 [
140 185 'object_type' => Constant::OBJECT_TYPE_BOARD_USER,
141 186 'settings' => maybe_serialize([
142 187 Constant::IS_BOARD_ADMIN => true
@@ -161,11 +206,22 @@
161 206
162 207 $user->tasks()->detach($taskIdsToDetach);
163 208 $user->watchingTasks()->detach($taskIdsToDetach);
164 209
165 -// do_action('fluent_boards/board_member_removed', $boardId, $user->display_name);
166 210 }
167 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 +
168 224 public function removeFromRecentlyOpened($boardId, $userId)
169 225 {
170 226 $recentlyOpened = Meta::where('object_id', $userId)
171 227 ->where('object_type', Constant::OBJECT_TYPE_USER)
@@ -173,9 +229,18 @@
173 229 ->first();
174 230 if ($recentlyOpened) {
175 231 $recentBoardIds = $recentlyOpened->value;
176 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 +
177 238 $index = array_search($boardId, $recentBoardIds);
239 + if ($index === false) {
240 + return;
241 + }
242 +
178 243 array_splice($recentBoardIds, $index, 1);
179 244
180 245 $recentlyOpened->value = $recentBoardIds;
181 246 $recentlyOpened->save();
@@ -187,12 +252,12 @@
187 252 {
188 253 if ($data['title']) {
189 254 $data['title'] = $data['title'];
190 255 } else {
191 - throw new \Exception('Title cannot be empty');
256 + throw new \Exception(esc_html__('Title cannot be empty', 'fluent-boards'));
192 257 }
193 258 if (isset($data['description'])) {
194 - $data['description'] = $data['description'];
259 + $data['description'] = DescriptionMarkdownConverter::normalize($data['description']);
195 260 }
196 261 $board->fill($data);
197 262 $board->save();
198 263 // do_action('fluent_boards/board_updated', $board);
@@ -218,50 +283,10 @@
218 283
219 284 return serialize($this->processStages($stages));
220 285 }
221 286
222 - public function changePositionOfStage($boardId, $changeData)
223 - {
224 - $allStages = Stage::where('board_id', $boardId)
225 - ->where('is_archived', 0)
226 - ->where('type', 'stage')
227 - ->orderBy('position', 'asc')
228 - ->get();
229 287
230 - $changedStage = null;
231 -
232 - foreach ($allStages as $stage) {
233 - if ($changeData['fromPosition'] < $changeData['toPosition']) {
234 - if ($stage['position'] == $changeData['fromPosition']) {
235 - $stage['position'] = (int)$changeData['toPosition'];
236 - $stage->save();
237 - $changedStage = $stage;
238 - } elseif (
239 - $stage['position'] > $changeData['fromPosition'] &&
240 - $stage['position'] <= $changeData['toPosition']
241 - ) {
242 - $stage['position'] = $stage->position - 1;
243 - $stage->save();
244 - }
245 - } else {
246 - if (
247 - $stage['position'] >= $changeData['toPosition'] &&
248 - $stage['position'] < $changeData['fromPosition']
249 - ) {
250 - $stage['position'] = $stage->position + 1;
251 - $stage->save();
252 - } elseif ($stage['position'] == $changeData['fromPosition']) {
253 - $stage['position'] = (int)$changeData['toPosition'];
254 - $stage->save();
255 - $changedStage = $stage;
256 - }
257 - }
258 - }
259 -
260 - do_action('fluent_boards/board_stage_dragged', $changedStage);
261 - }
262 -
263 - public function rePositionStages($boardId, $incomingList)
288 + public function repositionStages($boardId, $incomingList)
264 289 {
265 290 $oldList = Stage::where('board_id', $boardId)->where('type', 'stage')->whereNull('archived_at')->orderBy('position')->pluck('id');
266 291
267 292 foreach ($incomingList as $key => $stage_id) {
@@ -289,26 +314,42 @@
289 314 }
290 315 return $processedStages;
291 316 }
292 317
293 - public function archiveStage($id, $stage)
318 + /**
319 + * Archive a stage and persist the user who archived it for future archive-list metadata.
320 + */
321 + public function archiveStage($boardId, $stage)
294 322 {
323 + $settings = $stage->settings ?: [];
324 + $settings['archived_by_id'] = absint(get_current_user_id()) ?: null;
325 +
295 326 $stage->archived_at = current_time('mysql');
296 327 $stage->position = 0;
328 + $stage->settings = $settings;
297 329 $stage->save();
298 330
299 - do_action('fluent_boards/board_stage_archived', $id, $stage->title);
331 + do_action('fluent_boards/stage_archived', $boardId, $stage); // Old hook
332 + do_action('fluent_boards/stage_archived_with_tasks', $boardId, $stage); // New hook
300 333 return $stage;
301 334 }
302 335
303 - public function restoreStage($id, $stage)
336 + /**
337 + * Restore an archived stage and clear stale archived-by metadata.
338 + */
339 + public function restoreStage($boardId, $stage)
304 340 {
305 341 $stageService = new StageService();
306 342 $lastStagePosition = $stageService->getLastPositionOfStagesOfBoard($stage->board_id);
343 + $settings = $stage->settings ?: [];
344 + $settings['archived_by_id'] = null;
345 +
307 346 $stage->archived_at = null;
308 347 $stage->position = $lastStagePosition ? $lastStagePosition->position + 1 : 1;
348 + $stage->settings = $settings;
309 349 $stage->save();
310 - do_action('fluent_boards/board_stage_restored', $id, $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
311 352 return $stage;
312 353 }
313 354
314 355 public function getActivities($id, $data)
@@ -314,11 +355,15 @@
314 355 public function getActivities($id, $data)
315 356 {
316 357 $per_page = isset($data['per_page']) ? $data['per_page'] : 40;
317 358 $page = isset($data['page']) ? $data['page'] : 1;
318 - 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'])
319 360 ->orderBy('id', 'DESC')
320 361 ->paginate($per_page, ['*'], 'page', $page);
362 +
363 + Helper::translateActivities($activities);
364 +
365 + return $activities;
321 366 }
322 367
323 368 public function isAlreadyMember($boardId, $memberId)
324 369 {
@@ -328,26 +373,55 @@
328 373
329 374 return $isAlreadyMember ?? false;
330 375 }
331 376
332 - 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)
333 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 +
334 393 $board = Board::find($boardId);
394 + $boardMember = User::find($memberId);
335 395
336 - if (!$board) {
396 + if (!$board || !$boardMember) {
397 + return null;
398 + }
399 + $isAlreadyMember = $this->isAlreadyMember($boardId, $memberId);
400 + if($isAlreadyMember) {
337 401 return false;
338 402 }
403 + $settings = Constant::BOARD_USER_SETTINGS;
339 404
405 + if($isViewerOnly === 'yes') {
406 + $settings = Constant::BOARD_USER_VIEWER_ONLY_SETTINGS;
407 + }
408 +
409 +
410 +
340 411 $board->users()->attach(
341 412 $memberId,
342 413 [
343 414 'object_type' => Constant::OBJECT_TYPE_BOARD_USER,
344 - 'settings' => maybe_serialize(Constant::BOARD_USER_SETTINGS),
415 + 'settings' => maybe_serialize($settings),
345 416 'preferences' => maybe_serialize(Constant::BOARD_NOTIFICATION_TYPES)
346 417 ]
347 418 );
348 - $boardMember = User::find($memberId);
349 - do_action('fluent_boards/board_member_added', $boardId, $boardMember->display_name);
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 + }
350 424 return $boardMember;
351 425 }
352 426
353 427 public function makeAdminOfBoard($boardId, $userId)
@@ -384,8 +458,75 @@
384 458 $user['is_board_admin'] = false;
385 459 return $user;
386 460 }
387 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 +
388 529 public function getUsersOfBoards()
389 530 {
390 531 $userBoards = Relation::whereNotNull('board_id')
391 532 ->where('user_id', get_current_user_id())
@@ -394,35 +535,68 @@
394 535 return $userBoards;
395 536 }
396 537
397 538 /**
398 - * change board background
399 - * @param mixed $backgroundData
400 - * @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
401 548 */
402 -
403 549 public function setBoardBackground($backgroundData, $board_id)
404 550 {
405 - $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 +
406 568 $background = $board->background;
407 -
408 - // if board background has color
409 - if (isset($backgroundData['color'])) {
410 - $background['color'] = $backgroundData['color'];
411 - $background['image_url'] = null;
412 - $background['is_image'] = false;
569 + if (!is_array($background)) {
570 + $background = [];
413 571 }
414 572
415 - // if board background has image
573 + // Resolve image metadata from the board-owned attachment, never from the client URL.
416 574 if (isset($backgroundData['image_url'])) {
417 - $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);
418 587 $background['is_image'] = true;
419 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;
420 594 }
421 - $background['id'] = $backgroundData['id'];
422 595
423 596 $board->background = $background;
424 597 $board->save();
598 + do_action('fluent_boards/board_background_updated', $boardId, $oldBackground);
425 599
426 600 return $board->background;
427 601 }
428 602
@@ -432,25 +606,85 @@
432 606 * @param mixed $board_id
433 607 * @param mixed $stage_slug
434 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
435 609 */
436 - public function getStageTaskAvailablePositions($board_id, $stage_id)
610 + public function getStageTaskAvailablePositions($board_id, $stage_id, $task_id = null)
437 611 {
438 - $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()
439 619 ->where('board_id', $board_id)
440 620 ->where('parent_id', null)
441 621 ->where('stage_id', $stage_id)
442 622 ->whereNull('archived_at')
443 623 ->orderBy('position', 'asc')
444 - ->get()
445 - ->pluck('position')->toArray();
624 + ->get(['id', 'position']);
446 625
447 - $totalPosition = count($availablePositions);
448 - $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 + }
449 631
450 - 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 + ];
451 666 }
452 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 +
453 687 public function getAssigneesByBoard($board_id, $search = '')
454 688 {
455 689 $assignees = [];
456 690 $boardUsers = [];
@@ -475,17 +709,26 @@
475 709 }
476 710 return $assignees;
477 711 }
478 712
479 - public function updateRecentOpenedBoards($boardId)
713 + private function deleteFromRecentlyViewed($boardId)
480 714 {
715 + $recentlyOpened = $this->recentlyViewedByUserQuery()->first();
716 + if ($recentlyOpened) {
717 + $recentBoardIds = $recentlyOpened->value;
718 + if (in_array($boardId, $recentBoardIds)) {
719 + $index = array_search($boardId, $recentBoardIds);
720 + unset($recentBoardIds[$index]);
721 + $recentlyOpened->value = $recentBoardIds;
722 + $recentlyOpened->save();
723 + }
724 + }
725 + }
726 +
727 + public function updateRecentBoards($boardId)
728 + {
481 729 $userId = get_current_user_id();
482 -
483 - $recentlyOpened = Meta::where('object_id', $userId)
484 - ->where('object_type', Constant::OBJECT_TYPE_USER)
485 - ->where('key', Constant::USER_RECENT_BOARDS)
486 - ->first();
487 -
730 + $recentlyOpened = $this->recentlyViewedByUserQuery($userId)->first();
488 731 if (!$recentlyOpened) {
489 732 $openedBoards = [$boardId];
490 733 $userMeta = new Meta();
491 734 $userMeta->object_id = $userId;
@@ -493,44 +736,59 @@
493 736 $userMeta->key = Constant::USER_RECENT_BOARDS;
494 737 $userMeta->value = $openedBoards;
495 738 $userMeta->save();
496 739 } else {
497 - $boards = $recentlyOpened->value;
498 - if (in_array($boardId, $boards)) {
499 - $index = array_search($boardId, $boards);
500 - if ($index == 1) {
501 - $boards[1] = $boards[0];
502 - $boards[0] = $boardId;
503 - } elseif ($index == 2) {
504 - $boards[2] = $boards[1];
505 - $boards[1] = $boards[0];
506 - $boards[0] = $boardId;
740 + $recentBoardIds = $recentlyOpened->value;
741 + // Ensure the value is an array
742 + if (!is_array($recentBoardIds)) {
743 + $recentBoardIds = [];
744 + }
745 +
746 + // Check if the board is already in the list
747 + if (!in_array($boardId, $recentBoardIds)) {
748 + // Keep the 4 most recently opened boards for the dashboard view.
749 + if (count($recentBoardIds) >= 4) {
750 + array_pop($recentBoardIds);
507 751 }
508 - $recentlyOpened->value = $boards;
509 - $recentlyOpened->save();
510 752 } else {
511 - $boards[2] = $boards[1];
512 - $boards[1] = $boards[0];
513 - $boards[0] = $boardId;
514 - $recentlyOpened->value = $boards;
515 - $recentlyOpened->save();
753 + // Remove the existing board id to move it to the front
754 + $index = array_search($boardId, $recentBoardIds);
755 + unset($recentBoardIds[$index]);
516 756 }
757 + // Add the board to the beginning of the list
758 + array_unshift($recentBoardIds, $boardId);
759 +
760 + // Update the meta value and save it
761 + $recentlyOpened->value = $recentBoardIds;
762 + $recentlyOpened->save();
517 763 }
518 764 }
519 765
766 + public function recentlyViewedByUserQuery($userId = null)
767 + {
768 + if (!$userId) {
769 + $userId = get_current_user_id();
770 + }
771 +
772 + return Meta::query()->where('object_id', $userId)
773 + ->where('object_type', Constant::OBJECT_TYPE_USER)
774 + ->where('key', Constant::USER_RECENT_BOARDS);
775 + }
776 +
520 777 public function getRecentBoards()
521 778 {
522 779 $userId = get_current_user_id();
523 780
524 - $recentBoardIds = Meta::query()->where('object_id', $userId)
525 - ->where('object_type', Constant::OBJECT_TYPE_USER)
526 - ->where('key', Constant::USER_RECENT_BOARDS)
527 - ->value('value');
781 + $recentBoardIds = $this->recentlyViewedByUserQuery($userId)->value('value');
528 782
529 783 if (!$recentBoardIds) {
530 784 return [];
531 785 }
532 786
787 + if (!is_array($recentBoardIds)) {
788 + $recentBoardIds = [];
789 + }
790 +
533 791 $currentUser = User::find($userId);
534 792
535 793 if (!PermissionManager::isAdmin($userId)){
536 794 $recentBoardIds = array_intersect($recentBoardIds, $currentUser->whichBoards->pluck('id')->toArray());
@@ -535,11 +793,77 @@
535 793 if (!PermissionManager::isAdmin($userId)){
536 794 $recentBoardIds = array_intersect($recentBoardIds, $currentUser->whichBoards->pluck('id')->toArray());
537 795 }
538 796
539 - return Board::whereIn('id', $recentBoardIds)->withCount('completedTasks')->with(['stages', 'users'])->get();
797 + $recentBoardIds = array_values(array_slice($recentBoardIds, 0, 4));
798 +
799 + // This is for checking if that board is exists
800 + // TODO: we will remove this code in future version
801 + if (!$this->recentBoardBackwardCompatibilityCheck()) {
802 + foreach ($recentBoardIds as $index => $boardId) {
803 + $board = Board::find($boardId);
804 + if (!$board) {
805 + $this->deleteFromRecentlyViewed($boardId);
806 + unset($recentBoardIds[$index]);
807 + }
808 + }
809 +
810 + $this->updateRecentBoardCheckMeta();
811 + }
812 +
813 + return Board::whereIn('id', $recentBoardIds)
814 + ->whereNull('archived_at')
815 + ->excludeTemplates()
816 + ->availableInCurrentInstall()
817 + ->withCount('completedTasks')
818 + ->with(['stages', 'users'])
819 + ->get();
540 820 }
541 821
822 + public function getRecentBoardCheckMeta($userId = null){
823 + if (!$userId) {
824 + $userId = get_current_user_id();
825 + }
826 +
827 + return Meta::where('object_id', $userId)
828 + ->where('object_type', Constant::OBJECT_TYPE_USER)
829 + ->where('key', Constant::FBS_RECENTLY_VIEWED_CHECK)
830 + ->first();
831 + }
832 +
833 + private function recentBoardBackwardCompatibilityCheck() {
834 + $userId = get_current_user_id();
835 +
836 + $checkedMeta = $this->getRecentBoardCheckMeta($userId);
837 +
838 + if (!$checkedMeta) {
839 + $recentBoardCheck = new Meta();
840 + $recentBoardCheck->object_id = $userId;
841 + $recentBoardCheck->object_type = Constant::OBJECT_TYPE_USER;
842 + $recentBoardCheck->key = Constant::FBS_RECENTLY_VIEWED_CHECK;
843 + $recentBoardCheck->value = 'no';
844 + $recentBoardCheck->save();
845 +
846 + return false;
847 + } else {
848 + if ($checkedMeta->value == 'yes') {
849 + return true;
850 + } else {
851 + return false;
852 + }
853 + }
854 + }
855 +
856 + private function updateRecentBoardCheckMeta()
857 + {
858 + $checkedMeta = $this->getRecentBoardCheckMeta();
859 +
860 + if ($checkedMeta) {
861 + $checkedMeta->value = 'yes';
862 + $checkedMeta->save();
863 + }
864 + }
865 +
542 866 public function updateAssociateMember($contactId, $boardId)
543 867 {
544 868 $contactOfBoard = $this->getAssociateMember($boardId, true);
545 869
@@ -591,10 +915,15 @@
591 915
592 916 $contactOfBoard->delete();
593 917 }
594 918
595 - public function sendInvitationToBoard($boardId, $email)
919 + public function sendInvitationToBoard($boardId, $email, $role = 'member')
596 920 {
921 + $role = sanitize_text_field($role);
922 + if (!in_array($role, ['manager', 'member', 'viewer'], true)) {
923 + $role = 'member';
924 + }
925 +
597 926 $user = User::query()->where('user_email', $email)->first();
598 927
599 928 if ($user) {
600 929 return $user;
@@ -599,10 +928,12 @@
599 928 if ($user) {
600 929 return $user;
601 930 }
602 931
603 - do_action('fluent_boards_pro/send_invitation', $boardId, $email);
932 + $current_user_id = get_current_user_id();
604 933
934 + do_action('fluent_boards/send_invitation', $boardId, $email, $current_user_id, $role);
935 +
605 936 return;
606 937
607 938 }
608 939
@@ -613,92 +944,242 @@
613 944 ->where('key', Constant::BOARD_INVITATION)
614 945 ->get();
615 946 }
616 947
617 - 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)
618 955 {
619 - 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();
620 975 }
621 976
622 - public function hasDataChanged($boardId)
977 + public function hasDataChanged($boardId, $includeArchived = false, $since = null)
623 978 {
624 979 $stages = [];
625 980 $labels = [];
626 981 $tasks = [];
627 - $labels = [];
628 - $taskDeleted = false;
629 - $stageDeleted = false;
630 - $labelDeleted = false;
631 - $oneMinuteAgoTimestamp = current_time('timestamp') - 60; // Get the current timestamp and subtract 60 seconds
632 - $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;
633 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 +
634 994 $board = Board::find($boardId);
635 995 if (!$board) {
636 - throw new \Exception("Board doesn't exists");
996 + throw new \Exception(esc_html__("Board doesn't exists", 'fluent-boards'));
637 997 }
638 - // if stage in this board has been deleted
639 - $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;
640 1000
641 - if ($stageDeleted) {
642 - $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();
643 1017 } else {
644 - $stages = (new StageService())->getLastOneMinuteUpdatedStages($boardId);
1018 + $stages = (new StageService())->getLastOneMinuteUpdatedStages($boardId, $lastUpdated, $includeArchived);
645 1019 }
646 1020
647 - $labelDeleted = Activity::where('object_id', $boardId)->where('updated_at', '>=', $oneMinuteAgo)->where('action', 'deleted')->where('column', 'label')->exists();
648 - if ($labelDeleted) {
649 - $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();
650 1033 } else {
651 - $labels = (new LabelService())->getLastOneMinuteUpdatedLabels($boardId);
1034 + $labels = (new LabelService())->getLastOneMinuteUpdatedLabels($boardId, $lastUpdated, $includeArchived);
652 1035 }
653 1036
654 - // if task in this board has been deleted
655 - $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();
656 1040
657 - if ($taskDeletedOrMovedFormBoard) {
658 - $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()
659 1054 ->where([
660 1055 'board_id' => $boardId,
661 1056 'parent_id' => null,
662 - 'archived_at' => null
663 1057 ])
664 - ->with(['assignees', 'labels'])
665 - ->orderBy('due_at', 'ASC')
666 - ->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();
667 1069 } else {
668 - $tasks = (new TaskService())->getLastOneMinuteUpdatedTasks($boardId);
1070 + $tasks = (new TaskService())->getLastOneMinuteUpdatedTasks($boardId, $lastUpdated, $includeArchived);
669 1071 }
670 1072
671 1073 foreach ($tasks as $task) {
672 1074 $task->isOverdue = $task->isOverdue();
673 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);
674 1080 }
675 1081
676 - if ($board->updated_at >= $oneMinuteAgo) {
677 - $board->background = \maybe_unserialize($board->background);
678 - } else {
679 - $board = [];
1082 + $board->background = \maybe_unserialize($board->background);
1083 + if(!!defined('FLUENT_BOARDS_PRO_VERSION')) {
1084 + $board->custom_fields = $board->customFields;
680 1085 }
681 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 +
682 1096 return [
683 - 'board' => $board,
684 - 'stages' => $stages,
685 - 'labels' => $labels,
686 - 'tasks' => $tasks,
687 - 'taskDeleted' => $taskDeletedOrMovedFormBoard,
688 - '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,
689 1110 ];
690 1111 }
691 1112
692 - public function getAssociatedBoards($associatedId)
1113 + private function normalizeSyncCursor($since, $syncStartedAt)
693 1114 {
1115 + if (!is_string($since)) {
1116 + return null;
1117 + }
694 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 +
695 1160 $boardIds = Meta::query()->where('value', $associatedId)
696 1161 ->where('object_type', Constant::OBJECT_TYPE_BOARD)
697 - ->where('key', Constant::BOARD_ASSOCIATED_CRM_CONTACT)
1162 + ->whereIn('key', [
1163 + Constant::BOARD_ASSOCIATED_CRM_CONTACT,
1164 + self::LEGACY_BOARD_ASSOCIATED_CRM_CONTACT,
1165 + ])
698 1166 ->pluck('object_id');
699 1167
700 - 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;
701 1182 }
702 1183
703 1184 private function deleteBoardMeta($boardId)
704 1185 {
@@ -708,8 +1189,13 @@
708 1189 }
709 1190
710 1191 public function copyBoard($boardData)
711 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 + }
712 1198 $boardData = apply_filters('fluent_boards/before_create_board', $boardData);
713 1199
714 1200 $board = Board::create($boardData);
715 1201
@@ -717,86 +1203,267 @@
717 1203
718 1204 return $board;
719 1205 }
720 1206
721 - public function getBoardReports($board_id)
1207 + public function archiveBoard($boardId)
722 1208 {
723 - $completedTaskCount = Task::where('board_id', $board_id)
724 - ->where('status', 'closed')
725 - ->whereNull('parent_id')
726 - ->whereNull('archived_at')
727 - ->count();
1209 + $board = Board::findOrFail($boardId);
1210 + $board->archived_at = current_time('mysql');
1211 + $board->save();
728 1212
729 - $openTaskCount = Task::where('board_id', $board_id)
730 -// ->whereNull('due_at')
731 - ->whereNull('parent_id')
732 - ->whereNull('archived_at')
733 - ->where('status', 'open')
734 - ->count();
1213 + do_action('fluent_boards/board_archived', $board);
1214 + return $board;
1215 + }
735 1216
736 - $overDue = Task::where('board_id', $board_id)
737 -// ->whereNotNull('due_at')
738 -// ->where('status', 'open')
739 - ->whereNull('parent_id')
740 - ->whereNull('archived_at')
741 - ->overdue(true)
742 - ->count();
1217 + public function restoreBoard($boardId)
1218 + {
1219 + $board = Board::findOrFail($boardId);
1220 + $board->archived_at = null;
1221 + $board->save();
743 1222
744 - $totalTasks = Task::where('board_id', $board_id)
745 - ->whereNull('parent_id')
746 - ->whereNull('archived_at')
747 - ->count();
1223 + do_action('fluent_boards/board_restored', $board);
1224 + return $board;
1225 + }
748 1226
749 - $high = Task::where('board_id', $board_id)->where('status', 'open')->where('priority', 'high')->count();
750 - $medium = Task::where('board_id', $board_id)->where('status', 'open')->where('priority', 'medium')->count();
751 - $low = Task::where('board_id', $board_id)->where('status', 'open')->where('priority', 'low')->count();
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();
752 1232
753 - $reportData = [
754 - 'completion' => [
755 - 'completed' => $completedTaskCount,
756 - 'incomplete' => $openTaskCount,
757 - 'overdue' => $overDue,
758 - 'total' => $totalTasks
759 - ],
760 - 'priority' => [
761 - 'high' => $high,
762 - 'medium' => $medium,
763 - 'low' => $low
764 - ]
1233 + $boardUser->settings = [
1234 + 'is_admin' => false,
1235 + 'is_viewer_only' => false
1236 + ];
765 1237
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 + }
1245 +
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
766 1255 ];
767 - return $reportData;
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;
768 1263 }
769 1264
770 - public function getStageWiseBoardReports($board_id)
1265 + private function getUserWisePinnedBoards()
771 1266 {
772 - $stages = Stage::where('board_id', $board_id)
773 - ->where('type', 'stage')
774 - ->whereNull('archived_at')
1267 + $userId = get_current_user_id();
1268 +
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();
1273 +
1274 + return $pinnedBoardMeta;
1275 + }
1276 +
1277 + /**
1278 + * Sidebar counts cover every board the user can access, so they are counted
1279 + * with their own queries rather than derived from the filtered/paginated list.
1280 + *
1281 + * byAccessUser() re-reads the user's accessible board ids from the database on
1282 + * every call, so the access scope is resolved once and cloned per count.
1283 + *
1284 + * @return array{all: int, pinned: int, archived: int}
1285 + */
1286 + public function getBoardCounts($userId)
1287 + {
1288 + $baseQuery = Board::byAccessUser($userId)
1289 + ->excludeTemplates()
1290 + ->availableInCurrentInstall();
1291 +
1292 + $counts = [
1293 + 'all' => (clone $baseQuery)->whereNull('archived_at')->count(),
1294 + 'pinned' => 0,
1295 + 'archived' => (clone $baseQuery)->whereNotNull('archived_at')->count()
1296 + ];
1297 +
1298 + $pinnedIds = $this->getPinnedBoardIds();
1299 +
1300 + if ($pinnedIds) {
1301 + $counts['pinned'] = (clone $baseQuery)->whereNull('archived_at')
1302 + ->whereIn('id', $pinnedIds)
1303 + ->count();
1304 + }
1305 +
1306 + return $counts;
1307 + }
1308 +
1309 + /**
1310 + * @return array board ids the current user has pinned
1311 + */
1312 + public function getPinnedBoardIds()
1313 + {
1314 + $pinnedBoardMeta = $this->getUserWisePinnedBoards();
1315 +
1316 + if (!$pinnedBoardMeta) {
1317 + return [];
1318 + }
1319 +
1320 + return array_map('intval', (array) $pinnedBoardMeta->value);
1321 + }
1322 +
1323 + public function getPinnedBoards()
1324 + {
1325 + $pinnedBoardMeta = $this->getUserWisePinnedBoards();
1326 +
1327 + if (!$pinnedBoardMeta) {
1328 + return [];
1329 + } else {
1330 + $ids = $pinnedBoardMeta->value;
1331 +
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 + }
1340 + }
1341 +
1342 + public function pinBoard($boardId)
1343 + {
1344 + $pinnedBoardMeta = $this->getUserWisePinnedBoards();
1345 +
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;
1357 +
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 + }
1366 +
1367 + /**
1368 + * @param $boardId
1369 + * @return bool
1370 + */
1371 + public function unpinBoard($boardId)
1372 + {
1373 + $pinnedBoardMeta = $this->getUserWisePinnedBoards();
1374 +
1375 + if (!$pinnedBoardMeta) {
1376 + return false;
1377 + }
1378 +
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;
1389 + }
1390 +
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)
1397 + {
1398 + $pinnedBoardMeta = $this->getUserWisePinnedBoards();
1399 +
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;
1410 + }
1411 +
1412 + public function getBoardFolder($boardId)
1413 + {
1414 + $relation = Relation::where('object_type', Constant::OBJECT_TYPE_FOLDER_BOARD)
1415 + ->where('foreign_id', $boardId)
1416 + ->first();
1417 +
1418 + if (!$relation) {
1419 + return null;
1420 + }
1421 +
1422 + return Folder::find($relation->object_id);
1423 + }
1424 +
1425 + public function deleteWebhookData($boardId)
1426 + {
1427 + $outgoingRelations = Relation::where('object_type', 'outgoing_webhook_board')
1428 + ->where('foreign_id', $boardId)
775 1429 ->get();
776 1430
777 - foreach ($stages as $stage) {
778 - $completedTaskCount = Task::where('stage_id', $stage->id)
779 - ->where('status', 'closed')
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)
780 1436 ->count();
781 1437
782 - $openTaskCount = Task::where('stage_id', $stage->id)
783 - ->whereNull('due_at')
784 - ->where('status', 'open')
785 - ->count();
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;
786 1446
787 - $overDue = Task::where('stage_id', $stage->id)
788 - ->whereNotNull('due_at')
789 - ->where('status', 'open')
790 - ->overdue(true)
791 - ->count();
1447 + if (isset($value['board_id'])) {
1448 + $boards = $value['board_id'];
792 1449
793 - $stage->report = [
794 - 'completed' => $completedTaskCount,
795 - 'incomplete' => $openTaskCount,
796 - 'overdue' => $overDue
797 - ];
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();
798 1467 }
799 -
800 - return $stages;
801 1468 }
802 1469 }