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