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

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