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

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