PluginProbe
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration / 2.2.0
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration v2.2.0
2.2.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.0, at app/Services/BoardService.php

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