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 +946 -213 1.13 → 2.2.1 View file →
@@ -1,24 +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;
19 +use FluentBoards\App\Services\DescriptionMarkdownConverter;
20 +use FluentBoardsPro\App\Models\CustomField;
15 21
16 22 class BoardService
17 23 {
24 + private const LEGACY_BOARD_ASSOCIATED_CRM_CONTACT = 'crm_contact';
25 +
18 26 public function getBoardsByType($type)
19 27 {
20 - 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();
21 33 }
22 34
23 35 public function deleteBoard($boardId)
24 36 {
@@ -27,15 +39,65 @@
27 39 $options = null;
28 40 //if we need to do something before a board is deleted
29 41 do_action('fluent_boards/before_board_deleted', $board, $options);
30 42
31 - //related task delete, task related relations delete
43 + // Relation IDs overlap across boards, tasks and folders; delete only task-owned types.
32 44 $allTaskIdsInBoard = $board->tasks->pluck('id');
33 - $taskRelatedRelations = Relation::whereIn('object_id', $allTaskIdsInBoard);
34 - $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();
35 59 TaskMeta::whereIn('task_id', $allTaskIdsInBoard)->delete();
36 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 +
37 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();
38 100
39 101 //removing all Board Settings
40 102 $board->boardUserEmailNotificationSettings()->detach();
41 103 $board->boardUserNotificationSettings()->detach();
@@ -47,15 +109,25 @@
47 109
48 110 //removing add board labels
49 111 $board->labels()->delete();
50 112
51 - //removing add board comment
52 - $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 + }
53 123
124 +
54 125 foreach ($board->notifications as $notification) {
55 126 $notification->users()->detach();
56 127 }
57 128 $board->notifications()->delete();
129 + $board->removeBoardFromFolder();
58 130
59 131 //delete board related meta
60 132 $this->deleteBoardMeta($boardId);
61 133
@@ -61,13 +133,34 @@
61 133
62 134 //delete from recently viewed
63 135 $this->deleteFromRecentlyViewed($boardId);
64 136
137 + //delete webhook data
138 + $this->deleteWebhookData($boardId);
139 +
65 140 $board->delete();
66 - if(!defined('FLUENT_BOARDS_PRO')){
67 - 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 + }
68 162 }
69 -// do_action('fluent_boards/board_deleted', $board);
70 163 }
71 164
72 165 public function fetchBoardMeta($boardId)
73 166 {
@@ -122,12 +215,13 @@
122 215 public function createBoard($boardData)
123 216 {
124 217 $boardData = [
125 218 'title' => $boardData['title'],
126 - 'type' => $boardData['type'] ? $boardData['type'] : 'todo',
127 - 'description' => $boardData['description'],
219 + 'type' => $boardData['type'] ? $boardData['type'] : 'to-do',
220 + 'description' => DescriptionMarkdownConverter::normalize($boardData['description'] ?? ''),
128 221 'currency' => isset($boardData['currency']) ? $boardData['currency'] : 'USD',
129 222 'background' => isset($boardData['background']) ? $boardData['background'] : '',
223 + 'created_by' => isset($boardData['created_by']) ? $boardData['created_by'] : get_current_user_id()
130 224 ];
131 225
132 226 $boardData = apply_filters('fluent_boards/before_create_board', $boardData);
133 227
@@ -137,12 +231,23 @@
137 231
138 232 return $board;
139 233 }
140 234
141 - 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)
142 242 {
243 + $creatorId = absint($board->created_by);
244 + if (!$creatorId) {
245 + return;
246 + }
247 +
143 248 $board->users()->attach(
144 - get_current_user_id(),
249 + $creatorId,
145 250 [
146 251 'object_type' => Constant::OBJECT_TYPE_BOARD_USER,
147 252 'settings' => maybe_serialize([
148 253 Constant::IS_BOARD_ADMIN => true
@@ -161,15 +266,27 @@
161 266 $board->users()->detach($userId);
162 267 $board->boardUserNotificationSettings()->detach($userId); //removing notification settings of user in that board
163 268 $board->boardUserEmailNotificationSettings()->detach($userId); //removing email notification settings of user in that board
164 269
165 - //detacing all tasks of this board from user
166 - $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);
167 276
168 - $user->tasks()->detach($taskIdsToDetach);
169 - $user->watchingTasks()->detach($taskIdsToDetach);
277 + }
170 278
171 -// do_action('fluent_boards/board_member_removed', $boardId, $user->display_name);
279 + private function removeFromDefaultAssignee($boardId, $user)
280 + {
281 + $stages = Stage::where('board_id', $boardId)->get();
282 + foreach ($stages as $stage) {
283 + if (isset($stage->settings['default_task_assignees'])) {
284 + if (($key = array_search($user, $stage->settings['default_task_assignees'])) !== false) {
285 + unset($stage->settings['default_task_assignees'][$key]);
286 + }
287 + }
288 + }
172 289 }
173 290
174 291 public function removeFromRecentlyOpened($boardId, $userId)
175 292 {
@@ -179,9 +296,18 @@
179 296 ->first();
180 297 if ($recentlyOpened) {
181 298 $recentBoardIds = $recentlyOpened->value;
182 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 +
183 305 $index = array_search($boardId, $recentBoardIds);
306 + if ($index === false) {
307 + return;
308 + }
309 +
184 310 array_splice($recentBoardIds, $index, 1);
185 311
186 312 $recentlyOpened->value = $recentBoardIds;
187 313 $recentlyOpened->save();
@@ -193,12 +319,12 @@
193 319 {
194 320 if ($data['title']) {
195 321 $data['title'] = $data['title'];
196 322 } else {
197 - throw new \Exception('Title cannot be empty');
323 + throw new \Exception(esc_html__('Title cannot be empty', 'fluent-boards'));
198 324 }
199 325 if (isset($data['description'])) {
200 - $data['description'] = $data['description'];
326 + $data['description'] = DescriptionMarkdownConverter::normalize($data['description']);
201 327 }
202 328 $board->fill($data);
203 329 $board->save();
204 330 // do_action('fluent_boards/board_updated', $board);
@@ -224,50 +350,10 @@
224 350
225 351 return serialize($this->processStages($stages));
226 352 }
227 353
228 - public function changePositionOfStage($boardId, $changeData)
229 - {
230 - $allStages = Stage::where('board_id', $boardId)
231 - ->where('is_archived', 0)
232 - ->where('type', 'stage')
233 - ->orderBy('position', 'asc')
234 - ->get();
235 354
236 - $changedStage = null;
237 -
238 - foreach ($allStages as $stage) {
239 - if ($changeData['fromPosition'] < $changeData['toPosition']) {
240 - if ($stage['position'] == $changeData['fromPosition']) {
241 - $stage['position'] = (int)$changeData['toPosition'];
242 - $stage->save();
243 - $changedStage = $stage;
244 - } elseif (
245 - $stage['position'] > $changeData['fromPosition'] &&
246 - $stage['position'] <= $changeData['toPosition']
247 - ) {
248 - $stage['position'] = $stage->position - 1;
249 - $stage->save();
250 - }
251 - } else {
252 - if (
253 - $stage['position'] >= $changeData['toPosition'] &&
254 - $stage['position'] < $changeData['fromPosition']
255 - ) {
256 - $stage['position'] = $stage->position + 1;
257 - $stage->save();
258 - } elseif ($stage['position'] == $changeData['fromPosition']) {
259 - $stage['position'] = (int)$changeData['toPosition'];
260 - $stage->save();
261 - $changedStage = $stage;
262 - }
263 - }
264 - }
265 -
266 - do_action('fluent_boards/board_stage_dragged', $changedStage);
267 - }
268 -
269 - public function rePositionStages($boardId, $incomingList)
355 + public function repositionStages($boardId, $incomingList)
270 356 {
271 357 $oldList = Stage::where('board_id', $boardId)->where('type', 'stage')->whereNull('archived_at')->orderBy('position')->pluck('id');
272 358
273 359 foreach ($incomingList as $key => $stage_id) {
@@ -295,36 +381,56 @@
295 381 }
296 382 return $processedStages;
297 383 }
298 384
299 - public function archiveStage($id, $stage)
385 + /**
386 + * Archive a stage and persist the user who archived it for future archive-list metadata.
387 + */
388 + public function archiveStage($boardId, $stage)
300 389 {
390 + $settings = $stage->settings ?: [];
391 + $settings['archived_by_id'] = absint(get_current_user_id()) ?: null;
392 +
301 393 $stage->archived_at = current_time('mysql');
302 394 $stage->position = 0;
395 + $stage->settings = $settings;
303 396 $stage->save();
304 397
305 - do_action('fluent_boards/board_stage_archived', $id, $stage->title);
398 + do_action('fluent_boards/stage_archived', $boardId, $stage); // Old hook
399 + do_action('fluent_boards/stage_archived_with_tasks', $boardId, $stage); // New hook
306 400 return $stage;
307 401 }
308 402
309 - public function restoreStage($id, $stage)
403 + /**
404 + * Restore an archived stage and clear stale archived-by metadata.
405 + */
406 + public function restoreStage($boardId, $stage)
310 407 {
311 408 $stageService = new StageService();
312 409 $lastStagePosition = $stageService->getLastPositionOfStagesOfBoard($stage->board_id);
410 + $settings = $stage->settings ?: [];
411 + $settings['archived_by_id'] = null;
412 +
313 413 $stage->archived_at = null;
314 414 $stage->position = $lastStagePosition ? $lastStagePosition->position + 1 : 1;
415 + $stage->settings = $settings;
315 416 $stage->save();
316 - do_action('fluent_boards/board_stage_restored', $id, $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
317 419 return $stage;
318 420 }
319 421
320 422 public function getActivities($id, $data)
321 423 {
322 - $per_page = isset($data['per_page']) ? $data['per_page'] : 40;
323 - $page = isset($data['page']) ? $data['page'] : 1;
324 - 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'])
325 427 ->orderBy('id', 'DESC')
326 428 ->paginate($per_page, ['*'], 'page', $page);
429 +
430 + Helper::translateActivities($activities);
431 +
432 + return $activities;
327 433 }
328 434
329 435 public function isAlreadyMember($boardId, $memberId)
330 436 {
@@ -334,26 +440,55 @@
334 440
335 441 return $isAlreadyMember ?? false;
336 442 }
337 443
338 - public function addMembersInBoard($boardId, $memberId)
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 + */
450 + public function addMembersInBoard($boardId, $memberId, $isViewerOnly = null)
339 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 +
340 460 $board = Board::find($boardId);
461 + $boardMember = User::find($memberId);
341 462
342 - if (!$board) {
463 + if (!$board || !$boardMember) {
464 + return null;
465 + }
466 + $isAlreadyMember = $this->isAlreadyMember($boardId, $memberId);
467 + if($isAlreadyMember) {
343 468 return false;
344 469 }
470 + $settings = Constant::BOARD_USER_SETTINGS;
345 471
472 + if($isViewerOnly === 'yes') {
473 + $settings = Constant::BOARD_USER_VIEWER_ONLY_SETTINGS;
474 + }
475 +
476 +
477 +
346 478 $board->users()->attach(
347 479 $memberId,
348 480 [
349 481 'object_type' => Constant::OBJECT_TYPE_BOARD_USER,
350 - 'settings' => maybe_serialize(Constant::BOARD_USER_SETTINGS),
482 + 'settings' => maybe_serialize($settings),
351 483 'preferences' => maybe_serialize(Constant::BOARD_NOTIFICATION_TYPES)
352 484 ]
353 485 );
354 - $boardMember = User::find($memberId);
355 - do_action('fluent_boards/board_member_added', $boardId, $boardMember->display_name);
486 + if(!$isViewerOnly) {
487 + do_action('fluent_boards/board_member_added', $boardId, $boardMember);
488 + } else {
489 + do_action('fluent_boards/board_viewer_added', $boardId, $boardMember);
490 + }
356 491 return $boardMember;
357 492 }
358 493
359 494 public function makeAdminOfBoard($boardId, $userId)
@@ -390,8 +525,75 @@
390 525 $user['is_board_admin'] = false;
391 526 return $user;
392 527 }
393 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 +
394 596 public function getUsersOfBoards()
395 597 {
396 598 $userBoards = Relation::whereNotNull('board_id')
397 599 ->where('user_id', get_current_user_id())
@@ -400,35 +602,68 @@
400 602 return $userBoards;
401 603 }
402 604
403 605 /**
404 - * change board background
405 - * @param mixed $backgroundData
406 - * @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
407 615 */
408 -
409 616 public function setBoardBackground($backgroundData, $board_id)
410 617 {
411 - $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 +
412 635 $background = $board->background;
413 -
414 - // if board background has color
415 - if (isset($backgroundData['color'])) {
416 - $background['color'] = $backgroundData['color'];
417 - $background['image_url'] = null;
418 - $background['is_image'] = false;
636 + if (!is_array($background)) {
637 + $background = [];
419 638 }
420 639
421 - // if board background has image
640 + // Resolve image metadata from the board-owned attachment, never from the client URL.
422 641 if (isset($backgroundData['image_url'])) {
423 - $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);
424 654 $background['is_image'] = true;
425 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;
426 661 }
427 - $background['id'] = $backgroundData['id'];
428 662
429 663 $board->background = $background;
430 664 $board->save();
665 + do_action('fluent_boards/board_background_updated', $boardId, $oldBackground);
431 666
432 667 return $board->background;
433 668 }
434 669
@@ -438,25 +673,85 @@
438 673 * @param mixed $board_id
439 674 * @param mixed $stage_slug
440 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
441 676 */
442 - public function getStageTaskAvailablePositions($board_id, $stage_id)
677 + public function getStageTaskAvailablePositions($board_id, $stage_id, $task_id = null)
443 678 {
444 - $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()
445 686 ->where('board_id', $board_id)
446 687 ->where('parent_id', null)
447 688 ->where('stage_id', $stage_id)
448 689 ->whereNull('archived_at')
449 690 ->orderBy('position', 'asc')
450 - ->get()
451 - ->pluck('position')->toArray();
691 + ->get(['id', 'position']);
452 692
453 - $totalPosition = count($availablePositions);
454 - $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 + }
455 698
456 - 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 + ];
457 733 }
458 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 +
459 754 public function getAssigneesByBoard($board_id, $search = '')
460 755 {
461 756 $assignees = [];
462 757 $boardUsers = [];
@@ -516,10 +811,10 @@
516 811 }
517 812
518 813 // Check if the board is already in the list
519 814 if (!in_array($boardId, $recentBoardIds)) {
520 - // If there are already 3 boards, remove the last one
521 - if (count($recentBoardIds) >= 3) {
815 + // Keep the 4 most recently opened boards for the dashboard view.
816 + if (count($recentBoardIds) >= 4) {
522 817 array_pop($recentBoardIds);
523 818 }
524 819 } else {
525 820 // Remove the existing board id to move it to the front
@@ -555,8 +850,12 @@
555 850 if (!$recentBoardIds) {
556 851 return [];
557 852 }
558 853
854 + if (!is_array($recentBoardIds)) {
855 + $recentBoardIds = [];
856 + }
857 +
559 858 $currentUser = User::find($userId);
560 859
561 860 if (!PermissionManager::isAdmin($userId)){
562 861 $recentBoardIds = array_intersect($recentBoardIds, $currentUser->whichBoards->pluck('id')->toArray());
@@ -561,8 +860,10 @@
561 860 if (!PermissionManager::isAdmin($userId)){
562 861 $recentBoardIds = array_intersect($recentBoardIds, $currentUser->whichBoards->pluck('id')->toArray());
563 862 }
564 863
864 + $recentBoardIds = array_values(array_slice($recentBoardIds, 0, 4));
865 +
565 866 // This is for checking if that board is exists
566 867 // TODO: we will remove this code in future version
567 868 if (!$this->recentBoardBackwardCompatibilityCheck()) {
568 869 foreach ($recentBoardIds as $index => $boardId) {
@@ -575,9 +876,15 @@
575 876
576 877 $this->updateRecentBoardCheckMeta();
577 878 }
578 879
579 - 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();
580 887 }
581 888
582 889 public function getRecentBoardCheckMeta($userId = null){
583 890 if (!$userId) {
@@ -675,10 +982,15 @@
675 982
676 983 $contactOfBoard->delete();
677 984 }
678 985
679 - public function sendInvitationToBoard($boardId, $email)
986 + public function sendInvitationToBoard($boardId, $email, $role = 'member')
680 987 {
988 + $role = sanitize_text_field($role);
989 + if (!in_array($role, ['manager', 'member', 'viewer'], true)) {
990 + $role = 'member';
991 + }
992 +
681 993 $user = User::query()->where('user_email', $email)->first();
682 994
683 995 if ($user) {
684 996 return $user;
@@ -685,9 +997,9 @@
685 997 }
686 998
687 999 $current_user_id = get_current_user_id();
688 1000
689 - do_action('fluent_boards/send_invitation', $boardId, $email, $current_user_id);
1001 + do_action('fluent_boards/send_invitation', $boardId, $email, $current_user_id, $role);
690 1002
691 1003 return;
692 1004
693 1005 }
@@ -699,92 +1011,264 @@
699 1011 ->where('key', Constant::BOARD_INVITATION)
700 1012 ->get();
701 1013 }
702 1014
703 - 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)
704 1022 {
705 - 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();
706 1042 }
707 1043
708 - public function hasDataChanged($boardId)
1044 + public function hasDataChanged($boardId, $includeArchived = false, $since = null)
709 1045 {
710 1046 $stages = [];
711 1047 $labels = [];
712 1048 $tasks = [];
713 - $labels = [];
714 - $taskDeleted = false;
715 - $stageDeleted = false;
716 - $labelDeleted = false;
717 - $oneMinuteAgoTimestamp = current_time('timestamp') - 60; // Get the current timestamp and subtract 60 seconds
718 - $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;
719 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 +
720 1061 $board = Board::find($boardId);
721 1062 if (!$board) {
722 - throw new \Exception("Board doesn't exists");
1063 + throw new \Exception(esc_html__("Board doesn't exists", 'fluent-boards'));
723 1064 }
724 - // if stage in this board has been deleted
725 - $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;
726 1067
727 - if ($stageDeleted) {
728 - $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();
729 1084 } else {
730 - $stages = (new StageService())->getLastOneMinuteUpdatedStages($boardId);
1085 + $stages = (new StageService())->getLastOneMinuteUpdatedStages($boardId, $lastUpdated, $includeArchived);
731 1086 }
732 1087
733 - $labelDeleted = Activity::where('object_id', $boardId)->where('updated_at', '>=', $oneMinuteAgo)->where('action', 'deleted')->where('column', 'label')->exists();
734 - if ($labelDeleted) {
735 - $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();
736 1100 } else {
737 - $labels = (new LabelService())->getLastOneMinuteUpdatedLabels($boardId);
1101 + $labels = (new LabelService())->getLastOneMinuteUpdatedLabels($boardId, $lastUpdated, $includeArchived);
738 1102 }
739 1103
740 - // if task in this board has been deleted
741 - $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();
742 1107
743 - if ($taskDeletedOrMovedFormBoard) {
744 - $tasks = Task::query()
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) {
1120 + $tasksQuery = Task::query()
745 1121 ->where([
746 1122 'board_id' => $boardId,
747 1123 'parent_id' => null,
748 - 'archived_at' => null
749 1124 ])
750 - ->with(['assignees', 'labels'])
751 - ->orderBy('due_at', 'ASC')
752 - ->get();
1125 + ->with(['assignees', 'labels', 'watchers']);
1126 +
1127 + if (!$includeArchived) {
1128 + $tasksQuery->whereNull('archived_at');
1129 + }
1130 +
1131 + if (!!defined('FLUENT_BOARDS_PRO_VERSION')) {
1132 + $tasksQuery->with('customFields');
1133 + }
1134 +
1135 + $tasks = $tasksQuery->orderBy('due_at', 'ASC')->get();
753 1136 } else {
754 - $tasks = (new TaskService())->getLastOneMinuteUpdatedTasks($boardId);
1137 + $tasks = (new TaskService())->getLastOneMinuteUpdatedTasks($boardId, $lastUpdated, $includeArchived);
755 1138 }
756 1139
757 1140 foreach ($tasks as $task) {
758 1141 $task->isOverdue = $task->isOverdue();
759 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);
760 1147 }
761 1148
762 - if ($board->updated_at >= $oneMinuteAgo) {
763 - $board->background = \maybe_unserialize($board->background);
764 - } else {
765 - $board = [];
1149 + $board->background = \maybe_unserialize($board->background);
1150 + if(!!defined('FLUENT_BOARDS_PRO_VERSION')) {
1151 + $board->custom_fields = $board->customFields;
766 1152 }
767 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 +
768 1163 return [
769 - 'board' => $board,
770 - 'stages' => $stages,
771 - 'labels' => $labels,
772 - 'tasks' => $tasks,
773 - 'taskDeleted' => $taskDeletedOrMovedFormBoard,
774 - '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,
775 1177 ];
776 1178 }
777 1179
778 - public function getAssociatedBoards($associatedId)
1180 + private function normalizeSyncCursor($since, $syncStartedAt)
779 1181 {
1182 + if (!is_string($since)) {
1183 + return null;
1184 + }
780 1185
781 - $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)
782 1239 ->where('object_type', Constant::OBJECT_TYPE_BOARD)
783 - ->where('key', Constant::BOARD_ASSOCIATED_CRM_CONTACT)
784 - ->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();
785 1247
786 - 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;
787 1271 }
788 1272
789 1273 private function deleteBoardMeta($boardId)
790 1274 {
@@ -794,8 +1278,13 @@
794 1278 }
795 1279
796 1280 public function copyBoard($boardData)
797 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 + }
798 1287 $boardData = apply_filters('fluent_boards/before_create_board', $boardData);
799 1288
800 1289 $board = Board::create($boardData);
801 1290
@@ -803,101 +1292,345 @@
803 1292
804 1293 return $board;
805 1294 }
806 1295
807 - 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)
808 1300 {
809 - $taskQuery = Task::where('board_id', $board_id)
810 - ->whereNull('parent_id')
811 - ->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();
812 1305
813 - return $this->getTaskReports($taskQuery);
1306 + $sourceMembers = Relation::where('object_id', $sourceBoardId)
1307 + ->where('object_type', Constant::OBJECT_TYPE_BOARD_USER)
1308 + ->get();
1309 +
1310 + foreach ($sourceMembers as $sourceMember) {
1311 + if (in_array($sourceMember->foreign_id, $existingMemberIds)) {
1312 + continue;
1313 + }
1314 +
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 + ]);
1322 +
1323 + $boardMember = User::find($sourceMember->foreign_id);
1324 + $settings = is_array($sourceMember->settings) ? $sourceMember->settings : [];
1325 +
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 + }
1332 +
1333 + if (!empty($settings[Constant::IS_BOARD_ADMIN])) {
1334 + do_action('fluent_boards/board_admin_added', $newBoard->id, $sourceMember->foreign_id);
1335 + }
1336 + }
1337 +
1338 + $existingMemberIds[] = $sourceMember->foreign_id;
1339 + }
814 1340 }
815 1341
816 - public function getAllBoardReports(){
1342 + /**
1343 + * Copy Pro custom field definitions and return an old-to-new ID map.
1344 + */
1345 + public function copyCustomFieldsOfBoard($fromBoardId, $board)
1346 + {
1347 + if (!defined('FLUENT_BOARDS_PRO') || !class_exists(CustomField::class)) {
1348 + return [];
1349 + }
1350 +
1351 + $fromBoardId = absint($fromBoardId);
1352 +
1353 + if (!$fromBoardId || !$board) {
1354 + return [];
1355 + }
1356 +
1357 + $customFieldMap = [];
1358 + $customFields = CustomField::where('board_id', $fromBoardId)
1359 + ->whereNull('archived_at')
1360 + ->orderBy('position', 'asc')
1361 + ->get();
1362 +
1363 + foreach ($customFields as $customField) {
1364 + $newCustomField = $customField->replicate();
1365 + $newCustomField->board_id = $board->id;
1366 + $newCustomField->save();
1367 +
1368 + $customFieldMap[(int) $customField->id] = $newCustomField->id;
1369 + }
1370 +
1371 + return $customFieldMap;
1372 + }
1373 +
1374 + public function archiveBoard($boardId)
1375 + {
1376 + $board = Board::findOrFail($boardId);
1377 + $board->archived_at = current_time('mysql');
1378 + $board->save();
1379 +
1380 + do_action('fluent_boards/board_archived', $board);
1381 + return $board;
1382 + }
1383 +
1384 + public function restoreBoard($boardId)
1385 + {
1386 + $board = Board::findOrFail($boardId);
1387 + $board->archived_at = null;
1388 + $board->save();
1389 +
1390 + do_action('fluent_boards/board_restored', $board);
1391 + return $board;
1392 + }
1393 +
1394 + public function makeMember($boardId, $userId)
1395 + {
1396 + $boardUser = Relation::where('object_id', $boardId)
1397 + ->where('object_type', Constant::OBJECT_TYPE_BOARD_USER)
1398 + ->where('foreign_id', $userId)->first();
1399 +
1400 + $boardUser->settings = [
1401 + 'is_admin' => false,
1402 + 'is_viewer_only' => false
1403 + ];
1404 +
1405 + $boardUser->save();
1406 + $user = User::findOrFail($userId);
1407 + do_action('fluent_boards/board_member_added', $boardId, $boardUser);
1408 + $user['is_admin'] = false;
1409 + $user['is_board_admin'] = false;
1410 + return $user;
1411 + }
1412 +
1413 + public function makeViewer($boardId, $userId)
1414 + {
1415 + $boardUser = Relation::where('object_id', $boardId)
1416 + ->where('object_type', Constant::OBJECT_TYPE_BOARD_USER)
1417 + ->where('foreign_id', $userId)->first();
1418 +
1419 + $boardUser->settings = [
1420 + 'is_admin' => false,
1421 + 'is_viewer_only' => true
1422 + ];
1423 +
1424 + $boardUser->save();
1425 + $user = User::findOrFail($userId);
1426 + do_action('fluent_boards/board_viewer_added', $boardId, $boardUser);
1427 + $user['is_admin'] = false;
1428 + $user['is_board_admin'] = false;
1429 + return $user;
1430 + }
1431 +
1432 + private function getUserWisePinnedBoards()
1433 + {
817 1434 $userId = get_current_user_id();
818 1435
819 - $taskQuery = Task::whereNull('parent_id')
820 - ->whereNull('archived_at');
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();
821 1440
822 - if (!PermissionManager::isAdmin($userId))
823 - {
824 - $currentUser = User::find($userId);
825 - $relatedBoardIds = $currentUser->whichBoards->where('type', 'to-do')->pluck('id');
826 - $taskQuery->whereIn('board_id', $relatedBoardIds);
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();
827 1471 }
828 1472
829 - return $this->getTaskReports($taskQuery);
1473 + return $counts;
830 1474 }
831 1475
832 - private function getTaskReports($taskQuery)
1476 + /**
1477 + * @return array board ids the current user has pinned
1478 + */
1479 + public function getPinnedBoardIds()
833 1480 {
834 - $totalTasksQuery = clone $taskQuery;
835 - $completedTaskQuery = clone $taskQuery;
836 - $openTaskQuery = clone $taskQuery;
837 - $overDueTaskQuery = clone $taskQuery;
1481 + $pinnedBoardMeta = $this->getUserWisePinnedBoards();
838 1482
839 - $completedTaskCount = $completedTaskQuery->where('status', 'closed')->count();
840 - $openTaskCount = $openTaskQuery->where('status', 'open')->count();
841 - $overDueTasks = $overDueTaskQuery->overdue(true)->count();
842 - $totalTasks = $totalTasksQuery->count();
1483 + if (!$pinnedBoardMeta) {
1484 + return [];
1485 + }
843 1486
844 - $taskQuery->where('status', 'open');
1487 + return array_map('intval', (array) $pinnedBoardMeta->value);
1488 + }
845 1489
846 - $highQuery = clone $taskQuery;
847 - $mediumQuery = clone $taskQuery;
848 - $lowQuery = clone $taskQuery;
1490 + public function getPinnedBoards()
1491 + {
1492 + $pinnedBoardMeta = $this->getUserWisePinnedBoards();
849 1493
850 - $high = $highQuery->where('priority', 'high')->count();
851 - $low = $mediumQuery->where('priority', 'low')->count();
852 - $medium = $lowQuery->where('priority', 'medium')->count();
1494 + if (!$pinnedBoardMeta) {
1495 + return [];
1496 + } else {
1497 + $ids = $pinnedBoardMeta->value;
853 1498
854 - $reportData = [
855 - 'completion' => [
856 - 'completed' => $completedTaskCount,
857 - 'incomplete' => $openTaskCount,
858 - 'overdue' => $overDueTasks,
859 - 'total' => $totalTasks
860 - ],
861 - 'priority' => [
862 - 'high' => $high,
863 - 'medium' => $medium,
864 - 'low' => $low
865 - ]
1499 + // Convert to array of integers
1500 + $intIds = array_map('intval', $ids);
866 1501
867 - ];
868 - return $reportData;
1502 + return Board::whereIn('id', $intIds)
1503 + ->whereNull('archived_at')
1504 + ->byAccessUser(get_current_user_id())
1505 + ->get();
1506 + }
869 1507 }
870 1508
871 - public function getStageWiseBoardReports($board_id)
1509 + public function pinBoard($boardId)
872 1510 {
873 - $stages = Stage::where('board_id', $board_id)
874 - ->where('type', 'stage')
875 - ->whereNull('archived_at')
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)
876 1596 ->get();
877 1597
878 - foreach ($stages as $stage) {
879 - $completedTaskCount = Task::where('stage_id', $stage->id)
880 - ->where('status', 'closed')
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)
881 1603 ->count();
882 1604
883 - $openTaskCount = Task::where('stage_id', $stage->id)
884 - ->whereNull('due_at')
885 - ->where('status', 'open')
886 - ->count();
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;
887 1613
888 - $overDue = Task::where('stage_id', $stage->id)
889 - ->whereNotNull('due_at')
890 - ->where('status', 'open')
891 - ->overdue(true)
892 - ->count();
1614 + if (isset($value['board_id'])) {
1615 + $boards = $value['board_id'];
893 1616
894 - $stage->report = [
895 - 'completed' => $completedTaskCount,
896 - 'incomplete' => $openTaskCount,
897 - 'overdue' => $overDue
898 - ];
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();
899 1634 }
900 -
901 - return $stages;
902 1635 }
903 1636 }