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