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