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