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