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 +1289 -77 1.35trunk View file →
@@ -1,12 +1,14 @@
1 1 <?php
2 2
3 3 namespace FluentBoards\App\Http\Controllers;
4 4
5 -use DateTimeImmutable;
5 +use FluentBoards\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;
10 +use FluentBoards\App\Services\CommentService;
9 11 use FluentBoards\App\Services\Constant;
10 12 use FluentBoards\App\Services\Helper;
11 13 use FluentBoards\App\Services\StageService;
12 14 use FluentBoards\App\Services\TaskService;
@@ -15,8 +17,9 @@
15 17 use FluentBoards\Framework\Http\Request\Request;
16 18 use FluentBoards\App\Services\PermissionManager;
17 19 use FluentBoards\Framework\Support\Arr;
18 20 use FluentBoardsPro\App\Services\AttachmentService;
21 +use FluentCrm\App\Models\Subscriber;
19 22
20 23 class TaskController extends Controller
21 24 {
22 25 private TaskService $taskService;
@@ -34,70 +37,283 @@
34 37 public function getTopTasksForBoards()
35 38 {
36 39 $userId = get_current_user_id();
37 40 $task_ids = PermissionManager::getTaskIdsWatchByUser($userId);
38 - $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();
39 58
40 59 return [
41 60 'data' => $tasksArray,
61 + 'counts' => $taskCounts,
42 62 ];
43 63 }
44 64
45 - public function getTasksByBoard($board_id)
65 + public function getTasksByBoard(Request $request, $board_id)
46 66 {
67 + $board_id = absint($board_id);
47 68 $board = Board::findOrFail($board_id);
69 + $includeArchived = $request->getSafe('include_archived', 'boolval', false);
48 70
49 71 // Get stage IDs
50 - $stageIds = $this->getStageIdsByBoard($board_id);
72 + $stageIds = $this->getStageIdsByBoard($board_id, $includeArchived);
51 73
52 74 // Fetch tasks for the board
53 - $tasks = Task::with(['assignees', 'labels', 'watchers', 'taskCustomFields'])
75 + $tasksQuery = Task::with(['assignees', 'labels', 'watchers', 'taskCustomFields'])
54 76 ->where('board_id', $board_id)
55 - ->whereNull('archived_at')
56 77 ->whereNull('parent_id')
57 78 ->whereIn('stage_id', $stageIds)
58 - ->orderBy('due_at', 'ASC')
59 - ->get();
79 + ->orderBy('due_at', 'ASC');
60 80
81 + if (!$includeArchived) {
82 + $tasksQuery->whereNull('archived_at');
83 + }
84 +
85 + $tasks = $tasksQuery->get();
86 +
61 87 // Process each task
62 88 $this->processTasks($tasks, $board);
63 89
90 + if ($board->type === 'roadmap') {
91 + $this->taskService->loadIdeaVoteStatistics($tasks);
92 + }
93 +
64 94 return [
65 95 'tasks' => $tasks,
66 96 ];
67 97 }
68 98
69 - public function getTasksByBoardStage($board_id)
99 + public function getTasksByBoardStage(Request $request, $board_id)
70 100 {
101 + $board_id = absint($board_id);
71 102 $board = Board::findOrFail($board_id);
103 + $includeArchived = $request->getSafe('include_archived', 'boolval', false);
72 104
73 105 // Get stage IDs
74 - $stageIds = $this->getStageIdsByBoard($board_id);
106 + $stageIds = $this->getStageIdsByBoard($board_id, $includeArchived);
107 + $stageTaskCounts = $this->getStageTaskCounts($board_id, $stageIds, $includeArchived);
75 108
76 109 // Initialize tasks array
77 110 $tasks = [];
111 + $paginationByStage = [];
78 112
79 113 // Fetch and process tasks for each stage
80 114 foreach ($stageIds as $stageId) {
81 - $stageTasks = Task::with(['assignees', 'labels', 'watchers', 'taskCustomFields'])
82 - ->where('board_id', $board_id)
83 - ->where('stage_id', $stageId)
84 - ->whereNull('archived_at')
85 - ->whereNull('parent_id')
115 + $stageTasks = $this->makeStageTasksQuery($board_id, $stageId, $includeArchived)
86 116 ->orderBy('position', 'ASC')
87 117 ->limit(20)
88 118 ->get();
89 119
90 120 // Process each stage's tasks
91 - $this->processTasks($stageTasks, $board);
121 + $this->processTasks($stageTasks, $board, [
122 + 'includeContact' => false,
123 + 'includeObserverState' => false,
124 + 'includeRoadmapPopularity' => false,
125 + ]);
92 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 + ];
93 145 }
94 146
95 147 return [
96 148 'tasks' => $tasks,
149 + 'pagination_by_stage' => $paginationByStage,
97 150 ];
98 151 }
99 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 +
100 316 /**
101 317 * Get Stage IDs by Board ID.
102 318 *
103 319 * @param int $board_id
@@ -102,44 +318,153 @@
102 318 *
103 319 * @param int $board_id
104 320 * @return array
105 321 */
106 - private function getStageIdsByBoard($board_id)
322 + private function getStageIdsByBoard($board_id, $includeArchived = false)
107 323 {
108 - return Stage::where('board_id', $board_id)
109 - ->whereNull('archived_at')
110 - ->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 + })
111 372 ->toArray();
112 373 }
113 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 +
114 403 /**
115 404 * Process and append extra information for each task.
116 405 *
117 406 * @param \Illuminate\Database\Eloquent\Collection $tasks
118 407 * @param \App\Models\Board $board
408 + * @param array $options
119 409 */
120 - private function processTasks($tasks, $board)
410 + private function processTasks($tasks, $board, $options = [])
121 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 +
122 417 foreach ($tasks as $task) {
418 + $taskIds[] = (int) $task->id;
419 + }
420 +
421 + $unreadNotificationCounts = $this->notificationService->getUnreadNotificationCountsByTaskIds($taskIds);
422 +
423 + foreach ($tasks as $task) {
123 424 $task->isOverdue = $task->isOverdue();
124 425 $task->isUpcoming = $task->upcoming();
125 - $task->contact = Helper::crm_contact($task->crm_contact_id); // Handle possible null contact
126 - $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 + }
127 432 $task->assignees = Helper::sanitizeUserCollections($task->assignees);
128 433 $task->watchers = Helper::sanitizeUserCollections($task->watchers);
129 - $task->notifications = $this->notificationService->getUnreadNotificationsOfTasks($task);
434 + $task->notifications = $unreadNotificationCounts[(int) $task->id] ?? 0;
130 435
131 436 // If the board type is 'roadmap', calculate popularity
132 - if ($board->type === 'roadmap') {
437 + if ($includeRoadmapPopularity && $board->type === 'roadmap') {
133 438 $task->popular = $task->getPopularCount();
134 439 }
135 440 }
136 441 }
137 442
138 -
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 + */
139 450 public function create(Request $request, $board_id)
140 451 {
141 - $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, [
142 467 'title' => 'required|string',
143 468 'board_id' => 'required|numeric',
144 469 'stage_id' => 'required|numeric',
145 470 'priority' => 'nullable|string',
@@ -147,17 +472,28 @@
147 472 'is_template' => 'string',
148 473 ]);
149 474
150 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 +
151 484 if ($taskData['board_id'] != $board_id) {
152 - throw new \Exception(__('Board id is not valid', 'fluent-boards'));
485 + throw new \Exception(esc_html__('Board id is not valid', 'fluent-boards'));
153 486 }
154 487
155 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');
156 492
157 493 return $this->sendSuccess([
158 494 'task' => $task,
159 - 'message' => __('Task has been successfully created', 'fluent-boards'),
495 + 'message' => $message,
160 496 'updatedTasks' => $this->taskService->getLastOneMinuteUpdatedTasks($task->board_id)
161 497 ], 201);
162 498 } catch (\Exception $e) {
163 499 return $this->sendError($e->getMessage(), 400);
@@ -165,20 +501,22 @@
165 501 }
166 502
167 503 public function find($board_id, $task_id)
168 504 {
505 + $board_id = absint($board_id);
506 + $task_id = absint($task_id);
169 507 try {
170 508
171 509 $stageService = new StageService();
172 510
173 - $task = Task::findOrFail($task_id);
511 + $task = $this->taskService->findTaskOnBoard($task_id, $board_id);
174 512
175 513 if (isset($task->parent_id)) {
176 - $task = Task::findOrFail($task->parent_id);
514 + $task = $this->taskService->findTaskOnBoard($task->parent_id, $board_id, false);
177 515 }
178 516
179 517 if(!$task) {
180 - throw new \Exception(__('Task not found', 'fluent-boards'));
518 + throw new \Exception(esc_html__('Task not found', 'fluent-boards'));
181 519 }
182 520
183 521 if (defined('FLUENT_BOARDS_PRO')) {
184 522 $task->load(['attachments']);
@@ -183,11 +521,12 @@
183 521 if (defined('FLUENT_BOARDS_PRO')) {
184 522 $task->load(['attachments']);
185 523 }
186 524
187 - $task->load(['board', 'stage', 'labels', 'assignees']);
525 + $task->load(['board', 'stage', 'labels', 'assignees','watchers']);
188 526
189 527 $task->assignees = Helper::sanitizeUserCollections($task->assignees);
528 + $task->watchers = Helper::sanitizeUserCollections($task->watchers);
190 529
191 530 $task->isOverdue = $task->isOverdue();
192 531 $task->contact = Task::lead_contact($task->crm_contact_id);
193 532 $task->board->stages = $stageService->stagesByBoardId($board_id);
@@ -211,9 +550,10 @@
211 550 }
212 551
213 552 public function getStageType(Request $request)
214 553 {
215 - $stage = Stage::findOrFail($request->stage_id);
554 + $stage_id = $request->getSafe('stage_id', 'intval');
555 + $stage = Stage::findOrFail($stage_id);
216 556
217 557 return [
218 558 'stage' => $stage,
219 559 ];
@@ -220,10 +560,13 @@
220 560 }
221 561
222 562 public function getActivities(Request $request, $board_id, $task_id)
223 563 {
224 - $filter = $request->getSafe('filter');
564 + $board_id = absint($board_id);
565 + $task_id = absint($task_id);
566 + $filter = $request->getSafe('filter', 'sanitize_text_field');
225 567 $per_page = 15; // Apparently, let's use a fixed number of items per page.
568 + $this->taskService->findTaskOnBoard($task_id, $board_id);
226 569
227 570 return [
228 571 'activities' => $this->taskService->getActivities($task_id, $per_page, $filter)
229 572 ];
@@ -231,27 +574,236 @@
231 574 }
232 575
233 576 public function getArchivedTasks(Request $request, $board_id)
234 577 {
235 - $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 + ];
236 585
586 +
587 + $tasks = $this->taskService->getArchivedTasks($sanitizedParams, $board_id);
588 +
237 589 foreach ($tasks as $task) {
238 590 $task->assignees = Helper::sanitizeUserCollections($task->assignees);
239 591 }
240 592
593 +
241 594 return [
242 595 'tasks' => $tasks
243 596 ];
244 597 }
245 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 +
246 751 public function updateTaskProperties(Request $request, $board_id, $task_id)
247 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
248 756 $col = $request->getSafe('property', 'sanitize_text_field');
249 - $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 + }
250 780
251 781 $validatedData = $this->updateTaskPropValidationAndSanitation($col, $value);
252 - $task = Task::with(['board', 'labels', 'assignees'])->findOrFail($task_id);
782 + $task = $this->taskService->findTaskOnBoard($task_id, $board_id);
783 + $task->load(['board', 'labels', 'assignees']);
253 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 +
254 806 if ($task->parent_id && !$task->board_id) {
255 807 $task->board_id = $board_id;
256 808 $task->save();
257 809 }
@@ -262,11 +814,32 @@
262 814 $task->contact = Helper::crm_contact($task->crm_contact_id);
263 815 $task->is_watching = $task->isWatching();
264 816 $task->assignees = Helper::sanitizeUserCollections($task->assignees);
265 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 +
266 827 // A recent update to a task might impact other tasks on the board.
267 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 + }
268 837
838 + if (!$taskExists) {
839 + $updatedTasks[] = $task;
840 + }
841 +
269 842 return [
270 843 'message' => __('Task has been updated', 'fluent-boards'),
271 844 'task' => $task,
272 845 'updatedTasks' => $updatedTasks
@@ -272,35 +845,167 @@
272 845 'updatedTasks' => $updatedTasks
273 846 ];
274 847 }
275 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 +
276 869 public function updateTaskDates(Request $request, $board_id, $task_id)
277 870 {
278 - $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();
279 875
280 - $startAt = $request->getSafe('started_at', 'sanitize_text_field', NULL);
281 - $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 + ];
282 881
283 - 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) {
284 899 if (strtotime($startAt) > strtotime($dueAt)) {
285 - $startAt = gmdate('Y-m-d 00:00:00', strtotime($dueAt));
900 + $startAt = substr($dueAt, 0, 10) . ' 00:00:00';
286 901 }
287 902 }
288 903
289 - $task = $this->taskService->updateTaskProperty('started_at', $startAt, $task);
290 - $task = $this->taskService->updateTaskProperty('due_at', $dueAt, $task);
904 + if ($hasStartAt) {
905 + $task = $this->taskService->updateTaskProperty('started_at', $startAt, $task);
906 + }
291 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 +
292 940 return [
293 941 'task' => $task,
294 - 'message' => __('Dates has been updated', 'fluent-boards'),
942 + 'message' => __('Dates have been updated', 'fluent-boards'),
295 943 'updatedTasks' => $this->taskService->getLastOneMinuteUpdatedTasks($board_id),
296 944 ];
297 945 }
298 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 +
299 1002 public function updateTaskCoverPhoto(Request $request, $board_id, $task_id)
300 1003 {
301 - $imagePath = $request->thumbnail;
302 - $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);
303 1008
304 1009 return [
305 1010 'message' => __('Task cover photo has been updated', 'fluent-boards'),
306 1011 'task' => $task,
@@ -309,17 +1014,22 @@
309 1014 }
310 1015
311 1016 public function taskStatusUpdate(Request $request, $board_id, $task_id)
312 1017 {
1018 + $board_id = absint($board_id);
1019 + $task_id = absint($task_id);
1020 + $integrationType = $request->getSafe('integrationType', 'sanitize_text_field');
313 1021 return [
314 1022 'message' => __('Task status has been updated', 'fluent-boards'),
315 - 'task' => $this->taskService->taskStatusUpdate($task_id, $request->integrationType),
1023 + 'task' => $this->taskService->taskStatusUpdate($task_id, $integrationType, $board_id),
316 1024 ];
317 1025 }
318 1026
319 1027 public function deleteTask($board_id, $task_id)
320 1028 {
321 - $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);
322 1032 $options = null;
323 1033 //if we need to do something before a task is deleted
324 1034 do_action('fluent_boards/before_task_deleted', $task, $options);
325 1035
@@ -337,8 +1047,39 @@
337 1047
338 1048 return $this->validate($data, $rules);
339 1049 }
340 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 +
341 1082 private function updateTaskPropValidationAndSanitation($col, $value)
342 1083 {
343 1084 $rules = [
344 1085 'title' => 'required|string',
@@ -344,9 +1085,9 @@
344 1085 'title' => 'required|string',
345 1086 'board_id' => 'required',
346 1087 'parent_id' => 'required',
347 1088 'crm_contact_id' => 'nullable',
348 - 'task_type' => 'nullable|string',
1089 + 'type' => 'nullable|string',
349 1090 'status' => 'nullable|string',
350 1091 'stage_id' => 'required',
351 1092 'reminder_type' => 'nullable|string',
352 1093 'priority' => 'nullable|string',
@@ -361,9 +1102,9 @@
361 1102 'log_minutes' => 'nullable|integer|unsigned',
362 1103 'last_completed' => 'nullable|date',
363 1104 'assignees' => 'nullable|integer',
364 1105 'archived_at' => 'nullable|string',
365 - 'is_watching' => 'nullable|string',
1106 + 'is_watching' => 'nullable',
366 1107 'is_template' => 'string',
367 1108 'last_completed_at' => 'nullable',
368 1109 'settings' => 'nullable|array',
369 1110 ];
@@ -380,8 +1121,11 @@
380 1121 }
381 1122
382 1123 return [$col => $sanitizedAndValidatedValue];
383 1124 }
1125 + if ('is_watching' == $col && is_array($value)) {
1126 + return [$col => $value];
1127 + }
384 1128 $data = Helper::sanitizeTask([$col => $value]);
385 1129
386 1130 return $this->validate($data, [
387 1131 $col => $rule,
@@ -386,22 +1130,22 @@
386 1130 return $this->validate($data, [
387 1131 $col => $rule,
388 1132 ]);
389 1133 }
390 - }
391 1134
392 - public function getLabelsByTask($task_id)
393 - {
394 - $labels = $this->taskService->getLabelsByTask($task_id);
395 -
396 - return $this->sendSuccess([
397 - 'labels' => $labels,
398 - ], 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)));
399 1138 }
400 1139
401 1140 public function getStageByTask($task_id)
402 1141 {
403 - $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 + }
404 1148
405 1149 return [
406 1150 'stage' => $stage,
407 1151 ];
@@ -408,8 +1152,10 @@
408 1152 }
409 1153
410 1154 public function assignYourselfInTask($board_id, $task_id)
411 1155 {
1156 + $board_id = absint($board_id);
1157 + $task_id = absint($task_id);
412 1158 $task = $this->taskService->assignYourselfInTask($board_id, $task_id);
413 1159 $task->is_watching = $task->isWatching();
414 1160
415 1161 return [
@@ -418,8 +1164,10 @@
418 1164 }
419 1165
420 1166 public function detachYourselfFromTask($board_id, $task_id)
421 1167 {
1168 + $board_id = absint($board_id);
1169 + $task_id = absint($task_id);
422 1170 $task = $this->taskService->detachYourselfFromTask($board_id, $task_id);
423 1171 $task->assignees = Helper::sanitizeUserCollections($task->assignees);
424 1172 $task->is_watching = $task->isWatching();
425 1173
@@ -436,9 +1184,11 @@
436 1184 }
437 1185
438 1186 public function moveTaskToNextStage($board_id, $task_id)
439 1187 {
440 - $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);
441 1191
442 1192 return [
443 1193 'task' => $task
444 1194 ];
@@ -448,29 +1198,71 @@
448 1198 * @throws \Exception
449 1199 */
450 1200 public function moveTask(Request $request, $board_id, $task_id)
451 1201 {
452 - $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);
453 1205 $oldStageId = $task->stage_id;
454 1206 $newStageId = $request->getSafe('newStageId', 'intval');
455 1207 $newIndex = $request->getSafe('newIndex', 'intval');
456 1208 $newBoardId = $request->getSafe('newBoardId', 'intval');
1209 + $prevTaskId = $request->getSafe('prevTaskId', 'intval');
1210 + $nextTaskId = $request->getSafe('nextTaskId', 'intval');
457 1211
458 1212 if ((!is_numeric($newStageId) || $newStageId == 0)) {
459 - throw new \Exception(__('Invalid Stage', 'fluent-boards'));
1213 + throw new \Exception(esc_html__('Invalid Stage', 'fluent-boards'));
460 1214 }
461 - if ((!is_numeric($newIndex) || $newIndex == 0)) {
462 - 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'));
463 1218 }
1219 +
464 1220 if ($newBoardId) {
465 1221 if ((!is_numeric($newBoardId) || $newBoardId == 0)) {
466 - throw new \Exception(__('Invalid Board', 'fluent-boards'));
1222 + throw new \Exception(esc_html__('Invalid Board', 'fluent-boards'));
467 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) {
468 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']);
469 1247 }
470 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 +
471 1256 $task->stage_id = $newStageId;
472 - $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 + }
473 1265
474 1266 if ($oldStageId != $newStageId) {
475 1267
476 1268 $this->taskService->manageDefaultAssignees($task, $newStageId);
@@ -490,12 +1282,12 @@
490 1282 }
491 1283
492 1284 do_action('fluent_boards/task_updated', $task, 'position');
493 1285
494 - $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);
495 1288
496 1289 return [
497 - 'new_position' => $task,
498 1290 'message' => __('Task has been updated', 'fluent-boards'),
499 1291 'task' => $task,
500 1292 'updatedTasks' => $updatedTasks,
501 1293 'last_updated' => current_time('mysql')
@@ -511,15 +1303,17 @@
511 1303 * @return \WP_REST_Response The response containing paginated comments and activities, total count, current page, and items per page.
512 1304 */
513 1305 public function getCommentsAndActivities( Request $request, $board_id, $task_id)
514 1306 {
1307 + $board_id = absint($board_id);
1308 + $task_id = absint($task_id);
515 1309 try {
516 1310 // Pagination parameters
517 - $page = $request->get('page', 1);
518 - $perPage = $request->get('per_page', 10);
519 - $filter = $request->get('filter', 'newest'); // Filter for comments and activities
520 -
521 - $commentsAndActivities = $this->taskService->getCommentsAndActivities($task_id, $perPage, $page, $filter);
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);
522 1316 // Return the response with the task, paginated comments and activities, total count, current page, and items per page
523 1317 return $this->sendSuccess([
524 1318 'comments_and_activities' => $commentsAndActivities,
525 1319 ]);
@@ -537,14 +1331,35 @@
537 1331 as_enqueue_async_action('fluent_boards/one_time_schedule_send_email_for_stage_change', [$taskId, $usersToSendEmail, $current_user_id], 'fluent-boards');
538 1332 }
539 1333 public function getAssociatedTasks($associated_id)
540 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);
541 1340 return [
542 - 'tasks' => $this->taskService->getAssociatedTasks($associated_id)
1341 + 'tasks' => $this->taskService->getAssociatedTasks($associated_id, get_current_user_id())
543 1342 ];
544 1343 }
545 1344
546 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 + /**
547 1362 * @param Request $request
548 1363 * @param $board_id
549 1364 * @param $task_id
550 1365 * @return \WP_REST_Response
@@ -550,9 +1365,12 @@
550 1365 * @return \WP_REST_Response
551 1366 */
552 1367 public function uploadMediaFileFromWpEditor(Request $request, $board_id, $task_id)
553 1368 {
1369 + $board_id = absint($board_id);
1370 + $task_id = absint($task_id);
554 1371 try {
1372 + $this->taskService->findTaskOnBoard($task_id, $board_id);
555 1373
556 1374
557 1375 $file = Arr::get($request->files(), 'file')->toArray();
558 1376 (new \FluentBoards\App\Services\UploadService)->validateFile($file);
@@ -567,11 +1385,12 @@
567 1385 $fileUploadedData['file_path'] = $mediaData['file_path'];
568 1386 $fileUploadedData['full_url'] = $mediaData['full_url'];
569 1387 $fileUploadedData->save();
570 1388 }
1389 + $fileUploadedData['public_url'] = (new CommentService())->createPublicUrl($fileUploadedData, $board_id);
571 1390
572 1391 return $this->sendSuccess([
573 - 'message' => __('Image has been uploaded', 'fluent-boards-pro'),
1392 + 'message' => __('Image has been uploaded', 'fluent-boards'),
574 1393 'file' => $fileUploadedData
575 1394 ], 200);
576 1395
577 1396
@@ -579,5 +1398,398 @@
579 1398 return $this->sendError($e->getMessage(), 400);
580 1399 }
581 1400 }
582 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 + }
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 +
1427 + public function handleTaskCoverImageUpload(Request $request, $board_id, $task_id)
1428 + {
1429 + $board_id = absint($board_id);
1430 + $task_id = absint($task_id);
1431 + try {
1432 + $task = $this->taskService->findTaskOnBoard($task_id, $board_id);
1433 +
1434 + $file = Arr::get($request->files(), 'file')->toArray();
1435 + (new \FluentBoards\App\Services\UploadService)->validateFile($file);
1436 +
1437 + $uploadInfo = UploadService::handleFileUpload( $request->files(), $board_id);
1438 +
1439 + $fileData = $uploadInfo[0];
1440 + $fileUploadedData = $this->taskService->uploadMediaFileFromWpEditor($task_id, $fileData, Constant::TASK_DESCRIPTION);
1441 + if(!!defined('FLUENT_BOARDS_PRO_VERSION')) {
1442 + $mediaData = (new AttachmentService())->processMediaData($fileData, $file);
1443 + $fileUploadedData['driver'] = $mediaData['driver'];
1444 + $fileUploadedData['file_path'] = $mediaData['file_path'];
1445 + $fileUploadedData['full_url'] = $mediaData['full_url'];
1446 + $fileUploadedData->save();
1447 + }
1448 +
1449 + $settings = $task->settings;
1450 + $this->taskService->deleteTaskCoverImage($settings);
1451 + $publicUrl = (new CommentService())->createPublicUrl($fileUploadedData, $board_id);
1452 +
1453 + $settings['cover'] = [
1454 + 'imageId' => $fileUploadedData['id'],
1455 + 'backgroundImage' => $publicUrl,
1456 + ];
1457 + $task->settings = $settings;
1458 + $task->save();
1459 +
1460 + return $this->sendSuccess([
1461 + 'message' => __('Image has been uploaded', 'fluent-boards'),
1462 + 'public_url' => $publicUrl
1463 + ], 200);
1464 +
1465 +
1466 + } catch (\Exception $e) {
1467 + return $this->sendError($e->getMessage(), 400);
1468 + }
1469 + }
1470 + public function removeTaskCover($board_id, $task_id)
1471 + {
1472 + $board_id = absint($board_id);
1473 + $task_id = absint($task_id);
1474 + try {
1475 + $task = $this->taskService->findTaskOnBoard($task_id, $board_id);
1476 + $settings = $task->settings;
1477 + $this->taskService->deleteTaskCoverImage($settings);
1478 + unset($settings['cover']);
1479 + $task->settings = $settings;
1480 + $task->save();
1481 + return $this->sendSuccess([
1482 + 'task' => $task,
1483 + 'message' => __('Task Cover removed successfully', 'fluent-boards'),
1484 + ]);
1485 + } catch (\Exception $e) {
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);
1793 + }
1794 + }
583 1795 }