PluginProbe
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration / 2.2.0
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration v2.2.0
2.2.0 2.1.0 2.0.15 2.0.12 2.0.10 2.0.4 2.0.1 2.0.0 1.95.3 1.95.2 1.95 1.91.6 trunk 1.11 1.12 1.13 1.20 1.21 1.22 1.23 1.30 1.31 1.32 1.35 1.40 All 43 releases
← All changes | app/Http/Controllers/BoardController.php +319 -115 1.95.3 → 2.2.0 View file →
@@ -9,8 +9,9 @@
9 9 use FluentBoards\App\Models\User;
10 10 use FluentBoards\App\Models\Board;
11 11 use FluentBoards\App\Services\CommentService;
12 12 use FluentBoards\App\Services\Constant;
13 +use FluentBoards\App\Services\DescriptionMarkdownConverter;
13 14 use FluentBoards\App\Services\Helper;
14 15 use FluentBoards\App\Models\Stage;
15 16 use FluentBoards\App\Services\InstallService;
16 17 use FluentBoards\App\Services\StageService;
@@ -15,8 +16,9 @@
15 16 use FluentBoards\App\Services\InstallService;
16 17 use FluentBoards\App\Services\StageService;
17 18 use FluentBoards\App\Services\TaskService;
18 19 use FluentBoards\App\Services\BoardService;
20 +use FluentBoards\App\Services\FolderService;
19 21 use FluentBoards\App\Services\UploadService;
20 22 use FluentBoards\Framework\Http\Request\Request;
21 23 use FluentBoards\App\Services\PermissionManager;
22 24 use FluentBoards\App\Services\PublicAccessService;
@@ -26,14 +28,14 @@
26 28 use FluentBoards\Framework\Support\Arr;
27 29 use FluentBoards\Framework\Support\Collection;
28 30 use FluentBoardsPro\App\Services\AttachmentService;
29 31 use FluentBoardsPro\App\Services\CustomFieldService;
30 -use FluentBoardsPro\App\Services\ProHelper;
31 32 use FluentBoardsPro\App\Services\RemoteUrlParser;
32 -use FluentCrm\App\Models\Subscriber;
33 33
34 34 class BoardController extends Controller
35 35 {
36 + private const LEGACY_BOARD_ASSOCIATED_CRM_CONTACT = 'crm_contact';
37 +
36 38 private $boardService;
37 39 private $taskService;
38 40 private $stageService;
39 41 private $labelService;
@@ -54,13 +56,23 @@
54 56
55 57 public function getBoards(Request $request)
56 58 {
57 59 $per_page = $request->getSafe('per_page', 'intval', 100);
60 + $per_page = max(1, min(100, $per_page));
58 61 $userId = get_current_user_id();
59 62 $type = $request->getSafe('type', 'sanitize_text_field', 'to-do');
60 63
61 64 $order = $request->getSafe('order', 'sanitize_text_field', 'created_at');
62 65 $orderBy = $request->getSafe('orderBy', 'sanitize_text_field', 'DESC');
66 + $order = is_string($order) ? sanitize_sql_orderby($order) : false;
67 + if (!in_array($order, ['created_at', 'title', 'updated_at', 'id'], true)) {
68 + $order = 'created_at';
69 + }
70 + $orderBy = is_string($orderBy) ? strtoupper($orderBy) : 'DESC';
71 + if (!in_array($orderBy, ['ASC', 'DESC'], true)) {
72 + $orderBy = 'DESC';
73 + }
74 +
63 75 $searchInput = $request->getSafe('searchInput', 'sanitize_text_field');
64 76
65 77 $option = $request->getSafe('option', 'sanitize_text_field');
66 78 $folderId = $request->getSafe('fid', 'intval'); // Get folder ID from request
@@ -79,22 +91,28 @@
79 91 $relatedBoardsQuery = Board::whereNull('archived_at')->byAccessUser($userId);
80 92 }
81 93 }
82 94
83 - // If folder ID is provided, filter boards by folder
84 - if ($folderId && defined('FLUENT_BOARDS_PRO')) {
85 - $boardIds = ProHelper::getBoardIdsByFolder($folderId);
86 - $relatedBoardsQuery = $relatedBoardsQuery->whereIn('id', $boardIds);
95 + // Scope pinned before pagination, from the same id source as getBoardCounts(),
96 + // so the pinned page and board_counts.pinned always agree. Filtering pinned
97 + // after pagination would drop pinned boards that fall on a later active page.
98 + $pinnedIds = $this->boardService->getPinnedBoardIds();
99 + if ($option == 'pinned') {
100 +
101 + $relatedBoardsQuery = $pinnedIds
102 + ? $relatedBoardsQuery->whereIn('id', $pinnedIds)
103 + : $relatedBoardsQuery->where('id', 0);
87 104 }
88 105
106 + if ($folderId) {
107 + $boardIds = (new FolderService())->getBoardIdsByFolder($folderId);
108 + $relatedBoardsQuery = $boardIds
109 + ? $relatedBoardsQuery->whereIn('id', $boardIds)
110 + : $relatedBoardsQuery->where('id', 0);
111 + }
112 +
89 113 // Filter out boards that are templates (exclude boards where settings->is_template is true)
90 - $relatedBoardsQuery = $relatedBoardsQuery->where(function ($query) {
91 - $query->whereNull('settings')
92 - ->orWhere(function ($subQuery) {
93 - $subQuery->where('settings', 'NOT LIKE', '%"is_template";b:1%')
94 - ->where('settings', 'NOT LIKE', '%"is_template":true%');
95 - });
96 - });
114 + $relatedBoardsQuery = $relatedBoardsQuery->excludeTemplates();
97 115
98 116 // Add search functionality
99 117 if (!empty($searchInput)) {
100 118 $relatedBoardsQuery = $relatedBoardsQuery->where('title', 'like', '%' . $searchInput . '%');
@@ -101,29 +119,28 @@
101 119 }
102 120
103 121 $relatedBoards = $relatedBoardsQuery->orderBy($order, $orderBy)
104 122 ->withCount('completedTasks')
105 - ->with('stages', 'users')
123 + ->with('stages', 'users', 'boardMeta', 'viewerMembership')
106 124 ->paginate($per_page);
107 125
108 126 foreach ($relatedBoards as $relatedBoard) {
127 + $relatedBoard->description = DescriptionMarkdownConverter::normalize($relatedBoard->description);
109 128 $relatedBoard->users = Helper::sanitizeUserCollections($relatedBoard->users);
110 - $relatedBoard->is_pinned = $this->boardService->isPinned($relatedBoard->id);
129 + $relatedBoard->is_pinned = in_array((int) $relatedBoard->id, $pinnedIds, true);
111 130 }
112 131
113 132 $response = [
114 - 'boards' => $relatedBoards
133 + 'boards' => $relatedBoards,
134 + 'board_counts' => $this->boardService->getBoardCounts($userId)
115 135 ];
116 136
117 - // Include folder mapping if pro version is available - ALWAYS include for consistency
118 - if (defined('FLUENT_BOARDS_PRO')) {
119 - $response['folder_mapping'] = $this->getBoardFolderMapping($userId);
120 - if ($folderId) {
121 - $response['current_folder'] = $this->getCurrentFolderInfo($folderId);
122 - }
123 - } else {
124 - // Include empty folder mapping for consistency
125 - $response['folder_mapping'] = [];
137 + $folderMapping = $this->getBoardFolderMapping($userId);
138 + $response['folder_mapping'] = $folderMapping;
139 + if ($folderId) {
140 + $response['current_folder'] = isset($folderMapping[$folderId])
141 + ? $this->getCurrentFolderInfoFromMapping($folderMapping[$folderId])
142 + : null;
126 143 }
127 144
128 145 return $this->sendSuccess($response);
129 146 }
@@ -132,13 +149,9 @@
132 149 * Get folder mapping for boards
133 150 */
134 151 private function getBoardFolderMapping($userId)
135 152 {
136 - if (!defined('FLUENT_BOARDS_PRO')) {
137 - return [];
138 - }
139 -
140 - $folderService = new \FluentBoardsPro\App\Services\FolderService();
153 + $folderService = new FolderService();
141 154 $folders = $folderService->getFolders($userId);
142 155
143 156 $mapping = [];
144 157 foreach ($folders as $folder) {
@@ -151,28 +164,14 @@
151 164
152 165 return $mapping;
153 166 }
154 167
155 - /**
156 - * Get current folder information
157 - */
158 - private function getCurrentFolderInfo($folderId)
168 + private function getCurrentFolderInfoFromMapping(array $folder)
159 169 {
160 - if (!defined('FLUENT_BOARDS_PRO')) {
161 - return null;
162 - }
163 -
164 - $folderService = new \FluentBoardsPro\App\Services\FolderService();
165 - $folder = $folderService->getFolderById($folderId);
166 -
167 - if (!$folder) {
168 - return null;
169 - }
170 -
171 170 return [
172 - 'id' => $folder->id,
173 - 'title' => $folder->title,
174 - 'board_count' => $folder->boards ? $folder->boards->count() : 0
171 + 'id' => $folder['id'],
172 + 'title' => $folder['title'],
173 + 'board_count' => count($folder['board_ids'])
175 174 ];
176 175 }
177 176
178 177 /**
@@ -187,11 +186,11 @@
187 186
188 187 // Query to fetch boards that are not archived and accessible by the user
189 188 // Check if the FLUENT_ROADMAP constant is defined
190 189 if (!defined('FLUENT_ROADMAP')) {
191 - $relatedBoardsQuery = Board::whereNull('archived_at')->where('type', 'to-do')->byAccessUser($userId);
190 + $relatedBoardsQuery = Board::whereNull('archived_at')->where('type', 'to-do')->excludeTemplates()->byAccessUser($userId);
192 191 } else {
193 - $relatedBoardsQuery = Board::whereNull('archived_at')->byAccessUser($userId);
192 + $relatedBoardsQuery = Board::whereNull('archived_at')->excludeTemplates()->byAccessUser($userId);
194 193 }
195 194
196 195 $relatedBoards = $relatedBoardsQuery->with('stages')->get();
197 196
@@ -212,11 +211,11 @@
212 211
213 212
214 213 if(!defined('FLUENT_ROADMAP'))
215 214 {
216 - $relatedBoardsQuery = Board::whereNull('archived_at')->where('type', 'to-do')->byAccessUser($userId);
215 + $relatedBoardsQuery = Board::whereNull('archived_at')->where('type', 'to-do')->excludeTemplates()->byAccessUser($userId);
217 216 } else {
218 - $relatedBoardsQuery = Board::whereNull('archived_at')->byAccessUser($userId);
217 + $relatedBoardsQuery = Board::whereNull('archived_at')->excludeTemplates()->byAccessUser($userId);
219 218 }
220 219
221 220 if (!empty($searchInput)) {
222 221 $relatedBoardsQuery = $relatedBoardsQuery->where('title', 'like', '%' . $searchInput . '%');
@@ -238,9 +237,12 @@
238 237 {
239 238 $boards = $this->boardService->getRecentBoards();
240 239
241 240 if (!$boards || $boards->isEmpty()) {
242 - $boards = Board::where('type', 'to-do')->byAccessUser(get_current_user_id())
241 + $boards = Board::whereNull('archived_at')
242 + ->excludeTemplates()
243 + ->availableInCurrentInstall()
244 + ->byAccessUser(get_current_user_id())
243 245 ->limit(4)
244 246 ->withCount('completedTasks')
245 247 ->with(['stages', 'users'])
246 248 ->get();
@@ -346,25 +348,42 @@
346 348 'folder_id' => 'nullable',
347 349 ]);
348 350
349 351 try {
352 + $folderId = $request->getSafe('folder_id', 'intval');
353 + $folderService = new FolderService();
354 + if ($folderId) {
355 + $folderService->assertCanModifyFolder($folderId);
356 + }
357 +
358 + $backgroundData = $this->sanitizeCreateBoardBackground($request->get('background'));
359 + if (!empty($backgroundData)) {
360 + $boardData['background'] = $backgroundData;
361 + }
362 +
363 + $this->validateRequestedLabelPresets($request->get('labels'));
364 +
350 365 $board = $this->boardService->createBoard($boardData);
351 - $this->labelService->createDefaultLabel($board->id);
366 + $this->createBoardLabelsFromRequest($request, $board->id);
367 + $this->addBoardMembersFromRequest($request, $board->id);
352 368 $type = ucfirst($boardData['type']);
369 + $stages = $request->get('stages');
370 + $sanitizedStages = [];
353 371
372 + if (is_array($stages) && !empty($stages)) {
373 + foreach ($stages as $stage) {
374 + $sanitizedStages[] = $this->stageSanitizeAndValidate($stage, [
375 + 'title' => 'required|string',
376 + 'slug' => 'nullable|string',
377 + 'position' => 'nullable|numeric'
378 + ]);
379 + }
380 + }
381 +
354 382 if (isset($boardData['type']) && $boardData['type'] == 'roadmap') {
355 - $stages = $request->get('stages');
356 - $sanitizedStages = [];
357 - if (is_array($stages) && !empty($stages)) {
358 - foreach ($stages as $stage) {
359 - $sanitizedStages[] = $this->stageSanitizeAndValidate($stage, [
360 - 'title' => 'required|string',
361 - 'slug' => 'nullable|string',
362 - 'position' => 'nullable|numeric'
363 - ]);
364 - }
365 - }
366 383 $this->stageService->createRoadmapStages($board, $sanitizedStages);
384 + } elseif (!empty($sanitizedStages)) {
385 + $this->stageService->createStages($board, $sanitizedStages);
367 386 } else {
368 387 $this->stageService->createDefaultStages($board);
369 388 }
370 389
@@ -375,13 +394,10 @@
375 394
376 395 do_action('fluent_boards/board_created', $board);
377 396
378 397
379 - if(defined('FLUENT_BOARDS_PRO')) {
380 - $folderId = $request->getSafe('folder_id', 'intval');
381 - if ($folderId) {
382 - (new \FluentBoardsPro\App\Services\FolderService())->addBoardToFolder($folderId, [$board->id]);
383 - }
398 + if ($folderId) {
399 + $folderService->addBoardToFolder($folderId, [$board->id]);
384 400 }
385 401
386 402 $message = __('Board has been created successfully', 'fluent-boards');
387 403
@@ -395,26 +411,132 @@
395 411 ]);
396 412 }
397 413 }
398 414
415 + private function sanitizeCreateBoardBackground($background)
416 + {
417 + if (!is_array($background) || empty($background['id'])) {
418 + return '';
419 + }
420 +
421 + $backgroundId = sanitize_text_field($background['id']);
422 +
423 + // Only accept ids from the curated solid/gradient palettes and always
424 + // persist the canonical value from the constant (never the client-supplied
425 + // color) so arbitrary CSS can't be stored and later rendered into a style.
426 + $allowedBackgrounds = [];
427 + foreach (array_merge(
428 + Constant::BOARD_BACKGROUND_DEFAULT_SOLID_COLORS,
429 + Constant::BOARD_BACKGROUND_DEFAULT_GRADIENT_COLORS
430 + ) as $option) {
431 + if (isset($option['id'], $option['value'])) {
432 + $allowedBackgrounds[$option['id']] = $option['value'];
433 + }
434 + }
435 +
436 + if (!isset($allowedBackgrounds[$backgroundId])) {
437 + return '';
438 + }
439 +
440 + return [
441 + 'id' => $backgroundId,
442 + 'color' => $allowedBackgrounds[$backgroundId],
443 + 'is_image' => false,
444 + 'image_url' => null,
445 + ];
446 + }
447 +
448 + private function createBoardLabelsFromRequest(Request $request, $boardId)
449 + {
450 + $labels = $request->get('labels');
451 +
452 + if (!is_array($labels)) {
453 + $this->labelService->createDefaultLabel($boardId);
454 + return;
455 + }
456 +
457 + foreach ($labels as $label) {
458 + $labelData = Helper::sanitizeLabel((array) $label);
459 +
460 + if (empty($labelData['label']) && empty($labelData['bg_color']) && empty($labelData['color_preset'])) {
461 + continue;
462 + }
463 +
464 + $labelPayload = [
465 + 'label' => $labelData['label'] ?? '',
466 + 'bg_color' => $labelData['bg_color'] ?? '#f3f4f6',
467 + 'color' => $labelData['color'] ?? '#1B2533',
468 + ];
469 +
470 + if (array_key_exists('color_preset', $labelData)) {
471 + $labelPayload['color_preset'] = $labelData['color_preset'];
472 + }
473 +
474 + $this->labelService->createLabel($labelPayload, $boardId);
475 + }
476 + }
477 +
478 + /**
479 + * Reject unsupported label preset ids before creating any board records.
480 + *
481 + * @param mixed $labels
482 + * @return void
483 + * @throws \Exception
484 + */
485 + private function validateRequestedLabelPresets($labels)
486 + {
487 + if (!is_array($labels)) {
488 + return;
489 + }
490 +
491 + foreach ($labels as $label) {
492 + $labelData = Helper::sanitizeLabel((array) $label);
493 + $presetId = $labelData[Constant::LABEL_COLOR_PRESET_SETTING] ?? null;
494 +
495 + if ($presetId === null || $presetId === '') {
496 + continue;
497 + }
498 +
499 + if (!is_string($presetId) || !Constant::getLabelColorPreset($presetId)) {
500 + throw new \Exception(esc_html__('Invalid label color preset', 'fluent-boards'));
501 + }
502 + }
503 + }
504 +
505 + private function addBoardMembersFromRequest(Request $request, $boardId)
506 + {
507 + $memberIds = $request->get('member_ids');
508 +
509 + if (!is_array($memberIds)) {
510 + return;
511 + }
512 +
513 + $memberIds = array_filter(array_unique(array_map('intval', $memberIds)));
514 + $currentUserId = get_current_user_id();
515 +
516 + foreach ($memberIds as $memberId) {
517 + if ($memberId === $currentUserId) {
518 + continue;
519 + }
520 +
521 + $this->boardService->addMembersInBoard($boardId, $memberId);
522 + }
523 + }
524 +
525 + /**
526 + * Get archived stages for a board with optional pagination and archive actor metadata.
527 + */
399 528 public function getArchivedStage(Request $request, $board_id)
400 529 {
401 530 try {
402 - $pagination = $request->getSafe('noPagination', 'boolval', false);
403 - $per_page = $request->getSafe('per_page', 'intval', 30);
404 - $page = $request->getSafe('page', 'intval', 1);
531 + $board_id = absint($board_id);
532 + $sanitizedParams = [
533 + 'noPagination' => $request->getSafe('noPagination', 'boolval', false),
534 + 'per_page' => $request->getSafe('per_page', 'intval', 30),
535 + 'page' => $request->getSafe('page', 'intval', 1),
536 + ];
405 537
406 - if ($pagination) {
407 - $stages = Stage::where('board_id', $board_id)
408 - ->whereNotNull('archived_at')
409 - ->orderBy('created_at', 'DESC')
410 - ->get();
411 - } else {
412 - $stages = Stage::where('board_id', $board_id)
413 - ->whereNotNull('archived_at')
414 - ->orderBy('created_at', 'DESC')
415 - ->paginate($per_page, ['*'], 'page', $page);
416 - }
538 + $stages = $this->stageService->getArchivedStages($sanitizedParams, $board_id);
417 539
418 540 return $this->sendSuccess([
419 541 'stages' => $stages,
420 542 ], 200);
@@ -422,11 +544,14 @@
422 544 return $this->sendError($e->getMessage(), 404);
423 545 }
424 546 }
425 547
548 + /** Return board details with a cursor covering changes made during retrieval. */
426 549 public function find(Request $request, $board_id)
427 550 {
551 + $syncStartedAt = current_time('mysql');
428 552 $board = Board::findOrFail($board_id);
553 + $board->description = DescriptionMarkdownConverter::normalize($board->description);
429 554 $includeArchived = filter_var($request->get('include_archived', false), FILTER_VALIDATE_BOOLEAN);
430 555 $board->background = maybe_unserialize($board->background);
431 556 $board->createdOn = $board->created_at->format('Y-m-d');
432 557
@@ -453,8 +578,9 @@
453 578 $this->boardService->updateRecentBoards($board_id);
454 579
455 580 $board->labelColor = Constant::TRELLO_COLOR_MAP;
456 581 $board->labelColorText = Constant::TEXT_COLOR_MAP;
582 + $board->labelColorPresets = Constant::LABEL_COLOR_PRESETS;
457 583
458 584 $board->users = Helper::sanitizeUserCollections($board->users);
459 585 $board->owner = Helper::sanitizeUserCollections($board->owner);
460 586
@@ -463,14 +589,24 @@
463 589 $board = apply_filters('fluent_boards/board_find', $board);
464 590
465 591 return [
466 592 'board' => $board,
467 - 'synced_at' => current_time('mysql')
593 + 'synced_at' => $syncStartedAt
468 594 ];
469 595 }
470 596
471 597 public function update(Request $request, $board_id)
472 598 {
599 + // Board identity (title/description) is manager-only. This action shares the
600 + // `update` name with CommentController@update under the same policy group, so the
601 + // guard lives here rather than in a SingleBoardPolicy::update() method that would
602 + // also block ordinary members from editing their own comments.
603 + if (!PermissionManager::isBoardManager(absint($board_id))) {
604 + return $this->sendError([
605 + 'message' => __('You do not have permission to edit this board.', 'fluent-boards'),
606 + ], 403);
607 + }
608 +
473 609 $boardData = $this->boardSanitizeAndValidate($request->only(['title', 'description']), [
474 610 'title' => 'required|string',
475 611 'description' => 'nullable|string',
476 612 ]);
@@ -475,8 +611,9 @@
475 611 'description' => 'nullable|string',
476 612 ]);
477 613
478 614 $board = Board::findOrFail($board_id);
615 + $boardData['description'] = DescriptionMarkdownConverter::normalize($boardData['description']);
479 616
480 617 $oldBoard = clone $board;
481 618 $board->fill($boardData);
482 619 $board->save();
@@ -632,9 +769,8 @@
632 769
633 770 $formattedUsers[] = [
634 771 'ID' => $user->ID,
635 772 'display_name' => $name,
636 - 'user_login' => $user->user_login,
637 773 'email' => $user->user_email,
638 774 'photo' => fluent_boards_user_avatar($user->user_email, $name),
639 775 'role' => $this->boardUserRole($boardRelation),
640 776 'is_super' => in_array($user->ID, $superAdminIds),
@@ -656,16 +792,24 @@
656 792 return $returnData;
657 793 }
658 794
659 795 /*
660 - * These are the rest of the admin users who are not in the board
796 + * These are the rest of the Fluent Boards and WordPress admins who are not in the board.
661 797 */
662 - $adminUserIds = Meta::query()->where('object_type', Constant::FLUENT_BOARD_ADMIN)
798 + $fluentBoardAdminIds = Meta::query()->where('object_type', Constant::FLUENT_BOARD_ADMIN)
663 799 ->whereNotIn('object_id', $userIds)
664 800 ->get()
665 801 ->pluck('object_id')
666 802 ->toArray();
667 803
804 + $wordPressAdminIds = get_users([
805 + 'capability' => 'manage_options',
806 + 'exclude' => $userIds,
807 + 'fields' => 'ID',
808 + ]);
809 +
810 + $adminUserIds = array_values(array_unique(array_map('intval', array_merge($fluentBoardAdminIds, $wordPressAdminIds))));
811 +
668 812 if ($adminUserIds) {
669 813 $adminUsers = get_users([
670 814 'include' => $adminUserIds,
671 815 ]);
@@ -715,20 +859,28 @@
715 859 }
716 860
717 861 public function addMembersInBoard(Request $request, $board_id)
718 862 {
719 - $memberId = $request->getSafe('memberId');
720 - $isViewerOnly = $request->getSafe('isViewerOnly');
863 + $memberId = $request->getSafe('memberId', 'intval');
864 + $isViewerOnly = $request->getSafe('isViewerOnly', 'sanitize_text_field');
721 865 $member = $this->boardService->addMembersInBoard($board_id, $memberId, $isViewerOnly);
866 +
867 + if ($member === null) {
868 + return $this->sendError([
869 + 'message' => __('User not found.', 'fluent-boards'),
870 + ], 404);
871 + }
872 +
722 873 if (!$member) {
723 874 return $this->sendError([
724 875 'message' => __('User already a member', 'fluent-boards'),
725 - ], 304);
876 + ], 409);
726 877 }
727 878
728 879
729 880 return [
730 881 'message' => __('Member added successfully', 'fluent-boards'),
882 + 'is_admin' => PermissionManager::isAdmin($memberId),
731 883 'member' => Helper::sanitizeUserCollections($member)
732 884 ];
733 885 }
734 886
@@ -811,9 +963,9 @@
811 963
812 964 if (isset($settings['is_public'])) {
813 965 if ($settings['is_public']) {
814 966 $settings['is_public'] = false;
815 - $message = __('The stage is made admin only!', 'fluent-boards');
967 + $message = __('The stage is made private!', 'fluent-boards');
816 968 } else {
817 969 $settings['is_public'] = true;
818 970 }
819 971 } else {
@@ -832,24 +984,27 @@
832 984 }
833 985
834 986
835 987 /**
836 - * Set board background image or color
988 + * Set or reset board background image/color.
837 989 * @param \FluentBoards\Framework\Http\Request\Request $request
838 990 * @return
839 991 */
840 992 public function setBoardBackground(Request $request, $board_id)
841 993 {
842 - // sanitize and validate image_url
843 - if ($request->image_url) {
994 + $backgroundData = [];
995 + $isResetRequest = $request->getSafe('reset', 'rest_sanitize_boolean');
996 +
997 + if ($isResetRequest) {
998 + $backgroundData = [
999 + 'reset' => true,
1000 + ];
1001 + } elseif ($request->image_url) {
844 1002 $backgroundData = $this->boardSanitizeAndValidate($request->all(), [
845 - "id" => 'required',
1003 + 'id' => 'required|integer',
846 1004 'image_url' => 'required|string|url',
847 1005 ]);
848 - }
849 -
850 - // sanitize and validate color
851 - if ($request->color) {
1006 + } elseif ($request->color) {
852 1007 $backgroundData = $this->boardSanitizeAndValidate($request->all(), [
853 1008 "id" => 'required',
854 1009 'color' => 'required',
855 1010 ]);
@@ -860,8 +1015,13 @@
860 1015 $errorMessage = __('Board id is required', 'fluent-boards');
861 1016 throw new \Exception(esc_html($errorMessage), 400);
862 1017 }
863 1018
1019 + if (empty($backgroundData)) {
1020 + $errorMessage = __('Background data is required', 'fluent-boards');
1021 + throw new \Exception(esc_html($errorMessage), 400);
1022 + }
1023 +
864 1024 return $this->sendSuccess([
865 1025 'message' => __('Background updated successfully', 'fluent-boards'),
866 1026 'background' => $this->boardService->setBoardBackground($backgroundData, $board_id),
867 1027 ]);
@@ -911,24 +1071,47 @@
911 1071 $contactAssociatedTasks = Task::with('board')->where('board_id', $board_id)
912 1072 ->whereNotNull('crm_contact_id')
913 1073 ->get();
914 1074
915 - $formattedContacts = Collection::make($contactAssociatedTasks)
916 - ->groupBy('crm_contact_id')
917 - ->map(function ($tasks, $contactId) {
918 - $subscriber = Subscriber::find($contactId);
919 - if (!$subscriber) {
1075 + $tasksByContact = [];
1076 + foreach ($contactAssociatedTasks as $task) {
1077 + $tasksByContact[absint($task->crm_contact_id)][] = $task;
1078 + }
1079 +
1080 + $boardContactIds = Meta::query()
1081 + ->where('object_id', absint($board_id))
1082 + ->where('object_type', Constant::OBJECT_TYPE_BOARD)
1083 + ->whereIn('key', [
1084 + Constant::BOARD_ASSOCIATED_CRM_CONTACT,
1085 + self::LEGACY_BOARD_ASSOCIATED_CRM_CONTACT,
1086 + ])
1087 + ->pluck('value')
1088 + ->toArray();
1089 + $boardContactIds = array_values(array_unique(array_filter(array_map('absint', $boardContactIds))));
1090 +
1091 + $contactIds = array_values(array_unique(array_filter(array_map('absint', array_merge(
1092 + array_keys($tasksByContact),
1093 + $boardContactIds
1094 + )))));
1095 +
1096 + usort($contactIds, function ($firstContactId, $secondContactId) use ($boardContactIds) {
1097 + return (int) in_array($secondContactId, $boardContactIds, true) - (int) in_array($firstContactId, $boardContactIds, true);
1098 + });
1099 +
1100 + $formattedContacts = Collection::make($contactIds)
1101 + ->map(function ($contactId) use ($tasksByContact, $boardContactIds) {
1102 + $contact = Helper::crm_contact($contactId);
1103 + if (!$contact) {
920 1104 return null; // Skip if subscriber not found
921 1105 }
922 1106
923 - return [
924 - 'name' => $subscriber->first_name . ' ' . $subscriber->last_name,
925 - 'photo' => $subscriber->photo,
926 - 'email' => $subscriber->email,
927 - 'crm_contact_id' => $contactId,
928 - 'id' => $contactId,
929 - 'tasks' => $tasks,
930 - ];
1107 + $tasks = $tasksByContact[$contactId] ?? [];
1108 + $contact['name'] = trim(($contact['first_name'] ?? '') . ' ' . ($contact['last_name'] ?? '')) ?: ($contact['full_name'] ?? $contact['email'] ?? '');
1109 + $contact['crm_contact_id'] = $contactId;
1110 + $contact['is_board_contact'] = in_array($contactId, $boardContactIds, true);
1111 + $contact['tasks'] = $tasks;
1112 +
1113 + return $contact;
931 1114 })
932 1115 ->filter()->toArray();
933 1116
934 1117
@@ -1026,9 +1209,16 @@
1026 1209 if (!$this->currentUserCanReadCrmContacts()) {
1027 1210 return $this->sendError(esc_html__('You do not have permission to view CRM contact boards', 'fluent-boards'), 403);
1028 1211 }
1029 1212
1030 - $associatedBoards = $this->boardService->getAssociatedBoards($associated_id, get_current_user_id());
1213 + $associatedId = absint($associated_id);
1214 +
1215 + if (!$associatedId) {
1216 + return $this->sendError(__('Invalid CRM contact', 'fluent-boards'), 400);
1217 + }
1218 +
1219 + $associatedBoards = $this->boardService->getAssociatedBoards($associatedId, get_current_user_id());
1220 +
1031 1221 return [
1032 1222 'boards' => $associatedBoards,
1033 1223 ];
1034 1224 }
@@ -1043,8 +1233,11 @@
1043 1233
1044 1234 return (bool) $permissionManager::currentUserCan('fcrm_read_contacts');
1045 1235 }
1046 1236
1237 + /**
1238 + * Duplicate a board with the selected board data options.
1239 + */
1047 1240 public function duplicateBoard(Request $request, $board_id)
1048 1241 {
1049 1242 $boardData = $this->taskSanitizeAndValidate($request->get('board'), [
1050 1243 'title' => 'required|string'
@@ -1053,8 +1246,10 @@
1053 1246 $boardData['source_board_id'] = $board_id;
1054 1247
1055 1248 $isWithLabels = $request->getSafe('isWithLabels');
1056 1249 $isWithTasks = $request->getSafe('isWithTasks');
1250 + $isWithMembers = $request->getSafe('isWithMembers');
1251 + $isWithCustomFields = $request->getSafe('isWithCustomFields', 'sanitize_text_field', 'yes');
1057 1252 $isWithTemplates = $request->getSafe('isWithTemplates');
1058 1253
1059 1254 try {
1060 1255 if(!PermissionManager::isAdmin()) {
@@ -1062,9 +1257,14 @@
1062 1257 throw new \Exception(esc_html($errorMessage), 400);
1063 1258 }
1064 1259 //create board
1065 1260 $newBoard = $this->boardService->copyBoard($boardData);
1261 + $customFieldMap = [];
1066 1262
1263 + if ($isWithCustomFields == 'yes') {
1264 + $customFieldMap = $this->boardService->copyCustomFieldsOfBoard($board_id, $newBoard);
1265 + }
1266 +
1067 1267 //label copy
1068 1268 $labelMap = [];
1069 1269
1070 1270 if ($isWithLabels == 'yes') {
@@ -1075,9 +1275,13 @@
1075 1275 $stageMapForCopyingTask = $this->stageService->copyStagesOfBoard($newBoard, $board_id, $isWithTemplates);
1076 1276
1077 1277 //copy tasks of selected stages
1078 1278 if ($isWithTasks == 'yes') {
1079 - $this->taskService->copyTasks($board_id, $stageMapForCopyingTask, $newBoard, $labelMap,$isWithTemplates);
1279 + $this->taskService->copyTasks($board_id, $stageMapForCopyingTask, $newBoard, $labelMap, $isWithTemplates, $customFieldMap);
1280 + }
1281 +
1282 + if ($isWithMembers == 'yes') {
1283 + $this->boardService->copyMembersOfBoard($board_id, $newBoard);
1080 1284 }
1081 1285
1082 1286 return $this->sendSuccess([
1083 1287 'board' => $newBoard,