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 +1361 -65 1.302.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;
@@ -14,8 +17,11 @@
14 17 use FluentBoards\App\Services\UploadService;
15 18 use FluentBoards\Framework\Http\Request\Request;
16 19 use FluentBoards\App\Services\PermissionManager;
17 20 use FluentBoards\Framework\Support\Arr;
21 +use FluentBoardsPro\App\Services\AttachmentService;
22 +use FluentCrm\App\Models\Subscriber;
23 +
18 24 class TaskController extends Controller
19 25 {
20 26 private TaskService $taskService;
21 27
@@ -32,47 +38,434 @@
32 38 public function getTopTasksForBoards()
33 39 {
34 40 $userId = get_current_user_id();
35 41 $task_ids = PermissionManager::getTaskIdsWatchByUser($userId);
36 - $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();
37 59
38 60 return [
39 61 'data' => $tasksArray,
62 + 'counts' => $taskCounts,
40 63 ];
41 64 }
42 65
43 - public function getTasksByBoard($board_id)
66 + public function getTasksByBoard(Request $request, $board_id)
44 67 {
68 + $board_id = absint($board_id);
45 69 $board = Board::findOrFail($board_id);
46 -
47 - $tasks = Task::with(['assignees', 'labels', 'watchers', 'taskCustomFields'])
70 + $includeArchived = $request->getSafe('include_archived', 'boolval', false);
71 +
72 + // Get stage IDs
73 + $stageIds = $this->getStageIdsByBoard($board_id, $includeArchived);
74 +
75 + // Fetch tasks for the board
76 + $tasksQuery = Task::with(['assignees', 'labels', 'watchers', 'taskCustomFields'])
48 77 ->where('board_id', $board_id)
49 - ->whereNull('archived_at')
50 78 ->whereNull('parent_id')
51 - ->orderBy('due_at', 'ASC')
79 + ->whereIn('stage_id', $stageIds)
80 + ->orderBy('due_at', 'ASC');
81 +
82 + if (!$includeArchived) {
83 + $tasksQuery->whereNull('archived_at');
84 + }
85 +
86 + $tasks = $tasksQuery->get();
87 +
88 + // Process each task
89 + $this->processTasks($tasks, $board);
90 +
91 + if ($board->type === 'roadmap') {
92 + $this->taskService->loadIdeaVoteStatistics($tasks);
93 + }
94 +
95 + return [
96 + 'tasks' => $tasks,
97 + ];
98 + }
99 +
100 + public function getTasksByBoardStage(Request $request, $board_id)
101 + {
102 + $board_id = absint($board_id);
103 + $board = Board::findOrFail($board_id);
104 + $includeArchived = $request->getSafe('include_archived', 'boolval', false);
105 +
106 + // Get stage IDs
107 + $stageIds = $this->getStageIdsByBoard($board_id, $includeArchived);
108 + $stageTaskCounts = $this->getStageTaskCounts($board_id, $stageIds, $includeArchived);
109 +
110 + // Initialize tasks array
111 + $tasks = [];
112 + $paginationByStage = [];
113 +
114 + // Fetch and process tasks for each stage
115 + foreach ($stageIds as $stageId) {
116 + $stageTasks = $this->makeStageTasksQuery($board_id, $stageId, $includeArchived)
117 + ->orderBy('position', 'ASC')
118 + ->limit(20)
119 + ->get();
120 +
121 + // Process each stage's tasks
122 + $this->processTasks($stageTasks, $board, [
123 + 'includeContact' => false,
124 + 'includeObserverState' => false,
125 + 'includeRoadmapPopularity' => false,
126 + ]);
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 + ];
146 + }
147 +
148 + return [
149 + 'tasks' => $tasks,
150 + 'pagination_by_stage' => $paginationByStage,
151 + ];
152 + }
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)
52 266 ->get();
53 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 +
317 + /**
318 + * Get Stage IDs by Board ID.
319 + *
320 + * @param int $board_id
321 + * @return array
322 + */
323 + private function getStageIdsByBoard($board_id, $includeArchived = false)
324 + {
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 + })
373 + ->toArray();
374 + }
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 +
404 + /**
405 + * Process and append extra information for each task.
406 + *
407 + * @param \Illuminate\Database\Eloquent\Collection $tasks
408 + * @param \App\Models\Board $board
409 + * @param array $options
410 + */
411 + private function processTasks($tasks, $board, $options = [])
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 +
54 418 foreach ($tasks as $task) {
419 + $taskIds[] = (int) $task->id;
420 + }
421 +
422 + $unreadNotificationCounts = $this->notificationService->getUnreadNotificationCountsByTaskIds($taskIds);
423 +
424 + foreach ($tasks as $task) {
55 425 $task->isOverdue = $task->isOverdue();
56 426 $task->isUpcoming = $task->upcoming();
57 - $task->contact = Helper::crm_contact($task->crm_contact_id);
58 - $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 + }
59 433 $task->assignees = Helper::sanitizeUserCollections($task->assignees);
60 434 $task->watchers = Helper::sanitizeUserCollections($task->watchers);
61 - $task->notifications = $this->notificationService->getUnreadNotificationsOfTasks($task);
62 - if($board->type == 'roadmap') {
435 + $task->notifications = $unreadNotificationCounts[(int) $task->id] ?? 0;
436 +
437 + // If the board type is 'roadmap', calculate popularity
438 + if ($includeRoadmapPopularity && $board->type === 'roadmap') {
63 439 $task->popular = $task->getPopularCount();
64 440 }
65 441 }
66 -
67 - return [
68 - 'tasks' => $tasks,
69 - ];
70 442 }
71 443
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 + */
72 451 public function create(Request $request, $board_id)
73 452 {
74 - $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, [
75 468 'title' => 'required|string',
76 469 'board_id' => 'required|numeric',
77 470 'stage_id' => 'required|numeric',
78 471 'priority' => 'nullable|string',
@@ -80,17 +473,28 @@
80 473 'is_template' => 'string',
81 474 ]);
82 475
83 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 +
84 485 if ($taskData['board_id'] != $board_id) {
85 - throw new \Exception(__('Board id is not valid', 'fluent-boards'));
486 + throw new \Exception(esc_html__('Board id is not valid', 'fluent-boards'));
86 487 }
87 488
88 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');
89 493
90 494 return $this->sendSuccess([
91 495 'task' => $task,
92 - 'message' => __('Task has been successfully created', 'fluent-boards'),
496 + 'message' => $message,
93 497 'updatedTasks' => $this->taskService->getLastOneMinuteUpdatedTasks($task->board_id)
94 498 ], 201);
95 499 } catch (\Exception $e) {
96 500 return $this->sendError($e->getMessage(), 400);
@@ -98,24 +502,32 @@
98 502 }
99 503
100 504 public function find($board_id, $task_id)
101 505 {
506 + $board_id = absint($board_id);
507 + $task_id = absint($task_id);
102 508 try {
103 509
104 510 $stageService = new StageService();
105 511
106 - $task = Task::findOrFail($task_id);
512 + $task = $this->taskService->findTaskOnBoard($task_id, $board_id);
107 513
514 + if (isset($task->parent_id)) {
515 + $task = $this->taskService->findTaskOnBoard($task->parent_id, $board_id, false);
516 + }
517 +
108 518 if(!$task) {
109 - throw new \Exception(__('Task not found', 'fluent-boards'));
519 + throw new \Exception(esc_html__('Task not found', 'fluent-boards'));
110 520 }
521 +
111 522 if (defined('FLUENT_BOARDS_PRO')) {
112 523 $task->load(['attachments']);
113 524 }
114 525
115 - $task->load(['board', 'stage', 'labels', 'assignees']);
526 + $task->load(['board', 'stage', 'labels', 'assignees','watchers']);
116 527
117 528 $task->assignees = Helper::sanitizeUserCollections($task->assignees);
529 + $task->watchers = Helper::sanitizeUserCollections($task->watchers);
118 530
119 531 $task->isOverdue = $task->isOverdue();
120 532 $task->contact = Task::lead_contact($task->crm_contact_id);
121 533 $task->board->stages = $stageService->stagesByBoardId($board_id);
@@ -130,8 +542,10 @@
130 542 return [
131 543 'task' => $task
132 544 ];
133 545
546 + } catch (ModelNotFoundException $e) {
547 + throw $e;
134 548 } catch (\Exception $e ) {
135 549 return $this->sendError($e->getMessage(), 400);
136 550 }
137 551
@@ -139,9 +553,10 @@
139 553 }
140 554
141 555 public function getStageType(Request $request)
142 556 {
143 - $stage = Stage::findOrFail($request->stage_id);
557 + $stage_id = $request->getSafe('stage_id', 'intval');
558 + $stage = Stage::findOrFail($stage_id);
144 559
145 560 return [
146 561 'stage' => $stage,
147 562 ];
@@ -148,10 +563,13 @@
148 563 }
149 564
150 565 public function getActivities(Request $request, $board_id, $task_id)
151 566 {
152 - $filter = $request->getSafe('filter');
567 + $board_id = absint($board_id);
568 + $task_id = absint($task_id);
569 + $filter = $request->getSafe('filter', 'sanitize_text_field');
153 570 $per_page = 15; // Apparently, let's use a fixed number of items per page.
571 + $this->taskService->findTaskOnBoard($task_id, $board_id);
154 572
155 573 return [
156 574 'activities' => $this->taskService->getActivities($task_id, $per_page, $filter)
157 575 ];
@@ -159,27 +577,236 @@
159 577 }
160 578
161 579 public function getArchivedTasks(Request $request, $board_id)
162 580 {
163 - $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 + ];
164 588
589 +
590 + $tasks = $this->taskService->getArchivedTasks($sanitizedParams, $board_id);
591 +
165 592 foreach ($tasks as $task) {
166 593 $task->assignees = Helper::sanitizeUserCollections($task->assignees);
167 594 }
168 595
596 +
169 597 return [
170 598 'tasks' => $tasks
171 599 ];
172 600 }
173 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 +
174 754 public function updateTaskProperties(Request $request, $board_id, $task_id)
175 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
176 759 $col = $request->getSafe('property', 'sanitize_text_field');
177 - $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 + }
178 783
179 784 $validatedData = $this->updateTaskPropValidationAndSanitation($col, $value);
180 - $task = Task::with(['board', 'labels', 'assignees'])->findOrFail($task_id);
785 + $task = $this->taskService->findTaskOnBoard($task_id, $board_id);
786 + $task->load(['board', 'labels', 'assignees']);
181 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 +
182 809 if ($task->parent_id && !$task->board_id) {
183 810 $task->board_id = $board_id;
184 811 $task->save();
185 812 }
@@ -190,11 +817,32 @@
190 817 $task->contact = Helper::crm_contact($task->crm_contact_id);
191 818 $task->is_watching = $task->isWatching();
192 819 $task->assignees = Helper::sanitizeUserCollections($task->assignees);
193 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 +
194 830 // A recent update to a task might impact other tasks on the board.
195 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 + }
196 840
841 + if (!$taskExists) {
842 + $updatedTasks[] = $task;
843 + }
844 +
197 845 return [
198 846 'message' => __('Task has been updated', 'fluent-boards'),
199 847 'task' => $task,
200 848 'updatedTasks' => $updatedTasks
@@ -200,35 +848,167 @@
200 848 'updatedTasks' => $updatedTasks
201 849 ];
202 850 }
203 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 +
204 872 public function updateTaskDates(Request $request, $board_id, $task_id)
205 873 {
206 - $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();
207 878
208 - $startAt = $request->getSafe('started_at', 'sanitize_text_field', NULL);
209 - $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 + ];
210 884
211 - 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) {
212 902 if (strtotime($startAt) > strtotime($dueAt)) {
213 - $startAt = gmdate('Y-m-d 00:00:00', strtotime($dueAt));
903 + $startAt = substr($dueAt, 0, 10) . ' 00:00:00';
214 904 }
215 905 }
216 906
217 - $task = $this->taskService->updateTaskProperty('started_at', $startAt, $task);
218 - $task = $this->taskService->updateTaskProperty('due_at', $dueAt, $task);
907 + if ($hasStartAt) {
908 + $task = $this->taskService->updateTaskProperty('started_at', $startAt, $task);
909 + }
219 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 +
220 943 return [
221 944 'task' => $task,
222 - 'message' => __('Dates has been updated', 'fluent-boards'),
945 + 'message' => __('Dates have been updated', 'fluent-boards'),
223 946 'updatedTasks' => $this->taskService->getLastOneMinuteUpdatedTasks($board_id),
224 947 ];
225 948 }
226 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 +
227 1005 public function updateTaskCoverPhoto(Request $request, $board_id, $task_id)
228 1006 {
229 - $imagePath = $request->thumbnail;
230 - $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);
231 1011
232 1012 return [
233 1013 'message' => __('Task cover photo has been updated', 'fluent-boards'),
234 1014 'task' => $task,
@@ -237,17 +1017,22 @@
237 1017 }
238 1018
239 1019 public function taskStatusUpdate(Request $request, $board_id, $task_id)
240 1020 {
1021 + $board_id = absint($board_id);
1022 + $task_id = absint($task_id);
1023 + $integrationType = $request->getSafe('integrationType', 'sanitize_text_field');
241 1024 return [
242 1025 'message' => __('Task status has been updated', 'fluent-boards'),
243 - 'task' => $this->taskService->taskStatusUpdate($task_id, $request->integrationType),
1026 + 'task' => $this->taskService->taskStatusUpdate($task_id, $integrationType, $board_id),
244 1027 ];
245 1028 }
246 1029
247 1030 public function deleteTask($board_id, $task_id)
248 1031 {
249 - $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);
250 1035 $options = null;
251 1036 //if we need to do something before a task is deleted
252 1037 do_action('fluent_boards/before_task_deleted', $task, $options);
253 1038
@@ -265,8 +1050,39 @@
265 1050
266 1051 return $this->validate($data, $rules);
267 1052 }
268 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 +
269 1085 private function updateTaskPropValidationAndSanitation($col, $value)
270 1086 {
271 1087 $rules = [
272 1088 'title' => 'required|string',
@@ -272,9 +1088,9 @@
272 1088 'title' => 'required|string',
273 1089 'board_id' => 'required',
274 1090 'parent_id' => 'required',
275 1091 'crm_contact_id' => 'nullable',
276 - 'task_type' => 'nullable|string',
1092 + 'type' => 'nullable|string',
277 1093 'status' => 'nullable|string',
278 1094 'stage_id' => 'required',
279 1095 'reminder_type' => 'nullable|string',
280 1096 'priority' => 'nullable|string',
@@ -289,9 +1105,9 @@
289 1105 'log_minutes' => 'nullable|integer|unsigned',
290 1106 'last_completed' => 'nullable|date',
291 1107 'assignees' => 'nullable|integer',
292 1108 'archived_at' => 'nullable|string',
293 - 'is_watching' => 'nullable|string',
1109 + 'is_watching' => 'nullable',
294 1110 'is_template' => 'string',
295 1111 'last_completed_at' => 'nullable',
296 1112 'settings' => 'nullable|array',
297 1113 ];
@@ -308,8 +1124,11 @@
308 1124 }
309 1125
310 1126 return [$col => $sanitizedAndValidatedValue];
311 1127 }
1128 + if ('is_watching' == $col && is_array($value)) {
1129 + return [$col => $value];
1130 + }
312 1131 $data = Helper::sanitizeTask([$col => $value]);
313 1132
314 1133 return $this->validate($data, [
315 1134 $col => $rule,
@@ -314,22 +1133,22 @@
314 1133 return $this->validate($data, [
315 1134 $col => $rule,
316 1135 ]);
317 1136 }
318 - }
319 1137
320 - public function getLabelsByTask($task_id)
321 - {
322 - $labels = $this->taskService->getLabelsByTask($task_id);
323 -
324 - return $this->sendSuccess([
325 - 'labels' => $labels,
326 - ], 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)));
327 1141 }
328 1142
329 1143 public function getStageByTask($task_id)
330 1144 {
331 - $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 + }
332 1151
333 1152 return [
334 1153 'stage' => $stage,
335 1154 ];
@@ -336,8 +1155,10 @@
336 1155 }
337 1156
338 1157 public function assignYourselfInTask($board_id, $task_id)
339 1158 {
1159 + $board_id = absint($board_id);
1160 + $task_id = absint($task_id);
340 1161 $task = $this->taskService->assignYourselfInTask($board_id, $task_id);
341 1162 $task->is_watching = $task->isWatching();
342 1163
343 1164 return [
@@ -346,8 +1167,10 @@
346 1167 }
347 1168
348 1169 public function detachYourselfFromTask($board_id, $task_id)
349 1170 {
1171 + $board_id = absint($board_id);
1172 + $task_id = absint($task_id);
350 1173 $task = $this->taskService->detachYourselfFromTask($board_id, $task_id);
351 1174 $task->assignees = Helper::sanitizeUserCollections($task->assignees);
352 1175 $task->is_watching = $task->isWatching();
353 1176
@@ -364,9 +1187,11 @@
364 1187 }
365 1188
366 1189 public function moveTaskToNextStage($board_id, $task_id)
367 1190 {
368 - $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);
369 1194
370 1195 return [
371 1196 'task' => $task
372 1197 ];
@@ -376,32 +1201,76 @@
376 1201 * @throws \Exception
377 1202 */
378 1203 public function moveTask(Request $request, $board_id, $task_id)
379 1204 {
380 - $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);
381 1208 $oldStageId = $task->stage_id;
382 1209 $newStageId = $request->getSafe('newStageId', 'intval');
383 1210 $newIndex = $request->getSafe('newIndex', 'intval');
384 1211 $newBoardId = $request->getSafe('newBoardId', 'intval');
1212 + $prevTaskId = $request->getSafe('prevTaskId', 'intval');
1213 + $nextTaskId = $request->getSafe('nextTaskId', 'intval');
385 1214
386 1215 if ((!is_numeric($newStageId) || $newStageId == 0)) {
387 - throw new \Exception(__('Invalid Stage', 'fluent-boards'));
1216 + throw new \Exception(esc_html__('Invalid Stage', 'fluent-boards'));
388 1217 }
389 - if ((!is_numeric($newIndex) || $newIndex == 0)) {
390 - 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'));
391 1221 }
1222 +
392 1223 if ($newBoardId) {
393 1224 if ((!is_numeric($newBoardId) || $newBoardId == 0)) {
394 - throw new \Exception(__('Invalid Board', 'fluent-boards'));
1225 + throw new \Exception(esc_html__('Invalid Board', 'fluent-boards'));
395 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) {
396 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']);
397 1250 }
398 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 +
399 1259 $task->stage_id = $newStageId;
400 - $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 + }
401 1268
402 1269 if ($oldStageId != $newStageId) {
403 1270
1271 + $this->taskService->manageDefaultAssignees($task, $newStageId);
1272 +
404 1273 $defaultPosition = $task->stage->defaultTaskStatus();
405 1274
406 1275 if ($defaultPosition == 'closed' && $task->status != 'closed') {
407 1276 $task = $task->close();
@@ -416,12 +1285,12 @@
416 1285 }
417 1286
418 1287 do_action('fluent_boards/task_updated', $task, 'position');
419 1288
420 - $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);
421 1291
422 1292 return [
423 - 'new_position' => $task,
424 1293 'message' => __('Task has been updated', 'fluent-boards'),
425 1294 'task' => $task,
426 1295 'updatedTasks' => $updatedTasks,
427 1296 'last_updated' => current_time('mysql')
@@ -437,15 +1306,17 @@
437 1306 * @return \WP_REST_Response The response containing paginated comments and activities, total count, current page, and items per page.
438 1307 */
439 1308 public function getCommentsAndActivities( Request $request, $board_id, $task_id)
440 1309 {
1310 + $board_id = absint($board_id);
1311 + $task_id = absint($task_id);
441 1312 try {
442 1313 // Pagination parameters
443 - $page = $request->get('page', 1);
444 - $perPage = $request->get('per_page', 10);
445 - $filter = $request->get('filter', 'newest'); // Filter for comments and activities
446 -
447 - $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);
448 1319 // Return the response with the task, paginated comments and activities, total count, current page, and items per page
449 1320 return $this->sendSuccess([
450 1321 'comments_and_activities' => $commentsAndActivities,
451 1322 ]);
@@ -463,14 +1334,35 @@
463 1334 as_enqueue_async_action('fluent_boards/one_time_schedule_send_email_for_stage_change', [$taskId, $usersToSendEmail, $current_user_id], 'fluent-boards');
464 1335 }
465 1336 public function getAssociatedTasks($associated_id)
466 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);
467 1343 return [
468 - 'tasks' => $this->taskService->getAssociatedTasks($associated_id)
1344 + 'tasks' => $this->taskService->getAssociatedTasks($associated_id, get_current_user_id())
469 1345 ];
470 1346 }
471 1347
472 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 + /**
473 1365 * @param Request $request
474 1366 * @param $board_id
475 1367 * @param $task_id
476 1368 * @return \WP_REST_Response
@@ -476,9 +1368,12 @@
476 1368 * @return \WP_REST_Response
477 1369 */
478 1370 public function uploadMediaFileFromWpEditor(Request $request, $board_id, $task_id)
479 1371 {
1372 + $board_id = absint($board_id);
1373 + $task_id = absint($task_id);
480 1374 try {
1375 + $this->taskService->findTaskOnBoard($task_id, $board_id);
481 1376
482 1377
483 1378 $file = Arr::get($request->files(), 'file')->toArray();
484 1379 (new \FluentBoards\App\Services\UploadService)->validateFile($file);
@@ -486,11 +1381,19 @@
486 1381 $uploadInfo = UploadService::handleFileUpload( $request->files(), $board_id);
487 1382
488 1383 $fileData = $uploadInfo[0];
489 1384 $fileUploadedData = $this->taskService->uploadMediaFileFromWpEditor($task_id, $fileData, Constant::TASK_DESCRIPTION);
1385 + if(!!defined('FLUENT_BOARDS_PRO_VERSION')) {
1386 + $mediaData = (new AttachmentService())->processMediaData($fileData, $file);
1387 + $fileUploadedData['driver'] = $mediaData['driver'];
1388 + $fileUploadedData['file_path'] = $mediaData['file_path'];
1389 + $fileUploadedData['full_url'] = $mediaData['full_url'];
1390 + $fileUploadedData->save();
1391 + }
1392 + $fileUploadedData['public_url'] = (new CommentService())->createPublicUrl($fileUploadedData, $board_id);
490 1393
491 1394 return $this->sendSuccess([
492 - 'message' => __('Image has been uploaded', 'fluent-boards-pro'),
1395 + 'message' => __('Image has been uploaded', 'fluent-boards'),
493 1396 'file' => $fileUploadedData
494 1397 ], 200);
495 1398
496 1399
@@ -498,5 +1401,398 @@
498 1401 return $this->sendError($e->getMessage(), 400);
499 1402 }
500 1403 }
501 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 + }
502 1798 }