PluginProbe
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration / 2.1.0
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration v2.1.0
2.1.0 2.0.15 2.0.12 2.0.10 2.0.4 2.0.1 2.0.0 1.95.3 1.95.2 1.95 1.91.6 trunk 1.11 1.12 1.13 1.20 1.21 1.22 1.23 1.30 1.31 1.32 1.35 1.40 1.41 All 42 releases
← All changes | app/Http/Controllers/TaskController.php +1292 -77 1.352.1.0 View file →
@@ -1,12 +1,15 @@
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;
11 +use FluentBoards\App\Services\CommentService;
9 12 use FluentBoards\App\Services\Constant;
10 13 use FluentBoards\App\Services\Helper;
11 14 use FluentBoards\App\Services\StageService;
12 15 use FluentBoards\App\Services\TaskService;
@@ -15,8 +18,9 @@
15 18 use FluentBoards\Framework\Http\Request\Request;
16 19 use FluentBoards\App\Services\PermissionManager;
17 20 use FluentBoards\Framework\Support\Arr;
18 21 use FluentBoardsPro\App\Services\AttachmentService;
22 +use FluentCrm\App\Models\Subscriber;
19 23
20 24 class TaskController extends Controller
21 25 {
22 26 private TaskService $taskService;
@@ -34,70 +38,283 @@
34 38 public function getTopTasksForBoards()
35 39 {
36 40 $userId = get_current_user_id();
37 41 $task_ids = PermissionManager::getTaskIdsWatchByUser($userId);
38 - $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();
39 59
40 60 return [
41 61 'data' => $tasksArray,
62 + 'counts' => $taskCounts,
42 63 ];
43 64 }
44 65
45 - public function getTasksByBoard($board_id)
66 + public function getTasksByBoard(Request $request, $board_id)
46 67 {
68 + $board_id = absint($board_id);
47 69 $board = Board::findOrFail($board_id);
70 + $includeArchived = $request->getSafe('include_archived', 'boolval', false);
48 71
49 72 // Get stage IDs
50 - $stageIds = $this->getStageIdsByBoard($board_id);
73 + $stageIds = $this->getStageIdsByBoard($board_id, $includeArchived);
51 74
52 75 // Fetch tasks for the board
53 - $tasks = Task::with(['assignees', 'labels', 'watchers', 'taskCustomFields'])
76 + $tasksQuery = Task::with(['assignees', 'labels', 'watchers', 'taskCustomFields'])
54 77 ->where('board_id', $board_id)
55 - ->whereNull('archived_at')
56 78 ->whereNull('parent_id')
57 79 ->whereIn('stage_id', $stageIds)
58 - ->orderBy('due_at', 'ASC')
59 - ->get();
80 + ->orderBy('due_at', 'ASC');
60 81
82 + if (!$includeArchived) {
83 + $tasksQuery->whereNull('archived_at');
84 + }
85 +
86 + $tasks = $tasksQuery->get();
87 +
61 88 // Process each task
62 89 $this->processTasks($tasks, $board);
63 90
91 + if ($board->type === 'roadmap') {
92 + $this->taskService->loadIdeaVoteStatistics($tasks);
93 + }
94 +
64 95 return [
65 96 'tasks' => $tasks,
66 97 ];
67 98 }
68 99
69 - public function getTasksByBoardStage($board_id)
100 + public function getTasksByBoardStage(Request $request, $board_id)
70 101 {
102 + $board_id = absint($board_id);
71 103 $board = Board::findOrFail($board_id);
104 + $includeArchived = $request->getSafe('include_archived', 'boolval', false);
72 105
73 106 // Get stage IDs
74 - $stageIds = $this->getStageIdsByBoard($board_id);
107 + $stageIds = $this->getStageIdsByBoard($board_id, $includeArchived);
108 + $stageTaskCounts = $this->getStageTaskCounts($board_id, $stageIds, $includeArchived);
75 109
76 110 // Initialize tasks array
77 111 $tasks = [];
112 + $paginationByStage = [];
78 113
79 114 // Fetch and process tasks for each stage
80 115 foreach ($stageIds as $stageId) {
81 - $stageTasks = Task::with(['assignees', 'labels', 'watchers', 'taskCustomFields'])
82 - ->where('board_id', $board_id)
83 - ->where('stage_id', $stageId)
84 - ->whereNull('archived_at')
85 - ->whereNull('parent_id')
116 + $stageTasks = $this->makeStageTasksQuery($board_id, $stageId, $includeArchived)
86 117 ->orderBy('position', 'ASC')
87 118 ->limit(20)
88 119 ->get();
89 120
90 121 // Process each stage's tasks
91 - $this->processTasks($stageTasks, $board);
122 + $this->processTasks($stageTasks, $board, [
123 + 'includeContact' => false,
124 + 'includeObserverState' => false,
125 + 'includeRoadmapPopularity' => false,
126 + ]);
92 127 $tasks = array_merge($tasks, $stageTasks->toArray()); // Merge with the main task list
128 +
129 + $startCursor = $stageTasks->count() ? (float) $stageTasks->first()->position : null;
130 + $endCursor = $stageTasks->count() ? (float) $stageTasks->last()->position : null;
131 + $loadedCount = $stageTasks->count();
132 + $hasMoreAfter = (int) ($stageTaskCounts[$stageId] ?? 0) > $loadedCount;
133 +
134 + $paginationByStage[$stageId] = [
135 + 'stage_id' => (int) $stageId,
136 + 'total_count' => (int) ($stageTaskCounts[$stageId] ?? 0),
137 + 'limit' => 20,
138 + 'direction' => 'next',
139 + 'cursor' => null,
140 + 'has_more' => $hasMoreAfter,
141 + 'has_more_before' => false,
142 + 'has_more_after' => $hasMoreAfter,
143 + 'start_cursor' => $startCursor,
144 + 'end_cursor' => $endCursor,
145 + ];
93 146 }
94 147
95 148 return [
96 149 'tasks' => $tasks,
150 + 'pagination_by_stage' => $paginationByStage,
97 151 ];
98 152 }
99 153
154 + public function getTableTasks(Request $request, $board_id)
155 + {
156 + $board_id = absint($board_id);
157 + $board = Board::findOrFail($board_id);
158 + $args = [
159 + 'page' => $request->getSafe('page', 'intval', 1),
160 + 'per_page' => $request->getSafe('per_page', 'intval', 20),
161 + 'sort_by' => $request->getSafe('sort_by', 'sanitize_text_field', 'position'),
162 + 'sort_direction' => $request->getSafe('sort_direction', 'sanitize_text_field', 'asc'),
163 + 'search' => $request->getSafe('search', 'sanitize_text_field', ''),
164 + 'include_archived' => $request->getSafe('include_archived', 'boolval', false),
165 + 'stage' => $request->get('stage', []),
166 + 'task_status' => $request->get('task_status', []),
167 + 'priority' => $request->get('priority', []),
168 + 'assignee' => $request->get('assignee', []),
169 + 'labels' => $request->get('labels', []),
170 + 'watchers' => $request->get('watchers', []),
171 + 'contact' => $request->get('contact', []),
172 + 'custom_fields' => $request->get('custom_fields', []),
173 + 'due_date' => $request->get('due_date', []),
174 + ];
175 +
176 + $tasks = $this->taskService->getTableTasks($board_id, $args);
177 + $taskItems = $tasks->items();
178 + $this->processTasks($taskItems, $board, [
179 + 'includeContact' => false,
180 + 'includeObserverState' => false,
181 + 'includeRoadmapPopularity' => false,
182 + ]);
183 +
184 + return $this->sendSuccess([
185 + 'items' => $taskItems,
186 + 'pagination' => [
187 + 'total' => (int) $tasks->total(),
188 + 'current_page' => (int) $tasks->currentPage(),
189 + 'per_page' => (int) $tasks->perPage(),
190 + 'last_page' => (int) $tasks->lastPage(),
191 + ],
192 + ], 200);
193 + }
194 +
195 + public function getFilteredBoardTasks(Request $request, $board_id)
196 + {
197 + $board_id = absint($board_id);
198 + $board = Board::findOrFail($board_id);
199 + $args = [
200 + 'search' => $request->getSafe('search', 'sanitize_text_field', ''),
201 + 'include_archived' => $request->getSafe('include_archived', 'boolval', false),
202 + 'stage' => $request->get('stage', []),
203 + 'task_status' => $request->get('task_status', []),
204 + 'priority' => $request->get('priority', []),
205 + 'assignee' => $request->get('assignee', []),
206 + 'labels' => $request->get('labels', []),
207 + 'watchers' => $request->get('watchers', []),
208 + 'contact' => $request->get('contact', []),
209 + 'custom_fields' => $request->get('custom_fields', []),
210 + 'due_date' => $request->get('due_date', []),
211 + ];
212 +
213 + $tasks = $this->taskService->getBoardViewTasks($board_id, $args);
214 + $this->processTasks($tasks, $board, [
215 + 'includeContact' => false,
216 + 'includeObserverState' => false,
217 + 'includeRoadmapPopularity' => false,
218 + ]);
219 +
220 + return [
221 + 'tasks' => $tasks,
222 + ];
223 + }
224 +
225 + public function getStageTasksPage(Request $request, $board_id)
226 + {
227 + $board_id = absint($board_id);
228 + $board = Board::findOrFail($board_id);
229 + $includeArchived = $request->getSafe('include_archived', 'boolval', false);
230 + $stageId = $request->getSafe('stage_id', 'intval');
231 + $limit = $request->getSafe('limit', 'intval', 20);
232 + $direction = $request->getSafe('direction', 'sanitize_text_field', 'next');
233 + $cursor = $request->getSafe('cursor', 'floatval');
234 +
235 + if (!$stageId) {
236 + return $this->sendError(esc_html__('Invalid Stage', 'fluent-boards'), 400);
237 + }
238 +
239 + if (!in_array($direction, ['next', 'prev'], true)) {
240 + return $this->sendError(esc_html__('Invalid direction', 'fluent-boards'), 400);
241 + }
242 +
243 + $limit = max(1, min(100, $limit));
244 +
245 + $stage = Stage::where('board_id', $board_id)
246 + ->where('id', $stageId)
247 + ->first();
248 +
249 + if (!$stage) {
250 + return $this->sendError(esc_html__('Stage not found', 'fluent-boards'), 404);
251 + }
252 +
253 + $stageTasksQuery = $this->makeStageTasksQuery($board_id, $stageId, $includeArchived);
254 +
255 + if ($cursor !== null) {
256 + if ($direction === 'prev') {
257 + $stageTasksQuery->where('position', '<', $cursor);
258 + } else {
259 + $stageTasksQuery->where('position', '>', $cursor);
260 + }
261 + }
262 +
263 + $stageTasks = $stageTasksQuery
264 + ->orderBy('position', $direction === 'prev' ? 'DESC' : 'ASC')
265 + ->limit($limit + 1)
266 + ->get();
267 +
268 + $hasMoreInDirection = $stageTasks->count() > $limit;
269 + if ($hasMoreInDirection) {
270 + $stageTasks = $stageTasks->slice(0, $limit)->values();
271 + }
272 +
273 + if ($direction === 'prev') {
274 + $stageTasks = $stageTasks->sortBy('position')->values();
275 + }
276 +
277 + $this->processTasks($stageTasks, $board, [
278 + 'includeContact' => false,
279 + 'includeObserverState' => false,
280 + 'includeRoadmapPopularity' => false,
281 + ]);
282 +
283 + $startCursor = $stageTasks->count() ? (float) $stageTasks->first()->position : null;
284 + $endCursor = $stageTasks->count() ? (float) $stageTasks->last()->position : null;
285 +
286 + $hasMoreBefore = false;
287 + $hasMoreAfter = false;
288 +
289 + if ($startCursor !== null) {
290 + $hasMoreBefore = $this->makeStageTasksQuery($board_id, $stageId, $includeArchived)
291 + ->where('position', '<', $startCursor)
292 + ->exists();
293 + }
294 +
295 + if ($endCursor !== null) {
296 + $hasMoreAfter = $this->makeStageTasksQuery($board_id, $stageId, $includeArchived)
297 + ->where('position', '>', $endCursor)
298 + ->exists();
299 + }
300 +
301 + return [
302 + 'tasks' => $stageTasks,
303 + 'pagination' => [
304 + 'stage_id' => (int) $stageId,
305 + 'limit' => (int) $limit,
306 + 'direction' => $direction,
307 + 'cursor' => $cursor !== null ? (float) $cursor : null,
308 + 'has_more' => $hasMoreInDirection,
309 + 'has_more_before' => $hasMoreBefore,
310 + 'has_more_after' => $hasMoreAfter,
311 + 'start_cursor' => $startCursor,
312 + 'end_cursor' => $endCursor,
313 + ],
314 + ];
315 + }
316 +
100 317 /**
101 318 * Get Stage IDs by Board ID.
102 319 *
103 320 * @param int $board_id
@@ -102,44 +319,153 @@
102 319 *
103 320 * @param int $board_id
104 321 * @return array
105 322 */
106 - private function getStageIdsByBoard($board_id)
323 + private function getStageIdsByBoard($board_id, $includeArchived = false)
107 324 {
108 - return Stage::where('board_id', $board_id)
109 - ->whereNull('archived_at')
110 - ->pluck('id')
325 + $stageQuery = Stage::where('board_id', $board_id);
326 + if (!$includeArchived) {
327 + $stageQuery->whereNull('archived_at');
328 + }
329 +
330 + return $stageQuery->pluck('id')->toArray();
331 + }
332 +
333 + private function makeStageTasksQuery($board_id, $stageId, $includeArchived = false)
334 + {
335 + $stageTasksQuery = Task::query()
336 + // Kanban/List only need card-level task data here; full task detail is
337 + // fetched separately when the modal opens.
338 + ->select($this->getStageTaskCardColumns())
339 + ->with(['assignees', 'labels', 'watchers'])
340 + ->where('board_id', $board_id)
341 + ->where('stage_id', $stageId)
342 + ->whereNull('parent_id');
343 +
344 + if (!$includeArchived) {
345 + $stageTasksQuery->whereNull('archived_at');
346 + }
347 +
348 + return $stageTasksQuery;
349 + }
350 +
351 + private function getStageTaskCounts($board_id, array $stageIds, $includeArchived = false)
352 + {
353 + if (!$stageIds) {
354 + return [];
355 + }
356 +
357 + $query = Task::query()
358 + ->selectRaw('stage_id, COUNT(*) as total_count')
359 + ->where('board_id', $board_id)
360 + ->whereNull('parent_id')
361 + ->whereIn('stage_id', $stageIds);
362 +
363 + if (!$includeArchived) {
364 + $query->whereNull('archived_at');
365 + }
366 +
367 + return $query
368 + ->groupBy('stage_id')
369 + ->pluck('total_count', 'stage_id')
370 + ->map(function ($count) {
371 + return (int) $count;
372 + })
111 373 ->toArray();
112 374 }
113 375
376 + private function getStageTaskCardColumns()
377 + {
378 + return [
379 + 'id',
380 + 'title',
381 + 'slug',
382 + 'board_id',
383 + 'parent_id',
384 + 'crm_contact_id',
385 + 'type',
386 + 'stage_id',
387 + 'status',
388 + 'reminder_type',
389 + 'priority',
390 + 'archived_at',
391 + 'remind_at',
392 + 'started_at',
393 + 'due_at',
394 + 'last_completed_at',
395 + 'position',
396 + 'comments_count',
397 + 'created_by',
398 + 'settings',
399 + 'source',
400 + 'source_id',
401 + ];
402 + }
403 +
114 404 /**
115 405 * Process and append extra information for each task.
116 406 *
117 407 * @param \Illuminate\Database\Eloquent\Collection $tasks
118 408 * @param \App\Models\Board $board
409 + * @param array $options
119 410 */
120 - private function processTasks($tasks, $board)
411 + private function processTasks($tasks, $board, $options = [])
121 412 {
413 + $includeContact = Arr::get($options, 'includeContact', true);
414 + $includeObserverState = Arr::get($options, 'includeObserverState', true);
415 + $includeRoadmapPopularity = Arr::get($options, 'includeRoadmapPopularity', true);
416 + $taskIds = [];
417 +
122 418 foreach ($tasks as $task) {
419 + $taskIds[] = (int) $task->id;
420 + }
421 +
422 + $unreadNotificationCounts = $this->notificationService->getUnreadNotificationCountsByTaskIds($taskIds);
423 +
424 + foreach ($tasks as $task) {
123 425 $task->isOverdue = $task->isOverdue();
124 426 $task->isUpcoming = $task->upcoming();
125 - $task->contact = Helper::crm_contact($task->crm_contact_id); // Handle possible null contact
126 - $task->is_watching = $task->isWatching();
427 + if ($includeContact) {
428 + $task->contact = Helper::crm_contact($task->crm_contact_id); // Handle possible null contact
429 + }
430 + if ($includeObserverState) {
431 + $task->is_watching = $task->isWatching();
432 + }
127 433 $task->assignees = Helper::sanitizeUserCollections($task->assignees);
128 434 $task->watchers = Helper::sanitizeUserCollections($task->watchers);
129 - $task->notifications = $this->notificationService->getUnreadNotificationsOfTasks($task);
435 + $task->notifications = $unreadNotificationCounts[(int) $task->id] ?? 0;
130 436
131 437 // If the board type is 'roadmap', calculate popularity
132 - if ($board->type === 'roadmap') {
438 + if ($includeRoadmapPopularity && $board->type === 'roadmap') {
133 439 $task->popular = $task->getPopularCount();
134 440 }
135 441 }
136 442 }
137 443
138 -
444 + /**
445 + * Create a task with field-specific sanitization for its request data.
446 + *
447 + * @param Request $request
448 + * @param int $board_id
449 + * @return mixed
450 + */
139 451 public function create(Request $request, $board_id)
140 452 {
141 - $taskData = $this->taskSanitizeAndValidate($request->get('task'), [
453 + $board_id = absint($board_id);
454 + $safeTaskData = $request->getSafe('task');
455 + $rawTaskData = $request->get('task', []);
456 +
457 + // Milkdown serializes pasted URLs as <https://...>, which generic text
458 + // sanitization removes as a tag.
459 + if (
460 + is_array($safeTaskData) &&
461 + is_array($rawTaskData) &&
462 + array_key_exists('description', $rawTaskData)
463 + ) {
464 + $safeTaskData['description'] = fluent_boards_sanitize_description($rawTaskData['description']);
465 + }
466 +
467 + $taskData = $this->taskSanitizeAndValidate($safeTaskData, [
142 468 'title' => 'required|string',
143 469 'board_id' => 'required|numeric',
144 470 'stage_id' => 'required|numeric',
145 471 'priority' => 'nullable|string',
@@ -147,17 +473,28 @@
147 473 'is_template' => 'string',
148 474 ]);
149 475
150 476 try {
477 + if (isset($taskData['assignees'])) {
478 + $taskData['assignees'] = array_filter(array_map('intval', (array) $taskData['assignees']));
479 + }
480 +
481 + if (isset($taskData['labels'])) {
482 + $taskData['labels'] = array_filter(array_map('intval', (array) $taskData['labels']));
483 + }
484 +
151 485 if ($taskData['board_id'] != $board_id) {
152 - throw new \Exception(__('Board id is not valid', 'fluent-boards'));
486 + throw new \Exception(esc_html__('Board id is not valid', 'fluent-boards'));
153 487 }
154 488
155 489 $task = $this->taskService->createTask($taskData, $board_id);
490 + $message = $task->type === 'roadmap'
491 + ? __('Idea has been successfully created', 'fluent-boards')
492 + : __('Task has been successfully created', 'fluent-boards');
156 493
157 494 return $this->sendSuccess([
158 495 'task' => $task,
159 - 'message' => __('Task has been successfully created', 'fluent-boards'),
496 + 'message' => $message,
160 497 'updatedTasks' => $this->taskService->getLastOneMinuteUpdatedTasks($task->board_id)
161 498 ], 201);
162 499 } catch (\Exception $e) {
163 500 return $this->sendError($e->getMessage(), 400);
@@ -165,20 +502,22 @@
165 502 }
166 503
167 504 public function find($board_id, $task_id)
168 505 {
506 + $board_id = absint($board_id);
507 + $task_id = absint($task_id);
169 508 try {
170 509
171 510 $stageService = new StageService();
172 511
173 - $task = Task::findOrFail($task_id);
512 + $task = $this->taskService->findTaskOnBoard($task_id, $board_id);
174 513
175 514 if (isset($task->parent_id)) {
176 - $task = Task::findOrFail($task->parent_id);
515 + $task = $this->taskService->findTaskOnBoard($task->parent_id, $board_id, false);
177 516 }
178 517
179 518 if(!$task) {
180 - throw new \Exception(__('Task not found', 'fluent-boards'));
519 + throw new \Exception(esc_html__('Task not found', 'fluent-boards'));
181 520 }
182 521
183 522 if (defined('FLUENT_BOARDS_PRO')) {
184 523 $task->load(['attachments']);
@@ -183,11 +522,12 @@
183 522 if (defined('FLUENT_BOARDS_PRO')) {
184 523 $task->load(['attachments']);
185 524 }
186 525
187 - $task->load(['board', 'stage', 'labels', 'assignees']);
526 + $task->load(['board', 'stage', 'labels', 'assignees','watchers']);
188 527
189 528 $task->assignees = Helper::sanitizeUserCollections($task->assignees);
529 + $task->watchers = Helper::sanitizeUserCollections($task->watchers);
190 530
191 531 $task->isOverdue = $task->isOverdue();
192 532 $task->contact = Task::lead_contact($task->crm_contact_id);
193 533 $task->board->stages = $stageService->stagesByBoardId($board_id);
@@ -202,8 +542,10 @@
202 542 return [
203 543 'task' => $task
204 544 ];
205 545
546 + } catch (ModelNotFoundException $e) {
547 + throw $e;
206 548 } catch (\Exception $e ) {
207 549 return $this->sendError($e->getMessage(), 400);
208 550 }
209 551
@@ -211,9 +553,10 @@
211 553 }
212 554
213 555 public function getStageType(Request $request)
214 556 {
215 - $stage = Stage::findOrFail($request->stage_id);
557 + $stage_id = $request->getSafe('stage_id', 'intval');
558 + $stage = Stage::findOrFail($stage_id);
216 559
217 560 return [
218 561 'stage' => $stage,
219 562 ];
@@ -220,10 +563,13 @@
220 563 }
221 564
222 565 public function getActivities(Request $request, $board_id, $task_id)
223 566 {
224 - $filter = $request->getSafe('filter');
567 + $board_id = absint($board_id);
568 + $task_id = absint($task_id);
569 + $filter = $request->getSafe('filter', 'sanitize_text_field');
225 570 $per_page = 15; // Apparently, let's use a fixed number of items per page.
571 + $this->taskService->findTaskOnBoard($task_id, $board_id);
226 572
227 573 return [
228 574 'activities' => $this->taskService->getActivities($task_id, $per_page, $filter)
229 575 ];
@@ -231,27 +577,236 @@
231 577 }
232 578
233 579 public function getArchivedTasks(Request $request, $board_id)
234 580 {
235 - $tasks = $this->taskService->getArchivedTasks($request->all(), $board_id);
581 + $board_id = absint($board_id);
582 + // Sanitize request parameters before passing to service
583 + $sanitizedParams = [
584 + 'per_page' => $request->getSafe('per_page', 'intval', 20),
585 + 'page' => $request->getSafe('page', 'intval', 1),
586 + 'query' => $request->getSafe('searchInput', 'sanitize_text_field', '')
587 + ];
236 588
589 +
590 + $tasks = $this->taskService->getArchivedTasks($sanitizedParams, $board_id);
591 +
237 592 foreach ($tasks as $task) {
238 593 $task->assignees = Helper::sanitizeUserCollections($task->assignees);
239 594 }
240 595
596 +
241 597 return [
242 598 'tasks' => $tasks
243 599 ];
244 600 }
245 601
602 + public function bulkRestoreTasks(Request $request, $board_id)
603 + {
604 + $board_id = absint($board_id);
605 + try {
606 + $rawTaskIds = $request->getSafe('task_ids');
607 + // Sanitize task_ids array to integers
608 + $task_ids = [];
609 + if (is_array($rawTaskIds)) {
610 + $task_ids = array_filter(array_map('intval', $rawTaskIds));
611 + }
612 +
613 + if (empty($task_ids)) {
614 + return $this->response->sendError('No task IDs provided', 400);
615 + }
616 +
617 + $tasks = Task::where('board_id', $board_id)
618 + ->whereIn('id', $task_ids)
619 + ->whereNotNull('archived_at')
620 + ->get();
621 +
622 + if ($tasks->isEmpty()) {
623 + return $this->response->sendError('No archived tasks found with provided IDs', 404);
624 + }
625 +
626 + $restored_count = 0;
627 + $failed_count = 0;
628 + $failed_tasks = [];
629 +
630 + foreach ($tasks as $task) {
631 + try {
632 + // Use TaskService to properly restore the task (same as single task restoration)
633 + $this->taskService->updateTaskProperty('archived_at', null, $task);
634 +
635 + // Prepare task for response (same as single task update)
636 + $task->isOverdue = $task->isOverdue();
637 + $task->isUpcoming = $task->upcoming();
638 + $task->contact = Helper::crm_contact($task->crm_contact_id);
639 + $task->is_watching = $task->isWatching();
640 + $task->assignees = Helper::sanitizeUserCollections($task->assignees);
641 +
642 + $restored_count++;
643 + } catch (\Exception $e) {
644 + // Track failed tasks but continue processing others
645 + $failed_count++;
646 + $failed_tasks[] = [
647 + 'id' => $task->id,
648 + 'title' => $task->title,
649 + 'error' => $e->getMessage()
650 + ];
651 + }
652 + }
653 +
654 + // Get recently updated tasks (same as single task operations)
655 + $recentlyUpdatedTasks = $this->taskService->getLastOneMinuteUpdatedTasks($board_id);
656 +
657 + // Build response with detailed results
658 + $response = [
659 + 'restored_count' => $restored_count,
660 + 'failed_count' => $failed_count,
661 + 'updatedTasks' => $recentlyUpdatedTasks
662 + ];
663 +
664 + if ($failed_count > 0) {
665 + $response['failed_tasks'] = $failed_tasks;
666 + if ($restored_count > 0) {
667 + $response['message'] = $restored_count . ' ' . ($restored_count === 1 ? 'task' : 'tasks') . ' restored successfully, ' . $failed_count . ' ' . ($failed_count === 1 ? 'task' : 'tasks') . ' failed';
668 + } else {
669 + $response['message'] = 'Failed to restore ' . $failed_count . ' ' . ($failed_count === 1 ? 'task' : 'tasks');
670 + }
671 + } else {
672 + $response['message'] = $restored_count . ' ' . ($restored_count === 1 ? 'task' : 'tasks') . ' restored successfully';
673 + }
674 +
675 + return $this->response->sendSuccess($response, 200);
676 +
677 + } catch (\Exception $e) {
678 + return $this->response->sendError($e->getMessage(), 500);
679 + }
680 + }
681 +
682 + public function bulkDeleteTasks(Request $request, $board_id)
683 + {
684 + $board_id = absint($board_id);
685 + try {
686 + $rawTaskIds = $request->getSafe('task_ids');
687 + // Sanitize task_ids array to integers
688 + $task_ids = [];
689 + if (is_array($rawTaskIds)) {
690 + $task_ids = array_filter(array_map('intval', $rawTaskIds));
691 + }
692 +
693 + if (empty($task_ids)) {
694 + return $this->response->sendError('No task IDs provided', 400);
695 + }
696 +
697 + $tasks = Task::where('board_id', $board_id)
698 + ->whereIn('id', $task_ids)
699 + ->get();
700 +
701 + if ($tasks->isEmpty()) {
702 + return $this->response->sendError('No tasks found with provided IDs', 404);
703 + }
704 +
705 + $deleted_count = 0;
706 + $failed_count = 0;
707 + $failed_tasks = [];
708 + $options = null;
709 +
710 + foreach ($tasks as $task) {
711 + try {
712 + // This handles all cleanup: subtasks, watchers, assignees, labels, notifications, attachments, etc.
713 + $this->taskService->deleteTaskForBulk($task);
714 + $deleted_count++;
715 + } catch (\Exception $e) {
716 + // Track failed tasks but continue processing others
717 + $failed_count++;
718 + $failed_tasks[] = [
719 + 'id' => $task->id,
720 + 'title' => $task->title,
721 + 'error' => $e->getMessage()
722 + ];
723 + }
724 + }
725 +
726 + // Get recently updated tasks (same as single task operations)
727 + $recentlyUpdatedTasks = $this->taskService->getLastOneMinuteUpdatedTasks($board_id);
728 +
729 + // Build response with detailed results
730 + $response = [
731 + 'deleted_count' => $deleted_count,
732 + 'failed_count' => $failed_count,
733 + 'updatedTasks' => $recentlyUpdatedTasks
734 + ];
735 +
736 + if ($failed_count > 0) {
737 + $response['failed_tasks'] = $failed_tasks;
738 + if ($deleted_count > 0) {
739 + $response['message'] = $deleted_count . ' ' . ($deleted_count === 1 ? 'task' : 'tasks') . ' deleted successfully, ' . $failed_count . ' ' . ($failed_count === 1 ? 'task' : 'tasks') . ' failed';
740 + } else {
741 + $response['message'] = 'Failed to delete ' . $failed_count . ' ' . ($failed_count === 1 ? 'task' : 'tasks');
742 + }
743 + } else {
744 + $response['message'] = $deleted_count . ' ' . ($deleted_count === 1 ? 'task' : 'tasks') . ' deleted successfully';
745 + }
746 +
747 + return $this->response->sendSuccess($response, 200);
748 +
749 + } catch (\Exception $e) {
750 + return $this->response->sendError($e->getMessage(), 500);
751 + }
752 + }
753 +
246 754 public function updateTaskProperties(Request $request, $board_id, $task_id)
247 755 {
756 + $board_id = absint($board_id);
757 + $task_id = absint($task_id);
758 + //Properties in col: settings, assignees,crm_contact_id, archived_at(AUTO_SET_TIMESTAMP) , status, title, description, priority, is_watching, is_template
248 759 $col = $request->getSafe('property', 'sanitize_text_field');
249 - $value = $request->get('value');
760 + if ($col === 'description') {
761 + $value = $request->getSafe('value', 'fluent_boards_sanitize_description');
762 + } elseif ($col === 'settings' || $col === 'assignees') {
763 + $value = $request->get('value');
764 + if (is_array($value) && isset($value['cover']) && is_array($value['cover'])) {
765 + if (isset($value['cover']['backgroundColor'])) {
766 + $value['cover']['backgroundColor'] = sanitize_text_field($value['cover']['backgroundColor']);
767 + }
768 + }
769 + } elseif ($col === 'is_watching') {
770 + $value = $request->get('value');
771 + if (is_array($value)) {
772 + $action = isset($value['action']) ? sanitize_text_field($value['action']) : 'start';
773 + $value = [
774 + 'userId' => isset($value['userId']) ? absint($value['userId']) : 0,
775 + 'action' => in_array($action, ['start', 'stop'], true) ? $action : 'start',
776 + ];
777 + } else {
778 + $value = sanitize_text_field($value);
779 + }
780 + } else {
781 + $value = $request->getSafe('value', 'sanitize_text_field');
782 + }
250 783
251 784 $validatedData = $this->updateTaskPropValidationAndSanitation($col, $value);
252 - $task = Task::with(['board', 'labels', 'assignees'])->findOrFail($task_id);
785 + $task = $this->taskService->findTaskOnBoard($task_id, $board_id);
786 + $task->load(['board', 'labels', 'assignees']);
253 787
788 + if ($col === 'board_id' && (int) $validatedData[$col] !== $board_id) {
789 + throw new \Exception(esc_html__('Task not found', 'fluent-boards'));
790 + }
791 +
792 + if ($col === 'stage_id' && !Stage::where('id', (int) $validatedData[$col])->where('board_id', $board_id)->exists()) {
793 + throw new \Exception(esc_html__('Stage not found', 'fluent-boards'));
794 + }
795 +
796 + if ($col === 'parent_id' && $validatedData[$col]) {
797 + $this->taskService->findTaskOnBoard($validatedData[$col], $board_id, false);
798 + }
799 +
800 + if ($task->parent_id && $col === 'started_at') {
801 + $validatedData[$col] = null;
802 + }
803 +
804 + $oldDateValue = null;
805 + if (in_array($col, ['due_at', 'started_at'])) {
806 + $oldDateValue = $task->{$col};
807 + }
808 +
254 809 if ($task->parent_id && !$task->board_id) {
255 810 $task->board_id = $board_id;
256 811 $task->save();
257 812 }
@@ -262,11 +817,32 @@
262 817 $task->contact = Helper::crm_contact($task->crm_contact_id);
263 818 $task->is_watching = $task->isWatching();
264 819 $task->assignees = Helper::sanitizeUserCollections($task->assignees);
265 820
821 + if ($col === 'is_watching') {
822 + $task->load('watchers');
823 + $task->watchers = Helper::sanitizeUserCollections($task->watchers);
824 + }
825 +
826 + if ($task->parent_id) {
827 + $task->subtask_group_id = TaskMeta::where('task_id', $task->id)->where('key', Constant::SUBTASK_GROUP_CHILD)->value('value');
828 + }
829 +
266 830 // A recent update to a task might impact other tasks on the board.
267 831 $updatedTasks = $this->taskService->getLastOneMinuteUpdatedTasks($board_id);
832 + $taskExists = false;
833 + foreach ($updatedTasks as $index => $updatedTask) {
834 + if ($updatedTask->id === $task->id) {
835 + $updatedTasks[$index] = $task; // Replace the existing task
836 + $taskExists = true;
837 + break;
838 + }
839 + }
268 840
841 + if (!$taskExists) {
842 + $updatedTasks[] = $task;
843 + }
844 +
269 845 return [
270 846 'message' => __('Task has been updated', 'fluent-boards'),
271 847 'task' => $task,
272 848 'updatedTasks' => $updatedTasks
@@ -272,35 +848,167 @@
272 848 'updatedTasks' => $updatedTasks
273 849 ];
274 850 }
275 851
852 + /**
853 + * Remove a Fluent Support association from a task without deleting the ticket.
854 + *
855 + * @param int $board_id
856 + * @param int $task_id
857 + * @return mixed
858 + */
859 + public function removeSupportTicketLink($board_id, $task_id)
860 + {
861 + $boardId = absint($board_id);
862 + $taskId = absint($task_id);
863 + $task = $this->taskService->removeSupportTicketLink($taskId, $boardId);
864 +
865 + return $this->sendSuccess([
866 + 'message' => __('Support ticket link has been removed', 'fluent-boards'),
867 + 'task' => $task,
868 + 'updatedTasks' => [$task],
869 + ]);
870 + }
871 +
276 872 public function updateTaskDates(Request $request, $board_id, $task_id)
277 873 {
278 - $task = Task::findOrFail($task_id);
874 + $board_id = absint($board_id);
875 + $task_id = absint($task_id);
876 + $task = Task::where('id', $task_id)->where('board_id', $board_id)->firstOrFail();
877 + $payload = $request->all();
279 878
280 - $startAt = $request->getSafe('started_at', 'sanitize_text_field', NULL);
281 - $dueAt = $request->getSafe('due_at', 'sanitize_text_field', NULL);
879 + // Capture old dates before updating
880 + $oldDates = [
881 + 'due_at' => $task->due_at,
882 + 'started_at' => $task->started_at,
883 + ];
282 884
283 - if ($startAt && $dueAt) {
885 +
886 +
887 + $hasStartAt = array_key_exists('started_at', $payload);
888 + $hasDueAt = array_key_exists('due_at', $payload);
889 + $hasReminderType = array_key_exists('reminder_type', $payload);
890 + $hasRemindAt = array_key_exists('remind_at', $payload);
891 +
892 + $startAt = $hasStartAt ? $request->getSafe('started_at', 'sanitize_text_field', NULL) : $task->started_at;
893 + $dueAt = $hasDueAt ? $request->getSafe('due_at', 'sanitize_text_field', NULL) : $task->due_at;
894 + $isSubtask = (bool) $task->parent_id;
895 +
896 + if ($isSubtask) {
897 + $startAt = null;
898 + $hasStartAt = $hasStartAt || (bool) $task->started_at;
899 + }
900 +
901 + if (!$isSubtask && $hasStartAt && $hasDueAt && $startAt && $dueAt) {
284 902 if (strtotime($startAt) > strtotime($dueAt)) {
285 - $startAt = gmdate('Y-m-d 00:00:00', strtotime($dueAt));
903 + $startAt = substr($dueAt, 0, 10) . ' 00:00:00';
286 904 }
287 905 }
288 906
289 - $task = $this->taskService->updateTaskProperty('started_at', $startAt, $task);
290 - $task = $this->taskService->updateTaskProperty('due_at', $dueAt, $task);
907 + if ($hasStartAt) {
908 + $task = $this->taskService->updateTaskProperty('started_at', $startAt, $task);
909 + }
291 910
911 + if ($hasDueAt) {
912 + $task = $this->taskService->updateTaskProperty('due_at', $dueAt, $task);
913 + }
914 +
915 + // Only mutate reminder fields when the caller explicitly sends them.
916 + if ($hasReminderType) {
917 + $reminderType = $request->getSafe('reminder_type', 'sanitize_text_field', NULL);
918 + $task = $this->taskService->updateTaskProperty('reminder_type', $reminderType, $task);
919 + }
920 +
921 + if ($hasRemindAt) {
922 + $remindAt = $request->getSafe('remind_at', 'sanitize_text_field', NULL);
923 + $task = $this->taskService->updateTaskProperty('remind_at', $remindAt, $task);
924 + }
925 +
926 + $datesChanged = false;
927 + $changedDates = [];
928 +
929 + if ($oldDates['due_at'] !== $task->due_at) {
930 + $datesChanged = true;
931 + $changedDates['due_at'] = $oldDates['due_at'];
932 + }
933 +
934 + if ($oldDates['started_at'] !== $task->started_at) {
935 + $datesChanged = true;
936 + $changedDates['started_at'] = $oldDates['started_at'];
937 + }
938 +
939 + if ($datesChanged) {
940 + do_action('fluent_boards/task_date_changed', $task, $changedDates);
941 + }
942 +
292 943 return [
293 944 'task' => $task,
294 - 'message' => __('Dates has been updated', 'fluent-boards'),
945 + 'message' => __('Dates have been updated', 'fluent-boards'),
295 946 'updatedTasks' => $this->taskService->getLastOneMinuteUpdatedTasks($board_id),
296 947 ];
297 948 }
298 949
950 + /**
951 + * Toggle task pinned state (meta only). Only top-level tasks can be pinned.
952 + *
953 + * @param Request $request Expects body: pinned (bool or "true"/"1" for pin, false/"false"/"0" for unpin)
954 + * @param int $board_id
955 + * @param int $task_id
956 + * @return array{task: \FluentBoards\App\Models\Task, message: string, updatedTasks: array}
957 + */
958 + public function toggleTaskPinned(Request $request, $board_id, $task_id)
959 + {
960 + $board_id = absint($board_id);
961 + $task_id = absint($task_id);
962 +
963 + $task = Task::where('board_id', $board_id)->findOrFail($task_id);
964 +
965 + if ($task->parent_id) {
966 + return $this->sendError(__('Subtasks cannot be pinned', 'fluent-boards'), 400);
967 + }
968 +
969 + $pinned = filter_var($request->getSafe('pinned', 'sanitize_text_field', false), FILTER_VALIDATE_BOOLEAN);
970 +
971 + if ((int) $task->is_pinned !== ($pinned ? 1 : 0)) {
972 + if ($pinned) {
973 + $task = $this->taskService->pinTask($task);
974 + $message = __('Task has been pinned', 'fluent-boards');
975 + } else {
976 + $task = $this->taskService->unpinTask($task);
977 + $message = __('Task has been unpinned', 'fluent-boards');
978 + }
979 + } else {
980 + $message = $pinned ? __('Task is already pinned', 'fluent-boards') : __('Task is already unpinned', 'fluent-boards');
981 + }
982 +
983 + // Pin state is stored in task meta, so task.updated_at may not change.
984 + // Ensure the toggled task is always present in the incremental payload.
985 + $updatedTasks = $this->taskService->getLastOneMinuteUpdatedTasks($board_id);
986 + $taskExists = false;
987 + foreach ($updatedTasks as $index => $updatedTask) {
988 + if ($updatedTask->id === $task->id) {
989 + $updatedTasks[$index] = $task;
990 + $taskExists = true;
991 + break;
992 + }
993 + }
994 + if (!$taskExists) {
995 + $updatedTasks[] = $task;
996 + }
997 +
998 + return [
999 + 'task' => $task,
1000 + 'message' => $message,
1001 + 'updatedTasks' => $updatedTasks,
1002 + ];
1003 + }
1004 +
299 1005 public function updateTaskCoverPhoto(Request $request, $board_id, $task_id)
300 1006 {
301 - $imagePath = $request->thumbnail;
302 - $task = $this->taskService->taskCoverPhotoUpdate($task_id, $imagePath);
1007 + $board_id = absint($board_id);
1008 + $task_id = absint($task_id);
1009 + $imagePath = $request->getSafe('thumbnail', 'sanitize_text_field');
1010 + $task = $this->taskService->taskCoverPhotoUpdate($task_id, $imagePath, $board_id);
303 1011
304 1012 return [
305 1013 'message' => __('Task cover photo has been updated', 'fluent-boards'),
306 1014 'task' => $task,
@@ -309,17 +1017,22 @@
309 1017 }
310 1018
311 1019 public function taskStatusUpdate(Request $request, $board_id, $task_id)
312 1020 {
1021 + $board_id = absint($board_id);
1022 + $task_id = absint($task_id);
1023 + $integrationType = $request->getSafe('integrationType', 'sanitize_text_field');
313 1024 return [
314 1025 'message' => __('Task status has been updated', 'fluent-boards'),
315 - 'task' => $this->taskService->taskStatusUpdate($task_id, $request->integrationType),
1026 + 'task' => $this->taskService->taskStatusUpdate($task_id, $integrationType, $board_id),
316 1027 ];
317 1028 }
318 1029
319 1030 public function deleteTask($board_id, $task_id)
320 1031 {
321 - $task = Task::findOrFail($task_id);
1032 + $board_id = absint($board_id);
1033 + $task_id = absint($task_id);
1034 + $task = $this->taskService->findTaskOnBoard($task_id, $board_id);
322 1035 $options = null;
323 1036 //if we need to do something before a task is deleted
324 1037 do_action('fluent_boards/before_task_deleted', $task, $options);
325 1038
@@ -337,8 +1050,39 @@
337 1050
338 1051 return $this->validate($data, $rules);
339 1052 }
340 1053
1054 + /**
1055 + * Ensure write routes cannot pair an accessible route board with a task from another board.
1056 + *
1057 + * @param \FluentBoards\App\Models\Task $task
1058 + * @param int $boardId
1059 + * @return void
1060 + * @throws \Exception
1061 + */
1062 + private function assertTaskBelongsToBoard($task, $boardId)
1063 + {
1064 + $boardId = absint($boardId);
1065 +
1066 + if (!$task || !$boardId) {
1067 + throw new \Exception(esc_html__('Task not found', 'fluent-boards'));
1068 + }
1069 +
1070 + if ((int) $task->board_id === $boardId) {
1071 + return;
1072 + }
1073 +
1074 + if ($task->parent_id) {
1075 + $parentBoardId = Task::where('id', $task->parent_id)->value('board_id');
1076 +
1077 + if ((int) $parentBoardId === $boardId) {
1078 + return;
1079 + }
1080 + }
1081 +
1082 + throw new \Exception(esc_html__('Task not found', 'fluent-boards'));
1083 + }
1084 +
341 1085 private function updateTaskPropValidationAndSanitation($col, $value)
342 1086 {
343 1087 $rules = [
344 1088 'title' => 'required|string',
@@ -344,9 +1088,9 @@
344 1088 'title' => 'required|string',
345 1089 'board_id' => 'required',
346 1090 'parent_id' => 'required',
347 1091 'crm_contact_id' => 'nullable',
348 - 'task_type' => 'nullable|string',
1092 + 'type' => 'nullable|string',
349 1093 'status' => 'nullable|string',
350 1094 'stage_id' => 'required',
351 1095 'reminder_type' => 'nullable|string',
352 1096 'priority' => 'nullable|string',
@@ -361,9 +1105,9 @@
361 1105 'log_minutes' => 'nullable|integer|unsigned',
362 1106 'last_completed' => 'nullable|date',
363 1107 'assignees' => 'nullable|integer',
364 1108 'archived_at' => 'nullable|string',
365 - 'is_watching' => 'nullable|string',
1109 + 'is_watching' => 'nullable',
366 1110 'is_template' => 'string',
367 1111 'last_completed_at' => 'nullable',
368 1112 'settings' => 'nullable|array',
369 1113 ];
@@ -380,8 +1124,11 @@
380 1124 }
381 1125
382 1126 return [$col => $sanitizedAndValidatedValue];
383 1127 }
1128 + if ('is_watching' == $col && is_array($value)) {
1129 + return [$col => $value];
1130 + }
384 1131 $data = Helper::sanitizeTask([$col => $value]);
385 1132
386 1133 return $this->validate($data, [
387 1134 $col => $rule,
@@ -386,22 +1133,22 @@
386 1133 return $this->validate($data, [
387 1134 $col => $rule,
388 1135 ]);
389 1136 }
390 - }
391 1137
392 - public function getLabelsByTask($task_id)
393 - {
394 - $labels = $this->taskService->getLabelsByTask($task_id);
395 -
396 - return $this->sendSuccess([
397 - 'labels' => $labels,
398 - ], 200);
1138 + // If the column is not found in the rules array, throw an exception
1139 + // translators: %s is the property name
1140 + throw new \Exception(sprintf(esc_html__('Invalid property: %s', 'fluent-boards'), esc_html($col)));
399 1141 }
400 1142
401 1143 public function getStageByTask($task_id)
402 1144 {
403 - $stage = $this->taskService->getStageByTask($task_id);
1145 + $task_id = absint($task_id);
1146 + try {
1147 + $stage = $this->taskService->getStageByTask($task_id);
1148 + } catch (\Exception $e) {
1149 + return $this->sendError($e->getMessage(), 404);
1150 + }
404 1151
405 1152 return [
406 1153 'stage' => $stage,
407 1154 ];
@@ -408,8 +1155,10 @@
408 1155 }
409 1156
410 1157 public function assignYourselfInTask($board_id, $task_id)
411 1158 {
1159 + $board_id = absint($board_id);
1160 + $task_id = absint($task_id);
412 1161 $task = $this->taskService->assignYourselfInTask($board_id, $task_id);
413 1162 $task->is_watching = $task->isWatching();
414 1163
415 1164 return [
@@ -418,8 +1167,10 @@
418 1167 }
419 1168
420 1169 public function detachYourselfFromTask($board_id, $task_id)
421 1170 {
1171 + $board_id = absint($board_id);
1172 + $task_id = absint($task_id);
422 1173 $task = $this->taskService->detachYourselfFromTask($board_id, $task_id);
423 1174 $task->assignees = Helper::sanitizeUserCollections($task->assignees);
424 1175 $task->is_watching = $task->isWatching();
425 1176
@@ -436,9 +1187,11 @@
436 1187 }
437 1188
438 1189 public function moveTaskToNextStage($board_id, $task_id)
439 1190 {
440 - $task = $this->taskService->moveTaskToNextStage($task_id);
1191 + $board_id = absint($board_id);
1192 + $task_id = absint($task_id);
1193 + $task = $this->taskService->moveTaskToNextStage($task_id, $board_id);
441 1194
442 1195 return [
443 1196 'task' => $task
444 1197 ];
@@ -448,29 +1201,71 @@
448 1201 * @throws \Exception
449 1202 */
450 1203 public function moveTask(Request $request, $board_id, $task_id)
451 1204 {
452 - $task = Task::findOrFail($task_id);
1205 + $board_id = absint($board_id);
1206 + $task_id = absint($task_id);
1207 + $task = $this->taskService->findTaskOnBoard($task_id, $board_id);
453 1208 $oldStageId = $task->stage_id;
454 1209 $newStageId = $request->getSafe('newStageId', 'intval');
455 1210 $newIndex = $request->getSafe('newIndex', 'intval');
456 1211 $newBoardId = $request->getSafe('newBoardId', 'intval');
1212 + $prevTaskId = $request->getSafe('prevTaskId', 'intval');
1213 + $nextTaskId = $request->getSafe('nextTaskId', 'intval');
457 1214
458 1215 if ((!is_numeric($newStageId) || $newStageId == 0)) {
459 - throw new \Exception(__('Invalid Stage', 'fluent-boards'));
1216 + throw new \Exception(esc_html__('Invalid Stage', 'fluent-boards'));
460 1217 }
461 - if ((!is_numeric($newIndex) || $newIndex == 0)) {
462 - throw new \Exception(__('Invalid Value', 'fluent-boards'));
1218 +
1219 + if (!$prevTaskId && !$nextTaskId && (!is_numeric($newIndex) || $newIndex == 0)) {
1220 + throw new \Exception(esc_html__('Invalid Value', 'fluent-boards'));
463 1221 }
1222 +
464 1223 if ($newBoardId) {
465 1224 if ((!is_numeric($newBoardId) || $newBoardId == 0)) {
466 - throw new \Exception(__('Invalid Board', 'fluent-boards'));
1225 + throw new \Exception(esc_html__('Invalid Board', 'fluent-boards'));
467 1226 }
1227 +
1228 + if (!PermissionManager::userHasBoardPermission($newBoardId, 'PUT')) {
1229 + throw new \Exception(esc_html__('Task not found', 'fluent-boards'));
1230 + }
1231 + }
1232 +
1233 + $effectiveBoardId = $newBoardId ?: $task->board_id;
1234 + $targetStage = Stage::where('id', $newStageId)
1235 + ->where('board_id', $effectiveBoardId)
1236 + ->first();
1237 +
1238 + if (!$targetStage) {
1239 + throw new \Exception(esc_html__('Invalid Stage', 'fluent-boards'));
1240 + }
1241 +
1242 + foreach (array_filter([$prevTaskId, $nextTaskId]) as $neighborTaskId) {
1243 + $this->taskService->findTaskOnBoard($neighborTaskId, $effectiveBoardId);
1244 + }
1245 +
1246 + if ($newBoardId) {
468 1247 $task = $this->taskService->changeBoardByTask($task, $newBoardId);
1248 + // Load relationships to ensure frontend gets updated data after board move
1249 + $task->load(['assignees', 'labels', 'watchers', 'attachments']);
469 1250 }
470 1251
1252 + // Clean up archived_by_stage meta when task is moved to different stage
1253 + if ($oldStageId != $newStageId) {
1254 + TaskMeta::where('task_id', $task->id)
1255 + ->where('key', Constant::META_KEY_ARCHIVED_BY_STAGE)
1256 + ->delete();
1257 + }
1258 +
471 1259 $task->stage_id = $newStageId;
472 - $task = $task->moveToNewPosition($newIndex);
1260 + // New drag flows send neighbour ids so ordering stays correct even when
1261 + // the client only has a paged slice of the stage. Older move flows still
1262 + // rely on the legacy 1-based newIndex fallback.
1263 + if ($prevTaskId || $nextTaskId) {
1264 + $task = $task->moveBetweenTasks($prevTaskId, $nextTaskId);
1265 + } else {
1266 + $task = $task->moveToNewPosition($newIndex);
1267 + }
473 1268
474 1269 if ($oldStageId != $newStageId) {
475 1270
476 1271 $this->taskService->manageDefaultAssignees($task, $newStageId);
@@ -490,12 +1285,12 @@
490 1285 }
491 1286
492 1287 do_action('fluent_boards/task_updated', $task, 'position');
493 1288
494 - $updatedTasks = $this->taskService->getLastOneMinuteUpdatedTasks($task->board_id, $request->get('last_boards_updated'));
1289 + $lastBoardsUpdated = $request->getSafe('last_boards_updated', 'sanitize_text_field');
1290 + $updatedTasks = $this->taskService->getLastOneMinuteUpdatedTasks($task->board_id, $lastBoardsUpdated);
495 1291
496 1292 return [
497 - 'new_position' => $task,
498 1293 'message' => __('Task has been updated', 'fluent-boards'),
499 1294 'task' => $task,
500 1295 'updatedTasks' => $updatedTasks,
501 1296 'last_updated' => current_time('mysql')
@@ -511,15 +1306,17 @@
511 1306 * @return \WP_REST_Response The response containing paginated comments and activities, total count, current page, and items per page.
512 1307 */
513 1308 public function getCommentsAndActivities( Request $request, $board_id, $task_id)
514 1309 {
1310 + $board_id = absint($board_id);
1311 + $task_id = absint($task_id);
515 1312 try {
516 1313 // Pagination parameters
517 - $page = $request->get('page', 1);
518 - $perPage = $request->get('per_page', 10);
519 - $filter = $request->get('filter', 'newest'); // Filter for comments and activities
520 -
521 - $commentsAndActivities = $this->taskService->getCommentsAndActivities($task_id, $perPage, $page, $filter);
1314 + $page = $request->getSafe('page', 'intval', 1);
1315 + $perPage = $request->getSafe('per_page', 'intval', 10);
1316 + $filter = $request->getSafe('filter', 'sanitize_text_field', 'newest'); // Filter for comments and activities
1317 + $feedType = $request->getSafe('feed_type', 'sanitize_text_field', 'all');
1318 + $commentsAndActivities = $this->taskService->getCommentsAndActivities($task_id, $perPage, $page, $filter, $board_id, $feedType);
522 1319 // Return the response with the task, paginated comments and activities, total count, current page, and items per page
523 1320 return $this->sendSuccess([
524 1321 'comments_and_activities' => $commentsAndActivities,
525 1322 ]);
@@ -537,14 +1334,35 @@
537 1334 as_enqueue_async_action('fluent_boards/one_time_schedule_send_email_for_stage_change', [$taskId, $usersToSendEmail, $current_user_id], 'fluent-boards');
538 1335 }
539 1336 public function getAssociatedTasks($associated_id)
540 1337 {
1338 + if (!$this->currentUserCanReadCrmContacts()) {
1339 + return $this->sendError(esc_html__('You do not have permission to view CRM contact tasks', 'fluent-boards'), 403);
1340 + }
1341 +
1342 + $associated_id = absint($associated_id);
541 1343 return [
542 - 'tasks' => $this->taskService->getAssociatedTasks($associated_id)
1344 + 'tasks' => $this->taskService->getAssociatedTasks($associated_id, get_current_user_id())
543 1345 ];
544 1346 }
545 1347
546 1348 /**
1349 + * Check FluentCRM contact read permission before exposing CRM-associated task data.
1350 + *
1351 + * @return bool
1352 + */
1353 + private function currentUserCanReadCrmContacts()
1354 + {
1355 + $permissionManager = 'FluentCrm\\App\\Services\\PermissionManager';
1356 +
1357 + if (!class_exists($permissionManager)) {
1358 + return false;
1359 + }
1360 +
1361 + return (bool) $permissionManager::currentUserCan('fcrm_read_contacts');
1362 + }
1363 +
1364 + /**
547 1365 * @param Request $request
548 1366 * @param $board_id
549 1367 * @param $task_id
550 1368 * @return \WP_REST_Response
@@ -550,9 +1368,12 @@
550 1368 * @return \WP_REST_Response
551 1369 */
552 1370 public function uploadMediaFileFromWpEditor(Request $request, $board_id, $task_id)
553 1371 {
1372 + $board_id = absint($board_id);
1373 + $task_id = absint($task_id);
554 1374 try {
1375 + $this->taskService->findTaskOnBoard($task_id, $board_id);
555 1376
556 1377
557 1378 $file = Arr::get($request->files(), 'file')->toArray();
558 1379 (new \FluentBoards\App\Services\UploadService)->validateFile($file);
@@ -567,11 +1388,12 @@
567 1388 $fileUploadedData['file_path'] = $mediaData['file_path'];
568 1389 $fileUploadedData['full_url'] = $mediaData['full_url'];
569 1390 $fileUploadedData->save();
570 1391 }
1392 + $fileUploadedData['public_url'] = (new CommentService())->createPublicUrl($fileUploadedData, $board_id);
571 1393
572 1394 return $this->sendSuccess([
573 - 'message' => __('Image has been uploaded', 'fluent-boards-pro'),
1395 + 'message' => __('Image has been uploaded', 'fluent-boards'),
574 1396 'file' => $fileUploadedData
575 1397 ], 200);
576 1398
577 1399
@@ -579,5 +1401,398 @@
579 1401 return $this->sendError($e->getMessage(), 400);
580 1402 }
581 1403 }
582 1404
1405 + public function createTaskFromImage(Request $request, $board_id)
1406 + {
1407 + $board_id = absint($board_id);
1408 + $stageId = $request->getSafe('stage_id', 'intval');
1409 + if (!Stage::where('id', $stageId)->where('board_id', $board_id)->exists()) {
1410 + return $this->sendError(esc_html__('Stage not found', 'fluent-boards'), 400);
1411 + }
1412 +
1413 + $file = Arr::get($request->files(), 'file')->toArray();
1414 + (new \FluentBoards\App\Services\UploadService)->validateFile($file);
1415 +
1416 + $uploadInfo = UploadService::handleFileUpload( $request->files(), $board_id);
1417 + $task = $this->taskService->createTaskFromImage($board_id, $stageId, $uploadInfo, $file);
1418 + $message = $task->type === 'roadmap'
1419 + ? __('Idea has been created', 'fluent-boards')
1420 + : __('Task has been created', 'fluent-boards');
1421 +
1422 + return $this->sendSuccess([
1423 + 'task' => $task,
1424 + 'updatedTasks' => $this->taskService->getLastOneMinuteUpdatedTasks($board_id),
1425 + 'message' => $message,
1426 + ], 200);
1427 +
1428 + }
1429 +
1430 + public function handleTaskCoverImageUpload(Request $request, $board_id, $task_id)
1431 + {
1432 + $board_id = absint($board_id);
1433 + $task_id = absint($task_id);
1434 + try {
1435 + $task = $this->taskService->findTaskOnBoard($task_id, $board_id);
1436 +
1437 + $file = Arr::get($request->files(), 'file')->toArray();
1438 + (new \FluentBoards\App\Services\UploadService)->validateFile($file);
1439 +
1440 + $uploadInfo = UploadService::handleFileUpload( $request->files(), $board_id);
1441 +
1442 + $fileData = $uploadInfo[0];
1443 + $fileUploadedData = $this->taskService->uploadMediaFileFromWpEditor($task_id, $fileData, Constant::TASK_DESCRIPTION);
1444 + if(!!defined('FLUENT_BOARDS_PRO_VERSION')) {
1445 + $mediaData = (new AttachmentService())->processMediaData($fileData, $file);
1446 + $fileUploadedData['driver'] = $mediaData['driver'];
1447 + $fileUploadedData['file_path'] = $mediaData['file_path'];
1448 + $fileUploadedData['full_url'] = $mediaData['full_url'];
1449 + $fileUploadedData->save();
1450 + }
1451 +
1452 + $settings = $task->settings;
1453 + $this->taskService->deleteTaskCoverImage($settings);
1454 + $publicUrl = (new CommentService())->createPublicUrl($fileUploadedData, $board_id);
1455 +
1456 + $settings['cover'] = [
1457 + 'imageId' => $fileUploadedData['id'],
1458 + 'backgroundImage' => $publicUrl,
1459 + ];
1460 + $task->settings = $settings;
1461 + $task->save();
1462 +
1463 + return $this->sendSuccess([
1464 + 'message' => __('Image has been uploaded', 'fluent-boards'),
1465 + 'public_url' => $publicUrl
1466 + ], 200);
1467 +
1468 +
1469 + } catch (\Exception $e) {
1470 + return $this->sendError($e->getMessage(), 400);
1471 + }
1472 + }
1473 + public function removeTaskCover($board_id, $task_id)
1474 + {
1475 + $board_id = absint($board_id);
1476 + $task_id = absint($task_id);
1477 + try {
1478 + $task = $this->taskService->findTaskOnBoard($task_id, $board_id);
1479 + $settings = $task->settings;
1480 + $this->taskService->deleteTaskCoverImage($settings);
1481 + unset($settings['cover']);
1482 + $task->settings = $settings;
1483 + $task->save();
1484 + return $this->sendSuccess([
1485 + 'task' => $task,
1486 + 'message' => __('Task Cover removed successfully', 'fluent-boards'),
1487 + ]);
1488 + } catch (\Exception $e) {
1489 + return $this->sendError($e->getMessage(), 400);
1490 + }
1491 + }
1492 +
1493 + /**
1494 + * Get task tabs configuration
1495 + */
1496 + public function getTaskTabsConfig()
1497 + {
1498 + $default_config = [
1499 + [
1500 + 'name' => 'due_today',
1501 + 'label' => __('Due Today', 'fluent-boards'),
1502 + 'visible' => 'true',
1503 + 'order' => 1
1504 + ],
1505 + [
1506 + 'name' => 'assigned',
1507 + 'label' => __('Assigned', 'fluent-boards'),
1508 + 'visible' => 'true',
1509 + 'order' => 2
1510 + ],
1511 + [
1512 + 'name' => 'upcoming',
1513 + 'label' => __('Upcoming', 'fluent-boards'),
1514 + 'visible' => 'true',
1515 + 'order' => 3
1516 + ],
1517 + [
1518 + 'name' => 'overdue',
1519 + 'label' => __('Overdue', 'fluent-boards'),
1520 + 'visible' => 'true',
1521 + 'order' => 4
1522 + ],
1523 + [
1524 + 'name' => 'mentioned',
1525 + 'label' => __('Mentioned', 'fluent-boards'),
1526 + 'visible' => 'true',
1527 + 'order' => 5
1528 + ],
1529 + [
1530 + 'name' => 'completed',
1531 + 'label' => __('Completed', 'fluent-boards'),
1532 + 'visible' => 'true',
1533 + 'order' => 6
1534 + ],
1535 + [
1536 + 'name' => 'others',
1537 + 'label' => __('Others', 'fluent-boards'),
1538 + 'visible' => 'true',
1539 + 'order' => 7
1540 + ]
1541 + ];
1542 + $availableTabNames = array_column($default_config, 'name');
1543 +
1544 + $existConfig = Meta::where('object_id', get_current_user_id())->where('key', Constant::FBS_TASK_TABS_CONFIG)->first();
1545 + $config = $default_config;
1546 +
1547 + if ($existConfig && !empty($existConfig->value)) {
1548 + $storedConfig = $existConfig->value;
1549 + $configChanged = false;
1550 + $config = $storedConfig;
1551 + $config = array_values(array_filter($config, fn($tab) => in_array($tab['name'] ?? '', $availableTabNames, true)));
1552 + $configChanged = count($config) !== count($storedConfig);
1553 +
1554 + if (empty($config)) {
1555 + $config = $default_config;
1556 + $configChanged = true;
1557 + }
1558 +
1559 + $existingNames = array_column($config, 'name');
1560 + $missingTabs = [];
1561 + foreach ($default_config as $defaultTab) {
1562 + if (!in_array($defaultTab['name'], $existingNames)) {
1563 + $missingTabs[] = $defaultTab;
1564 + }
1565 + }
1566 +
1567 + if (!empty($missingTabs)) {
1568 + $newConfig = [];
1569 + $order = 1;
1570 + $addedDueToday = false;
1571 + $addedAssigned = false;
1572 + foreach ($config as $tab) {
1573 + if (!$addedDueToday) {
1574 + $dueTodayTab = array_filter($missingTabs, fn($t) => $t['name'] === 'due_today');
1575 + if (!empty($dueTodayTab)) {
1576 + $dueTodayTab = reset($dueTodayTab);
1577 + $dueTodayTab['order'] = $order++;
1578 + $newConfig[] = $dueTodayTab;
1579 + $addedDueToday = true;
1580 + }
1581 + }
1582 +
1583 + if ($tab['name'] === 'upcoming' && !$addedAssigned) {
1584 + $assignedTab = array_filter($missingTabs, fn($t) => $t['name'] === 'assigned');
1585 + if (!empty($assignedTab)) {
1586 + $assignedTab = reset($assignedTab);
1587 + $assignedTab['order'] = $order++;
1588 + $newConfig[] = $assignedTab;
1589 + $addedAssigned = true;
1590 + }
1591 + }
1592 + $tab['order'] = $order++;
1593 + $newConfig[] = $tab;
1594 + }
1595 + foreach ($missingTabs as $missingTab) {
1596 + if (!in_array($missingTab['name'], ['assigned', 'due_today'], true)) {
1597 + $missingTab['order'] = $order++;
1598 + $newConfig[] = $missingTab;
1599 + }
1600 + }
1601 + $config = $newConfig;
1602 + $configChanged = true;
1603 + }
1604 +
1605 + if ($configChanged) {
1606 + $existConfig->value = $config;
1607 + $existConfig->save();
1608 + }
1609 + }
1610 +
1611 + // Always apply fresh translations based on tab name
1612 + $labelMap = [
1613 + 'due_today' => __('Due Today', 'fluent-boards'),
1614 + 'assigned' => __('Assigned', 'fluent-boards'),
1615 + 'upcoming' => __('Upcoming', 'fluent-boards'),
1616 + 'overdue' => __('Overdue', 'fluent-boards'),
1617 + 'mentioned' => __('Mentioned', 'fluent-boards'),
1618 + 'completed' => __('Completed', 'fluent-boards'),
1619 + 'others' => __('Others', 'fluent-boards'),
1620 + ];
1621 +
1622 + foreach ($config as &$tab) {
1623 + if (isset($labelMap[$tab['name']])) {
1624 + $tab['label'] = $labelMap[$tab['name']];
1625 + }
1626 + }
1627 +
1628 + return $this->sendSuccess([
1629 + 'data' => $config
1630 + ]);
1631 + }
1632 +
1633 + /**
1634 + * Save task tabs configuration
1635 + */
1636 + public function saveTaskTabsConfig(Request $request)
1637 + {
1638 + $rawConfig = $request->getSafe('tabs');
1639 +
1640 + if (empty($rawConfig) || !is_array($rawConfig)) {
1641 + return $this->sendError([
1642 + 'message' => __('Invalid data format', 'fluent-boards')
1643 + ], 400);
1644 + }
1645 +
1646 + // Sanitize config array
1647 + $config = [];
1648 + foreach ($rawConfig as $tab) {
1649 + if (!is_array($tab)) {
1650 + continue;
1651 + }
1652 + $sanitizedTab = [
1653 + 'name' => isset($tab['name']) ? sanitize_text_field($tab['name']) : '',
1654 + 'label' => isset($tab['label']) ? sanitize_text_field($tab['label']) : '',
1655 + 'visible' => isset($tab['visible']) ? sanitize_text_field($tab['visible']) : 'false',
1656 + 'order' => isset($tab['order']) ? absint($tab['order']) : 0,
1657 + ];
1658 + $config[] = $sanitizedTab;
1659 + }
1660 +
1661 + if (count(array_filter($config, fn($tab) => $tab['visible'] == 'true')) == 0) {
1662 + return $this->sendError([
1663 + 'message' => __('At least one tab must be visible', 'fluent-boards')
1664 + ], 400);
1665 + }
1666 +
1667 + $userId = get_current_user_id();
1668 +
1669 + $exit = Meta::where('object_id', $userId)->where('key', 'fbs_task_tabs_config')->first();
1670 +
1671 + if ($exit) {
1672 + $exit->value = $config;
1673 + $exit->save();
1674 + } else {
1675 + $exit = Meta::create([
1676 + 'object_id' => $userId,
1677 + 'object_type' => 'option',
1678 + 'key' => Constant::FBS_TASK_TABS_CONFIG,
1679 + 'value' => $config
1680 + ]);
1681 + }
1682 + $config = $exit->value;
1683 +
1684 + return $this->sendSuccess([
1685 + 'message' => __('Configuration saved successfully', 'fluent-boards'),
1686 + 'config' => $config
1687 + ]);
1688 + }
1689 + public function getAssociatedCrmContacts($board_id)
1690 + {
1691 + $board_id = absint($board_id);
1692 + $contactsInTasks = Task::where('board_id', $board_id)
1693 + ->whereNotNull('crm_contact_id')
1694 + ->get();
1695 +
1696 + if ($contactsInTasks->isEmpty()) {
1697 + return $this->sendSuccess([]);
1698 + }
1699 +
1700 + $contactIds = $contactsInTasks->pluck('crm_contact_id')
1701 + ->unique()
1702 + ->toArray();
1703 +
1704 + $allContacts = Subscriber::whereIn('id', $contactIds)->get();
1705 +
1706 + if ($allContacts->isEmpty()) {
1707 + return $this->sendSuccess([]);
1708 + }
1709 +
1710 + $formattedContacts = [];
1711 + foreach ($allContacts as $contact) {
1712 + $name = trim($contact->first_name . ' ' . $contact->last_name);
1713 +
1714 + $formattedContacts[] = [
1715 + 'id' => $contact->id,
1716 + 'display_name' => $name,
1717 + 'email' => $contact->email,
1718 + 'photo' => fluent_boards_user_avatar($contact->user_email, $name),
1719 + ];
1720 + }
1721 + if (!empty($formattedContacts)) {
1722 + usort($formattedContacts, function ($a, $b) {
1723 + return strcmp($a['display_name'], $b['display_name']);
1724 + });
1725 + }
1726 +
1727 + return $this->sendSuccess($formattedContacts);
1728 + }
1729 +
1730 + public function cloneTask(Request $request, $board_id, $task_id)
1731 + {
1732 + $board_id = absint($board_id);
1733 + $task_id = absint($task_id);
1734 + $taskData = $this->taskSanitizeAndValidate($request->only(['title', 'stage_id', 'assignee', 'subtask', 'label', 'attachment', 'comment']), [
1735 + 'title' => 'required|string',
1736 + 'stage_id' => 'required|numeric',
1737 + 'assignee' => 'required',
1738 + 'subtask' => 'required',
1739 + 'label' => 'required',
1740 + 'attachment' => 'required',
1741 + 'comment' => 'required',
1742 + ]);
1743 + try {
1744 + $taskData = fluent_boards_string_to_bool($taskData);
1745 + $clonedTask = $this->taskService->cloneTask($task_id, $taskData, $board_id);
1746 +
1747 + return $this->sendSuccess([
1748 + 'message' => __('Task has been cloned successfully', 'fluent-boards'),
1749 + 'task' => $clonedTask,
1750 + 'updatedTasks' => $this->taskService->getLastOneMinuteUpdatedTasks($clonedTask->board_id)
1751 + ], 200);
1752 + } catch (\Exception $e) {
1753 + return $this->sendError($e->getMessage(), 400);
1754 + }
1755 + }
1756 +
1757 + public function bulkActions(Request $request, $board_id)
1758 + {
1759 + $board_id = absint($board_id);
1760 + try {
1761 + $rawTaskIds = $request->getSafe('task_ids');
1762 + // Sanitize task_ids array to integers
1763 + $taskIds = [];
1764 + if (is_array($rawTaskIds)) {
1765 + $taskIds = array_filter(array_map('intval', $rawTaskIds));
1766 + }
1767 + $action = $request->getSafe('action', 'sanitize_text_field');
1768 + // Sanitize params array
1769 + $rawParams = $request->except(['task_ids', 'action']);
1770 + // Ensure rawParams is sanitized
1771 + if (!is_array($rawParams)) {
1772 + $rawParams = [];
1773 + }
1774 + $params = [];
1775 + foreach ($rawParams as $key => $value) {
1776 + $sanitizedKey = sanitize_text_field($key);
1777 + if (is_array($value)) {
1778 + $params[$sanitizedKey] = array_map('sanitize_text_field', $value);
1779 + } else {
1780 + $params[$sanitizedKey] = sanitize_text_field($value);
1781 + }
1782 + }
1783 +
1784 + $result = $this->taskService->bulkActions($taskIds, $action, $params, $board_id);
1785 +
1786 + // Process successful tasks the same way as getTasksByBoard
1787 + if (!empty($result['successful_tasks'])) {
1788 + $board = Board::findOrFail($board_id);
1789 + $this->processTasks($result['successful_tasks'], $board);
1790 + }
1791 +
1792 + return $this->sendSuccess($result);
1793 +
1794 + } catch (\Exception $e) {
1795 + return $this->sendError($e->getMessage(), 500);
1796 + }
1797 + }
583 1798 }