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/Services/TaskService.php +2885 -215 1.122.1.0 View file →
@@ -1,36 +1,118 @@
1 1 <?php
2 2
3 3 namespace FluentBoards\App\Services;
4 4
5 +use FluentBoards\Framework\Database\Orm\ModelNotFoundException;
6 +use FluentBoards\App\App;
7 +use FluentBoards\App\Models\Attachment;
8 +use FluentBoards\App\Models\Comment;
9 +use FluentBoards\App\Models\Notification;
10 +use FluentBoards\App\Models\NotificationUser;
11 +use FluentBoards\App\Models\TaskImage;
5 12 use FluentBoards\App\Services\Constant;
6 -use FluentBoardsPro\App\Models\Attachment;
13 +use FluentBoards\App\Models\Label;
7 14 use FluentBoards\App\Models\Stage;
8 15 use FluentBoards\App\Models\Task;
9 16 use FluentBoards\App\Models\Board;
10 17 use FluentBoards\App\Models\TaskMeta;
18 +use FluentBoards\App\Models\Meta;
11 19 use FluentBoards\App\Models\Activity;
12 -use FluentBoards\App\Models\BoardTerm;
20 +use FluentBoards\App\Models\CommentImage;
21 +use FluentBoards\App\Models\Relation;
13 22 use FluentBoards\Framework\Support\Arr;
23 +use FluentBoardsPro\App\Models\TaskAttachment;
24 +use FluentBoardsPro\App\Services\AttachmentService;
25 +use FluentBoardsPro\App\Services\RemoteUrlParser;
14 26 use FluentRoadmap\App\Models\IdeaReaction;
15 27
16 28 class TaskService
17 29 {
30 + private static $physicalTableNameCache = [];
31 +
32 + /**
33 + * Resolve a task only when it belongs to the requested board.
34 + *
35 + * Subtasks normally carry the same board_id as their parent, but the parent
36 + * fallback protects older data where that relationship may be incomplete.
37 + * Missing or mismatched tasks use the router's deliberate 404 response.
38 + *
39 + * @param int $taskId
40 + * @param int $boardId
41 + * @param bool $allowParentFallback
42 + * @return Task
43 + * @throws ModelNotFoundException
44 + */
45 + public function findTaskOnBoard($taskId, $boardId, $allowParentFallback = true)
46 + {
47 + $taskId = absint($taskId);
48 + $boardId = absint($boardId);
49 +
50 + if (!$taskId || !$boardId) {
51 + throw new ModelNotFoundException(esc_html__('Task not found', 'fluent-boards'));
52 + }
53 +
54 + $task = Task::where('id', $taskId)
55 + ->where('board_id', $boardId)
56 + ->first();
57 +
58 + if ($task) {
59 + return $this->normalizeTaskDescriptionForEditor($task);
60 + }
61 +
62 + if ($allowParentFallback) {
63 + $task = Task::where('id', $taskId)
64 + ->whereNull('board_id')
65 + ->whereNotNull('parent_id')
66 + ->first();
67 +
68 + if ($task) {
69 + $parentBoardId = Task::where('id', $task->parent_id)->value('board_id');
70 +
71 + if ((int) $parentBoardId === $boardId) {
72 + return $this->normalizeTaskDescriptionForEditor($task);
73 + }
74 + }
75 + }
76 +
77 + throw new ModelNotFoundException(esc_html__('Task not found', 'fluent-boards'));
78 + }
79 +
80 + private function normalizeTaskDescriptionForEditor(Task $task)
81 + {
82 + $task->description = DescriptionMarkdownConverter::normalize($task->description);
83 +
84 + return $task;
85 + }
86 +
18 87 public function createTask($data, $boardId)
19 88 {
20 89 $board = Board::select('id', 'type')->find($boardId);
21 90
22 91 if (!$board) {
23 - throw new \Exception(__("Board doesn't exists", 'fluent-boards'));
92 + throw new \Exception(esc_html__("Board doesn't exists", 'fluent-boards'));
24 93 }
25 94
26 95 $stage = Stage::find($data['stage_id']);
27 96 if (!$stage) {
28 - throw new \Exception(__("Stage doesn't exists", 'fluent-boards'));
97 + throw new \Exception(esc_html__("Stage doesn't exists", 'fluent-boards'));
29 98 }
30 99
100 + if ((int) $stage->board_id !== (int) $boardId) {
101 + throw new \Exception(esc_html__("Stage doesn't exists", 'fluent-boards'));
102 + }
103 +
31 104 $data['status'] = $stage->defaultTaskStatus();
32 105
106 + // Image covers require a persisted task, so creation only accepts a sanitized color cover.
107 + $coverColor = sanitize_hex_color(Arr::get($data, 'settings.cover.backgroundColor', ''));
108 + $taskSettings = [];
109 + if ($coverColor) {
110 + $taskSettings['cover'] = [
111 + 'backgroundColor' => $coverColor,
112 + ];
113 + }
114 +
33 115 if ($board->type == 'roadmap') {
34 116 $current_user = wp_get_current_user();
35 117 $settingData = array(
36 118 'integration_type' => 'feature',
@@ -38,10 +120,14 @@
38 120 'author' => [
39 121 'email' => $current_user->user_email // email of who posted this feature
40 122 ],
41 123 );
42 - $data['settings'] = $settingData;
124 + $data['settings'] = array_merge($taskSettings, $settingData);
43 125 $data['type'] = 'roadmap';
126 + } elseif ($taskSettings) {
127 + $data['settings'] = $taskSettings;
128 + } else {
129 + unset($data['settings']);
44 130 }
45 131
46 132 $providerPosition = Arr::get($data, 'position');
47 133
@@ -47,11 +133,19 @@
47 133
48 134 $data['position'] = $this->getLastPositionOfTasks($stage->id);
49 135
50 136 $data['board_id'] = $boardId;
137 + $data = Helper::normalizeDates($data, ['due_at', 'started_at', 'last_completed_at', 'archived_at', 'remind_at']);
138 + if (isset($data['description'])) {
139 + $data['description'] = DescriptionMarkdownConverter::normalize($data['description']);
140 + }
51 141
52 142 $data = array_filter($data);
53 143 $task = (new Task())->createTask($data);
144 +
145 + $this->manageDefaultAssignees($task, $stage->id);
146 + $this->manageDefaultWatchers($task, $stage->id);
147 +
54 148 if (isset($data['is_template']) && $data['is_template'] == 'yes') {
55 149 $task->updateMeta(Constant::IS_TASK_TEMPLATE, $data['is_template']);
56 150 }
57 151
@@ -77,20 +171,38 @@
77 171 }
78 172
79 173 public function getTasksForBoards($filters = ['overdue', 'upcoming'], $limit = 5, $task_ids = [])
80 174 {
175 + $assigned = $this->getTasksForBoardsByCategory('assigned', $limit, $task_ids);
81 176 $overDue = $this->getTasksForBoardsByCategory('overdue', $limit, $task_ids);
177 + $dueToday = $this->getTasksForBoardsByCategory('due_today', $limit, $task_ids);
82 178 $completed = $this->getTasksForBoardsByCategory('completed', $limit, $task_ids);
179 + $mentioned = $this->getTasksForBoardsByCategory('mentioned', $limit, $task_ids);
83 180 $upcoming = $this->getTasksForBoardsByCategory('upcoming', $limit, $task_ids);
181 + $others = $this->getTasksForBoardsByCategory('others', $limit, $task_ids);
84 182
85 -
86 183 return [
184 + 'assigned' => $assigned ?? [],
87 185 'overdue' => $overDue ?? [],
186 + 'due_today' => $dueToday ?? [],
88 187 'upcoming' => $upcoming ?? [],
89 - 'completed' => $completed ?? []
188 + 'mentioned' => $mentioned ?? [],
189 + 'completed' => $completed ?? [],
190 + 'others' => $others ?? []
90 191 ];
91 192 }
92 193
194 + public function getTaskCountsForBoards($categories = ['assigned', 'overdue', 'upcoming', 'completed', 'others'], $taskIds = [])
195 + {
196 + $counts = [];
197 +
198 + foreach ($categories as $category) {
199 + $counts[$category] = $this->getTaskCountForBoardsByCategory($category, $taskIds);
200 + }
201 +
202 + return $counts;
203 + }
204 +
93 205 public function getTasksForBoardsByCategory($category, $limit, $taskIds)
94 206 {
95 207 unset($taskQuery);
96 208 $taskQuery = Task::whereIn('id', $taskIds)
@@ -96,20 +208,67 @@
96 208 $taskQuery = Task::whereIn('id', $taskIds)
97 209 ->with(['assignees', 'board', 'stage'])
98 210 ->whereNull('archived_at')
99 211 ->where('parent_id', null)
100 - ->orderBy('due_at', 'ASC');
212 + ->onActiveAvailableBoards()
213 + ->orderBy('due_at', 'DESC');
101 214
102 - if ('overdue' == $category) {
103 - $taskQuery->overdue();
104 - } elseif ('upcoming' == $category) {
105 - $taskQuery->upcoming();
106 - } elseif ('upcoming_no_duedate' == $category) {
107 - $taskQuery->whereNull('due_at');
108 - } elseif ('completed' == $category) {
109 - $taskQuery->where('status', 'closed');
110 - } else {
111 - return [];
215 + switch ($category) {
216 + case 'overdue':
217 + $taskQuery->overdue();
218 + break;
219 + case 'upcoming':
220 + $taskQuery->upcoming();
221 + break;
222 + case 'due_today':
223 + $taskQuery->dueToday();
224 + break;
225 + case 'others':
226 + $taskQuery->whereNull('due_at');
227 + break;
228 + case 'completed':
229 + $taskQuery->where('status', 'closed');
230 + break;
231 + case 'assigned':
232 + // Rebuild query to order by latest assignment (pivot created_at) so the most recently assigned tasks come first.
233 + $currentUserId = get_current_user_id();
234 + $taskQuery = Task::query()
235 + ->select('fbs_tasks.*')
236 + ->distinct()
237 + ->with(['assignees', 'board', 'stage'])
238 + ->whereIn('fbs_tasks.id', $taskIds)
239 + ->whereNull('fbs_tasks.archived_at')
240 + ->whereNull('fbs_tasks.parent_id')
241 + ->where('fbs_tasks.status', '!=', 'closed')
242 + ->onActiveAvailableBoards()
243 + ->join('fbs_relations as rel', function ($join) use ($currentUserId) {
244 + $join->on('rel.object_id', '=', 'fbs_tasks.id')
245 + ->where('rel.object_type', Constant::OBJECT_TYPE_TASK_ASSIGNEE)
246 + ->where('rel.foreign_id', $currentUserId);
247 + })
248 + ->orderBy('rel.created_at', 'DESC')
249 + ->orderBy('fbs_tasks.updated_at', 'DESC');
250 + break;
251 + case 'mentioned':
252 + $currentUserId = get_current_user_id();
253 + $userNotifications = NotificationUser::where('user_id', $currentUserId)
254 + ->with(['notification' => function ($query) {
255 + $query->where('action', 'task_comment_mentioned');
256 + }])
257 + ->orderBy('created_at', 'desc')
258 + ->get();
259 + $taskIds = $userNotifications->filter(function ($userNotification) {
260 + $notification = $userNotification->notification;
261 + return $notification && $notification->task && is_null($notification->task->archived_at) && is_null($notification->task->parent_id);
262 + })->pluck('notification.task_id')->unique();
263 + $validTasks = Task::whereIn('id', $taskIds)
264 + ->with(['assignees', 'board', 'stage'])
265 + ->onActiveAvailableBoards()
266 + ->get();
267 +
268 + return $validTasks->toArray();
269 + default:
270 + return [];
112 271 }
113 272
114 273 $tasks = $taskQuery->take($limit)->get();
115 274
@@ -115,8 +274,108 @@
115 274
116 275 return $tasks->toArray();
117 276 }
118 277
278 + public function getTaskCountForBoardsByCategory($category, $taskIds)
279 + {
280 + if (empty($taskIds)) {
281 + return 0;
282 + }
283 +
284 + $taskQuery = Task::query()
285 + ->whereIn('id', $taskIds)
286 + ->whereNull('archived_at')
287 + ->whereNull('parent_id')
288 + ->onActiveAvailableBoards();
289 +
290 + switch ($category) {
291 + case 'overdue':
292 + $taskQuery->overdue();
293 + break;
294 + case 'upcoming':
295 + $taskQuery->upcoming();
296 + break;
297 + case 'due_today':
298 + $taskQuery->dueToday();
299 + break;
300 + case 'completed':
301 + $taskQuery->where('status', 'closed');
302 + break;
303 + case 'others':
304 + $taskQuery->whereNull('due_at');
305 + break;
306 + case 'assigned':
307 + $currentUserId = get_current_user_id();
308 + $taskQuery = Task::query()
309 + ->select('fbs_tasks.id')
310 + ->whereIn('fbs_tasks.id', $taskIds)
311 + ->whereNull('fbs_tasks.archived_at')
312 + ->whereNull('fbs_tasks.parent_id')
313 + ->where('fbs_tasks.status', '!=', 'closed')
314 + ->onActiveAvailableBoards()
315 + ->join('fbs_relations as rel', function ($join) use ($currentUserId) {
316 + $join->on('rel.object_id', '=', 'fbs_tasks.id')
317 + ->where('rel.object_type', Constant::OBJECT_TYPE_TASK_ASSIGNEE)
318 + ->where('rel.foreign_id', $currentUserId);
319 + });
320 +
321 + return (int) $taskQuery->distinct()->count('fbs_tasks.id');
322 + case 'mentioned':
323 + $currentUserId = get_current_user_id();
324 + $taskIds = NotificationUser::where('user_id', $currentUserId)
325 + ->with(['notification' => function ($query) {
326 + $query->where('action', 'task_comment_mentioned')
327 + ->with('task');
328 + }])
329 + ->get()
330 + ->filter(function ($userNotification) {
331 + $notification = $userNotification->notification;
332 +
333 + return $notification && $notification->task && is_null($notification->task->archived_at) && is_null($notification->task->parent_id);
334 + })
335 + ->pluck('notification.task_id')
336 + ->unique();
337 +
338 + return Task::whereIn('id', $taskIds)->onActiveAvailableBoards()->count();
339 + default:
340 + return 0;
341 + }
342 +
343 + return (int) $taskQuery->count();
344 + }
345 +
346 + /**
347 + * Unlink a Fluent Support ticket from a board-scoped task.
348 + *
349 + * @param int $taskId
350 + * @param int $boardId
351 + * @return Task
352 + * @throws \Exception
353 + */
354 + public function removeSupportTicketLink($taskId, $boardId)
355 + {
356 + $taskId = absint($taskId);
357 + $boardId = absint($boardId);
358 + $task = $this->findTaskOnBoard($taskId, $boardId);
359 +
360 + if ($task->source !== Constant::TASK_SOURCE_FLUENT_SUPPORT || !$task->source_id) {
361 + return $task;
362 + }
363 +
364 + $ticketId = $task->source_id;
365 + $settings = is_array($task->settings) ? $task->settings : [];
366 + unset($settings['author']);
367 +
368 + $task->source = null;
369 + $task->source_id = null;
370 + $task->settings = $settings ?: null;
371 + $task->save();
372 +
373 + do_action('fluent_boards/support_ticket_unlinked', $task, $ticketId);
374 +
375 + return $task;
376 + }
377 +
119 378 /*
120 379 * TODO: Refactor this function - For me.
121 380 */
122 381 public function updateTaskProperty($col, $value, $task)
@@ -123,70 +382,135 @@
123 382 {
124 383 $oldTask = clone $task; // normal assigning won't work here. because objects are passed by reference in php
125 384 $validColumns = [
126 385 'board_id',
127 - 'task_type',
128 - 'reminder_type',
386 + 'type',
387 +// 'reminder_type',
129 388 'remind_at',
130 389 'log_minutes',
390 + 'source',
391 + 'source_id',
131 392 'settings'
132 393 ];
133 394
395 + if ($col === 'description') {
396 + $value = DescriptionMarkdownConverter::normalize($value);
397 + }
398 +
134 399 if (in_array($col, $validColumns) && $task->{$col} != $value) {
135 - $task->{$col} = $value;
136 - $task->save();
137 - // do_action('fluent_boards/task_prop_changed', $col, $task, $oldTask);
138 - } elseif ('assignees' == $col) {
139 - if (is_array($value)) {
140 - foreach ($value as $id) {
141 - $this->updateAssignee($id, $task);
142 - }
143 - } else {
144 - $this->updateAssignee($value, $task);
400 + if ($col === 'remind_at') {
401 + $value = Helper::normalizeDateValue($value);
145 402 }
146 403
147 - } elseif ('crm_contact_id' == $col) {
148 - $this->updateAssociate($value, $task);
149 - } elseif ('archived_at' == $col) {
150 - $this->updateArchive($value, $task);
151 - } elseif ('status' == $col) {
152 - $this->updateStatus($value, $task);
153 - } elseif ('parent_id' == $col) {
154 - $this->updateParent($value, $task);
155 - } elseif ('title' == $col) {
156 - $this->updateTitle($col, $value, $task, $oldTask);
157 - } elseif ('description' == $col) {
158 - $this->updateDescription($col, $value, $task);
159 - } elseif ($col == 'due_at') {
160 - $this->updateDueDate($value, $task);
161 - } elseif ($col == 'started_at') {
162 - $this->updateStartedDate($value, $task);
163 - } elseif ($col == 'priority') {
164 - $this->updatePriority($value, $task);
165 - } elseif ($col == 'is_watching') {
166 - $this->updateObservationOfCurrentUser($value, $task);
167 - } elseif ($col == 'last_completed_at') {
168 - $isClosed = $value == 'true' || $value === true;
169 - if ($isClosed) {
170 - $task = $task->close();
171 - } else {
172 - $task = $task->reopen();
404 + if ($col == 'settings' && isset($value['cover']['backgroundColor']) && $value['cover']['backgroundColor']) {
405 + $settings = $task->settings;
406 + $this->deleteTaskCoverImage($settings);
407 + unset($value['cover']['imageId']);
408 + unset($value['cover']['backgroundImage']);
173 409 }
410 + $task->{$col} = $value ?: null;
174 411 $task->save();
175 - } elseif ($col == 'attachment_count') {
176 - $settings = $task->settings;
177 - $settings['attachment_count'] = $task->attachments()->count();
178 - $task->settings = $settings;
179 - $task->save();
180 - } elseif ($col == 'subtask_count') {
181 - $settings = $task->settings;
182 - $subtasksCount = Task::where('parent_id', $task->id)->count();
183 - $settings['subtask_count'] = $subtasksCount;
184 - $task->settings = $settings;
185 - $task->save();
186 - } elseif ($col == 'is_template') {
187 - if (defined('FLUENT_BOARDS_PRO')) {
188 - $task->updateMeta(Constant::IS_TASK_TEMPLATE, $value);
412 + // do_action('fluent_boards/task_prop_changed', $col, $task, $oldTask);
413 + } else {
414 + switch ($col) {
415 + case 'assignees':
416 + if (is_array($value)) {
417 + foreach ($value as $id) {
418 + $this->updateAssignee($id, $task);
419 + }
420 + } else {
421 + $this->updateAssignee($value, $task);
422 + }
423 + break;
424 +
425 + case 'crm_contact_id':
426 + $this->updateAssociate($value, $task);
427 + break;
428 +
429 + case 'archived_at':
430 + $this->updateArchive($value, $task);
431 + break;
432 +
433 + case 'status':
434 + $this->updateStatus($value, $task);
435 + break;
436 +
437 + case 'parent_id':
438 + $this->updateParent($value, $task);
439 + break;
440 +
441 + case 'title':
442 + $this->updateTitle($col, $value, $task, $oldTask);
443 + break;
444 +
445 + case 'description':
446 + $this->updateDescription($col, $value, $task, $oldTask);
447 + break;
448 +
449 + case 'due_at':
450 + $this->updateDueDate($value, $task);
451 + break;
452 +
453 + case 'started_at':
454 + $this->updateStartedDate($value, $task);
455 + break;
456 +
457 + case 'priority':
458 + $this->updatePriority($value, $task);
459 + break;
460 +
461 + case 'is_watching':
462 + $this->updateObservationOfUser($value, $task);
463 + break;
464 +
465 + case 'last_completed_at':
466 + $isClosed = $value == 'true' || $value === true;
467 + if ($isClosed) {
468 + $task = $task->close();
469 + } else {
470 + $task = $task->reopen();
471 + }
472 + $task->save();
473 + break;
474 +
475 + case 'attachment_count':
476 + $settings = $task->settings;
477 + $settings['attachment_count'] = $task->attachments()->count();
478 + $task->settings = $settings;
479 + $task->save();
480 + break;
481 +
482 + case 'subtask_count':
483 + $settings = $task->settings;
484 + $subtasksCount = Task::where('parent_id', $task->id)->count();
485 + $settings['subtask_count'] = $subtasksCount;
486 + $task->settings = $settings;
487 + $task->save();
488 + break;
489 +
490 + case 'is_template':
491 + if (defined('FLUENT_BOARDS_PRO')) {
492 + $task->updateMeta(Constant::IS_TASK_TEMPLATE, $value);
493 + }
494 + break;
495 +
496 + case 'reminder_type':
497 + if (defined('FLUENT_BOARDS_PRO')) {
498 + $allowedTypes = Helper::taskReminderTypes();
499 +
500 + // check in keys of allowed types
501 + if (array_key_exists($value, $allowedTypes)) {
502 +
503 + $value = $value;
504 + } else {
505 + $value = null;
506 + }
507 +
508 + $task->reminder_type = $value;
509 + $task->save();
510 + do_action('fluent_boards/task_reminder_type_changed', $task, $value);
511 + }
512 + break;
189 513 }
190 514 }
191 515
192 516 return $task;
@@ -200,19 +524,22 @@
200 524
201 525 $task->save();
202 526
203 527 if ($operation == 'added') {
204 - if ((new NotificationService())->checkIfEmailEnable($payloadAssigneeId, Constant::BOARD_EMAIL_TASK_ASSIGN, $task->board_id)) {
528 + if ((new NotificationService())->checkIfEmailEnable($payloadAssigneeId, Constant::BOARD_EMAIL_TASK_ASSIGN, $task->board_id) && $payloadAssigneeId != get_current_user_id()) {
205 529 $this->sendMailAfterTaskModify('add_assignee', $payloadAssigneeId, $task->id);
206 530 }
207 531 // $assigneeIdsToSendEmail = $this->filterAssigneeToSendEmail($task, $idArray, Constant::BOARD_EMAIL_TASK_ASSIGN);
208 532 // $this->sendMailAfterAddAssignees($assigneeIdsToSendEmail, $task->id);
209 - do_action('fluent_boards/task_assignee_changed', $task, $payloadAssigneeId, $operation);
533 + do_action('fluent_boards/task_assignee_added', $task, $payloadAssigneeId);
534 + if($payloadAssigneeId != get_current_user_id()){
535 + do_action('fluent_boards/assign_another_user', $task, $payloadAssigneeId);
536 + }
210 537 } else {
211 - if ((new NotificationService())->checkIfEmailEnable($payloadAssigneeId, Constant::BOARD_EMAIL_REMOVE_FROM_TASK, $task->board_id)) {
538 + if ((new NotificationService())->checkIfEmailEnable($payloadAssigneeId, Constant::BOARD_EMAIL_REMOVE_FROM_TASK, $task->board_id) && $payloadAssigneeId != get_current_user_id()) {
212 539 $this->sendMailAfterTaskModify('remove_assignee', $payloadAssigneeId, $task->id);
213 540 }
214 - do_action('fluent_boards/task_assignee_changed', $task, $payloadAssigneeId, $operation);
541 + do_action('fluent_boards/task_assignee_removed', $task, $payloadAssigneeId);
215 542 }
216 543
217 544 }
218 545
@@ -265,17 +592,34 @@
265 592
266 593 private function updateArchive($value, $task)
267 594 {
268 595 if ($value != null) {
596 + // Archiving task
269 597 $task->position = 0;
270 598 } else {
599 + // Restoring task - check if stage is archived
600 + $stage = Stage::find($task->stage_id);
601 + if ($stage && $stage->archived_at !== null) {
602 + throw new \Exception(
603 + sprintf(
604 + // translators: %s is the archived stage title.
605 + esc_html__('This task cannot be restored because its stage "%s" is archived. Please restore the stage first.', 'fluent-boards'),
606 + esc_html($stage->title)
607 + ),
608 + 400
609 + );
610 + }
611 +
271 612 $task->moveToNewPosition(1);
613 +
614 + // Clean up archived_by_stage meta when task is manually restored
615 + $this->cleanupArchivedByStageMetaIfExists($task->id);
272 616 }
273 617 $task->archived_at = $value == null ? null : current_time('mysql');
274 618 $task->save();
275 - do_action('fluent_boards/board_task_archived', $task);
276 - $wathersToSendEmail = (new NotificationService())->filterAssigneeToSendEmail($task->id, Constant::BOARD_EMAIL_TASK_ARCHIVE);
277 - $this->sendMailAfterTaskModify('task_archived', $wathersToSendEmail, $task->id);
619 + do_action('fluent_boards/task_archived', $task);
620 + $watchersToSendEmail = (new NotificationService())->filterAssigneeToSendEmail($task->id, Constant::BOARD_EMAIL_TASK_ARCHIVE);
621 + $this->sendMailAfterTaskModify('task_archived', $watchersToSendEmail, $task->id);
278 622 }
279 623
280 624 private function updateStatus($value, $task)
281 625 {
@@ -300,25 +644,29 @@
300 644 $task->save();
301 645 do_action('fluent_boards/task_content_updated', $task, $col, $oldTask);
302 646 }
303 647
304 - private function updateDescription($col, $value, $task)
648 + private function updateDescription($col, $value, $task, $oldTask)
305 649 {
306 - $task->description = $value;
650 + $task->description = DescriptionMarkdownConverter::normalize($value);
307 651 $task->save();
308 - do_action('fluent_boards/task_content_updated', $task, $col, null);
652 + do_action('fluent_boards/task_content_updated', $task, $col, $oldTask);
309 653 }
310 654
311 655 private function updateDueDate($value, $task)
312 656 {
313 657 $oldValue = $task->due_at;
314 - $value = $this->filterNullDate($value);
658 + $value = Helper::normalizeDateValue($value);
315 659 $task->due_at = $value;
316 660 $task->save();
317 661
318 662 $task = $task->reopen();
319 663
320 - do_action('fluent_boards/task_date_changed', $task, $oldValue, 'Due Date');
664 + if($value){
665 + do_action('fluent_boards/task_due_date_changed', $task, $oldValue);
666 + } else {
667 + do_action('fluent_boards/task_due_date_removed', $task);
668 + }
321 669
322 670 $wathersToSendEmail = (new NotificationService())->filterAssigneeToSendEmail($task->id, Constant::BOARD_EMAIL_DUE_DATE_CHANGE);
323 671 $this->sendMailAfterTaskModify('due_date_update', $wathersToSendEmail, $task->id);
324 672 }
@@ -325,14 +673,15 @@
325 673
326 674 private function updateStartedDate($value, $task)
327 675 {
328 676 $oldValue = $task->started_at;
329 - $value = $this->filterNullDate($value);
677 + $value = Helper::normalizeDateValue($value);
330 678 $task->started_at = $value;
331 679 $task->save();
332 680
333 - do_action('fluent_boards/task_date_changed', $task, $oldValue, 'Start Date');
334 -
681 + if($value){
682 + do_action('fluent_boards/task_start_date_changed', $task, $oldValue);
683 + }
335 684 }
336 685
337 686 private function updatePriority($value, $task)
338 687 {
@@ -341,46 +690,53 @@
341 690 $task->save();
342 691 do_action('fluent_boards/task_priority_changed', $task, $oldPriority);
343 692 }
344 693
345 - public function updateObservationOfCurrentUser($value, $task)
346 - {
347 - $currentUserId = get_current_user_id();
694 + public function updateObservationOfUser($value, $task)
695 +{
696 + if (is_array($value) && isset($value['userId'])) {
697 + $userId = intval($value['userId']);
698 + $action = isset($value['action']) ? $value['action'] : 'start';
699 + } else {
700 + $userId = get_current_user_id();
701 + $action = is_string($value) ? $value : 'start';
702 + }
348 703
349 - if ($value == 'stop') {
350 - $task->watchers()->detach($currentUserId);
351 - } else {
352 - $task->watchers()->syncWithoutDetaching([$currentUserId => ['object_type' => Constant::OBJECT_TYPE_USER_TASK_WATCH]]);
353 - }
354 - $task->updated_at = current_time('mysql');
355 - $task->save();
704 + if (!$userId || !in_array($action, ['stop', 'start'])) {
705 + return;
706 + }
356 707
357 - if ($value == 'stop') {
358 - $task->is_watching = false;
359 - } else {
360 - $task->is_watching = true;
361 - }
708 + if ($action == 'stop') {
709 + $task->watchers()->detach($userId);
710 + } else {
711 + $task->watchers()->syncWithoutDetaching([$userId => ['object_type' => Constant::OBJECT_TYPE_USER_TASK_WATCH]]);
362 712 }
713 + $task->updated_at = current_time('mysql');
714 + $task->save();
715 +}
363 716
364 - public function taskCoverPhotoUpdate($taskId, $imagePath)
717 + public function taskCoverPhotoUpdate($taskId, $imagePath, $boardId = null)
365 718 {
366 - $task = Task::find($taskId);
719 + $task = $boardId ? $this->findTaskOnBoard($taskId, $boardId) : Task::find($taskId);
367 720 if (!$task) {
368 721 return null;
369 722 }
370 723
371 - $settings = unserialize($task->settings);
724 + $settings = $task->settings;
725 + if (!is_array($settings)) {
726 + $settings = [];
727 + }
372 728
373 729 $settings['logo'] = $imagePath;
374 - $task->settings = serialize($settings);
730 + $task->settings = $settings;
375 731 $task->save();
376 732
377 733 return $task;
378 734 }
379 735
380 - public function taskStatusUpdate($taskId, $integrationType)
736 + public function taskStatusUpdate($taskId, $integrationType, $boardId = null)
381 737 {
382 - $task = Task::find($taskId);
738 + $task = $boardId ? $this->findTaskOnBoard($taskId, $boardId) : Task::find($taskId);
383 739 if (!$task) {
384 740 return null;
385 741 }
386 742
@@ -385,9 +741,9 @@
385 741 }
386 742
387 743 $settings = $task->settings;
388 744 $settings['integration_type'] = $integrationType;
389 - $task->settings = serialize($settings);
745 + $task->settings = $settings;
390 746 $task->save();
391 747
392 748 return $task;
393 749 }
@@ -393,9 +749,9 @@
393 749 }
394 750
395 751 public function assignYourselfInTask($boardId, $taskId)
396 752 {
397 - $task = Task::find($taskId);
753 + $task = $this->findTaskOnBoard($taskId, $boardId);
398 754 $authUserId = get_current_user_id();
399 755
400 756 $boardService = new BoardService();
401 757 if (!$boardService->isAlreadyMember($boardId, $authUserId)) {
@@ -402,10 +758,13 @@
402 758 $boardService->addMembersInBoard($boardId, $authUserId);
403 759 }
404 760
405 761 $task->addOrRemoveAssignee($authUserId);
762 + // when user assign himself then he will be watching that task
763 + $task->watchers()->syncWithoutDetaching([$authUserId => ['object_type' => Constant::OBJECT_TYPE_USER_TASK_WATCH]]);
406 764
407 765 $task->load('assignees');
766 + do_action('fluent_boards/task_assignee_added', $task, $authUserId);
408 767
409 768 return $task;
410 769 }
411 770
@@ -410,11 +769,13 @@
410 769 }
411 770
412 771 public function detachYourselfFromTask($boardId, $taskId)
413 772 {
414 - $task = Task::find($taskId);
415 - $task->addOrRemoveAssignee(get_current_user_id());
773 + $task = $this->findTaskOnBoard($taskId, $boardId);
774 + $currentUserId = get_current_user_id();
775 + $task->addOrRemoveAssignee($currentUserId);
416 776 $task->load('assignees');
777 + do_action('fluent_boards/task_assignee_removed', $task, $currentUserId);
417 778
418 779 return $task;
419 780 }
420 781
@@ -419,8 +780,158 @@
419 780 }
420 781
421 782 public function deleteTask($task)
422 783 {
784 + // If this is a parent task, delete all subtasks first
785 + if (!$task->parent_id) {
786 + $subtasks = Task::where('parent_id', $task->id)->get();
787 + foreach ($subtasks as $subtask) {
788 + // Recursively delete each subtask (cleans up all their relations)
789 + $this->deleteTask($subtask);
790 + }
791 + }
792 +
793 + $this->deleteTasksBatch([$task]);
794 + }
795 +
796 + /**
797 + * Delete the supplied tasks and their owned records without discovering children.
798 + *
799 + * @param iterable $tasks
800 + * @param bool $manageTransaction Set false only when the caller owns an active transaction.
801 + * @return void
802 + * @throws \Throwable
803 + */
804 + public function deleteTasksBatch($tasks, $manageTransaction = true)
805 + {
806 + if (!is_array($tasks) && !($tasks instanceof \Traversable)) {
807 + $tasks = [$tasks];
808 + }
809 +
810 + $deletedTasks = [];
811 + $taskBoardIds = [];
812 + foreach ($tasks as $task) {
813 + if (!$task instanceof Task) {
814 + continue;
815 + }
816 +
817 + $taskId = (int) $task->id;
818 + if ($taskId < 1) {
819 + continue;
820 + }
821 +
822 + $deletedTasks[$taskId] = clone $task;
823 + $taskBoardIds[$taskId] = (int) $task->board_id;
824 + }
825 +
826 + if (!$deletedTasks) {
827 + return;
828 + }
829 +
830 + ksort($deletedTasks, SORT_NUMERIC);
831 + $taskIds = array_keys($deletedTasks);
832 + $dbInstance = App::getInstance('db');
833 +
834 + if (!$manageTransaction && !$dbInstance->inTransaction()) {
835 + throw new \RuntimeException(__('An active transaction is required for caller-managed task deletion.', 'fluent-boards'));
836 + }
837 +
838 + if ($manageTransaction) {
839 + $dbInstance->beginTransaction();
840 + }
841 +
842 + try {
843 + $relationTypes = [
844 + Constant::OBJECT_TYPE_USER_TASK_WATCH,
845 + Constant::OBJECT_TYPE_TASK_ASSIGNEE,
846 + Constant::OBJECT_TYPE_TASK_LABEL,
847 + ];
848 +
849 + if (defined('FLUENT_BOARDS_PRO_VERSION')) {
850 + $relationTypes[] = \FluentBoardsPro\App\Services\Constant::TASK_CUSTOM_FIELD;
851 + }
852 +
853 + Relation::whereIn('object_id', $taskIds)
854 + ->whereIn('object_type', $relationTypes)
855 + ->delete();
856 +
857 + Relation::where('object_type', Constant::OBJECT_TYPE_TASK_DEPENDENCY)
858 + ->where(function ($query) use ($taskIds) {
859 + $query->whereIn('object_id', $taskIds)
860 + ->orWhereIn('foreign_id', $taskIds);
861 + })
862 + ->delete();
863 +
864 + $notificationIds = Notification::whereIn('task_id', $taskIds)->pluck('id')->toArray();
865 + NotificationUser::whereIn('notification_id', $notificationIds)->delete();
866 + Notification::whereIn('task_id', $taskIds)->delete();
867 +
868 + $this->deleteTaskAttachmentsBatch($taskIds, $taskBoardIds);
869 + Activity::whereIn('object_id', $taskIds)
870 + ->where('object_type', Constant::ACTIVITY_TASK)
871 + ->delete();
872 + Meta::whereIn('object_id', $taskIds)
873 + ->where('object_type', Constant::REPEAT_TASK_META)
874 + ->delete();
875 + $this->deleteTimeTrackingRecords($taskIds, false);
876 + TaskMeta::whereIn('task_id', $taskIds)->delete();
877 +
878 + $deletedCount = Task::whereIn('id', $taskIds)->delete();
879 + if ($deletedCount !== count($taskIds)) {
880 + throw new \RuntimeException(__('Task could not be deleted.', 'fluent-boards'));
881 + }
882 +
883 + $this->dispatchTaskDeletedHooksAfterCommit($dbInstance, $deletedTasks);
884 +
885 + if ($manageTransaction) {
886 + $dbInstance->commit();
887 + }
888 + } catch (\Throwable $e) {
889 + if ($manageTransaction) {
890 + $dbInstance->rollBack();
891 + }
892 +
893 + throw $e;
894 + }
895 + }
896 +
897 + /**
898 + * Dispatch task deletion hooks after the outermost transaction commits.
899 + *
900 + * @param mixed $dbInstance
901 + * @param array $deletedTasks
902 + * @return void
903 + */
904 + private function dispatchTaskDeletedHooksAfterCommit($dbInstance, $deletedTasks)
905 + {
906 + $dbInstance->afterCommit(function () use ($deletedTasks) {
907 + foreach ($deletedTasks as $deletedTask) {
908 + try {
909 + do_action('fluent_boards/task_deleted', $deletedTask);
910 + } catch (\Throwable $e) {
911 + error_log(sprintf(
912 + 'FluentBoards: Failed to dispatch committed task deletion hook for task %d: %s',
913 + (int) $deletedTask->id,
914 + sanitize_text_field($e->getMessage())
915 + ));
916 + }
917 + }
918 + });
919 + }
920 +
921 + public function deleteTaskForBulk($task)
922 + {
923 + // If this is a parent task, delete all subtasks first
924 + if (!$task->parent_id) {
925 + $subtasks = Task::where('parent_id', $task->id)->get();
926 + foreach ($subtasks as $subtask) {
927 + // Recursively delete each subtask (cleans up all their relations)
928 + $this->deleteTaskForBulk($subtask);
929 + }
930 + }
931 +
932 + $this->deleteTimeTrackingRecords($task->id, false);
933 +
423 934 $deleted = $task->delete();
424 935
425 936 if ($deleted) {
426 937
@@ -428,26 +939,26 @@
428 939 $task->watchers()->detach();
429 940 $task->assignees()->detach();
430 941
431 942 //removing all task related notifications
943 + $notificationIds = $task->notifications->pluck('id');
432 944 $task->notifications()->delete();
945 + NotificationUser::whereIn('notification_id', $notificationIds)->delete();
433 946
434 947 //task labels removed
435 948 $task->labels()->detach();
436 949
437 - do_action('fluent_boards/task_deleted', $task);
950 + //task custom field value
951 + if (defined('FLUENT_BOARDS_PRO_VERSION')) {
952 + $task->customFields()->detach();
953 + $this->deleteTaskAttachments($task);
954 + }
955 +
956 + // For bulk delete, you might want to avoid firing hooks/actions,
957 + // so 'fluent_boards/task_deleted' is not triggered here.
438 958 TaskMeta::where('task_id', $task->id)->delete();
439 959 }
440 960 }
441 -
442 - public function filterNullDate($date)
443 - {
444 - if ('0000-00-00 00:00:00' == $date || false === strtotime($date)) {
445 - return null;
446 - }
447 - return $date;
448 - }
449 -
450 961 // this is invoked when task is moved to another board
451 962
452 963 /**
453 964 * @throws \Exception
@@ -453,86 +964,910 @@
453 964 * @throws \Exception
454 965 */
455 966 public function changeBoardByTask($task, $targetBoardId)
456 967 {
968 + // Input validation - must be positive integer
969 + if (!is_numeric($targetBoardId) || $targetBoardId <= 0 || !is_int($targetBoardId + 0) || $targetBoardId != (int)$targetBoardId) {
970 + throw new \Exception(esc_html__('Invalid board id - must be a positive integer', 'fluent-boards'), 400);
971 + }
972 +
973 +
457 974 if ($task->board_id == $targetBoardId) {
458 975 return $task;
459 976 }
460 977
461 978 $oldBoard = Board::find($task->board_id);
979 + $newBoard = Board::find($targetBoardId);
462 980
463 - $newBoard = Board::find($targetBoardId);
981 + if (!$oldBoard) {
982 + throw new \Exception(esc_html__('Source board not found', 'fluent-boards'), 404);
983 + }
984 +
464 985 if (!$newBoard) {
465 - throw new \Exception('Invalid board id', 400);
986 + throw new \Exception(esc_html__('Target board not found', 'fluent-boards'), 404);
466 987 }
467 - $task->board_id = $targetBoardId;
468 - $task->save();
469 - //delete labels of that task because labels have board dependencies
470 - $task->labels()->detach();
471 988
989 +
990 + $dbInstance = App::getInstance('db');
991 + $attachmentFileService = new AttachmentFileService();
992 + $oldBoardId = (int) $task->board_id;
993 +
994 + $dbInstance->beginTransaction();
995 +
996 + try {
997 + $attachmentFileService->moveTaskFilesToBoard($task, $oldBoardId, (int) $targetBoardId);
998 + $this->moveCommentsToBoard($task->id, $oldBoardId, (int) $targetBoardId, $attachmentFileService);
999 +
1000 + $task->board_id = (int) $targetBoardId;
1001 + $task->type = $newBoard->type === 'roadmap' ? 'roadmap' : 'task';
1002 +
1003 + // REMOVE: Board-dependent data
1004 + $task->labels()->detach();
1005 + $task->assignees()->detach();
1006 + $task->watchers()->detach();
1007 + $this->removeCustomFieldAssociations($task);
1008 +
1009 + // REMOVE: Recurring task settings for security
1010 + $this->removeRecurringTaskSettings($task->id);
1011 +
1012 + $task->save();
1013 + do_action('fluent_boards/task_moved_update_time_tracking', $task);
1014 +
1015 + // MOVE: Subtasks to new board (preserves subtask groups)
1016 + $this->moveSubtasksToNewBoard($task->id, $oldBoardId, $targetBoardId, $newBoard->type, $attachmentFileService);
1017 +
1018 + $dbInstance->commit();
1019 + $attachmentFileService->commitMovedOriginalFiles();
1020 + } catch (\Exception $e) {
1021 + $dbInstance->rollBack();
1022 + $attachmentFileService->rollbackCreatedFiles();
1023 + throw $e;
1024 + }
1025 +
472 1026 do_action('fluent_boards/task_moved_from_board', $task, $oldBoard, $newBoard);
1027 + return $task;
1028 + }
473 1029
474 - return $task;
1030 + /**
1031 + * Move all subtasks to the new board when parent task is moved
1032 + * Preserves subtask groups and their relationships
1033 + */
1034 + private function moveSubtasksToNewBoard($parentTaskId, $sourceBoardId, $targetBoardId, $boardType, AttachmentFileService $attachmentFileService)
1035 + {
1036 + // Get all subtasks of the parent task
1037 + $subtasks = Task::where('parent_id', $parentTaskId)->get();
1038 +
1039 + if ($subtasks->isEmpty()) {
1040 + return;
1041 + }
1042 +
1043 + foreach ($subtasks as $subtask) {
1044 + // Update board_id and type
1045 + // Legacy subtasks may not have their own board_id; inherit the parent's source board.
1046 + $oldBoardId = absint($subtask->board_id) ?: absint($sourceBoardId);
1047 + $attachmentFileService->moveTaskFilesToBoard($subtask, $oldBoardId, (int) $targetBoardId);
1048 + $this->moveCommentsToBoard($subtask->id, $oldBoardId, (int) $targetBoardId, $attachmentFileService);
1049 +
1050 + $subtask->board_id = (int) $targetBoardId;
1051 + $subtask->type = $boardType === 'roadmap' ? 'roadmap' : 'task';
1052 +
1053 + // REMOVE: Board-dependent data for subtasks
1054 + $subtask->labels()->detach();
1055 + $subtask->assignees()->detach();
1056 + $subtask->watchers()->detach();
1057 +
1058 + // Remove custom fields but preserve subtask group relationships
1059 + $subtask->taskMeta()
1060 + ->where('key', '!=', Constant::SUBTASK_GROUP_CHILD)
1061 + ->delete();
1062 +
1063 + // REMOVE: Recurring task settings
1064 + $this->removeRecurringTaskSettings($subtask->id);
1065 +
1066 + $subtask->save();
1067 + do_action('fluent_boards/task_moved_update_time_tracking', $subtask);
1068 + }
475 1069 }
476 1070
1071 + /**
1072 + * Remove task cover image for security reasons
1073 + * Keeps background colors but removes image references
1074 + */
1075 + private function removeTaskCoverImage($task)
1076 + {
1077 + $settings = $task->settings;
1078 + if (empty($settings) || !is_array($settings)) {
1079 + return;
1080 + }
477 1081
1082 + if (isset($settings['cover']) && is_array($settings['cover'])) {
1083 + $cover = $settings['cover'];
1084 +
1085 + // Remove image references
1086 + unset($cover['imageId']);
1087 + unset($cover['backgroundImage']);
1088 +
1089 + // Keep only background color if it exists
1090 + if (isset($cover['backgroundColor'])) {
1091 + $settings['cover'] = array('backgroundColor' => $cover['backgroundColor']);
1092 + } else {
1093 + unset($settings['cover']);
1094 + }
1095 +
1096 + $task->settings = $settings;
1097 + }
1098 + }
1099 +
1100 + /**
1101 + * Remove custom field associations for board move
1102 + * Custom field values are stored in fbs_relations table, not fbs_task_meta
1103 + * This method removes task-to-customfield associations from fbs_relations
1104 + */
1105 + private function removeCustomFieldAssociations($task)
1106 + {
1107 + // Remove custom field values from fbs_relations table
1108 + // Custom fields are board-specific, so they must be removed when task moves to different board
1109 + if (defined('FLUENT_BOARDS_PRO')) {
1110 + $task->customFields()->detach();
1111 + }
1112 + }
1113 +
1114 + /**
1115 + * Move a task's complete comment history and images to another board.
1116 + */
1117 + private function moveCommentsToBoard($taskId, $sourceBoardId, $targetBoardId, AttachmentFileService $attachmentFileService)
1118 + {
1119 + $taskId = absint($taskId);
1120 + $sourceBoardId = absint($sourceBoardId);
1121 + $targetBoardId = absint($targetBoardId);
1122 +
1123 + if (!$taskId || !$sourceBoardId || !$targetBoardId || $sourceBoardId === $targetBoardId) {
1124 + return;
1125 + }
1126 +
1127 + $attachmentFileService->moveCommentImagesToBoard($taskId, $sourceBoardId, $targetBoardId);
1128 +
1129 + // Bypass ORM timestamps so only board ownership changes.
1130 + Comment::where('task_id', $taskId)
1131 + ->toBase()
1132 + ->update(['board_id' => $targetBoardId]);
1133 + }
1134 +
1135 + /**
1136 + * Remove time tracking records for security reasons
1137 + * Prevents exposing user-specific time data to unauthorized users
1138 + */
1139 + private function removeTimeTrackingRecords($taskId)
1140 + {
1141 + // Input validation
1142 + if (!is_numeric($taskId) || $taskId <= 0) {
1143 + return;
1144 + }
1145 +
1146 + // Remove all time tracking records for this task
1147 + $this->deleteTimeTrackingRecords((int) $taskId);
1148 + }
1149 +
1150 + /**
1151 + * Remove attachments for security reasons
1152 + * Prevents file access issues across boards
1153 + */
1154 + private function removeAttachments($taskId)
1155 + {
1156 + // Input validation
1157 + if (!is_numeric($taskId) || $taskId <= 0) {
1158 + return;
1159 + }
1160 +
1161 + // Remove all attachments for this task
1162 + if (defined('FLUENT_BOARDS_PRO_VERSION')) {
1163 + \FluentBoardsPro\App\Models\TaskAttachment::where('object_id', (int) $taskId)
1164 + ->where('object_type', 'task')
1165 + ->delete();
1166 + }
1167 + }
1168 +
1169 + /**
1170 + * Remove recurring task settings for security reasons
1171 + * Prevents recurring task settings from being moved between boards
1172 + */
1173 + private function removeRecurringTaskSettings($taskId)
1174 + {
1175 + // Input validation
1176 + if (!is_numeric($taskId) || $taskId <= 0) {
1177 + return;
1178 + }
1179 +
1180 + // Remove recurring task settings for this task from fbs_metas table
1181 + Meta::where('object_id', (int) $taskId)
1182 + ->where('object_type', Constant::REPEAT_TASK_META)
1183 + ->delete();
1184 + }
1185 +
478 1186 public function getIdeaVoteStatistics($taskId)
479 1187 {
480 - $reactionTypes = [
481 - [
482 - 'label' => 'Upvote',
483 - 'type' => 'upvote'
484 - ],
485 - [
486 - 'label' => 'Downvote',
487 - 'type' => 'downvote'
488 - ]
489 - ];
1188 + $taskId = absint($taskId);
1189 + $voteStatistics = $this->getIdeaVoteStatisticsByTaskIds([$taskId]);
490 1190
491 - $reactionCounts = [];
1191 + return $voteStatistics[$taskId] ?? 0;
1192 + }
492 1193
493 - foreach ($reactionTypes as $reactionType) {
494 - $count = IdeaReaction::where('object_id', $taskId)
495 - ->where('object_type', 'idea')
496 - ->where('type', $reactionType['type'])
497 - ->count();
1194 + public function loadIdeaVoteStatistics($tasks)
1195 + {
1196 + $taskIds = [];
498 1197
499 - $reactionCounts[] = [
500 - 'label' => $reactionType['label'],
501 - 'type' => $reactionType['type'],
502 - 'count' => $count
503 - ];
1198 + foreach ($tasks as $task) {
1199 + $taskId = absint($task->id);
1200 + if ($taskId) {
1201 + $taskIds[] = $taskId;
1202 + }
504 1203 }
505 1204
506 - return $reactionCounts;
1205 + $voteStatistics = $this->getIdeaVoteStatisticsByTaskIds($taskIds);
1206 +
1207 + foreach ($tasks as $task) {
1208 + $task->vote_statistics = $voteStatistics[(int) $task->id] ?? 0;
1209 + }
1210 +
1211 + return $tasks;
507 1212 }
508 1213
1214 + private function getIdeaVoteStatisticsByTaskIds(array $taskIds)
1215 + {
1216 + global $wpdb;
509 1217
1218 + $taskIds = array_values(array_unique(array_filter(array_map('absint', $taskIds))));
1219 +
1220 + if (!$taskIds) {
1221 + return [];
1222 + }
1223 +
1224 + $placeholders = implode(', ', array_fill(0, count($taskIds), '%d'));
1225 + $ideaReactionTable = $this->getIdeaReactionTable();
1226 + $counts = [];
1227 +
1228 + if ($ideaReactionTable) {
1229 + $rows = $wpdb->get_results(
1230 + $wpdb->prepare(
1231 + "SELECT object_id, COUNT(*) as total FROM {$ideaReactionTable} WHERE object_id IN ({$placeholders}) AND object_type = %s AND type = %s GROUP BY object_id",
1232 + array_merge($taskIds, ['idea', 'upvote'])
1233 + )
1234 + );
1235 +
1236 + foreach ($rows as $row) {
1237 + $counts[(int) $row->object_id] = (int) $row->total;
1238 + }
1239 +
1240 + return $counts;
1241 + }
1242 +
1243 + $taskMetaTable = $this->getPhysicalTableName((new TaskMeta())->getTable());
1244 + $rows = $wpdb->get_results(
1245 + $wpdb->prepare(
1246 + "SELECT task_id, COALESCE(MAX(CAST(value AS UNSIGNED)), 0) as total FROM {$taskMetaTable} WHERE task_id IN ({$placeholders}) AND `key` = %s GROUP BY task_id",
1247 + array_merge($taskIds, ['upvote'])
1248 + )
1249 + );
1250 +
1251 + foreach ($rows as $row) {
1252 + $counts[(int) $row->task_id] = (int) $row->total;
1253 + }
1254 +
1255 + return $counts;
1256 + }
1257 +
510 1258 /**
511 - * Summary of getArchivedOrCompletedTasks
512 - * this function will return completd tasks or archived tasks based on users input and also can search by name
513 - * @param mixed $data
514 - * @param mixed $taskType
1259 + * Returns the canonical SQL expression for an idea's upvote count.
1260 + *
1261 + * The roadmap reaction table is authoritative when it exists; legacy task
1262 + * metadata remains the fallback for installations without that table.
1263 + */
1264 + public function getIdeaVoteStatisticsSelect()
1265 + {
1266 + $taskTable = $this->getPhysicalTableName((new Task())->getTable());
1267 +
1268 + return $this->buildIdeaVoteStatisticsSelect($taskTable);
1269 + }
1270 +
1271 + private function buildIdeaVoteStatisticsSelect($taskTable)
1272 + {
1273 + $ideaReactionTable = $this->getIdeaReactionTable();
1274 +
1275 + if ($ideaReactionTable) {
1276 + return "(SELECT COUNT(*) FROM {$ideaReactionTable} WHERE {$ideaReactionTable}.object_id = {$taskTable}.id AND {$ideaReactionTable}.object_type = 'idea' AND {$ideaReactionTable}.type = 'upvote')";
1277 + }
1278 +
1279 + $taskMetaTable = $this->getPhysicalTableName((new TaskMeta())->getTable());
1280 +
1281 + return "(SELECT COALESCE(MAX(CAST({$taskMetaTable}.value AS UNSIGNED)), 0) FROM {$taskMetaTable} WHERE {$taskMetaTable}.task_id = {$taskTable}.id AND {$taskMetaTable}.key = 'upvote')";
1282 + }
1283 +
1284 + private function getIdeaReactionTable()
1285 + {
1286 + $table = $this->getPhysicalTableName((new IdeaReaction())->getTable(), false);
1287 +
1288 + if ($table) {
1289 + return $table;
1290 + }
1291 +
1292 + return '';
1293 + }
1294 +
1295 + private function getPhysicalTableName($table, $usePrefixedFallback = true)
1296 + {
1297 + global $wpdb;
1298 + $cacheKey = $table . '|' . (int) $usePrefixedFallback;
1299 +
1300 + if (array_key_exists($cacheKey, self::$physicalTableNameCache)) {
1301 + return self::$physicalTableNameCache[$cacheKey];
1302 + }
1303 +
1304 + $candidates = array_values(array_unique([
1305 + $wpdb->prefix . $table,
1306 + $table,
1307 + ]));
1308 +
1309 + foreach ($candidates as $candidate) {
1310 + if ($wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($candidate))) === $candidate) {
1311 + self::$physicalTableNameCache[$cacheKey] = $candidate;
1312 + return $candidate;
1313 + }
1314 + }
1315 +
1316 + self::$physicalTableNameCache[$cacheKey] = $usePrefixedFallback ? $wpdb->prefix . $table : '';
1317 +
1318 + return self::$physicalTableNameCache[$cacheKey];
1319 + }
1320 +
1321 +
1322 + /**
1323 + * Get a bounded paginated list of archived board tasks with their latest archive actor.
1324 + *
1325 + * @param array $data
1326 + * @param int $boardId
515 1327 * @return mixed
516 1328 * @throws \Exception
517 1329 */
518 1330 public function getArchivedTasks($data, $boardId)
519 1331 {
520 - $per_page = isset($data['per_page']) ? $data['per_page'] : 25;
521 - $page = isset($data['page']) ? $data['page'] : 1;
1332 + if (!$boardId) {
1333 + throw new \Exception(esc_html__('Board id is required', 'fluent-boards'));
1334 + }
1335 +
1336 + // Bound the task page so the related activity and user batch queries stay predictable.
1337 + $perPage = max(1, min(50, absint($data['per_page'] ?? 20)));
1338 + $page = max(1, absint($data['page'] ?? 1));
522 1339 $tasksQuery = Task::where('board_id', $boardId)->whereNotNull('archived_at');
523 - if (isset($data['searchInput'])) {
524 - $tasksQuery = $tasksQuery->where('title', 'LIKE', '%' . $data['searchInput'] . '%');
1340 +
1341 + if (!empty($data['query'])) {
1342 + $query = strtolower($data['query']);
1343 + $firstThreeChars = substr($query, 0, 3);
1344 +
1345 + if($firstThreeChars == 'id:') {
1346 + $idPart = substr($query, 3);
1347 + $idPart = preg_replace('/[^a-zA-Z0-9]/', '', $idPart);
1348 + $tasksQuery = $tasksQuery->where('id', 'LIKE', '%' . $idPart . '%');
1349 + } else {
1350 + $tasksQuery = $tasksQuery->where('title', 'LIKE', '%' . $data['query'] . '%');
1351 + }
525 1352 }
526 1353
527 - // if board_id is not passed then throw an exception
528 - if (!$boardId) {
529 - throw new \Exception('Board id is required', 'fluent-boards');
1354 + $tasks = $tasksQuery->orderBy('created_at', 'DESC')->with('assignees')->paginate($perPage, ['*'], 'page', $page);
1355 +
1356 + $taskIds = [];
1357 + foreach ($tasks as $task) {
1358 + $taskIds[] = (int) $task->id;
1359 + $task->archived_by_id = null;
1360 + $task->archived_by = null;
530 1361 }
531 1362
532 - return $tasksQuery->orderBy('created_at', 'DESC')->with('assignees')->paginate($per_page, ['*'], 'page', $page);
1363 + if (empty($taskIds)) {
1364 + return $tasks;
1365 + }
1366 +
1367 + $activityIds = Activity::whereIn('object_id', $taskIds)
1368 + ->where('object_type', Constant::ACTIVITY_TASK)
1369 + ->where('action', 'archived')
1370 + ->where('column', 'task')
1371 + ->selectRaw('MAX(id) as id')
1372 + ->groupBy('object_id')
1373 + ->pluck('id')
1374 + ->toArray();
1375 +
1376 + if (empty($activityIds)) {
1377 + return $tasks;
1378 + }
1379 +
1380 + $activities = Activity::whereIn('id', $activityIds)
1381 + ->with('user')
1382 + ->get()
1383 + ->keyBy('object_id');
1384 +
1385 + foreach ($tasks as $task) {
1386 + $activity = $activities->get($task->id);
1387 +
1388 + if (!$activity) {
1389 + continue;
1390 + }
1391 +
1392 + $task->archived_by_id = $activity->created_by ? (int) $activity->created_by : null;
1393 + $task->archived_by = $activity->user ? Helper::sanitizeUserCollections($activity->user) : null;
1394 + }
1395 +
1396 + return $tasks;
533 1397 }
534 1398
1399 + public function getTableTasks($boardId, $data = [])
1400 + {
1401 + $perPage = isset($data['per_page']) ? intval($data['per_page']) : 20;
1402 + $page = isset($data['page']) ? intval($data['page']) : 1;
1403 + $sortBy = isset($data['sort_by']) ? sanitize_text_field($data['sort_by']) : 'position';
1404 + $sortDirection = isset($data['sort_direction']) ? sanitize_text_field($data['sort_direction']) : 'asc';
1405 + $search = isset($data['search']) ? sanitize_text_field($data['search']) : '';
1406 + $stageFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'stage', []));
1407 + $taskStatusFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'task_status', []));
1408 + $priorityFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'priority', []), true);
1409 + $assigneeFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'assignee', []));
1410 + $labelFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'labels', []));
1411 + $watcherFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'watchers', []));
1412 + $contactFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'contact', []));
1413 + $customFieldFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'custom_fields', []));
1414 + $dueDateFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'due_date', []));
1415 + $includeArchived = !empty($data['include_archived']) || in_array('archived', $taskStatusFilters, true);
1416 + $board = Board::select('id', 'type')->find($boardId);
1417 + $isRoadmapBoard = $board && $board->type === 'roadmap';
1418 +
1419 + $perPage = max(1, min(150, $perPage));
1420 + $page = max(1, $page);
1421 + $sortDirection = strtolower($sortDirection) === 'desc' ? 'desc' : 'asc';
1422 +
1423 + $sortColumnMap = [
1424 + 'title' => 'title',
1425 + 'status' => 'status',
1426 + 'stage_id' => 'stage_id',
1427 + 'priority' => 'priority',
1428 + 'due_at' => 'due_at',
1429 + 'created_at' => 'created_at',
1430 + 'updated_at' => 'updated_at',
1431 + 'position' => 'position',
1432 + ];
1433 +
1434 + if ($isRoadmapBoard) {
1435 + $sortColumnMap['vote_statistics'] = 'vote_statistics';
1436 + }
1437 +
1438 + $sortColumn = Arr::get($sortColumnMap, $sortBy, 'position');
1439 + $taskTable = (new Task())->getTable();
1440 + $taskColumnNames = [
1441 + 'id',
1442 + 'title',
1443 + 'slug',
1444 + 'board_id',
1445 + 'parent_id',
1446 + 'type',
1447 + 'stage_id',
1448 + 'status',
1449 + 'priority',
1450 + 'archived_at',
1451 + 'remind_at',
1452 + 'reminder_type',
1453 + 'started_at',
1454 + 'due_at',
1455 + 'last_completed_at',
1456 + 'position',
1457 + 'comments_count',
1458 + 'created_by',
1459 + 'settings',
1460 + 'source',
1461 + 'source_id',
1462 + 'created_at',
1463 + 'updated_at',
1464 + ];
1465 + $taskColumns = array_map(function ($columnName) use ($taskTable) {
1466 + return "{$taskTable}.{$columnName}";
1467 + }, $taskColumnNames);
1468 +
1469 + $tasksQuery = Task::query()
1470 + // Table rows only need row-level fields; modal open rehydrates the full task.
1471 + ->with(['assignees', 'labels', 'watchers'])
1472 + ->where('board_id', $boardId)
1473 + ->whereNull('parent_id');
1474 +
1475 + if ($isRoadmapBoard) {
1476 + $taskSqlTable = $this->getPhysicalTableName($taskTable);
1477 + $taskSqlColumns = [];
1478 +
1479 + foreach ($taskColumnNames as $columnName) {
1480 + $taskSqlColumns[] = "{$taskSqlTable}.{$columnName}";
1481 + }
1482 +
1483 + $taskSqlColumns[] = $this->buildIdeaVoteStatisticsSelect($taskSqlTable) . ' as vote_statistics';
1484 + $tasksQuery->selectRaw(implode(', ', $taskSqlColumns));
1485 + } else {
1486 + $tasksQuery->select($taskColumns);
1487 + }
1488 +
1489 + if (!$includeArchived && !$taskStatusFilters) {
1490 + $tasksQuery->whereNull('archived_at');
1491 + }
1492 +
1493 + $this->applyTableTaskSearch($tasksQuery, $search);
1494 + $this->applyTableTaskFilters($tasksQuery, [
1495 + 'stage' => $stageFilters,
1496 + 'task_status' => $taskStatusFilters,
1497 + 'priority' => $priorityFilters,
1498 + 'assignee' => $assigneeFilters,
1499 + 'labels' => $labelFilters,
1500 + 'watchers' => $watcherFilters,
1501 + 'contact' => $contactFilters,
1502 + 'custom_fields' => $customFieldFilters,
1503 + 'due_date' => $dueDateFilters,
1504 + ]);
1505 +
1506 + if ($sortColumn === 'position') {
1507 + $tasksQuery->orderBy('stage_id', 'asc');
1508 + }
1509 +
1510 + return $tasksQuery
1511 + ->orderBy($sortColumn, $sortDirection)
1512 + ->paginate($perPage, ['*'], 'page', $page);
1513 + }
1514 +
1515 + public function getBoardViewTasks($boardId, $data = [])
1516 + {
1517 + $search = isset($data['search']) ? sanitize_text_field($data['search']) : '';
1518 + $stageFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'stage', []));
1519 + $taskStatusFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'task_status', []));
1520 + $priorityFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'priority', []), true);
1521 + $assigneeFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'assignee', []));
1522 + $labelFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'labels', []));
1523 + $watcherFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'watchers', []));
1524 + $contactFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'contact', []));
1525 + $customFieldFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'custom_fields', []));
1526 + $dueDateFilters = $this->sanitizeTableFilterValues(Arr::get($data, 'due_date', []));
1527 + $includeArchived = !empty($data['include_archived']) || in_array('archived', $taskStatusFilters, true);
1528 +
1529 + $tasksQuery = Task::query()
1530 + // Kanban/List filtering only need board-card fields because opening a
1531 + // task already rehydrates the full payload through the detail endpoint.
1532 + ->select([
1533 + 'id',
1534 + 'title',
1535 + 'slug',
1536 + 'board_id',
1537 + 'parent_id',
1538 + 'crm_contact_id',
1539 + 'type',
1540 + 'stage_id',
1541 + 'status',
1542 + 'reminder_type',
1543 + 'priority',
1544 + 'archived_at',
1545 + 'remind_at',
1546 + 'started_at',
1547 + 'due_at',
1548 + 'last_completed_at',
1549 + 'position',
1550 + 'comments_count',
1551 + 'created_by',
1552 + 'settings',
1553 + 'source',
1554 + 'source_id',
1555 + 'updated_at',
1556 + ])
1557 + ->with(['assignees', 'labels', 'watchers'])
1558 + ->where('board_id', $boardId)
1559 + ->whereNull('parent_id');
1560 +
1561 + if (!$includeArchived && !$taskStatusFilters) {
1562 + $tasksQuery->whereNull('archived_at');
1563 + }
1564 +
1565 + $this->applyTableTaskSearch($tasksQuery, $search);
1566 + $this->applyTableTaskFilters($tasksQuery, [
1567 + 'stage' => $stageFilters,
1568 + 'task_status' => $taskStatusFilters,
1569 + 'priority' => $priorityFilters,
1570 + 'assignee' => $assigneeFilters,
1571 + 'labels' => $labelFilters,
1572 + 'watchers' => $watcherFilters,
1573 + 'contact' => $contactFilters,
1574 + 'custom_fields' => $customFieldFilters,
1575 + 'due_date' => $dueDateFilters,
1576 + ]);
1577 +
1578 + return $tasksQuery
1579 + ->orderBy('stage_id', 'asc')
1580 + ->orderBy('position', 'asc')
1581 + ->get();
1582 + }
1583 +
1584 + private function sanitizeTableFilterValues($values, $allowEmpty = false)
1585 + {
1586 + if (!is_array($values)) {
1587 + $values = ($values === null || (!$allowEmpty && $values === '')) ? [] : [$values];
1588 + }
1589 +
1590 + return array_values(array_filter(array_map(static function ($value) {
1591 + return sanitize_text_field($value);
1592 + }, $values), static function ($value) use ($allowEmpty) {
1593 + return $allowEmpty || $value !== '';
1594 + }));
1595 + }
1596 +
1597 + private function applyTableTaskSearch($tasksQuery, $search)
1598 + {
1599 + if (!$search) {
1600 + return;
1601 + }
1602 +
1603 + global $wpdb;
1604 +
1605 + $query = strtolower($search);
1606 + $firstThreeChars = substr($query, 0, 3);
1607 +
1608 + if ($firstThreeChars === 'id:') {
1609 + $idPart = preg_replace('/[^a-zA-Z0-9]/', '', substr($query, 3));
1610 + if ($idPart !== '') {
1611 + $tasksQuery->where('id', 'LIKE', '%' . $idPart . '%');
1612 + }
1613 + return;
1614 + }
1615 +
1616 + $escapedSearch = $wpdb->esc_like($search);
1617 + $tasksQuery->where('title', 'LIKE', '%' . $escapedSearch . '%');
1618 + }
1619 +
1620 + private function applyTableTaskFilters($tasksQuery, $filters)
1621 + {
1622 + $stageFilters = Arr::get($filters, 'stage', []);
1623 + $taskStatusFilters = Arr::get($filters, 'task_status', []);
1624 + $priorityFilters = Arr::get($filters, 'priority', []);
1625 + $assigneeFilters = Arr::get($filters, 'assignee', []);
1626 + $labelFilters = Arr::get($filters, 'labels', []);
1627 + $watcherFilters = Arr::get($filters, 'watchers', []);
1628 + $contactFilters = Arr::get($filters, 'contact', []);
1629 + $customFieldFilters = Arr::get($filters, 'custom_fields', []);
1630 + $dueDateFilters = Arr::get($filters, 'due_date', []);
1631 +
1632 + if ($stageFilters) {
1633 + $this->applyTableStageFilters($tasksQuery, $stageFilters);
1634 + }
1635 +
1636 + if ($taskStatusFilters) {
1637 + $this->applyTableTaskStatusFilters($tasksQuery, $taskStatusFilters);
1638 + }
1639 +
1640 + if ($priorityFilters) {
1641 + $priorityFilters = array_map('strtolower', $priorityFilters);
1642 + $hasNoPriorityFilter = in_array('', $priorityFilters, true);
1643 + $selectedPriorities = array_values(array_filter($priorityFilters, static function ($priority) {
1644 + return $priority !== '';
1645 + }));
1646 +
1647 + $tasksQuery->where(function ($query) use ($hasNoPriorityFilter, $selectedPriorities) {
1648 + if ($selectedPriorities) {
1649 + $query->whereIn('priority', $selectedPriorities);
1650 + }
1651 +
1652 + if ($hasNoPriorityFilter) {
1653 + $method = $selectedPriorities ? 'orWhere' : 'where';
1654 + $query->{$method}(function ($priorityQuery) {
1655 + $priorityQuery->whereNull('priority')->orWhere('priority', '');
1656 + });
1657 + }
1658 + });
1659 + }
1660 +
1661 + if ($contactFilters) {
1662 + $contactIds = array_values(array_filter(array_map('intval', $contactFilters)));
1663 + if ($contactIds) {
1664 + $tasksQuery->whereIn('crm_contact_id', $contactIds);
1665 + }
1666 + }
1667 +
1668 + if ($labelFilters) {
1669 + $labelIds = array_values(array_filter(array_map('intval', array_diff($labelFilters, ['no-label']))));
1670 + $includeNoLabel = in_array('no-label', $labelFilters, true);
1671 + $labelTable = (new Label())->getTable();
1672 +
1673 + if ($labelIds || $includeNoLabel) {
1674 + $tasksQuery->where(function ($query) use ($labelIds, $includeNoLabel, $labelTable) {
1675 + if ($includeNoLabel) {
1676 + $query->orWhereDoesntHave('labels');
1677 + }
1678 +
1679 + if ($labelIds) {
1680 + $query->orWhereHas('labels', function ($labelQuery) use ($labelIds, $labelTable) {
1681 + $labelQuery->whereIn($labelTable . '.id', $labelIds);
1682 + });
1683 + }
1684 + });
1685 + }
1686 + }
1687 +
1688 + if ($customFieldFilters) {
1689 + $customFieldIds = array_values(array_filter(array_map('intval', array_diff($customFieldFilters, ['no-custom-field']))));
1690 + $includeNoCustomField = in_array('no-custom-field', $customFieldFilters, true);
1691 +
1692 + if ($customFieldIds || $includeNoCustomField) {
1693 + $tasksQuery->where(function ($query) use ($customFieldIds, $includeNoCustomField) {
1694 + if ($includeNoCustomField) {
1695 + $query->orWhereDoesntHave('taskCustomFields');
1696 + }
1697 +
1698 + if ($customFieldIds) {
1699 + $query->orWhereHas('taskCustomFields', function ($customFieldQuery) use ($customFieldIds) {
1700 + $customFieldQuery->whereIn('foreign_id', $customFieldIds);
1701 + });
1702 + }
1703 + });
1704 + }
1705 + }
1706 +
1707 + if ($dueDateFilters) {
1708 + $this->applyTableDueDateFilters($tasksQuery, $dueDateFilters);
1709 + }
1710 +
1711 + if ($assigneeFilters || $watcherFilters) {
1712 + $this->applyTableAssignmentFilters($tasksQuery, $assigneeFilters, $watcherFilters);
1713 + }
1714 + }
1715 +
1716 + private function applyTableStageFilters($tasksQuery, $stageFilters)
1717 + {
1718 + $stageIds = array_values(array_filter(array_map('intval', array_diff($stageFilters, ['archived']))));
1719 + $includeArchivedStages = in_array('archived', $stageFilters, true);
1720 +
1721 + if (!$stageIds && !$includeArchivedStages) {
1722 + return;
1723 + }
1724 +
1725 + $tasksQuery->where(function ($query) use ($stageIds, $includeArchivedStages) {
1726 + if ($stageIds) {
1727 + $query->orWhereIn('stage_id', $stageIds);
1728 + }
1729 +
1730 + if ($includeArchivedStages) {
1731 + $query->orWhereHas('stage', function ($stageQuery) {
1732 + $stageQuery->whereNotNull('archived_at');
1733 + });
1734 + }
1735 + });
1736 + }
1737 +
1738 + private function applyTableTaskStatusFilters($tasksQuery, $taskStatusFilters)
1739 + {
1740 + $statuses = array_values(array_diff($taskStatusFilters, ['archived']));
1741 + $includeArchived = in_array('archived', $taskStatusFilters, true);
1742 +
1743 + if (!$statuses && !$includeArchived) {
1744 + return;
1745 + }
1746 +
1747 + $tasksQuery->where(function ($query) use ($statuses, $includeArchived) {
1748 + if ($statuses) {
1749 + $query->orWhere(function ($statusQuery) use ($statuses) {
1750 + $statusQuery->whereNull('archived_at')
1751 + ->whereIn('status', $statuses);
1752 + });
1753 + }
1754 +
1755 + if ($includeArchived) {
1756 + $query->orWhereNotNull('archived_at');
1757 + }
1758 + });
1759 + }
1760 +
1761 + private function applyTableDueDateFilters($tasksQuery, $dueDateFilters)
1762 + {
1763 + $dueDateFilters = array_values(array_intersect($dueDateFilters, [
1764 + 'overdue',
1765 + 'no-dates',
1766 + 'today',
1767 + 'this-week',
1768 + 'next-week',
1769 + 'this-month',
1770 + 'upcoming',
1771 + ]));
1772 +
1773 + if (!$dueDateFilters) {
1774 + return;
1775 + }
1776 +
1777 + $nowTimestamp = current_time('timestamp');
1778 + $startOfTodayTimestamp = strtotime(gmdate('Y-m-d 00:00:00', $nowTimestamp));
1779 + $dayOfWeek = (int) gmdate('w', $nowTimestamp);
1780 + $startOfThisWeekTimestamp = strtotime('-' . $dayOfWeek . ' days', $startOfTodayTimestamp);
1781 + $startOfToday = gmdate('Y-m-d 00:00:00', $nowTimestamp);
1782 + $endOfToday = gmdate('Y-m-d 23:59:59', $nowTimestamp);
1783 + $startOfThisWeek = gmdate('Y-m-d 00:00:00', $startOfThisWeekTimestamp);
1784 + $startOfNextWeek = gmdate('Y-m-d 00:00:00', strtotime('+7 days', $startOfThisWeekTimestamp));
1785 + $startOfWeekAfterNext = gmdate('Y-m-d 00:00:00', strtotime('+14 days', $startOfThisWeekTimestamp));
1786 + $endOfThisMonth = gmdate('Y-m-t 23:59:59', $nowTimestamp);
1787 + $nowMysql = current_time('mysql');
1788 +
1789 + $tasksQuery->where(function ($query) use ($dueDateFilters, $startOfToday, $endOfToday, $startOfThisWeek, $startOfNextWeek, $startOfWeekAfterNext, $endOfThisMonth, $nowMysql) {
1790 + foreach ($dueDateFilters as $filter) {
1791 + switch ($filter) {
1792 + case 'overdue':
1793 + $query->orWhere(function ($dueQuery) use ($nowMysql) {
1794 + $dueQuery->whereNull('last_completed_at')
1795 + ->whereNotNull('due_at')
1796 + ->where('due_at', '<=', $nowMysql);
1797 + });
1798 + break;
1799 + case 'no-dates':
1800 + $query->orWhereNull('due_at');
1801 + break;
1802 + case 'today':
1803 + $query->orWhereBetween('due_at', [$startOfToday, $endOfToday]);
1804 + break;
1805 + case 'this-week':
1806 + $query->orWhereBetween('due_at', [$startOfThisWeek, $startOfNextWeek]);
1807 + break;
1808 + case 'next-week':
1809 + $query->orWhereBetween('due_at', [$startOfNextWeek, $startOfWeekAfterNext]);
1810 + break;
1811 + case 'this-month':
1812 + $query->orWhereBetween('due_at', [$nowMysql, $endOfThisMonth]);
1813 + break;
1814 + case 'upcoming':
1815 + $query->orWhere(function ($upcomingQuery) use ($nowMysql) {
1816 + $upcomingQuery->whereNull('last_completed_at')
1817 + ->whereNotNull('due_at')
1818 + ->where('due_at', '>=', $nowMysql);
1819 + });
1820 + break;
1821 + }
1822 + }
1823 + });
1824 + }
1825 +
1826 + private function applyTableAssignmentFilters($tasksQuery, $assigneeFilters, $watcherFilters)
1827 + {
1828 + $assigneeIds = array_values(array_filter(array_map('intval', array_diff($assigneeFilters, ['no-assignee']))));
1829 + $watcherIds = array_values(array_filter(array_map('intval', $watcherFilters)));
1830 + $includeNoAssignee = in_array('no-assignee', $assigneeFilters, true);
1831 + $commonIds = array_values(array_intersect($assigneeIds, $watcherIds));
1832 + $assigneeOnlyIds = array_values(array_diff($assigneeIds, $commonIds));
1833 + $watcherOnlyIds = array_values(array_diff($watcherIds, $commonIds));
1834 +
1835 + if ($commonIds) {
1836 + // Shared watcher/assignee filters are treated as an OR group, matching
1837 + // the existing client-side filter semantics.
1838 + $tasksQuery->where(function ($query) use ($commonIds) {
1839 + $query->whereHas('assignees', function ($assigneeQuery) use ($commonIds) {
1840 + $assigneeQuery->whereIn('users.ID', $commonIds);
1841 + })->orWhereHas('watchers', function ($watcherQuery) use ($commonIds) {
1842 + $watcherQuery->whereIn('users.ID', $commonIds);
1843 + });
1844 + });
1845 + }
1846 +
1847 + if ($includeNoAssignee || $assigneeOnlyIds) {
1848 + $tasksQuery->where(function ($query) use ($includeNoAssignee, $assigneeOnlyIds) {
1849 + if ($includeNoAssignee) {
1850 + $query->orWhereDoesntHave('assignees');
1851 + }
1852 +
1853 + if ($assigneeOnlyIds) {
1854 + $query->orWhereHas('assignees', function ($assigneeQuery) use ($assigneeOnlyIds) {
1855 + $assigneeQuery->whereIn('users.ID', $assigneeOnlyIds);
1856 + });
1857 + }
1858 + });
1859 + }
1860 +
1861 + if ($watcherOnlyIds) {
1862 + $tasksQuery->whereHas('watchers', function ($watcherQuery) use ($watcherOnlyIds) {
1863 + $watcherQuery->whereIn('users.ID', $watcherOnlyIds);
1864 + })->whereDoesntHave('assignees', function ($assigneeQuery) use ($watcherOnlyIds) {
1865 + $assigneeQuery->whereIn('users.ID', $watcherOnlyIds);
1866 + });
1867 + }
1868 + }
1869 +
535 1870 public function sendMailAfterTaskModify($column, $assigneeIds, $taskId)
536 1871 {
537 1872 $current_user_id = get_current_user_id();
538 1873 /* this will run in background as soon as possible */
@@ -543,14 +1878,17 @@
543 1878
544 1879 public function getStageByTask($task_id)
545 1880 {
546 1881 $task = Task::find($task_id);
1882 + if (!$task || !PermissionManager::userHasPermission($task->board_id)) {
1883 + throw new \Exception(esc_html__('Task not found', 'fluent-boards'));
1884 + }
547 1885 return $task->stage;
548 1886 }
549 1887
550 - public function moveTaskToNextStage($task_id)
1888 + public function moveTaskToNextStage($task_id, $boardId = null)
551 1889 {
552 - $task = Task::findOrFail($task_id);
1890 + $task = $boardId ? $this->findTaskOnBoard($task_id, $boardId) : Task::findOrFail($task_id);
553 1891
554 1892 $oldStage = $task->stage;
555 1893
556 1894 $nextStage = Stage::where('board_id', $task->board_id)
@@ -568,8 +1906,11 @@
568 1906 $task->last_completed_at = current_time('mysql');
569 1907 }
570 1908 }
571 1909
1910 + // Clean up archived_by_stage meta when moving to different stage
1911 + $this->cleanupArchivedByStageMetaIfExists($task->id);
1912 +
572 1913 $task->stage_id = $nextStage->id;
573 1914 $task->save();
574 1915
575 1916 $task->load(['board', 'stage', 'attachments']);
@@ -599,27 +1940,36 @@
599 1940 $activityQuery = $activityQuery->latest();
600 1941 } else if ($filter == 'oldest') {
601 1942 $activityQuery = $activityQuery->oldest();
602 1943 }
603 - return $activityQuery->with('user')->paginate($perPage);
1944 + $activities = $activityQuery->with('user')->paginate($perPage);
1945 +
1946 + Helper::translateActivities($activities);
1947 +
1948 + return $activities;
604 1949 }
605 1950
606 - public function getLastOneMinuteUpdatedTasks($boardId, $lastUpdated = null)
1951 + public function getLastOneMinuteUpdatedTasks($boardId, $lastUpdated = null, $includeArchived = true)
607 1952 {
608 1953 if (!$lastUpdated) {
609 - $lastUpdated = gmdate('Y-m-d H:i:s', current_time('timestamp') - 60);
1954 + $lastUpdated = date_i18n('Y-m-d H:i:s', current_time('timestamp') - 60); // 1 minute ago
610 1955 }
611 1956
612 - $tasks = Task::query()
1957 + $tasksQuery = Task::query()
613 1958 ->where([
614 1959 'board_id' => $boardId,
615 1960 'parent_id' => null,
616 1961 ])
617 - ->where('updated_at', '>', $lastUpdated)
618 - ->with(['assignees', 'labels', 'watchers'])
619 - ->orderBy('due_at', 'ASC')
620 - ->get();
1962 + ->where('updated_at', '>=', $lastUpdated) // updated since the sync cursor
1963 + ->with(['assignees', 'labels', 'watchers', 'taskCustomFields'])
1964 + ->orderBy('due_at', 'ASC');
621 1965
1966 + if (!$includeArchived) {
1967 + $tasksQuery->whereNull('archived_at');
1968 + }
1969 +
1970 + $tasks = $tasksQuery->get();
1971 +
622 1972 foreach ($tasks as $task) {
623 1973 $task->isOverdue = $task->isOverdue();
624 1974 $task->isUpcoming = $task->upcoming();
625 1975 $task->is_watching = $task->isWatching();
@@ -642,72 +1992,212 @@
642 1992
643 1993 return $lastPosition + 1;
644 1994 }
645 1995
646 - public function getAssociatedTasks($associatedId)
1996 + /**
1997 + * Pin a task: set is_pinned in task meta only. No position change.
1998 + * Only parent tasks can be pinned.
1999 + *
2000 + * @param \FluentBoards\App\Models\Task $task
2001 + * @return \FluentBoards\App\Models\Task
2002 + */
2003 + public function pinTask($task)
647 2004 {
648 - $tasks = Task::query()
2005 + if ($task->parent_id !== null) {
2006 + return $task;
2007 + }
2008 +
2009 + $task->updateMeta(Constant::IS_TASK_PINNED, 1);
2010 +
2011 + return $task;
2012 + }
2013 +
2014 + /**
2015 + * Unpin a task: set is_pinned in task meta only. No position change.
2016 + *
2017 + * @param \FluentBoards\App\Models\Task $task
2018 + * @return \FluentBoards\App\Models\Task
2019 + */
2020 + public function unpinTask($task)
2021 + {
2022 + $task->updateMeta(Constant::IS_TASK_PINNED, 0);
2023 +
2024 + return $task;
2025 + }
2026 +
2027 + /**
2028 + * Get CRM-associated tasks and mark whether the current user may edit each task's board.
2029 + *
2030 + * @param int $associatedId CRM contact/subscriber id associated with tasks.
2031 + * @param int|null $userId User id for board permission checks.
2032 + * @return \FluentBoards\Framework\Database\Orm\Collection|array
2033 + */
2034 + public function getAssociatedTasks($associatedId, $userId = null)
2035 + {
2036 + $associatedId = absint($associatedId);
2037 + $userId = $userId ?: get_current_user_id();
2038 +
2039 + if (!$associatedId || !$userId) {
2040 + return [];
2041 + }
2042 +
2043 + $isAdmin = PermissionManager::isAdmin($userId);
2044 + $editableBoardIds = [];
2045 +
2046 + $tasksQuery = Task::query()
649 2047 ->where('crm_contact_id', $associatedId)
650 - ->with(['board', 'stage', 'assignees', 'labels', 'watchers',])
651 - ->orderBy('due_at', 'ASC')
652 - ->get();
2048 + ->with(['board', 'stage', 'assignees', 'subtaskGroup', 'subtaskGroup.subtasks', 'subtaskGroup.subtasks.assignees'])
2049 + ->orderBy('due_at', 'ASC');
653 2050
2051 + if ($isAdmin) {
2052 + $tasksQuery->whereHas('board', function ($query) {
2053 + $query->whereNull('archived_at');
2054 + });
2055 + } else {
2056 + $editableBoardIds = array_map('intval', PermissionManager::getBoardIdsForUser($userId));
2057 +
2058 + if (!$editableBoardIds) {
2059 + return [];
2060 + }
2061 +
2062 + $tasksQuery->whereIn('board_id', $editableBoardIds);
2063 + }
2064 +
2065 + $tasks = $tasksQuery->get();
2066 +
654 2067 foreach ($tasks as $task) {
655 2068 $task->isOverdue = $task->isOverdue();
656 2069 $task->isUpcoming = $task->upcoming();
657 - $task->contact = Task::lead_contact($task->crm_contact_id);
658 - $task->is_watching = $task->isWatching();
2070 + $task->can_edit = $isAdmin || in_array((int)$task->board_id, $editableBoardIds, true);
659 2071
660 2072 $task->assignees = Helper::sanitizeUserCollections($task->assignees);
661 - $task->watchers = Helper::sanitizeUserCollections($task->watchers);
662 2073
663 - $subTasks = Task::query()
664 - ->where('parent_id', $task->id)
665 - ->with(['assignees'])
666 - ->whereNull('archived_at')
667 - ->orderBy('position', 'ASC')
668 - ->get();
2074 + foreach ($task->subtaskGroup as $group) {
2075 + foreach ($group->subtasks as $subtask) {
2076 + $subtask->assignees = Helper::sanitizeUserCollections($subtask->assignees);
2077 + }
2078 + }
2079 + $task->subtask_group = $task->subtaskGroup;
2080 + }
669 2081
670 - foreach ($subTasks as $subTask) {
671 - $subTask->assignees = Helper::sanitizeUserCollections($subTask->assignees);
672 - }
2082 + return $tasks;
2083 + }
673 2084
674 - $task->subtasks = $subTasks;
2085 + public function copySubtaskGroup($task, $newTask, $subtaskGroupMap)
2086 + {
2087 + $subtaskGroups = TaskMeta::where('task_id', $task->id)->where('key', Constant::SUBTASK_GROUP_NAME)->get();
2088 + foreach ($subtaskGroups as $group) {
2089 + $newGroup = TaskMeta::create([
2090 + 'task_id' => $newTask->id,
2091 + 'key' => Constant::SUBTASK_GROUP_NAME,
2092 + 'value' => $group->value
2093 + ]);
2094 +
2095 + $subtaskGroupMap[$group->id] = $newGroup->id;
675 2096 }
676 2097
677 - return $tasks;
2098 + return $subtaskGroupMap;
678 2099 }
679 2100
680 - public function copyTasks($boardId, $stageMap, $newBoard)
2101 + public function copyTasks($boardId, $stageMap, $newBoard, $labelMap = [],$isWithTemplates='no')
681 2102 {
682 2103 $allActiveTasks = Task::where('board_id', $boardId)->whereNull('archived_at')->get();
683 2104 $taskMap = [];
684 - foreach ($allActiveTasks as $task) {
685 - $newTask = array();
686 - $newTask['title'] = $task->title;
687 - $newTask['parent_id'] = $task->parent_id ? $taskMap[$task->parent_id] : null;
688 - $newTask['description'] = $task->description;
689 - $newTask['board_id'] = $newBoard->id;
690 - $newTask['stage_id'] = $stageMap[$task->stage_id];
691 - $newTask['status'] = $task->status;
692 - $newTask['priority'] = $task->priority;
693 - $newTask['position'] = $task->position;
694 - $newTask['due_at'] = $task->due_at;
695 - $newTask = Task::create($newTask);
696 - if(!$task->parent_id){
697 - $taskMap[$task['id']] = $newTask->id;
698 - } else {
699 - $this->subtaskCountUpdate($newTask->parent_id);
2105 + $subtaskGroupMap = [];
2106 + $parentTaskCount = 0;
2107 + $attachmentFileService = new AttachmentFileService();
2108 + $dbInstance = App::getInstance('db');
2109 +
2110 + $dbInstance->beginTransaction();
2111 +
2112 + try {
2113 + foreach ($allActiveTasks as $task) {
2114 + if ($task->parent_id && empty($taskMap[$task->parent_id])) {
2115 + continue;
2116 + }
2117 +
2118 + $stageId = !empty($task->stage_id) ? (int) $task->stage_id : 0;
2119 + if (!$task->parent_id && empty($stageMap[$stageId])) {
2120 + continue;
2121 + }
2122 +
2123 + $newTask = array();
2124 + $newTask['title'] = $task->title;
2125 + $newTask['parent_id'] = $task->parent_id ? $taskMap[$task->parent_id] : null;
2126 + $newTask['description'] = DescriptionMarkdownConverter::normalize($task->description);
2127 + $newTask['board_id'] = $newBoard->id;
2128 + $newTask['stage_id'] = $stageId && isset($stageMap[$stageId]) ? $stageMap[$stageId] : null;
2129 + $newTask['status'] = $task->status;
2130 + $newTask['priority'] = $task->priority;
2131 + $newTask['position'] = $task->position;
2132 + $newTask['due_at'] = $task->due_at;
2133 + $backgroundColor = $task->settings['cover']['backgroundColor'] ?? '';
2134 + $newTask['settings'] = [
2135 + 'cover' => [
2136 + 'backgroundColor' => $backgroundColor,
2137 + ]
2138 + ];
2139 +
2140 + $newTask = Task::create($newTask);
2141 + $attachmentFileService->cloneTaskFilesToBoard($task, $newTask, $newBoard->id);
2142 +
2143 + if (!$task->parent_id) {
2144 + //group mapping
2145 + $subtaskGroupMap = $this->copySubtaskGroup($task, $newTask, $subtaskGroupMap);
2146 + } else {
2147 + $groupRelationOfTask = TaskMeta::where('key', Constant::SUBTASK_GROUP_CHILD)
2148 + ->where('task_id', $task->id)
2149 + ->first();
2150 +
2151 + if ($groupRelationOfTask && !empty($subtaskGroupMap[$groupRelationOfTask->value])) {
2152 + TaskMeta::create([
2153 + 'task_id' => $newTask->id,
2154 + 'key' => Constant::SUBTASK_GROUP_CHILD,
2155 + 'value' => $subtaskGroupMap[$groupRelationOfTask->value]
2156 + ]);
2157 + }
2158 + }
2159 +
2160 + if($isWithTemplates == 'yes') {
2161 + $isTemplate = TaskMeta::where('task_id', $task->id)
2162 + ->where('key', 'is_template')
2163 + ->first();
2164 + if($isTemplate) {
2165 + TaskMeta::create([
2166 + 'task_id' => $newTask->id,
2167 + 'key' => 'is_template',
2168 + 'value' => $isTemplate->value
2169 + ]);
2170 + }
2171 + }
2172 + if(!$task->parent_id){
2173 + ++$parentTaskCount;
2174 + $taskMap[$task['id']] = $newTask->id;
2175 + //duplicate labels to task
2176 + $labelIds = $task->labels->pluck('id')->toArray();
2177 + if($labelIds){
2178 + $flipLabelIds = array_flip($labelIds);
2179 + $labelsToAttach = array_intersect_key($labelMap, $flipLabelIds);
2180 +
2181 + $newTask->labels()->attach($labelsToAttach, [
2182 + 'object_type' => Constant::OBJECT_TYPE_TASK_LABEL
2183 + ]);
2184 + }
2185 + }
700 2186 }
2187 +
2188 + $board = Board::findOrFail($newBoard->id);
2189 + $settings = [];
2190 + $settings['tasks_count'] = $parentTaskCount;
2191 + $board->settings = $settings;
2192 + $board->save();
2193 +
2194 + $dbInstance->commit();
2195 + } catch (\Exception $e) {
2196 + $dbInstance->rollBack();
2197 + $attachmentFileService->rollbackCreatedFiles();
2198 + throw $e;
701 2199 }
702 -
703 - //initiate task count
704 - $totalTasks = sizeof($allActiveTasks);
705 - $board = Board::findOrFail($newBoard->id);
706 - $settings = [];
707 - $settings['tasks_count'] = $totalTasks;
708 - $board->settings = $settings;
709 - $board->save();
710 2200 }
711 2201
712 2202 private function subtaskCountUpdate($taskId){
713 2203 $parentTask = Task::findOrFail($taskId);
@@ -716,6 +2206,1186 @@
716 2206 $parentTask->settings = $settings;
717 2207 $parentTask->save();
718 2208 }
719 2209
2210 + /**
2211 + * @param $taskId
2212 + * @param $perPage
2213 + * @param $page
2214 + * @param string $filter
2215 + * @param $boardId
2216 + * @param string $feedType
2217 + * @return array
2218 + */
2219 + public function getCommentsAndActivities($taskId, $perPage, $page, string $filter = 'newest', $boardId = null, string $feedType = 'all'): array
2220 + {
2221 + // Fetch the task
2222 + $task = $boardId ? $this->findTaskOnBoard($taskId, $boardId) : Task::findOrFail($taskId);
2223 + $feedType = in_array($feedType, ['all', 'comments', 'activities'], true) ? $feedType : 'all';
2224 +
2225 + // Fetch comments and activities separately
2226 + $comments = [];
2227 + if ($feedType !== 'activities') {
2228 + $comments = $task->comments()->with(['user', 'replies.user'])->orderBy('created_at', 'desc')->get()->toArray();
2229 + }
2230 +
2231 + $activities = [];
2232 + if ($feedType !== 'comments') {
2233 + $activities = $task->activities()
2234 + ->with('user')
2235 + ->where(function($query) {
2236 + $query->whereNotIn('column', [ 'comment', 'a reply'])
2237 + ->orWhere(function($subQuery) {
2238 + $subQuery->whereNotIn('action', ['added', 'updated']);
2239 + });
2240 + })
2241 + ->orderBy('created_at', 'desc')
2242 + ->get()
2243 + ->toArray();
2244 + }
2245 +
2246 +
2247 + // Merge comments and activities into a single array
2248 + $commentsAndActivities = array_merge($comments, $activities);
2249 +
2250 + // Sort the merged array by created_at date in ascending or descending order
2251 + $order = $filter == 'newest' ? -1 : 1;
2252 + usort($commentsAndActivities, function ($a, $b) use ($order) {
2253 + return $order * (strtotime($a['created_at']) - strtotime($b['created_at']));
2254 + });
2255 +
2256 + // Paginate the results
2257 + $offset = ($page - 1) * $perPage; // Calculate the offset for slicing the array
2258 + $paginatedResults = array_slice($commentsAndActivities, $offset, $perPage);
2259 +
2260 + // Get the total count of comments and activities
2261 + $total = count($commentsAndActivities);
2262 + $lastPage = (int) ceil($total / $perPage);
2263 +
2264 + // Construct pagination metadata
2265 + $path = "https://wordpress.test/wp-json/fluent-boards/v2/projects/{$task->board_id}/tasks/{$task->id}/comments-and-activities";
2266 + return [
2267 + 'current_page' => (int) $page,
2268 + 'data' => $paginatedResults,
2269 + 'first_page_url' => "{$path}?page=1",
2270 + 'from' => $total > 0 ? (int) ($offset + 1) : null,
2271 + 'last_page' => (int) $lastPage,
2272 + 'last_page_url' => "{$path}?page={$lastPage}",
2273 + 'links' => [
2274 + [
2275 + 'url' => $page > 1 ? "{$path}?page=" . ($page - 1) : null,
2276 + 'label' => 'pagination.previous',
2277 + 'active' => false
2278 + ],
2279 + [
2280 + 'url' => "{$path}?page={$page}",
2281 + 'label' => (int) $page,
2282 + 'active' => true
2283 + ],
2284 + [
2285 + 'url' => $page < $lastPage ? "{$path}?page=" . ($page + 1) : null,
2286 + 'label' => 'pagination.next',
2287 + 'active' => false
2288 + ]
2289 + ],
2290 + 'next_page_url' => $page < $lastPage ? "{$path}?page=" . ($page + 1) : null,
2291 + 'path' => $path,
2292 + 'per_page' => (int) $perPage,
2293 + 'prev_page_url' => $page > 1 ? "{$path}?page=" . ($page - 1) : null,
2294 + 'to' => $total > 0 ? (int) min($offset + $perPage, $total) : null,
2295 + 'total' => (int) $total
2296 + ];
2297 + }
2298 +
2299 + /**
2300 + * @param $task_id
2301 + * @param $fileData
2302 + * @param $type
2303 + * @return Attachment
2304 + */
2305 + public function uploadMediaFileFromWpEditor($task_id, $fileData, $type)
2306 + {
2307 + $initialDataData = [
2308 + 'type' => 'url',
2309 + 'url' => '',
2310 + 'name' => '',
2311 + 'size' => 0,
2312 + ];
2313 +
2314 + $attachData = array_merge($initialDataData, $fileData);
2315 + $UrlMeta = [];
2316 + if($attachData['type'] == 'url') {
2317 + $UrlMeta = RemoteUrlParser::parse($attachData['url']);
2318 + }
2319 + $attachment = new TaskImage();
2320 + $attachment->object_id = $task_id;
2321 + $attachment->object_type = $type;
2322 + $attachment->attachment_type = $attachData['type'];
2323 + $attachment->title = $this->setTitle($attachData['type'], $attachData['name'], $UrlMeta);
2324 + $attachment->file_path = $attachData['type'] != 'url' ? $attachData['file'] : null;
2325 + $attachment->full_url = esc_url($attachData['url']);
2326 + $attachment->file_size = $attachData['size'];
2327 + $attachment->settings = $attachData['type'] == 'url' ? [
2328 + 'meta' => $UrlMeta
2329 + ] : '';
2330 + $attachment->driver = 'local';
2331 + $attachment->save();
2332 + return $attachment;
2333 + }
2334 +
2335 +
2336 + /**
2337 + * @param $type
2338 + * @param $title
2339 + * @param $UrlMeta
2340 + * @return mixed|string
2341 + */
2342 + public function setTitle($type, $title, $UrlMeta)
2343 + {
2344 + if($type != 'url') {
2345 + return sanitize_file_name($title);
2346 + }
2347 + return $title ?? $UrlMeta['title'] ?? '';
2348 + }
2349 +
2350 + public function manageDefaultAssignees($task, $stageId)
2351 + {
2352 + $stage = Stage::findOrFail($stageId);
2353 + if ($stage && isset($stage->settings['default_task_assignees'])) {
2354 + $defaultAssignees = $stage->settings['default_task_assignees'];
2355 + foreach ($defaultAssignees as $assigneeId) {
2356 + $alreadyAssigneeIds = $task->assignees->pluck('ID')->toArray();
2357 + $IfAlreadyAssignee = in_array($assigneeId, $alreadyAssigneeIds);
2358 + if (!$IfAlreadyAssignee) {
2359 + $this->updateAssignee($assigneeId, $task);
2360 + }
2361 + }
2362 + }
2363 + }
2364 +
2365 + public function manageDefaultWatchers($task, $stageId)
2366 + {
2367 + $stage = Stage::findOrFail($stageId);
2368 + if ($stage) {
2369 + $settings = $stage->settings;
2370 + $defaultWatchers = [];
2371 + if (isset($settings['default_task_watchers']) && is_array($settings['default_task_watchers'])) {
2372 + $defaultWatchers = $settings['default_task_watchers'];
2373 + }
2374 + if (isset($settings['default_task_assignees']) && is_array($settings['default_task_assignees'])) {
2375 + $defaultWatchers = array_unique(array_merge($defaultWatchers, $settings['default_task_assignees']));
2376 + }
2377 + foreach ($defaultWatchers as $watcherId) {
2378 + $alreadyWatcherIds = $task->watchers->pluck('ID')->toArray();
2379 + $isAlreadyWatcher = in_array($watcherId, $alreadyWatcherIds);
2380 + if (!$isAlreadyWatcher) {
2381 + $task->watchers()->syncWithoutDetaching([
2382 + $watcherId => ['object_type' => Constant::OBJECT_TYPE_USER_TASK_WATCH]
2383 + ]);
2384 + }
2385 + }
2386 + }
2387 + }
2388 +
2389 + public function setDefaultAssigneesToEveryTasks($stage)
2390 + {
2391 + $tasks = $stage->tasks->whereNull('archived_at');
2392 + foreach ($tasks as $task) {
2393 + $this->manageDefaultAssignees($task, $stage->id);
2394 + }
2395 + }
2396 +
2397 + public function createTaskFromImage($board_id, $stage_id, $uploadInfo, $file)
2398 + {
2399 +
2400 + $board = Board::find($board_id);
2401 + $stage = Stage::where('id', absint($stage_id))
2402 + ->where('board_id', absint($board_id))
2403 + ->first();
2404 +
2405 + if (!$board || !$stage) {
2406 + throw new \Exception(esc_html__('Stage not found', 'fluent-boards'));
2407 + }
2408 +
2409 + $task = new Task();
2410 + $taskType = $board->type === 'to-do' ? 'task' : 'roadmap' ;
2411 + $taskData = [
2412 + 'title' => $uploadInfo[0]['name'],
2413 + 'board_id' => $board_id,
2414 + 'stage_id' => $stage_id,
2415 + 'type' => $taskType,
2416 + ];
2417 + $task->fill($taskData);
2418 + $task->save();
2419 +
2420 + $fileData = $uploadInfo[0];
2421 + $fileUploadedData = $this->uploadMediaFileFromWpEditor($task->id, $fileData, Constant::TASK_DESCRIPTION);
2422 + if(!!defined('FLUENT_BOARDS_PRO_VERSION')) {
2423 + $mediaData = (new AttachmentService())->processMediaData($fileData, $file);
2424 + $fileUploadedData['driver'] = $mediaData['driver'];
2425 + $fileUploadedData['file_path'] = $mediaData['file_path'];
2426 + $fileUploadedData['full_url'] = $mediaData['full_url'];
2427 + $fileUploadedData->save();
2428 + }
2429 +
2430 + $settings = $task->settings;
2431 + $settings['cover'] = [
2432 + 'imageId' => $fileUploadedData['id'],
2433 + 'backgroundImage' => (new CommentService())->createPublicUrl($fileUploadedData, $board_id),
2434 + ];
2435 + $task->settings = $settings;
2436 + $task = $task->moveToNewPosition(1);
2437 + $task->save();
2438 + $task->load(['board', 'stage', 'labels', 'assignees']);
2439 +
2440 + $task->assignees = Helper::sanitizeUserCollections($task->assignees);
2441 +
2442 + $task->isOverdue = $task->isOverdue();
2443 + $task->contact = Task::lead_contact($task->crm_contact_id);
2444 + $task->board->stages = (new StageService())->stagesByBoardId($board_id);
2445 + $task->is_watching = (new NotificationService())->isCurrentUserObservingTask($task);
2446 +
2447 + $task = $this->loadNextStage($task);
2448 +
2449 + if ($task->type == 'roadmap') {
2450 + $task->vote_statistics = $this->getIdeaVoteStatistics($task->id);
2451 + }
2452 +
2453 + return $task;
2454 + }
2455 + public function deleteTaskCoverImage($settings)
2456 + {
2457 + if (isset($settings['cover']['imageId']) && $settings['cover']['imageId']) {
2458 + $image = TaskImage::find($settings['cover']['imageId']);
2459 + if ($image) {
2460 + $deletedImage = clone $image;
2461 + $deletedImage->delete();
2462 +
2463 + do_action('fluent_boards/task_attachment_deleted', $deletedImage);
2464 + }
2465 + }
2466 +
2467 + }
2468 +
2469 + private function deleteTaskAttachments($task)
2470 + {
2471 + if (!defined('FLUENT_BOARDS_PRO_VERSION')) {
2472 + return;
2473 + }
2474 +
2475 + $attachments = TaskAttachment::where('object_id', $task->id)
2476 + ->where('object_type', Constant::TASK_ATTACHMENT)
2477 + ->get();
2478 + foreach ($attachments as $attachment) {
2479 + $deletedAttachment = clone $attachment;
2480 + $attachment->delete();
2481 +
2482 + do_action('fluent_boards/task_attachment_deleted', $deletedAttachment, $task->board_id);
2483 + }
2484 + }
2485 +
2486 + /**
2487 + * Delete task attachments one at a time so each attachment-deleted hook is preserved.
2488 + *
2489 + * @param array $taskIds
2490 + * @param array $taskBoardIds
2491 + * @return void
2492 + */
2493 + private function deleteTaskAttachmentsBatch($taskIds, $taskBoardIds)
2494 + {
2495 + if (!defined('FLUENT_BOARDS_PRO_VERSION')) {
2496 + return;
2497 + }
2498 +
2499 + $attachments = TaskAttachment::whereIn('object_id', $taskIds)
2500 + ->where('object_type', Constant::TASK_ATTACHMENT)
2501 + ->get();
2502 +
2503 + foreach ($attachments as $attachment) {
2504 + $deletedAttachment = clone $attachment;
2505 + $attachment->delete();
2506 + $boardId = $taskBoardIds[(int) $attachment->object_id] ?? null;
2507 +
2508 + do_action('fluent_boards/task_attachment_deleted', $deletedAttachment, $boardId);
2509 + }
2510 + }
2511 +
2512 + public function cloneTask(int $taskId, $taskData, $boardId = null): Task
2513 + {
2514 + global $wpdb;
2515 + $attachmentFileService = new AttachmentFileService();
2516 +
2517 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Transaction control for atomic task cloning operation
2518 + $wpdb->query('START TRANSACTION');
2519 +
2520 + try {
2521 + // Load task with all necessary relationships
2522 + $taskQuery = Task::with([
2523 + 'assignees',
2524 + 'labels',
2525 + 'watchers',
2526 + ])->where('id', $taskId);
2527 +
2528 + if ($boardId) {
2529 + $taskQuery->where('board_id', absint($boardId));
2530 + }
2531 +
2532 + $task = $taskQuery->first();
2533 +
2534 + if (!$task) {
2535 + throw new \Exception(esc_html__('Task not found', 'fluent-boards'));
2536 + }
2537 +
2538 + // Create new task with cloned data
2539 + $clonedTask = $task->replicate();
2540 + $clonedTask->title = $taskData['title'] ?? $task->title . ' (' . \__('cloned', 'fluent-boards') . ')';
2541 +
2542 + $settings = $clonedTask->settings ?? [];
2543 +
2544 + unset(
2545 + $settings['attachment_count'],
2546 + $settings['subtask_completed_count'],
2547 + $settings['subtask_count']
2548 + );
2549 + $clonedTask->settings = $settings;
2550 + $clonedTask->stage_id = $taskData['stage_id'] ?? $task->stage_id;
2551 +
2552 + // Validate that target stage belongs to the same board
2553 + $targetStage = Stage::where('id', $clonedTask->stage_id)
2554 + ->where('board_id', $task->board_id)
2555 + ->first();
2556 +
2557 + if (!$targetStage) {
2558 + throw new \Exception(esc_html__('Stage not found', 'fluent-boards'));
2559 + }
2560 +
2561 + $clonedTask->board_id = $targetStage->board_id;
2562 +
2563 + $clonedTask->comments_count = 0; // Reset comments count for cloned task
2564 + $clonedTask->save();
2565 +
2566 + $positionIndex = 1; // Default position index for new task
2567 + if($task->stage_id === $clonedTask->stage_id) {
2568 + // Calculate position for the cloned task next to original task
2569 + $positionIndex = $this->calculateClonedTaskPosition($task);
2570 + }
2571 + // Move cloned task to the new position
2572 + $clonedTask->moveToNewPosition($positionIndex);
2573 +
2574 + $this->cloneTaskMeta($task, $clonedTask);
2575 +
2576 + $this->cloneTaskCustomFields($task, $clonedTask);
2577 +
2578 + // Apply stage default assignees if any are set
2579 + $this->manageDefaultAssignees($clonedTask, $clonedTask->stage_id);
2580 +
2581 + if($taskData['assignee']) {
2582 + $this->cloneAssignees($task, $clonedTask);
2583 + }
2584 + if($taskData['label']) {
2585 + $this->cloneTaskLabels($task, $clonedTask);
2586 + }
2587 + $this->cloneTaskWatchers($task, $clonedTask);
2588 +
2589 + $attachmentFileService->cloneTaskFilesToBoard($task, $clonedTask, $clonedTask->board_id, [
2590 + 'description_images' => true,
2591 + 'cover' => true,
2592 + 'task_attachments' => (bool) $taskData['attachment'],
2593 + ]);
2594 +
2595 + if(!!defined('FLUENT_BOARDS_PRO_VERSION')) {
2596 + // Clone time tracking data if Pro version is active
2597 + if ($taskData['subtask']) {
2598 + $this->cloneSubtasks($task, $clonedTask, (bool) $taskData['attachment'], $attachmentFileService);
2599 + }
2600 + }
2601 +
2602 + if($taskData['comment']) {
2603 + $this->cloneCommentsAndReplies($task, $clonedTask);
2604 + }
2605 +
2606 + // Load and prepare the cloned task for response
2607 + $clonedTask = $this->prepareClonedTaskForResponse($clonedTask);
2608 + do_action('fluent_boards/task_cloned', $task, $clonedTask);
2609 +
2610 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Transaction control for atomic task cloning operation
2611 + $wpdb->query('COMMIT');
2612 + return $clonedTask;
2613 +
2614 + } catch (\Exception $e) {
2615 + $attachmentFileService->rollbackCreatedFiles();
2616 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Transaction control for atomic task cloning operation
2617 + $wpdb->query('ROLLBACK');
2618 + throw new \Exception(
2619 + esc_html(\__('Failed to clone task: ', 'fluent-boards') . $e->getMessage()),
2620 + (int) ($e->getCode() ?: 500)
2621 + );
2622 + }
2623 + }
2624 +
2625 + private function calculateClonedTaskPosition(Task $originalTask): int
2626 + {
2627 + $tasks = Task::where('stage_id', $originalTask->stage_id)
2628 + ->whereNull('archived_at')
2629 + ->orderBy('position', 'asc')
2630 + ->get();
2631 +
2632 + $index = $tasks->search(function($task) use ($originalTask) {
2633 + return $task->id === $originalTask->id;
2634 + });
2635 +
2636 + return $index !== false ? $index + 2 : 1; // Return 1-based index
2637 + }
2638 +
2639 + private function cloneTaskMeta(Task $originalTask, Task $clonedTask): void
2640 + {
2641 + $taskMetas = TaskMeta::where('task_id', $originalTask->id)
2642 + ->where('key', '!=', Constant::SUBTASK_GROUP_NAME)
2643 + ->get();
2644 + foreach ($taskMetas as $meta) {
2645 + TaskMeta::create([
2646 + 'task_id' => $clonedTask->id,
2647 + 'key' => $meta->key,
2648 + 'value' => $meta->value
2649 + ]);
2650 + }
2651 + }
2652 +
2653 + private function cloneAssignees($originalTask, $clonedTask)
2654 + {
2655 + // Clone assignees
2656 + if ($originalTask->assignees) {
2657 + foreach ($originalTask->assignees as $assignee) {
2658 + $clonedTask->assignees()->syncWithoutDetaching([$assignee->ID => ['object_type' => Constant::OBJECT_TYPE_TASK_ASSIGNEE]]);
2659 + }
2660 + }
2661 + }
2662 +
2663 + private function cloneTaskLabels(Task $originalTask, Task $clonedTask): void
2664 + {
2665 + // Clone labels
2666 + if ($originalTask->labels) {
2667 + foreach ($originalTask->labels as $label) {
2668 + $clonedTask->labels()->syncWithoutDetaching([$label->id => ['object_type' => Constant::OBJECT_TYPE_TASK_LABEL]]);
2669 + }
2670 + }
2671 + }
2672 +
2673 + private function cloneTaskWatchers(Task $originalTask, Task $clonedTask): void
2674 + {
2675 + /// Clone watchers
2676 + if ($originalTask->watchers) {
2677 + foreach ($originalTask->watchers as $watcher) {
2678 + $clonedTask->watchers()->syncWithoutDetaching([$watcher->ID => ['object_type' => Constant::OBJECT_TYPE_USER_TASK_WATCH]]);
2679 + }
2680 + }
2681 + }
2682 + private function cloneTaskCustomFields(Task $originalTask, Task $clonedTask): void
2683 + {
2684 +
2685 + // Clone custom fields
2686 + if ($originalTask->taskCustomFields) {
2687 + foreach ($originalTask->taskCustomFields as $customField) {
2688 + $clonedField = $customField->replicate();
2689 + $clonedField->object_id = $clonedTask->id;
2690 + $clonedField->save();
2691 + }
2692 + }
2693 + }
2694 + private function cloneAttachments(Task $originalTask, Task $clonedTask): void
2695 + {
2696 + $attachments = $originalTask->attachments;
2697 + foreach ($attachments as $attachment) {
2698 + $clonedAttachment = $attachment->replicate();
2699 + $clonedAttachment->object_id = $clonedTask->id;
2700 + $clonedAttachment->save();
2701 +
2702 + // If this is a cover image, update task settings
2703 + if ($attachment->type === 'cover_image') {
2704 + $settings = $clonedTask->settings;
2705 + if (isset($settings['cover_image'])) {
2706 + $settings['cover_image'] = $clonedAttachment->id;
2707 + $clonedTask->settings = $settings;
2708 + $clonedTask->save();
2709 + }
2710 + }
2711 + }
2712 + $settings = $clonedTask->settings;
2713 + $settings['attachment_count'] = $clonedTask->attachments()->count();
2714 + $clonedTask['settings'] = $settings;
2715 + $clonedTask->save();
2716 + }
2717 + private function cloneCommentsAndReplies(Task $originalTask, Task $clonedTask)
2718 + {
2719 + // Get comments ordered by created_at
2720 + $comments = Comment::where('task_id', $originalTask->id)
2721 + ->where('type', 'comment')
2722 + ->whereNull('parent_id')
2723 + ->orderBy('created_at', 'asc')
2724 + ->get();
2725 +
2726 + if ($comments->isEmpty()) {
2727 + return;
2728 + }
2729 +
2730 + foreach ($comments as $comment) {
2731 + $clonedComment = $comment->replicate();
2732 + $clonedComment->task_id = $clonedTask->id;
2733 + $clonedComment->save();
2734 +
2735 + // Get replies ordered by created_at
2736 + $replies = Comment::where('parent_id', $comment->id)
2737 + ->where('type', 'reply')
2738 + ->orderBy('created_at', 'asc')
2739 + ->get();
2740 +
2741 + foreach ($replies as $reply) {
2742 + $clonedReply = $reply->replicate();
2743 + $clonedReply->task_id = $clonedTask->id;
2744 + $clonedReply->parent_id = $clonedComment->id;
2745 + $clonedReply->save();
2746 +
2747 + // Clone reply image if any
2748 + $this->cloneCommentOrReplyImage($reply, $clonedReply);
2749 + }
2750 +
2751 + // Clone comment image if any
2752 + $this->cloneCommentOrReplyImage($comment, $clonedComment);
2753 + }
2754 + return;
2755 + }
2756 + private function cloneCommentOrReplyImage($oldCommentOrReply, $clonedCommentOrReply)
2757 + {
2758 + $images = CommentImage::where('object_id', $oldCommentOrReply->id)
2759 + ->where('object_type', Constant::COMMENT_IMAGE)
2760 + ->orderBy('created_at', 'asc')
2761 + ->get();
2762 +
2763 + if ($images->count() > 0) {
2764 + foreach ($images as $image) {
2765 + $clonedImage = $image->replicate();
2766 + $clonedImage->object_id = $clonedCommentOrReply->id;
2767 + (new CommentService())->applyCommentImageScope(
2768 + $clonedImage,
2769 + $clonedCommentOrReply->board_id,
2770 + $clonedCommentOrReply->task_id,
2771 + $clonedCommentOrReply->created_by
2772 + );
2773 + $clonedImage->save();
2774 + }
2775 + }
2776 + }
2777 + private function cloneSubtasks(Task $originalTask, Task $clonedTask, bool $cloneAttachments = false, ?AttachmentFileService $attachmentFileService = null): void
2778 + {
2779 + // First clone subtask groups
2780 + $subtaskGroupMap = $this->cloneSubtaskGroups($originalTask, $clonedTask);
2781 + $completedSubtasksCount = 0;
2782 +
2783 + if ($originalTask->subtasks) {
2784 + foreach ($originalTask->subtasks as $subtask) {
2785 + $clonedSubtask = $subtask->replicate();
2786 + $clonedSubtask->parent_id = $clonedTask->id;
2787 + $clonedSubtask->board_id = $clonedTask->board_id; // Ensure subtask has same board_id as parent
2788 + $clonedSubtask->save();
2789 + $attachmentFileService = $attachmentFileService ?: new AttachmentFileService();
2790 + $attachmentFileService->cloneTaskFilesToBoard($subtask, $clonedSubtask, $clonedTask->board_id, [
2791 + 'description_images' => true,
2792 + 'cover' => true,
2793 + 'task_attachments' => $cloneAttachments,
2794 + ]);
2795 + if($clonedSubtask->status == 'closed') {
2796 + $completedSubtasksCount++;
2797 + }
2798 +
2799 + // Update subtask group relationship if exists
2800 + $groupRelation = TaskMeta::where('task_id', $subtask->id)
2801 + ->where('key', Constant::SUBTASK_GROUP_CHILD)
2802 + ->first();
2803 +
2804 + if ($groupRelation && isset($subtaskGroupMap[$groupRelation->value])) {
2805 + TaskMeta::create([
2806 + 'task_id' => $clonedSubtask->id,
2807 + 'key' => Constant::SUBTASK_GROUP_CHILD,
2808 + 'value' => $subtaskGroupMap[$groupRelation->value]
2809 + ]);
2810 + }
2811 + }
2812 + }
2813 + $settings = $clonedTask->settings;
2814 + $settings['subtask_count'] = $clonedTask->subtasks()->count();
2815 + $clonedTask['settings'] = $settings;
2816 + $clonedTask->settings['subtask_completed_count'] = $completedSubtasksCount;
2817 + $clonedTask->save();
2818 + }
2819 + private function cloneSubtaskGroups(Task $originalTask, Task $clonedTask): array
2820 + {
2821 + $subtaskGroupMap = [];
2822 +
2823 + if ($originalTask->subtaskGroup) {
2824 + foreach ($originalTask->subtaskGroup as $group) {
2825 + $clonedGroup = TaskMeta::create([
2826 + 'task_id' => $clonedTask->id,
2827 + 'key' => Constant::SUBTASK_GROUP_NAME,
2828 + 'value' => $group->value
2829 + ]);
2830 +
2831 + $subtaskGroupMap[$group->id] = $clonedGroup->id;
2832 + }
2833 + }
2834 +
2835 + return $subtaskGroupMap;
2836 + }
2837 + private function prepareClonedTaskForResponse(Task $clonedTask): Task
2838 + {
2839 + // Load relationships
2840 + $clonedTask->load(['board', 'stage', 'labels', 'assignees', 'subtasks']);
2841 +
2842 + // Sanitize assignees
2843 + $clonedTask->assignees = Helper::sanitizeUserCollections($clonedTask->assignees);
2844 +
2845 + // Set additional properties
2846 + $clonedTask->isOverdue = $clonedTask->isOverdue();
2847 + $clonedTask->contact = Task::lead_contact($clonedTask->crm_contact_id);
2848 + $clonedTask->board->stages = (new StageService())->stagesByBoardId($clonedTask->board_id);
2849 + $clonedTask->is_watching = (new NotificationService())->isCurrentUserObservingTask($clonedTask);
2850 +
2851 + // Load next stage if applicable
2852 + return $this->loadNextStage($clonedTask);
2853 + }
2854 +
2855 + /**
2856 + * Clean up archived_by_stage metadata if it exists for a task
2857 + *
2858 + * @param int $taskId
2859 + * @return void
2860 + */
2861 + private function cleanupArchivedByStageMetaIfExists($taskId)
2862 + {
2863 + TaskMeta::where('task_id', $taskId)
2864 + ->where('key', Constant::META_KEY_ARCHIVED_BY_STAGE)
2865 + ->delete();
2866 + }
2867 +
2868 + /**
2869 + * Handle bulk actions for multiple tasks
2870 + *
2871 + * @param array $taskIds
2872 + * @param string $action
2873 + * @param array $params
2874 + * @param int $boardId
2875 + * @return array
2876 + * @throws \Exception
2877 + */
2878 + public function bulkActions($taskIds, $action, $params, $boardId)
2879 + {
2880 + if (empty($taskIds) || !is_array($taskIds)) {
2881 + throw new \Exception(esc_html__('No tasks selected', 'fluent-boards'));
2882 + }
2883 +
2884 + if (count($taskIds) > 150) {
2885 + throw new \Exception(esc_html__('Cannot process more than 150 tasks at once. Please select fewer tasks.', 'fluent-boards'));
2886 + }
2887 +
2888 + if (empty($action)) {
2889 + throw new \Exception(esc_html__('No action specified', 'fluent-boards'));
2890 + }
2891 +
2892 + $tasks = Task::whereIn('id', $taskIds)
2893 + ->where('board_id', $boardId)
2894 + ->get();
2895 +
2896 + if ($tasks->isEmpty()) {
2897 + throw new \Exception(esc_html__('No valid tasks found', 'fluent-boards'));
2898 + }
2899 +
2900 + $result = [
2901 + 'successful_tasks' => [],
2902 + 'failed_tasks' => [],
2903 + 'message' => ''
2904 + ];
2905 +
2906 + switch ($action) {
2907 + case 'move_tasks':
2908 + $result = $this->bulkMoveTasks($tasks, $params, $boardId);
2909 + break;
2910 +
2911 + case 'move_to_stage':
2912 + // Backward compatibility - redirect to move_tasks
2913 + $result = $this->bulkMoveTasks($tasks, $params, $boardId);
2914 + break;
2915 +
2916 + case 'archive_tasks':
2917 + $result = $this->bulkArchiveTasks($tasks);
2918 + break;
2919 +
2920 + case 'change_priority':
2921 + $result = $this->bulkChangePriority($tasks, $params);
2922 + break;
2923 +
2924 + case 'assign_members':
2925 + $result = $this->bulkAssignMembers($tasks, $params, $boardId);
2926 + break;
2927 +
2928 + case 'add_labels':
2929 + $result = $this->bulkAddLabels($tasks, $params, $boardId);
2930 + break;
2931 +
2932 + default:
2933 + throw new \Exception(esc_html__('Invalid action specified', 'fluent-boards'));
2934 + }
2935 +
2936 + // Dispatch WordPress action for other plugins to hook into
2937 + do_action('fluent_boards/bulk_action_completed', $action, $tasks, $boardId);
2938 +
2939 + return $result;
2940 + }
2941 +
2942 + /**
2943 + * Bulk move tasks to a stage (same board) or to another board
2944 + * Unified method that handles both same-board stage moves and cross-board moves
2945 + */
2946 + private function bulkMoveTasks($tasks, $params, $sourceBoardId)
2947 + {
2948 + $targetStageId = $params['target_stage_id'] ?? null;
2949 + if (!$targetStageId) {
2950 + throw new \Exception(esc_html__('Target stage ID is required', 'fluent-boards'));
2951 + }
2952 +
2953 + $targetBoardId = $params['target_board_id'] ?? null;
2954 + $isMovingToAnotherBoard = $targetBoardId && $targetBoardId != $sourceBoardId;
2955 +
2956 + // Determine effective target board ID
2957 + $effectiveTargetBoardId = $isMovingToAnotherBoard ? $targetBoardId : $sourceBoardId;
2958 +
2959 + // Validate target board exists and is not archived
2960 + $targetBoard = Board::find($effectiveTargetBoardId);
2961 + if (!$targetBoard) {
2962 + throw new \Exception(esc_html__('Target board not found', 'fluent-boards'));
2963 + }
2964 +
2965 + if ($targetBoard->archived_at) {
2966 + throw new \Exception(esc_html__('Cannot move tasks to an archived board', 'fluent-boards'));
2967 + }
2968 +
2969 + // Verify user has write access to target board if moving to different board
2970 + if ($isMovingToAnotherBoard && !PermissionManager::userHasBoardPermission($effectiveTargetBoardId, 'POST')) {
2971 + throw new \Exception(esc_html__('You do not have permission to add tasks to this board', 'fluent-boards'));
2972 + }
2973 +
2974 + // Validate target stage exists and belongs to target board
2975 + $targetStage = Stage::where('id', $targetStageId)
2976 + ->where('board_id', $effectiveTargetBoardId)
2977 + ->first();
2978 +
2979 + if (!$targetStage) {
2980 + throw new \Exception(esc_html__('Target stage not found in the selected board', 'fluent-boards'));
2981 + }
2982 +
2983 + $successfulTasks = [];
2984 +
2985 + foreach ($tasks as $task) {
2986 + if ($isMovingToAnotherBoard) {
2987 + // Cross-board move - use existing method for data cleanup and security
2988 + $task = $this->changeBoardByTask($task, $effectiveTargetBoardId);
2989 + $task->stage_id = $targetStageId;
2990 + $task = $task->moveToNewPosition(null);
2991 + } else {
2992 + // Same board - simple stage move
2993 + $oldStageId = $task->stage_id;
2994 + $task->stage_id = $targetStageId;
2995 + $task = $task->moveToNewPosition(1);
2996 +
2997 + // Only process stage-specific logic if stage actually changed
2998 + if ($oldStageId != $targetStageId) {
2999 + $this->manageDefaultAssignees($task, $targetStageId);
3000 +
3001 + $defaultPosition = $task->stage->defaultTaskStatus();
3002 + if ($defaultPosition == 'closed' && $task->status != 'closed') {
3003 + $task = $task->close();
3004 + }
3005 +
3006 + $usersToSendEmail = (new NotificationService())->filterAssigneeToSendEmail($task->id, Constant::BOARD_EMAIL_STAGE_CHANGE);
3007 + $this->sendMailAfterTaskModify('stage_change', $usersToSendEmail, $task->id);
3008 + }
3009 + }
3010 +
3011 + // Reload task with all relationships
3012 + $task->load(['labels', 'assignees', 'board', 'stage', 'watchers', 'taskCustomFields']);
3013 +
3014 + $successfulTasks[] = $task;
3015 + }
3016 +
3017 + $successCount = count($successfulTasks);
3018 +
3019 + if ($isMovingToAnotherBoard) {
3020 + // translators: %d is the number of tasks successfully moved to another board
3021 + $message = sprintf(__('%d tasks moved to new board successfully', 'fluent-boards'), $successCount);
3022 + } else {
3023 + // translators: %d is the number of tasks successfully moved to the stage
3024 + $message = sprintf(__('%d tasks moved to stage successfully', 'fluent-boards'), $successCount);
3025 + }
3026 +
3027 + return [
3028 + 'successful_tasks' => $successfulTasks,
3029 + 'failed_tasks' => [],
3030 + 'message' => $message,
3031 + 'moved_to_another_board' => $isMovingToAnotherBoard
3032 + ];
3033 + }
3034 +
3035 + /**
3036 + * Legacy method - kept for backward compatibility
3037 + * @deprecated Use bulkMoveTasks instead
3038 + */
3039 + private function bulkMoveToStage($tasks, $params, $boardId)
3040 + {
3041 + return $this->bulkMoveTasks($tasks, $params, $boardId);
3042 + }
3043 +
3044 + /**
3045 + * Bulk archive tasks
3046 + */
3047 + private function bulkArchiveTasks($tasks)
3048 + {
3049 + $successfulTasks = [];
3050 + $failedTasks = [];
3051 +
3052 + foreach ($tasks as $task) {
3053 + try {
3054 + // Use the same logic as single task archiving
3055 + $this->updateTaskProperty('archived_at', current_time('mysql'), $task);
3056 +
3057 + // Reload task with all relationships
3058 + $task->load(['labels', 'assignees', 'board', 'stage', 'watchers', 'taskCustomFields']);
3059 +
3060 + $successfulTasks[] = $task;
3061 + } catch (\Exception $e) {
3062 + $failedTasks[] = [
3063 + 'id' => $task->id,
3064 + 'title' => $task->title,
3065 + 'error' => $e->getMessage()
3066 + ];
3067 + }
3068 + }
3069 +
3070 + $successCount = count($successfulTasks);
3071 + $failureCount = count($failedTasks);
3072 +
3073 + $message = '';
3074 + if ($failureCount === 0) {
3075 + // translators: %d is the number of tasks archived successfully
3076 + $message = sprintf(__('%d tasks archived successfully', 'fluent-boards'), $successCount);
3077 + } elseif ($successCount === 0) {
3078 + // translators: %d is the number of tasks that failed to archive
3079 + $message = sprintf(__('Failed to archive %d tasks', 'fluent-boards'), $failureCount);
3080 + } else {
3081 + // translators: 1: number of tasks archived successfully; 2: number of tasks failed to archive
3082 + $message = sprintf(__('%1$d tasks archived successfully, %2$d failed', 'fluent-boards'), $successCount, $failureCount);
3083 + }
3084 +
3085 + return [
3086 + 'successful_tasks' => $successfulTasks,
3087 + 'failed_tasks' => $failedTasks,
3088 + 'message' => $message
3089 + ];
3090 + }
3091 +
3092 + /**
3093 + * Bulk change task priority
3094 + */
3095 + private function bulkChangePriority($tasks, $params)
3096 + {
3097 + $priority = $params['priority'] ?? null;
3098 +
3099 + // Get valid priorities including custom ones added by hooks
3100 + $validPriorities = array_keys(apply_filters('fluent_boards/task_priorities', [
3101 + '' => __('No priority', 'fluent-boards'),
3102 + 'urgent' => __('Urgent', 'fluent-boards'),
3103 + 'high' => __('High', 'fluent-boards'),
3104 + 'medium' => __('Medium', 'fluent-boards'),
3105 + 'low' => __('Low', 'fluent-boards')
3106 + ]));
3107 +
3108 + if (!in_array($priority, $validPriorities, true)) {
3109 + throw new \Exception(esc_html__('Invalid priority level', 'fluent-boards'));
3110 + }
3111 +
3112 + $successfulTasks = [];
3113 + $failedTasks = [];
3114 +
3115 + foreach ($tasks as $task) {
3116 + try {
3117 + // Use the same logic as single task priority update
3118 + $this->updateTaskProperty('priority', $priority, $task);
3119 +
3120 + // Reload task with all relationships
3121 + $task->load(['labels', 'assignees', 'board', 'stage', 'watchers', 'taskCustomFields']);
3122 +
3123 + $successfulTasks[] = $task;
3124 + } catch (\Exception $e) {
3125 + $failedTasks[] = [
3126 + 'id' => $task->id,
3127 + 'title' => $task->title,
3128 + 'error' => $e->getMessage()
3129 + ];
3130 + }
3131 + }
3132 +
3133 + $successCount = count($successfulTasks);
3134 + $failureCount = count($failedTasks);
3135 +
3136 + $message = '';
3137 + if ($failureCount === 0) {
3138 + // translators: %d is the number of tasks whose priorities were updated successfully
3139 + $message = sprintf(__('%d task priorities updated successfully', 'fluent-boards'), $successCount);
3140 + } elseif ($successCount === 0) {
3141 + // translators: %d is the number of tasks whose priorities failed to update
3142 + $message = sprintf(__('Failed to update %d task priorities', 'fluent-boards'), $failureCount);
3143 + } else {
3144 + // translators: 1: number of tasks priorities updated; 2: number of tasks priorities failed to update
3145 + $message = sprintf(__('%1$d task priorities updated successfully, %2$d failed', 'fluent-boards'), $successCount, $failureCount);
3146 + }
3147 +
3148 + return [
3149 + 'successful_tasks' => $successfulTasks,
3150 + 'failed_tasks' => $failedTasks,
3151 + 'message' => $message
3152 + ];
3153 + }
3154 +
3155 + /**
3156 + * Bulk assign members to tasks
3157 + */
3158 + private function bulkAssignMembers($tasks, $params, $boardId)
3159 + {
3160 + $userIds = $params['user_ids'] ?? [];
3161 + if (!is_array($userIds)) {
3162 + throw new \Exception(esc_html__('User IDs must be an array', 'fluent-boards'));
3163 + }
3164 +
3165 + // Validate that all users are valid WordPress users
3166 + $validUsers = get_users(['include' => $userIds]);
3167 + $validUserIds = array_map(function($user) {
3168 + return $user->ID;
3169 + }, $validUsers);
3170 +
3171 + if (count($validUserIds) !== count($userIds)) {
3172 + throw new \Exception(esc_html__('Some user IDs are invalid', 'fluent-boards'));
3173 + }
3174 +
3175 + // Filter only users who are already board members (skip non-members)
3176 + $boardService = new \FluentBoards\App\Services\BoardService();
3177 + $boardMemberIds = [];
3178 + foreach ($validUserIds as $userId) {
3179 + if ($boardService->isAlreadyMember($boardId, $userId)) {
3180 + $boardMemberIds[] = $userId;
3181 + }
3182 + }
3183 +
3184 + // If no valid board members, skip assignment silently
3185 + if (empty($boardMemberIds)) {
3186 + return [
3187 + 'successful_tasks' => [],
3188 + 'failed_tasks' => [],
3189 + 'message' => __('No valid board members selected for assignment', 'fluent-boards')
3190 + ];
3191 + }
3192 +
3193 + // Use only board members for assignment
3194 + $validUserIds = $boardMemberIds;
3195 +
3196 + $successfulTasks = [];
3197 + $failedTasks = [];
3198 +
3199 + foreach ($tasks as $task) {
3200 + try {
3201 + // Use pure "add-only" logic for bulk assignment - never remove existing assignees
3202 + $currentAssigneeIds = $task->assignees->pluck('ID')->toArray();
3203 + $newAssignees = [];
3204 +
3205 + foreach ($validUserIds as $userId) {
3206 + // Only add if not already assigned
3207 + if (!in_array($userId, $currentAssigneeIds)) {
3208 + $newAssignees[] = $userId;
3209 + }
3210 + }
3211 +
3212 + // Add all new assignees at once
3213 + if (!empty($newAssignees)) {
3214 + $assigneeData = [];
3215 + foreach ($newAssignees as $userId) {
3216 + $assigneeData[$userId] = ['object_type' => Constant::OBJECT_TYPE_TASK_ASSIGNEE];
3217 + }
3218 + $task->assignees()->syncWithoutDetaching($assigneeData);
3219 +
3220 + // Add as watchers
3221 + $watcherData = [];
3222 + foreach ($newAssignees as $userId) {
3223 + $watcherData[$userId] = ['object_type' => Constant::OBJECT_TYPE_USER_TASK_WATCH];
3224 + }
3225 + $task->watchers()->syncWithoutDetaching($watcherData);
3226 +
3227 + // Send notifications and actions only for new assignees
3228 + foreach ($newAssignees as $userId) {
3229 + // Send email notification if enabled and not current user
3230 + if ((new \FluentBoards\App\Services\NotificationService())->checkIfEmailEnable($userId, Constant::BOARD_EMAIL_TASK_ASSIGN, $task->board_id) && $userId != get_current_user_id()) {
3231 + $this->sendMailAfterTaskModify('add_assignee', $userId, $task->id);
3232 + }
3233 +
3234 + // Dispatch WordPress actions
3235 + //currently commented, need to check in future for bulk action
3236 +// do_action('fluent_boards/task_assignee_added', $task, $userId);
3237 +// if ($userId != get_current_user_id()) {
3238 +// do_action('fluent_boards/assign_another_user', $task, $userId);
3239 +// }
3240 + }
3241 + }
3242 +
3243 + // Update task timestamp and reload all relationships
3244 + $task->updated_at = current_time('mysql');
3245 + $task->save();
3246 + $task->load(['labels', 'assignees', 'board', 'stage', 'watchers', 'taskCustomFields']);
3247 +
3248 + $successfulTasks[] = $task;
3249 + } catch (\Exception $e) {
3250 + $failedTasks[] = [
3251 + 'id' => $task->id,
3252 + 'title' => $task->title,
3253 + 'error' => $e->getMessage()
3254 + ];
3255 + }
3256 + }
3257 +
3258 + $successCount = count($successfulTasks);
3259 + $failureCount = count($failedTasks);
3260 +
3261 + $message = '';
3262 + if ($failureCount === 0) {
3263 + // translators: %d is the number of tasks where members were assigned successfully
3264 + $message = sprintf(__('%d tasks assigned members successfully', 'fluent-boards'), $successCount);
3265 + } elseif ($successCount === 0) {
3266 + // translators: %d is the number of tasks where assigning members failed
3267 + $message = sprintf(__('Failed to assign members to %d tasks', 'fluent-boards'), $failureCount);
3268 + } else {
3269 + // translators: 1: number of tasks with members assigned successfully; 2: number of tasks where assigning members failed
3270 + $message = sprintf(__('%1$d tasks assigned members successfully, %2$d failed', 'fluent-boards'), $successCount, $failureCount);
3271 + }
3272 +
3273 + return [
3274 + 'successful_tasks' => $successfulTasks,
3275 + 'failed_tasks' => $failedTasks,
3276 + 'message' => $message
3277 + ];
3278 + }
3279 +
3280 + /**
3281 + * Bulk add labels to tasks
3282 + */
3283 + private function bulkAddLabels($tasks, $params, $boardId)
3284 + {
3285 + $labelIds = $params['label_ids'] ?? [];
3286 + if (!is_array($labelIds)) {
3287 + throw new \Exception(esc_html__('Label IDs must be an array', 'fluent-boards'));
3288 + }
3289 +
3290 + // Validate that all labels exist and belong to the board
3291 + $validLabels = \FluentBoards\App\Models\Label::whereIn('id', $labelIds)
3292 + ->where('board_id', $boardId)
3293 + ->whereNull('archived_at')
3294 + ->get();
3295 +
3296 + if (count($validLabels) !== count($labelIds)) {
3297 + throw new \Exception(esc_html__('Some label IDs are invalid or do not belong to this board', 'fluent-boards'));
3298 + }
3299 +
3300 + $successfulTasks = [];
3301 + $failedTasks = [];
3302 +
3303 + foreach ($tasks as $task) {
3304 + try {
3305 + // Load existing labels first to avoid query issues
3306 + $task->load('labels');
3307 + $existingLabelIds = $task->labels->pluck('id')->toArray();
3308 +
3309 + // Use the same logic as single task label adding
3310 + foreach ($validLabels as $label) {
3311 + // Check if label is already attached
3312 + if (!in_array($label->id, $existingLabelIds)) {
3313 + // Add the label using syncWithoutDetaching to avoid duplicates
3314 + $task->labels()->syncWithoutDetaching([
3315 + $label->id => ['object_type' => Constant::OBJECT_TYPE_TASK_LABEL]
3316 + ]);
3317 +
3318 + // Dispatch WordPress action for label addition
3319 + //currently commented, need to check in future for bulk action
3320 +// do_action('fluent_boards/task_label', $task, $label, 'added');
3321 + }
3322 + }
3323 +
3324 + // Reload the task with all relationships
3325 + $task->load(['labels', 'assignees', 'board', 'stage', 'watchers', 'taskCustomFields']);
3326 +
3327 + $successfulTasks[] = $task;
3328 + } catch (\Exception $e) {
3329 + $failedTasks[] = [
3330 + 'id' => $task->id,
3331 + 'title' => $task->title,
3332 + 'error' => $e->getMessage()
3333 + ];
3334 + }
3335 + }
3336 +
3337 + $successCount = count($successfulTasks);
3338 + $failureCount = count($failedTasks);
3339 +
3340 + $message = '';
3341 + if ($failureCount === 0) {
3342 + // translators: %d is the number of tasks labeled successfully
3343 + $message = sprintf(__('%d tasks labeled successfully', 'fluent-boards'), $successCount);
3344 + } elseif ($successCount === 0) {
3345 + // translators: %d is the number of tasks that failed to label
3346 + $message = sprintf(__('Failed to label %d tasks', 'fluent-boards'), $failureCount);
3347 + } else {
3348 + // translators: 1: number of tasks labeled successfully; 2: number of tasks failed to label
3349 + $message = sprintf(__('%1$d tasks labeled successfully, %2$d failed', 'fluent-boards'), $successCount, $failureCount);
3350 + }
3351 +
3352 + return [
3353 + 'successful_tasks' => $successfulTasks,
3354 + 'failed_tasks' => $failedTasks,
3355 + 'message' => $message
3356 + ];
3357 + }
3358 +
3359 + /**
3360 + * Delete time tracking records for one or multiple tasks.
3361 + *
3362 + * @param int|array $taskIds Single task ID or array of task IDs.
3363 + * @param bool $suppressErrors Whether cleanup failures should be ignored.
3364 + * @return void
3365 + */
3366 + public function deleteTimeTrackingRecords($taskIds, $suppressErrors = true)
3367 + {
3368 + // Check if FluentBoards Pro time tracking is available
3369 + if (!class_exists('FluentBoardsPro\App\Modules\TimeTracking\Model\TimeTrack')) {
3370 + return;
3371 + }
3372 +
3373 + try {
3374 + // Handle single task ID or array of task IDs
3375 + if (is_array($taskIds)) {
3376 + if (!empty($taskIds)) {
3377 + \FluentBoardsPro\App\Modules\TimeTracking\Model\TimeTrack::whereIn('task_id', $taskIds)->delete();
3378 + }
3379 + } else {
3380 + if (is_numeric($taskIds) && $taskIds > 0) {
3381 + \FluentBoardsPro\App\Modules\TimeTracking\Model\TimeTrack::where('task_id', (int) $taskIds)->delete();
3382 + }
3383 + }
3384 + } catch (\Throwable $e) {
3385 + if (!$suppressErrors) {
3386 + throw $e;
3387 + }
3388 + }
3389 + }
720 3390
721 3391 }