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 +2733 -226 1.32trunk View file →
@@ -1,42 +1,116 @@
1 1 <?php
2 2
3 3 namespace FluentBoards\App\Services;
4 4
5 +use FluentBoards\App\App;
5 6 use FluentBoards\App\Models\Attachment;
6 7 use FluentBoards\App\Models\Comment;
8 +use FluentBoards\App\Models\Notification;
7 9 use FluentBoards\App\Models\NotificationUser;
8 10 use FluentBoards\App\Models\TaskImage;
9 11 use FluentBoards\App\Services\Constant;
12 +use FluentBoards\App\Models\Label;
10 13 use FluentBoards\App\Models\Stage;
11 14 use FluentBoards\App\Models\Task;
12 15 use FluentBoards\App\Models\Board;
13 16 use FluentBoards\App\Models\TaskMeta;
17 +use FluentBoards\App\Models\Meta;
14 18 use FluentBoards\App\Models\Activity;
15 -use FluentBoards\App\Models\BoardTerm;
19 +use FluentBoards\App\Models\CommentImage;
20 +use FluentBoards\App\Models\Relation;
16 21 use FluentBoards\Framework\Support\Arr;
17 -use FluentBoardsPro\App\Modules\TimeTracking\TimeTrackingHelper;
18 -use FluentBoardsPro\App\Services\ProTaskService;
22 +use FluentBoardsPro\App\Models\TaskAttachment;
23 +use FluentBoardsPro\App\Services\AttachmentService;
19 24 use FluentBoardsPro\App\Services\RemoteUrlParser;
20 25 use FluentRoadmap\App\Models\IdeaReaction;
21 26
22 27 class TaskService
23 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 +
24 85 public function createTask($data, $boardId)
25 86 {
26 87 $board = Board::select('id', 'type')->find($boardId);
27 88
28 89 if (!$board) {
29 - throw new \Exception(__("Board doesn't exists", 'fluent-boards'));
90 + throw new \Exception(esc_html__("Board doesn't exists", 'fluent-boards'));
30 91 }
31 92
32 93 $stage = Stage::find($data['stage_id']);
33 94 if (!$stage) {
34 - throw new \Exception(__("Stage doesn't exists", 'fluent-boards'));
95 + throw new \Exception(esc_html__("Stage doesn't exists", 'fluent-boards'));
35 96 }
36 97
98 + if ((int) $stage->board_id !== (int) $boardId) {
99 + throw new \Exception(esc_html__("Stage doesn't exists", 'fluent-boards'));
100 + }
101 +
37 102 $data['status'] = $stage->defaultTaskStatus();
38 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 +
39 113 if ($board->type == 'roadmap') {
40 114 $current_user = wp_get_current_user();
41 115 $settingData = array(
42 116 'integration_type' => 'feature',
@@ -44,10 +118,14 @@
44 118 'author' => [
45 119 'email' => $current_user->user_email // email of who posted this feature
46 120 ],
47 121 );
48 - $data['settings'] = $settingData;
122 + $data['settings'] = array_merge($taskSettings, $settingData);
49 123 $data['type'] = 'roadmap';
124 + } elseif ($taskSettings) {
125 + $data['settings'] = $taskSettings;
126 + } else {
127 + unset($data['settings']);
50 128 }
51 129
52 130 $providerPosition = Arr::get($data, 'position');
53 131
@@ -53,11 +131,19 @@
53 131
54 132 $data['position'] = $this->getLastPositionOfTasks($stage->id);
55 133
56 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 + }
57 139
58 140 $data = array_filter($data);
59 141 $task = (new Task())->createTask($data);
142 +
143 + $this->manageDefaultAssignees($task, $stage->id);
144 + $this->manageDefaultWatchers($task, $stage->id);
145 +
60 146 if (isset($data['is_template']) && $data['is_template'] == 'yes') {
61 147 $task->updateMeta(Constant::IS_TASK_TEMPLATE, $data['is_template']);
62 148 }
63 149
@@ -83,21 +169,38 @@
83 169 }
84 170
85 171 public function getTasksForBoards($filters = ['overdue', 'upcoming'], $limit = 5, $task_ids = [])
86 172 {
173 + $assigned = $this->getTasksForBoardsByCategory('assigned', $limit, $task_ids);
87 174 $overDue = $this->getTasksForBoardsByCategory('overdue', $limit, $task_ids);
175 + $dueToday = $this->getTasksForBoardsByCategory('due_today', $limit, $task_ids);
88 176 $completed = $this->getTasksForBoardsByCategory('completed', $limit, $task_ids);
177 + $mentioned = $this->getTasksForBoardsByCategory('mentioned', $limit, $task_ids);
89 178 $upcoming = $this->getTasksForBoardsByCategory('upcoming', $limit, $task_ids);
90 179 $others = $this->getTasksForBoardsByCategory('others', $limit, $task_ids);
91 180
92 181 return [
182 + 'assigned' => $assigned ?? [],
93 183 'overdue' => $overDue ?? [],
184 + 'due_today' => $dueToday ?? [],
94 185 'upcoming' => $upcoming ?? [],
186 + 'mentioned' => $mentioned ?? [],
95 187 'completed' => $completed ?? [],
96 188 'others' => $others ?? []
97 189 ];
98 190 }
99 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 +
100 203 public function getTasksForBoardsByCategory($category, $limit, $taskIds)
101 204 {
102 205 unset($taskQuery);
103 206 $taskQuery = Task::whereIn('id', $taskIds)
@@ -103,20 +206,67 @@
103 206 $taskQuery = Task::whereIn('id', $taskIds)
104 207 ->with(['assignees', 'board', 'stage'])
105 208 ->whereNull('archived_at')
106 209 ->where('parent_id', null)
107 - ->orderBy('due_at', 'ASC');
210 + ->onActiveAvailableBoards()
211 + ->orderBy('due_at', 'DESC');
108 212
109 - if ('overdue' == $category) {
110 - $taskQuery->overdue();
111 - } elseif ('upcoming' == $category) {
112 - $taskQuery->upcoming();
113 - } elseif ('others' == $category) {
114 - $taskQuery->whereNull('due_at');
115 - } elseif ('completed' == $category) {
116 - $taskQuery->where('status', 'closed');
117 - } else {
118 - 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 [];
119 269 }
120 270
121 271 $tasks = $taskQuery->take($limit)->get();
122 272
@@ -122,8 +272,108 @@
122 272
123 273 return $tasks->toArray();
124 274 }
125 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 +
126 376 /*
127 377 * TODO: Refactor this function - For me.
128 378 */
129 379 public function updateTaskProperty($col, $value, $task)
@@ -130,70 +380,135 @@
130 380 {
131 381 $oldTask = clone $task; // normal assigning won't work here. because objects are passed by reference in php
132 382 $validColumns = [
133 383 'board_id',
134 - 'task_type',
135 - 'reminder_type',
384 + 'type',
385 +// 'reminder_type',
136 386 'remind_at',
137 387 'log_minutes',
388 + 'source',
389 + 'source_id',
138 390 'settings'
139 391 ];
140 392
393 + if ($col === 'description') {
394 + $value = DescriptionMarkdownConverter::normalize($value);
395 + }
396 +
141 397 if (in_array($col, $validColumns) && $task->{$col} != $value) {
142 - $task->{$col} = $value;
143 - $task->save();
144 - // do_action('fluent_boards/task_prop_changed', $col, $task, $oldTask);
145 - } elseif ('assignees' == $col) {
146 - if (is_array($value)) {
147 - foreach ($value as $id) {
148 - $this->updateAssignee($id, $task);
149 - }
150 - } else {
151 - $this->updateAssignee($value, $task);
398 + if ($col === 'remind_at') {
399 + $value = Helper::normalizeDateValue($value);
152 400 }
153 401
154 - } elseif ('crm_contact_id' == $col) {
155 - $this->updateAssociate($value, $task);
156 - } elseif ('archived_at' == $col) {
157 - $this->updateArchive($value, $task);
158 - } elseif ('status' == $col) {
159 - $this->updateStatus($value, $task);
160 - } elseif ('parent_id' == $col) {
161 - $this->updateParent($value, $task);
162 - } elseif ('title' == $col) {
163 - $this->updateTitle($col, $value, $task, $oldTask);
164 - } elseif ('description' == $col) {
165 - $this->updateDescription($col, $value, $task, $oldTask);
166 - } elseif ($col == 'due_at') {
167 - $this->updateDueDate($value, $task);
168 - } elseif ($col == 'started_at') {
169 - $this->updateStartedDate($value, $task);
170 - } elseif ($col == 'priority') {
171 - $this->updatePriority($value, $task);
172 - } elseif ($col == 'is_watching') {
173 - $this->updateObservationOfCurrentUser($value, $task);
174 - } elseif ($col == 'last_completed_at') {
175 - $isClosed = $value == 'true' || $value === true;
176 - if ($isClosed) {
177 - $task = $task->close();
178 - } else {
179 - $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']);
180 407 }
408 + $task->{$col} = $value ?: null;
181 409 $task->save();
182 - } elseif ($col == 'attachment_count') {
183 - $settings = $task->settings;
184 - $settings['attachment_count'] = $task->attachments()->count();
185 - $task->settings = $settings;
186 - $task->save();
187 - } elseif ($col == 'subtask_count') {
188 - $settings = $task->settings;
189 - $subtasksCount = Task::where('parent_id', $task->id)->count();
190 - $settings['subtask_count'] = $subtasksCount;
191 - $task->settings = $settings;
192 - $task->save();
193 - } elseif ($col == 'is_template') {
194 - if (defined('FLUENT_BOARDS_PRO')) {
195 - $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;
196 511 }
197 512 }
198 513
199 514 return $task;
@@ -207,9 +522,9 @@
207 522
208 523 $task->save();
209 524
210 525 if ($operation == 'added') {
211 - 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()) {
212 527 $this->sendMailAfterTaskModify('add_assignee', $payloadAssigneeId, $task->id);
213 528 }
214 529 // $assigneeIdsToSendEmail = $this->filterAssigneeToSendEmail($task, $idArray, Constant::BOARD_EMAIL_TASK_ASSIGN);
215 530 // $this->sendMailAfterAddAssignees($assigneeIdsToSendEmail, $task->id);
@@ -217,9 +532,9 @@
217 532 if($payloadAssigneeId != get_current_user_id()){
218 533 do_action('fluent_boards/assign_another_user', $task, $payloadAssigneeId);
219 534 }
220 535 } else {
221 - 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()) {
222 537 $this->sendMailAfterTaskModify('remove_assignee', $payloadAssigneeId, $task->id);
223 538 }
224 539 do_action('fluent_boards/task_assignee_removed', $task, $payloadAssigneeId);
225 540 }
@@ -275,17 +590,34 @@
275 590
276 591 private function updateArchive($value, $task)
277 592 {
278 593 if ($value != null) {
594 + // Archiving task
279 595 $task->position = 0;
280 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 +
281 610 $task->moveToNewPosition(1);
611 +
612 + // Clean up archived_by_stage meta when task is manually restored
613 + $this->cleanupArchivedByStageMetaIfExists($task->id);
282 614 }
283 615 $task->archived_at = $value == null ? null : current_time('mysql');
284 616 $task->save();
285 617 do_action('fluent_boards/task_archived', $task);
286 - $wathersToSendEmail = (new NotificationService())->filterAssigneeToSendEmail($task->id, Constant::BOARD_EMAIL_TASK_ARCHIVE);
287 - $this->sendMailAfterTaskModify('task_archived', $wathersToSendEmail, $task->id);
618 + $watchersToSendEmail = (new NotificationService())->filterAssigneeToSendEmail($task->id, Constant::BOARD_EMAIL_TASK_ARCHIVE);
619 + $this->sendMailAfterTaskModify('task_archived', $watchersToSendEmail, $task->id);
288 620 }
289 621
290 622 private function updateStatus($value, $task)
291 623 {
@@ -312,9 +644,9 @@
312 644 }
313 645
314 646 private function updateDescription($col, $value, $task, $oldTask)
315 647 {
316 - $task->description = $value;
648 + $task->description = DescriptionMarkdownConverter::normalize($value);
317 649 $task->save();
318 650 do_action('fluent_boards/task_content_updated', $task, $col, $oldTask);
319 651 }
320 652
@@ -320,15 +652,19 @@
320 652
321 653 private function updateDueDate($value, $task)
322 654 {
323 655 $oldValue = $task->due_at;
324 - $value = $this->filterNullDate($value);
656 + $value = Helper::normalizeDateValue($value);
325 657 $task->due_at = $value;
326 658 $task->save();
327 659
328 660 $task = $task->reopen();
329 661
330 - do_action('fluent_boards/task_due_date_changed', $task, $oldValue);
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 + }
331 667
332 668 $wathersToSendEmail = (new NotificationService())->filterAssigneeToSendEmail($task->id, Constant::BOARD_EMAIL_DUE_DATE_CHANGE);
333 669 $this->sendMailAfterTaskModify('due_date_update', $wathersToSendEmail, $task->id);
334 670 }
@@ -335,13 +671,15 @@
335 671
336 672 private function updateStartedDate($value, $task)
337 673 {
338 674 $oldValue = $task->started_at;
339 - $value = $this->filterNullDate($value);
675 + $value = Helper::normalizeDateValue($value);
340 676 $task->started_at = $value;
341 677 $task->save();
342 678
343 - do_action('fluent_boards/task_start_date_changed', $task, $oldValue);
679 + if($value){
680 + do_action('fluent_boards/task_start_date_changed', $task, $oldValue);
681 + }
344 682 }
345 683
346 684 private function updatePriority($value, $task)
347 685 {
@@ -350,46 +688,53 @@
350 688 $task->save();
351 689 do_action('fluent_boards/task_priority_changed', $task, $oldPriority);
352 690 }
353 691
354 - public function updateObservationOfCurrentUser($value, $task)
355 - {
356 - $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 + }
357 701
358 - if ($value == 'stop') {
359 - $task->watchers()->detach($currentUserId);
360 - } else {
361 - $task->watchers()->syncWithoutDetaching([$currentUserId => ['object_type' => Constant::OBJECT_TYPE_USER_TASK_WATCH]]);
362 - }
363 - $task->updated_at = current_time('mysql');
364 - $task->save();
702 + if (!$userId || !in_array($action, ['stop', 'start'])) {
703 + return;
704 + }
365 705
366 - if ($value == 'stop') {
367 - $task->is_watching = false;
368 - } else {
369 - $task->is_watching = true;
370 - }
706 + if ($action == 'stop') {
707 + $task->watchers()->detach($userId);
708 + } else {
709 + $task->watchers()->syncWithoutDetaching([$userId => ['object_type' => Constant::OBJECT_TYPE_USER_TASK_WATCH]]);
371 710 }
711 + $task->updated_at = current_time('mysql');
712 + $task->save();
713 +}
372 714
373 - public function taskCoverPhotoUpdate($taskId, $imagePath)
715 + public function taskCoverPhotoUpdate($taskId, $imagePath, $boardId = null)
374 716 {
375 - $task = Task::find($taskId);
717 + $task = $boardId ? $this->findTaskOnBoard($taskId, $boardId) : Task::find($taskId);
376 718 if (!$task) {
377 719 return null;
378 720 }
379 721
380 - $settings = unserialize($task->settings);
722 + $settings = $task->settings;
723 + if (!is_array($settings)) {
724 + $settings = [];
725 + }
381 726
382 727 $settings['logo'] = $imagePath;
383 - $task->settings = serialize($settings);
728 + $task->settings = $settings;
384 729 $task->save();
385 730
386 731 return $task;
387 732 }
388 733
389 - public function taskStatusUpdate($taskId, $integrationType)
734 + public function taskStatusUpdate($taskId, $integrationType, $boardId = null)
390 735 {
391 - $task = Task::find($taskId);
736 + $task = $boardId ? $this->findTaskOnBoard($taskId, $boardId) : Task::find($taskId);
392 737 if (!$task) {
393 738 return null;
394 739 }
395 740
@@ -394,9 +739,9 @@
394 739 }
395 740
396 741 $settings = $task->settings;
397 742 $settings['integration_type'] = $integrationType;
398 - $task->settings = serialize($settings);
743 + $task->settings = $settings;
399 744 $task->save();
400 745
401 746 return $task;
402 747 }
@@ -402,9 +747,9 @@
402 747 }
403 748
404 749 public function assignYourselfInTask($boardId, $taskId)
405 750 {
406 - $task = Task::find($taskId);
751 + $task = $this->findTaskOnBoard($taskId, $boardId);
407 752 $authUserId = get_current_user_id();
408 753
409 754 $boardService = new BoardService();
410 755 if (!$boardService->isAlreadyMember($boardId, $authUserId)) {
@@ -415,8 +760,9 @@
415 760 // when user assign himself then he will be watching that task
416 761 $task->watchers()->syncWithoutDetaching([$authUserId => ['object_type' => Constant::OBJECT_TYPE_USER_TASK_WATCH]]);
417 762
418 763 $task->load('assignees');
764 + do_action('fluent_boards/task_assignee_added', $task, $authUserId);
419 765
420 766 return $task;
421 767 }
422 768
@@ -421,11 +767,13 @@
421 767 }
422 768
423 769 public function detachYourselfFromTask($boardId, $taskId)
424 770 {
425 - $task = Task::find($taskId);
426 - $task->addOrRemoveAssignee(get_current_user_id());
771 + $task = $this->findTaskOnBoard($taskId, $boardId);
772 + $currentUserId = get_current_user_id();
773 + $task->addOrRemoveAssignee($currentUserId);
427 774 $task->load('assignees');
775 + do_action('fluent_boards/task_assignee_removed', $task, $currentUserId);
428 776
429 777 return $task;
430 778 }
431 779
@@ -430,8 +778,158 @@
430 778 }
431 779
432 780 public function deleteTask($task)
433 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 +
434 932 $deleted = $task->delete();
435 933
436 934 if ($deleted) {
437 935
@@ -447,23 +945,18 @@
447 945 //task labels removed
448 946 $task->labels()->detach();
449 947
450 948 //task custom field value
451 - $task->customFields()->detach();
949 + if (defined('FLUENT_BOARDS_PRO_VERSION')) {
950 + $task->customFields()->detach();
951 + $this->deleteTaskAttachments($task);
952 + }
452 953
453 - 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.
454 956 TaskMeta::where('task_id', $task->id)->delete();
455 957 }
456 958 }
457 -
458 - public function filterNullDate($date)
459 - {
460 - if ('0000-00-00 00:00:00' == $date || false === strtotime($date)) {
461 - return null;
462 - }
463 - return $date;
464 - }
465 -
466 959 // this is invoked when task is moved to another board
467 960
468 961 /**
469 962 * @throws \Exception
@@ -469,77 +962,383 @@
469 962 * @throws \Exception
470 963 */
471 964 public function changeBoardByTask($task, $targetBoardId)
472 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 +
473 972 if ($task->board_id == $targetBoardId) {
474 973 return $task;
475 974 }
476 975
477 976 $oldBoard = Board::find($task->board_id);
977 + $newBoard = Board::find($targetBoardId);
478 978
479 - $newBoard = Board::find($targetBoardId);
979 + if (!$oldBoard) {
980 + throw new \Exception(esc_html__('Source board not found', 'fluent-boards'), 404);
981 + }
982 +
480 983 if (!$newBoard) {
481 - throw new \Exception('Invalid board id', 400);
984 + throw new \Exception(esc_html__('Target board not found', 'fluent-boards'), 404);
482 985 }
483 - $task->board_id = $targetBoardId;
484 - $task->save();
485 - //delete labels of that task because labels have board dependencies
486 - $task->labels()->detach();
487 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 +
488 1024 do_action('fluent_boards/task_moved_from_board', $task, $oldBoard, $newBoard);
1025 + return $task;
1026 + }
489 1027
490 - 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 + }
491 1067 }
492 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 + }
493 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 +
494 1184 public function getIdeaVoteStatistics($taskId)
495 1185 {
496 - $reactionTypes = [
497 - [
498 - 'label' => 'Upvote',
499 - 'type' => 'upvote'
500 - ],
501 - [
502 - 'label' => 'Downvote',
503 - 'type' => 'downvote'
504 - ]
505 - ];
1186 + $taskId = absint($taskId);
1187 + $voteStatistics = $this->getIdeaVoteStatisticsByTaskIds([$taskId]);
506 1188
507 - $reactionCounts = [];
1189 + return $voteStatistics[$taskId] ?? 0;
1190 + }
508 1191
509 - foreach ($reactionTypes as $reactionType) {
510 - $count = IdeaReaction::where('object_id', $taskId)
511 - ->where('object_type', 'idea')
512 - ->where('type', $reactionType['type'])
513 - ->count();
1192 + public function loadIdeaVoteStatistics($tasks)
1193 + {
1194 + $taskIds = [];
514 1195
515 - $reactionCounts[] = [
516 - 'label' => $reactionType['label'],
517 - 'type' => $reactionType['type'],
518 - 'count' => $count
519 - ];
1196 + foreach ($tasks as $task) {
1197 + $taskId = absint($task->id);
1198 + if ($taskId) {
1199 + $taskIds[] = $taskId;
1200 + }
520 1201 }
521 1202
522 - 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;
523 1210 }
524 1211
1212 + private function getIdeaVoteStatisticsByTaskIds(array $taskIds)
1213 + {
1214 + global $wpdb;
525 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 +
526 1256 /**
527 - * Summary of getArchivedOrCompletedTasks
528 - * this function will return completd tasks or archived tasks based on users input and also can search by name
529 - * @param mixed $data
530 - * @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
531 1325 * @return mixed
532 1326 * @throws \Exception
533 1327 */
534 1328 public function getArchivedTasks($data, $boardId)
535 1329 {
536 - $per_page = isset($data['per_page']) ? $data['per_page'] : 25;
537 - $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));
538 1337 $tasksQuery = Task::where('board_id', $boardId)->whereNotNull('archived_at');
539 1338
540 - if (isset($data['searchInput'])) {
541 - $query = strtolower($data['searchInput']);
1339 + if (!empty($data['query'])) {
1340 + $query = strtolower($data['query']);
542 1341 $firstThreeChars = substr($query, 0, 3);
543 1342
544 1343 if($firstThreeChars == 'id:') {
545 1344 $idPart = substr($query, 3);
@@ -545,20 +1344,528 @@
545 1344 $idPart = substr($query, 3);
546 1345 $idPart = preg_replace('/[^a-zA-Z0-9]/', '', $idPart);
547 1346 $tasksQuery = $tasksQuery->where('id', 'LIKE', '%' . $idPart . '%');
548 1347 } else {
549 - $tasksQuery = $tasksQuery->where('title', 'LIKE', '%' . $data['searchInput'] . '%');
1348 + $tasksQuery = $tasksQuery->where('title', 'LIKE', '%' . $data['query'] . '%');
550 1349 }
551 1350 }
552 1351
553 - // if board_id is not passed then throw an exception
554 - if (!$boardId) {
555 - 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;
556 1359 }
557 1360
558 - 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;
559 1395 }
560 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 +
561 1868 public function sendMailAfterTaskModify($column, $assigneeIds, $taskId)
562 1869 {
563 1870 $current_user_id = get_current_user_id();
564 1871 /* this will run in background as soon as possible */
@@ -569,14 +1876,17 @@
569 1876
570 1877 public function getStageByTask($task_id)
571 1878 {
572 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 + }
573 1883 return $task->stage;
574 1884 }
575 1885
576 - public function moveTaskToNextStage($task_id)
1886 + public function moveTaskToNextStage($task_id, $boardId = null)
577 1887 {
578 - $task = Task::findOrFail($task_id);
1888 + $task = $boardId ? $this->findTaskOnBoard($task_id, $boardId) : Task::findOrFail($task_id);
579 1889
580 1890 $oldStage = $task->stage;
581 1891
582 1892 $nextStage = Stage::where('board_id', $task->board_id)
@@ -594,8 +1904,11 @@
594 1904 $task->last_completed_at = current_time('mysql');
595 1905 }
596 1906 }
597 1907
1908 + // Clean up archived_by_stage meta when moving to different stage
1909 + $this->cleanupArchivedByStageMetaIfExists($task->id);
1910 +
598 1911 $task->stage_id = $nextStage->id;
599 1912 $task->save();
600 1913
601 1914 $task->load(['board', 'stage', 'attachments']);
@@ -625,27 +1938,36 @@
625 1938 $activityQuery = $activityQuery->latest();
626 1939 } else if ($filter == 'oldest') {
627 1940 $activityQuery = $activityQuery->oldest();
628 1941 }
629 - return $activityQuery->with('user')->paginate($perPage);
1942 + $activities = $activityQuery->with('user')->paginate($perPage);
1943 +
1944 + Helper::translateActivities($activities);
1945 +
1946 + return $activities;
630 1947 }
631 1948
632 - public function getLastOneMinuteUpdatedTasks($boardId, $lastUpdated = null)
1949 + public function getLastOneMinuteUpdatedTasks($boardId, $lastUpdated = null, $includeArchived = true)
633 1950 {
634 1951 if (!$lastUpdated) {
635 - $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
636 1953 }
637 1954
638 - $tasks = Task::query()
1955 + $tasksQuery = Task::query()
639 1956 ->where([
640 1957 'board_id' => $boardId,
641 1958 'parent_id' => null,
642 1959 ])
643 - ->where('updated_at', '>', $lastUpdated)
1960 + ->where('updated_at', '>=', $lastUpdated) // updated since the sync cursor
644 1961 ->with(['assignees', 'labels', 'watchers', 'taskCustomFields'])
645 - ->orderBy('due_at', 'ASC')
646 - ->get();
1962 + ->orderBy('due_at', 'ASC');
647 1963
1964 + if (!$includeArchived) {
1965 + $tasksQuery->whereNull('archived_at');
1966 + }
1967 +
1968 + $tasks = $tasksQuery->get();
1969 +
648 1970 foreach ($tasks as $task) {
649 1971 $task->isOverdue = $task->isOverdue();
650 1972 $task->isUpcoming = $task->upcoming();
651 1973 $task->is_watching = $task->isWatching();
@@ -668,88 +1990,212 @@
668 1990
669 1991 return $lastPosition + 1;
670 1992 }
671 1993
672 - 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)
673 2002 {
674 - $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()
675 2045 ->where('crm_contact_id', $associatedId)
676 - ->with(['board', 'stage', 'assignees', 'labels', 'watchers',])
677 - ->orderBy('due_at', 'ASC')
678 - ->get();
2046 + ->with(['board', 'stage', 'assignees', 'subtaskGroup', 'subtaskGroup.subtasks', 'subtaskGroup.subtasks.assignees'])
2047 + ->orderBy('due_at', 'ASC');
679 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 +
680 2065 foreach ($tasks as $task) {
681 2066 $task->isOverdue = $task->isOverdue();
682 2067 $task->isUpcoming = $task->upcoming();
683 - $task->contact = Task::lead_contact($task->crm_contact_id);
684 - $task->is_watching = $task->isWatching();
2068 + $task->can_edit = $isAdmin || in_array((int)$task->board_id, $editableBoardIds, true);
685 2069
686 2070 $task->assignees = Helper::sanitizeUserCollections($task->assignees);
687 - $task->watchers = Helper::sanitizeUserCollections($task->watchers);
688 2071
689 - if(!!defined('FLUENT_BOARDS_PRO_VERSION')) {
690 - $task->time_tracks= [
691 - 'tracks' => (new ProTaskService())->getTaskTimeTrack($task->board_id, $task->id),
692 - 'estimated_minutes' => TimeTrackingHelper::getTaskEstimation($task->id)
693 - ];
2072 + foreach ($task->subtaskGroup as $group) {
2073 + foreach ($group->subtasks as $subtask) {
2074 + $subtask->assignees = Helper::sanitizeUserCollections($subtask->assignees);
2075 + }
694 2076 }
2077 + $task->subtask_group = $task->subtaskGroup;
2078 + }
695 2079
2080 + return $tasks;
2081 + }
696 2082
697 - $subTasks = Task::query()
698 - ->where('parent_id', $task->id)
699 - ->with(['assignees'])
700 - ->whereNull('archived_at')
701 - ->orderBy('position', 'ASC')
702 - ->get();
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 + ]);
703 2092
704 - foreach ($subTasks as $subTask) {
705 - $subTask->assignees = Helper::sanitizeUserCollections($subTask->assignees);
706 - }
707 -
708 - $task->subtasks = $subTasks;
2093 + $subtaskGroupMap[$group->id] = $newGroup->id;
709 2094 }
710 2095
711 - return $tasks;
2096 + return $subtaskGroupMap;
712 2097 }
713 2098
714 - public function copyTasks($boardId, $stageMap, $newBoard, $labelMap = [])
2099 + public function copyTasks($boardId, $stageMap, $newBoard, $labelMap = [],$isWithTemplates='no')
715 2100 {
716 2101 $allActiveTasks = Task::where('board_id', $boardId)->whereNull('archived_at')->get();
717 2102 $taskMap = [];
2103 + $subtaskGroupMap = [];
718 2104 $parentTaskCount = 0;
719 - foreach ($allActiveTasks as $task) {
720 - $newTask = array();
721 - $newTask['title'] = $task->title;
722 - $newTask['parent_id'] = $task->parent_id ? $taskMap[$task->parent_id] : null;
723 - $newTask['description'] = $task->description;
724 - $newTask['board_id'] = $newBoard->id;
725 - $newTask['stage_id'] = $stageMap[$task->stage_id];
726 - $newTask['status'] = $task->status;
727 - $newTask['priority'] = $task->priority;
728 - $newTask['position'] = $task->position;
729 - $newTask['due_at'] = $task->due_at;
730 - $newTask = Task::create($newTask);
731 - if(!$task->parent_id){
732 - ++$parentTaskCount;
733 - $taskMap[$task['id']] = $newTask->id;
734 - //duplicate labels to task
735 - $labelIds = $task->labels->pluck('id')->toArray();
736 - if($labelIds){
737 - $flipLabelIds = array_flip($labelIds);
738 - $labelsToAttach = array_intersect_key($labelMap, $flipLabelIds);
2105 + $attachmentFileService = new AttachmentFileService();
2106 + $dbInstance = App::getInstance('db');
739 2107
740 - $newTask->labels()->attach($labelsToAttach, [
741 - 'object_type' => Constant::OBJECT_TYPE_TASK_LABEL
742 - ]);
2108 + $dbInstance->beginTransaction();
2109 +
2110 + try {
2111 + foreach ($allActiveTasks as $task) {
2112 + if ($task->parent_id && empty($taskMap[$task->parent_id])) {
2113 + continue;
743 2114 }
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 + }
744 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;
745 2197 }
746 -
747 - $board = Board::findOrFail($newBoard->id);
748 - $settings = [];
749 - $settings['tasks_count'] = $parentTaskCount;
750 - $board->settings = $settings;
751 - $board->save();
752 2198 }
753 2199
754 2200 private function subtaskCountUpdate($taskId){
755 2201 $parentTask = Task::findOrFail($taskId);
@@ -761,21 +2207,42 @@
761 2207
762 2208 /**
763 2209 * @param $taskId
764 2210 * @param $perPage
765 - * @param $offset
2211 + * @param $page
766 2212 * @param string $filter
2213 + * @param $boardId
2214 + * @param string $feedType
767 2215 * @return array
768 2216 */
769 - public function getCommentsAndActivities($taskId, $perPage, $page, string $filter = 'newest'): array
2217 + public function getCommentsAndActivities($taskId, $perPage, $page, string $filter = 'newest', $boardId = null, string $feedType = 'all'): array
770 2218 {
771 2219 // Fetch the task
772 - $task = Task::findOrFail($taskId);
2220 + $task = $boardId ? $this->findTaskOnBoard($taskId, $boardId) : Task::findOrFail($taskId);
2221 + $feedType = in_array($feedType, ['all', 'comments', 'activities'], true) ? $feedType : 'all';
773 2222
774 2223 // Fetch comments and activities separately
775 - $comments = $task->comments()->with('user')->orderBy('created_at', 'desc')->get()->toArray();
776 - $activities = $task->activities()->with('user')->orderBy('created_at', 'desc')->get()->toArray();
2224 + $comments = [];
2225 + if ($feedType !== 'activities') {
2226 + $comments = $task->comments()->with('user')->orderBy('created_at', 'desc')->get()->toArray();
2227 + }
777 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 +
778 2245 // Merge comments and activities into a single array
779 2246 $commentsAndActivities = array_merge($comments, $activities);
780 2247
781 2248 // Sort the merged array by created_at date in ascending or descending order
@@ -869,9 +2336,9 @@
869 2336 * @param $title
870 2337 * @param $UrlMeta
871 2338 * @return mixed|string
872 2339 */
873 - private function setTitle($type, $title, $UrlMeta)
2340 + public function setTitle($type, $title, $UrlMeta)
874 2341 {
875 2342 if($type != 'url') {
876 2343 return sanitize_file_name($title);
877 2344 }
@@ -877,6 +2344,1046 @@
877 2344 }
878 2345 return $title ?? $UrlMeta['title'] ?? '';
879 2346 }
880 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 + }
881 3388
882 3389 }