PluginProbe
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration / 2.1.0
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration v2.1.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 1.41 All 42 releases
← All changes | app/Http/Controllers/BoardController.php +421 -124 1.91.62.1.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,11 +16,13 @@
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;
24 +use FluentBoards\App\Services\PublicAccessService;
22 25 use FluentBoards\App\Hooks\Handlers\BoardHandler;
23 26 use FluentBoards\App\Hooks\Handlers\BoardMenuHandler;
24 27 use FluentBoards\App\Services\LabelService;
25 28 use FluentBoards\Framework\Support\Arr;
@@ -25,14 +28,14 @@
25 28 use FluentBoards\Framework\Support\Arr;
26 29 use FluentBoards\Framework\Support\Collection;
27 30 use FluentBoardsPro\App\Services\AttachmentService;
28 31 use FluentBoardsPro\App\Services\CustomFieldService;
29 -use FluentBoardsPro\App\Services\ProHelper;
30 32 use FluentBoardsPro\App\Services\RemoteUrlParser;
31 -use FluentCrm\App\Models\Subscriber;
32 33
33 34 class BoardController extends Controller
34 35 {
36 + private const LEGACY_BOARD_ASSOCIATED_CRM_CONTACT = 'crm_contact';
37 +
35 38 private $boardService;
36 39 private $taskService;
37 40 private $stageService;
38 41 private $labelService;
@@ -53,8 +56,9 @@
53 56
54 57 public function getBoards(Request $request)
55 58 {
56 59 $per_page = $request->getSafe('per_page', 'intval', 100);
60 + $per_page = max(1, min(100, $per_page));
57 61 $userId = get_current_user_id();
58 62 $type = $request->getSafe('type', 'sanitize_text_field', 'to-do');
59 63
60 64 $order = $request->getSafe('order', 'sanitize_text_field', 'created_at');
@@ -78,14 +82,29 @@
78 82 $relatedBoardsQuery = Board::whereNull('archived_at')->byAccessUser($userId);
79 83 }
80 84 }
81 85
82 - // If folder ID is provided, filter boards by folder
83 - if ($folderId && defined('FLUENT_BOARDS_PRO')) {
84 - $boardIds = ProHelper::getBoardIdsByFolder($folderId);
85 - $relatedBoardsQuery = $relatedBoardsQuery->whereIn('id', $boardIds);
86 + // Scope pinned before pagination, from the same id source as getBoardCounts(),
87 + // so the pinned page and board_counts.pinned always agree. Filtering pinned
88 + // after pagination would drop pinned boards that fall on a later active page.
89 + if ($option == 'pinned') {
90 + $pinnedIds = $this->boardService->getPinnedBoardIds();
91 +
92 + $relatedBoardsQuery = $pinnedIds
93 + ? $relatedBoardsQuery->whereIn('id', $pinnedIds)
94 + : $relatedBoardsQuery->where('id', 0);
86 95 }
87 96
97 + if ($folderId) {
98 + $boardIds = (new FolderService())->getBoardIdsByFolder($folderId);
99 + $relatedBoardsQuery = $boardIds
100 + ? $relatedBoardsQuery->whereIn('id', $boardIds)
101 + : $relatedBoardsQuery->where('id', 0);
102 + }
103 +
104 + // Filter out boards that are templates (exclude boards where settings->is_template is true)
105 + $relatedBoardsQuery = $relatedBoardsQuery->excludeTemplates();
106 +
88 107 // Add search functionality
89 108 if (!empty($searchInput)) {
90 109 $relatedBoardsQuery = $relatedBoardsQuery->where('title', 'like', '%' . $searchInput . '%');
91 110 }
@@ -95,25 +114,24 @@
95 114 ->with('stages', 'users')
96 115 ->paginate($per_page);
97 116
98 117 foreach ($relatedBoards as $relatedBoard) {
118 + $relatedBoard->description = DescriptionMarkdownConverter::normalize($relatedBoard->description);
99 119 $relatedBoard->users = Helper::sanitizeUserCollections($relatedBoard->users);
100 120 $relatedBoard->is_pinned = $this->boardService->isPinned($relatedBoard->id);
101 121 }
102 122
103 123 $response = [
104 - 'boards' => $relatedBoards
124 + 'boards' => $relatedBoards,
125 + 'board_counts' => $this->boardService->getBoardCounts($userId)
105 126 ];
106 127
107 - // Include folder mapping if pro version is available - ALWAYS include for consistency
108 - if (defined('FLUENT_BOARDS_PRO')) {
109 - $response['folder_mapping'] = $this->getBoardFolderMapping($userId);
110 - if ($folderId) {
111 - $response['current_folder'] = $this->getCurrentFolderInfo($folderId);
112 - }
113 - } else {
114 - // Include empty folder mapping for consistency
115 - $response['folder_mapping'] = [];
128 + $folderMapping = $this->getBoardFolderMapping($userId);
129 + $response['folder_mapping'] = $folderMapping;
130 + if ($folderId) {
131 + $response['current_folder'] = isset($folderMapping[$folderId])
132 + ? $this->getCurrentFolderInfoFromMapping($folderMapping[$folderId])
133 + : null;
116 134 }
117 135
118 136 return $this->sendSuccess($response);
119 137 }
@@ -122,13 +140,9 @@
122 140 * Get folder mapping for boards
123 141 */
124 142 private function getBoardFolderMapping($userId)
125 143 {
126 - if (!defined('FLUENT_BOARDS_PRO')) {
127 - return [];
128 - }
129 -
130 - $folderService = new \FluentBoardsPro\App\Services\FolderService();
144 + $folderService = new FolderService();
131 145 $folders = $folderService->getFolders($userId);
132 146
133 147 $mapping = [];
134 148 foreach ($folders as $folder) {
@@ -141,28 +155,14 @@
141 155
142 156 return $mapping;
143 157 }
144 158
145 - /**
146 - * Get current folder information
147 - */
148 - private function getCurrentFolderInfo($folderId)
159 + private function getCurrentFolderInfoFromMapping(array $folder)
149 160 {
150 - if (!defined('FLUENT_BOARDS_PRO')) {
151 - return null;
152 - }
153 -
154 - $folderService = new \FluentBoardsPro\App\Services\FolderService();
155 - $folder = $folderService->getFolderById($folderId);
156 -
157 - if (!$folder) {
158 - return null;
159 - }
160 -
161 161 return [
162 - 'id' => $folder->id,
163 - 'title' => $folder->title,
164 - 'board_count' => $folder->boards ? $folder->boards->count() : 0
162 + 'id' => $folder['id'],
163 + 'title' => $folder['title'],
164 + 'board_count' => count($folder['board_ids'])
165 165 ];
166 166 }
167 167
168 168 /**
@@ -177,11 +177,11 @@
177 177
178 178 // Query to fetch boards that are not archived and accessible by the user
179 179 // Check if the FLUENT_ROADMAP constant is defined
180 180 if (!defined('FLUENT_ROADMAP')) {
181 - $relatedBoardsQuery = Board::whereNull('archived_at')->where('type', 'to-do')->byAccessUser($userId);
181 + $relatedBoardsQuery = Board::whereNull('archived_at')->where('type', 'to-do')->excludeTemplates()->byAccessUser($userId);
182 182 } else {
183 - $relatedBoardsQuery = Board::whereNull('archived_at')->byAccessUser($userId);
183 + $relatedBoardsQuery = Board::whereNull('archived_at')->excludeTemplates()->byAccessUser($userId);
184 184 }
185 185
186 186 $relatedBoards = $relatedBoardsQuery->with('stages')->get();
187 187
@@ -202,11 +202,11 @@
202 202
203 203
204 204 if(!defined('FLUENT_ROADMAP'))
205 205 {
206 - $relatedBoardsQuery = Board::whereNull('archived_at')->where('type', 'to-do')->byAccessUser($userId);
206 + $relatedBoardsQuery = Board::whereNull('archived_at')->where('type', 'to-do')->excludeTemplates()->byAccessUser($userId);
207 207 } else {
208 - $relatedBoardsQuery = Board::whereNull('archived_at')->byAccessUser($userId);
208 + $relatedBoardsQuery = Board::whereNull('archived_at')->excludeTemplates()->byAccessUser($userId);
209 209 }
210 210
211 211 if (!empty($searchInput)) {
212 212 $relatedBoardsQuery = $relatedBoardsQuery->where('title', 'like', '%' . $searchInput . '%');
@@ -228,9 +228,12 @@
228 228 {
229 229 $boards = $this->boardService->getRecentBoards();
230 230
231 231 if (!$boards || $boards->isEmpty()) {
232 - $boards = Board::where('type', 'to-do')->byAccessUser(get_current_user_id())
232 + $boards = Board::whereNull('archived_at')
233 + ->excludeTemplates()
234 + ->availableInCurrentInstall()
235 + ->byAccessUser(get_current_user_id())
233 236 ->limit(4)
234 237 ->withCount('completedTasks')
235 238 ->with(['stages', 'users'])
236 239 ->get();
@@ -336,25 +339,42 @@
336 339 'folder_id' => 'nullable',
337 340 ]);
338 341
339 342 try {
343 + $folderId = $request->getSafe('folder_id', 'intval');
344 + $folderService = new FolderService();
345 + if ($folderId) {
346 + $folderService->assertCanModifyFolder($folderId);
347 + }
348 +
349 + $backgroundData = $this->sanitizeCreateBoardBackground($request->get('background'));
350 + if (!empty($backgroundData)) {
351 + $boardData['background'] = $backgroundData;
352 + }
353 +
354 + $this->validateRequestedLabelPresets($request->get('labels'));
355 +
340 356 $board = $this->boardService->createBoard($boardData);
341 - $this->labelService->createDefaultLabel($board->id);
357 + $this->createBoardLabelsFromRequest($request, $board->id);
358 + $this->addBoardMembersFromRequest($request, $board->id);
342 359 $type = ucfirst($boardData['type']);
360 + $stages = $request->get('stages');
361 + $sanitizedStages = [];
343 362
363 + if (is_array($stages) && !empty($stages)) {
364 + foreach ($stages as $stage) {
365 + $sanitizedStages[] = $this->stageSanitizeAndValidate($stage, [
366 + 'title' => 'required|string',
367 + 'slug' => 'nullable|string',
368 + 'position' => 'nullable|numeric'
369 + ]);
370 + }
371 + }
372 +
344 373 if (isset($boardData['type']) && $boardData['type'] == 'roadmap') {
345 - $stages = $request->get('stages');
346 - $sanitizedStages = [];
347 - if (is_array($stages) && !empty($stages)) {
348 - foreach ($stages as $stage) {
349 - $sanitizedStages[] = $this->stageSanitizeAndValidate($stage, [
350 - 'title' => 'required|string',
351 - 'slug' => 'nullable|string',
352 - 'position' => 'nullable|numeric'
353 - ]);
354 - }
355 - }
356 374 $this->stageService->createRoadmapStages($board, $sanitizedStages);
375 + } elseif (!empty($sanitizedStages)) {
376 + $this->stageService->createStages($board, $sanitizedStages);
357 377 } else {
358 378 $this->stageService->createDefaultStages($board);
359 379 }
360 380
@@ -365,13 +385,10 @@
365 385
366 386 do_action('fluent_boards/board_created', $board);
367 387
368 388
369 - if(defined('FLUENT_BOARDS_PRO')) {
370 - $folderId = $request->getSafe('folder_id', 'intval');
371 - if ($folderId) {
372 - (new \FluentBoardsPro\App\Services\FolderService())->addBoardToFolder($folderId, [$board->id]);
373 - }
389 + if ($folderId) {
390 + $folderService->addBoardToFolder($folderId, [$board->id]);
374 391 }
375 392
376 393 $message = __('Board has been created successfully', 'fluent-boards');
377 394
@@ -385,26 +402,132 @@
385 402 ]);
386 403 }
387 404 }
388 405
406 + private function sanitizeCreateBoardBackground($background)
407 + {
408 + if (!is_array($background) || empty($background['id'])) {
409 + return '';
410 + }
411 +
412 + $backgroundId = sanitize_text_field($background['id']);
413 +
414 + // Only accept ids from the curated solid/gradient palettes and always
415 + // persist the canonical value from the constant (never the client-supplied
416 + // color) so arbitrary CSS can't be stored and later rendered into a style.
417 + $allowedBackgrounds = [];
418 + foreach (array_merge(
419 + Constant::BOARD_BACKGROUND_DEFAULT_SOLID_COLORS,
420 + Constant::BOARD_BACKGROUND_DEFAULT_GRADIENT_COLORS
421 + ) as $option) {
422 + if (isset($option['id'], $option['value'])) {
423 + $allowedBackgrounds[$option['id']] = $option['value'];
424 + }
425 + }
426 +
427 + if (!isset($allowedBackgrounds[$backgroundId])) {
428 + return '';
429 + }
430 +
431 + return [
432 + 'id' => $backgroundId,
433 + 'color' => $allowedBackgrounds[$backgroundId],
434 + 'is_image' => false,
435 + 'image_url' => null,
436 + ];
437 + }
438 +
439 + private function createBoardLabelsFromRequest(Request $request, $boardId)
440 + {
441 + $labels = $request->get('labels');
442 +
443 + if (!is_array($labels)) {
444 + $this->labelService->createDefaultLabel($boardId);
445 + return;
446 + }
447 +
448 + foreach ($labels as $label) {
449 + $labelData = Helper::sanitizeLabel((array) $label);
450 +
451 + if (empty($labelData['label']) && empty($labelData['bg_color']) && empty($labelData['color_preset'])) {
452 + continue;
453 + }
454 +
455 + $labelPayload = [
456 + 'label' => $labelData['label'] ?? '',
457 + 'bg_color' => $labelData['bg_color'] ?? '#f3f4f6',
458 + 'color' => $labelData['color'] ?? '#1B2533',
459 + ];
460 +
461 + if (array_key_exists('color_preset', $labelData)) {
462 + $labelPayload['color_preset'] = $labelData['color_preset'];
463 + }
464 +
465 + $this->labelService->createLabel($labelPayload, $boardId);
466 + }
467 + }
468 +
469 + /**
470 + * Reject unsupported label preset ids before creating any board records.
471 + *
472 + * @param mixed $labels
473 + * @return void
474 + * @throws \Exception
475 + */
476 + private function validateRequestedLabelPresets($labels)
477 + {
478 + if (!is_array($labels)) {
479 + return;
480 + }
481 +
482 + foreach ($labels as $label) {
483 + $labelData = Helper::sanitizeLabel((array) $label);
484 + $presetId = $labelData[Constant::LABEL_COLOR_PRESET_SETTING] ?? null;
485 +
486 + if ($presetId === null || $presetId === '') {
487 + continue;
488 + }
489 +
490 + if (!is_string($presetId) || !Constant::getLabelColorPreset($presetId)) {
491 + throw new \Exception(esc_html__('Invalid label color preset', 'fluent-boards'));
492 + }
493 + }
494 + }
495 +
496 + private function addBoardMembersFromRequest(Request $request, $boardId)
497 + {
498 + $memberIds = $request->get('member_ids');
499 +
500 + if (!is_array($memberIds)) {
501 + return;
502 + }
503 +
504 + $memberIds = array_filter(array_unique(array_map('intval', $memberIds)));
505 + $currentUserId = get_current_user_id();
506 +
507 + foreach ($memberIds as $memberId) {
508 + if ($memberId === $currentUserId) {
509 + continue;
510 + }
511 +
512 + $this->boardService->addMembersInBoard($boardId, $memberId);
513 + }
514 + }
515 +
516 + /**
517 + * Get archived stages for a board with optional pagination and archive actor metadata.
518 + */
389 519 public function getArchivedStage(Request $request, $board_id)
390 520 {
391 521 try {
392 - $pagination = $request->getSafe('noPagination', 'boolval', false);
393 - $per_page = $request->getSafe('per_page', 'intval', 30);
394 - $page = $request->getSafe('page', 'intval', 1);
522 + $board_id = absint($board_id);
523 + $sanitizedParams = [
524 + 'noPagination' => $request->getSafe('noPagination', 'boolval', false),
525 + 'per_page' => $request->getSafe('per_page', 'intval', 30),
526 + 'page' => $request->getSafe('page', 'intval', 1),
527 + ];
395 528
396 - if ($pagination) {
397 - $stages = Stage::where('board_id', $board_id)
398 - ->whereNotNull('archived_at')
399 - ->orderBy('created_at', 'DESC')
400 - ->get();
401 - } else {
402 - $stages = Stage::where('board_id', $board_id)
403 - ->whereNotNull('archived_at')
404 - ->orderBy('created_at', 'DESC')
405 - ->paginate($per_page, ['*'], 'page', $page);
406 - }
529 + $stages = $this->stageService->getArchivedStages($sanitizedParams, $board_id);
407 530
408 531 return $this->sendSuccess([
409 532 'stages' => $stages,
410 533 ], 200);
@@ -412,16 +535,26 @@
412 535 return $this->sendError($e->getMessage(), 404);
413 536 }
414 537 }
415 538
416 - public function find($board_id)
539 + public function find(Request $request, $board_id)
417 540 {
418 541 $board = Board::findOrFail($board_id);
542 + $board->description = DescriptionMarkdownConverter::normalize($board->description);
543 + $includeArchived = filter_var($request->get('include_archived', false), FILTER_VALIDATE_BOOLEAN);
419 544 $board->background = maybe_unserialize($board->background);
420 545 $board->createdOn = $board->created_at->format('Y-m-d');
421 546
422 - $board->load(['users', 'stages', 'labels', 'owner']);
547 + $board->load(['users', 'labels', 'owner']);
423 548
549 + if ($includeArchived) {
550 + $board->stages = Stage::where('board_id', $board_id)
551 + ->orderBy('position', 'asc')
552 + ->get();
553 + } else {
554 + $board->load('stages');
555 + }
556 +
424 557 if (defined('FLUENT_BOARDS_PRO')){
425 558 $customFiledPositionMeta = $board->getMetaByKey('custom_field_positions');
426 559 if(!$customFiledPositionMeta) {
427 560 (new CustomFieldService())->reIndexCustomFieldPositions($board_id);
@@ -434,8 +567,9 @@
434 567 $this->boardService->updateRecentBoards($board_id);
435 568
436 569 $board->labelColor = Constant::TRELLO_COLOR_MAP;
437 570 $board->labelColorText = Constant::TEXT_COLOR_MAP;
571 + $board->labelColorPresets = Constant::LABEL_COLOR_PRESETS;
438 572
439 573 $board->users = Helper::sanitizeUserCollections($board->users);
440 574 $board->owner = Helper::sanitizeUserCollections($board->owner);
441 575
@@ -443,14 +577,25 @@
443 577
444 578 $board = apply_filters('fluent_boards/board_find', $board);
445 579
446 580 return [
447 - 'board' => $board
581 + 'board' => $board,
582 + 'synced_at' => current_time('mysql')
448 583 ];
449 584 }
450 585
451 586 public function update(Request $request, $board_id)
452 587 {
588 + // Board identity (title/description) is manager-only. This action shares the
589 + // `update` name with CommentController@update under the same policy group, so the
590 + // guard lives here rather than in a SingleBoardPolicy::update() method that would
591 + // also block ordinary members from editing their own comments.
592 + if (!PermissionManager::isBoardManager(absint($board_id))) {
593 + return $this->sendError([
594 + 'message' => __('You do not have permission to edit this board.', 'fluent-boards'),
595 + ], 403);
596 + }
597 +
453 598 $boardData = $this->boardSanitizeAndValidate($request->only(['title', 'description']), [
454 599 'title' => 'required|string',
455 600 'description' => 'nullable|string',
456 601 ]);
@@ -455,8 +600,9 @@
455 600 'description' => 'nullable|string',
456 601 ]);
457 602
458 603 $board = Board::findOrFail($board_id);
604 + $boardData['description'] = DescriptionMarkdownConverter::normalize($boardData['description']);
459 605
460 606 $oldBoard = clone $board;
461 607 $board->fill($boardData);
462 608 $board->save();
@@ -471,13 +617,15 @@
471 617 }
472 618
473 619 public function archiveStage($board_id, $stage_id)
474 620 {
621 + $board_id = absint($board_id);
622 + $stage_id = absint($stage_id);
623 +
475 624 try {
476 - $stage = Stage::findOrFail($stage_id);
477 - $board = Board::findOrFail($stage->board_id);
625 + $stage = $this->findStageOnBoard($stage_id, $board_id);
478 626
479 - $updatedStage = $this->boardService->archiveStage($board->id, $stage);
627 + $updatedStage = $this->boardService->archiveStage($board_id, $stage);
480 628
481 629 return $this->sendSuccess([
482 630 'updatedStage' => $updatedStage,
483 631 'message' => __('Stage has been archived', 'fluent-boards'),
@@ -488,13 +636,15 @@
488 636 }
489 637
490 638 public function restoreStage($board_id, $stage_id)
491 639 {
640 + $board_id = absint($board_id);
641 + $stage_id = absint($stage_id);
642 +
492 643 try {
493 - $stage = Stage::findOrFail($stage_id);
494 - $board = Board::findOrFail($board_id);
644 + $stage = $this->findStageOnBoard($stage_id, $board_id);
495 645
496 - $updatedStage = $this->boardService->restoreStage($board->id, $stage);
646 + $updatedStage = $this->boardService->restoreStage($board_id, $stage);
497 647
498 648 return $this->sendSuccess([
499 649 'success' => true,
500 650 'updatedStage' => $updatedStage,
@@ -513,8 +663,12 @@
513 663 $incomingList = [];
514 664 }
515 665 $incomingList = array_map('intval', $incomingList);
516 666 try {
667 + foreach ($incomingList as $stageId) {
668 + $this->findStageOnBoard($stageId, $board_id);
669 + }
670 +
517 671 $this->boardService->repositionStages($board_id, $incomingList);
518 672 return $this->sendSuccess([
519 673 'message' => __('Stages Reordered', 'fluent-boards'),
520 674 'updatedStages' => $this->stageService->getLastOneMinuteUpdatedStages($board_id)
@@ -604,9 +758,8 @@
604 758
605 759 $formattedUsers[] = [
606 760 'ID' => $user->ID,
607 761 'display_name' => $name,
608 - 'user_login' => $user->user_login,
609 762 'email' => $user->user_email,
610 763 'photo' => fluent_boards_user_avatar($user->user_email, $name),
611 764 'role' => $this->boardUserRole($boardRelation),
612 765 'is_super' => in_array($user->ID, $superAdminIds),
@@ -628,16 +781,24 @@
628 781 return $returnData;
629 782 }
630 783
631 784 /*
632 - * These are the rest of the admin users who are not in the board
785 + * These are the rest of the Fluent Boards and WordPress admins who are not in the board.
633 786 */
634 - $adminUserIds = Meta::query()->where('object_type', Constant::FLUENT_BOARD_ADMIN)
787 + $fluentBoardAdminIds = Meta::query()->where('object_type', Constant::FLUENT_BOARD_ADMIN)
635 788 ->whereNotIn('object_id', $userIds)
636 789 ->get()
637 790 ->pluck('object_id')
638 791 ->toArray();
639 792
793 + $wordPressAdminIds = get_users([
794 + 'capability' => 'manage_options',
795 + 'exclude' => $userIds,
796 + 'fields' => 'ID',
797 + ]);
798 +
799 + $adminUserIds = array_values(array_unique(array_map('intval', array_merge($fluentBoardAdminIds, $wordPressAdminIds))));
800 +
640 801 if ($adminUserIds) {
641 802 $adminUsers = get_users([
642 803 'include' => $adminUserIds,
643 804 ]);
@@ -687,15 +848,22 @@
687 848 }
688 849
689 850 public function addMembersInBoard(Request $request, $board_id)
690 851 {
691 - $memberId = $request->getSafe('memberId');
692 - $isViewerOnly = $request->getSafe('isViewerOnly');
852 + $memberId = $request->getSafe('memberId', 'intval');
853 + $isViewerOnly = $request->getSafe('isViewerOnly', 'sanitize_text_field');
693 854 $member = $this->boardService->addMembersInBoard($board_id, $memberId, $isViewerOnly);
855 +
856 + if ($member === null) {
857 + return $this->sendError([
858 + 'message' => __('User not found.', 'fluent-boards'),
859 + ], 404);
860 + }
861 +
694 862 if (!$member) {
695 863 return $this->sendError([
696 864 'message' => __('User already a member', 'fluent-boards'),
697 - ], 304);
865 + ], 409);
698 866 }
699 867
700 868
701 869 return [
@@ -772,10 +940,13 @@
772 940 * @return
773 941 */
774 942 public function changeStageView($board_id, $stage_id)
775 943 {
944 + $board_id = absint($board_id);
945 + $stage_id = absint($stage_id);
946 +
776 947 try {
777 - $stage = Stage::findOrFail($stage_id);
948 + $stage = $this->findStageOnBoard($stage_id, $board_id);
778 949 $message = __('The stage is made public!', 'fluent-boards');
779 950 $settings = $stage->settings;
780 951
781 952 if (isset($settings['is_public'])) {
@@ -780,9 +951,9 @@
780 951
781 952 if (isset($settings['is_public'])) {
782 953 if ($settings['is_public']) {
783 954 $settings['is_public'] = false;
784 - $message = __('The stage is made admin only!', 'fluent-boards');
955 + $message = __('The stage is made private!', 'fluent-boards');
785 956 } else {
786 957 $settings['is_public'] = true;
787 958 }
788 959 } else {
@@ -801,24 +972,27 @@
801 972 }
802 973
803 974
804 975 /**
805 - * Set board background image or color
976 + * Set or reset board background image/color.
806 977 * @param \FluentBoards\Framework\Http\Request\Request $request
807 978 * @return
808 979 */
809 980 public function setBoardBackground(Request $request, $board_id)
810 981 {
811 - // sanitize and validate image_url
812 - if ($request->image_url) {
982 + $backgroundData = [];
983 + $isResetRequest = $request->getSafe('reset', 'rest_sanitize_boolean');
984 +
985 + if ($isResetRequest) {
986 + $backgroundData = [
987 + 'reset' => true,
988 + ];
989 + } elseif ($request->image_url) {
813 990 $backgroundData = $this->boardSanitizeAndValidate($request->all(), [
814 - "id" => 'required',
991 + 'id' => 'required|integer',
815 992 'image_url' => 'required|string|url',
816 993 ]);
817 - }
818 -
819 - // sanitize and validate color
820 - if ($request->color) {
994 + } elseif ($request->color) {
821 995 $backgroundData = $this->boardSanitizeAndValidate($request->all(), [
822 996 "id" => 'required',
823 997 'color' => 'required',
824 998 ]);
@@ -829,8 +1003,13 @@
829 1003 $errorMessage = __('Board id is required', 'fluent-boards');
830 1004 throw new \Exception(esc_html($errorMessage), 400);
831 1005 }
832 1006
1007 + if (empty($backgroundData)) {
1008 + $errorMessage = __('Background data is required', 'fluent-boards');
1009 + throw new \Exception(esc_html($errorMessage), 400);
1010 + }
1011 +
833 1012 return $this->sendSuccess([
834 1013 'message' => __('Background updated successfully', 'fluent-boards'),
835 1014 'background' => $this->boardService->setBoardBackground($backgroundData, $board_id),
836 1015 ]);
@@ -846,15 +1025,19 @@
846 1025 * @param mixed $stage_slug
847 1026 * @return $availablePositions as an array
848 1027 * @throws \Exception
849 1028 */
850 - public function getStageTaskAvailablePositions($board_id, $stage_id)
1029 + public function getStageTaskAvailablePositions(Request $request, $board_id, $stage_id)
851 1030 {
852 1031 try {
853 1032 if ($board_id && $stage_id) {
854 - $availablePositions = $this->boardService->getStageTaskAvailablePositions($board_id, $stage_id);
1033 + $taskId = $request->getSafe('task_id', 'intval');
1034 + $availablePositions = $this->boardService->getStageTaskAvailablePositions($board_id, $stage_id, $taskId);
855 1035 return $this->sendSuccess([
856 - 'availablePositions' => $availablePositions
1036 + 'availablePositions' => $availablePositions['availablePositions'],
1037 + 'moveTargets' => $availablePositions['moveTargets'],
1038 + 'currentMoveTargetKey' => $availablePositions['currentMoveTargetKey'],
1039 + 'defaultMoveTargetKey' => $availablePositions['defaultMoveTargetKey'],
857 1040 ], 200);
858 1041 } else {
859 1042 $message = '';
860 1043 if (!$board_id) {
@@ -876,24 +1059,47 @@
876 1059 $contactAssociatedTasks = Task::with('board')->where('board_id', $board_id)
877 1060 ->whereNotNull('crm_contact_id')
878 1061 ->get();
879 1062
880 - $formattedContacts = Collection::make($contactAssociatedTasks)
881 - ->groupBy('crm_contact_id')
882 - ->map(function ($tasks, $contactId) {
883 - $subscriber = Subscriber::find($contactId);
884 - if (!$subscriber) {
1063 + $tasksByContact = [];
1064 + foreach ($contactAssociatedTasks as $task) {
1065 + $tasksByContact[absint($task->crm_contact_id)][] = $task;
1066 + }
1067 +
1068 + $boardContactIds = Meta::query()
1069 + ->where('object_id', absint($board_id))
1070 + ->where('object_type', Constant::OBJECT_TYPE_BOARD)
1071 + ->whereIn('key', [
1072 + Constant::BOARD_ASSOCIATED_CRM_CONTACT,
1073 + self::LEGACY_BOARD_ASSOCIATED_CRM_CONTACT,
1074 + ])
1075 + ->pluck('value')
1076 + ->toArray();
1077 + $boardContactIds = array_values(array_unique(array_filter(array_map('absint', $boardContactIds))));
1078 +
1079 + $contactIds = array_values(array_unique(array_filter(array_map('absint', array_merge(
1080 + array_keys($tasksByContact),
1081 + $boardContactIds
1082 + )))));
1083 +
1084 + usort($contactIds, function ($firstContactId, $secondContactId) use ($boardContactIds) {
1085 + return (int) in_array($secondContactId, $boardContactIds, true) - (int) in_array($firstContactId, $boardContactIds, true);
1086 + });
1087 +
1088 + $formattedContacts = Collection::make($contactIds)
1089 + ->map(function ($contactId) use ($tasksByContact, $boardContactIds) {
1090 + $contact = Helper::crm_contact($contactId);
1091 + if (!$contact) {
885 1092 return null; // Skip if subscriber not found
886 1093 }
887 1094
888 - return [
889 - 'name' => $subscriber->first_name . ' ' . $subscriber->last_name,
890 - 'photo' => $subscriber->photo,
891 - 'email' => $subscriber->email,
892 - 'crm_contact_id' => $contactId,
893 - 'id' => $contactId,
894 - 'tasks' => $tasks,
895 - ];
1095 + $tasks = $tasksByContact[$contactId] ?? [];
1096 + $contact['name'] = trim(($contact['first_name'] ?? '') . ' ' . ($contact['last_name'] ?? '')) ?: ($contact['full_name'] ?? $contact['email'] ?? '');
1097 + $contact['crm_contact_id'] = $contactId;
1098 + $contact['is_board_contact'] = in_array($contactId, $boardContactIds, true);
1099 + $contact['tasks'] = $tasks;
1100 +
1101 + return $contact;
896 1102 })
897 1103 ->filter()->toArray();
898 1104
899 1105
@@ -914,11 +1120,13 @@
914 1120 'message' => __('Associated Crm Member has been updated', 'fluent-boards'),
915 1121 ], 200);
916 1122 }
917 1123
918 - public function hasDataChanged($board_id)
1124 + public function hasDataChanged(Request $request, $board_id)
919 1125 {
920 - return $this->boardService->hasDataChanged($board_id);
1126 + $includeArchived = filter_var($request->get('include_archived', false), FILTER_VALIDATE_BOOLEAN);
1127 + $since = $request->getSafe('since', 'sanitize_text_field');
1128 + return $this->boardService->hasDataChanged($board_id, $includeArchived, $since);
921 1129 }
922 1130
923 1131 public function createStage(Request $request, $board_id)
924 1132 {
@@ -967,23 +1175,54 @@
967 1175 }
968 1176
969 1177 public function archiveAllTasksInStage($board_id, $stage_id)
970 1178 {
971 - $updates = $this->stageService->archiveAllTasksInStage($stage_id, $board_id);
972 - return [
973 - 'message' => __('Tasks have been archived', 'fluent-boards'),
974 - 'updatedTasks' => $updates,
975 - ];
1179 + $board_id = absint($board_id);
1180 + $stage_id = absint($stage_id);
1181 +
1182 + try {
1183 + $this->findStageOnBoard($stage_id, $board_id);
1184 + $updates = $this->stageService->archiveAllTasksInStage($stage_id, $board_id);
1185 +
1186 + return [
1187 + 'message' => __('Tasks have been archived', 'fluent-boards'),
1188 + 'updatedTasks' => $updates,
1189 + ];
1190 + } catch (\Exception $e) {
1191 + return $this->sendError($e->getMessage(), 400);
1192 + }
976 1193 }
977 1194
978 1195 public function getAssociatedBoards(Request $request, $associated_id)
979 1196 {
980 - $associatedBoards = $this->boardService->getAssociatedBoards($associated_id);
1197 + if (!$this->currentUserCanReadCrmContacts()) {
1198 + return $this->sendError(esc_html__('You do not have permission to view CRM contact boards', 'fluent-boards'), 403);
1199 + }
1200 +
1201 + $associatedId = absint($associated_id);
1202 +
1203 + if (!$associatedId) {
1204 + return $this->sendError(__('Invalid CRM contact', 'fluent-boards'), 400);
1205 + }
1206 +
1207 + $associatedBoards = $this->boardService->getAssociatedBoards($associatedId, get_current_user_id());
1208 +
981 1209 return [
982 1210 'boards' => $associatedBoards,
983 1211 ];
984 1212 }
985 1213
1214 + private function currentUserCanReadCrmContacts()
1215 + {
1216 + $permissionManager = 'FluentCrm\\App\\Services\\PermissionManager';
1217 +
1218 + if (!class_exists($permissionManager)) {
1219 + return false;
1220 + }
1221 +
1222 + return (bool) $permissionManager::currentUserCan('fcrm_read_contacts');
1223 + }
1224 +
986 1225 public function duplicateBoard(Request $request, $board_id)
987 1226 {
988 1227 $boardData = $this->taskSanitizeAndValidate($request->get('board'), [
989 1228 'title' => 'required|string'
@@ -1231,7 +1470,65 @@
1231 1470 ], 200);
1232 1471 } catch (\Exception $e) {
1233 1472 return $this->sendError($e->getMessage(), 500);
1234 1473 }
1474 + }
1475 +
1476 + public function getPublicAccessSettings($board_id)
1477 + {
1478 + $board_id = absint($board_id);
1479 + $board = Board::findOrFail($board_id);
1480 +
1481 + $enabled = (bool) $board->getMetaByKey('public_access_enabled');
1482 + $shortcode = $enabled ? '[fluent_board_public id="' . $board_id . '"]' : '';
1483 +
1484 + return $this->sendSuccess([
1485 + 'enabled' => $enabled,
1486 + 'shortcode' => $shortcode,
1487 + ], 200);
1488 + }
1489 +
1490 + public function togglePublicAccess(Request $request, $board_id)
1491 + {
1492 + $board_id = absint($board_id);
1493 + $board = Board::findOrFail($board_id);
1494 +
1495 + $enabled = filter_var(
1496 + $request->getSafe('enabled', 'sanitize_text_field', false),
1497 + FILTER_VALIDATE_BOOLEAN
1498 + );
1499 +
1500 + $board->updateMeta('public_access_enabled', $enabled ? '1' : '');
1501 +
1502 + $shortcode = $enabled ? '[fluent_board_public id="' . $board_id . '"]' : '';
1503 +
1504 + return $this->sendSuccess([
1505 + 'message' => $enabled
1506 + ? __('Public access has been enabled', 'fluent-boards')
1507 + : __('Public access has been disabled', 'fluent-boards'),
1508 + 'enabled' => $enabled,
1509 + 'shortcode' => $shortcode,
1510 + ], 200);
1511 + }
1512 +
1513 + /**
1514 + * Resolve a stage only when it belongs to the requested board.
1515 + *
1516 + * @param int $stageId
1517 + * @param int $boardId
1518 + * @return Stage
1519 + * @throws \Exception
1520 + */
1521 + private function findStageOnBoard($stageId, $boardId)
1522 + {
1523 + $stage = Stage::where('id', absint($stageId))
1524 + ->where('board_id', absint($boardId))
1525 + ->first();
1526 +
1527 + if (!$stage) {
1528 + throw new \Exception(esc_html__('Stage not found', 'fluent-boards'));
1529 + }
1530 +
1531 + return $stage;
1235 1532 }
1236 1533
1237 1534 }