PluginProbe
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration / 2.0.1
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration v2.0.1
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 1.40 1.41 All 42 releases
fluent-boards / app / Services / BoardService.php

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

1,408 lines 46.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\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)->excludeTemplates()->withCount('completedTasks')->with(['stages', 'users'])->get();
780 }
781
782 public function getRecentBoardCheckMeta($userId = null){
783 if (!$userId) {
784 $userId = get_current_user_id();
785 }
786
787 return Meta::where('object_id', $userId)
788 ->where('object_type', Constant::OBJECT_TYPE_USER)
789 ->where('key', Constant::FBS_RECENTLY_VIEWED_CHECK)
790 ->first();
791 }
792
793 private function recentBoardBackwardCompatibilityCheck() {
794 $userId = get_current_user_id();
795
796 $checkedMeta = $this->getRecentBoardCheckMeta($userId);
797
798 if (!$checkedMeta) {
799 $recentBoardCheck = new Meta();
800 $recentBoardCheck->object_id = $userId;
801 $recentBoardCheck->object_type = Constant::OBJECT_TYPE_USER;
802 $recentBoardCheck->key = Constant::FBS_RECENTLY_VIEWED_CHECK;
803 $recentBoardCheck->value = 'no';
804 $recentBoardCheck->save();
805
806 return false;
807 } else {
808 if ($checkedMeta->value == 'yes') {
809 return true;
810 } else {
811 return false;
812 }
813 }
814 }
815
816 private function updateRecentBoardCheckMeta()
817 {
818 $checkedMeta = $this->getRecentBoardCheckMeta();
819
820 if ($checkedMeta) {
821 $checkedMeta->value = 'yes';
822 $checkedMeta->save();
823 }
824 }
825
826 public function updateAssociateMember($contactId, $boardId)
827 {
828 $contactOfBoard = $this->getAssociateMember($boardId, true);
829
830 if ($contactOfBoard) {
831 $contactOfBoard->value = $contactId;
832 $contactOfBoard->save();
833 } else {
834 $contactOfBoard = new Meta();
835 $contactOfBoard->object_id = $boardId;
836 $contactOfBoard->object_type = Constant::OBJECT_TYPE_BOARD;
837 $contactOfBoard->key = Constant::BOARD_ASSOCIATED_CRM_CONTACT;
838 $contactOfBoard->value = $contactId;
839 $contactOfBoard->save();
840 }
841
842 $board = Board::findOrFail($boardId);
843 do_action('fluent_boards/contact_added_to_board', $board, $contactId);
844
845 }
846
847 public function getAssociateMember($boardId, $fromUpdateMethod = false)
848 {
849
850 $contactOfBoard = Meta::query()->where('object_id', $boardId)
851 ->where('object_type', Constant::OBJECT_TYPE_BOARD)
852 ->where('key', Constant::BOARD_ASSOCIATED_CRM_CONTACT)
853 ->first();
854
855 if ($fromUpdateMethod) {
856 return $contactOfBoard;
857 }
858
859 if (!$contactOfBoard) {
860 return null;
861 }
862
863 return Helper::crm_contact($contactOfBoard->value);
864
865 // return \FluentCrm\App\Models\Subscriber::find($contactOfBoard->value);
866 }
867
868 public function deleteAssociateMember($boardId, $contact_id)
869 {
870 $contactOfBoard = Meta::query()->where('object_id', $boardId)
871 ->where('object_type', Constant::OBJECT_TYPE_BOARD)
872 ->where('key', Constant::BOARD_ASSOCIATED_CRM_CONTACT)
873 ->where('value', $contact_id)
874 ->first();
875
876 $contactOfBoard->delete();
877 }
878
879 public function sendInvitationToBoard($boardId, $email, $role = 'member')
880 {
881 $role = sanitize_text_field($role);
882 if (!in_array($role, ['manager', 'member', 'viewer'], true)) {
883 $role = 'member';
884 }
885
886 $user = User::query()->where('user_email', $email)->first();
887
888 if ($user) {
889 return $user;
890 }
891
892 $current_user_id = get_current_user_id();
893
894 do_action('fluent_boards/send_invitation', $boardId, $email, $current_user_id, $role);
895
896 return;
897
898 }
899
900 public function getInvitations($boardId)
901 {
902 return Meta::query()->where('object_id', $boardId)
903 ->where('object_type', Constant::OBJECT_TYPE_BOARD)
904 ->where('key', Constant::BOARD_INVITATION)
905 ->get();
906 }
907
908 public function deleteInvitation($invitationId)
909 {
910 Meta::findOrFail($invitationId)->delete();
911 }
912
913 public function hasDataChanged($boardId, $includeArchived = false, $since = null)
914 {
915 $stages = [];
916 $labels = [];
917 $tasks = [];
918 $syncStartedAt = current_time('mysql');
919 $isCursorRequest = !empty($since);
920 $forceFullSync = false;
921
922 if ($isCursorRequest) {
923 $lastUpdated = $this->normalizeSyncCursor($since, $syncStartedAt);
924 $forceFullSync = !$lastUpdated;
925 } else {
926 $oneMinuteAgoTimestamp = current_time('timestamp') - 60;
927 $lastUpdated = date_i18n('Y-m-d H:i:s', $oneMinuteAgoTimestamp);
928 }
929
930 $board = Board::find($boardId);
931 if (!$board) {
932 throw new \Exception(esc_html__("Board doesn't exists", 'fluent-boards'));
933 }
934 $boardUpdatedAt = $this->formatSyncTimestamp($board->updated_at);
935 $boardChanged = !$isCursorRequest || $forceFullSync || $boardUpdatedAt >= $lastUpdated;
936
937 // Reset the local list if a change can remove an item from the user's current view.
938 $stageActivityQuery = Activity::where('object_id', $boardId)
939 ->where('object_type', Constant::ACTIVITY_BOARD)
940 ->where('updated_at', '>=', $lastUpdated)
941 ->where('column', 'stage');
942
943 $stageResetRequired = $forceFullSync || (clone $stageActivityQuery)
944 ->whereIn('action', ['deleted', 'archived', 'restored'])
945 ->exists();
946
947 if ($stageResetRequired) {
948 $stagesQuery = Stage::where('board_id', $boardId)->orderBy('position', 'asc');
949 if (!$includeArchived) {
950 $stagesQuery->whereNull('archived_at');
951 }
952 $stages = $stagesQuery->get();
953 } else {
954 $stages = (new StageService())->getLastOneMinuteUpdatedStages($boardId, $lastUpdated, $includeArchived);
955 }
956
957 $labelResetRequired = $forceFullSync || Activity::where('object_id', $boardId)
958 ->where('object_type', Constant::ACTIVITY_BOARD)
959 ->where('updated_at', '>=', $lastUpdated)
960 ->where('action', 'deleted')
961 ->where('column', 'label')
962 ->exists();
963 if ($labelResetRequired) {
964 $labelsQuery = Label::where('board_id', $boardId)->orderBy('position', 'asc');
965 if (!$includeArchived) {
966 $labelsQuery->whereNull('archived_at');
967 }
968 $labels = $labelsQuery->get();
969 } else {
970 $labels = (new LabelService())->getLastOneMinuteUpdatedLabels($boardId, $lastUpdated, $includeArchived);
971 }
972
973 $stageArchiveRestored = !$forceFullSync && (clone $stageActivityQuery)
974 ->whereIn('action', ['archived', 'restored'])
975 ->exists();
976
977 $taskResetRequired = $forceFullSync || $stageArchiveRestored || Activity::where('object_id', $boardId)
978 ->where('object_type', Constant::ACTIVITY_BOARD)
979 ->where('updated_at', '>=', $lastUpdated)
980 ->where(function($query) {
981 $query->where('action', 'deleted')
982 ->orWhere('action', 'moved')
983 ->orWhere('action', 'archived')
984 ->orWhere('action', 'restored');
985 })
986 ->where('column', 'task')
987 ->exists();
988 if ($taskResetRequired) {
989 $tasksQuery = Task::query()
990 ->where([
991 'board_id' => $boardId,
992 'parent_id' => null,
993 ])
994 ->with(['assignees', 'labels', 'watchers']);
995
996 if (!$includeArchived) {
997 $tasksQuery->whereNull('archived_at');
998 }
999
1000 if (!!defined('FLUENT_BOARDS_PRO_VERSION')) {
1001 $tasksQuery->with('customFields');
1002 }
1003
1004 $tasks = $tasksQuery->orderBy('due_at', 'ASC')->get();
1005 } else {
1006 $tasks = (new TaskService())->getLastOneMinuteUpdatedTasks($boardId, $lastUpdated, $includeArchived);
1007 }
1008
1009 foreach ($tasks as $task) {
1010 $task->isOverdue = $task->isOverdue();
1011 $task->isUpcoming = $task->upcoming();
1012 $task->is_watching = $task->isWatching();
1013 $task->contact = Helper::crm_contact($task->crm_contact_id);
1014 $task->assignees = Helper::sanitizeUserCollections($task->assignees);
1015 $task->watchers = Helper::sanitizeUserCollections($task->watchers);
1016 }
1017
1018 $board->background = \maybe_unserialize($board->background);
1019 if(!!defined('FLUENT_BOARDS_PRO_VERSION')) {
1020 $board->custom_fields = $board->customFields;
1021 }
1022
1023 $boardPayload = $boardChanged ? $board : (object) [];
1024 $hasChanges = $boardChanged
1025 || $stageResetRequired
1026 || $labelResetRequired
1027 || $taskResetRequired
1028 || count($stages)
1029 || count($labels)
1030 || count($tasks);
1031
1032 return [
1033 'board' => $boardPayload,
1034 'stages' => $stages,
1035 'labels' => $labels,
1036 'tasks' => $tasks,
1037 'taskDeleted' => $taskResetRequired,
1038 'stageDeleted' => $stageResetRequired,
1039 'labelDeleted' => $labelResetRequired,
1040 'taskResetRequired' => $taskResetRequired,
1041 'stageResetRequired' => $stageResetRequired,
1042 'labelResetRequired' => $labelResetRequired,
1043 'has_changes' => (bool) $hasChanges,
1044 'synced_at' => $syncStartedAt,
1045 'sync_reset' => $forceFullSync,
1046 ];
1047 }
1048
1049 private function normalizeSyncCursor($since, $syncStartedAt)
1050 {
1051 if (!is_string($since)) {
1052 return null;
1053 }
1054
1055 $since = trim($since);
1056 if (!preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $since)) {
1057 return null;
1058 }
1059
1060 if ($since > $syncStartedAt) {
1061 return null;
1062 }
1063
1064 if (strtotime($since) < strtotime('-24 hours', strtotime($syncStartedAt))) {
1065 return null;
1066 }
1067
1068 return $since;
1069 }
1070
1071 private function formatSyncTimestamp($timestamp)
1072 {
1073 if ($timestamp instanceof \DateTimeInterface) {
1074 return $timestamp->format('Y-m-d H:i:s');
1075 }
1076
1077 return (string) $timestamp;
1078 }
1079
1080 /**
1081 * Get CRM-associated boards that the current user can access.
1082 *
1083 * @param int $associatedId CRM contact/subscriber id.
1084 * @param int|null $userId WordPress user id used for board access checks.
1085 * @return \FluentBoards\Framework\Database\Orm\Collection|array
1086 */
1087 public function getAssociatedBoards($associatedId, $userId = null)
1088 {
1089 $associatedId = absint($associatedId);
1090 $userId = $userId ?: get_current_user_id();
1091
1092 if (!$associatedId || !$userId) {
1093 return [];
1094 }
1095
1096 $boardIds = Meta::query()->where('value', $associatedId)
1097 ->where('object_type', Constant::OBJECT_TYPE_BOARD)
1098 ->whereIn('key', [
1099 Constant::BOARD_ASSOCIATED_CRM_CONTACT,
1100 self::LEGACY_BOARD_ASSOCIATED_CRM_CONTACT,
1101 ])
1102 ->pluck('object_id');
1103
1104 $boards = Board::query()
1105 ->whereIn('id', array_values(array_unique(array_map('intval', $boardIds->toArray()))))
1106 ->whereNull('archived_at')
1107 ->byAccessUser($userId)
1108 ->withCount('completedTasks')
1109 ->with(['stages', 'users'])
1110 ->orderBy('created_at', 'DESC')
1111 ->get();
1112
1113 foreach ($boards as $board) {
1114 $board->users = Helper::sanitizeUserCollections($board->users);
1115 }
1116
1117 return $boards;
1118 }
1119
1120 private function deleteBoardMeta($boardId)
1121 {
1122 Meta::where('object_id', $boardId)
1123 ->where('object_type', Constant::OBJECT_TYPE_BOARD)
1124 ->delete();
1125 }
1126
1127 public function copyBoard($boardData)
1128 {
1129 $sourceBoard = Board::findOrFail($boardData['source_board_id']);
1130 $boardData['background'] = $sourceBoard->background;
1131 if (isset($boardData['description'])) {
1132 $boardData['description'] = DescriptionMarkdownConverter::normalize($boardData['description']);
1133 }
1134 $boardData = apply_filters('fluent_boards/before_create_board', $boardData);
1135
1136 $board = Board::create($boardData);
1137
1138 $this->setCurrentUserPreferencesOnBoardCreate($board);
1139
1140 return $board;
1141 }
1142
1143 public function archiveBoard($boardId)
1144 {
1145 $board = Board::findOrFail($boardId);
1146 $board->archived_at = current_time('mysql');
1147 $board->save();
1148
1149 do_action('fluent_boards/board_archived', $board);
1150 return $board;
1151 }
1152
1153 public function restoreBoard($boardId)
1154 {
1155 $board = Board::findOrFail($boardId);
1156 $board->archived_at = null;
1157 $board->save();
1158
1159 do_action('fluent_boards/board_restored', $board);
1160 return $board;
1161 }
1162
1163 public function makeMember($boardId, $userId)
1164 {
1165 $boardUser = Relation::where('object_id', $boardId)
1166 ->where('object_type', Constant::OBJECT_TYPE_BOARD_USER)
1167 ->where('foreign_id', $userId)->first();
1168
1169 $boardUser->settings = [
1170 'is_admin' => false,
1171 'is_viewer_only' => false
1172 ];
1173
1174 $boardUser->save();
1175 $user = User::findOrFail($userId);
1176 do_action('fluent_boards/board_member_added', $boardId, $boardUser);
1177 $user['is_admin'] = false;
1178 $user['is_board_admin'] = false;
1179 return $user;
1180 }
1181
1182 public function makeViewer($boardId, $userId)
1183 {
1184 $boardUser = Relation::where('object_id', $boardId)
1185 ->where('object_type', Constant::OBJECT_TYPE_BOARD_USER)
1186 ->where('foreign_id', $userId)->first();
1187
1188 $boardUser->settings = [
1189 'is_admin' => false,
1190 'is_viewer_only' => true
1191 ];
1192
1193 $boardUser->save();
1194 $user = User::findOrFail($userId);
1195 do_action('fluent_boards/board_viewer_added', $boardId, $boardUser);
1196 $user['is_admin'] = false;
1197 $user['is_board_admin'] = false;
1198 return $user;
1199 }
1200
1201 private function getUserWisePinnedBoards()
1202 {
1203 $userId = get_current_user_id();
1204
1205 $pinnedBoardMeta = Meta::query()->where('object_id', $userId)
1206 ->where('object_type', Constant::OBJECT_TYPE_USER)
1207 ->where('key', Constant::USER_PINNED_BOARDS)
1208 ->first();
1209
1210 return $pinnedBoardMeta;
1211 }
1212
1213 /**
1214 * Sidebar counts cover every board the user can access, so they are counted
1215 * with their own queries rather than derived from the filtered/paginated list.
1216 *
1217 * byAccessUser() re-reads the user's accessible board ids from the database on
1218 * every call, so the access scope is resolved once and cloned per count.
1219 *
1220 * @return array{all: int, pinned: int, archived: int}
1221 */
1222 public function getBoardCounts($userId)
1223 {
1224 $baseQuery = Board::byAccessUser($userId)->excludeTemplates();
1225
1226 if (!defined('FLUENT_ROADMAP')) {
1227 $baseQuery = $baseQuery->where('type', 'to-do');
1228 }
1229
1230 $counts = [
1231 'all' => (clone $baseQuery)->whereNull('archived_at')->count(),
1232 'pinned' => 0,
1233 'archived' => (clone $baseQuery)->whereNotNull('archived_at')->count()
1234 ];
1235
1236 $pinnedIds = $this->getPinnedBoardIds();
1237
1238 if ($pinnedIds) {
1239 $counts['pinned'] = (clone $baseQuery)->whereNull('archived_at')
1240 ->whereIn('id', $pinnedIds)
1241 ->count();
1242 }
1243
1244 return $counts;
1245 }
1246
1247 /**
1248 * @return array board ids the current user has pinned
1249 */
1250 public function getPinnedBoardIds()
1251 {
1252 $pinnedBoardMeta = $this->getUserWisePinnedBoards();
1253
1254 if (!$pinnedBoardMeta) {
1255 return [];
1256 }
1257
1258 return array_map('intval', (array) $pinnedBoardMeta->value);
1259 }
1260
1261 public function getPinnedBoards()
1262 {
1263 $pinnedBoardMeta = $this->getUserWisePinnedBoards();
1264
1265 if (!$pinnedBoardMeta) {
1266 return [];
1267 } else {
1268 $ids = $pinnedBoardMeta->value;
1269
1270 // Convert to array of integers
1271 $intIds = array_map('intval', $ids);
1272
1273 return Board::whereIn('id', $intIds)
1274 ->whereNull('archived_at')
1275 ->byAccessUser(get_current_user_id())
1276 ->get();
1277 }
1278 }
1279
1280 public function pinBoard($boardId)
1281 {
1282 $pinnedBoardMeta = $this->getUserWisePinnedBoards();
1283
1284 if ($pinnedBoardMeta) {
1285 $currentPinnedBoards = $pinnedBoardMeta->value;
1286 if (!in_array($boardId, $currentPinnedBoards)) {
1287 $currentPinnedBoards[] = $boardId;
1288 $pinnedBoardMeta->value = $currentPinnedBoards;
1289 $pinnedBoardMeta->save();
1290 }
1291 } else {
1292 // Create an empty array
1293 $boardIds = [];
1294 $boardIds[] = $boardId;
1295
1296 $meta = new Meta();
1297 $meta->object_id = get_current_user_id();
1298 $meta->object_type = Constant::OBJECT_TYPE_USER;
1299 $meta->key = Constant::USER_PINNED_BOARDS;
1300 $meta->value = $boardIds;
1301 $meta->save();
1302 }
1303 }
1304
1305 /**
1306 * @param $boardId
1307 * @return bool
1308 */
1309 public function unpinBoard($boardId)
1310 {
1311 $pinnedBoardMeta = $this->getUserWisePinnedBoards();
1312
1313 if (!$pinnedBoardMeta) {
1314 return false;
1315 }
1316
1317 $currentPinnedBoards = $pinnedBoardMeta->value;
1318 if (in_array($boardId, $currentPinnedBoards)) {
1319 $index = array_search($boardId, $currentPinnedBoards);
1320 array_splice($currentPinnedBoards, $index, 1);
1321 $pinnedBoardMeta->value = $currentPinnedBoards;
1322 $pinnedBoardMeta->save();
1323 return true;
1324 }
1325
1326 return false;
1327 }
1328
1329 /**
1330 * @param $boardId
1331 * @return bool
1332 * If board id is in user's current pinned boards list
1333 */
1334 public function isPinned($boardId)
1335 {
1336 $pinnedBoardMeta = $this->getUserWisePinnedBoards();
1337
1338 if (!$pinnedBoardMeta) {
1339 return false;
1340 }
1341
1342 $currentPinnedBoards = $pinnedBoardMeta->value;
1343 if (in_array($boardId, $currentPinnedBoards)) {
1344 return true;
1345 }
1346
1347 return false;
1348 }
1349
1350 public function getBoardFolder($boardId)
1351 {
1352 $relation = Relation::where('object_type', Constant::OBJECT_TYPE_FOLDER_BOARD)
1353 ->where('foreign_id', $boardId)
1354 ->first();
1355
1356 if (!$relation) {
1357 return null;
1358 }
1359
1360 return Folder::find($relation->object_id);
1361 }
1362
1363 public function deleteWebhookData($boardId)
1364 {
1365 $outgoingRelations = Relation::where('object_type', 'outgoing_webhook_board')
1366 ->where('foreign_id', $boardId)
1367 ->get();
1368
1369 foreach ($outgoingRelations as $relation) {
1370 $webhookMetaId = (int) $relation->object_id;
1371
1372 $linkedCount = Relation::where('object_type', 'outgoing_webhook_board')
1373 ->where('object_id', $webhookMetaId)
1374 ->count();
1375
1376 if ($linkedCount === 1) {
1377 Meta::where('id', $webhookMetaId)
1378 ->where('object_type', 'outgoing_webhook')
1379 ->delete();
1380 } else if ($linkedCount > 1) {
1381 $meta = Meta::find($webhookMetaId);
1382 if ($meta && $meta->object_type === 'outgoing_webhook') {
1383 $value = $meta->value;
1384
1385 if (isset($value['board_id'])) {
1386 $boards = $value['board_id'];
1387
1388 if (is_array($boards)) {
1389 $boards = array_values(array_filter($boards, function ($id) use ($boardId) {
1390 return intval($id) !== intval($boardId);
1391 }));
1392 $value['board_id'] = $boards;
1393 } else {
1394 if ($boards !== null && intval($boards) === intval($boardId)) {
1395 $value['board_id'] = [];
1396 }
1397 }
1398
1399 $meta->value = $value;
1400 $meta->save();
1401 }
1402 }
1403 }
1404 $relation->delete();
1405 }
1406 }
1407 }
1408