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
fluent-boards / app / Services / BoardService.php

BoardService.php in FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration 2.2.1, at app/Services/BoardService.php

1,637 lines 55.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentBoards\App\Services;
4
5 use FluentBoards\App\Hooks\Handlers\FileHandler;
6 use FluentBoards\App\Models\Activity;
7 use FluentBoards\App\Models\Attachment;
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;
13 use FluentBoards\App\Models\Relation;
14 use FluentBoards\App\Models\Stage;
15 use FluentBoards\App\Models\Task;
16 use FluentBoards\App\Models\TaskMeta;
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;
21
22 class BoardService
23 {
24 private const LEGACY_BOARD_ASSOCIATED_CRM_CONTACT = 'crm_contact';
25
26 public function getBoardsByType($type)
27 {
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();
33 }
34
35 public function deleteBoard($boardId)
36 {
37 $board = Board::findOrFail($boardId);
38
39 $options = null;
40 //if we need to do something before a board is deleted
41 do_action('fluent_boards/before_board_deleted', $board, $options);
42
43 // Relation IDs overlap across boards, tasks and folders; delete only task-owned types.
44 $allTaskIdsInBoard = $board->tasks->pluck('id');
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();
59 TaskMeta::whereIn('task_id', $allTaskIdsInBoard)->delete();
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
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();
100
101 //removing all Board Settings
102 $board->boardUserEmailNotificationSettings()->detach();
103 $board->boardUserNotificationSettings()->detach();
104 //removing all Board users
105 $board->users()->detach();
106
107 //removing add board stages
108 $board->stages()->delete();
109
110 //removing add board labels
111 $board->labels()->delete();
112
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 }
123
124
125 foreach ($board->notifications as $notification) {
126 $notification->users()->detach();
127 }
128 $board->notifications()->delete();
129 $board->removeBoardFromFolder();
130
131 //delete board related meta
132 $this->deleteBoardMeta($boardId);
133
134 //delete from recently viewed
135 $this->deleteFromRecentlyViewed($boardId);
136
137 //delete webhook data
138 $this->deleteWebhookData($boardId);
139
140 $board->delete();
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 }
162 }
163 }
164
165 public function fetchBoardMeta($boardId)
166 {
167 $boardMeta = Meta::where('object_id', $boardId)
168 ->where('object_type', 'board')
169 ->where('key', 'is_auth_require')
170 ->orderBy('id', 'desc')->first();
171
172 if ($boardMeta) {
173 $boardMeta->value = maybe_unserialize($boardMeta->value);
174 return $boardMeta;
175 } else {
176 $meta = new Meta();
177 $settingData = array(
178 'is_auth_require_idea_submit' => '',
179 'is_auth_require_voting_commenting' => '',
180 'is_auth_require_reaction' => '',
181 'is_allow_email_along_with_auth' => '',
182 'is_allow_unauthentication_reaction_along_with_auth' => ''
183 );
184 $meta->object_id = $boardId;
185 $meta->object_type = 'board';
186 $meta->key = 'is_auth_require';
187 $meta->value = \maybe_serialize($settingData);
188 $meta->save();
189 $meta->value = $settingData;
190 return $meta;
191 }
192 }
193
194 public function modifyAuthenticationPermission($data, $boardId)
195 {
196 $boardMeta = Meta::where('object_id', $boardId)
197 ->where('object_type', 'board')
198 ->where('key', 'is_auth_require')
199 ->orderBy('id', 'desc')->first();
200
201 if ($boardMeta) {
202 $settings = array(
203 'is_auth_require_idea_submit' => $data['is_auth_require_idea_submit'],
204 'is_auth_require_voting_commenting' => $data['is_auth_require_voting_commenting'],
205 'is_auth_require_reaction' => $data['is_auth_require_reaction'],
206 'is_allow_email_along_with_auth' => $data['is_allow_email_along_with_auth'],
207 'is_allow_unauthentication_reaction_along_with_auth' => $data['is_allow_unauthentication_reaction_along_with_auth']
208 );
209 $boardMeta->value = \maybe_serialize($settings);
210 $boardMeta->save();
211 }
212 return $boardMeta;
213 }
214
215 public function createBoard($boardData)
216 {
217 $boardData = [
218 'title' => $boardData['title'],
219 'type' => $boardData['type'] ? $boardData['type'] : 'to-do',
220 'description' => DescriptionMarkdownConverter::normalize($boardData['description'] ?? ''),
221 'currency' => isset($boardData['currency']) ? $boardData['currency'] : 'USD',
222 'background' => isset($boardData['background']) ? $boardData['background'] : '',
223 'created_by' => isset($boardData['created_by']) ? $boardData['created_by'] : get_current_user_id()
224 ];
225
226 $boardData = apply_filters('fluent_boards/before_create_board', $boardData);
227
228 $board = Board::create($boardData);
229
230 $this->setCurrentUserPreferencesOnBoardCreate($board);
231
232 return $board;
233 }
234
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)
242 {
243 $creatorId = absint($board->created_by);
244 if (!$creatorId) {
245 return;
246 }
247
248 $board->users()->attach(
249 $creatorId,
250 [
251 'object_type' => Constant::OBJECT_TYPE_BOARD_USER,
252 'settings' => maybe_serialize([
253 Constant::IS_BOARD_ADMIN => true
254 ]),
255 'preferences' => maybe_serialize(Constant::BOARD_NOTIFICATION_TYPES)
256 ]
257 );
258 }
259
260 public function removeUserFromBoard($boardId, $userId)
261 {
262
263 $board = Board::findOrFail($boardId);
264 $user = User::findOrFail($userId);
265
266 $board->users()->detach($userId);
267 $board->boardUserNotificationSettings()->detach($userId); //removing notification settings of user in that board
268 $board->boardUserEmailNotificationSettings()->detach($userId); //removing email notification settings of user in that board
269
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);
276
277 }
278
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 }
289 }
290
291 public function removeFromRecentlyOpened($boardId, $userId)
292 {
293 $recentlyOpened = Meta::where('object_id', $userId)
294 ->where('object_type', Constant::OBJECT_TYPE_USER)
295 ->where('key', Constant::USER_RECENT_BOARDS)
296 ->first();
297 if ($recentlyOpened) {
298 $recentBoardIds = $recentlyOpened->value;
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
305 $index = array_search($boardId, $recentBoardIds);
306 if ($index === false) {
307 return;
308 }
309
310 array_splice($recentBoardIds, $index, 1);
311
312 $recentlyOpened->value = $recentBoardIds;
313 $recentlyOpened->save();
314 }
315
316 }
317
318 public function updateBoard($board, $data)
319 {
320 if ($data['title']) {
321 $data['title'] = $data['title'];
322 } else {
323 throw new \Exception(esc_html__('Title cannot be empty', 'fluent-boards'));
324 }
325 if (isset($data['description'])) {
326 $data['description'] = DescriptionMarkdownConverter::normalize($data['description']);
327 }
328 $board->fill($data);
329 $board->save();
330 // do_action('fluent_boards/board_updated', $board);
331 return $board;
332 }
333
334 public function defaultStages()
335 {
336 $stages = [
337 (object)[
338 'group' => 'open',
339 'label' => 'Open',
340 ],
341 (object)[
342 'group' => 'in_progress',
343 'label' => 'In Progress',
344 ],
345 (object)[
346 'group' => 'completed',
347 'label' => 'Completed',
348 ],
349 ];
350
351 return serialize($this->processStages($stages));
352 }
353
354
355 public function repositionStages($boardId, $incomingList)
356 {
357 $oldList = Stage::where('board_id', $boardId)->where('type', 'stage')->whereNull('archived_at')->orderBy('position')->pluck('id');
358
359 foreach ($incomingList as $key => $stage_id) {
360 $stage = Stage::findOrFail($stage_id);
361 $stage->moveToNewPosition($key + 1);
362 }
363 do_action('fluent_boards/board_stages_reordered', $boardId, $oldList);
364 }
365
366 public function processStages($stages)
367 {
368 $processedStages = [];
369 foreach ($stages as $stage) {
370 if (is_object($stage)) {
371 $processedStages[] = (object)[
372 'group' => Helper::snake_case($stage->slug),
373 'label' => sanitize_text_field($stage->label)
374 ];
375 } else {
376 $processedStages[] = (object)[
377 'group' => Helper::snake_case($stage['group']),
378 'label' => sanitize_text_field($stage['label'])
379 ];
380 }
381 }
382 return $processedStages;
383 }
384
385 /**
386 * Archive a stage and persist the user who archived it for future archive-list metadata.
387 */
388 public function archiveStage($boardId, $stage)
389 {
390 $settings = $stage->settings ?: [];
391 $settings['archived_by_id'] = absint(get_current_user_id()) ?: null;
392
393 $stage->archived_at = current_time('mysql');
394 $stage->position = 0;
395 $stage->settings = $settings;
396 $stage->save();
397
398 do_action('fluent_boards/stage_archived', $boardId, $stage); // Old hook
399 do_action('fluent_boards/stage_archived_with_tasks', $boardId, $stage); // New hook
400 return $stage;
401 }
402
403 /**
404 * Restore an archived stage and clear stale archived-by metadata.
405 */
406 public function restoreStage($boardId, $stage)
407 {
408 $stageService = new StageService();
409 $lastStagePosition = $stageService->getLastPositionOfStagesOfBoard($stage->board_id);
410 $settings = $stage->settings ?: [];
411 $settings['archived_by_id'] = null;
412
413 $stage->archived_at = null;
414 $stage->position = $lastStagePosition ? $lastStagePosition->position + 1 : 1;
415 $stage->settings = $settings;
416 $stage->save();
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
419 return $stage;
420 }
421
422 public function getActivities($id, $data)
423 {
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'])
427 ->orderBy('id', 'DESC')
428 ->paginate($per_page, ['*'], 'page', $page);
429
430 Helper::translateActivities($activities);
431
432 return $activities;
433 }
434
435 public function isAlreadyMember($boardId, $memberId)
436 {
437 $isAlreadyMember = Relation::where('object_id', $boardId)
438 ->where('object_type', Constant::OBJECT_TYPE_BOARD_USER)
439 ->where('foreign_id', $memberId)->first();
440
441 return $isAlreadyMember ?? false;
442 }
443
444 /**
445 * Add a WordPress user to a board.
446 *
447 * @return User|false|null User on success, false for an existing relation,
448 * or null when the board/user does not exist.
449 */
450 public function addMembersInBoard($boardId, $memberId, $isViewerOnly = null)
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
460 $board = Board::find($boardId);
461 $boardMember = User::find($memberId);
462
463 if (!$board || !$boardMember) {
464 return null;
465 }
466 $isAlreadyMember = $this->isAlreadyMember($boardId, $memberId);
467 if($isAlreadyMember) {
468 return false;
469 }
470 $settings = Constant::BOARD_USER_SETTINGS;
471
472 if($isViewerOnly === 'yes') {
473 $settings = Constant::BOARD_USER_VIEWER_ONLY_SETTINGS;
474 }
475
476
477
478 $board->users()->attach(
479 $memberId,
480 [
481 'object_type' => Constant::OBJECT_TYPE_BOARD_USER,
482 'settings' => maybe_serialize($settings),
483 'preferences' => maybe_serialize(Constant::BOARD_NOTIFICATION_TYPES)
484 ]
485 );
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 }
491 return $boardMember;
492 }
493
494 public function makeAdminOfBoard($boardId, $userId)
495 {
496 $boardUser = Relation::where('object_id', $boardId)
497 ->where('object_type', Constant::OBJECT_TYPE_BOARD_USER)
498 ->where('foreign_id', $userId)->first();
499 $boardUser->settings = [
500 'is_admin' => true
501 ];
502 $boardUser->save();
503
504 $user = User::findOrFail($userId);
505 do_action('fluent_boards/board_admin_added', $boardId, $userId);
506 $user['is_admin'] = true;
507 $user['is_board_admin'] = true;
508 return $user;
509 }
510
511 public function removeAdminFromBoard($boardId, $userId)
512 {
513 $boardUser = Relation::where('object_id', $boardId)
514 ->where('object_type', Constant::OBJECT_TYPE_BOARD_USER)
515 ->where('foreign_id', $userId)->first();
516
517 $boardUser->settings = [
518 'is_admin' => false
519 ];
520
521 $boardUser->save();
522 $user = User::findOrFail($userId);
523 do_action('fluent_boards/board_admin_removed', $boardId, $userId);
524 $user['is_admin'] = false;
525 $user['is_board_admin'] = false;
526 return $user;
527 }
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
596 public function getUsersOfBoards()
597 {
598 $userBoards = Relation::whereNotNull('board_id')
599 ->where('user_id', get_current_user_id())
600 ->where('status', 'ACTIVE')->get();
601
602 return $userBoards;
603 }
604
605 /**
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
615 */
616 public function setBoardBackground($backgroundData, $board_id)
617 {
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
635 $background = $board->background;
636 if (!is_array($background)) {
637 $background = [];
638 }
639
640 // Resolve image metadata from the board-owned attachment, never from the client URL.
641 if (isset($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);
654 $background['is_image'] = true;
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;
661 }
662
663 $board->background = $background;
664 $board->save();
665 do_action('fluent_boards/board_background_updated', $boardId, $oldBackground);
666
667 return $board->background;
668 }
669
670
671 /**
672 * Summary of getStageTaskAvailablePositions
673 * @param mixed $board_id
674 * @param mixed $stage_slug
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
676 */
677 public function getStageTaskAvailablePositions($board_id, $stage_id, $task_id = null)
678 {
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()
686 ->where('board_id', $board_id)
687 ->where('parent_id', null)
688 ->where('stage_id', $stage_id)
689 ->whereNull('archived_at')
690 ->orderBy('position', 'asc')
691 ->get(['id', 'position']);
692
693 if ($isCurrentStage) {
694 $stageTasks = $stageTasks->filter(function ($stageTask) use ($task_id) {
695 return (int) $stageTask->id !== $task_id;
696 })->values();
697 }
698
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 ];
733 }
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
754 public function getAssigneesByBoard($board_id, $search = '')
755 {
756 $assignees = [];
757 $boardUsers = [];
758 $board = Board::with('users')->find($board_id);
759
760 if ($board) {
761 if ($search) {
762 $boardUsers = $board->users->filter(
763 function ($user) use ($search) {
764 return strpos($user->display_name, $search) !== false || strpos($user->user_email, $search) !== false;
765 }
766 );
767 } else {
768 $boardUsers = $board->users;
769 }
770 };
771 foreach ($boardUsers as $user) {
772 $taskAssignee = Relation::where('foreign_id', $user->ID)->where('object_type', 'task_assignee')->exists();
773 if ($taskAssignee) {
774 $assignees[] = $user;
775 }
776 }
777 return $assignees;
778 }
779
780 private function deleteFromRecentlyViewed($boardId)
781 {
782 $recentlyOpened = $this->recentlyViewedByUserQuery()->first();
783 if ($recentlyOpened) {
784 $recentBoardIds = $recentlyOpened->value;
785 if (in_array($boardId, $recentBoardIds)) {
786 $index = array_search($boardId, $recentBoardIds);
787 unset($recentBoardIds[$index]);
788 $recentlyOpened->value = $recentBoardIds;
789 $recentlyOpened->save();
790 }
791 }
792 }
793
794 public function updateRecentBoards($boardId)
795 {
796 $userId = get_current_user_id();
797 $recentlyOpened = $this->recentlyViewedByUserQuery($userId)->first();
798 if (!$recentlyOpened) {
799 $openedBoards = [$boardId];
800 $userMeta = new Meta();
801 $userMeta->object_id = $userId;
802 $userMeta->object_type = Constant::OBJECT_TYPE_USER;
803 $userMeta->key = Constant::USER_RECENT_BOARDS;
804 $userMeta->value = $openedBoards;
805 $userMeta->save();
806 } else {
807 $recentBoardIds = $recentlyOpened->value;
808 // Ensure the value is an array
809 if (!is_array($recentBoardIds)) {
810 $recentBoardIds = [];
811 }
812
813 // Check if the board is already in the list
814 if (!in_array($boardId, $recentBoardIds)) {
815 // Keep the 4 most recently opened boards for the dashboard view.
816 if (count($recentBoardIds) >= 4) {
817 array_pop($recentBoardIds);
818 }
819 } else {
820 // Remove the existing board id to move it to the front
821 $index = array_search($boardId, $recentBoardIds);
822 unset($recentBoardIds[$index]);
823 }
824 // Add the board to the beginning of the list
825 array_unshift($recentBoardIds, $boardId);
826
827 // Update the meta value and save it
828 $recentlyOpened->value = $recentBoardIds;
829 $recentlyOpened->save();
830 }
831 }
832
833 public function recentlyViewedByUserQuery($userId = null)
834 {
835 if (!$userId) {
836 $userId = get_current_user_id();
837 }
838
839 return Meta::query()->where('object_id', $userId)
840 ->where('object_type', Constant::OBJECT_TYPE_USER)
841 ->where('key', Constant::USER_RECENT_BOARDS);
842 }
843
844 public function getRecentBoards()
845 {
846 $userId = get_current_user_id();
847
848 $recentBoardIds = $this->recentlyViewedByUserQuery($userId)->value('value');
849
850 if (!$recentBoardIds) {
851 return [];
852 }
853
854 if (!is_array($recentBoardIds)) {
855 $recentBoardIds = [];
856 }
857
858 $currentUser = User::find($userId);
859
860 if (!PermissionManager::isAdmin($userId)){
861 $recentBoardIds = array_intersect($recentBoardIds, $currentUser->whichBoards->pluck('id')->toArray());
862 }
863
864 $recentBoardIds = array_values(array_slice($recentBoardIds, 0, 4));
865
866 // This is for checking if that board is exists
867 // TODO: we will remove this code in future version
868 if (!$this->recentBoardBackwardCompatibilityCheck()) {
869 foreach ($recentBoardIds as $index => $boardId) {
870 $board = Board::find($boardId);
871 if (!$board) {
872 $this->deleteFromRecentlyViewed($boardId);
873 unset($recentBoardIds[$index]);
874 }
875 }
876
877 $this->updateRecentBoardCheckMeta();
878 }
879
880 return Board::whereIn('id', $recentBoardIds)
881 ->whereNull('archived_at')
882 ->excludeTemplates()
883 ->availableInCurrentInstall()
884 ->withCount('completedTasks')
885 ->with(['stages', 'users'])
886 ->get();
887 }
888
889 public function getRecentBoardCheckMeta($userId = null){
890 if (!$userId) {
891 $userId = get_current_user_id();
892 }
893
894 return Meta::where('object_id', $userId)
895 ->where('object_type', Constant::OBJECT_TYPE_USER)
896 ->where('key', Constant::FBS_RECENTLY_VIEWED_CHECK)
897 ->first();
898 }
899
900 private function recentBoardBackwardCompatibilityCheck() {
901 $userId = get_current_user_id();
902
903 $checkedMeta = $this->getRecentBoardCheckMeta($userId);
904
905 if (!$checkedMeta) {
906 $recentBoardCheck = new Meta();
907 $recentBoardCheck->object_id = $userId;
908 $recentBoardCheck->object_type = Constant::OBJECT_TYPE_USER;
909 $recentBoardCheck->key = Constant::FBS_RECENTLY_VIEWED_CHECK;
910 $recentBoardCheck->value = 'no';
911 $recentBoardCheck->save();
912
913 return false;
914 } else {
915 if ($checkedMeta->value == 'yes') {
916 return true;
917 } else {
918 return false;
919 }
920 }
921 }
922
923 private function updateRecentBoardCheckMeta()
924 {
925 $checkedMeta = $this->getRecentBoardCheckMeta();
926
927 if ($checkedMeta) {
928 $checkedMeta->value = 'yes';
929 $checkedMeta->save();
930 }
931 }
932
933 public function updateAssociateMember($contactId, $boardId)
934 {
935 $contactOfBoard = $this->getAssociateMember($boardId, true);
936
937 if ($contactOfBoard) {
938 $contactOfBoard->value = $contactId;
939 $contactOfBoard->save();
940 } else {
941 $contactOfBoard = new Meta();
942 $contactOfBoard->object_id = $boardId;
943 $contactOfBoard->object_type = Constant::OBJECT_TYPE_BOARD;
944 $contactOfBoard->key = Constant::BOARD_ASSOCIATED_CRM_CONTACT;
945 $contactOfBoard->value = $contactId;
946 $contactOfBoard->save();
947 }
948
949 $board = Board::findOrFail($boardId);
950 do_action('fluent_boards/contact_added_to_board', $board, $contactId);
951
952 }
953
954 public function getAssociateMember($boardId, $fromUpdateMethod = false)
955 {
956
957 $contactOfBoard = Meta::query()->where('object_id', $boardId)
958 ->where('object_type', Constant::OBJECT_TYPE_BOARD)
959 ->where('key', Constant::BOARD_ASSOCIATED_CRM_CONTACT)
960 ->first();
961
962 if ($fromUpdateMethod) {
963 return $contactOfBoard;
964 }
965
966 if (!$contactOfBoard) {
967 return null;
968 }
969
970 return Helper::crm_contact($contactOfBoard->value);
971
972 // return \FluentCrm\App\Models\Subscriber::find($contactOfBoard->value);
973 }
974
975 public function deleteAssociateMember($boardId, $contact_id)
976 {
977 $contactOfBoard = Meta::query()->where('object_id', $boardId)
978 ->where('object_type', Constant::OBJECT_TYPE_BOARD)
979 ->where('key', Constant::BOARD_ASSOCIATED_CRM_CONTACT)
980 ->where('value', $contact_id)
981 ->first();
982
983 $contactOfBoard->delete();
984 }
985
986 public function sendInvitationToBoard($boardId, $email, $role = 'member')
987 {
988 $role = sanitize_text_field($role);
989 if (!in_array($role, ['manager', 'member', 'viewer'], true)) {
990 $role = 'member';
991 }
992
993 $user = User::query()->where('user_email', $email)->first();
994
995 if ($user) {
996 return $user;
997 }
998
999 $current_user_id = get_current_user_id();
1000
1001 do_action('fluent_boards/send_invitation', $boardId, $email, $current_user_id, $role);
1002
1003 return;
1004
1005 }
1006
1007 public function getInvitations($boardId)
1008 {
1009 return Meta::query()->where('object_id', $boardId)
1010 ->where('object_type', Constant::OBJECT_TYPE_BOARD)
1011 ->where('key', Constant::BOARD_INVITATION)
1012 ->get();
1013 }
1014
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)
1022 {
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();
1042 }
1043
1044 public function hasDataChanged($boardId, $includeArchived = false, $since = null)
1045 {
1046 $stages = [];
1047 $labels = [];
1048 $tasks = [];
1049 $syncStartedAt = current_time('mysql');
1050 $isCursorRequest = !empty($since);
1051 $forceFullSync = false;
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
1061 $board = Board::find($boardId);
1062 if (!$board) {
1063 throw new \Exception(esc_html__("Board doesn't exists", 'fluent-boards'));
1064 }
1065 $boardUpdatedAt = $this->formatSyncTimestamp($board->updated_at);
1066 $boardChanged = !$isCursorRequest || $forceFullSync || $boardUpdatedAt >= $lastUpdated;
1067
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();
1084 } else {
1085 $stages = (new StageService())->getLastOneMinuteUpdatedStages($boardId, $lastUpdated, $includeArchived);
1086 }
1087
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();
1100 } else {
1101 $labels = (new LabelService())->getLastOneMinuteUpdatedLabels($boardId, $lastUpdated, $includeArchived);
1102 }
1103
1104 $stageArchiveRestored = !$forceFullSync && (clone $stageActivityQuery)
1105 ->whereIn('action', ['archived', 'restored'])
1106 ->exists();
1107
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()
1121 ->where([
1122 'board_id' => $boardId,
1123 'parent_id' => null,
1124 ])
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();
1136 } else {
1137 $tasks = (new TaskService())->getLastOneMinuteUpdatedTasks($boardId, $lastUpdated, $includeArchived);
1138 }
1139
1140 foreach ($tasks as $task) {
1141 $task->isOverdue = $task->isOverdue();
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);
1147 }
1148
1149 $board->background = \maybe_unserialize($board->background);
1150 if(!!defined('FLUENT_BOARDS_PRO_VERSION')) {
1151 $board->custom_fields = $board->customFields;
1152 }
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
1163 return [
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,
1177 ];
1178 }
1179
1180 private function normalizeSyncCursor($since, $syncStartedAt)
1181 {
1182 if (!is_string($since)) {
1183 return null;
1184 }
1185
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)
1239 ->where('object_type', Constant::OBJECT_TYPE_BOARD)
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();
1247
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;
1271 }
1272
1273 private function deleteBoardMeta($boardId)
1274 {
1275 Meta::where('object_id', $boardId)
1276 ->where('object_type', Constant::OBJECT_TYPE_BOARD)
1277 ->delete();
1278 }
1279
1280 public function copyBoard($boardData)
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 }
1287 $boardData = apply_filters('fluent_boards/before_create_board', $boardData);
1288
1289 $board = Board::create($boardData);
1290
1291 $this->setCurrentUserPreferencesOnBoardCreate($board);
1292
1293 return $board;
1294 }
1295
1296 /**
1297 * Copy board member relations and their role preferences to a duplicated board.
1298 */
1299 public function copyMembersOfBoard($sourceBoardId, Board $newBoard)
1300 {
1301 $existingMemberIds = Relation::where('object_id', $newBoard->id)
1302 ->where('object_type', Constant::OBJECT_TYPE_BOARD_USER)
1303 ->pluck('foreign_id')
1304 ->toArray();
1305
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 }
1340 }
1341
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 {
1434 $userId = get_current_user_id();
1435
1436 $pinnedBoardMeta = Meta::query()->where('object_id', $userId)
1437 ->where('object_type', Constant::OBJECT_TYPE_USER)
1438 ->where('key', Constant::USER_PINNED_BOARDS)
1439 ->first();
1440
1441 return $pinnedBoardMeta;
1442 }
1443
1444 /**
1445 * Sidebar counts cover every board the user can access, so they are counted
1446 * with their own queries rather than derived from the filtered/paginated list.
1447 *
1448 * byAccessUser() re-reads the user's accessible board ids from the database on
1449 * every call, so the access scope is resolved once and cloned per count.
1450 *
1451 * @return array{all: int, pinned: int, archived: int}
1452 */
1453 public function getBoardCounts($userId)
1454 {
1455 $baseQuery = Board::byAccessUser($userId)
1456 ->excludeTemplates()
1457 ->availableInCurrentInstall();
1458
1459 $counts = [
1460 'all' => (clone $baseQuery)->whereNull('archived_at')->count(),
1461 'pinned' => 0,
1462 'archived' => (clone $baseQuery)->whereNotNull('archived_at')->count()
1463 ];
1464
1465 $pinnedIds = $this->getPinnedBoardIds();
1466
1467 if ($pinnedIds) {
1468 $counts['pinned'] = (clone $baseQuery)->whereNull('archived_at')
1469 ->whereIn('id', $pinnedIds)
1470 ->count();
1471 }
1472
1473 return $counts;
1474 }
1475
1476 /**
1477 * @return array board ids the current user has pinned
1478 */
1479 public function getPinnedBoardIds()
1480 {
1481 $pinnedBoardMeta = $this->getUserWisePinnedBoards();
1482
1483 if (!$pinnedBoardMeta) {
1484 return [];
1485 }
1486
1487 return array_map('intval', (array) $pinnedBoardMeta->value);
1488 }
1489
1490 public function getPinnedBoards()
1491 {
1492 $pinnedBoardMeta = $this->getUserWisePinnedBoards();
1493
1494 if (!$pinnedBoardMeta) {
1495 return [];
1496 } else {
1497 $ids = $pinnedBoardMeta->value;
1498
1499 // Convert to array of integers
1500 $intIds = array_map('intval', $ids);
1501
1502 return Board::whereIn('id', $intIds)
1503 ->whereNull('archived_at')
1504 ->byAccessUser(get_current_user_id())
1505 ->get();
1506 }
1507 }
1508
1509 public function pinBoard($boardId)
1510 {
1511 $pinnedBoardMeta = $this->getUserWisePinnedBoards();
1512
1513 if ($pinnedBoardMeta) {
1514 $currentPinnedBoards = $pinnedBoardMeta->value;
1515 if (!in_array($boardId, $currentPinnedBoards)) {
1516 $currentPinnedBoards[] = $boardId;
1517 $pinnedBoardMeta->value = $currentPinnedBoards;
1518 $pinnedBoardMeta->save();
1519 }
1520 } else {
1521 // Create an empty array
1522 $boardIds = [];
1523 $boardIds[] = $boardId;
1524
1525 $meta = new Meta();
1526 $meta->object_id = get_current_user_id();
1527 $meta->object_type = Constant::OBJECT_TYPE_USER;
1528 $meta->key = Constant::USER_PINNED_BOARDS;
1529 $meta->value = $boardIds;
1530 $meta->save();
1531 }
1532 }
1533
1534 /**
1535 * @param $boardId
1536 * @return bool
1537 */
1538 public function unpinBoard($boardId)
1539 {
1540 $pinnedBoardMeta = $this->getUserWisePinnedBoards();
1541
1542 if (!$pinnedBoardMeta) {
1543 return false;
1544 }
1545
1546 $currentPinnedBoards = $pinnedBoardMeta->value;
1547 if (in_array($boardId, $currentPinnedBoards)) {
1548 $index = array_search($boardId, $currentPinnedBoards);
1549 array_splice($currentPinnedBoards, $index, 1);
1550 $pinnedBoardMeta->value = $currentPinnedBoards;
1551 $pinnedBoardMeta->save();
1552 return true;
1553 }
1554
1555 return false;
1556 }
1557
1558 /**
1559 * @param $boardId
1560 * @return bool
1561 * If board id is in user's current pinned boards list
1562 */
1563 public function isPinned($boardId)
1564 {
1565 $pinnedBoardMeta = $this->getUserWisePinnedBoards();
1566
1567 if (!$pinnedBoardMeta) {
1568 return false;
1569 }
1570
1571 $currentPinnedBoards = $pinnedBoardMeta->value;
1572 if (in_array($boardId, $currentPinnedBoards)) {
1573 return true;
1574 }
1575
1576 return false;
1577 }
1578
1579 public function getBoardFolder($boardId)
1580 {
1581 $relation = Relation::where('object_type', Constant::OBJECT_TYPE_FOLDER_BOARD)
1582 ->where('foreign_id', $boardId)
1583 ->first();
1584
1585 if (!$relation) {
1586 return null;
1587 }
1588
1589 return Folder::find($relation->object_id);
1590 }
1591
1592 public function deleteWebhookData($boardId)
1593 {
1594 $outgoingRelations = Relation::where('object_type', 'outgoing_webhook_board')
1595 ->where('foreign_id', $boardId)
1596 ->get();
1597
1598 foreach ($outgoingRelations as $relation) {
1599 $webhookMetaId = (int) $relation->object_id;
1600
1601 $linkedCount = Relation::where('object_type', 'outgoing_webhook_board')
1602 ->where('object_id', $webhookMetaId)
1603 ->count();
1604
1605 if ($linkedCount === 1) {
1606 Meta::where('id', $webhookMetaId)
1607 ->where('object_type', 'outgoing_webhook')
1608 ->delete();
1609 } else if ($linkedCount > 1) {
1610 $meta = Meta::find($webhookMetaId);
1611 if ($meta && $meta->object_type === 'outgoing_webhook') {
1612 $value = $meta->value;
1613
1614 if (isset($value['board_id'])) {
1615 $boards = $value['board_id'];
1616
1617 if (is_array($boards)) {
1618 $boards = array_values(array_filter($boards, function ($id) use ($boardId) {
1619 return intval($id) !== intval($boardId);
1620 }));
1621 $value['board_id'] = $boards;
1622 } else {
1623 if ($boards !== null && intval($boards) === intval($boardId)) {
1624 $value['board_id'] = [];
1625 }
1626 }
1627
1628 $meta->value = $value;
1629 $meta->save();
1630 }
1631 }
1632 }
1633 $relation->delete();
1634 }
1635 }
1636 }
1637