PluginProbe
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration / 2.2.0
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration v2.2.0
2.2.0 2.1.0 2.0.15 2.0.12 2.0.10 2.0.4 2.0.1 2.0.0 1.95.3 1.95.2 1.95 1.91.6 trunk 1.11 1.12 1.13 1.20 1.21 1.22 1.23 1.30 1.31 1.32 1.35 1.40 All 43 releases
← All changes | app/Http/Controllers/TaskController.php +1248 -79 1.40 → 2.2.0 View file →
@@ -1,12 +1,14 @@
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 +use FluentBoards\App\Models\Meta;
6 7 use FluentBoards\App\Models\Stage;
7 8 use FluentBoards\App\Models\Task;
8 9 use FluentBoards\App\Models\Board;
10 +use FluentBoards\App\Models\TaskMeta;
9 11 use FluentBoards\App\Services\CommentService;
10 12 use FluentBoards\App\Services\Constant;
11 13 use FluentBoards\App\Services\Helper;
12 14 use FluentBoards\App\Services\StageService;
@@ -16,8 +18,9 @@
16 18 use FluentBoards\Framework\Http\Request\Request;
17 19 use FluentBoards\App\Services\PermissionManager;
18 20 use FluentBoards\Framework\Support\Arr;
19 21 use FluentBoardsPro\App\Services\AttachmentService;
22 +use FluentCrm\App\Models\Subscriber;
20 23
21 24 class TaskController extends Controller
22 25 {
23 26 private TaskService $taskService;
@@ -35,70 +38,295 @@
35 38 public function getTopTasksForBoards()
36 39 {
37 40 $userId = get_current_user_id();
38 41 $task_ids = PermissionManager::getTaskIdsWatchByUser($userId);
39 - $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();
40 59
41 60 return [
42 61 'data' => $tasksArray,
62 + 'counts' => $taskCounts,
43 63 ];
44 64 }
45 65
46 - 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)
47 68 {
69 + $syncStartedAt = current_time('mysql');
70 + $board_id = absint($board_id);
48 71 $board = Board::findOrFail($board_id);
72 + $includeArchived = $request->getSafe('include_archived', 'boolval', false);
49 73
50 74 // Get stage IDs
51 - $stageIds = $this->getStageIdsByBoard($board_id);
75 + $stageIds = $this->getStageIdsByBoard($board_id, $includeArchived);
52 76
53 77 // Fetch tasks for the board
54 - $tasks = Task::with(['assignees', 'labels', 'watchers', 'taskCustomFields'])
78 + $tasksQuery = Task::with(['assignees', 'labels', 'watchers', 'taskCustomFields'])
55 79 ->where('board_id', $board_id)
56 - ->whereNull('archived_at')
57 80 ->whereNull('parent_id')
58 81 ->whereIn('stage_id', $stageIds)
59 - ->orderBy('due_at', 'ASC')
60 - ->get();
82 + ->orderBy('due_at', 'ASC');
61 83
84 + if (!$includeArchived) {
85 + $tasksQuery->whereNull('archived_at');
86 + }
87 +
88 + $tasks = $tasksQuery->get();
89 +
62 90 // Process each task
63 91 $this->processTasks($tasks, $board);
64 92
93 + if ($board->type === 'roadmap') {
94 + $this->taskService->loadIdeaVoteStatistics($tasks);
95 + }
96 +
65 97 return [
66 98 'tasks' => $tasks,
99 + 'synced_at' => $syncStartedAt,
67 100 ];
68 101 }
69 102
70 - 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)
71 105 {
106 + $syncStartedAt = current_time('mysql');
107 + $board_id = absint($board_id);
72 108 $board = Board::findOrFail($board_id);
109 + $includeArchived = $request->getSafe('include_archived', 'boolval', false);
73 110
74 111 // Get stage IDs
75 - $stageIds = $this->getStageIdsByBoard($board_id);
112 + $stageIds = $this->getStageIdsByBoard($board_id, $includeArchived);
113 + $stageTaskCounts = $this->getStageTaskCounts($board_id, $stageIds, $includeArchived);
76 114
77 115 // Initialize tasks array
78 116 $tasks = [];
117 + $paginationByStage = [];
79 118
80 119 // Fetch and process tasks for each stage
81 120 foreach ($stageIds as $stageId) {
82 - $stageTasks = Task::with(['assignees', 'labels', 'watchers', 'taskCustomFields'])
83 - ->where('board_id', $board_id)
84 - ->where('stage_id', $stageId)
85 - ->whereNull('archived_at')
86 - ->whereNull('parent_id')
121 + $stageTasks = $this->makeStageTasksQuery($board_id, $stageId, $includeArchived)
87 122 ->orderBy('position', 'ASC')
88 123 ->limit(20)
89 124 ->get();
90 125
91 - // Process each stage's tasks
92 - $this->processTasks($stageTasks, $board);
93 - $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 + ];
94 145 }
95 146
147 + $this->processTasks($tasks, $board, [
148 + 'includeContact' => false,
149 + 'includeObserverState' => false,
150 + 'includeRoadmapPopularity' => false,
151 + ]);
152 +
96 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 [
97 233 'tasks' => $tasks,
98 234 ];
99 235 }
100 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 +
101 329 /**
102 330 * Get Stage IDs by Board ID.
103 331 *
104 332 * @param int $board_id
@@ -103,44 +331,155 @@
103 331 *
104 332 * @param int $board_id
105 333 * @return array
106 334 */
107 - private function getStageIdsByBoard($board_id)
335 + private function getStageIdsByBoard($board_id, $includeArchived = false)
108 336 {
109 - return Stage::where('board_id', $board_id)
110 - ->whereNull('archived_at')
111 - ->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 + })
112 385 ->toArray();
113 386 }
114 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 +
115 416 /**
116 417 * Process and append extra information for each task.
117 418 *
118 419 * @param \Illuminate\Database\Eloquent\Collection $tasks
119 420 * @param \App\Models\Board $board
421 + * @param array $options
120 422 */
121 - private function processTasks($tasks, $board)
423 + private function processTasks($tasks, $board, $options = [])
122 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 +
123 432 foreach ($tasks as $task) {
433 + $taskIds[] = (int) $task->id;
434 + }
435 +
436 + $unreadNotificationCounts = $this->notificationService->getUnreadNotificationCountsByTaskIds($taskIds);
437 +
438 + foreach ($tasks as $task) {
124 439 $task->isOverdue = $task->isOverdue();
125 440 $task->isUpcoming = $task->upcoming();
126 - $task->contact = Helper::crm_contact($task->crm_contact_id); // Handle possible null contact
127 - $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 + }
128 447 $task->assignees = Helper::sanitizeUserCollections($task->assignees);
129 448 $task->watchers = Helper::sanitizeUserCollections($task->watchers);
130 - $task->notifications = $this->notificationService->getUnreadNotificationsOfTasks($task);
449 + $task->notifications = $unreadNotificationCounts[(int) $task->id] ?? 0;
131 450
132 451 // If the board type is 'roadmap', calculate popularity
133 - if ($board->type === 'roadmap') {
452 + if ($includeRoadmapPopularity && $board->type === 'roadmap') {
134 453 $task->popular = $task->getPopularCount();
135 454 }
136 455 }
137 456 }
138 457
139 -
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 + */
140 465 public function create(Request $request, $board_id)
141 466 {
142 - $taskData = $this->taskSanitizeAndValidate($request->get('task'), [
467 + $board_id = absint($board_id);
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, [
143 482 'title' => 'required|string',
144 483 'board_id' => 'required|numeric',
145 484 'stage_id' => 'required|numeric',
146 485 'priority' => 'nullable|string',
@@ -148,17 +487,28 @@
148 487 'is_template' => 'string',
149 488 ]);
150 489
151 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 +
152 499 if ($taskData['board_id'] != $board_id) {
153 - throw new \Exception(__('Board id is not valid', 'fluent-boards'));
500 + throw new \Exception(esc_html__('Board id is not valid', 'fluent-boards'));
154 501 }
155 502
156 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');
157 507
158 508 return $this->sendSuccess([
159 509 'task' => $task,
160 - 'message' => __('Task has been successfully created', 'fluent-boards'),
510 + 'message' => $message,
161 511 'updatedTasks' => $this->taskService->getLastOneMinuteUpdatedTasks($task->board_id)
162 512 ], 201);
163 513 } catch (\Exception $e) {
164 514 return $this->sendError($e->getMessage(), 400);
@@ -166,20 +516,22 @@
166 516 }
167 517
168 518 public function find($board_id, $task_id)
169 519 {
520 + $board_id = absint($board_id);
521 + $task_id = absint($task_id);
170 522 try {
171 523
172 524 $stageService = new StageService();
173 525
174 - $task = Task::findOrFail($task_id);
526 + $task = $this->taskService->findTaskOnBoard($task_id, $board_id);
175 527
176 528 if (isset($task->parent_id)) {
177 - $task = Task::findOrFail($task->parent_id);
529 + $task = $this->taskService->findTaskOnBoard($task->parent_id, $board_id, false);
178 530 }
179 531
180 532 if(!$task) {
181 - throw new \Exception(__('Task not found', 'fluent-boards'));
533 + throw new \Exception(esc_html__('Task not found', 'fluent-boards'));
182 534 }
183 535
184 536 if (defined('FLUENT_BOARDS_PRO')) {
185 537 $task->load(['attachments']);
@@ -184,11 +536,12 @@
184 536 if (defined('FLUENT_BOARDS_PRO')) {
185 537 $task->load(['attachments']);
186 538 }
187 539
188 - $task->load(['board', 'stage', 'labels', 'assignees']);
540 + $task->load(['board', 'stage', 'labels', 'assignees','watchers']);
189 541
190 542 $task->assignees = Helper::sanitizeUserCollections($task->assignees);
543 + $task->watchers = Helper::sanitizeUserCollections($task->watchers);
191 544
192 545 $task->isOverdue = $task->isOverdue();
193 546 $task->contact = Task::lead_contact($task->crm_contact_id);
194 547 $task->board->stages = $stageService->stagesByBoardId($board_id);
@@ -203,8 +556,10 @@
203 556 return [
204 557 'task' => $task
205 558 ];
206 559
560 + } catch (ModelNotFoundException $e) {
561 + throw $e;
207 562 } catch (\Exception $e ) {
208 563 return $this->sendError($e->getMessage(), 400);
209 564 }
210 565
@@ -212,9 +567,10 @@
212 567 }
213 568
214 569 public function getStageType(Request $request)
215 570 {
216 - $stage = Stage::findOrFail($request->stage_id);
571 + $stage_id = $request->getSafe('stage_id', 'intval');
572 + $stage = Stage::findOrFail($stage_id);
217 573
218 574 return [
219 575 'stage' => $stage,
220 576 ];
@@ -221,10 +577,13 @@
221 577 }
222 578
223 579 public function getActivities(Request $request, $board_id, $task_id)
224 580 {
225 - $filter = $request->getSafe('filter');
581 + $board_id = absint($board_id);
582 + $task_id = absint($task_id);
583 + $filter = $request->getSafe('filter', 'sanitize_text_field');
226 584 $per_page = 15; // Apparently, let's use a fixed number of items per page.
585 + $this->taskService->findTaskOnBoard($task_id, $board_id);
227 586
228 587 return [
229 588 'activities' => $this->taskService->getActivities($task_id, $per_page, $filter)
230 589 ];
@@ -232,27 +591,236 @@
232 591 }
233 592
234 593 public function getArchivedTasks(Request $request, $board_id)
235 594 {
236 - $tasks = $this->taskService->getArchivedTasks($request->all(), $board_id);
595 + $board_id = absint($board_id);
596 + // Sanitize request parameters before passing to service
597 + $sanitizedParams = [
598 + 'per_page' => $request->getSafe('per_page', 'intval', 20),
599 + 'page' => $request->getSafe('page', 'intval', 1),
600 + 'query' => $request->getSafe('searchInput', 'sanitize_text_field', '')
601 + ];
237 602
603 +
604 + $tasks = $this->taskService->getArchivedTasks($sanitizedParams, $board_id);
605 +
238 606 foreach ($tasks as $task) {
239 607 $task->assignees = Helper::sanitizeUserCollections($task->assignees);
240 608 }
241 609
610 +
242 611 return [
243 612 'tasks' => $tasks
244 613 ];
245 614 }
246 615
616 + public function bulkRestoreTasks(Request $request, $board_id)
617 + {
618 + $board_id = absint($board_id);
619 + try {
620 + $rawTaskIds = $request->getSafe('task_ids');
621 + // Sanitize task_ids array to integers
622 + $task_ids = [];
623 + if (is_array($rawTaskIds)) {
624 + $task_ids = array_filter(array_map('intval', $rawTaskIds));
625 + }
626 +
627 + if (empty($task_ids)) {
628 + return $this->response->sendError('No task IDs provided', 400);
629 + }
630 +
631 + $tasks = Task::where('board_id', $board_id)
632 + ->whereIn('id', $task_ids)
633 + ->whereNotNull('archived_at')
634 + ->get();
635 +
636 + if ($tasks->isEmpty()) {
637 + return $this->response->sendError('No archived tasks found with provided IDs', 404);
638 + }
639 +
640 + $restored_count = 0;
641 + $failed_count = 0;
642 + $failed_tasks = [];
643 +
644 + foreach ($tasks as $task) {
645 + try {
646 + // Use TaskService to properly restore the task (same as single task restoration)
647 + $this->taskService->updateTaskProperty('archived_at', null, $task);
648 +
649 + // Prepare task for response (same as single task update)
650 + $task->isOverdue = $task->isOverdue();
651 + $task->isUpcoming = $task->upcoming();
652 + $task->contact = Helper::crm_contact($task->crm_contact_id);
653 + $task->is_watching = $task->isWatching();
654 + $task->assignees = Helper::sanitizeUserCollections($task->assignees);
655 +
656 + $restored_count++;
657 + } catch (\Exception $e) {
658 + // Track failed tasks but continue processing others
659 + $failed_count++;
660 + $failed_tasks[] = [
661 + 'id' => $task->id,
662 + 'title' => $task->title,
663 + 'error' => $e->getMessage()
664 + ];
665 + }
666 + }
667 +
668 + // Get recently updated tasks (same as single task operations)
669 + $recentlyUpdatedTasks = $this->taskService->getLastOneMinuteUpdatedTasks($board_id);
670 +
671 + // Build response with detailed results
672 + $response = [
673 + 'restored_count' => $restored_count,
674 + 'failed_count' => $failed_count,
675 + 'updatedTasks' => $recentlyUpdatedTasks
676 + ];
677 +
678 + if ($failed_count > 0) {
679 + $response['failed_tasks'] = $failed_tasks;
680 + if ($restored_count > 0) {
681 + $response['message'] = $restored_count . ' ' . ($restored_count === 1 ? 'task' : 'tasks') . ' restored successfully, ' . $failed_count . ' ' . ($failed_count === 1 ? 'task' : 'tasks') . ' failed';
682 + } else {
683 + $response['message'] = 'Failed to restore ' . $failed_count . ' ' . ($failed_count === 1 ? 'task' : 'tasks');
684 + }
685 + } else {
686 + $response['message'] = $restored_count . ' ' . ($restored_count === 1 ? 'task' : 'tasks') . ' restored successfully';
687 + }
688 +
689 + return $this->response->sendSuccess($response, 200);
690 +
691 + } catch (\Exception $e) {
692 + return $this->response->sendError($e->getMessage(), 500);
693 + }
694 + }
695 +
696 + public function bulkDeleteTasks(Request $request, $board_id)
697 + {
698 + $board_id = absint($board_id);
699 + try {
700 + $rawTaskIds = $request->getSafe('task_ids');
701 + // Sanitize task_ids array to integers
702 + $task_ids = [];
703 + if (is_array($rawTaskIds)) {
704 + $task_ids = array_filter(array_map('intval', $rawTaskIds));
705 + }
706 +
707 + if (empty($task_ids)) {
708 + return $this->response->sendError('No task IDs provided', 400);
709 + }
710 +
711 + $tasks = Task::where('board_id', $board_id)
712 + ->whereIn('id', $task_ids)
713 + ->get();
714 +
715 + if ($tasks->isEmpty()) {
716 + return $this->response->sendError('No tasks found with provided IDs', 404);
717 + }
718 +
719 + $deleted_count = 0;
720 + $failed_count = 0;
721 + $failed_tasks = [];
722 + $options = null;
723 +
724 + foreach ($tasks as $task) {
725 + try {
726 + // This handles all cleanup: subtasks, watchers, assignees, labels, notifications, attachments, etc.
727 + $this->taskService->deleteTaskForBulk($task);
728 + $deleted_count++;
729 + } catch (\Exception $e) {
730 + // Track failed tasks but continue processing others
731 + $failed_count++;
732 + $failed_tasks[] = [
733 + 'id' => $task->id,
734 + 'title' => $task->title,
735 + 'error' => $e->getMessage()
736 + ];
737 + }
738 + }
739 +
740 + // Get recently updated tasks (same as single task operations)
741 + $recentlyUpdatedTasks = $this->taskService->getLastOneMinuteUpdatedTasks($board_id);
742 +
743 + // Build response with detailed results
744 + $response = [
745 + 'deleted_count' => $deleted_count,
746 + 'failed_count' => $failed_count,
747 + 'updatedTasks' => $recentlyUpdatedTasks
748 + ];
749 +
750 + if ($failed_count > 0) {
751 + $response['failed_tasks'] = $failed_tasks;
752 + if ($deleted_count > 0) {
753 + $response['message'] = $deleted_count . ' ' . ($deleted_count === 1 ? 'task' : 'tasks') . ' deleted successfully, ' . $failed_count . ' ' . ($failed_count === 1 ? 'task' : 'tasks') . ' failed';
754 + } else {
755 + $response['message'] = 'Failed to delete ' . $failed_count . ' ' . ($failed_count === 1 ? 'task' : 'tasks');
756 + }
757 + } else {
758 + $response['message'] = $deleted_count . ' ' . ($deleted_count === 1 ? 'task' : 'tasks') . ' deleted successfully';
759 + }
760 +
761 + return $this->response->sendSuccess($response, 200);
762 +
763 + } catch (\Exception $e) {
764 + return $this->response->sendError($e->getMessage(), 500);
765 + }
766 + }
767 +
247 768 public function updateTaskProperties(Request $request, $board_id, $task_id)
248 769 {
770 + $board_id = absint($board_id);
771 + $task_id = absint($task_id);
772 + //Properties in col: settings, assignees,crm_contact_id, archived_at(AUTO_SET_TIMESTAMP) , status, title, description, priority, is_watching, is_template
249 773 $col = $request->getSafe('property', 'sanitize_text_field');
250 - $value = $request->get('value');
774 + if ($col === 'description') {
775 + $value = $request->getSafe('value', 'fluent_boards_sanitize_description');
776 + } elseif ($col === 'settings' || $col === 'assignees') {
777 + $value = $request->get('value');
778 + if (is_array($value) && isset($value['cover']) && is_array($value['cover'])) {
779 + if (isset($value['cover']['backgroundColor'])) {
780 + $value['cover']['backgroundColor'] = sanitize_text_field($value['cover']['backgroundColor']);
781 + }
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 + }
794 + } else {
795 + $value = $request->getSafe('value', 'sanitize_text_field');
796 + }
251 797
252 798 $validatedData = $this->updateTaskPropValidationAndSanitation($col, $value);
253 - $task = Task::with(['board', 'labels', 'assignees'])->findOrFail($task_id);
799 + $task = $this->taskService->findTaskOnBoard($task_id, $board_id);
800 + $task->load(['board', 'labels', 'assignees']);
254 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 +
818 + $oldDateValue = null;
819 + if (in_array($col, ['due_at', 'started_at'])) {
820 + $oldDateValue = $task->{$col};
821 + }
822 +
255 823 if ($task->parent_id && !$task->board_id) {
256 824 $task->board_id = $board_id;
257 825 $task->save();
258 826 }
@@ -263,11 +831,32 @@
263 831 $task->contact = Helper::crm_contact($task->crm_contact_id);
264 832 $task->is_watching = $task->isWatching();
265 833 $task->assignees = Helper::sanitizeUserCollections($task->assignees);
266 834
835 + if ($col === 'is_watching') {
836 + $task->load('watchers');
837 + $task->watchers = Helper::sanitizeUserCollections($task->watchers);
838 + }
839 +
840 + if ($task->parent_id) {
841 + $task->subtask_group_id = TaskMeta::where('task_id', $task->id)->where('key', Constant::SUBTASK_GROUP_CHILD)->value('value');
842 + }
843 +
267 844 // A recent update to a task might impact other tasks on the board.
268 845 $updatedTasks = $this->taskService->getLastOneMinuteUpdatedTasks($board_id);
846 + $taskExists = false;
847 + foreach ($updatedTasks as $index => $updatedTask) {
848 + if ($updatedTask->id === $task->id) {
849 + $updatedTasks[$index] = $task; // Replace the existing task
850 + $taskExists = true;
851 + break;
852 + }
853 + }
269 854
855 + if (!$taskExists) {
856 + $updatedTasks[] = $task;
857 + }
858 +
270 859 return [
271 860 'message' => __('Task has been updated', 'fluent-boards'),
272 861 'task' => $task,
273 862 'updatedTasks' => $updatedTasks
@@ -273,35 +862,167 @@
273 862 'updatedTasks' => $updatedTasks
274 863 ];
275 864 }
276 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 +
277 886 public function updateTaskDates(Request $request, $board_id, $task_id)
278 887 {
279 - $task = Task::findOrFail($task_id);
888 + $board_id = absint($board_id);
889 + $task_id = absint($task_id);
890 + $task = Task::where('id', $task_id)->where('board_id', $board_id)->firstOrFail();
891 + $payload = $request->all();
280 892
281 - $startAt = $request->getSafe('started_at', 'sanitize_text_field', NULL);
282 - $dueAt = $request->getSafe('due_at', 'sanitize_text_field', NULL);
893 + // Capture old dates before updating
894 + $oldDates = [
895 + 'due_at' => $task->due_at,
896 + 'started_at' => $task->started_at,
897 + ];
283 898
284 - if ($startAt && $dueAt) {
899 +
900 +
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) {
285 916 if (strtotime($startAt) > strtotime($dueAt)) {
286 - $startAt = gmdate('Y-m-d 00:00:00', strtotime($dueAt));
917 + $startAt = substr($dueAt, 0, 10) . ' 00:00:00';
287 918 }
288 919 }
289 920
290 - $task = $this->taskService->updateTaskProperty('started_at', $startAt, $task);
291 - $task = $this->taskService->updateTaskProperty('due_at', $dueAt, $task);
921 + if ($hasStartAt) {
922 + $task = $this->taskService->updateTaskProperty('started_at', $startAt, $task);
923 + }
292 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 +
940 + $datesChanged = false;
941 + $changedDates = [];
942 +
943 + if ($oldDates['due_at'] !== $task->due_at) {
944 + $datesChanged = true;
945 + $changedDates['due_at'] = $oldDates['due_at'];
946 + }
947 +
948 + if ($oldDates['started_at'] !== $task->started_at) {
949 + $datesChanged = true;
950 + $changedDates['started_at'] = $oldDates['started_at'];
951 + }
952 +
953 + if ($datesChanged) {
954 + do_action('fluent_boards/task_date_changed', $task, $changedDates);
955 + }
956 +
293 957 return [
294 958 'task' => $task,
295 - 'message' => __('Dates has been updated', 'fluent-boards'),
959 + 'message' => __('Dates have been updated', 'fluent-boards'),
296 960 'updatedTasks' => $this->taskService->getLastOneMinuteUpdatedTasks($board_id),
297 961 ];
298 962 }
299 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 +
300 1019 public function updateTaskCoverPhoto(Request $request, $board_id, $task_id)
301 1020 {
302 - $imagePath = $request->thumbnail;
303 - $task = $this->taskService->taskCoverPhotoUpdate($task_id, $imagePath);
1021 + $board_id = absint($board_id);
1022 + $task_id = absint($task_id);
1023 + $imagePath = $request->getSafe('thumbnail', 'sanitize_text_field');
1024 + $task = $this->taskService->taskCoverPhotoUpdate($task_id, $imagePath, $board_id);
304 1025
305 1026 return [
306 1027 'message' => __('Task cover photo has been updated', 'fluent-boards'),
307 1028 'task' => $task,
@@ -310,17 +1031,22 @@
310 1031 }
311 1032
312 1033 public function taskStatusUpdate(Request $request, $board_id, $task_id)
313 1034 {
1035 + $board_id = absint($board_id);
1036 + $task_id = absint($task_id);
1037 + $integrationType = $request->getSafe('integrationType', 'sanitize_text_field');
314 1038 return [
315 1039 'message' => __('Task status has been updated', 'fluent-boards'),
316 - 'task' => $this->taskService->taskStatusUpdate($task_id, $request->integrationType),
1040 + 'task' => $this->taskService->taskStatusUpdate($task_id, $integrationType, $board_id),
317 1041 ];
318 1042 }
319 1043
320 1044 public function deleteTask($board_id, $task_id)
321 1045 {
322 - $task = Task::findOrFail($task_id);
1046 + $board_id = absint($board_id);
1047 + $task_id = absint($task_id);
1048 + $task = $this->taskService->findTaskOnBoard($task_id, $board_id);
323 1049 $options = null;
324 1050 //if we need to do something before a task is deleted
325 1051 do_action('fluent_boards/before_task_deleted', $task, $options);
326 1052
@@ -338,8 +1064,39 @@
338 1064
339 1065 return $this->validate($data, $rules);
340 1066 }
341 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 +
342 1099 private function updateTaskPropValidationAndSanitation($col, $value)
343 1100 {
344 1101 $rules = [
345 1102 'title' => 'required|string',
@@ -362,9 +1119,9 @@
362 1119 'log_minutes' => 'nullable|integer|unsigned',
363 1120 'last_completed' => 'nullable|date',
364 1121 'assignees' => 'nullable|integer',
365 1122 'archived_at' => 'nullable|string',
366 - 'is_watching' => 'nullable|string',
1123 + 'is_watching' => 'nullable',
367 1124 'is_template' => 'string',
368 1125 'last_completed_at' => 'nullable',
369 1126 'settings' => 'nullable|array',
370 1127 ];
@@ -381,8 +1138,11 @@
381 1138 }
382 1139
383 1140 return [$col => $sanitizedAndValidatedValue];
384 1141 }
1142 + if ('is_watching' == $col && is_array($value)) {
1143 + return [$col => $value];
1144 + }
385 1145 $data = Helper::sanitizeTask([$col => $value]);
386 1146
387 1147 return $this->validate($data, [
388 1148 $col => $rule,
@@ -387,22 +1147,22 @@
387 1147 return $this->validate($data, [
388 1148 $col => $rule,
389 1149 ]);
390 1150 }
391 - }
392 1151
393 - public function getLabelsByTask($task_id)
394 - {
395 - $labels = $this->taskService->getLabelsByTask($task_id);
396 -
397 - return $this->sendSuccess([
398 - 'labels' => $labels,
399 - ], 200);
1152 + // If the column is not found in the rules array, throw an exception
1153 + // translators: %s is the property name
1154 + throw new \Exception(sprintf(esc_html__('Invalid property: %s', 'fluent-boards'), esc_html($col)));
400 1155 }
401 1156
402 1157 public function getStageByTask($task_id)
403 1158 {
404 - $stage = $this->taskService->getStageByTask($task_id);
1159 + $task_id = absint($task_id);
1160 + try {
1161 + $stage = $this->taskService->getStageByTask($task_id);
1162 + } catch (\Exception $e) {
1163 + return $this->sendError($e->getMessage(), 404);
1164 + }
405 1165
406 1166 return [
407 1167 'stage' => $stage,
408 1168 ];
@@ -409,8 +1169,10 @@
409 1169 }
410 1170
411 1171 public function assignYourselfInTask($board_id, $task_id)
412 1172 {
1173 + $board_id = absint($board_id);
1174 + $task_id = absint($task_id);
413 1175 $task = $this->taskService->assignYourselfInTask($board_id, $task_id);
414 1176 $task->is_watching = $task->isWatching();
415 1177
416 1178 return [
@@ -419,8 +1181,10 @@
419 1181 }
420 1182
421 1183 public function detachYourselfFromTask($board_id, $task_id)
422 1184 {
1185 + $board_id = absint($board_id);
1186 + $task_id = absint($task_id);
423 1187 $task = $this->taskService->detachYourselfFromTask($board_id, $task_id);
424 1188 $task->assignees = Helper::sanitizeUserCollections($task->assignees);
425 1189 $task->is_watching = $task->isWatching();
426 1190
@@ -437,9 +1201,11 @@
437 1201 }
438 1202
439 1203 public function moveTaskToNextStage($board_id, $task_id)
440 1204 {
441 - $task = $this->taskService->moveTaskToNextStage($task_id);
1205 + $board_id = absint($board_id);
1206 + $task_id = absint($task_id);
1207 + $task = $this->taskService->moveTaskToNextStage($task_id, $board_id);
442 1208
443 1209 return [
444 1210 'task' => $task
445 1211 ];
@@ -449,29 +1215,71 @@
449 1215 * @throws \Exception
450 1216 */
451 1217 public function moveTask(Request $request, $board_id, $task_id)
452 1218 {
453 - $task = Task::findOrFail($task_id);
1219 + $board_id = absint($board_id);
1220 + $task_id = absint($task_id);
1221 + $task = $this->taskService->findTaskOnBoard($task_id, $board_id);
454 1222 $oldStageId = $task->stage_id;
455 1223 $newStageId = $request->getSafe('newStageId', 'intval');
456 1224 $newIndex = $request->getSafe('newIndex', 'intval');
457 1225 $newBoardId = $request->getSafe('newBoardId', 'intval');
1226 + $prevTaskId = $request->getSafe('prevTaskId', 'intval');
1227 + $nextTaskId = $request->getSafe('nextTaskId', 'intval');
458 1228
459 1229 if ((!is_numeric($newStageId) || $newStageId == 0)) {
460 - throw new \Exception(__('Invalid Stage', 'fluent-boards'));
1230 + throw new \Exception(esc_html__('Invalid Stage', 'fluent-boards'));
461 1231 }
462 - if ((!is_numeric($newIndex) || $newIndex == 0)) {
463 - throw new \Exception(__('Invalid Value', 'fluent-boards'));
1232 +
1233 + if (!$prevTaskId && !$nextTaskId && (!is_numeric($newIndex) || $newIndex == 0)) {
1234 + throw new \Exception(esc_html__('Invalid Value', 'fluent-boards'));
464 1235 }
1236 +
465 1237 if ($newBoardId) {
466 1238 if ((!is_numeric($newBoardId) || $newBoardId == 0)) {
467 - throw new \Exception(__('Invalid Board', 'fluent-boards'));
1239 + throw new \Exception(esc_html__('Invalid Board', 'fluent-boards'));
468 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) {
469 1261 $task = $this->taskService->changeBoardByTask($task, $newBoardId);
1262 + // Load relationships to ensure frontend gets updated data after board move
1263 + $task->load(['assignees', 'labels', 'watchers', 'attachments']);
470 1264 }
471 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 +
472 1273 $task->stage_id = $newStageId;
473 - $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 + }
474 1282
475 1283 if ($oldStageId != $newStageId) {
476 1284
477 1285 $this->taskService->manageDefaultAssignees($task, $newStageId);
@@ -491,12 +1299,12 @@
491 1299 }
492 1300
493 1301 do_action('fluent_boards/task_updated', $task, 'position');
494 1302
495 - $updatedTasks = $this->taskService->getLastOneMinuteUpdatedTasks($task->board_id, $request->get('last_boards_updated'));
1303 + $lastBoardsUpdated = $request->getSafe('last_boards_updated', 'sanitize_text_field');
1304 + $updatedTasks = $this->taskService->getLastOneMinuteUpdatedTasks($task->board_id, $lastBoardsUpdated);
496 1305
497 1306 return [
498 - 'new_position' => $task,
499 1307 'message' => __('Task has been updated', 'fluent-boards'),
500 1308 'task' => $task,
501 1309 'updatedTasks' => $updatedTasks,
502 1310 'last_updated' => current_time('mysql')
@@ -512,15 +1320,17 @@
512 1320 * @return \WP_REST_Response The response containing paginated comments and activities, total count, current page, and items per page.
513 1321 */
514 1322 public function getCommentsAndActivities( Request $request, $board_id, $task_id)
515 1323 {
1324 + $board_id = absint($board_id);
1325 + $task_id = absint($task_id);
516 1326 try {
517 1327 // Pagination parameters
518 - $page = $request->get('page', 1);
519 - $perPage = $request->get('per_page', 10);
520 - $filter = $request->get('filter', 'newest'); // Filter for comments and activities
521 -
522 - $commentsAndActivities = $this->taskService->getCommentsAndActivities($task_id, $perPage, $page, $filter);
1328 + $page = $request->getSafe('page', 'intval', 1);
1329 + $perPage = $request->getSafe('per_page', 'intval', 10);
1330 + $filter = $request->getSafe('filter', 'sanitize_text_field', 'newest'); // Filter for comments and activities
1331 + $feedType = $request->getSafe('feed_type', 'sanitize_text_field', 'all');
1332 + $commentsAndActivities = $this->taskService->getCommentsAndActivities($task_id, $perPage, $page, $filter, $board_id, $feedType);
523 1333 // Return the response with the task, paginated comments and activities, total count, current page, and items per page
524 1334 return $this->sendSuccess([
525 1335 'comments_and_activities' => $commentsAndActivities,
526 1336 ]);
@@ -538,14 +1348,35 @@
538 1348 as_enqueue_async_action('fluent_boards/one_time_schedule_send_email_for_stage_change', [$taskId, $usersToSendEmail, $current_user_id], 'fluent-boards');
539 1349 }
540 1350 public function getAssociatedTasks($associated_id)
541 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 +
1356 + $associated_id = absint($associated_id);
542 1357 return [
543 - 'tasks' => $this->taskService->getAssociatedTasks($associated_id)
1358 + 'tasks' => $this->taskService->getAssociatedTasks($associated_id, get_current_user_id())
544 1359 ];
545 1360 }
546 1361
547 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 + /**
548 1379 * @param Request $request
549 1380 * @param $board_id
550 1381 * @param $task_id
551 1382 * @return \WP_REST_Response
@@ -551,9 +1382,12 @@
551 1382 * @return \WP_REST_Response
552 1383 */
553 1384 public function uploadMediaFileFromWpEditor(Request $request, $board_id, $task_id)
554 1385 {
1386 + $board_id = absint($board_id);
1387 + $task_id = absint($task_id);
555 1388 try {
1389 + $this->taskService->findTaskOnBoard($task_id, $board_id);
556 1390
557 1391
558 1392 $file = Arr::get($request->files(), 'file')->toArray();
559 1393 (new \FluentBoards\App\Services\UploadService)->validateFile($file);
@@ -581,12 +1415,39 @@
581 1415 return $this->sendError($e->getMessage(), 400);
582 1416 }
583 1417 }
584 1418
1419 + public function createTaskFromImage(Request $request, $board_id)
1420 + {
1421 + $board_id = absint($board_id);
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 + }
585 1426
1427 + $file = Arr::get($request->files(), 'file')->toArray();
1428 + (new \FluentBoards\App\Services\UploadService)->validateFile($file);
1429 +
1430 + $uploadInfo = UploadService::handleFileUpload( $request->files(), $board_id);
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 +
1436 + return $this->sendSuccess([
1437 + 'task' => $task,
1438 + 'updatedTasks' => $this->taskService->getLastOneMinuteUpdatedTasks($board_id),
1439 + 'message' => $message,
1440 + ], 200);
1441 +
1442 + }
1443 +
586 1444 public function handleTaskCoverImageUpload(Request $request, $board_id, $task_id)
587 1445 {
1446 + $board_id = absint($board_id);
1447 + $task_id = absint($task_id);
588 1448 try {
1449 + $task = $this->taskService->findTaskOnBoard($task_id, $board_id);
589 1450
590 1451 $file = Arr::get($request->files(), 'file')->toArray();
591 1452 (new \FluentBoards\App\Services\UploadService)->validateFile($file);
592 1453
@@ -601,12 +1462,12 @@
601 1462 $fileUploadedData['full_url'] = $mediaData['full_url'];
602 1463 $fileUploadedData->save();
603 1464 }
604 1465
605 - $task = Task::find($task_id);
606 1466 $settings = $task->settings;
607 1467 $this->taskService->deleteTaskCoverImage($settings);
608 1468 $publicUrl = (new CommentService())->createPublicUrl($fileUploadedData, $board_id);
1469 +
609 1470 $settings['cover'] = [
610 1471 'imageId' => $fileUploadedData['id'],
611 1472 'backgroundImage' => $publicUrl,
612 1473 ];
@@ -624,10 +1485,12 @@
624 1485 }
625 1486 }
626 1487 public function removeTaskCover($board_id, $task_id)
627 1488 {
1489 + $board_id = absint($board_id);
1490 + $task_id = absint($task_id);
628 1491 try {
629 - $task = Task::find($task_id);
1492 + $task = $this->taskService->findTaskOnBoard($task_id, $board_id);
630 1493 $settings = $task->settings;
631 1494 $this->taskService->deleteTaskCoverImage($settings);
632 1495 unset($settings['cover']);
633 1496 $task->settings = $settings;
@@ -637,7 +1500,313 @@
637 1500 'message' => __('Task Cover removed successfully', 'fluent-boards'),
638 1501 ]);
639 1502 } catch (\Exception $e) {
640 1503 return $this->sendError($e->getMessage(), 400);
1504 + }
1505 + }
1506 +
1507 + /**
1508 + * Get task tabs configuration
1509 + */
1510 + public function getTaskTabsConfig()
1511 + {
1512 + $default_config = [
1513 + [
1514 + 'name' => 'due_today',
1515 + 'label' => __('Due Today', 'fluent-boards'),
1516 + 'visible' => 'true',
1517 + 'order' => 1
1518 + ],
1519 + [
1520 + 'name' => 'assigned',
1521 + 'label' => __('Assigned', 'fluent-boards'),
1522 + 'visible' => 'true',
1523 + 'order' => 2
1524 + ],
1525 + [
1526 + 'name' => 'upcoming',
1527 + 'label' => __('Upcoming', 'fluent-boards'),
1528 + 'visible' => 'true',
1529 + 'order' => 3
1530 + ],
1531 + [
1532 + 'name' => 'overdue',
1533 + 'label' => __('Overdue', 'fluent-boards'),
1534 + 'visible' => 'true',
1535 + 'order' => 4
1536 + ],
1537 + [
1538 + 'name' => 'mentioned',
1539 + 'label' => __('Mentioned', 'fluent-boards'),
1540 + 'visible' => 'true',
1541 + 'order' => 5
1542 + ],
1543 + [
1544 + 'name' => 'completed',
1545 + 'label' => __('Completed', 'fluent-boards'),
1546 + 'visible' => 'true',
1547 + 'order' => 6
1548 + ],
1549 + [
1550 + 'name' => 'others',
1551 + 'label' => __('Others', 'fluent-boards'),
1552 + 'visible' => 'true',
1553 + 'order' => 7
1554 + ]
1555 + ];
1556 + $availableTabNames = array_column($default_config, 'name');
1557 +
1558 + $existConfig = Meta::where('object_id', get_current_user_id())->where('key', Constant::FBS_TASK_TABS_CONFIG)->first();
1559 + $config = $default_config;
1560 +
1561 + if ($existConfig && !empty($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 +
1573 + $existingNames = array_column($config, 'name');
1574 + $missingTabs = [];
1575 + foreach ($default_config as $defaultTab) {
1576 + if (!in_array($defaultTab['name'], $existingNames)) {
1577 + $missingTabs[] = $defaultTab;
1578 + }
1579 + }
1580 +
1581 + if (!empty($missingTabs)) {
1582 + $newConfig = [];
1583 + $order = 1;
1584 + $addedDueToday = false;
1585 + $addedAssigned = false;
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 +
1597 + if ($tab['name'] === 'upcoming' && !$addedAssigned) {
1598 + $assignedTab = array_filter($missingTabs, fn($t) => $t['name'] === 'assigned');
1599 + if (!empty($assignedTab)) {
1600 + $assignedTab = reset($assignedTab);
1601 + $assignedTab['order'] = $order++;
1602 + $newConfig[] = $assignedTab;
1603 + $addedAssigned = true;
1604 + }
1605 + }
1606 + $tab['order'] = $order++;
1607 + $newConfig[] = $tab;
1608 + }
1609 + foreach ($missingTabs as $missingTab) {
1610 + if (!in_array($missingTab['name'], ['assigned', 'due_today'], true)) {
1611 + $missingTab['order'] = $order++;
1612 + $newConfig[] = $missingTab;
1613 + }
1614 + }
1615 + $config = $newConfig;
1616 + $configChanged = true;
1617 + }
1618 +
1619 + if ($configChanged) {
1620 + $existConfig->value = $config;
1621 + $existConfig->save();
1622 + }
1623 + }
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 +
1642 + return $this->sendSuccess([
1643 + 'data' => $config
1644 + ]);
1645 + }
1646 +
1647 + /**
1648 + * Save task tabs configuration
1649 + */
1650 + public function saveTaskTabsConfig(Request $request)
1651 + {
1652 + $rawConfig = $request->getSafe('tabs');
1653 +
1654 + if (empty($rawConfig) || !is_array($rawConfig)) {
1655 + return $this->sendError([
1656 + 'message' => __('Invalid data format', 'fluent-boards')
1657 + ], 400);
1658 + }
1659 +
1660 + // Sanitize config array
1661 + $config = [];
1662 + foreach ($rawConfig as $tab) {
1663 + if (!is_array($tab)) {
1664 + continue;
1665 + }
1666 + $sanitizedTab = [
1667 + 'name' => isset($tab['name']) ? sanitize_text_field($tab['name']) : '',
1668 + 'label' => isset($tab['label']) ? sanitize_text_field($tab['label']) : '',
1669 + 'visible' => isset($tab['visible']) ? sanitize_text_field($tab['visible']) : 'false',
1670 + 'order' => isset($tab['order']) ? absint($tab['order']) : 0,
1671 + ];
1672 + $config[] = $sanitizedTab;
1673 + }
1674 +
1675 + if (count(array_filter($config, fn($tab) => $tab['visible'] == 'true')) == 0) {
1676 + return $this->sendError([
1677 + 'message' => __('At least one tab must be visible', 'fluent-boards')
1678 + ], 400);
1679 + }
1680 +
1681 + $userId = get_current_user_id();
1682 +
1683 + $exit = Meta::where('object_id', $userId)->where('key', 'fbs_task_tabs_config')->first();
1684 +
1685 + if ($exit) {
1686 + $exit->value = $config;
1687 + $exit->save();
1688 + } else {
1689 + $exit = Meta::create([
1690 + 'object_id' => $userId,
1691 + 'object_type' => 'option',
1692 + 'key' => Constant::FBS_TASK_TABS_CONFIG,
1693 + 'value' => $config
1694 + ]);
1695 + }
1696 + $config = $exit->value;
1697 +
1698 + return $this->sendSuccess([
1699 + 'message' => __('Configuration saved successfully', 'fluent-boards'),
1700 + 'config' => $config
1701 + ]);
1702 + }
1703 + public function getAssociatedCrmContacts($board_id)
1704 + {
1705 + $board_id = absint($board_id);
1706 + $contactsInTasks = Task::where('board_id', $board_id)
1707 + ->whereNotNull('crm_contact_id')
1708 + ->get();
1709 +
1710 + if ($contactsInTasks->isEmpty()) {
1711 + return $this->sendSuccess([]);
1712 + }
1713 +
1714 + $contactIds = $contactsInTasks->pluck('crm_contact_id')
1715 + ->unique()
1716 + ->toArray();
1717 +
1718 + $allContacts = Subscriber::whereIn('id', $contactIds)->get();
1719 +
1720 + if ($allContacts->isEmpty()) {
1721 + return $this->sendSuccess([]);
1722 + }
1723 +
1724 + $formattedContacts = [];
1725 + foreach ($allContacts as $contact) {
1726 + $name = trim($contact->first_name . ' ' . $contact->last_name);
1727 +
1728 + $formattedContacts[] = [
1729 + 'id' => $contact->id,
1730 + 'display_name' => $name,
1731 + 'email' => $contact->email,
1732 + 'photo' => fluent_boards_user_avatar($contact->user_email, $name),
1733 + ];
1734 + }
1735 + if (!empty($formattedContacts)) {
1736 + usort($formattedContacts, function ($a, $b) {
1737 + return strcmp($a['display_name'], $b['display_name']);
1738 + });
1739 + }
1740 +
1741 + return $this->sendSuccess($formattedContacts);
1742 + }
1743 +
1744 + public function cloneTask(Request $request, $board_id, $task_id)
1745 + {
1746 + $board_id = absint($board_id);
1747 + $task_id = absint($task_id);
1748 + $taskData = $this->taskSanitizeAndValidate($request->only(['title', 'stage_id', 'assignee', 'subtask', 'label', 'attachment', 'comment']), [
1749 + 'title' => 'required|string',
1750 + 'stage_id' => 'required|numeric',
1751 + 'assignee' => 'required',
1752 + 'subtask' => 'required',
1753 + 'label' => 'required',
1754 + 'attachment' => 'required',
1755 + 'comment' => 'required',
1756 + ]);
1757 + try {
1758 + $taskData = fluent_boards_string_to_bool($taskData);
1759 + $clonedTask = $this->taskService->cloneTask($task_id, $taskData, $board_id);
1760 +
1761 + return $this->sendSuccess([
1762 + 'message' => __('Task has been cloned successfully', 'fluent-boards'),
1763 + 'task' => $clonedTask,
1764 + 'updatedTasks' => $this->taskService->getLastOneMinuteUpdatedTasks($clonedTask->board_id)
1765 + ], 200);
1766 + } catch (\Exception $e) {
1767 + return $this->sendError($e->getMessage(), 400);
1768 + }
1769 + }
1770 +
1771 + public function bulkActions(Request $request, $board_id)
1772 + {
1773 + $board_id = absint($board_id);
1774 + try {
1775 + $rawTaskIds = $request->getSafe('task_ids');
1776 + // Sanitize task_ids array to integers
1777 + $taskIds = [];
1778 + if (is_array($rawTaskIds)) {
1779 + $taskIds = array_filter(array_map('intval', $rawTaskIds));
1780 + }
1781 + $action = $request->getSafe('action', 'sanitize_text_field');
1782 + // Sanitize params array
1783 + $rawParams = $request->except(['task_ids', 'action']);
1784 + // Ensure rawParams is sanitized
1785 + if (!is_array($rawParams)) {
1786 + $rawParams = [];
1787 + }
1788 + $params = [];
1789 + foreach ($rawParams as $key => $value) {
1790 + $sanitizedKey = sanitize_text_field($key);
1791 + if (is_array($value)) {
1792 + $params[$sanitizedKey] = array_map('sanitize_text_field', $value);
1793 + } else {
1794 + $params[$sanitizedKey] = sanitize_text_field($value);
1795 + }
1796 + }
1797 +
1798 + $result = $this->taskService->bulkActions($taskIds, $action, $params, $board_id);
1799 +
1800 + // Process successful tasks the same way as getTasksByBoard
1801 + if (!empty($result['successful_tasks'])) {
1802 + $board = Board::findOrFail($board_id);
1803 + $this->processTasks($result['successful_tasks'], $board);
1804 + }
1805 +
1806 + return $this->sendSuccess($result);
1807 +
1808 + } catch (\Exception $e) {
1809 + return $this->sendError($e->getMessage(), 500);
641 1810 }
642 1811 }
643 1812 }