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.1 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 All 44 releases
← All changes | app/Services/TaskService.php +2919 -244 1.40 → 2.2.0 View file →
@@ -1,42 +1,180 @@
1 1 <?php
2 2
3 3 namespace FluentBoards\App\Services;
4 4
5 +use FluentBoards\Framework\Database\Orm\ModelNotFoundException;
6 +use FluentBoards\App\App;
7 +use FluentBoards\App\Hooks\Handlers\FileHandler;
5 8 use FluentBoards\App\Models\Attachment;
6 9 use FluentBoards\App\Models\Comment;
10 +use FluentBoards\App\Models\Notification;
7 11 use FluentBoards\App\Models\NotificationUser;
8 12 use FluentBoards\App\Models\TaskImage;
9 13 use FluentBoards\App\Services\Constant;
14 +use FluentBoards\App\Models\Label;
10 15 use FluentBoards\App\Models\Stage;
11 16 use FluentBoards\App\Models\Task;
12 17 use FluentBoards\App\Models\Board;
13 18 use FluentBoards\App\Models\TaskMeta;
19 +use FluentBoards\App\Models\Meta;
14 20 use FluentBoards\App\Models\Activity;
15 -use FluentBoards\App\Models\BoardTerm;
21 +use FluentBoards\App\Models\CommentImage;
22 +use FluentBoards\App\Models\Relation;
16 23 use FluentBoards\Framework\Support\Arr;
24 +use FluentBoardsPro\App\Models\TaskAttachment;
25 +use FluentBoardsPro\App\Modules\TimeTracking\Model\TimeTrack;
17 26 use FluentBoardsPro\App\Modules\TimeTracking\TimeTrackingHelper;
18 -use FluentBoardsPro\App\Services\ProTaskService;
27 +use FluentBoardsPro\App\Services\AttachmentService;
19 28 use FluentBoardsPro\App\Services\RemoteUrlParser;
20 29 use FluentRoadmap\App\Models\IdeaReaction;
21 30
22 31 class TaskService
23 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 + */
24 149 public function createTask($data, $boardId)
25 150 {
26 151 $board = Board::select('id', 'type')->find($boardId);
27 152
28 153 if (!$board) {
29 - throw new \Exception(__("Board doesn't exists", 'fluent-boards'));
154 + throw new \Exception(esc_html__("Board doesn't exists", 'fluent-boards'));
30 155 }
31 156
32 157 $stage = Stage::find($data['stage_id']);
33 158 if (!$stage) {
34 - throw new \Exception(__("Stage doesn't exists", 'fluent-boards'));
159 + throw new \Exception(esc_html__("Stage doesn't exists", 'fluent-boards'));
35 160 }
36 161
162 + if ((int) $stage->board_id !== (int) $boardId) {
163 + throw new \Exception(esc_html__("Stage doesn't exists", 'fluent-boards'));
164 + }
165 +
37 166 $data['status'] = $stage->defaultTaskStatus();
38 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 +
39 177 if ($board->type == 'roadmap') {
40 178 $current_user = wp_get_current_user();
41 179 $settingData = array(
42 180 'integration_type' => 'feature',
@@ -44,10 +182,14 @@
44 182 'author' => [
45 183 'email' => $current_user->user_email // email of who posted this feature
46 184 ],
47 185 );
48 - $data['settings'] = $settingData;
186 + $data['settings'] = array_merge($taskSettings, $settingData);
49 187 $data['type'] = 'roadmap';
188 + } elseif ($taskSettings) {
189 + $data['settings'] = $taskSettings;
190 + } else {
191 + unset($data['settings']);
50 192 }
51 193
52 194 $providerPosition = Arr::get($data, 'position');
53 195
@@ -53,13 +195,28 @@
53 195
54 196 $data['position'] = $this->getLastPositionOfTasks($stage->id);
55 197
56 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 + }
57 203
58 204 $data = array_filter($data);
59 205 $task = (new Task())->createTask($data);
60 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 +
61 217 $this->manageDefaultAssignees($task, $stage->id);
218 + $this->manageDefaultWatchers($task, $stage->id);
62 219
63 220 if (isset($data['is_template']) && $data['is_template'] == 'yes') {
64 221 $task->updateMeta(Constant::IS_TASK_TEMPLATE, $data['is_template']);
65 222 }
@@ -86,21 +243,38 @@
86 243 }
87 244
88 245 public function getTasksForBoards($filters = ['overdue', 'upcoming'], $limit = 5, $task_ids = [])
89 246 {
247 + $assigned = $this->getTasksForBoardsByCategory('assigned', $limit, $task_ids);
90 248 $overDue = $this->getTasksForBoardsByCategory('overdue', $limit, $task_ids);
249 + $dueToday = $this->getTasksForBoardsByCategory('due_today', $limit, $task_ids);
91 250 $completed = $this->getTasksForBoardsByCategory('completed', $limit, $task_ids);
251 + $mentioned = $this->getTasksForBoardsByCategory('mentioned', $limit, $task_ids);
92 252 $upcoming = $this->getTasksForBoardsByCategory('upcoming', $limit, $task_ids);
93 253 $others = $this->getTasksForBoardsByCategory('others', $limit, $task_ids);
94 254
95 255 return [
256 + 'assigned' => $assigned ?? [],
96 257 'overdue' => $overDue ?? [],
258 + 'due_today' => $dueToday ?? [],
97 259 'upcoming' => $upcoming ?? [],
260 + 'mentioned' => $mentioned ?? [],
98 261 'completed' => $completed ?? [],
99 262 'others' => $others ?? []
100 263 ];
101 264 }
102 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 +
103 277 public function getTasksForBoardsByCategory($category, $limit, $taskIds)
104 278 {
105 279 unset($taskQuery);
106 280 $taskQuery = Task::whereIn('id', $taskIds)
@@ -106,20 +280,67 @@
106 280 $taskQuery = Task::whereIn('id', $taskIds)
107 281 ->with(['assignees', 'board', 'stage'])
108 282 ->whereNull('archived_at')
109 283 ->where('parent_id', null)
110 - ->orderBy('due_at', 'ASC');
284 + ->onActiveAvailableBoards()
285 + ->orderBy('due_at', 'DESC');
111 286
112 - if ('overdue' == $category) {
113 - $taskQuery->overdue();
114 - } elseif ('upcoming' == $category) {
115 - $taskQuery->upcoming();
116 - } elseif ('others' == $category) {
117 - $taskQuery->whereNull('due_at');
118 - } elseif ('completed' == $category) {
119 - $taskQuery->where('status', 'closed');
120 - } else {
121 - return [];
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 [];
122 343 }
123 344
124 345 $tasks = $taskQuery->take($limit)->get();
125 346
@@ -125,8 +346,108 @@
125 346
126 347 return $tasks->toArray();
127 348 }
128 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 +
129 450 /*
130 451 * TODO: Refactor this function - For me.
131 452 */
132 453 public function updateTaskProperty($col, $value, $task)
@@ -134,76 +455,135 @@
134 455 $oldTask = clone $task; // normal assigning won't work here. because objects are passed by reference in php
135 456 $validColumns = [
136 457 'board_id',
137 458 'type',
138 - 'reminder_type',
459 +// 'reminder_type',
139 460 'remind_at',
140 461 'log_minutes',
462 + 'source',
463 + 'source_id',
141 464 'settings'
142 465 ];
143 466
467 + if ($col === 'description') {
468 + $value = DescriptionMarkdownConverter::normalize($value);
469 + }
470 +
144 471 if (in_array($col, $validColumns) && $task->{$col} != $value) {
145 - if($col == 'settings' && $value['cover']['backgroundColor']) {
472 + if ($col === 'remind_at') {
473 + $value = Helper::normalizeDateValue($value);
474 + }
475 +
476 + if ($col == 'settings' && isset($value['cover']['backgroundColor']) && $value['cover']['backgroundColor']) {
146 477 $settings = $task->settings;
147 478 $this->deleteTaskCoverImage($settings);
148 479 unset($value['cover']['imageId']);
149 480 unset($value['cover']['backgroundImage']);
150 481 }
151 - $task->{$col} = $value;
482 + $task->{$col} = $value ?: null;
152 483 $task->save();
153 484 // do_action('fluent_boards/task_prop_changed', $col, $task, $oldTask);
154 - } elseif ('assignees' == $col) {
155 - if (is_array($value)) {
156 - foreach ($value as $id) {
157 - $this->updateAssignee($id, $task);
158 - }
159 - } else {
160 - $this->updateAssignee($value, $task);
161 - }
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;
162 496
163 - } elseif ('crm_contact_id' == $col) {
164 - $this->updateAssociate($value, $task);
165 - } elseif ('archived_at' == $col) {
166 - $this->updateArchive($value, $task);
167 - } elseif ('status' == $col) {
168 - $this->updateStatus($value, $task);
169 - } elseif ('parent_id' == $col) {
170 - $this->updateParent($value, $task);
171 - } elseif ('title' == $col) {
172 - $this->updateTitle($col, $value, $task, $oldTask);
173 - } elseif ('description' == $col) {
174 - $this->updateDescription($col, $value, $task, $oldTask);
175 - } elseif ($col == 'due_at') {
176 - $this->updateDueDate($value, $task);
177 - } elseif ($col == 'started_at') {
178 - $this->updateStartedDate($value, $task);
179 - } elseif ($col == 'priority') {
180 - $this->updatePriority($value, $task);
181 - } elseif ($col == 'is_watching') {
182 - $this->updateObservationOfCurrentUser($value, $task);
183 - } elseif ($col == 'last_completed_at') {
184 - $isClosed = $value == 'true' || $value === true;
185 - if ($isClosed) {
186 - $task = $task->close();
187 - } else {
188 - $task = $task->reopen();
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;
189 585 }
190 - $task->save();
191 - } elseif ($col == 'attachment_count') {
192 - $settings = $task->settings;
193 - $settings['attachment_count'] = $task->attachments()->count();
194 - $task->settings = $settings;
195 - $task->save();
196 - } elseif ($col == 'subtask_count') {
197 - $settings = $task->settings;
198 - $subtasksCount = Task::where('parent_id', $task->id)->count();
199 - $settings['subtask_count'] = $subtasksCount;
200 - $task->settings = $settings;
201 - $task->save();
202 - } elseif ($col == 'is_template') {
203 - if (defined('FLUENT_BOARDS_PRO')) {
204 - $task->updateMeta(Constant::IS_TASK_TEMPLATE, $value);
205 - }
206 586 }
207 587
208 588 return $task;
209 589 }
@@ -216,9 +596,9 @@
216 596
217 597 $task->save();
218 598
219 599 if ($operation == 'added') {
220 - 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()) {
221 601 $this->sendMailAfterTaskModify('add_assignee', $payloadAssigneeId, $task->id);
222 602 }
223 603 // $assigneeIdsToSendEmail = $this->filterAssigneeToSendEmail($task, $idArray, Constant::BOARD_EMAIL_TASK_ASSIGN);
224 604 // $this->sendMailAfterAddAssignees($assigneeIdsToSendEmail, $task->id);
@@ -226,9 +606,9 @@
226 606 if($payloadAssigneeId != get_current_user_id()){
227 607 do_action('fluent_boards/assign_another_user', $task, $payloadAssigneeId);
228 608 }
229 609 } else {
230 - 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()) {
231 611 $this->sendMailAfterTaskModify('remove_assignee', $payloadAssigneeId, $task->id);
232 612 }
233 613 do_action('fluent_boards/task_assignee_removed', $task, $payloadAssigneeId);
234 614 }
@@ -284,17 +664,36 @@
284 664
285 665 private function updateArchive($value, $task)
286 666 {
287 667 if ($value != null) {
668 + // Archiving task
288 669 $task->position = 0;
289 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 +
290 684 $task->moveToNewPosition(1);
685 +
686 + // Clean up archived_by_stage meta when task is manually restored
687 + $this->cleanupArchivedByStageMetaIfExists($task->id);
291 688 }
292 689 $task->archived_at = $value == null ? null : current_time('mysql');
293 690 $task->save();
294 691 do_action('fluent_boards/task_archived', $task);
295 - $wathersToSendEmail = (new NotificationService())->filterAssigneeToSendEmail($task->id, Constant::BOARD_EMAIL_TASK_ARCHIVE);
296 - $this->sendMailAfterTaskModify('task_archived', $wathersToSendEmail, $task->id);
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 + }
297 696 }
298 697
299 698 private function updateStatus($value, $task)
300 699 {
@@ -321,9 +720,9 @@
321 720 }
322 721
323 722 private function updateDescription($col, $value, $task, $oldTask)
324 723 {
325 - $task->description = $value;
724 + $task->description = DescriptionMarkdownConverter::normalize($value);
326 725 $task->save();
327 726 do_action('fluent_boards/task_content_updated', $task, $col, $oldTask);
328 727 }
329 728
@@ -329,15 +728,19 @@
329 728
330 729 private function updateDueDate($value, $task)
331 730 {
332 731 $oldValue = $task->due_at;
333 - $value = $this->filterNullDate($value);
732 + $value = Helper::normalizeDateValue($value);
334 733 $task->due_at = $value;
335 734 $task->save();
336 735
337 736 $task = $task->reopen();
338 737
339 - do_action('fluent_boards/task_due_date_changed', $task, $oldValue);
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 + }
340 743
341 744 $wathersToSendEmail = (new NotificationService())->filterAssigneeToSendEmail($task->id, Constant::BOARD_EMAIL_DUE_DATE_CHANGE);
342 745 $this->sendMailAfterTaskModify('due_date_update', $wathersToSendEmail, $task->id);
343 746 }
@@ -344,13 +747,15 @@
344 747
345 748 private function updateStartedDate($value, $task)
346 749 {
347 750 $oldValue = $task->started_at;
348 - $value = $this->filterNullDate($value);
751 + $value = Helper::normalizeDateValue($value);
349 752 $task->started_at = $value;
350 753 $task->save();
351 754
352 - do_action('fluent_boards/task_start_date_changed', $task, $oldValue);
755 + if($value){
756 + do_action('fluent_boards/task_start_date_changed', $task, $oldValue);
757 + }
353 758 }
354 759
355 760 private function updatePriority($value, $task)
356 761 {
@@ -359,30 +764,34 @@
359 764 $task->save();
360 765 do_action('fluent_boards/task_priority_changed', $task, $oldPriority);
361 766 }
362 767
363 - public function updateObservationOfCurrentUser($value, $task)
364 - {
365 - $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 + }
366 777
367 - if ($value == 'stop') {
368 - $task->watchers()->detach($currentUserId);
369 - } else {
370 - $task->watchers()->syncWithoutDetaching([$currentUserId => ['object_type' => Constant::OBJECT_TYPE_USER_TASK_WATCH]]);
371 - }
372 - $task->updated_at = current_time('mysql');
373 - $task->save();
778 + if (!$userId || !in_array($action, ['stop', 'start'])) {
779 + return;
780 + }
374 781
375 - if ($value == 'stop') {
376 - $task->is_watching = false;
377 - } else {
378 - $task->is_watching = true;
379 - }
782 + if ($action == 'stop') {
783 + $task->watchers()->detach($userId);
784 + } else {
785 + $task->watchers()->syncWithoutDetaching([$userId => ['object_type' => Constant::OBJECT_TYPE_USER_TASK_WATCH]]);
380 786 }
787 + $task->updated_at = current_time('mysql');
788 + $task->save();
789 +}
381 790
382 - public function taskCoverPhotoUpdate($taskId, $imagePath)
791 + public function taskCoverPhotoUpdate($taskId, $imagePath, $boardId = null)
383 792 {
384 - $task = Task::find($taskId);
793 + $task = $boardId ? $this->findTaskOnBoard($taskId, $boardId) : Task::find($taskId);
385 794 if (!$task) {
386 795 return null;
387 796 }
388 797
@@ -397,11 +806,11 @@
397 806
398 807 return $task;
399 808 }
400 809
401 - public function taskStatusUpdate($taskId, $integrationType)
810 + public function taskStatusUpdate($taskId, $integrationType, $boardId = null)
402 811 {
403 - $task = Task::find($taskId);
812 + $task = $boardId ? $this->findTaskOnBoard($taskId, $boardId) : Task::find($taskId);
404 813 if (!$task) {
405 814 return null;
406 815 }
407 816
@@ -414,9 +823,9 @@
414 823 }
415 824
416 825 public function assignYourselfInTask($boardId, $taskId)
417 826 {
418 - $task = Task::find($taskId);
827 + $task = $this->findTaskOnBoard($taskId, $boardId);
419 828 $authUserId = get_current_user_id();
420 829
421 830 $boardService = new BoardService();
422 831 if (!$boardService->isAlreadyMember($boardId, $authUserId)) {
@@ -423,9 +832,8 @@
423 832 $boardService->addMembersInBoard($boardId, $authUserId);
424 833 }
425 834
426 835 $task->addOrRemoveAssignee($authUserId);
427 - do_action('fluent_boards/task_assignee_added', $task, $authUserId);
428 836 // when user assign himself then he will be watching that task
429 837 $task->watchers()->syncWithoutDetaching([$authUserId => ['object_type' => Constant::OBJECT_TYPE_USER_TASK_WATCH]]);
430 838
431 839 $task->load('assignees');
@@ -435,9 +843,9 @@
435 843 }
436 844
437 845 public function detachYourselfFromTask($boardId, $taskId)
438 846 {
439 - $task = Task::find($taskId);
847 + $task = $this->findTaskOnBoard($taskId, $boardId);
440 848 $currentUserId = get_current_user_id();
441 849 $task->addOrRemoveAssignee($currentUserId);
442 850 $task->load('assignees');
443 851 do_action('fluent_boards/task_assignee_removed', $task, $currentUserId);
@@ -446,40 +854,216 @@
446 854 }
447 855
448 856 public function deleteTask($task)
449 857 {
450 - $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 + }
451 866
452 - if ($deleted) {
867 + $this->deleteTasksBatch([$task]);
868 + }
453 869
454 - //task assignees watchers removed
455 - $task->watchers()->detach();
456 - $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 + }
457 883
458 - //removing all task related notifications
459 - $notificationIds = $task->notifications->pluck('id');
460 - $task->notifications()->delete();
884 + $deletedTasks = [];
885 + $taskBoardIds = [];
886 + foreach ($tasks as $task) {
887 + if (!$task instanceof Task) {
888 + continue;
889 + }
890 +
891 + $taskId = (int) $task->id;
892 + if ($taskId < 1) {
893 + continue;
894 + }
895 +
896 + $deletedTasks[$taskId] = clone $task;
897 + $taskBoardIds[$taskId] = (int) $task->board_id;
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();
461 939 NotificationUser::whereIn('notification_id', $notificationIds)->delete();
940 + Notification::whereIn('task_id', $taskIds)->delete();
462 941
463 - //task labels removed
464 - $task->labels()->detach();
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();
465 952
466 - //task custom field value
467 - $task->customFields()->detach();
953 + $deletedCount = Task::whereIn('id', $taskIds)->delete();
954 + if ($deletedCount !== count($taskIds)) {
955 + throw new \RuntimeException(__('Task could not be deleted.', 'fluent-boards'));
956 + }
468 957
469 - do_action('fluent_boards/task_deleted', $task);
470 - TaskMeta::where('task_id', $task->id)->delete();
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;
471 969 }
472 970 }
473 971
474 - public function filterNullDate($date)
972 + /** Remove comment rows transactionally; file cleanup must wait for commit. */
973 + private function deleteTaskCommentsBatch($taskIds, $taskBoardIds, $dbInstance)
475 974 {
476 - if ('0000-00-00 00:00:00' == $date || false === strtotime($date)) {
477 - return null;
975 + $commentTasks = Comment::whereIn('task_id', $taskIds)->pluck('task_id', 'id')->toArray();
976 + if (!$commentTasks) {
977 + return;
478 978 }
479 - 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 + });
480 1030 }
481 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 + }
482 1066 // this is invoked when task is moved to another board
483 1067
484 1068 /**
485 1069 * @throws \Exception
@@ -485,54 +1069,383 @@
485 1069 * @throws \Exception
486 1070 */
487 1071 public function changeBoardByTask($task, $targetBoardId)
488 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 +
489 1079 if ($task->board_id == $targetBoardId) {
490 1080 return $task;
491 1081 }
492 1082
493 1083 $oldBoard = Board::find($task->board_id);
1084 + $newBoard = Board::find($targetBoardId);
494 1085
495 - $newBoard = Board::find($targetBoardId);
1086 + if (!$oldBoard) {
1087 + throw new \Exception(esc_html__('Source board not found', 'fluent-boards'), 404);
1088 + }
1089 +
496 1090 if (!$newBoard) {
497 - throw new \Exception('Invalid board id', 400);
1091 + throw new \Exception(esc_html__('Target board not found', 'fluent-boards'), 404);
498 1092 }
499 - $task->board_id = $targetBoardId;
500 - $task->save();
501 - //delete labels of that task because labels have board dependencies
502 - $task->labels()->detach();
503 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 +
504 1131 do_action('fluent_boards/task_moved_from_board', $task, $oldBoard, $newBoard);
1132 + return $task;
1133 + }
505 1134
506 - 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 + }
507 1174 }
508 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 + }
509 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 +
510 1291 public function getIdeaVoteStatistics($taskId)
511 1292 {
512 - return IdeaReaction::where('object_id', $taskId)
513 - ->where('object_type', 'idea')
514 - ->where('type', 'upvote')
515 - ->count();
1293 + $taskId = absint($taskId);
1294 + $voteStatistics = $this->getIdeaVoteStatisticsByTaskIds([$taskId]);
1295 +
1296 + return $voteStatistics[$taskId] ?? 0;
516 1297 }
517 1298
1299 + public function loadIdeaVoteStatistics($tasks)
1300 + {
1301 + $taskIds = [];
518 1302
1303 + foreach ($tasks as $task) {
1304 + $taskId = absint($task->id);
1305 + if ($taskId) {
1306 + $taskIds[] = $taskId;
1307 + }
1308 + }
1309 +
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;
1317 + }
1318 +
1319 + private function getIdeaVoteStatisticsByTaskIds(array $taskIds)
1320 + {
1321 + global $wpdb;
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 +
519 1363 /**
520 - * Summary of getArchivedOrCompletedTasks
521 - * this function will return completd tasks or archived tasks based on users input and also can search by name
522 - * @param mixed $data
523 - * @param mixed $taskType
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
524 1432 * @return mixed
525 1433 * @throws \Exception
526 1434 */
527 1435 public function getArchivedTasks($data, $boardId)
528 1436 {
529 - $per_page = isset($data['per_page']) ? $data['per_page'] : 25;
530 - $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));
531 1444 $tasksQuery = Task::where('board_id', $boardId)->whereNotNull('archived_at');
532 1445
533 - if (isset($data['searchInput'])) {
534 - $query = strtolower($data['searchInput']);
1446 + if (!empty($data['query'])) {
1447 + $query = strtolower($data['query']);
535 1448 $firstThreeChars = substr($query, 0, 3);
536 1449
537 1450 if($firstThreeChars == 'id:') {
538 1451 $idPart = substr($query, 3);
@@ -538,22 +1451,560 @@
538 1451 $idPart = substr($query, 3);
539 1452 $idPart = preg_replace('/[^a-zA-Z0-9]/', '', $idPart);
540 1453 $tasksQuery = $tasksQuery->where('id', 'LIKE', '%' . $idPart . '%');
541 1454 } else {
542 - $tasksQuery = $tasksQuery->where('title', 'LIKE', '%' . $data['searchInput'] . '%');
1455 + $tasksQuery = $tasksQuery->where('title', 'LIKE', '%' . $data['query'] . '%');
543 1456 }
544 1457 }
545 1458
546 - // if board_id is not passed then throw an exception
547 - if (!$boardId) {
548 - 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;
549 1466 }
550 1467
551 - 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;
552 1502 }
553 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 +
554 2001 public function sendMailAfterTaskModify($column, $assigneeIds, $taskId)
555 2002 {
2003 + if (empty($assigneeIds)) {
2004 + return;
2005 + }
2006 +
556 2007 $current_user_id = get_current_user_id();
557 2008 /* this will run in background as soon as possible */
558 2009 /* sending Model or Model Instance won't work here */
559 2010
@@ -562,14 +2013,17 @@
562 2013
563 2014 public function getStageByTask($task_id)
564 2015 {
565 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 + }
566 2020 return $task->stage;
567 2021 }
568 2022
569 - public function moveTaskToNextStage($task_id)
2023 + public function moveTaskToNextStage($task_id, $boardId = null)
570 2024 {
571 - $task = Task::findOrFail($task_id);
2025 + $task = $boardId ? $this->findTaskOnBoard($task_id, $boardId) : Task::findOrFail($task_id);
572 2026
573 2027 $oldStage = $task->stage;
574 2028
575 2029 $nextStage = Stage::where('board_id', $task->board_id)
@@ -587,8 +2041,11 @@
587 2041 $task->last_completed_at = current_time('mysql');
588 2042 }
589 2043 }
590 2044
2045 + // Clean up archived_by_stage meta when moving to different stage
2046 + $this->cleanupArchivedByStageMetaIfExists($task->id);
2047 +
591 2048 $task->stage_id = $nextStage->id;
592 2049 $task->save();
593 2050
594 2051 $task->load(['board', 'stage', 'attachments']);
@@ -611,8 +2068,9 @@
611 2068 }
612 2069
613 2070 public function getActivities($taskId, $perPage, $filter = 'newest')
614 2071 {
2072 + $perPage = Sanitize::perPage($perPage);
615 2073 $activityQuery = Activity::where('object_id', $taskId)
616 2074 ->where('object_type', Constant::ACTIVITY_TASK);
617 2075 if ($filter == 'newest') {
618 2076 $activityQuery = $activityQuery->latest();
@@ -618,27 +2076,36 @@
618 2076 $activityQuery = $activityQuery->latest();
619 2077 } else if ($filter == 'oldest') {
620 2078 $activityQuery = $activityQuery->oldest();
621 2079 }
622 - return $activityQuery->with('user')->paginate($perPage);
2080 + $activities = $activityQuery->with('user')->paginate($perPage);
2081 +
2082 + Helper::translateActivities($activities);
2083 +
2084 + return $activities;
623 2085 }
624 2086
625 - public function getLastOneMinuteUpdatedTasks($boardId, $lastUpdated = null)
2087 + public function getLastOneMinuteUpdatedTasks($boardId, $lastUpdated = null, $includeArchived = true)
626 2088 {
627 2089 if (!$lastUpdated) {
628 - $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
629 2091 }
630 2092
631 - $tasks = Task::query()
2093 + $tasksQuery = Task::query()
632 2094 ->where([
633 2095 'board_id' => $boardId,
634 2096 'parent_id' => null,
635 2097 ])
636 - ->where('updated_at', '>', $lastUpdated)
2098 + ->where('updated_at', '>=', $lastUpdated) // updated since the sync cursor
637 2099 ->with(['assignees', 'labels', 'watchers', 'taskCustomFields'])
638 - ->orderBy('due_at', 'ASC')
639 - ->get();
2100 + ->orderBy('due_at', 'ASC');
640 2101
2102 + if (!$includeArchived) {
2103 + $tasksQuery->whereNull('archived_at');
2104 + }
2105 +
2106 + $tasks = $tasksQuery->get();
2107 +
641 2108 foreach ($tasks as $task) {
642 2109 $task->isOverdue = $task->isOverdue();
643 2110 $task->isUpcoming = $task->upcoming();
644 2111 $task->is_watching = $task->isWatching();
@@ -661,88 +2128,239 @@
661 2128
662 2129 return $lastPosition + 1;
663 2130 }
664 2131
665 - 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)
666 2140 {
667 - $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()
668 2183 ->where('crm_contact_id', $associatedId)
669 - ->with(['board', 'stage', 'assignees', 'labels', 'watchers',])
670 - ->orderBy('due_at', 'ASC')
671 - ->get();
2184 + ->with(['board', 'stage', 'assignees', 'subtaskGroup', 'subtaskGroup.subtasks', 'subtaskGroup.subtasks.assignees'])
2185 + ->orderBy('due_at', 'ASC');
672 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 +
673 2203 foreach ($tasks as $task) {
674 2204 $task->isOverdue = $task->isOverdue();
675 2205 $task->isUpcoming = $task->upcoming();
676 - $task->contact = Task::lead_contact($task->crm_contact_id);
677 - $task->is_watching = $task->isWatching();
2206 + $task->can_edit = $isAdmin || in_array((int)$task->board_id, $editableBoardIds, true);
678 2207
679 2208 $task->assignees = Helper::sanitizeUserCollections($task->assignees);
680 - $task->watchers = Helper::sanitizeUserCollections($task->watchers);
681 2209
682 - if(!!defined('FLUENT_BOARDS_PRO_VERSION')) {
683 - $task->time_tracks= [
684 - 'tracks' => (new ProTaskService())->getTaskTimeTrack($task->board_id, $task->id),
685 - 'estimated_minutes' => TimeTrackingHelper::getTaskEstimation($task->id)
686 - ];
2210 + foreach ($task->subtaskGroup as $group) {
2211 + foreach ($group->subtasks as $subtask) {
2212 + $subtask->assignees = Helper::sanitizeUserCollections($subtask->assignees);
2213 + }
687 2214 }
2215 + $task->subtask_group = $task->subtaskGroup;
2216 + }
688 2217
2218 + return $tasks;
2219 + }
689 2220
690 - $subTasks = Task::query()
691 - ->where('parent_id', $task->id)
692 - ->with(['assignees'])
693 - ->whereNull('archived_at')
694 - ->orderBy('position', 'ASC')
695 - ->get();
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 + ]);
696 2230
697 - foreach ($subTasks as $subTask) {
698 - $subTask->assignees = Helper::sanitizeUserCollections($subTask->assignees);
699 - }
700 -
701 - $task->subtasks = $subTasks;
2231 + $subtaskGroupMap[$group->id] = $newGroup->id;
702 2232 }
703 2233
704 - return $tasks;
2234 + return $subtaskGroupMap;
705 2235 }
706 2236
707 - public function copyTasks($boardId, $stageMap, $newBoard, $labelMap = [])
2237 + public function copyTasks($boardId, $stageMap, $newBoard, $labelMap = [], $isWithTemplates = 'no', $customFieldMap = [])
708 2238 {
709 - $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();
710 2243 $taskMap = [];
2244 + $subtaskGroupMap = [];
711 2245 $parentTaskCount = 0;
712 - foreach ($allActiveTasks as $task) {
713 - $newTask = array();
714 - $newTask['title'] = $task->title;
715 - $newTask['parent_id'] = $task->parent_id ? $taskMap[$task->parent_id] : null;
716 - $newTask['description'] = $task->description;
717 - $newTask['board_id'] = $newBoard->id;
718 - $newTask['stage_id'] = $stageMap[$task->stage_id];
719 - $newTask['status'] = $task->status;
720 - $newTask['priority'] = $task->priority;
721 - $newTask['position'] = $task->position;
722 - $newTask['due_at'] = $task->due_at;
723 - $newTask = Task::create($newTask);
724 - if(!$task->parent_id){
725 - ++$parentTaskCount;
726 - $taskMap[$task['id']] = $newTask->id;
727 - //duplicate labels to task
728 - $labelIds = $task->labels->pluck('id')->toArray();
729 - if($labelIds){
730 - $flipLabelIds = array_flip($labelIds);
731 - $labelsToAttach = array_intersect_key($labelMap, $flipLabelIds);
2246 + $attachmentFileService = new AttachmentFileService();
2247 + $dbInstance = App::getInstance('db');
732 2248
733 - $newTask->labels()->attach($labelsToAttach, [
734 - 'object_type' => Constant::OBJECT_TYPE_TASK_LABEL
735 - ]);
2249 + $dbInstance->beginTransaction();
2250 +
2251 + try {
2252 + foreach ($allActiveTasks as $task) {
2253 + if ($task->parent_id && empty($taskMap[$task->parent_id])) {
2254 + continue;
736 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 + }
737 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;
738 2339 }
2340 + }
739 2341
740 - $board = Board::findOrFail($newBoard->id);
741 - $settings = [];
742 - $settings['tasks_count'] = $parentTaskCount;
743 - $board->settings = $settings;
744 - $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 + }
745 2363 }
746 2364
747 2365 private function subtaskCountUpdate($taskId){
748 2366 $parentTask = Task::findOrFail($taskId);
@@ -754,80 +2372,110 @@
754 2372
755 2373 /**
756 2374 * @param $taskId
757 2375 * @param $perPage
758 - * @param $offset
2376 + * @param $page
759 2377 * @param string $filter
2378 + * @param $boardId
2379 + * @param string $feedType
760 2380 * @return array
761 2381 */
762 - public function getCommentsAndActivities($taskId, $perPage, $page, string $filter = 'newest'): array
2382 + public function getCommentsAndActivities($taskId, $perPage, $page, string $filter = 'newest', $boardId = null, string $feedType = 'all'): array
763 2383 {
2384 + $perPage = Sanitize::perPage($perPage);
2385 + $page = max(1, (int) $page);
764 2386 // Fetch the task
765 - $task = Task::findOrFail($taskId);
2387 + $task = $boardId ? $this->findTaskOnBoard($taskId, $boardId) : Task::findOrFail($taskId);
2388 + $feedType = in_array($feedType, ['all', 'comments', 'activities'], true) ? $feedType : 'all';
766 2389
767 - // Fetch comments and activities separately
768 - $comments = $task->comments()->with('user')->orderBy('created_at', 'desc')->get()->toArray();
769 - $activities = $task->activities()
770 - ->with('user')
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()
771 2397 ->where(function($query) {
772 2398 $query->whereNotIn('column', [ 'comment', 'a reply'])
773 2399 ->orWhere(function($subQuery) {
774 2400 $subQuery->whereNotIn('action', ['added', 'updated']);
775 2401 });
776 - })
777 - ->orderBy('created_at', 'desc')
778 - ->get()
779 - ->toArray();
2402 + });
780 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 + }
781 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);
782 2425
783 - // Merge comments and activities into a single array
784 - $commentsAndActivities = array_merge($comments, $activities);
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 + }
785 2438
786 - // Sort the merged array by created_at date in ascending or descending order
787 - $order = $filter == 'newest' ? -1 : 1;
788 - usort($commentsAndActivities, function ($a, $b) use ($order) {
789 - return $order * (strtotime($a['created_at']) - strtotime($b['created_at']));
790 - });
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 + }
791 2445
792 - // Paginate the results
793 - $offset = ($page - 1) * $perPage; // Calculate the offset for slicing the array
794 - $paginatedResults = array_slice($commentsAndActivities, $offset, $perPage);
795 -
796 - // Get the total count of comments and activities
797 - $total = count($commentsAndActivities);
798 2446 $lastPage = (int) ceil($total / $perPage);
799 2447
800 2448 // Construct pagination metadata
801 - $path = "https://wordpress.test/wp-json/fluent-boards/v2/projects/{$task->board_id}/tasks/{$task->id}/comments-and-activities";
2449 + $path = rest_url("fluent-boards/v2/projects/{$task->board_id}/tasks/{$task->id}/comments-and-activities");
802 2450 return [
803 2451 'current_page' => (int) $page,
804 2452 'data' => $paginatedResults,
805 - 'first_page_url' => "{$path}?page=1",
2453 + 'first_page_url' => add_query_arg('page', 1, $path),
806 2454 'from' => $total > 0 ? (int) ($offset + 1) : null,
807 2455 'last_page' => (int) $lastPage,
808 - 'last_page_url' => "{$path}?page={$lastPage}",
2456 + 'last_page_url' => add_query_arg('page', $lastPage, $path),
809 2457 'links' => [
810 2458 [
811 - 'url' => $page > 1 ? "{$path}?page=" . ($page - 1) : null,
2459 + 'url' => $page > 1 ? add_query_arg('page', ($page - 1), $path) : null,
812 2460 'label' => 'pagination.previous',
813 2461 'active' => false
814 2462 ],
815 2463 [
816 - 'url' => "{$path}?page={$page}",
2464 + 'url' => add_query_arg('page', $page, $path),
817 2465 'label' => (int) $page,
818 2466 'active' => true
819 2467 ],
820 2468 [
821 - 'url' => $page < $lastPage ? "{$path}?page=" . ($page + 1) : null,
2469 + 'url' => $page < $lastPage ? add_query_arg('page', ($page + 1), $path) : null,
822 2470 'label' => 'pagination.next',
823 2471 'active' => false
824 2472 ]
825 2473 ],
826 - 'next_page_url' => $page < $lastPage ? "{$path}?page=" . ($page + 1) : null,
2474 + 'next_page_url' => $page < $lastPage ? add_query_arg('page', ($page + 1), $path) : null,
827 2475 'path' => $path,
828 2476 'per_page' => (int) $perPage,
829 - 'prev_page_url' => $page > 1 ? "{$path}?page=" . ($page - 1) : null,
2477 + 'prev_page_url' => $page > 1 ? add_query_arg('page', ($page - 1), $path) : null,
830 2478 'to' => $total > 0 ? (int) min($offset + $perPage, $total) : null,
831 2479 'total' => (int) $total
832 2480 ];
833 2481 }
@@ -874,9 +2522,9 @@
874 2522 * @param $title
875 2523 * @param $UrlMeta
876 2524 * @return mixed|string
877 2525 */
878 - private function setTitle($type, $title, $UrlMeta)
2526 + public function setTitle($type, $title, $UrlMeta)
879 2527 {
880 2528 if($type != 'url') {
881 2529 return sanitize_file_name($title);
882 2530 }
@@ -882,11 +2530,15 @@
882 2530 }
883 2531 return $title ?? $UrlMeta['title'] ?? '';
884 2532 }
885 2533
886 - public function manageDefaultAssignees($task, $stageId)
2534 + /** Apply missing stage assignees and report whether any assignments were added. */
2535 + public function manageDefaultAssignees($task, $stageId, ?Stage $stage = null)
887 2536 {
888 - $stage = Stage::findOrFail($stageId);
2537 + if (!$stage || (int) $stage->id !== (int) $stageId) {
2538 + $stage = Stage::findOrFail($stageId);
2539 + }
2540 + $assignmentsChanged = false;
889 2541 if ($stage && isset($stage->settings['default_task_assignees'])) {
890 2542 $defaultAssignees = $stage->settings['default_task_assignees'];
891 2543 foreach ($defaultAssignees as $assigneeId) {
892 2544 $alreadyAssigneeIds = $task->assignees->pluck('ID')->toArray();
@@ -892,13 +2544,39 @@
892 2544 $alreadyAssigneeIds = $task->assignees->pluck('ID')->toArray();
893 2545 $IfAlreadyAssignee = in_array($assigneeId, $alreadyAssigneeIds);
894 2546 if (!$IfAlreadyAssignee) {
895 2547 $this->updateAssignee($assigneeId, $task);
2548 + $assignmentsChanged = true;
896 2549 }
897 2550 }
898 2551 }
2552 + return $assignmentsChanged;
899 2553 }
900 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 +
901 2579 public function setDefaultAssigneesToEveryTasks($stage)
902 2580 {
903 2581 $tasks = $stage->tasks->whereNull('archived_at');
904 2582 foreach ($tasks as $task) {
@@ -905,17 +2583,1014 @@
905 2583 $this->manageDefaultAssignees($task, $stage->id);
906 2584 }
907 2585 }
908 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 + }
909 2645 public function deleteTaskCoverImage($settings)
910 2646 {
911 2647 if (isset($settings['cover']['imageId']) && $settings['cover']['imageId']) {
912 2648 $image = TaskImage::find($settings['cover']['imageId']);
913 - $deletedImage = clone $image;
914 - $deletedImage->delete();
2649 + if ($image) {
2650 + $deletedImage = clone $image;
2651 + $deletedImage->delete();
915 2652
916 - do_action('fluent_boards/task_attachment_deleted', $deletedImage);
2653 + do_action('fluent_boards/task_attachment_deleted', $deletedImage);
2654 + }
917 2655 }
918 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 + }
919 3594 }
920 3595
921 3596 }