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