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