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/TaskController.php +693 -92 1.91.62.1.0 View file →
@@ -1,9 +1,9 @@
1 1 <?php
2 2
3 3 namespace FluentBoards\App\Http\Controllers;
4 4
5 -use DateTimeImmutable;
5 +use FluentBoards\Framework\Database\Orm\ModelNotFoundException;
6 6 use FluentBoards\App\Models\Meta;
7 7 use FluentBoards\App\Models\Stage;
8 8 use FluentBoards\App\Models\Task;
9 9 use FluentBoards\App\Models\Board;
@@ -38,39 +38,59 @@
38 38 public function getTopTasksForBoards()
39 39 {
40 40 $userId = get_current_user_id();
41 41 $task_ids = PermissionManager::getTaskIdsWatchByUser($userId);
42 - $tasksArray = $this->taskService->getTasksForBoards(['overdue', 'upcoming'], 6, $task_ids);
42 + $boardIds = PermissionManager::getBoardIdsForUser($userId);
43 + $taskCategories = ['due_today', 'assigned', 'overdue', 'upcoming', 'mentioned', 'completed', 'others'];
44 + $tasksArray = $this->taskService->getTasksForBoards($taskCategories, 6, $task_ids);
45 + $taskCounts = $this->taskService->getTaskCountsForBoards($taskCategories, $task_ids);
46 + $taskCounts['all_boards'] = empty($boardIds)
47 + ? 0
48 + : (int) Board::whereIn('id', $boardIds)
49 + ->whereNull('archived_at')
50 + ->excludeTemplates()
51 + ->count();
52 + $taskCounts['all_tasks'] = empty($task_ids)
53 + ? 0
54 + : (int) Task::whereIn('id', $task_ids)
55 + ->whereNull('archived_at')
56 + ->whereNull('parent_id')
57 + ->onActiveAvailableBoards()
58 + ->count();
43 59
44 60 return [
45 61 'data' => $tasksArray,
62 + 'counts' => $taskCounts,
46 63 ];
47 64 }
48 65
49 - public function getTasksByBoard($board_id)
66 + public function getTasksByBoard(Request $request, $board_id)
50 67 {
51 68 $board_id = absint($board_id);
52 69 $board = Board::findOrFail($board_id);
70 + $includeArchived = $request->getSafe('include_archived', 'boolval', false);
53 71
54 72 // Get stage IDs
55 - $stageIds = $this->getStageIdsByBoard($board_id);
73 + $stageIds = $this->getStageIdsByBoard($board_id, $includeArchived);
56 74
57 75 // Fetch tasks for the board
58 - $tasks = Task::with(['assignees', 'labels', 'watchers', 'taskCustomFields'])
76 + $tasksQuery = Task::with(['assignees', 'labels', 'watchers', 'taskCustomFields'])
59 77 ->where('board_id', $board_id)
60 - ->whereNull('archived_at')
61 78 ->whereNull('parent_id')
62 79 ->whereIn('stage_id', $stageIds)
63 - ->orderBy('due_at', 'ASC')
64 - ->get();
80 + ->orderBy('due_at', 'ASC');
65 81
82 + if (!$includeArchived) {
83 + $tasksQuery->whereNull('archived_at');
84 + }
85 +
86 + $tasks = $tasksQuery->get();
87 +
66 88 // Process each task
67 89 $this->processTasks($tasks, $board);
68 90
69 91 if ($board->type === 'roadmap') {
70 - foreach ($tasks as $task) {
71 - $task->vote_statistics = $this->taskService->getIdeaVoteStatistics($task->id);
72 - }
92 + $this->taskService->loadIdeaVoteStatistics($tasks);
73 93 }
74 94
75 95 return [
76 96 'tasks' => $tasks,
@@ -76,40 +96,225 @@
76 96 'tasks' => $tasks,
77 97 ];
78 98 }
79 99
80 - public function getTasksByBoardStage($board_id)
100 + public function getTasksByBoardStage(Request $request, $board_id)
81 101 {
82 102 $board_id = absint($board_id);
83 103 $board = Board::findOrFail($board_id);
104 + $includeArchived = $request->getSafe('include_archived', 'boolval', false);
84 105
85 106 // Get stage IDs
86 - $stageIds = $this->getStageIdsByBoard($board_id);
107 + $stageIds = $this->getStageIdsByBoard($board_id, $includeArchived);
108 + $stageTaskCounts = $this->getStageTaskCounts($board_id, $stageIds, $includeArchived);
87 109
88 110 // Initialize tasks array
89 111 $tasks = [];
112 + $paginationByStage = [];
90 113
91 114 // Fetch and process tasks for each stage
92 115 foreach ($stageIds as $stageId) {
93 - $stageTasks = Task::with(['assignees', 'labels', 'watchers', 'taskCustomFields'])
94 - ->where('board_id', $board_id)
95 - ->where('stage_id', $stageId)
96 - ->whereNull('archived_at')
97 - ->whereNull('parent_id')
116 + $stageTasks = $this->makeStageTasksQuery($board_id, $stageId, $includeArchived)
98 117 ->orderBy('position', 'ASC')
99 118 ->limit(20)
100 119 ->get();
101 120
102 121 // Process each stage's tasks
103 - $this->processTasks($stageTasks, $board);
122 + $this->processTasks($stageTasks, $board, [
123 + 'includeContact' => false,
124 + 'includeObserverState' => false,
125 + 'includeRoadmapPopularity' => false,
126 + ]);
104 127 $tasks = array_merge($tasks, $stageTasks->toArray()); // Merge with the main task list
128 +
129 + $startCursor = $stageTasks->count() ? (float) $stageTasks->first()->position : null;
130 + $endCursor = $stageTasks->count() ? (float) $stageTasks->last()->position : null;
131 + $loadedCount = $stageTasks->count();
132 + $hasMoreAfter = (int) ($stageTaskCounts[$stageId] ?? 0) > $loadedCount;
133 +
134 + $paginationByStage[$stageId] = [
135 + 'stage_id' => (int) $stageId,
136 + 'total_count' => (int) ($stageTaskCounts[$stageId] ?? 0),
137 + 'limit' => 20,
138 + 'direction' => 'next',
139 + 'cursor' => null,
140 + 'has_more' => $hasMoreAfter,
141 + 'has_more_before' => false,
142 + 'has_more_after' => $hasMoreAfter,
143 + 'start_cursor' => $startCursor,
144 + 'end_cursor' => $endCursor,
145 + ];
105 146 }
106 147
107 148 return [
108 149 'tasks' => $tasks,
150 + 'pagination_by_stage' => $paginationByStage,
109 151 ];
110 152 }
111 153
154 + public function getTableTasks(Request $request, $board_id)
155 + {
156 + $board_id = absint($board_id);
157 + $board = Board::findOrFail($board_id);
158 + $args = [
159 + 'page' => $request->getSafe('page', 'intval', 1),
160 + 'per_page' => $request->getSafe('per_page', 'intval', 20),
161 + 'sort_by' => $request->getSafe('sort_by', 'sanitize_text_field', 'position'),
162 + 'sort_direction' => $request->getSafe('sort_direction', 'sanitize_text_field', 'asc'),
163 + 'search' => $request->getSafe('search', 'sanitize_text_field', ''),
164 + 'include_archived' => $request->getSafe('include_archived', 'boolval', false),
165 + 'stage' => $request->get('stage', []),
166 + 'task_status' => $request->get('task_status', []),
167 + 'priority' => $request->get('priority', []),
168 + 'assignee' => $request->get('assignee', []),
169 + 'labels' => $request->get('labels', []),
170 + 'watchers' => $request->get('watchers', []),
171 + 'contact' => $request->get('contact', []),
172 + 'custom_fields' => $request->get('custom_fields', []),
173 + 'due_date' => $request->get('due_date', []),
174 + ];
175 +
176 + $tasks = $this->taskService->getTableTasks($board_id, $args);
177 + $taskItems = $tasks->items();
178 + $this->processTasks($taskItems, $board, [
179 + 'includeContact' => false,
180 + 'includeObserverState' => false,
181 + 'includeRoadmapPopularity' => false,
182 + ]);
183 +
184 + return $this->sendSuccess([
185 + 'items' => $taskItems,
186 + 'pagination' => [
187 + 'total' => (int) $tasks->total(),
188 + 'current_page' => (int) $tasks->currentPage(),
189 + 'per_page' => (int) $tasks->perPage(),
190 + 'last_page' => (int) $tasks->lastPage(),
191 + ],
192 + ], 200);
193 + }
194 +
195 + public function getFilteredBoardTasks(Request $request, $board_id)
196 + {
197 + $board_id = absint($board_id);
198 + $board = Board::findOrFail($board_id);
199 + $args = [
200 + 'search' => $request->getSafe('search', 'sanitize_text_field', ''),
201 + 'include_archived' => $request->getSafe('include_archived', 'boolval', false),
202 + 'stage' => $request->get('stage', []),
203 + 'task_status' => $request->get('task_status', []),
204 + 'priority' => $request->get('priority', []),
205 + 'assignee' => $request->get('assignee', []),
206 + 'labels' => $request->get('labels', []),
207 + 'watchers' => $request->get('watchers', []),
208 + 'contact' => $request->get('contact', []),
209 + 'custom_fields' => $request->get('custom_fields', []),
210 + 'due_date' => $request->get('due_date', []),
211 + ];
212 +
213 + $tasks = $this->taskService->getBoardViewTasks($board_id, $args);
214 + $this->processTasks($tasks, $board, [
215 + 'includeContact' => false,
216 + 'includeObserverState' => false,
217 + 'includeRoadmapPopularity' => false,
218 + ]);
219 +
220 + return [
221 + 'tasks' => $tasks,
222 + ];
223 + }
224 +
225 + public function getStageTasksPage(Request $request, $board_id)
226 + {
227 + $board_id = absint($board_id);
228 + $board = Board::findOrFail($board_id);
229 + $includeArchived = $request->getSafe('include_archived', 'boolval', false);
230 + $stageId = $request->getSafe('stage_id', 'intval');
231 + $limit = $request->getSafe('limit', 'intval', 20);
232 + $direction = $request->getSafe('direction', 'sanitize_text_field', 'next');
233 + $cursor = $request->getSafe('cursor', 'floatval');
234 +
235 + if (!$stageId) {
236 + return $this->sendError(esc_html__('Invalid Stage', 'fluent-boards'), 400);
237 + }
238 +
239 + if (!in_array($direction, ['next', 'prev'], true)) {
240 + return $this->sendError(esc_html__('Invalid direction', 'fluent-boards'), 400);
241 + }
242 +
243 + $limit = max(1, min(100, $limit));
244 +
245 + $stage = Stage::where('board_id', $board_id)
246 + ->where('id', $stageId)
247 + ->first();
248 +
249 + if (!$stage) {
250 + return $this->sendError(esc_html__('Stage not found', 'fluent-boards'), 404);
251 + }
252 +
253 + $stageTasksQuery = $this->makeStageTasksQuery($board_id, $stageId, $includeArchived);
254 +
255 + if ($cursor !== null) {
256 + if ($direction === 'prev') {
257 + $stageTasksQuery->where('position', '<', $cursor);
258 + } else {
259 + $stageTasksQuery->where('position', '>', $cursor);
260 + }
261 + }
262 +
263 + $stageTasks = $stageTasksQuery
264 + ->orderBy('position', $direction === 'prev' ? 'DESC' : 'ASC')
265 + ->limit($limit + 1)
266 + ->get();
267 +
268 + $hasMoreInDirection = $stageTasks->count() > $limit;
269 + if ($hasMoreInDirection) {
270 + $stageTasks = $stageTasks->slice(0, $limit)->values();
271 + }
272 +
273 + if ($direction === 'prev') {
274 + $stageTasks = $stageTasks->sortBy('position')->values();
275 + }
276 +
277 + $this->processTasks($stageTasks, $board, [
278 + 'includeContact' => false,
279 + 'includeObserverState' => false,
280 + 'includeRoadmapPopularity' => false,
281 + ]);
282 +
283 + $startCursor = $stageTasks->count() ? (float) $stageTasks->first()->position : null;
284 + $endCursor = $stageTasks->count() ? (float) $stageTasks->last()->position : null;
285 +
286 + $hasMoreBefore = false;
287 + $hasMoreAfter = false;
288 +
289 + if ($startCursor !== null) {
290 + $hasMoreBefore = $this->makeStageTasksQuery($board_id, $stageId, $includeArchived)
291 + ->where('position', '<', $startCursor)
292 + ->exists();
293 + }
294 +
295 + if ($endCursor !== null) {
296 + $hasMoreAfter = $this->makeStageTasksQuery($board_id, $stageId, $includeArchived)
297 + ->where('position', '>', $endCursor)
298 + ->exists();
299 + }
300 +
301 + return [
302 + 'tasks' => $stageTasks,
303 + 'pagination' => [
304 + 'stage_id' => (int) $stageId,
305 + 'limit' => (int) $limit,
306 + 'direction' => $direction,
307 + 'cursor' => $cursor !== null ? (float) $cursor : null,
308 + 'has_more' => $hasMoreInDirection,
309 + 'has_more_before' => $hasMoreBefore,
310 + 'has_more_after' => $hasMoreAfter,
311 + 'start_cursor' => $startCursor,
312 + 'end_cursor' => $endCursor,
313 + ],
314 + ];
315 + }
316 +
112 317 /**
113 318 * Get Stage IDs by Board ID.
114 319 *
115 320 * @param int $board_id
@@ -114,45 +319,153 @@
114 319 *
115 320 * @param int $board_id
116 321 * @return array
117 322 */
118 - private function getStageIdsByBoard($board_id)
323 + private function getStageIdsByBoard($board_id, $includeArchived = false)
119 324 {
120 - return Stage::where('board_id', $board_id)
121 - ->whereNull('archived_at')
122 - ->pluck('id')
325 + $stageQuery = Stage::where('board_id', $board_id);
326 + if (!$includeArchived) {
327 + $stageQuery->whereNull('archived_at');
328 + }
329 +
330 + return $stageQuery->pluck('id')->toArray();
331 + }
332 +
333 + private function makeStageTasksQuery($board_id, $stageId, $includeArchived = false)
334 + {
335 + $stageTasksQuery = Task::query()
336 + // Kanban/List only need card-level task data here; full task detail is
337 + // fetched separately when the modal opens.
338 + ->select($this->getStageTaskCardColumns())
339 + ->with(['assignees', 'labels', 'watchers'])
340 + ->where('board_id', $board_id)
341 + ->where('stage_id', $stageId)
342 + ->whereNull('parent_id');
343 +
344 + if (!$includeArchived) {
345 + $stageTasksQuery->whereNull('archived_at');
346 + }
347 +
348 + return $stageTasksQuery;
349 + }
350 +
351 + private function getStageTaskCounts($board_id, array $stageIds, $includeArchived = false)
352 + {
353 + if (!$stageIds) {
354 + return [];
355 + }
356 +
357 + $query = Task::query()
358 + ->selectRaw('stage_id, COUNT(*) as total_count')
359 + ->where('board_id', $board_id)
360 + ->whereNull('parent_id')
361 + ->whereIn('stage_id', $stageIds);
362 +
363 + if (!$includeArchived) {
364 + $query->whereNull('archived_at');
365 + }
366 +
367 + return $query
368 + ->groupBy('stage_id')
369 + ->pluck('total_count', 'stage_id')
370 + ->map(function ($count) {
371 + return (int) $count;
372 + })
123 373 ->toArray();
124 374 }
125 375
376 + private function getStageTaskCardColumns()
377 + {
378 + return [
379 + 'id',
380 + 'title',
381 + 'slug',
382 + 'board_id',
383 + 'parent_id',
384 + 'crm_contact_id',
385 + 'type',
386 + 'stage_id',
387 + 'status',
388 + 'reminder_type',
389 + 'priority',
390 + 'archived_at',
391 + 'remind_at',
392 + 'started_at',
393 + 'due_at',
394 + 'last_completed_at',
395 + 'position',
396 + 'comments_count',
397 + 'created_by',
398 + 'settings',
399 + 'source',
400 + 'source_id',
401 + ];
402 + }
403 +
126 404 /**
127 405 * Process and append extra information for each task.
128 406 *
129 407 * @param \Illuminate\Database\Eloquent\Collection $tasks
130 408 * @param \App\Models\Board $board
409 + * @param array $options
131 410 */
132 - private function processTasks($tasks, $board)
411 + private function processTasks($tasks, $board, $options = [])
133 412 {
413 + $includeContact = Arr::get($options, 'includeContact', true);
414 + $includeObserverState = Arr::get($options, 'includeObserverState', true);
415 + $includeRoadmapPopularity = Arr::get($options, 'includeRoadmapPopularity', true);
416 + $taskIds = [];
417 +
134 418 foreach ($tasks as $task) {
419 + $taskIds[] = (int) $task->id;
420 + }
421 +
422 + $unreadNotificationCounts = $this->notificationService->getUnreadNotificationCountsByTaskIds($taskIds);
423 +
424 + foreach ($tasks as $task) {
135 425 $task->isOverdue = $task->isOverdue();
136 426 $task->isUpcoming = $task->upcoming();
137 - $task->contact = Helper::crm_contact($task->crm_contact_id); // Handle possible null contact
138 - $task->is_watching = $task->isWatching();
427 + if ($includeContact) {
428 + $task->contact = Helper::crm_contact($task->crm_contact_id); // Handle possible null contact
429 + }
430 + if ($includeObserverState) {
431 + $task->is_watching = $task->isWatching();
432 + }
139 433 $task->assignees = Helper::sanitizeUserCollections($task->assignees);
140 434 $task->watchers = Helper::sanitizeUserCollections($task->watchers);
141 - $task->notifications = $this->notificationService->getUnreadNotificationsOfTasks($task);
435 + $task->notifications = $unreadNotificationCounts[(int) $task->id] ?? 0;
142 436
143 437 // If the board type is 'roadmap', calculate popularity
144 - if ($board->type === 'roadmap') {
438 + if ($includeRoadmapPopularity && $board->type === 'roadmap') {
145 439 $task->popular = $task->getPopularCount();
146 440 }
147 441 }
148 442 }
149 443
150 -
444 + /**
445 + * Create a task with field-specific sanitization for its request data.
446 + *
447 + * @param Request $request
448 + * @param int $board_id
449 + * @return mixed
450 + */
151 451 public function create(Request $request, $board_id)
152 452 {
153 453 $board_id = absint($board_id);
154 - $taskData = $this->taskSanitizeAndValidate($request->getSafe('task'), [
454 + $safeTaskData = $request->getSafe('task');
455 + $rawTaskData = $request->get('task', []);
456 +
457 + // Milkdown serializes pasted URLs as <https://...>, which generic text
458 + // sanitization removes as a tag.
459 + if (
460 + is_array($safeTaskData) &&
461 + is_array($rawTaskData) &&
462 + array_key_exists('description', $rawTaskData)
463 + ) {
464 + $safeTaskData['description'] = fluent_boards_sanitize_description($rawTaskData['description']);
465 + }
466 +
467 + $taskData = $this->taskSanitizeAndValidate($safeTaskData, [
155 468 'title' => 'required|string',
156 469 'board_id' => 'required|numeric',
157 470 'stage_id' => 'required|numeric',
158 471 'priority' => 'nullable|string',
@@ -160,17 +473,28 @@
160 473 'is_template' => 'string',
161 474 ]);
162 475
163 476 try {
477 + if (isset($taskData['assignees'])) {
478 + $taskData['assignees'] = array_filter(array_map('intval', (array) $taskData['assignees']));
479 + }
480 +
481 + if (isset($taskData['labels'])) {
482 + $taskData['labels'] = array_filter(array_map('intval', (array) $taskData['labels']));
483 + }
484 +
164 485 if ($taskData['board_id'] != $board_id) {
165 486 throw new \Exception(esc_html__('Board id is not valid', 'fluent-boards'));
166 487 }
167 488
168 489 $task = $this->taskService->createTask($taskData, $board_id);
490 + $message = $task->type === 'roadmap'
491 + ? __('Idea has been successfully created', 'fluent-boards')
492 + : __('Task has been successfully created', 'fluent-boards');
169 493
170 494 return $this->sendSuccess([
171 495 'task' => $task,
172 - 'message' => __('Task has been successfully created', 'fluent-boards'),
496 + 'message' => $message,
173 497 'updatedTasks' => $this->taskService->getLastOneMinuteUpdatedTasks($task->board_id)
174 498 ], 201);
175 499 } catch (\Exception $e) {
176 500 return $this->sendError($e->getMessage(), 400);
@@ -184,12 +508,12 @@
184 508 try {
185 509
186 510 $stageService = new StageService();
187 511
188 - $task = Task::where('board_id', $board_id)->where('id', $task_id)->firstOrFail();
512 + $task = $this->taskService->findTaskOnBoard($task_id, $board_id);
189 513
190 - if ($task->parent_id) {
191 - $task = Task::where('board_id', $board_id)->where('id', $task->parent_id)->firstOrFail();
514 + if (isset($task->parent_id)) {
515 + $task = $this->taskService->findTaskOnBoard($task->parent_id, $board_id, false);
192 516 }
193 517
194 518 if(!$task) {
195 519 throw new \Exception(esc_html__('Task not found', 'fluent-boards'));
@@ -201,8 +525,9 @@
201 525
202 526 $task->load(['board', 'stage', 'labels', 'assignees','watchers']);
203 527
204 528 $task->assignees = Helper::sanitizeUserCollections($task->assignees);
529 + $task->watchers = Helper::sanitizeUserCollections($task->watchers);
205 530
206 531 $task->isOverdue = $task->isOverdue();
207 532 $task->contact = Task::lead_contact($task->crm_contact_id);
208 533 $task->board->stages = $stageService->stagesByBoardId($board_id);
@@ -217,8 +542,10 @@
217 542 return [
218 543 'task' => $task
219 544 ];
220 545
546 + } catch (ModelNotFoundException $e) {
547 + throw $e;
221 548 } catch (\Exception $e ) {
222 549 return $this->sendError($e->getMessage(), 400);
223 550 }
224 551
@@ -238,11 +565,11 @@
238 565 public function getActivities(Request $request, $board_id, $task_id)
239 566 {
240 567 $board_id = absint($board_id);
241 568 $task_id = absint($task_id);
242 - Task::where('board_id', $board_id)->where('id', $task_id)->firstOrFail();
243 569 $filter = $request->getSafe('filter', 'sanitize_text_field');
244 570 $per_page = 15; // Apparently, let's use a fixed number of items per page.
571 + $this->taskService->findTaskOnBoard($task_id, $board_id);
245 572
246 573 return [
247 574 'activities' => $this->taskService->getActivities($task_id, $per_page, $filter)
248 575 ];
@@ -255,9 +582,12 @@
255 582 // Sanitize request parameters before passing to service
256 583 $sanitizedParams = [
257 584 'per_page' => $request->getSafe('per_page', 'intval', 20),
258 585 'page' => $request->getSafe('page', 'intval', 1),
586 + 'query' => $request->getSafe('searchInput', 'sanitize_text_field', '')
259 587 ];
588 +
589 +
260 590 $tasks = $this->taskService->getArchivedTasks($sanitizedParams, $board_id);
261 591
262 592 foreach ($tasks as $task) {
263 593 $task->assignees = Helper::sanitizeUserCollections($task->assignees);
@@ -262,8 +592,9 @@
262 592 foreach ($tasks as $task) {
263 593 $task->assignees = Helper::sanitizeUserCollections($task->assignees);
264 594 }
265 595
596 +
266 597 return [
267 598 'tasks' => $tasks
268 599 ];
269 600 }
@@ -426,10 +757,10 @@
426 757 $task_id = absint($task_id);
427 758 //Properties in col: settings, assignees,crm_contact_id, archived_at(AUTO_SET_TIMESTAMP) , status, title, description, priority, is_watching, is_template
428 759 $col = $request->getSafe('property', 'sanitize_text_field');
429 760 if ($col === 'description') {
430 - $value = $request->getSafe('value', 'wp_kses_post');
431 - } elseif ($col === 'settings') {
761 + $value = $request->getSafe('value', 'fluent_boards_sanitize_description');
762 + } elseif ($col === 'settings' || $col === 'assignees') {
432 763 $value = $request->get('value');
433 764 if (is_array($value) && isset($value['cover']) && is_array($value['cover'])) {
434 765 if (isset($value['cover']['backgroundColor'])) {
435 766 $value['cover']['backgroundColor'] = sanitize_text_field($value['cover']['backgroundColor']);
@@ -434,15 +765,43 @@
434 765 if (isset($value['cover']['backgroundColor'])) {
435 766 $value['cover']['backgroundColor'] = sanitize_text_field($value['cover']['backgroundColor']);
436 767 }
437 768 }
769 + } elseif ($col === 'is_watching') {
770 + $value = $request->get('value');
771 + if (is_array($value)) {
772 + $action = isset($value['action']) ? sanitize_text_field($value['action']) : 'start';
773 + $value = [
774 + 'userId' => isset($value['userId']) ? absint($value['userId']) : 0,
775 + 'action' => in_array($action, ['start', 'stop'], true) ? $action : 'start',
776 + ];
777 + } else {
778 + $value = sanitize_text_field($value);
779 + }
438 780 } else {
439 781 $value = $request->getSafe('value', 'sanitize_text_field');
440 782 }
441 783
442 784 $validatedData = $this->updateTaskPropValidationAndSanitation($col, $value);
443 - $task = Task::with(['board', 'labels', 'assignees'])->where('board_id', $board_id)->where('id', $task_id)->firstOrFail();
785 + $task = $this->taskService->findTaskOnBoard($task_id, $board_id);
786 + $task->load(['board', 'labels', 'assignees']);
444 787
788 + if ($col === 'board_id' && (int) $validatedData[$col] !== $board_id) {
789 + throw new \Exception(esc_html__('Task not found', 'fluent-boards'));
790 + }
791 +
792 + if ($col === 'stage_id' && !Stage::where('id', (int) $validatedData[$col])->where('board_id', $board_id)->exists()) {
793 + throw new \Exception(esc_html__('Stage not found', 'fluent-boards'));
794 + }
795 +
796 + if ($col === 'parent_id' && $validatedData[$col]) {
797 + $this->taskService->findTaskOnBoard($validatedData[$col], $board_id, false);
798 + }
799 +
800 + if ($task->parent_id && $col === 'started_at') {
801 + $validatedData[$col] = null;
802 + }
803 +
445 804 $oldDateValue = null;
446 805 if (in_array($col, ['due_at', 'started_at'])) {
447 806 $oldDateValue = $task->{$col};
448 807 }
@@ -458,8 +817,13 @@
458 817 $task->contact = Helper::crm_contact($task->crm_contact_id);
459 818 $task->is_watching = $task->isWatching();
460 819 $task->assignees = Helper::sanitizeUserCollections($task->assignees);
461 820
821 + if ($col === 'is_watching') {
822 + $task->load('watchers');
823 + $task->watchers = Helper::sanitizeUserCollections($task->watchers);
824 + }
825 +
462 826 if ($task->parent_id) {
463 827 $task->subtask_group_id = TaskMeta::where('task_id', $task->id)->where('key', Constant::SUBTASK_GROUP_CHILD)->value('value');
464 828 }
465 829
@@ -484,13 +848,34 @@
484 848 'updatedTasks' => $updatedTasks
485 849 ];
486 850 }
487 851
852 + /**
853 + * Remove a Fluent Support association from a task without deleting the ticket.
854 + *
855 + * @param int $board_id
856 + * @param int $task_id
857 + * @return mixed
858 + */
859 + public function removeSupportTicketLink($board_id, $task_id)
860 + {
861 + $boardId = absint($board_id);
862 + $taskId = absint($task_id);
863 + $task = $this->taskService->removeSupportTicketLink($taskId, $boardId);
864 +
865 + return $this->sendSuccess([
866 + 'message' => __('Support ticket link has been removed', 'fluent-boards'),
867 + 'task' => $task,
868 + 'updatedTasks' => [$task],
869 + ]);
870 + }
871 +
488 872 public function updateTaskDates(Request $request, $board_id, $task_id)
489 873 {
490 874 $board_id = absint($board_id);
491 875 $task_id = absint($task_id);
492 - $task = Task::where('board_id', $board_id)->where('id', $task_id)->firstOrFail();
876 + $task = Task::where('id', $task_id)->where('board_id', $board_id)->firstOrFail();
877 + $payload = $request->all();
493 878
494 879 // Capture old dates before updating
495 880 $oldDates = [
496 881 'due_at' => $task->due_at,
@@ -496,26 +881,49 @@
496 881 'due_at' => $task->due_at,
497 882 'started_at' => $task->started_at,
498 883 ];
499 884
500 - $startAt = $request->getSafe('started_at', 'sanitize_text_field', NULL);
501 - $dueAt = $request->getSafe('due_at', 'sanitize_text_field', NULL);
502 - $reminderType = $request->getSafe('reminder_type', 'sanitize_text_field', NULL);
503 - $remindAt = $request->getSafe('remind_at', 'sanitize_text_field', NULL);
885 +
504 886
505 - if ($startAt && $dueAt) {
887 + $hasStartAt = array_key_exists('started_at', $payload);
888 + $hasDueAt = array_key_exists('due_at', $payload);
889 + $hasReminderType = array_key_exists('reminder_type', $payload);
890 + $hasRemindAt = array_key_exists('remind_at', $payload);
891 +
892 + $startAt = $hasStartAt ? $request->getSafe('started_at', 'sanitize_text_field', NULL) : $task->started_at;
893 + $dueAt = $hasDueAt ? $request->getSafe('due_at', 'sanitize_text_field', NULL) : $task->due_at;
894 + $isSubtask = (bool) $task->parent_id;
895 +
896 + if ($isSubtask) {
897 + $startAt = null;
898 + $hasStartAt = $hasStartAt || (bool) $task->started_at;
899 + }
900 +
901 + if (!$isSubtask && $hasStartAt && $hasDueAt && $startAt && $dueAt) {
506 902 if (strtotime($startAt) > strtotime($dueAt)) {
507 - $startAt = gmdate('Y-m-d 00:00:00', strtotime($dueAt));
903 + $startAt = substr($dueAt, 0, 10) . ' 00:00:00';
508 904 }
509 905 }
510 -
511 - $task = $this->taskService->updateTaskProperty('started_at', $startAt, $task);
512 - $task = $this->taskService->updateTaskProperty('due_at', $dueAt, $task);
513 906
514 - // Handle task reminder for all tasks (both tasks and subtasks)
515 - $task = $this->taskService->updateTaskProperty('reminder_type', $reminderType, $task);
516 - $task = $this->taskService->updateTaskProperty('remind_at', $remindAt, $task);
907 + if ($hasStartAt) {
908 + $task = $this->taskService->updateTaskProperty('started_at', $startAt, $task);
909 + }
517 910
911 + if ($hasDueAt) {
912 + $task = $this->taskService->updateTaskProperty('due_at', $dueAt, $task);
913 + }
914 +
915 + // Only mutate reminder fields when the caller explicitly sends them.
916 + if ($hasReminderType) {
917 + $reminderType = $request->getSafe('reminder_type', 'sanitize_text_field', NULL);
918 + $task = $this->taskService->updateTaskProperty('reminder_type', $reminderType, $task);
919 + }
920 +
921 + if ($hasRemindAt) {
922 + $remindAt = $request->getSafe('remind_at', 'sanitize_text_field', NULL);
923 + $task = $this->taskService->updateTaskProperty('remind_at', $remindAt, $task);
924 + }
925 +
518 926 $datesChanged = false;
519 927 $changedDates = [];
520 928
521 929 if ($oldDates['due_at'] !== $task->due_at) {
@@ -538,14 +946,69 @@
538 946 'updatedTasks' => $this->taskService->getLastOneMinuteUpdatedTasks($board_id),
539 947 ];
540 948 }
541 949
950 + /**
951 + * Toggle task pinned state (meta only). Only top-level tasks can be pinned.
952 + *
953 + * @param Request $request Expects body: pinned (bool or "true"/"1" for pin, false/"false"/"0" for unpin)
954 + * @param int $board_id
955 + * @param int $task_id
956 + * @return array{task: \FluentBoards\App\Models\Task, message: string, updatedTasks: array}
957 + */
958 + public function toggleTaskPinned(Request $request, $board_id, $task_id)
959 + {
960 + $board_id = absint($board_id);
961 + $task_id = absint($task_id);
962 +
963 + $task = Task::where('board_id', $board_id)->findOrFail($task_id);
964 +
965 + if ($task->parent_id) {
966 + return $this->sendError(__('Subtasks cannot be pinned', 'fluent-boards'), 400);
967 + }
968 +
969 + $pinned = filter_var($request->getSafe('pinned', 'sanitize_text_field', false), FILTER_VALIDATE_BOOLEAN);
970 +
971 + if ((int) $task->is_pinned !== ($pinned ? 1 : 0)) {
972 + if ($pinned) {
973 + $task = $this->taskService->pinTask($task);
974 + $message = __('Task has been pinned', 'fluent-boards');
975 + } else {
976 + $task = $this->taskService->unpinTask($task);
977 + $message = __('Task has been unpinned', 'fluent-boards');
978 + }
979 + } else {
980 + $message = $pinned ? __('Task is already pinned', 'fluent-boards') : __('Task is already unpinned', 'fluent-boards');
981 + }
982 +
983 + // Pin state is stored in task meta, so task.updated_at may not change.
984 + // Ensure the toggled task is always present in the incremental payload.
985 + $updatedTasks = $this->taskService->getLastOneMinuteUpdatedTasks($board_id);
986 + $taskExists = false;
987 + foreach ($updatedTasks as $index => $updatedTask) {
988 + if ($updatedTask->id === $task->id) {
989 + $updatedTasks[$index] = $task;
990 + $taskExists = true;
991 + break;
992 + }
993 + }
994 + if (!$taskExists) {
995 + $updatedTasks[] = $task;
996 + }
997 +
998 + return [
999 + 'task' => $task,
1000 + 'message' => $message,
1001 + 'updatedTasks' => $updatedTasks,
1002 + ];
1003 + }
1004 +
542 1005 public function updateTaskCoverPhoto(Request $request, $board_id, $task_id)
543 1006 {
544 1007 $board_id = absint($board_id);
545 1008 $task_id = absint($task_id);
546 1009 $imagePath = $request->getSafe('thumbnail', 'sanitize_text_field');
547 - $task = $this->taskService->taskCoverPhotoUpdate($task_id, $imagePath);
1010 + $task = $this->taskService->taskCoverPhotoUpdate($task_id, $imagePath, $board_id);
548 1011
549 1012 return [
550 1013 'message' => __('Task cover photo has been updated', 'fluent-boards'),
551 1014 'task' => $task,
@@ -556,13 +1019,12 @@
556 1019 public function taskStatusUpdate(Request $request, $board_id, $task_id)
557 1020 {
558 1021 $board_id = absint($board_id);
559 1022 $task_id = absint($task_id);
560 - Task::where('board_id', $board_id)->where('id', $task_id)->firstOrFail();
561 1023 $integrationType = $request->getSafe('integrationType', 'sanitize_text_field');
562 1024 return [
563 1025 'message' => __('Task status has been updated', 'fluent-boards'),
564 - 'task' => $this->taskService->taskStatusUpdate($task_id, $integrationType),
1026 + 'task' => $this->taskService->taskStatusUpdate($task_id, $integrationType, $board_id),
565 1027 ];
566 1028 }
567 1029
568 1030 public function deleteTask($board_id, $task_id)
@@ -568,9 +1030,9 @@
568 1030 public function deleteTask($board_id, $task_id)
569 1031 {
570 1032 $board_id = absint($board_id);
571 1033 $task_id = absint($task_id);
572 - $task = Task::where('board_id', $board_id)->where('id', $task_id)->firstOrFail();
1034 + $task = $this->taskService->findTaskOnBoard($task_id, $board_id);
573 1035 $options = null;
574 1036 //if we need to do something before a task is deleted
575 1037 do_action('fluent_boards/before_task_deleted', $task, $options);
576 1038
@@ -588,8 +1050,39 @@
588 1050
589 1051 return $this->validate($data, $rules);
590 1052 }
591 1053
1054 + /**
1055 + * Ensure write routes cannot pair an accessible route board with a task from another board.
1056 + *
1057 + * @param \FluentBoards\App\Models\Task $task
1058 + * @param int $boardId
1059 + * @return void
1060 + * @throws \Exception
1061 + */
1062 + private function assertTaskBelongsToBoard($task, $boardId)
1063 + {
1064 + $boardId = absint($boardId);
1065 +
1066 + if (!$task || !$boardId) {
1067 + throw new \Exception(esc_html__('Task not found', 'fluent-boards'));
1068 + }
1069 +
1070 + if ((int) $task->board_id === $boardId) {
1071 + return;
1072 + }
1073 +
1074 + if ($task->parent_id) {
1075 + $parentBoardId = Task::where('id', $task->parent_id)->value('board_id');
1076 +
1077 + if ((int) $parentBoardId === $boardId) {
1078 + return;
1079 + }
1080 + }
1081 +
1082 + throw new \Exception(esc_html__('Task not found', 'fluent-boards'));
1083 + }
1084 +
592 1085 private function updateTaskPropValidationAndSanitation($col, $value)
593 1086 {
594 1087 $rules = [
595 1088 'title' => 'required|string',
@@ -631,8 +1124,11 @@
631 1124 }
632 1125
633 1126 return [$col => $sanitizedAndValidatedValue];
634 1127 }
1128 + if ('is_watching' == $col && is_array($value)) {
1129 + return [$col => $value];
1130 + }
635 1131 $data = Helper::sanitizeTask([$col => $value]);
636 1132
637 1133 return $this->validate($data, [
638 1134 $col => $rule,
@@ -643,22 +1139,16 @@
643 1139 // translators: %s is the property name
644 1140 throw new \Exception(sprintf(esc_html__('Invalid property: %s', 'fluent-boards'), esc_html($col)));
645 1141 }
646 1142
647 - public function getLabelsByTask($task_id)
648 - {
649 - $task_id = absint($task_id);
650 - $labels = $this->taskService->getLabelsByTask($task_id);
651 -
652 - return $this->sendSuccess([
653 - 'labels' => $labels,
654 - ], 200);
655 - }
656 -
657 1143 public function getStageByTask($task_id)
658 1144 {
659 1145 $task_id = absint($task_id);
660 - $stage = $this->taskService->getStageByTask($task_id);
1146 + try {
1147 + $stage = $this->taskService->getStageByTask($task_id);
1148 + } catch (\Exception $e) {
1149 + return $this->sendError($e->getMessage(), 404);
1150 + }
661 1151
662 1152 return [
663 1153 'stage' => $stage,
664 1154 ];
@@ -699,10 +1189,9 @@
699 1189 public function moveTaskToNextStage($board_id, $task_id)
700 1190 {
701 1191 $board_id = absint($board_id);
702 1192 $task_id = absint($task_id);
703 - Task::where('board_id', $board_id)->where('id', $task_id)->firstOrFail();
704 - $task = $this->taskService->moveTaskToNextStage($task_id);
1193 + $task = $this->taskService->moveTaskToNextStage($task_id, $board_id);
705 1194
706 1195 return [
707 1196 'task' => $task
708 1197 ];
@@ -714,31 +1203,69 @@
714 1203 public function moveTask(Request $request, $board_id, $task_id)
715 1204 {
716 1205 $board_id = absint($board_id);
717 1206 $task_id = absint($task_id);
718 - $task = Task::where('board_id', $board_id)->where('id', $task_id)->firstOrFail();
1207 + $task = $this->taskService->findTaskOnBoard($task_id, $board_id);
719 1208 $oldStageId = $task->stage_id;
720 1209 $newStageId = $request->getSafe('newStageId', 'intval');
721 1210 $newIndex = $request->getSafe('newIndex', 'intval');
722 1211 $newBoardId = $request->getSafe('newBoardId', 'intval');
1212 + $prevTaskId = $request->getSafe('prevTaskId', 'intval');
1213 + $nextTaskId = $request->getSafe('nextTaskId', 'intval');
723 1214
724 1215 if ((!is_numeric($newStageId) || $newStageId == 0)) {
725 1216 throw new \Exception(esc_html__('Invalid Stage', 'fluent-boards'));
726 1217 }
727 -// if ((!is_numeric($newIndex) || $newIndex == 0)) {
728 -// throw new \Exception(__('Invalid Value', 'fluent-boards'));
729 -// }
1218 +
1219 + if (!$prevTaskId && !$nextTaskId && (!is_numeric($newIndex) || $newIndex == 0)) {
1220 + throw new \Exception(esc_html__('Invalid Value', 'fluent-boards'));
1221 + }
1222 +
730 1223 if ($newBoardId) {
731 1224 if ((!is_numeric($newBoardId) || $newBoardId == 0)) {
732 1225 throw new \Exception(esc_html__('Invalid Board', 'fluent-boards'));
733 1226 }
1227 +
1228 + if (!PermissionManager::userHasBoardPermission($newBoardId, 'PUT')) {
1229 + throw new \Exception(esc_html__('Task not found', 'fluent-boards'));
1230 + }
1231 + }
1232 +
1233 + $effectiveBoardId = $newBoardId ?: $task->board_id;
1234 + $targetStage = Stage::where('id', $newStageId)
1235 + ->where('board_id', $effectiveBoardId)
1236 + ->first();
1237 +
1238 + if (!$targetStage) {
1239 + throw new \Exception(esc_html__('Invalid Stage', 'fluent-boards'));
1240 + }
1241 +
1242 + foreach (array_filter([$prevTaskId, $nextTaskId]) as $neighborTaskId) {
1243 + $this->taskService->findTaskOnBoard($neighborTaskId, $effectiveBoardId);
1244 + }
1245 +
1246 + if ($newBoardId) {
734 1247 $task = $this->taskService->changeBoardByTask($task, $newBoardId);
735 1248 // Load relationships to ensure frontend gets updated data after board move
736 1249 $task->load(['assignees', 'labels', 'watchers', 'attachments']);
737 1250 }
738 1251
1252 + // Clean up archived_by_stage meta when task is moved to different stage
1253 + if ($oldStageId != $newStageId) {
1254 + TaskMeta::where('task_id', $task->id)
1255 + ->where('key', Constant::META_KEY_ARCHIVED_BY_STAGE)
1256 + ->delete();
1257 + }
1258 +
739 1259 $task->stage_id = $newStageId;
740 - $task = $task->moveToNewPosition($newIndex);
1260 + // New drag flows send neighbour ids so ordering stays correct even when
1261 + // the client only has a paged slice of the stage. Older move flows still
1262 + // rely on the legacy 1-based newIndex fallback.
1263 + if ($prevTaskId || $nextTaskId) {
1264 + $task = $task->moveBetweenTasks($prevTaskId, $nextTaskId);
1265 + } else {
1266 + $task = $task->moveToNewPosition($newIndex);
1267 + }
741 1268
742 1269 if ($oldStageId != $newStageId) {
743 1270
744 1271 $this->taskService->manageDefaultAssignees($task, $newStageId);
@@ -781,16 +1308,15 @@
781 1308 public function getCommentsAndActivities( Request $request, $board_id, $task_id)
782 1309 {
783 1310 $board_id = absint($board_id);
784 1311 $task_id = absint($task_id);
785 - Task::where('board_id', $board_id)->where('id', $task_id)->firstOrFail();
786 1312 try {
787 1313 // Pagination parameters
788 1314 $page = $request->getSafe('page', 'intval', 1);
789 1315 $perPage = $request->getSafe('per_page', 'intval', 10);
790 1316 $filter = $request->getSafe('filter', 'sanitize_text_field', 'newest'); // Filter for comments and activities
791 -
792 - $commentsAndActivities = $this->taskService->getCommentsAndActivities($task_id, $perPage, $page, $filter);
1317 + $feedType = $request->getSafe('feed_type', 'sanitize_text_field', 'all');
1318 + $commentsAndActivities = $this->taskService->getCommentsAndActivities($task_id, $perPage, $page, $filter, $board_id, $feedType);
793 1319 // Return the response with the task, paginated comments and activities, total count, current page, and items per page
794 1320 return $this->sendSuccess([
795 1321 'comments_and_activities' => $commentsAndActivities,
796 1322 ]);
@@ -808,15 +1334,35 @@
808 1334 as_enqueue_async_action('fluent_boards/one_time_schedule_send_email_for_stage_change', [$taskId, $usersToSendEmail, $current_user_id], 'fluent-boards');
809 1335 }
810 1336 public function getAssociatedTasks($associated_id)
811 1337 {
1338 + if (!$this->currentUserCanReadCrmContacts()) {
1339 + return $this->sendError(esc_html__('You do not have permission to view CRM contact tasks', 'fluent-boards'), 403);
1340 + }
1341 +
812 1342 $associated_id = absint($associated_id);
813 1343 return [
814 - 'tasks' => $this->taskService->getAssociatedTasks($associated_id)
1344 + 'tasks' => $this->taskService->getAssociatedTasks($associated_id, get_current_user_id())
815 1345 ];
816 1346 }
817 1347
818 1348 /**
1349 + * Check FluentCRM contact read permission before exposing CRM-associated task data.
1350 + *
1351 + * @return bool
1352 + */
1353 + private function currentUserCanReadCrmContacts()
1354 + {
1355 + $permissionManager = 'FluentCrm\\App\\Services\\PermissionManager';
1356 +
1357 + if (!class_exists($permissionManager)) {
1358 + return false;
1359 + }
1360 +
1361 + return (bool) $permissionManager::currentUserCan('fcrm_read_contacts');
1362 + }
1363 +
1364 + /**
819 1365 * @param Request $request
820 1366 * @param $board_id
821 1367 * @param $task_id
822 1368 * @return \WP_REST_Response
@@ -824,10 +1370,10 @@
824 1370 public function uploadMediaFileFromWpEditor(Request $request, $board_id, $task_id)
825 1371 {
826 1372 $board_id = absint($board_id);
827 1373 $task_id = absint($task_id);
828 - Task::where('board_id', $board_id)->where('id', $task_id)->firstOrFail();
829 1374 try {
1375 + $this->taskService->findTaskOnBoard($task_id, $board_id);
830 1376
831 1377
832 1378 $file = Arr::get($request->files(), 'file')->toArray();
833 1379 (new \FluentBoards\App\Services\UploadService)->validateFile($file);
@@ -859,17 +1405,25 @@
859 1405 public function createTaskFromImage(Request $request, $board_id)
860 1406 {
861 1407 $board_id = absint($board_id);
862 1408 $stageId = $request->getSafe('stage_id', 'intval');
1409 + if (!Stage::where('id', $stageId)->where('board_id', $board_id)->exists()) {
1410 + return $this->sendError(esc_html__('Stage not found', 'fluent-boards'), 400);
1411 + }
1412 +
863 1413 $file = Arr::get($request->files(), 'file')->toArray();
864 1414 (new \FluentBoards\App\Services\UploadService)->validateFile($file);
865 1415
866 1416 $uploadInfo = UploadService::handleFileUpload( $request->files(), $board_id);
867 1417 $task = $this->taskService->createTaskFromImage($board_id, $stageId, $uploadInfo, $file);
1418 + $message = $task->type === 'roadmap'
1419 + ? __('Idea has been created', 'fluent-boards')
1420 + : __('Task has been created', 'fluent-boards');
1421 +
868 1422 return $this->sendSuccess([
869 1423 'task' => $task,
870 1424 'updatedTasks' => $this->taskService->getLastOneMinuteUpdatedTasks($board_id),
871 - 'message' => __('Task has been created', 'fluent-boards'),
1425 + 'message' => $message,
872 1426 ], 200);
873 1427
874 1428 }
875 1429
@@ -876,10 +1430,10 @@
876 1430 public function handleTaskCoverImageUpload(Request $request, $board_id, $task_id)
877 1431 {
878 1432 $board_id = absint($board_id);
879 1433 $task_id = absint($task_id);
880 - Task::where('board_id', $board_id)->where('id', $task_id)->firstOrFail();
881 1434 try {
1435 + $task = $this->taskService->findTaskOnBoard($task_id, $board_id);
882 1436
883 1437 $file = Arr::get($request->files(), 'file')->toArray();
884 1438 (new \FluentBoards\App\Services\UploadService)->validateFile($file);
885 1439
@@ -894,9 +1448,8 @@
894 1448 $fileUploadedData['full_url'] = $mediaData['full_url'];
895 1449 $fileUploadedData->save();
896 1450 }
897 1451
898 - $task = Task::where('board_id', $board_id)->where('id', $task_id)->firstOrFail();
899 1452 $settings = $task->settings;
900 1453 $this->taskService->deleteTaskCoverImage($settings);
901 1454 $publicUrl = (new CommentService())->createPublicUrl($fileUploadedData, $board_id);
902 1455
@@ -921,9 +1474,9 @@
921 1474 {
922 1475 $board_id = absint($board_id);
923 1476 $task_id = absint($task_id);
924 1477 try {
925 - $task = Task::where('board_id', $board_id)->where('id', $task_id)->firstOrFail();
1478 + $task = $this->taskService->findTaskOnBoard($task_id, $board_id);
926 1479 $settings = $task->settings;
927 1480 $this->taskService->deleteTaskCoverImage($settings);
928 1481 unset($settings['cover']);
929 1482 $task->settings = $settings;
@@ -943,50 +1496,67 @@
943 1496 public function getTaskTabsConfig()
944 1497 {
945 1498 $default_config = [
946 1499 [
1500 + 'name' => 'due_today',
1501 + 'label' => __('Due Today', 'fluent-boards'),
1502 + 'visible' => 'true',
1503 + 'order' => 1
1504 + ],
1505 + [
947 1506 'name' => 'assigned',
948 1507 'label' => __('Assigned', 'fluent-boards'),
949 1508 'visible' => 'true',
950 - 'order' => 1
1509 + 'order' => 2
951 1510 ],
952 1511 [
953 1512 'name' => 'upcoming',
954 1513 'label' => __('Upcoming', 'fluent-boards'),
955 1514 'visible' => 'true',
956 - 'order' => 2
1515 + 'order' => 3
957 1516 ],
958 1517 [
959 1518 'name' => 'overdue',
960 1519 'label' => __('Overdue', 'fluent-boards'),
961 1520 'visible' => 'true',
962 - 'order' => 3
1521 + 'order' => 4
963 1522 ],
964 1523 [
965 1524 'name' => 'mentioned',
966 1525 'label' => __('Mentioned', 'fluent-boards'),
967 1526 'visible' => 'true',
968 - 'order' => 4
1527 + 'order' => 5
969 1528 ],
970 1529 [
971 1530 'name' => 'completed',
972 1531 'label' => __('Completed', 'fluent-boards'),
973 1532 'visible' => 'true',
974 - 'order' => 5
1533 + 'order' => 6
975 1534 ],
976 1535 [
977 1536 'name' => 'others',
978 1537 'label' => __('Others', 'fluent-boards'),
979 1538 'visible' => 'true',
980 - 'order' => 6
1539 + 'order' => 7
981 1540 ]
982 1541 ];
1542 + $availableTabNames = array_column($default_config, 'name');
983 1543
984 1544 $existConfig = Meta::where('object_id', get_current_user_id())->where('key', Constant::FBS_TASK_TABS_CONFIG)->first();
985 1545 $config = $default_config;
986 1546
987 1547 if ($existConfig && !empty($existConfig->value)) {
988 - $config = $existConfig->value;
1548 + $storedConfig = $existConfig->value;
1549 + $configChanged = false;
1550 + $config = $storedConfig;
1551 + $config = array_values(array_filter($config, fn($tab) => in_array($tab['name'] ?? '', $availableTabNames, true)));
1552 + $configChanged = count($config) !== count($storedConfig);
1553 +
1554 + if (empty($config)) {
1555 + $config = $default_config;
1556 + $configChanged = true;
1557 + }
1558 +
989 1559 $existingNames = array_column($config, 'name');
990 1560 $missingTabs = [];
991 1561 foreach ($default_config as $defaultTab) {
992 1562 if (!in_array($defaultTab['name'], $existingNames)) {
@@ -996,10 +1566,21 @@
996 1566
997 1567 if (!empty($missingTabs)) {
998 1568 $newConfig = [];
999 1569 $order = 1;
1570 + $addedDueToday = false;
1000 1571 $addedAssigned = false;
1001 1572 foreach ($config as $tab) {
1573 + if (!$addedDueToday) {
1574 + $dueTodayTab = array_filter($missingTabs, fn($t) => $t['name'] === 'due_today');
1575 + if (!empty($dueTodayTab)) {
1576 + $dueTodayTab = reset($dueTodayTab);
1577 + $dueTodayTab['order'] = $order++;
1578 + $newConfig[] = $dueTodayTab;
1579 + $addedDueToday = true;
1580 + }
1581 + }
1582 +
1002 1583 if ($tab['name'] === 'upcoming' && !$addedAssigned) {
1003 1584 $assignedTab = array_filter($missingTabs, fn($t) => $t['name'] === 'assigned');
1004 1585 if (!empty($assignedTab)) {
1005 1586 $assignedTab = reset($assignedTab);
@@ -1011,19 +1592,40 @@
1011 1592 $tab['order'] = $order++;
1012 1593 $newConfig[] = $tab;
1013 1594 }
1014 1595 foreach ($missingTabs as $missingTab) {
1015 - if ($missingTab['name'] !== 'assigned') {
1596 + if (!in_array($missingTab['name'], ['assigned', 'due_today'], true)) {
1016 1597 $missingTab['order'] = $order++;
1017 1598 $newConfig[] = $missingTab;
1018 1599 }
1019 1600 }
1020 1601 $config = $newConfig;
1602 + $configChanged = true;
1603 + }
1604 +
1605 + if ($configChanged) {
1021 1606 $existConfig->value = $config;
1022 1607 $existConfig->save();
1023 1608 }
1024 1609 }
1025 1610
1611 + // Always apply fresh translations based on tab name
1612 + $labelMap = [
1613 + 'due_today' => __('Due Today', 'fluent-boards'),
1614 + 'assigned' => __('Assigned', 'fluent-boards'),
1615 + 'upcoming' => __('Upcoming', 'fluent-boards'),
1616 + 'overdue' => __('Overdue', 'fluent-boards'),
1617 + 'mentioned' => __('Mentioned', 'fluent-boards'),
1618 + 'completed' => __('Completed', 'fluent-boards'),
1619 + 'others' => __('Others', 'fluent-boards'),
1620 + ];
1621 +
1622 + foreach ($config as &$tab) {
1623 + if (isset($labelMap[$tab['name']])) {
1624 + $tab['label'] = $labelMap[$tab['name']];
1625 + }
1626 + }
1627 +
1026 1628 return $this->sendSuccess([
1027 1629 'data' => $config
1028 1630 ]);
1029 1631 }
@@ -1138,11 +1740,10 @@
1138 1740 'attachment' => 'required',
1139 1741 'comment' => 'required',
1140 1742 ]);
1141 1743 try {
1142 - Task::where('board_id', $board_id)->where('id', $task_id)->firstOrFail();
1143 1744 $taskData = fluent_boards_string_to_bool($taskData);
1144 - $clonedTask = $this->taskService->cloneTask($task_id, $taskData);
1745 + $clonedTask = $this->taskService->cloneTask($task_id, $taskData, $board_id);
1145 1746
1146 1747 return $this->sendSuccess([
1147 1748 'message' => __('Task has been cloned successfully', 'fluent-boards'),
1148 1749 'task' => $clonedTask,