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