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 +1414 -58 1.13trunk View file →
@@ -1,20 +1,25 @@
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;
13 15 use FluentBoards\App\Services\NotificationService;
16 +use FluentBoards\App\Services\UploadService;
14 17 use FluentBoards\Framework\Http\Request\Request;
15 18 use FluentBoards\App\Services\PermissionManager;
16 19 use FluentBoards\Framework\Support\Arr;
20 +use FluentBoardsPro\App\Services\AttachmentService;
21 +use FluentCrm\App\Models\Subscriber;
17 22
18 23 class TaskController extends Controller
19 24 {
20 25 private TaskService $taskService;
@@ -32,41 +37,434 @@
32 37 public function getTopTasksForBoards()
33 38 {
34 39 $userId = get_current_user_id();
35 40 $task_ids = PermissionManager::getTaskIdsWatchByUser($userId);
36 - $tasksArray = $this->taskService->getTasksForBoards(['overdue', 'upcoming'], 5, $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();
37 58
38 59 return [
39 60 'data' => $tasksArray,
61 + 'counts' => $taskCounts,
40 62 ];
41 63 }
42 64
43 - public function getTasksByBoard($board_id)
65 + public function getTasksByBoard(Request $request, $board_id)
44 66 {
45 - $tasks = Task::with(['assignees', 'labels', 'watchers'])
67 + $board_id = absint($board_id);
68 + $board = Board::findOrFail($board_id);
69 + $includeArchived = $request->getSafe('include_archived', 'boolval', false);
70 +
71 + // Get stage IDs
72 + $stageIds = $this->getStageIdsByBoard($board_id, $includeArchived);
73 +
74 + // Fetch tasks for the board
75 + $tasksQuery = Task::with(['assignees', 'labels', 'watchers', 'taskCustomFields'])
46 76 ->where('board_id', $board_id)
47 - ->whereNull('archived_at')
48 77 ->whereNull('parent_id')
49 - ->orderBy('due_at', 'ASC')
78 + ->whereIn('stage_id', $stageIds)
79 + ->orderBy('due_at', 'ASC');
80 +
81 + if (!$includeArchived) {
82 + $tasksQuery->whereNull('archived_at');
83 + }
84 +
85 + $tasks = $tasksQuery->get();
86 +
87 + // Process each task
88 + $this->processTasks($tasks, $board);
89 +
90 + if ($board->type === 'roadmap') {
91 + $this->taskService->loadIdeaVoteStatistics($tasks);
92 + }
93 +
94 + return [
95 + 'tasks' => $tasks,
96 + ];
97 + }
98 +
99 + public function getTasksByBoardStage(Request $request, $board_id)
100 + {
101 + $board_id = absint($board_id);
102 + $board = Board::findOrFail($board_id);
103 + $includeArchived = $request->getSafe('include_archived', 'boolval', false);
104 +
105 + // Get stage IDs
106 + $stageIds = $this->getStageIdsByBoard($board_id, $includeArchived);
107 + $stageTaskCounts = $this->getStageTaskCounts($board_id, $stageIds, $includeArchived);
108 +
109 + // Initialize tasks array
110 + $tasks = [];
111 + $paginationByStage = [];
112 +
113 + // Fetch and process tasks for each stage
114 + foreach ($stageIds as $stageId) {
115 + $stageTasks = $this->makeStageTasksQuery($board_id, $stageId, $includeArchived)
116 + ->orderBy('position', 'ASC')
117 + ->limit(20)
118 + ->get();
119 +
120 + // Process each stage's tasks
121 + $this->processTasks($stageTasks, $board, [
122 + 'includeContact' => false,
123 + 'includeObserverState' => false,
124 + 'includeRoadmapPopularity' => false,
125 + ]);
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 + ];
145 + }
146 +
147 + return [
148 + 'tasks' => $tasks,
149 + 'pagination_by_stage' => $paginationByStage,
150 + ];
151 + }
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)
50 265 ->get();
51 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 +
316 + /**
317 + * Get Stage IDs by Board ID.
318 + *
319 + * @param int $board_id
320 + * @return array
321 + */
322 + private function getStageIdsByBoard($board_id, $includeArchived = false)
323 + {
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 + })
372 + ->toArray();
373 + }
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 +
403 + /**
404 + * Process and append extra information for each task.
405 + *
406 + * @param \Illuminate\Database\Eloquent\Collection $tasks
407 + * @param \App\Models\Board $board
408 + * @param array $options
409 + */
410 + private function processTasks($tasks, $board, $options = [])
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 +
52 417 foreach ($tasks as $task) {
418 + $taskIds[] = (int) $task->id;
419 + }
420 +
421 + $unreadNotificationCounts = $this->notificationService->getUnreadNotificationCountsByTaskIds($taskIds);
422 +
423 + foreach ($tasks as $task) {
53 424 $task->isOverdue = $task->isOverdue();
54 425 $task->isUpcoming = $task->upcoming();
55 - $task->contact = Helper::crm_contact($task->crm_contact_id);
56 - $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 + }
57 432 $task->assignees = Helper::sanitizeUserCollections($task->assignees);
58 433 $task->watchers = Helper::sanitizeUserCollections($task->watchers);
434 + $task->notifications = $unreadNotificationCounts[(int) $task->id] ?? 0;
435 +
436 + // If the board type is 'roadmap', calculate popularity
437 + if ($includeRoadmapPopularity && $board->type === 'roadmap') {
438 + $task->popular = $task->getPopularCount();
439 + }
59 440 }
60 -
61 - return [
62 - 'tasks' => $tasks,
63 - ];
64 441 }
65 442
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 + */
66 450 public function create(Request $request, $board_id)
67 451 {
68 - $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, [
69 467 'title' => 'required|string',
70 468 'board_id' => 'required|numeric',
71 469 'stage_id' => 'required|numeric',
72 470 'priority' => 'nullable|string',
@@ -74,17 +472,28 @@
74 472 'is_template' => 'string',
75 473 ]);
76 474
77 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 +
78 484 if ($taskData['board_id'] != $board_id) {
79 - throw new \Exception(__('Board id is not valid', 'fluent-boards'));
485 + throw new \Exception(esc_html__('Board id is not valid', 'fluent-boards'));
80 486 }
81 487
82 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');
83 492
84 493 return $this->sendSuccess([
85 494 'task' => $task,
86 - 'message' => __('Task has been successfully created', 'fluent-boards'),
495 + 'message' => $message,
87 496 'updatedTasks' => $this->taskService->getLastOneMinuteUpdatedTasks($task->board_id)
88 497 ], 201);
89 498 } catch (\Exception $e) {
90 499 return $this->sendError($e->getMessage(), 400);
@@ -92,24 +501,32 @@
92 501 }
93 502
94 503 public function find($board_id, $task_id)
95 504 {
505 + $board_id = absint($board_id);
506 + $task_id = absint($task_id);
96 507 try {
97 508
98 509 $stageService = new StageService();
99 510
100 - $task = Task::findOrFail($task_id);
511 + $task = $this->taskService->findTaskOnBoard($task_id, $board_id);
101 512
513 + if (isset($task->parent_id)) {
514 + $task = $this->taskService->findTaskOnBoard($task->parent_id, $board_id, false);
515 + }
516 +
102 517 if(!$task) {
103 - throw new \Exception(__('Task not found', 'fluent-boards'));
518 + throw new \Exception(esc_html__('Task not found', 'fluent-boards'));
104 519 }
520 +
105 521 if (defined('FLUENT_BOARDS_PRO')) {
106 522 $task->load(['attachments']);
107 523 }
108 524
109 - $task->load(['board', 'stage', 'labels', 'assignees']);
525 + $task->load(['board', 'stage', 'labels', 'assignees','watchers']);
110 526
111 527 $task->assignees = Helper::sanitizeUserCollections($task->assignees);
528 + $task->watchers = Helper::sanitizeUserCollections($task->watchers);
112 529
113 530 $task->isOverdue = $task->isOverdue();
114 531 $task->contact = Task::lead_contact($task->crm_contact_id);
115 532 $task->board->stages = $stageService->stagesByBoardId($board_id);
@@ -133,9 +550,10 @@
133 550 }
134 551
135 552 public function getStageType(Request $request)
136 553 {
137 - $stage = Stage::findOrFail($request->stage_id);
554 + $stage_id = $request->getSafe('stage_id', 'intval');
555 + $stage = Stage::findOrFail($stage_id);
138 556
139 557 return [
140 558 'stage' => $stage,
141 559 ];
@@ -142,10 +560,13 @@
142 560 }
143 561
144 562 public function getActivities(Request $request, $board_id, $task_id)
145 563 {
146 - $filter = $request->getSafe('filter');
564 + $board_id = absint($board_id);
565 + $task_id = absint($task_id);
566 + $filter = $request->getSafe('filter', 'sanitize_text_field');
147 567 $per_page = 15; // Apparently, let's use a fixed number of items per page.
568 + $this->taskService->findTaskOnBoard($task_id, $board_id);
148 569
149 570 return [
150 571 'activities' => $this->taskService->getActivities($task_id, $per_page, $filter)
151 572 ];
@@ -153,27 +574,236 @@
153 574 }
154 575
155 576 public function getArchivedTasks(Request $request, $board_id)
156 577 {
157 - $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 + ];
158 585
586 +
587 + $tasks = $this->taskService->getArchivedTasks($sanitizedParams, $board_id);
588 +
159 589 foreach ($tasks as $task) {
160 590 $task->assignees = Helper::sanitizeUserCollections($task->assignees);
161 591 }
162 592
593 +
163 594 return [
164 595 'tasks' => $tasks
165 596 ];
166 597 }
167 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 +
168 751 public function updateTaskProperties(Request $request, $board_id, $task_id)
169 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
170 756 $col = $request->getSafe('property', 'sanitize_text_field');
171 - $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 + }
172 780
173 781 $validatedData = $this->updateTaskPropValidationAndSanitation($col, $value);
174 - $task = Task::with(['board', 'labels', 'assignees'])->findOrFail($task_id);
782 + $task = $this->taskService->findTaskOnBoard($task_id, $board_id);
783 + $task->load(['board', 'labels', 'assignees']);
175 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 +
176 806 if ($task->parent_id && !$task->board_id) {
177 807 $task->board_id = $board_id;
178 808 $task->save();
179 809 }
@@ -184,11 +814,32 @@
184 814 $task->contact = Helper::crm_contact($task->crm_contact_id);
185 815 $task->is_watching = $task->isWatching();
186 816 $task->assignees = Helper::sanitizeUserCollections($task->assignees);
187 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 +
188 827 // A recent update to a task might impact other tasks on the board.
189 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 + }
190 837
838 + if (!$taskExists) {
839 + $updatedTasks[] = $task;
840 + }
841 +
191 842 return [
192 843 'message' => __('Task has been updated', 'fluent-boards'),
193 844 'task' => $task,
194 845 'updatedTasks' => $updatedTasks
@@ -194,35 +845,167 @@
194 845 'updatedTasks' => $updatedTasks
195 846 ];
196 847 }
197 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 +
198 869 public function updateTaskDates(Request $request, $board_id, $task_id)
199 870 {
200 - $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();
201 875
202 - $startAt = $request->getSafe('started_at', 'sanitize_text_field', NULL);
203 - $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 + ];
204 881
205 - 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) {
206 899 if (strtotime($startAt) > strtotime($dueAt)) {
207 - $startAt = gmdate('Y-m-d 00:00:00', strtotime($dueAt));
900 + $startAt = substr($dueAt, 0, 10) . ' 00:00:00';
208 901 }
209 902 }
210 903
211 - $task = $this->taskService->updateTaskProperty('started_at', $startAt, $task);
212 - $task = $this->taskService->updateTaskProperty('due_at', $dueAt, $task);
904 + if ($hasStartAt) {
905 + $task = $this->taskService->updateTaskProperty('started_at', $startAt, $task);
906 + }
213 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 +
214 940 return [
215 941 'task' => $task,
216 - 'message' => __('Dates has been updated', 'fluent-boards'),
942 + 'message' => __('Dates have been updated', 'fluent-boards'),
217 943 'updatedTasks' => $this->taskService->getLastOneMinuteUpdatedTasks($board_id),
218 944 ];
219 945 }
220 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 +
221 1002 public function updateTaskCoverPhoto(Request $request, $board_id, $task_id)
222 1003 {
223 - $imagePath = $request->thumbnail;
224 - $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);
225 1008
226 1009 return [
227 1010 'message' => __('Task cover photo has been updated', 'fluent-boards'),
228 1011 'task' => $task,
@@ -231,17 +1014,22 @@
231 1014 }
232 1015
233 1016 public function taskStatusUpdate(Request $request, $board_id, $task_id)
234 1017 {
1018 + $board_id = absint($board_id);
1019 + $task_id = absint($task_id);
1020 + $integrationType = $request->getSafe('integrationType', 'sanitize_text_field');
235 1021 return [
236 1022 'message' => __('Task status has been updated', 'fluent-boards'),
237 - 'task' => $this->taskService->taskStatusUpdate($task_id, $request->integrationType),
1023 + 'task' => $this->taskService->taskStatusUpdate($task_id, $integrationType, $board_id),
238 1024 ];
239 1025 }
240 1026
241 1027 public function deleteTask($board_id, $task_id)
242 1028 {
243 - $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);
244 1032 $options = null;
245 1033 //if we need to do something before a task is deleted
246 1034 do_action('fluent_boards/before_task_deleted', $task, $options);
247 1035
@@ -259,8 +1047,39 @@
259 1047
260 1048 return $this->validate($data, $rules);
261 1049 }
262 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 +
263 1082 private function updateTaskPropValidationAndSanitation($col, $value)
264 1083 {
265 1084 $rules = [
266 1085 'title' => 'required|string',
@@ -266,9 +1085,9 @@
266 1085 'title' => 'required|string',
267 1086 'board_id' => 'required',
268 1087 'parent_id' => 'required',
269 1088 'crm_contact_id' => 'nullable',
270 - 'task_type' => 'nullable|string',
1089 + 'type' => 'nullable|string',
271 1090 'status' => 'nullable|string',
272 1091 'stage_id' => 'required',
273 1092 'reminder_type' => 'nullable|string',
274 1093 'priority' => 'nullable|string',
@@ -283,9 +1102,9 @@
283 1102 'log_minutes' => 'nullable|integer|unsigned',
284 1103 'last_completed' => 'nullable|date',
285 1104 'assignees' => 'nullable|integer',
286 1105 'archived_at' => 'nullable|string',
287 - 'is_watching' => 'nullable|string',
1106 + 'is_watching' => 'nullable',
288 1107 'is_template' => 'string',
289 1108 'last_completed_at' => 'nullable',
290 1109 'settings' => 'nullable|array',
291 1110 ];
@@ -302,8 +1121,11 @@
302 1121 }
303 1122
304 1123 return [$col => $sanitizedAndValidatedValue];
305 1124 }
1125 + if ('is_watching' == $col && is_array($value)) {
1126 + return [$col => $value];
1127 + }
306 1128 $data = Helper::sanitizeTask([$col => $value]);
307 1129
308 1130 return $this->validate($data, [
309 1131 $col => $rule,
@@ -308,22 +1130,22 @@
308 1130 return $this->validate($data, [
309 1131 $col => $rule,
310 1132 ]);
311 1133 }
312 - }
313 1134
314 - public function getLabelsByTask($task_id)
315 - {
316 - $labels = $this->taskService->getLabelsByTask($task_id);
317 -
318 - return $this->sendSuccess([
319 - 'labels' => $labels,
320 - ], 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)));
321 1138 }
322 1139
323 1140 public function getStageByTask($task_id)
324 1141 {
325 - $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 + }
326 1148
327 1149 return [
328 1150 'stage' => $stage,
329 1151 ];
@@ -330,9 +1152,12 @@
330 1152 }
331 1153
332 1154 public function assignYourselfInTask($board_id, $task_id)
333 1155 {
1156 + $board_id = absint($board_id);
1157 + $task_id = absint($task_id);
334 1158 $task = $this->taskService->assignYourselfInTask($board_id, $task_id);
1159 + $task->is_watching = $task->isWatching();
335 1160
336 1161 return [
337 1162 'task' => $task,
338 1163 ];
@@ -339,10 +1164,13 @@
339 1164 }
340 1165
341 1166 public function detachYourselfFromTask($board_id, $task_id)
342 1167 {
1168 + $board_id = absint($board_id);
1169 + $task_id = absint($task_id);
343 1170 $task = $this->taskService->detachYourselfFromTask($board_id, $task_id);
344 1171 $task->assignees = Helper::sanitizeUserCollections($task->assignees);
1172 + $task->is_watching = $task->isWatching();
345 1173
346 1174 return [
347 1175 'task' => $task,
348 1176 ];
@@ -356,9 +1184,11 @@
356 1184 }
357 1185
358 1186 public function moveTaskToNextStage($board_id, $task_id)
359 1187 {
360 - $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);
361 1191
362 1192 return [
363 1193 'task' => $task
364 1194 ];
@@ -368,32 +1198,76 @@
368 1198 * @throws \Exception
369 1199 */
370 1200 public function moveTask(Request $request, $board_id, $task_id)
371 1201 {
372 - $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);
373 1205 $oldStageId = $task->stage_id;
374 1206 $newStageId = $request->getSafe('newStageId', 'intval');
375 1207 $newIndex = $request->getSafe('newIndex', 'intval');
376 1208 $newBoardId = $request->getSafe('newBoardId', 'intval');
1209 + $prevTaskId = $request->getSafe('prevTaskId', 'intval');
1210 + $nextTaskId = $request->getSafe('nextTaskId', 'intval');
377 1211
378 1212 if ((!is_numeric($newStageId) || $newStageId == 0)) {
379 - throw new \Exception(__('Invalid Stage', 'fluent-boards'));
1213 + throw new \Exception(esc_html__('Invalid Stage', 'fluent-boards'));
380 1214 }
381 - if ((!is_numeric($newIndex) || $newIndex == 0)) {
382 - 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'));
383 1218 }
1219 +
384 1220 if ($newBoardId) {
385 1221 if ((!is_numeric($newBoardId) || $newBoardId == 0)) {
386 - throw new \Exception(__('Invalid Board', 'fluent-boards'));
1222 + throw new \Exception(esc_html__('Invalid Board', 'fluent-boards'));
387 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) {
388 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']);
389 1247 }
390 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 +
391 1256 $task->stage_id = $newStageId;
392 - $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 + }
393 1265
394 1266 if ($oldStageId != $newStageId) {
395 1267
1268 + $this->taskService->manageDefaultAssignees($task, $newStageId);
1269 +
396 1270 $defaultPosition = $task->stage->defaultTaskStatus();
397 1271
398 1272 if ($defaultPosition == 'closed' && $task->status != 'closed') {
399 1273 $task = $task->close();
@@ -398,9 +1272,9 @@
398 1272 if ($defaultPosition == 'closed' && $task->status != 'closed') {
399 1273 $task = $task->close();
400 1274 }
401 1275
402 - do_action('fluent_boards/task_moved_to_new_stage', $task, $oldStageId);
1276 +// do_action('fluent_boards/task_moved_to_new_stage', $task, $oldStageId);
403 1277
404 1278 do_action('fluent_boards/task_stage_updated', $task, $oldStageId);
405 1279
406 1280 $usersToSendEmail = $this->notificationService->filterAssigneeToSendEmail($task->id, Constant::BOARD_EMAIL_STAGE_CHANGE);
@@ -408,12 +1282,12 @@
408 1282 }
409 1283
410 1284 do_action('fluent_boards/task_updated', $task, 'position');
411 1285
412 - $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);
413 1288
414 1289 return [
415 - 'new_position' => $task,
416 1290 'message' => __('Task has been updated', 'fluent-boards'),
417 1291 'task' => $task,
418 1292 'updatedTasks' => $updatedTasks,
419 1293 'last_updated' => current_time('mysql')
@@ -419,8 +1293,36 @@
419 1293 'last_updated' => current_time('mysql')
420 1294 ];
421 1295 }
422 1296
1297 + /**
1298 + * Get comments and activities for a task, merged into a single array, sorted by creation date, and paginated.
1299 + *
1300 + * @param Request $request The HTTP request instance.
1301 + * @param int $board_id The ID of the board.
1302 + * @param int $task_id The ID of the task.
1303 + * @return \WP_REST_Response The response containing paginated comments and activities, total count, current page, and items per page.
1304 + */
1305 + public function getCommentsAndActivities( Request $request, $board_id, $task_id)
1306 + {
1307 + $board_id = absint($board_id);
1308 + $task_id = absint($task_id);
1309 + try {
1310 + // Pagination parameters
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);
1316 + // Return the response with the task, paginated comments and activities, total count, current page, and items per page
1317 + return $this->sendSuccess([
1318 + 'comments_and_activities' => $commentsAndActivities,
1319 + ]);
1320 + } catch (\Exception $e) {
1321 + return $this->sendError($e->getMessage(), 500);
1322 + }
1323 + }
1324 +
423 1325 public function sendMailAfterStageChange($usersToSendEmail, $taskId)
424 1326 {
425 1327 $current_user_id = get_current_user_id();
426 1328
@@ -427,13 +1329,467 @@
427 1329 /* this will run in background as soon as possible */
428 1330 /* sending Model or Model Instance won't work here */
429 1331 as_enqueue_async_action('fluent_boards/one_time_schedule_send_email_for_stage_change', [$taskId, $usersToSendEmail, $current_user_id], 'fluent-boards');
430 1332 }
431 -
432 1333 public function getAssociatedTasks($associated_id)
433 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);
434 1340 return [
435 - 'tasks' => $this->taskService->getAssociatedTasks($associated_id)
1341 + 'tasks' => $this->taskService->getAssociatedTasks($associated_id, get_current_user_id())
436 1342 ];
437 1343 }
438 1344
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 + /**
1362 + * @param Request $request
1363 + * @param $board_id
1364 + * @param $task_id
1365 + * @return \WP_REST_Response
1366 + */
1367 + public function uploadMediaFileFromWpEditor(Request $request, $board_id, $task_id)
1368 + {
1369 + $board_id = absint($board_id);
1370 + $task_id = absint($task_id);
1371 + try {
1372 + $this->taskService->findTaskOnBoard($task_id, $board_id);
1373 +
1374 +
1375 + $file = Arr::get($request->files(), 'file')->toArray();
1376 + (new \FluentBoards\App\Services\UploadService)->validateFile($file);
1377 +
1378 + $uploadInfo = UploadService::handleFileUpload( $request->files(), $board_id);
1379 +
1380 + $fileData = $uploadInfo[0];
1381 + $fileUploadedData = $this->taskService->uploadMediaFileFromWpEditor($task_id, $fileData, Constant::TASK_DESCRIPTION);
1382 + if(!!defined('FLUENT_BOARDS_PRO_VERSION')) {
1383 + $mediaData = (new AttachmentService())->processMediaData($fileData, $file);
1384 + $fileUploadedData['driver'] = $mediaData['driver'];
1385 + $fileUploadedData['file_path'] = $mediaData['file_path'];
1386 + $fileUploadedData['full_url'] = $mediaData['full_url'];
1387 + $fileUploadedData->save();
1388 + }
1389 + $fileUploadedData['public_url'] = (new CommentService())->createPublicUrl($fileUploadedData, $board_id);
1390 +
1391 + return $this->sendSuccess([
1392 + 'message' => __('Image has been uploaded', 'fluent-boards'),
1393 + 'file' => $fileUploadedData
1394 + ], 200);
1395 +
1396 +
1397 + } catch (\Exception $e) {
1398 + return $this->sendError($e->getMessage(), 400);
1399 + }
1400 + }
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 + }
439 1795 }