PluginProbe
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration / trunk
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration vtrunk
2.0.15 2.0.12 2.0.10 2.0.4 2.0.1 2.0.0 1.95.3 1.95.2 1.95 1.91.6 trunk 1.11 1.12 1.13 1.20 1.21 1.22 1.23 1.30 1.31 1.32 1.35 1.40 1.41 1.45 All 41 releases
fluent-boards / app / Services / BoardService.php

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

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