PluginProbe
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration / 2.2.0
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration v2.2.0
2.2.0 2.1.0 2.0.15 2.0.12 2.0.10 2.0.4 2.0.1 2.0.0 1.95.3 1.95.2 1.95 1.91.6 trunk 1.11 1.12 1.13 1.20 1.21 1.22 1.23 1.30 1.31 1.32 1.35 1.40 All 43 releases
← All changes | app/Services/TaskService.php +3095 -220 1.12 → 2.2.0 View file →
@@ -1,36 +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;
8 +use FluentBoards\App\Models\Attachment;
9 +use FluentBoards\App\Models\Comment;
10 +use FluentBoards\App\Models\Notification;
11 +use FluentBoards\App\Models\NotificationUser;
12 +use FluentBoards\App\Models\TaskImage;
5 13 use FluentBoards\App\Services\Constant;
6 -use FluentBoardsPro\App\Models\Attachment;
14 +use FluentBoards\App\Models\Label;
7 15 use FluentBoards\App\Models\Stage;
8 16 use FluentBoards\App\Models\Task;
9 17 use FluentBoards\App\Models\Board;
10 18 use FluentBoards\App\Models\TaskMeta;
19 +use FluentBoards\App\Models\Meta;
11 20 use FluentBoards\App\Models\Activity;
12 -use FluentBoards\App\Models\BoardTerm;
21 +use FluentBoards\App\Models\CommentImage;
22 +use FluentBoards\App\Models\Relation;
13 23 use FluentBoards\Framework\Support\Arr;
24 +use FluentBoardsPro\App\Models\TaskAttachment;
25 +use FluentBoardsPro\App\Modules\TimeTracking\Model\TimeTrack;
26 +use FluentBoardsPro\App\Modules\TimeTracking\TimeTrackingHelper;
27 +use FluentBoardsPro\App\Services\AttachmentService;
28 +use FluentBoardsPro\App\Services\RemoteUrlParser;
14 29 use FluentRoadmap\App\Models\IdeaReaction;
15 30
16 31 class TaskService
17 32 {
33 + private static $physicalTableNameCache = [];
34 +
35 + /** Load appended task metadata in two queries without exposing internal relation rows. */
36 + public function loadTaskListMetadata($tasks)
37 + {
38 + $items = [];
39 + foreach ($tasks as $task) {
40 + $items[] = $task;
41 + }
42 +
43 + $collection = (new Task())->newCollection($items);
44 + $collection->load(['taskMeta', 'repeatTaskMeta']);
45 +
46 + foreach ($collection as $task) {
47 + // Keep the existing meta/repeat_task_meta API fields, not duplicate raw relations.
48 + $task->makeHidden(['taskMeta', 'repeatTaskMeta']);
49 + }
50 + }
51 +
52 + /**
53 + * Preserve the original CRM template copy when Pro lacks automation-context support.
54 + * Copies parent data, assignees and labels; full related-data copying requires updated Pro.
55 + * Context is provided by the automation handler, not directly from an HTTP request.
56 + */
57 + public function createAutomationTaskFromLegacyTemplate(Task $template, array $data, array $context): Task
58 + {
59 + $stage = Stage::where('id', absint($data['stage_id']))
60 + ->where('board_id', absint($data['board_id']))->firstOrFail();
61 + $task = new Task();
62 + $task->fill($template->toArray());
63 + $task->title = sanitize_text_field($data['title']);
64 + $task->board_id = $stage->board_id;
65 + $task->stage_id = $stage->id;
66 + $task->parent_id = null;
67 + $task->created_by = absint($context['created_by']);
68 + $task->crm_contact_id = absint($context['crm_contact_id']);
69 + $task->source = sanitize_key($context['source']);
70 + $task->comments_count = 0;
71 + $settings = $task->settings ?: [];
72 + $settings['subtask_count'] = 0;
73 + $settings['subtask_completed_count'] = 0;
74 + $task->settings = $settings;
75 + $task->moveToNewPosition(1);
76 + $task->save();
77 +
78 + $assigneeIds = array_key_exists('assignees', $context)
79 + ? $context['assignees']
80 + : $template->assignees()->get()->pluck('ID')->toArray();
81 + foreach (array_unique(array_filter(array_map('absint', (array) $assigneeIds))) as $assigneeId) {
82 + $this->updateAssignee($assigneeId, $task);
83 + }
84 + foreach ($template->labels as $label) {
85 + $task->labels()->syncWithoutDetaching([$label->id => ['object_type' => Constant::OBJECT_TYPE_TASK_LABEL]]);
86 + }
87 +
88 + return $task;
89 + }
90 +
91 + /**
92 + * Resolve a task only when it belongs to the requested board.
93 + *
94 + * Subtasks normally carry the same board_id as their parent, but the parent
95 + * fallback protects older data where that relationship may be incomplete.
96 + * Missing or mismatched tasks use the router's deliberate 404 response.
97 + *
98 + * @param int $taskId
99 + * @param int $boardId
100 + * @param bool $allowParentFallback
101 + * @return Task
102 + * @throws ModelNotFoundException
103 + */
104 + public function findTaskOnBoard($taskId, $boardId, $allowParentFallback = true)
105 + {
106 + $taskId = absint($taskId);
107 + $boardId = absint($boardId);
108 +
109 + if (!$taskId || !$boardId) {
110 + throw new ModelNotFoundException(esc_html__('Task not found', 'fluent-boards'));
111 + }
112 +
113 + $task = Task::where('id', $taskId)
114 + ->where('board_id', $boardId)
115 + ->first();
116 +
117 + if ($task) {
118 + return $this->normalizeTaskDescriptionForEditor($task);
119 + }
120 +
121 + if ($allowParentFallback) {
122 + $task = Task::where('id', $taskId)
123 + ->whereNull('board_id')
124 + ->whereNotNull('parent_id')
125 + ->first();
126 +
127 + if ($task) {
128 + $parentBoardId = Task::where('id', $task->parent_id)->value('board_id');
129 +
130 + if ((int) $parentBoardId === $boardId) {
131 + return $this->normalizeTaskDescriptionForEditor($task);
132 + }
133 + }
134 + }
135 +
136 + throw new ModelNotFoundException(esc_html__('Task not found', 'fluent-boards'));
137 + }
138 +
139 + private function normalizeTaskDescriptionForEditor(Task $task)
140 + {
141 + $task->description = DescriptionMarkdownConverter::normalize($task->description);
142 +
143 + return $task;
144 + }
145 +
146 + /**
147 + * Create a board task and notify eligible selected assignees before applying stage defaults.
148 + */
18 149 public function createTask($data, $boardId)
19 150 {
20 151 $board = Board::select('id', 'type')->find($boardId);
21 152
22 153 if (!$board) {
23 - throw new \Exception(__("Board doesn't exists", 'fluent-boards'));
154 + throw new \Exception(esc_html__("Board doesn't exists", 'fluent-boards'));
24 155 }
25 156
26 157 $stage = Stage::find($data['stage_id']);
27 158 if (!$stage) {
28 - throw new \Exception(__("Stage doesn't exists", 'fluent-boards'));
159 + throw new \Exception(esc_html__("Stage doesn't exists", 'fluent-boards'));
29 160 }
30 161
162 + if ((int) $stage->board_id !== (int) $boardId) {
163 + throw new \Exception(esc_html__("Stage doesn't exists", 'fluent-boards'));
164 + }
165 +
31 166 $data['status'] = $stage->defaultTaskStatus();
32 167
168 + // Image covers require a persisted task, so creation only accepts a sanitized color cover.
169 + $coverColor = sanitize_hex_color(Arr::get($data, 'settings.cover.backgroundColor', ''));
170 + $taskSettings = [];
171 + if ($coverColor) {
172 + $taskSettings['cover'] = [
173 + 'backgroundColor' => $coverColor,
174 + ];
175 + }
176 +
33 177 if ($board->type == 'roadmap') {
34 178 $current_user = wp_get_current_user();
35 179 $settingData = array(
36 180 'integration_type' => 'feature',
@@ -38,10 +182,14 @@
38 182 'author' => [
39 183 'email' => $current_user->user_email // email of who posted this feature
40 184 ],
41 185 );
42 - $data['settings'] = $settingData;
186 + $data['settings'] = array_merge($taskSettings, $settingData);
43 187 $data['type'] = 'roadmap';
188 + } elseif ($taskSettings) {
189 + $data['settings'] = $taskSettings;
190 + } else {
191 + unset($data['settings']);
44 192 }
45 193
46 194 $providerPosition = Arr::get($data, 'position');
47 195
@@ -47,11 +195,29 @@
47 195
48 196 $data['position'] = $this->getLastPositionOfTasks($stage->id);
49 197
50 198 $data['board_id'] = $boardId;
199 + $data = Helper::normalizeDates($data, ['due_at', 'started_at', 'last_completed_at', 'archived_at', 'remind_at']);
200 + if (isset($data['description'])) {
201 + $data['description'] = DescriptionMarkdownConverter::normalize($data['description']);
202 + }
51 203
52 204 $data = array_filter($data);
53 205 $task = (new Task())->createTask($data);
206 +
207 + // Selected assignees are already attached; toggling them via updateAssignee would remove them.
208 + $task->load('assignees');
209 + $currentUserId = get_current_user_id();
210 + $assigneeIds = array_diff($task->assignees->pluck('ID')->all(), [$currentUserId]);
211 + $notificationService = new NotificationService();
212 + $recipientIds = $notificationService->getEmailEnabledUserIds($task->board_id, $assigneeIds, Constant::BOARD_EMAIL_TASK_ASSIGN);
213 + foreach ($recipientIds as $recipientId) {
214 + $this->sendMailAfterTaskModify('add_assignee', $recipientId, $task->id);
215 + }
216 +
217 + $this->manageDefaultAssignees($task, $stage->id);
218 + $this->manageDefaultWatchers($task, $stage->id);
219 +
54 220 if (isset($data['is_template']) && $data['is_template'] == 'yes') {
55 221 $task->updateMeta(Constant::IS_TASK_TEMPLATE, $data['is_template']);
56 222 }
57 223
@@ -77,20 +243,38 @@
77 243 }
78 244
79 245 public function getTasksForBoards($filters = ['overdue', 'upcoming'], $limit = 5, $task_ids = [])
80 246 {
247 + $assigned = $this->getTasksForBoardsByCategory('assigned', $limit, $task_ids);
81 248 $overDue = $this->getTasksForBoardsByCategory('overdue', $limit, $task_ids);
249 + $dueToday = $this->getTasksForBoardsByCategory('due_today', $limit, $task_ids);
82 250 $completed = $this->getTasksForBoardsByCategory('completed', $limit, $task_ids);
251 + $mentioned = $this->getTasksForBoardsByCategory('mentioned', $limit, $task_ids);
83 252 $upcoming = $this->getTasksForBoardsByCategory('upcoming', $limit, $task_ids);
253 + $others = $this->getTasksForBoardsByCategory('others', $limit, $task_ids);
84 254
85 -
86 255 return [
256 + 'assigned' => $assigned ?? [],
87 257 'overdue' => $overDue ?? [],
258 + 'due_today' => $dueToday ?? [],
88 259 'upcoming' => $upcoming ?? [],
89 - 'completed' => $completed ?? []
260 + 'mentioned' => $mentioned ?? [],
261 + 'completed' => $completed ?? [],
262 + 'others' => $others ?? []
90 263 ];
91 264 }
92 265
266 + public function getTaskCountsForBoards($categories = ['assigned', 'overdue', 'upcoming', 'completed', 'others'], $taskIds = [])
267 + {
268 + $counts = [];
269 +
270 + foreach ($categories as $category) {
271 + $counts[$category] = $this->getTaskCountForBoardsByCategory($category, $taskIds);
272 + }
273 +
274 + return $counts;
275 + }
276 +
93 277 public function getTasksForBoardsByCategory($category, $limit, $taskIds)
94 278 {
95 279 unset($taskQuery);
96 280 $taskQuery = Task::whereIn('id', $taskIds)
@@ -96,20 +280,67 @@
96 280 $taskQuery = Task::whereIn('id', $taskIds)
97 281 ->with(['assignees', 'board', 'stage'])
98 282 ->whereNull('archived_at')
99 283 ->where('parent_id', null)
100 - ->orderBy('due_at', 'ASC');
284 + ->onActiveAvailableBoards()
285 + ->orderBy('due_at', 'DESC');
101 286
102 - if ('overdue' == $category) {
103 - $taskQuery->overdue();
104 - } elseif ('upcoming' == $category) {
105 - $taskQuery->upcoming();
106 - } elseif ('upcoming_no_duedate' == $category) {
107 - $taskQuery->whereNull('due_at');
108 - } elseif ('completed' == $category) {
109 - $taskQuery->where('status', 'closed');
110 - } else {
111 - return [];
287 + switch ($category) {
288 + case 'overdue':
289 + $taskQuery->overdue();
290 + break;
291 + case 'upcoming':
292 + $taskQuery->upcoming();
293 + break;
294 + case 'due_today':
295 + $taskQuery->dueToday();
296 + break;
297 + case 'others':
298 + $taskQuery->whereNull('due_at');
299 + break;
300 + case 'completed':
301 + $taskQuery->where('status', 'closed');
302 + break;
303 + case 'assigned':
304 + // Rebuild query to order by latest assignment (pivot created_at) so the most recently assigned tasks come first.
305 + $currentUserId = get_current_user_id();
306 + $taskQuery = Task::query()
307 + ->select('fbs_tasks.*')
308 + ->distinct()
309 + ->with(['assignees', 'board', 'stage'])
310 + ->whereIn('fbs_tasks.id', $taskIds)
311 + ->whereNull('fbs_tasks.archived_at')
312 + ->whereNull('fbs_tasks.parent_id')
313 + ->where('fbs_tasks.status', '!=', 'closed')
314 + ->onActiveAvailableBoards()
315 + ->join('fbs_relations as rel', function ($join) use ($currentUserId) {
316 + $join->on('rel.object_id', '=', 'fbs_tasks.id')
317 + ->where('rel.object_type', Constant::OBJECT_TYPE_TASK_ASSIGNEE)
318 + ->where('rel.foreign_id', $currentUserId);
319 + })
320 + ->orderBy('rel.created_at', 'DESC')
321 + ->orderBy('fbs_tasks.updated_at', 'DESC');
322 + break;
323 + case 'mentioned':
324 + $currentUserId = get_current_user_id();
325 + $userNotifications = NotificationUser::where('user_id', $currentUserId)
326 + ->with(['notification' => function ($query) {
327 + $query->where('action', 'task_comment_mentioned');
328 + }])
329 + ->orderBy('created_at', 'desc')
330 + ->get();
331 + $taskIds = $userNotifications->filter(function ($userNotification) {
332 + $notification = $userNotification->notification;
333 + return $notification && $notification->task && is_null($notification->task->archived_at) && is_null($notification->task->parent_id);
334 + })->pluck('notification.task_id')->unique();
335 + $validTasks = Task::whereIn('id', $taskIds)
336 + ->with(['assignees', 'board', 'stage'])
337 + ->onActiveAvailableBoards()
338 + ->get();
339 +
340 + return $validTasks->toArray();
341 + default:
342 + return [];
112 343 }
113 344
114 345 $tasks = $taskQuery->take($limit)->get();
115 346
@@ -115,8 +346,108 @@
115 346
116 347 return $tasks->toArray();
117 348 }
118 349
350 + public function getTaskCountForBoardsByCategory($category, $taskIds)
351 + {
352 + if (empty($taskIds)) {
353 + return 0;
354 + }
355 +
356 + $taskQuery = Task::query()
357 + ->whereIn('id', $taskIds)
358 + ->whereNull('archived_at')
359 + ->whereNull('parent_id')
360 + ->onActiveAvailableBoards();
361 +
362 + switch ($category) {
363 + case 'overdue':
364 + $taskQuery->overdue();
365 + break;
366 + case 'upcoming':
367 + $taskQuery->upcoming();
368 + break;
369 + case 'due_today':
370 + $taskQuery->dueToday();
371 + break;
372 + case 'completed':
373 + $taskQuery->where('status', 'closed');
374 + break;
375 + case 'others':
376 + $taskQuery->whereNull('due_at');
377 + break;
378 + case 'assigned':
379 + $currentUserId = get_current_user_id();
380 + $taskQuery = Task::query()
381 + ->select('fbs_tasks.id')
382 + ->whereIn('fbs_tasks.id', $taskIds)
383 + ->whereNull('fbs_tasks.archived_at')
384 + ->whereNull('fbs_tasks.parent_id')
385 + ->where('fbs_tasks.status', '!=', 'closed')
386 + ->onActiveAvailableBoards()
387 + ->join('fbs_relations as rel', function ($join) use ($currentUserId) {
388 + $join->on('rel.object_id', '=', 'fbs_tasks.id')
389 + ->where('rel.object_type', Constant::OBJECT_TYPE_TASK_ASSIGNEE)
390 + ->where('rel.foreign_id', $currentUserId);
391 + });
392 +
393 + return (int) $taskQuery->distinct()->count('fbs_tasks.id');
394 + case 'mentioned':
395 + $currentUserId = get_current_user_id();
396 + $taskIds = NotificationUser::where('user_id', $currentUserId)
397 + ->with(['notification' => function ($query) {
398 + $query->where('action', 'task_comment_mentioned')
399 + ->with('task');
400 + }])
401 + ->get()
402 + ->filter(function ($userNotification) {
403 + $notification = $userNotification->notification;
404 +
405 + return $notification && $notification->task && is_null($notification->task->archived_at) && is_null($notification->task->parent_id);
406 + })
407 + ->pluck('notification.task_id')
408 + ->unique();
409 +
410 + return Task::whereIn('id', $taskIds)->onActiveAvailableBoards()->count();
411 + default:
412 + return 0;
413 + }
414 +
415 + return (int) $taskQuery->count();
416 + }
417 +
418 + /**
419 + * Unlink a Fluent Support ticket from a board-scoped task.
420 + *
421 + * @param int $taskId
422 + * @param int $boardId
423 + * @return Task
424 + * @throws \Exception
425 + */
426 + public function removeSupportTicketLink($taskId, $boardId)
427 + {
428 + $taskId = absint($taskId);
429 + $boardId = absint($boardId);
430 + $task = $this->findTaskOnBoard($taskId, $boardId);
431 +
432 + if ($task->source !== Constant::TASK_SOURCE_FLUENT_SUPPORT || !$task->source_id) {
433 + return $task;
434 + }
435 +
436 + $ticketId = $task->source_id;
437 + $settings = is_array($task->settings) ? $task->settings : [];
438 + unset($settings['author']);
439 +
440 + $task->source = null;
441 + $task->source_id = null;
442 + $task->settings = $settings ?: null;
443 + $task->save();
444 +
445 + do_action('fluent_boards/support_ticket_unlinked', $task, $ticketId);
446 +
447 + return $task;
448 + }
449 +
119 450 /*
120 451 * TODO: Refactor this function - For me.
121 452 */
122 453 public function updateTaskProperty($col, $value, $task)
@@ -123,70 +454,135 @@
123 454 {
124 455 $oldTask = clone $task; // normal assigning won't work here. because objects are passed by reference in php
125 456 $validColumns = [
126 457 'board_id',
127 - 'task_type',
128 - 'reminder_type',
458 + 'type',
459 +// 'reminder_type',
129 460 'remind_at',
130 461 'log_minutes',
462 + 'source',
463 + 'source_id',
131 464 'settings'
132 465 ];
133 466
467 + if ($col === 'description') {
468 + $value = DescriptionMarkdownConverter::normalize($value);
469 + }
470 +
134 471 if (in_array($col, $validColumns) && $task->{$col} != $value) {
135 - $task->{$col} = $value;
136 - $task->save();
137 - // do_action('fluent_boards/task_prop_changed', $col, $task, $oldTask);
138 - } elseif ('assignees' == $col) {
139 - if (is_array($value)) {
140 - foreach ($value as $id) {
141 - $this->updateAssignee($id, $task);
142 - }
143 - } else {
144 - $this->updateAssignee($value, $task);
472 + if ($col === 'remind_at') {
473 + $value = Helper::normalizeDateValue($value);
145 474 }
146 475
147 - } elseif ('crm_contact_id' == $col) {
148 - $this->updateAssociate($value, $task);
149 - } elseif ('archived_at' == $col) {
150 - $this->updateArchive($value, $task);
151 - } elseif ('status' == $col) {
152 - $this->updateStatus($value, $task);
153 - } elseif ('parent_id' == $col) {
154 - $this->updateParent($value, $task);
155 - } elseif ('title' == $col) {
156 - $this->updateTitle($col, $value, $task, $oldTask);
157 - } elseif ('description' == $col) {
158 - $this->updateDescription($col, $value, $task);
159 - } elseif ($col == 'due_at') {
160 - $this->updateDueDate($value, $task);
161 - } elseif ($col == 'started_at') {
162 - $this->updateStartedDate($value, $task);
163 - } elseif ($col == 'priority') {
164 - $this->updatePriority($value, $task);
165 - } elseif ($col == 'is_watching') {
166 - $this->updateObservationOfCurrentUser($value, $task);
167 - } elseif ($col == 'last_completed_at') {
168 - $isClosed = $value == 'true' || $value === true;
169 - if ($isClosed) {
170 - $task = $task->close();
171 - } else {
172 - $task = $task->reopen();
476 + if ($col == 'settings' && isset($value['cover']['backgroundColor']) && $value['cover']['backgroundColor']) {
477 + $settings = $task->settings;
478 + $this->deleteTaskCoverImage($settings);
479 + unset($value['cover']['imageId']);
480 + unset($value['cover']['backgroundImage']);
173 481 }
482 + $task->{$col} = $value ?: null;
174 483 $task->save();
175 - } elseif ($col == 'attachment_count') {
176 - $settings = $task->settings;
177 - $settings['attachment_count'] = $task->attachments()->count();
178 - $task->settings = $settings;
179 - $task->save();
180 - } elseif ($col == 'subtask_count') {
181 - $settings = $task->settings;
182 - $subtasksCount = Task::where('parent_id', $task->id)->count();
183 - $settings['subtask_count'] = $subtasksCount;
184 - $task->settings = $settings;
185 - $task->save();
186 - } elseif ($col == 'is_template') {
187 - if (defined('FLUENT_BOARDS_PRO')) {
188 - $task->updateMeta(Constant::IS_TASK_TEMPLATE, $value);
484 + // do_action('fluent_boards/task_prop_changed', $col, $task, $oldTask);
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;
496 +
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 586 }
191 587
192 588 return $task;
@@ -200,19 +596,22 @@
200 596
201 597 $task->save();
202 598
203 599 if ($operation == 'added') {
204 - 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()) {
205 601 $this->sendMailAfterTaskModify('add_assignee', $payloadAssigneeId, $task->id);
206 602 }
207 603 // $assigneeIdsToSendEmail = $this->filterAssigneeToSendEmail($task, $idArray, Constant::BOARD_EMAIL_TASK_ASSIGN);
208 604 // $this->sendMailAfterAddAssignees($assigneeIdsToSendEmail, $task->id);
209 - do_action('fluent_boards/task_assignee_changed', $task, $payloadAssigneeId, $operation);
605 + do_action('fluent_boards/task_assignee_added', $task, $payloadAssigneeId);
606 + if($payloadAssigneeId != get_current_user_id()){
607 + do_action('fluent_boards/assign_another_user', $task, $payloadAssigneeId);
608 + }
210 609 } else {
211 - 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()) {
212 611 $this->sendMailAfterTaskModify('remove_assignee', $payloadAssigneeId, $task->id);
213 612 }
214 - do_action('fluent_boards/task_assignee_changed', $task, $payloadAssigneeId, $operation);
613 + do_action('fluent_boards/task_assignee_removed', $task, $payloadAssigneeId);
215 614 }
216 615
217 616 }
218 617
@@ -265,17 +664,36 @@
265 664
266 665 private function updateArchive($value, $task)
267 666 {
268 667 if ($value != null) {
668 + // Archiving task
269 669 $task->position = 0;
270 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 +
271 684 $task->moveToNewPosition(1);
685 +
686 + // Clean up archived_by_stage meta when task is manually restored
687 + $this->cleanupArchivedByStageMetaIfExists($task->id);
272 688 }
273 689 $task->archived_at = $value == null ? null : current_time('mysql');
274 690 $task->save();
275 - do_action('fluent_boards/board_task_archived', $task);
276 - $wathersToSendEmail = (new NotificationService())->filterAssigneeToSendEmail($task->id, Constant::BOARD_EMAIL_TASK_ARCHIVE);
277 - $this->sendMailAfterTaskModify('task_archived', $wathersToSendEmail, $task->id);
691 + do_action('fluent_boards/task_archived', $task);
692 + if ($value != null) {
693 + $watchersToSendEmail = (new NotificationService())->filterAssigneeToSendEmail($task->id, Constant::BOARD_EMAIL_TASK_ARCHIVE);
694 + $this->sendMailAfterTaskModify('task_archived', $watchersToSendEmail, $task->id);
695 + }
278 696 }
279 697
280 698 private function updateStatus($value, $task)
281 699 {
@@ -300,25 +718,29 @@
300 718 $task->save();
301 719 do_action('fluent_boards/task_content_updated', $task, $col, $oldTask);
302 720 }
303 721
304 - private function updateDescription($col, $value, $task)
722 + private function updateDescription($col, $value, $task, $oldTask)
305 723 {
306 - $task->description = $value;
724 + $task->description = DescriptionMarkdownConverter::normalize($value);
307 725 $task->save();
308 - do_action('fluent_boards/task_content_updated', $task, $col, null);
726 + do_action('fluent_boards/task_content_updated', $task, $col, $oldTask);
309 727 }
310 728
311 729 private function updateDueDate($value, $task)
312 730 {
313 731 $oldValue = $task->due_at;
314 - $value = $this->filterNullDate($value);
732 + $value = Helper::normalizeDateValue($value);
315 733 $task->due_at = $value;
316 734 $task->save();
317 735
318 736 $task = $task->reopen();
319 737
320 - do_action('fluent_boards/task_date_changed', $task, $oldValue, 'Due Date');
738 + if($value){
739 + do_action('fluent_boards/task_due_date_changed', $task, $oldValue);
740 + } else {
741 + do_action('fluent_boards/task_due_date_removed', $task);
742 + }
321 743
322 744 $wathersToSendEmail = (new NotificationService())->filterAssigneeToSendEmail($task->id, Constant::BOARD_EMAIL_DUE_DATE_CHANGE);
323 745 $this->sendMailAfterTaskModify('due_date_update', $wathersToSendEmail, $task->id);
324 746 }
@@ -325,14 +747,15 @@
325 747
326 748 private function updateStartedDate($value, $task)
327 749 {
328 750 $oldValue = $task->started_at;
329 - $value = $this->filterNullDate($value);
751 + $value = Helper::normalizeDateValue($value);
330 752 $task->started_at = $value;
331 753 $task->save();
332 754
333 - do_action('fluent_boards/task_date_changed', $task, $oldValue, 'Start Date');
334 -
755 + if($value){
756 + do_action('fluent_boards/task_start_date_changed', $task, $oldValue);
757 + }
335 758 }
336 759
337 760 private function updatePriority($value, $task)
338 761 {
@@ -341,46 +764,53 @@
341 764 $task->save();
342 765 do_action('fluent_boards/task_priority_changed', $task, $oldPriority);
343 766 }
344 767
345 - public function updateObservationOfCurrentUser($value, $task)
346 - {
347 - $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 + }
348 777
349 - if ($value == 'stop') {
350 - $task->watchers()->detach($currentUserId);
351 - } else {
352 - $task->watchers()->syncWithoutDetaching([$currentUserId => ['object_type' => Constant::OBJECT_TYPE_USER_TASK_WATCH]]);
353 - }
354 - $task->updated_at = current_time('mysql');
355 - $task->save();
778 + if (!$userId || !in_array($action, ['stop', 'start'])) {
779 + return;
780 + }
356 781
357 - if ($value == 'stop') {
358 - $task->is_watching = false;
359 - } else {
360 - $task->is_watching = true;
361 - }
782 + if ($action == 'stop') {
783 + $task->watchers()->detach($userId);
784 + } else {
785 + $task->watchers()->syncWithoutDetaching([$userId => ['object_type' => Constant::OBJECT_TYPE_USER_TASK_WATCH]]);
362 786 }
787 + $task->updated_at = current_time('mysql');
788 + $task->save();
789 +}
363 790
364 - public function taskCoverPhotoUpdate($taskId, $imagePath)
791 + public function taskCoverPhotoUpdate($taskId, $imagePath, $boardId = null)
365 792 {
366 - $task = Task::find($taskId);
793 + $task = $boardId ? $this->findTaskOnBoard($taskId, $boardId) : Task::find($taskId);
367 794 if (!$task) {
368 795 return null;
369 796 }
370 797
371 - $settings = unserialize($task->settings);
798 + $settings = $task->settings;
799 + if (!is_array($settings)) {
800 + $settings = [];
801 + }
372 802
373 803 $settings['logo'] = $imagePath;
374 - $task->settings = serialize($settings);
804 + $task->settings = $settings;
375 805 $task->save();
376 806
377 807 return $task;
378 808 }
379 809
380 - public function taskStatusUpdate($taskId, $integrationType)
810 + public function taskStatusUpdate($taskId, $integrationType, $boardId = null)
381 811 {
382 - $task = Task::find($taskId);
812 + $task = $boardId ? $this->findTaskOnBoard($taskId, $boardId) : Task::find($taskId);
383 813 if (!$task) {
384 814 return null;
385 815 }
386 816
@@ -385,9 +815,9 @@
385 815 }
386 816
387 817 $settings = $task->settings;
388 818 $settings['integration_type'] = $integrationType;
389 - $task->settings = serialize($settings);
819 + $task->settings = $settings;
390 820 $task->save();
391 821
392 822 return $task;
393 823 }
@@ -393,9 +823,9 @@
393 823 }
394 824
395 825 public function assignYourselfInTask($boardId, $taskId)
396 826 {
397 - $task = Task::find($taskId);
827 + $task = $this->findTaskOnBoard($taskId, $boardId);
398 828 $authUserId = get_current_user_id();
399 829
400 830 $boardService = new BoardService();
401 831 if (!$boardService->isAlreadyMember($boardId, $authUserId)) {
@@ -402,10 +832,13 @@
402 832 $boardService->addMembersInBoard($boardId, $authUserId);
403 833 }
404 834
405 835 $task->addOrRemoveAssignee($authUserId);
836 + // when user assign himself then he will be watching that task
837 + $task->watchers()->syncWithoutDetaching([$authUserId => ['object_type' => Constant::OBJECT_TYPE_USER_TASK_WATCH]]);
406 838
407 839 $task->load('assignees');
840 + do_action('fluent_boards/task_assignee_added', $task, $authUserId);
408 841
409 842 return $task;
410 843 }
411 844
@@ -410,11 +843,13 @@
410 843 }
411 844
412 845 public function detachYourselfFromTask($boardId, $taskId)
413 846 {
414 - $task = Task::find($taskId);
415 - $task->addOrRemoveAssignee(get_current_user_id());
847 + $task = $this->findTaskOnBoard($taskId, $boardId);
848 + $currentUserId = get_current_user_id();
849 + $task->addOrRemoveAssignee($currentUserId);
416 850 $task->load('assignees');
851 + do_action('fluent_boards/task_assignee_removed', $task, $currentUserId);
417 852
418 853 return $task;
419 854 }
420 855
@@ -419,35 +854,216 @@
419 854 }
420 855
421 856 public function deleteTask($task)
422 857 {
423 - $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 + }
424 866
425 - if ($deleted) {
867 + $this->deleteTasksBatch([$task]);
868 + }
426 869
427 - //task assignees watchers removed
428 - $task->watchers()->detach();
429 - $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 + }
430 883
431 - //removing all task related notifications
432 - $task->notifications()->delete();
884 + $deletedTasks = [];
885 + $taskBoardIds = [];
886 + foreach ($tasks as $task) {
887 + if (!$task instanceof Task) {
888 + continue;
889 + }
433 890
434 - //task labels removed
435 - $task->labels()->detach();
891 + $taskId = (int) $task->id;
892 + if ($taskId < 1) {
893 + continue;
894 + }
436 895
437 - do_action('fluent_boards/task_deleted', $task);
438 - TaskMeta::where('task_id', $task->id)->delete();
896 + $deletedTasks[$taskId] = clone $task;
897 + $taskBoardIds[$taskId] = (int) $task->board_id;
439 898 }
899 +
900 + if (!$deletedTasks) {
901 + return;
902 + }
903 +
904 + ksort($deletedTasks, SORT_NUMERIC);
905 + $taskIds = array_keys($deletedTasks);
906 + $dbInstance = App::getInstance('db');
907 +
908 + if (!$manageTransaction && !$dbInstance->inTransaction()) {
909 + throw new \RuntimeException(__('An active transaction is required for caller-managed task deletion.', 'fluent-boards'));
910 + }
911 +
912 + if ($manageTransaction) {
913 + $dbInstance->beginTransaction();
914 + }
915 +
916 + try {
917 + $relationTypes = [
918 + Constant::OBJECT_TYPE_USER_TASK_WATCH,
919 + Constant::OBJECT_TYPE_TASK_ASSIGNEE,
920 + Constant::OBJECT_TYPE_TASK_LABEL,
921 + ];
922 +
923 + if (defined('FLUENT_BOARDS_PRO_VERSION')) {
924 + $relationTypes[] = \FluentBoardsPro\App\Services\Constant::TASK_CUSTOM_FIELD;
925 + }
926 +
927 + Relation::whereIn('object_id', $taskIds)
928 + ->whereIn('object_type', $relationTypes)
929 + ->delete();
930 +
931 + Relation::where('object_type', Constant::OBJECT_TYPE_TASK_DEPENDENCY)
932 + ->where(function ($query) use ($taskIds) {
933 + $query->whereIn('object_id', $taskIds)
934 + ->orWhereIn('foreign_id', $taskIds);
935 + })
936 + ->delete();
937 +
938 + $notificationIds = Notification::whereIn('task_id', $taskIds)->pluck('id')->toArray();
939 + NotificationUser::whereIn('notification_id', $notificationIds)->delete();
940 + Notification::whereIn('task_id', $taskIds)->delete();
941 +
942 + $this->deleteTaskAttachmentsBatch($taskIds, $taskBoardIds);
943 + $this->deleteTaskCommentsBatch($taskIds, $taskBoardIds, $dbInstance);
944 + Activity::whereIn('object_id', $taskIds)
945 + ->where('object_type', Constant::ACTIVITY_TASK)
946 + ->delete();
947 + Meta::whereIn('object_id', $taskIds)
948 + ->where('object_type', Constant::REPEAT_TASK_META)
949 + ->delete();
950 + $this->deleteTimeTrackingRecords($taskIds, false);
951 + TaskMeta::whereIn('task_id', $taskIds)->delete();
952 +
953 + $deletedCount = Task::whereIn('id', $taskIds)->delete();
954 + if ($deletedCount !== count($taskIds)) {
955 + throw new \RuntimeException(__('Task could not be deleted.', 'fluent-boards'));
956 + }
957 +
958 + $this->dispatchTaskDeletedHooksAfterCommit($dbInstance, $deletedTasks);
959 +
960 + if ($manageTransaction) {
961 + $dbInstance->commit();
962 + }
963 + } catch (\Throwable $e) {
964 + if ($manageTransaction) {
965 + $dbInstance->rollBack();
966 + }
967 +
968 + throw $e;
969 + }
440 970 }
441 971
442 - public function filterNullDate($date)
972 + /** Remove comment rows transactionally; file cleanup must wait for commit. */
973 + private function deleteTaskCommentsBatch($taskIds, $taskBoardIds, $dbInstance)
443 974 {
444 - if ('0000-00-00 00:00:00' == $date || false === strtotime($date)) {
445 - return null;
975 + $commentTasks = Comment::whereIn('task_id', $taskIds)->pluck('task_id', 'id')->toArray();
976 + if (!$commentTasks) {
977 + return;
446 978 }
447 - 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 + });
448 1030 }
449 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 + }
450 1066 // this is invoked when task is moved to another board
451 1067
452 1068 /**
453 1069 * @throws \Exception
@@ -453,88 +1069,942 @@
453 1069 * @throws \Exception
454 1070 */
455 1071 public function changeBoardByTask($task, $targetBoardId)
456 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 +
457 1079 if ($task->board_id == $targetBoardId) {
458 1080 return $task;
459 1081 }
460 1082
461 1083 $oldBoard = Board::find($task->board_id);
1084 + $newBoard = Board::find($targetBoardId);
462 1085
463 - $newBoard = Board::find($targetBoardId);
1086 + if (!$oldBoard) {
1087 + throw new \Exception(esc_html__('Source board not found', 'fluent-boards'), 404);
1088 + }
1089 +
464 1090 if (!$newBoard) {
465 - throw new \Exception('Invalid board id', 400);
1091 + throw new \Exception(esc_html__('Target board not found', 'fluent-boards'), 404);
466 1092 }
467 - $task->board_id = $targetBoardId;
468 - $task->save();
469 - //delete labels of that task because labels have board dependencies
470 - $task->labels()->detach();
471 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 +
472 1131 do_action('fluent_boards/task_moved_from_board', $task, $oldBoard, $newBoard);
1132 + return $task;
1133 + }
473 1134
474 - 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 + }
475 1174 }
476 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 + }
477 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 +
478 1291 public function getIdeaVoteStatistics($taskId)
479 1292 {
480 - $reactionTypes = [
481 - [
482 - 'label' => 'Upvote',
483 - 'type' => 'upvote'
484 - ],
485 - [
486 - 'label' => 'Downvote',
487 - 'type' => 'downvote'
488 - ]
489 - ];
1293 + $taskId = absint($taskId);
1294 + $voteStatistics = $this->getIdeaVoteStatisticsByTaskIds([$taskId]);
490 1295
491 - $reactionCounts = [];
1296 + return $voteStatistics[$taskId] ?? 0;
1297 + }
492 1298
493 - foreach ($reactionTypes as $reactionType) {
494 - $count = IdeaReaction::where('object_id', $taskId)
495 - ->where('object_type', 'idea')
496 - ->where('type', $reactionType['type'])
497 - ->count();
1299 + public function loadIdeaVoteStatistics($tasks)
1300 + {
1301 + $taskIds = [];
498 1302
499 - $reactionCounts[] = [
500 - 'label' => $reactionType['label'],
501 - 'type' => $reactionType['type'],
502 - 'count' => $count
503 - ];
1303 + foreach ($tasks as $task) {
1304 + $taskId = absint($task->id);
1305 + if ($taskId) {
1306 + $taskIds[] = $taskId;
1307 + }
504 1308 }
505 1309
506 - return $reactionCounts;
1310 + $voteStatistics = $this->getIdeaVoteStatisticsByTaskIds($taskIds);
1311 +
1312 + foreach ($tasks as $task) {
1313 + $task->vote_statistics = $voteStatistics[(int) $task->id] ?? 0;
1314 + }
1315 +
1316 + return $tasks;
507 1317 }
508 1318
1319 + private function getIdeaVoteStatisticsByTaskIds(array $taskIds)
1320 + {
1321 + global $wpdb;
509 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 +
510 1363 /**
511 - * Summary of getArchivedOrCompletedTasks
512 - * this function will return completd tasks or archived tasks based on users input and also can search by name
513 - * @param mixed $data
514 - * @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
515 1432 * @return mixed
516 1433 * @throws \Exception
517 1434 */
518 1435 public function getArchivedTasks($data, $boardId)
519 1436 {
520 - $per_page = isset($data['per_page']) ? $data['per_page'] : 25;
521 - $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));
522 1444 $tasksQuery = Task::where('board_id', $boardId)->whereNotNull('archived_at');
523 - if (isset($data['searchInput'])) {
524 - $tasksQuery = $tasksQuery->where('title', 'LIKE', '%' . $data['searchInput'] . '%');
1445 +
1446 + if (!empty($data['query'])) {
1447 + $query = strtolower($data['query']);
1448 + $firstThreeChars = substr($query, 0, 3);
1449 +
1450 + if($firstThreeChars == 'id:') {
1451 + $idPart = substr($query, 3);
1452 + $idPart = preg_replace('/[^a-zA-Z0-9]/', '', $idPart);
1453 + $tasksQuery = $tasksQuery->where('id', 'LIKE', '%' . $idPart . '%');
1454 + } else {
1455 + $tasksQuery = $tasksQuery->where('title', 'LIKE', '%' . $data['query'] . '%');
1456 + }
525 1457 }
526 1458
527 - // if board_id is not passed then throw an exception
528 - if (!$boardId) {
529 - 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;
530 1466 }
531 1467
532 - 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;
533 1502 }
534 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 +
535 2001 public function sendMailAfterTaskModify($column, $assigneeIds, $taskId)
536 2002 {
2003 + if (empty($assigneeIds)) {
2004 + return;
2005 + }
2006 +
537 2007 $current_user_id = get_current_user_id();
538 2008 /* this will run in background as soon as possible */
539 2009 /* sending Model or Model Instance won't work here */
540 2010
@@ -543,14 +2013,17 @@
543 2013
544 2014 public function getStageByTask($task_id)
545 2015 {
546 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 + }
547 2020 return $task->stage;
548 2021 }
549 2022
550 - public function moveTaskToNextStage($task_id)
2023 + public function moveTaskToNextStage($task_id, $boardId = null)
551 2024 {
552 - $task = Task::findOrFail($task_id);
2025 + $task = $boardId ? $this->findTaskOnBoard($task_id, $boardId) : Task::findOrFail($task_id);
553 2026
554 2027 $oldStage = $task->stage;
555 2028
556 2029 $nextStage = Stage::where('board_id', $task->board_id)
@@ -568,8 +2041,11 @@
568 2041 $task->last_completed_at = current_time('mysql');
569 2042 }
570 2043 }
571 2044
2045 + // Clean up archived_by_stage meta when moving to different stage
2046 + $this->cleanupArchivedByStageMetaIfExists($task->id);
2047 +
572 2048 $task->stage_id = $nextStage->id;
573 2049 $task->save();
574 2050
575 2051 $task->load(['board', 'stage', 'attachments']);
@@ -592,8 +2068,9 @@
592 2068 }
593 2069
594 2070 public function getActivities($taskId, $perPage, $filter = 'newest')
595 2071 {
2072 + $perPage = Sanitize::perPage($perPage);
596 2073 $activityQuery = Activity::where('object_id', $taskId)
597 2074 ->where('object_type', Constant::ACTIVITY_TASK);
598 2075 if ($filter == 'newest') {
599 2076 $activityQuery = $activityQuery->latest();
@@ -599,27 +2076,36 @@
599 2076 $activityQuery = $activityQuery->latest();
600 2077 } else if ($filter == 'oldest') {
601 2078 $activityQuery = $activityQuery->oldest();
602 2079 }
603 - return $activityQuery->with('user')->paginate($perPage);
2080 + $activities = $activityQuery->with('user')->paginate($perPage);
2081 +
2082 + Helper::translateActivities($activities);
2083 +
2084 + return $activities;
604 2085 }
605 2086
606 - public function getLastOneMinuteUpdatedTasks($boardId, $lastUpdated = null)
2087 + public function getLastOneMinuteUpdatedTasks($boardId, $lastUpdated = null, $includeArchived = true)
607 2088 {
608 2089 if (!$lastUpdated) {
609 - $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
610 2091 }
611 2092
612 - $tasks = Task::query()
2093 + $tasksQuery = Task::query()
613 2094 ->where([
614 2095 'board_id' => $boardId,
615 2096 'parent_id' => null,
616 2097 ])
617 - ->where('updated_at', '>', $lastUpdated)
618 - ->with(['assignees', 'labels', 'watchers'])
619 - ->orderBy('due_at', 'ASC')
620 - ->get();
2098 + ->where('updated_at', '>=', $lastUpdated) // updated since the sync cursor
2099 + ->with(['assignees', 'labels', 'watchers', 'taskCustomFields'])
2100 + ->orderBy('due_at', 'ASC');
621 2101
2102 + if (!$includeArchived) {
2103 + $tasksQuery->whereNull('archived_at');
2104 + }
2105 +
2106 + $tasks = $tasksQuery->get();
2107 +
622 2108 foreach ($tasks as $task) {
623 2109 $task->isOverdue = $task->isOverdue();
624 2110 $task->isUpcoming = $task->upcoming();
625 2111 $task->is_watching = $task->isWatching();
@@ -642,72 +2128,239 @@
642 2128
643 2129 return $lastPosition + 1;
644 2130 }
645 2131
646 - 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)
647 2140 {
648 - $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()
649 2183 ->where('crm_contact_id', $associatedId)
650 - ->with(['board', 'stage', 'assignees', 'labels', 'watchers',])
651 - ->orderBy('due_at', 'ASC')
652 - ->get();
2184 + ->with(['board', 'stage', 'assignees', 'subtaskGroup', 'subtaskGroup.subtasks', 'subtaskGroup.subtasks.assignees'])
2185 + ->orderBy('due_at', 'ASC');
653 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 +
654 2203 foreach ($tasks as $task) {
655 2204 $task->isOverdue = $task->isOverdue();
656 2205 $task->isUpcoming = $task->upcoming();
657 - $task->contact = Task::lead_contact($task->crm_contact_id);
658 - $task->is_watching = $task->isWatching();
2206 + $task->can_edit = $isAdmin || in_array((int)$task->board_id, $editableBoardIds, true);
659 2207
660 2208 $task->assignees = Helper::sanitizeUserCollections($task->assignees);
661 - $task->watchers = Helper::sanitizeUserCollections($task->watchers);
662 2209
663 - $subTasks = Task::query()
664 - ->where('parent_id', $task->id)
665 - ->with(['assignees'])
666 - ->whereNull('archived_at')
667 - ->orderBy('position', 'ASC')
668 - ->get();
2210 + foreach ($task->subtaskGroup as $group) {
2211 + foreach ($group->subtasks as $subtask) {
2212 + $subtask->assignees = Helper::sanitizeUserCollections($subtask->assignees);
2213 + }
2214 + }
2215 + $task->subtask_group = $task->subtaskGroup;
2216 + }
669 2217
670 - foreach ($subTasks as $subTask) {
671 - $subTask->assignees = Helper::sanitizeUserCollections($subTask->assignees);
672 - }
2218 + return $tasks;
2219 + }
673 2220
674 - $task->subtasks = $subTasks;
2221 + public function copySubtaskGroup($task, $newTask, $subtaskGroupMap)
2222 + {
2223 + $subtaskGroups = TaskMeta::where('task_id', $task->id)->where('key', Constant::SUBTASK_GROUP_NAME)->get();
2224 + foreach ($subtaskGroups as $group) {
2225 + $newGroup = TaskMeta::create([
2226 + 'task_id' => $newTask->id,
2227 + 'key' => Constant::SUBTASK_GROUP_NAME,
2228 + 'value' => $group->value
2229 + ]);
2230 +
2231 + $subtaskGroupMap[$group->id] = $newGroup->id;
675 2232 }
676 2233
677 - return $tasks;
2234 + return $subtaskGroupMap;
678 2235 }
679 2236
680 - public function copyTasks($boardId, $stageMap, $newBoard)
2237 + public function copyTasks($boardId, $stageMap, $newBoard, $labelMap = [], $isWithTemplates = 'no', $customFieldMap = [])
681 2238 {
682 - $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();
683 2243 $taskMap = [];
684 - foreach ($allActiveTasks as $task) {
685 - $newTask = array();
686 - $newTask['title'] = $task->title;
687 - $newTask['parent_id'] = $task->parent_id ? $taskMap[$task->parent_id] : null;
688 - $newTask['description'] = $task->description;
689 - $newTask['board_id'] = $newBoard->id;
690 - $newTask['stage_id'] = $stageMap[$task->stage_id];
691 - $newTask['status'] = $task->status;
692 - $newTask['priority'] = $task->priority;
693 - $newTask['position'] = $task->position;
694 - $newTask['due_at'] = $task->due_at;
695 - $newTask = Task::create($newTask);
696 - if(!$task->parent_id){
697 - $taskMap[$task['id']] = $newTask->id;
698 - } else {
699 - $this->subtaskCountUpdate($newTask->parent_id);
2244 + $subtaskGroupMap = [];
2245 + $parentTaskCount = 0;
2246 + $attachmentFileService = new AttachmentFileService();
2247 + $dbInstance = App::getInstance('db');
2248 +
2249 + $dbInstance->beginTransaction();
2250 +
2251 + try {
2252 + foreach ($allActiveTasks as $task) {
2253 + if ($task->parent_id && empty($taskMap[$task->parent_id])) {
2254 + continue;
2255 + }
2256 +
2257 + $stageId = !empty($task->stage_id) ? (int) $task->stage_id : 0;
2258 + if (!$task->parent_id && empty($stageMap[$stageId])) {
2259 + continue;
2260 + }
2261 +
2262 + $newTask = array();
2263 + $newTask['title'] = $task->title;
2264 + $newTask['parent_id'] = $task->parent_id ? $taskMap[$task->parent_id] : null;
2265 + $newTask['description'] = DescriptionMarkdownConverter::normalize($task->description);
2266 + $newTask['board_id'] = $newBoard->id;
2267 + $newTask['stage_id'] = $stageId && isset($stageMap[$stageId]) ? $stageMap[$stageId] : null;
2268 + $newTask['status'] = $task->status;
2269 + $newTask['priority'] = $task->priority;
2270 + $newTask['position'] = $task->position;
2271 + $newTask['due_at'] = $task->due_at;
2272 + $backgroundColor = $task->settings['cover']['backgroundColor'] ?? '';
2273 + $newTask['settings'] = [
2274 + 'cover' => [
2275 + 'backgroundColor' => $backgroundColor,
2276 + ]
2277 + ];
2278 +
2279 + $newTask = Task::create($newTask);
2280 + $attachmentFileService->cloneTaskFilesToBoard($task, $newTask, $newBoard->id);
2281 +
2282 + if (!$task->parent_id) {
2283 + //group mapping
2284 + $subtaskGroupMap = $this->copySubtaskGroup($task, $newTask, $subtaskGroupMap);
2285 + } else {
2286 + $groupRelationOfTask = TaskMeta::where('key', Constant::SUBTASK_GROUP_CHILD)
2287 + ->where('task_id', $task->id)
2288 + ->first();
2289 +
2290 + if ($groupRelationOfTask && !empty($subtaskGroupMap[$groupRelationOfTask->value])) {
2291 + TaskMeta::create([
2292 + 'task_id' => $newTask->id,
2293 + 'key' => Constant::SUBTASK_GROUP_CHILD,
2294 + 'value' => $subtaskGroupMap[$groupRelationOfTask->value]
2295 + ]);
2296 + }
2297 + }
2298 +
2299 + if($isWithTemplates == 'yes') {
2300 + $isTemplate = TaskMeta::where('task_id', $task->id)
2301 + ->where('key', 'is_template')
2302 + ->first();
2303 + if($isTemplate) {
2304 + TaskMeta::create([
2305 + 'task_id' => $newTask->id,
2306 + 'key' => 'is_template',
2307 + 'value' => $isTemplate->value
2308 + ]);
2309 + }
2310 + }
2311 + $this->copyTaskCustomFields($task, $newTask, $customFieldMap);
2312 + if(!$task->parent_id){
2313 + ++$parentTaskCount;
2314 + $taskMap[$task['id']] = $newTask->id;
2315 + //duplicate labels to task
2316 + $labelIds = $task->labels->pluck('id')->toArray();
2317 + if($labelIds){
2318 + $flipLabelIds = array_flip($labelIds);
2319 + $labelsToAttach = array_intersect_key($labelMap, $flipLabelIds);
2320 +
2321 + $newTask->labels()->attach($labelsToAttach, [
2322 + 'object_type' => Constant::OBJECT_TYPE_TASK_LABEL
2323 + ]);
2324 + }
2325 + }
700 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;
701 2339 }
2340 + }
702 2341
703 - //initiate task count
704 - $totalTasks = sizeof($allActiveTasks);
705 - $board = Board::findOrFail($newBoard->id);
706 - $settings = [];
707 - $settings['tasks_count'] = $totalTasks;
708 - $board->settings = $settings;
709 - $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 + }
710 2363 }
711 2364
712 2365 private function subtaskCountUpdate($taskId){
713 2366 $parentTask = Task::findOrFail($taskId);
@@ -716,6 +2369,1228 @@
716 2369 $parentTask->settings = $settings;
717 2370 $parentTask->save();
718 2371 }
719 2372
2373 + /**
2374 + * @param $taskId
2375 + * @param $perPage
2376 + * @param $page
2377 + * @param string $filter
2378 + * @param $boardId
2379 + * @param string $feedType
2380 + * @return array
2381 + */
2382 + public function getCommentsAndActivities($taskId, $perPage, $page, string $filter = 'newest', $boardId = null, string $feedType = 'all'): array
2383 + {
2384 + $perPage = Sanitize::perPage($perPage);
2385 + $page = max(1, (int) $page);
2386 + // Fetch the task
2387 + $task = $boardId ? $this->findTaskOnBoard($taskId, $boardId) : Task::findOrFail($taskId);
2388 + $feedType = in_array($feedType, ['all', 'comments', 'activities'], true) ? $feedType : 'all';
2389 +
2390 + $direction = $filter == 'newest' ? 'desc' : 'asc';
2391 + $offset = ($page - 1) * $perPage;
2392 + // Each source needs at most offset + perPage rows to fill the requested page.
2393 + $limit = $offset + $perPage;
2394 +
2395 + $commentQuery = $task->comments();
2396 + $activityQuery = $task->activities()
2397 + ->where(function($query) {
2398 + $query->whereNotIn('column', [ 'comment', 'a reply'])
2399 + ->orWhere(function($subQuery) {
2400 + $subQuery->whereNotIn('action', ['added', 'updated']);
2401 + });
2402 + });
2403 +
2404 + $entries = [];
2405 + $total = 0;
2406 + if ($feedType !== 'activities') {
2407 + $total += (clone $commentQuery)->count();
2408 + foreach ((clone $commentQuery)->reorder('created_at', $direction)->orderBy('id', $direction)->limit($limit)->get(['id', 'created_at']) as $row) {
2409 + $entries[] = ['comment', (int) $row->id, $row->created_at];
2410 + }
2411 + }
2412 + if ($feedType !== 'comments') {
2413 + $total += (clone $activityQuery)->count();
2414 + foreach ((clone $activityQuery)->reorder('created_at', $direction)->orderBy('id', $direction)->limit($limit)->get(['id', 'created_at']) as $row) {
2415 + $entries[] = ['activity', (int) $row->id, $row->created_at];
2416 + }
2417 + }
2418 +
2419 + // Stable sort keeps comments ahead of activities on equal timestamps, as before.
2420 + $order = $direction === 'desc' ? -1 : 1;
2421 + usort($entries, function ($a, $b) use ($order) {
2422 + return $order * (strtotime($a[2]) - strtotime($b[2]));
2423 + });
2424 + $entries = array_slice($entries, $offset, $perPage);
2425 +
2426 + // Hydrate relations only for the selected page.
2427 + $selectedIds = ['comment' => [], 'activity' => []];
2428 + foreach ($entries as $entry) {
2429 + $selectedIds[$entry[0]][] = $entry[1];
2430 + }
2431 + $models = ['comment' => [], 'activity' => []];
2432 + if ($selectedIds['comment']) {
2433 + $models['comment'] = $commentQuery->with(['user', 'replies.user'])->whereIn('id', $selectedIds['comment'])->get()->keyBy('id')->all();
2434 + }
2435 + if ($selectedIds['activity']) {
2436 + $models['activity'] = $activityQuery->with('user')->whereIn('id', $selectedIds['activity'])->get()->keyBy('id')->all();
2437 + }
2438 +
2439 + $paginatedResults = [];
2440 + foreach ($entries as $entry) {
2441 + if (isset($models[$entry[0]][$entry[1]])) {
2442 + $paginatedResults[] = $models[$entry[0]][$entry[1]]->toArray();
2443 + }
2444 + }
2445 +
2446 + $lastPage = (int) ceil($total / $perPage);
2447 +
2448 + // Construct pagination metadata
2449 + $path = rest_url("fluent-boards/v2/projects/{$task->board_id}/tasks/{$task->id}/comments-and-activities");
2450 + return [
2451 + 'current_page' => (int) $page,
2452 + 'data' => $paginatedResults,
2453 + 'first_page_url' => add_query_arg('page', 1, $path),
2454 + 'from' => $total > 0 ? (int) ($offset + 1) : null,
2455 + 'last_page' => (int) $lastPage,
2456 + 'last_page_url' => add_query_arg('page', $lastPage, $path),
2457 + 'links' => [
2458 + [
2459 + 'url' => $page > 1 ? add_query_arg('page', ($page - 1), $path) : null,
2460 + 'label' => 'pagination.previous',
2461 + 'active' => false
2462 + ],
2463 + [
2464 + 'url' => add_query_arg('page', $page, $path),
2465 + 'label' => (int) $page,
2466 + 'active' => true
2467 + ],
2468 + [
2469 + 'url' => $page < $lastPage ? add_query_arg('page', ($page + 1), $path) : null,
2470 + 'label' => 'pagination.next',
2471 + 'active' => false
2472 + ]
2473 + ],
2474 + 'next_page_url' => $page < $lastPage ? add_query_arg('page', ($page + 1), $path) : null,
2475 + 'path' => $path,
2476 + 'per_page' => (int) $perPage,
2477 + 'prev_page_url' => $page > 1 ? add_query_arg('page', ($page - 1), $path) : null,
2478 + 'to' => $total > 0 ? (int) min($offset + $perPage, $total) : null,
2479 + 'total' => (int) $total
2480 + ];
2481 + }
2482 +
2483 + /**
2484 + * @param $task_id
2485 + * @param $fileData
2486 + * @param $type
2487 + * @return Attachment
2488 + */
2489 + public function uploadMediaFileFromWpEditor($task_id, $fileData, $type)
2490 + {
2491 + $initialDataData = [
2492 + 'type' => 'url',
2493 + 'url' => '',
2494 + 'name' => '',
2495 + 'size' => 0,
2496 + ];
2497 +
2498 + $attachData = array_merge($initialDataData, $fileData);
2499 + $UrlMeta = [];
2500 + if($attachData['type'] == 'url') {
2501 + $UrlMeta = RemoteUrlParser::parse($attachData['url']);
2502 + }
2503 + $attachment = new TaskImage();
2504 + $attachment->object_id = $task_id;
2505 + $attachment->object_type = $type;
2506 + $attachment->attachment_type = $attachData['type'];
2507 + $attachment->title = $this->setTitle($attachData['type'], $attachData['name'], $UrlMeta);
2508 + $attachment->file_path = $attachData['type'] != 'url' ? $attachData['file'] : null;
2509 + $attachment->full_url = esc_url($attachData['url']);
2510 + $attachment->file_size = $attachData['size'];
2511 + $attachment->settings = $attachData['type'] == 'url' ? [
2512 + 'meta' => $UrlMeta
2513 + ] : '';
2514 + $attachment->driver = 'local';
2515 + $attachment->save();
2516 + return $attachment;
2517 + }
2518 +
2519 +
2520 + /**
2521 + * @param $type
2522 + * @param $title
2523 + * @param $UrlMeta
2524 + * @return mixed|string
2525 + */
2526 + public function setTitle($type, $title, $UrlMeta)
2527 + {
2528 + if($type != 'url') {
2529 + return sanitize_file_name($title);
2530 + }
2531 + return $title ?? $UrlMeta['title'] ?? '';
2532 + }
2533 +
2534 + /** Apply missing stage assignees and report whether any assignments were added. */
2535 + public function manageDefaultAssignees($task, $stageId, ?Stage $stage = null)
2536 + {
2537 + if (!$stage || (int) $stage->id !== (int) $stageId) {
2538 + $stage = Stage::findOrFail($stageId);
2539 + }
2540 + $assignmentsChanged = false;
2541 + if ($stage && isset($stage->settings['default_task_assignees'])) {
2542 + $defaultAssignees = $stage->settings['default_task_assignees'];
2543 + foreach ($defaultAssignees as $assigneeId) {
2544 + $alreadyAssigneeIds = $task->assignees->pluck('ID')->toArray();
2545 + $IfAlreadyAssignee = in_array($assigneeId, $alreadyAssigneeIds);
2546 + if (!$IfAlreadyAssignee) {
2547 + $this->updateAssignee($assigneeId, $task);
2548 + $assignmentsChanged = true;
2549 + }
2550 + }
2551 + }
2552 + return $assignmentsChanged;
2553 + }
2554 +
2555 + public function manageDefaultWatchers($task, $stageId)
2556 + {
2557 + $stage = Stage::findOrFail($stageId);
2558 + if ($stage) {
2559 + $settings = $stage->settings;
2560 + $defaultWatchers = [];
2561 + if (isset($settings['default_task_watchers']) && is_array($settings['default_task_watchers'])) {
2562 + $defaultWatchers = $settings['default_task_watchers'];
2563 + }
2564 + if (isset($settings['default_task_assignees']) && is_array($settings['default_task_assignees'])) {
2565 + $defaultWatchers = array_unique(array_merge($defaultWatchers, $settings['default_task_assignees']));
2566 + }
2567 + foreach ($defaultWatchers as $watcherId) {
2568 + $alreadyWatcherIds = $task->watchers->pluck('ID')->toArray();
2569 + $isAlreadyWatcher = in_array($watcherId, $alreadyWatcherIds);
2570 + if (!$isAlreadyWatcher) {
2571 + $task->watchers()->syncWithoutDetaching([
2572 + $watcherId => ['object_type' => Constant::OBJECT_TYPE_USER_TASK_WATCH]
2573 + ]);
2574 + }
2575 + }
2576 + }
2577 + }
2578 +
2579 + public function setDefaultAssigneesToEveryTasks($stage)
2580 + {
2581 + $tasks = $stage->tasks->whereNull('archived_at');
2582 + foreach ($tasks as $task) {
2583 + $this->manageDefaultAssignees($task, $stage->id);
2584 + }
2585 + }
2586 +
2587 + public function createTaskFromImage($board_id, $stage_id, $uploadInfo, $file)
2588 + {
2589 +
2590 + $board = Board::find($board_id);
2591 + $stage = Stage::where('id', absint($stage_id))
2592 + ->where('board_id', absint($board_id))
2593 + ->first();
2594 +
2595 + if (!$board || !$stage) {
2596 + throw new \Exception(esc_html__('Stage not found', 'fluent-boards'));
2597 + }
2598 +
2599 + $task = new Task();
2600 + $taskType = $board->type === 'to-do' ? 'task' : 'roadmap' ;
2601 + $taskData = [
2602 + 'title' => $uploadInfo[0]['name'],
2603 + 'board_id' => $board_id,
2604 + 'stage_id' => $stage_id,
2605 + 'type' => $taskType,
2606 + ];
2607 + $task->fill($taskData);
2608 + $task->save();
2609 +
2610 + $fileData = $uploadInfo[0];
2611 + $fileUploadedData = $this->uploadMediaFileFromWpEditor($task->id, $fileData, Constant::TASK_DESCRIPTION);
2612 + if(!!defined('FLUENT_BOARDS_PRO_VERSION')) {
2613 + $mediaData = (new AttachmentService())->processMediaData($fileData, $file);
2614 + $fileUploadedData['driver'] = $mediaData['driver'];
2615 + $fileUploadedData['file_path'] = $mediaData['file_path'];
2616 + $fileUploadedData['full_url'] = $mediaData['full_url'];
2617 + $fileUploadedData->save();
2618 + }
2619 +
2620 + $settings = $task->settings;
2621 + $settings['cover'] = [
2622 + 'imageId' => $fileUploadedData['id'],
2623 + 'backgroundImage' => (new CommentService())->createPublicUrl($fileUploadedData, $board_id),
2624 + ];
2625 + $task->settings = $settings;
2626 + $task = $task->moveToNewPosition(1);
2627 + $task->save();
2628 + $task->load(['board', 'stage', 'labels', 'assignees']);
2629 +
2630 + $task->assignees = Helper::sanitizeUserCollections($task->assignees);
2631 +
2632 + $task->isOverdue = $task->isOverdue();
2633 + $task->contact = Task::lead_contact($task->crm_contact_id);
2634 + $task->board->stages = (new StageService())->stagesByBoardId($board_id);
2635 + $task->is_watching = (new NotificationService())->isCurrentUserObservingTask($task);
2636 +
2637 + $task = $this->loadNextStage($task);
2638 +
2639 + if ($task->type == 'roadmap') {
2640 + $task->vote_statistics = $this->getIdeaVoteStatistics($task->id);
2641 + }
2642 +
2643 + return $task;
2644 + }
2645 + public function deleteTaskCoverImage($settings)
2646 + {
2647 + if (isset($settings['cover']['imageId']) && $settings['cover']['imageId']) {
2648 + $image = TaskImage::find($settings['cover']['imageId']);
2649 + if ($image) {
2650 + $deletedImage = clone $image;
2651 + $deletedImage->delete();
2652 +
2653 + do_action('fluent_boards/task_attachment_deleted', $deletedImage);
2654 + }
2655 + }
2656 +
2657 + }
2658 +
2659 + private function deleteTaskAttachments($task)
2660 + {
2661 + if (!defined('FLUENT_BOARDS_PRO_VERSION')) {
2662 + return;
2663 + }
2664 +
2665 + $attachments = TaskAttachment::where('object_id', $task->id)
2666 + ->where('object_type', Constant::TASK_ATTACHMENT)
2667 + ->get();
2668 + foreach ($attachments as $attachment) {
2669 + $deletedAttachment = clone $attachment;
2670 + $attachment->delete();
2671 +
2672 + do_action('fluent_boards/task_attachment_deleted', $deletedAttachment, $task->board_id);
2673 + }
2674 + }
2675 +
2676 + /**
2677 + * Delete task attachments one at a time so each attachment-deleted hook is preserved.
2678 + *
2679 + * @param array $taskIds
2680 + * @param array $taskBoardIds
2681 + * @return void
2682 + */
2683 + private function deleteTaskAttachmentsBatch($taskIds, $taskBoardIds)
2684 + {
2685 + if (!defined('FLUENT_BOARDS_PRO_VERSION')) {
2686 + return;
2687 + }
2688 +
2689 + $attachments = TaskAttachment::whereIn('object_id', $taskIds)
2690 + ->where('object_type', Constant::TASK_ATTACHMENT)
2691 + ->get();
2692 +
2693 + foreach ($attachments as $attachment) {
2694 + $deletedAttachment = clone $attachment;
2695 + $attachment->delete();
2696 + $boardId = $taskBoardIds[(int) $attachment->object_id] ?? null;
2697 +
2698 + do_action('fluent_boards/task_attachment_deleted', $deletedAttachment, $boardId);
2699 + }
2700 + }
2701 +
2702 + public function cloneTask(int $taskId, $taskData, $boardId = null): Task
2703 + {
2704 + global $wpdb;
2705 + $attachmentFileService = new AttachmentFileService();
2706 +
2707 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Transaction control for atomic task cloning operation
2708 + $wpdb->query('START TRANSACTION');
2709 +
2710 + try {
2711 + // Load task with all necessary relationships
2712 + $taskQuery = Task::with([
2713 + 'assignees',
2714 + 'labels',
2715 + 'watchers',
2716 + ])->where('id', $taskId);
2717 +
2718 + if ($boardId) {
2719 + $taskQuery->where('board_id', absint($boardId));
2720 + }
2721 +
2722 + $task = $taskQuery->first();
2723 +
2724 + if (!$task) {
2725 + throw new \Exception(esc_html__('Task not found', 'fluent-boards'));
2726 + }
2727 +
2728 + // Create new task with cloned data
2729 + $clonedTask = $task->replicate();
2730 + /* translators: %s is the title of the task being cloned */
2731 + $clonedTask->title = $taskData['title'] ?? sprintf(\__('%s (cloned)', 'fluent-boards'), $task->title);
2732 +
2733 + $settings = $clonedTask->settings ?? [];
2734 +
2735 + unset(
2736 + $settings['attachment_count'],
2737 + $settings['subtask_completed_count'],
2738 + $settings['subtask_count']
2739 + );
2740 + $clonedTask->settings = $settings;
2741 + $clonedTask->stage_id = $taskData['stage_id'] ?? $task->stage_id;
2742 +
2743 + // Validate that target stage belongs to the same board
2744 + $targetStage = Stage::where('id', $clonedTask->stage_id)
2745 + ->where('board_id', $task->board_id)
2746 + ->first();
2747 +
2748 + if (!$targetStage) {
2749 + throw new \Exception(esc_html__('Stage not found', 'fluent-boards'));
2750 + }
2751 +
2752 + $clonedTask->board_id = $targetStage->board_id;
2753 +
2754 + $clonedTask->comments_count = 0; // Reset comments count for cloned task
2755 + $clonedTask->save();
2756 +
2757 + $positionIndex = 1; // Default position index for new task
2758 + if($task->stage_id === $clonedTask->stage_id) {
2759 + // Calculate position for the cloned task next to original task
2760 + $positionIndex = $this->calculateClonedTaskPosition($task);
2761 + }
2762 + // Move cloned task to the new position
2763 + $clonedTask->moveToNewPosition($positionIndex);
2764 +
2765 + $this->cloneTaskMeta($task, $clonedTask);
2766 +
2767 + $this->cloneTaskCustomFields($task, $clonedTask);
2768 +
2769 + // Apply stage default assignees if any are set
2770 + $this->manageDefaultAssignees($clonedTask, $clonedTask->stage_id);
2771 +
2772 + if($taskData['assignee']) {
2773 + $this->cloneAssignees($task, $clonedTask);
2774 + }
2775 + if($taskData['label']) {
2776 + $this->cloneTaskLabels($task, $clonedTask);
2777 + }
2778 + $this->cloneTaskWatchers($task, $clonedTask);
2779 +
2780 + $attachmentFileService->cloneTaskFilesToBoard($task, $clonedTask, $clonedTask->board_id, [
2781 + 'description_images' => true,
2782 + 'cover' => true,
2783 + 'task_attachments' => (bool) $taskData['attachment'],
2784 + ]);
2785 +
2786 + if(!!defined('FLUENT_BOARDS_PRO_VERSION')) {
2787 + // Clone time tracking data if Pro version is active
2788 + if ($taskData['subtask']) {
2789 + $this->cloneSubtasks($task, $clonedTask, (bool) $taskData['attachment'], $attachmentFileService);
2790 + }
2791 + }
2792 +
2793 + if($taskData['comment']) {
2794 + $this->cloneCommentsAndReplies($task, $clonedTask);
2795 + }
2796 +
2797 + // Load and prepare the cloned task for response
2798 + $clonedTask = $this->prepareClonedTaskForResponse($clonedTask);
2799 + do_action('fluent_boards/task_cloned', $task, $clonedTask);
2800 +
2801 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Transaction control for atomic task cloning operation
2802 + $wpdb->query('COMMIT');
2803 + return $clonedTask;
2804 +
2805 + } catch (\Exception $e) {
2806 + $attachmentFileService->rollbackCreatedFiles();
2807 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Transaction control for atomic task cloning operation
2808 + $wpdb->query('ROLLBACK');
2809 + throw new \Exception(
2810 + /* translators: %s is the error message */
2811 + esc_html(sprintf(\__('Failed to clone task: %s', 'fluent-boards'), $e->getMessage())),
2812 + (int) ($e->getCode() ?: 500)
2813 + );
2814 + }
2815 + }
2816 +
2817 + private function calculateClonedTaskPosition(Task $originalTask): int
2818 + {
2819 + $tasks = Task::where('stage_id', $originalTask->stage_id)
2820 + ->whereNull('archived_at')
2821 + ->orderBy('position', 'asc')
2822 + ->get();
2823 +
2824 + $index = $tasks->search(function($task) use ($originalTask) {
2825 + return $task->id === $originalTask->id;
2826 + });
2827 +
2828 + return $index !== false ? $index + 2 : 1; // Return 1-based index
2829 + }
2830 +
2831 + private function cloneTaskMeta(Task $originalTask, Task $clonedTask): void
2832 + {
2833 + $taskMetas = TaskMeta::where('task_id', $originalTask->id)
2834 + ->where('key', '!=', Constant::SUBTASK_GROUP_NAME)
2835 + ->get();
2836 + foreach ($taskMetas as $meta) {
2837 + TaskMeta::create([
2838 + 'task_id' => $clonedTask->id,
2839 + 'key' => $meta->key,
2840 + 'value' => $meta->value
2841 + ]);
2842 + }
2843 + }
2844 +
2845 + private function cloneAssignees($originalTask, $clonedTask)
2846 + {
2847 + // Clone assignees
2848 + if ($originalTask->assignees) {
2849 + foreach ($originalTask->assignees as $assignee) {
2850 + $clonedTask->assignees()->syncWithoutDetaching([$assignee->ID => ['object_type' => Constant::OBJECT_TYPE_TASK_ASSIGNEE]]);
2851 + }
2852 + }
2853 + }
2854 +
2855 + private function cloneTaskLabels(Task $originalTask, Task $clonedTask): void
2856 + {
2857 + // Clone labels
2858 + if ($originalTask->labels) {
2859 + foreach ($originalTask->labels as $label) {
2860 + $clonedTask->labels()->syncWithoutDetaching([$label->id => ['object_type' => Constant::OBJECT_TYPE_TASK_LABEL]]);
2861 + }
2862 + }
2863 + }
2864 +
2865 + private function cloneTaskWatchers(Task $originalTask, Task $clonedTask): void
2866 + {
2867 + /// Clone watchers
2868 + if ($originalTask->watchers) {
2869 + foreach ($originalTask->watchers as $watcher) {
2870 + $clonedTask->watchers()->syncWithoutDetaching([$watcher->ID => ['object_type' => Constant::OBJECT_TYPE_USER_TASK_WATCH]]);
2871 + }
2872 + }
2873 + }
2874 + private function cloneTaskCustomFields(Task $originalTask, Task $clonedTask): void
2875 + {
2876 +
2877 + // Clone custom fields
2878 + if ($originalTask->taskCustomFields) {
2879 + foreach ($originalTask->taskCustomFields as $customField) {
2880 + $clonedField = $customField->replicate();
2881 + $clonedField->object_id = $clonedTask->id;
2882 + $clonedField->save();
2883 + }
2884 + }
2885 + }
2886 + private function cloneAttachments(Task $originalTask, Task $clonedTask): void
2887 + {
2888 + $attachments = $originalTask->attachments;
2889 + foreach ($attachments as $attachment) {
2890 + $clonedAttachment = $attachment->replicate();
2891 + $clonedAttachment->object_id = $clonedTask->id;
2892 + $clonedAttachment->save();
2893 +
2894 + // If this is a cover image, update task settings
2895 + if ($attachment->type === 'cover_image') {
2896 + $settings = $clonedTask->settings;
2897 + if (isset($settings['cover_image'])) {
2898 + $settings['cover_image'] = $clonedAttachment->id;
2899 + $clonedTask->settings = $settings;
2900 + $clonedTask->save();
2901 + }
2902 + }
2903 + }
2904 + $settings = $clonedTask->settings;
2905 + $settings['attachment_count'] = $clonedTask->attachments()->count();
2906 + $clonedTask['settings'] = $settings;
2907 + $clonedTask->save();
2908 + }
2909 + private function cloneCommentsAndReplies(Task $originalTask, Task $clonedTask)
2910 + {
2911 + // Get comments ordered by created_at
2912 + $comments = Comment::where('task_id', $originalTask->id)
2913 + ->where('type', 'comment')
2914 + ->whereNull('parent_id')
2915 + ->orderBy('created_at', 'asc')
2916 + ->get();
2917 +
2918 + if ($comments->isEmpty()) {
2919 + return;
2920 + }
2921 +
2922 + foreach ($comments as $comment) {
2923 + $clonedComment = $comment->replicate();
2924 + $clonedComment->task_id = $clonedTask->id;
2925 + $clonedComment->save();
2926 +
2927 + // Get replies ordered by created_at
2928 + $replies = Comment::where('parent_id', $comment->id)
2929 + ->where('type', 'reply')
2930 + ->orderBy('created_at', 'asc')
2931 + ->get();
2932 +
2933 + foreach ($replies as $reply) {
2934 + $clonedReply = $reply->replicate();
2935 + $clonedReply->task_id = $clonedTask->id;
2936 + $clonedReply->parent_id = $clonedComment->id;
2937 + $clonedReply->save();
2938 +
2939 + // Clone reply image if any
2940 + $this->cloneCommentOrReplyImage($reply, $clonedReply);
2941 + }
2942 +
2943 + // Clone comment image if any
2944 + $this->cloneCommentOrReplyImage($comment, $clonedComment);
2945 + }
2946 + return;
2947 + }
2948 + private function cloneCommentOrReplyImage($oldCommentOrReply, $clonedCommentOrReply)
2949 + {
2950 + $images = CommentImage::where('object_id', $oldCommentOrReply->id)
2951 + ->where('object_type', Constant::COMMENT_IMAGE)
2952 + ->orderBy('created_at', 'asc')
2953 + ->get();
2954 +
2955 + if ($images->count() > 0) {
2956 + foreach ($images as $image) {
2957 + $clonedImage = $image->replicate();
2958 + $clonedImage->object_id = $clonedCommentOrReply->id;
2959 + (new CommentService())->applyCommentImageScope(
2960 + $clonedImage,
2961 + $clonedCommentOrReply->board_id,
2962 + $clonedCommentOrReply->task_id,
2963 + $clonedCommentOrReply->created_by
2964 + );
2965 + $clonedImage->save();
2966 + }
2967 + }
2968 + }
2969 + private function cloneSubtasks(Task $originalTask, Task $clonedTask, bool $cloneAttachments = false, ?AttachmentFileService $attachmentFileService = null): void
2970 + {
2971 + // First clone subtask groups
2972 + $subtaskGroupMap = $this->cloneSubtaskGroups($originalTask, $clonedTask);
2973 + $completedSubtasksCount = 0;
2974 +
2975 + if ($originalTask->subtasks) {
2976 + foreach ($originalTask->subtasks as $subtask) {
2977 + $clonedSubtask = $subtask->replicate();
2978 + $clonedSubtask->parent_id = $clonedTask->id;
2979 + $clonedSubtask->board_id = $clonedTask->board_id; // Ensure subtask has same board_id as parent
2980 + $clonedSubtask->save();
2981 + $attachmentFileService = $attachmentFileService ?: new AttachmentFileService();
2982 + $attachmentFileService->cloneTaskFilesToBoard($subtask, $clonedSubtask, $clonedTask->board_id, [
2983 + 'description_images' => true,
2984 + 'cover' => true,
2985 + 'task_attachments' => $cloneAttachments,
2986 + ]);
2987 + if($clonedSubtask->status == 'closed') {
2988 + $completedSubtasksCount++;
2989 + }
2990 +
2991 + // Update subtask group relationship if exists
2992 + $groupRelation = TaskMeta::where('task_id', $subtask->id)
2993 + ->where('key', Constant::SUBTASK_GROUP_CHILD)
2994 + ->first();
2995 +
2996 + if ($groupRelation && isset($subtaskGroupMap[$groupRelation->value])) {
2997 + TaskMeta::create([
2998 + 'task_id' => $clonedSubtask->id,
2999 + 'key' => Constant::SUBTASK_GROUP_CHILD,
3000 + 'value' => $subtaskGroupMap[$groupRelation->value]
3001 + ]);
3002 + }
3003 + }
3004 + }
3005 + $settings = $clonedTask->settings;
3006 + $settings['subtask_count'] = $clonedTask->subtasks()->count();
3007 + $clonedTask['settings'] = $settings;
3008 + $clonedTask->settings['subtask_completed_count'] = $completedSubtasksCount;
3009 + $clonedTask->save();
3010 + }
3011 + private function cloneSubtaskGroups(Task $originalTask, Task $clonedTask): array
3012 + {
3013 + $subtaskGroupMap = [];
3014 +
3015 + if ($originalTask->subtaskGroup) {
3016 + foreach ($originalTask->subtaskGroup as $group) {
3017 + $clonedGroup = TaskMeta::create([
3018 + 'task_id' => $clonedTask->id,
3019 + 'key' => Constant::SUBTASK_GROUP_NAME,
3020 + 'value' => $group->value
3021 + ]);
3022 +
3023 + $subtaskGroupMap[$group->id] = $clonedGroup->id;
3024 + }
3025 + }
3026 +
3027 + return $subtaskGroupMap;
3028 + }
3029 + private function prepareClonedTaskForResponse(Task $clonedTask): Task
3030 + {
3031 + // Load relationships
3032 + $clonedTask->load(['board', 'stage', 'labels', 'assignees', 'subtasks']);
3033 +
3034 + // Sanitize assignees
3035 + $clonedTask->assignees = Helper::sanitizeUserCollections($clonedTask->assignees);
3036 +
3037 + // Set additional properties
3038 + $clonedTask->isOverdue = $clonedTask->isOverdue();
3039 + $clonedTask->contact = Task::lead_contact($clonedTask->crm_contact_id);
3040 + $clonedTask->board->stages = (new StageService())->stagesByBoardId($clonedTask->board_id);
3041 + $clonedTask->is_watching = (new NotificationService())->isCurrentUserObservingTask($clonedTask);
3042 +
3043 + // Load next stage if applicable
3044 + return $this->loadNextStage($clonedTask);
3045 + }
3046 +
3047 + /**
3048 + * Clean up archived_by_stage metadata if it exists for a task
3049 + *
3050 + * @param int $taskId
3051 + * @return void
3052 + */
3053 + private function cleanupArchivedByStageMetaIfExists($taskId)
3054 + {
3055 + TaskMeta::where('task_id', $taskId)
3056 + ->where('key', Constant::META_KEY_ARCHIVED_BY_STAGE)
3057 + ->delete();
3058 + }
3059 +
3060 + /**
3061 + * Handle bulk actions for multiple tasks
3062 + *
3063 + * @param array $taskIds
3064 + * @param string $action
3065 + * @param array $params
3066 + * @param int $boardId
3067 + * @return array
3068 + * @throws \Exception
3069 + */
3070 + public function bulkActions($taskIds, $action, $params, $boardId)
3071 + {
3072 + if (empty($taskIds) || !is_array($taskIds)) {
3073 + throw new \Exception(esc_html__('No tasks selected', 'fluent-boards'));
3074 + }
3075 +
3076 + if (count($taskIds) > 150) {
3077 + throw new \Exception(esc_html__('Cannot process more than 150 tasks at once. Please select fewer tasks.', 'fluent-boards'));
3078 + }
3079 +
3080 + if (empty($action)) {
3081 + throw new \Exception(esc_html__('No action specified', 'fluent-boards'));
3082 + }
3083 +
3084 + $tasks = Task::whereIn('id', $taskIds)
3085 + ->where('board_id', $boardId)
3086 + ->get();
3087 +
3088 + if ($tasks->isEmpty()) {
3089 + throw new \Exception(esc_html__('No valid tasks found', 'fluent-boards'));
3090 + }
3091 +
3092 + $result = [
3093 + 'successful_tasks' => [],
3094 + 'failed_tasks' => [],
3095 + 'message' => ''
3096 + ];
3097 +
3098 + switch ($action) {
3099 + case 'move_tasks':
3100 + $result = $this->bulkMoveTasks($tasks, $params, $boardId);
3101 + break;
3102 +
3103 + case 'move_to_stage':
3104 + // Backward compatibility - redirect to move_tasks
3105 + $result = $this->bulkMoveTasks($tasks, $params, $boardId);
3106 + break;
3107 +
3108 + case 'archive_tasks':
3109 + $result = $this->bulkArchiveTasks($tasks);
3110 + break;
3111 +
3112 + case 'change_priority':
3113 + $result = $this->bulkChangePriority($tasks, $params);
3114 + break;
3115 +
3116 + case 'assign_members':
3117 + $result = $this->bulkAssignMembers($tasks, $params, $boardId);
3118 + break;
3119 +
3120 + case 'add_labels':
3121 + $result = $this->bulkAddLabels($tasks, $params, $boardId);
3122 + break;
3123 +
3124 + default:
3125 + throw new \Exception(esc_html__('Invalid action specified', 'fluent-boards'));
3126 + }
3127 +
3128 + // Dispatch WordPress action for other plugins to hook into
3129 + do_action('fluent_boards/bulk_action_completed', $action, $tasks, $boardId);
3130 +
3131 + return $result;
3132 + }
3133 +
3134 + /**
3135 + * Bulk move tasks to a stage (same board) or to another board
3136 + * Unified method that handles both same-board stage moves and cross-board moves
3137 + */
3138 + private function bulkMoveTasks($tasks, $params, $sourceBoardId)
3139 + {
3140 + $targetStageId = $params['target_stage_id'] ?? null;
3141 + if (!$targetStageId) {
3142 + throw new \Exception(esc_html__('Target stage ID is required', 'fluent-boards'));
3143 + }
3144 +
3145 + $targetBoardId = $params['target_board_id'] ?? null;
3146 + $isMovingToAnotherBoard = $targetBoardId && $targetBoardId != $sourceBoardId;
3147 +
3148 + // Determine effective target board ID
3149 + $effectiveTargetBoardId = $isMovingToAnotherBoard ? $targetBoardId : $sourceBoardId;
3150 +
3151 + // Validate target board exists and is not archived
3152 + $targetBoard = Board::find($effectiveTargetBoardId);
3153 + if (!$targetBoard) {
3154 + throw new \Exception(esc_html__('Target board not found', 'fluent-boards'));
3155 + }
3156 +
3157 + if ($targetBoard->archived_at) {
3158 + throw new \Exception(esc_html__('Cannot move tasks to an archived board', 'fluent-boards'));
3159 + }
3160 +
3161 + // Verify user has write access to target board if moving to different board
3162 + if ($isMovingToAnotherBoard && !PermissionManager::userHasBoardPermission($effectiveTargetBoardId, 'POST')) {
3163 + throw new \Exception(esc_html__('You do not have permission to add tasks to this board', 'fluent-boards'));
3164 + }
3165 +
3166 + // Validate target stage exists and belongs to target board
3167 + $targetStage = Stage::where('id', $targetStageId)
3168 + ->where('board_id', $effectiveTargetBoardId)
3169 + ->first();
3170 +
3171 + if (!$targetStage) {
3172 + throw new \Exception(esc_html__('Target stage not found in the selected board', 'fluent-boards'));
3173 + }
3174 +
3175 + $successfulTasks = [];
3176 +
3177 + foreach ($tasks as $task) {
3178 + $oldStageId = $task->stage_id;
3179 + if ($isMovingToAnotherBoard) {
3180 + // Cross-board move - use existing method for data cleanup and security
3181 + $task = $this->changeBoardByTask($task, $effectiveTargetBoardId);
3182 + $task->stage_id = $targetStageId;
3183 + $task = $task->moveToNewPosition(null);
3184 + } else {
3185 + $task->stage_id = $targetStageId;
3186 + $task = $task->moveToNewPosition(1);
3187 + }
3188 +
3189 + // Board moves detach relations; listeners must see the destination state.
3190 + $task->load(['labels', 'assignees', 'board', 'stage', 'watchers', 'taskCustomFields']);
3191 +
3192 + if ($oldStageId != $targetStageId) {
3193 + $this->manageDefaultAssignees($task, $targetStageId);
3194 + if ($targetStage->defaultTaskStatus() == 'closed' && $task->status != 'closed') {
3195 + $task = $task->close();
3196 + }
3197 +
3198 + // Default assignees may also become watchers.
3199 + $task->load(['assignees', 'watchers']);
3200 + do_action('fluent_boards/task_stage_updated', $task, $oldStageId);
3201 +
3202 + $usersToSendEmail = (new NotificationService())->filterAssigneeToSendEmail($task->id, Constant::BOARD_EMAIL_STAGE_CHANGE);
3203 + $this->sendMailAfterTaskModify('stage_change', $usersToSendEmail, $task->id);
3204 + }
3205 +
3206 + $successfulTasks[] = $task;
3207 + }
3208 +
3209 + $successCount = count($successfulTasks);
3210 +
3211 + if ($isMovingToAnotherBoard) {
3212 + // translators: %d is the number of tasks successfully moved to another board
3213 + $message = sprintf(__('%d tasks moved to new board successfully', 'fluent-boards'), $successCount);
3214 + } else {
3215 + // translators: %d is the number of tasks successfully moved to the stage
3216 + $message = sprintf(__('%d tasks moved to stage successfully', 'fluent-boards'), $successCount);
3217 + }
3218 +
3219 + return [
3220 + 'successful_tasks' => $successfulTasks,
3221 + 'failed_tasks' => [],
3222 + 'message' => $message,
3223 + 'moved_to_another_board' => $isMovingToAnotherBoard
3224 + ];
3225 + }
3226 +
3227 + /**
3228 + * Legacy method - kept for backward compatibility
3229 + * @deprecated Use bulkMoveTasks instead
3230 + */
3231 + private function bulkMoveToStage($tasks, $params, $boardId)
3232 + {
3233 + return $this->bulkMoveTasks($tasks, $params, $boardId);
3234 + }
3235 +
3236 + /**
3237 + * Bulk archive tasks
3238 + */
3239 + private function bulkArchiveTasks($tasks)
3240 + {
3241 + $successfulTasks = [];
3242 + $failedTasks = [];
3243 +
3244 + foreach ($tasks as $task) {
3245 + try {
3246 + // Use the same logic as single task archiving
3247 + $this->updateTaskProperty('archived_at', current_time('mysql'), $task);
3248 +
3249 + // Reload task with all relationships
3250 + $task->load(['labels', 'assignees', 'board', 'stage', 'watchers', 'taskCustomFields']);
3251 +
3252 + $successfulTasks[] = $task;
3253 + } catch (\Exception $e) {
3254 + $failedTasks[] = [
3255 + 'id' => $task->id,
3256 + 'title' => $task->title,
3257 + 'error' => $e->getMessage()
3258 + ];
3259 + }
3260 + }
3261 +
3262 + $successCount = count($successfulTasks);
3263 + $failureCount = count($failedTasks);
3264 +
3265 + $message = '';
3266 + if ($failureCount === 0) {
3267 + // translators: %d is the number of tasks archived successfully
3268 + $message = sprintf(__('%d tasks archived successfully', 'fluent-boards'), $successCount);
3269 + } elseif ($successCount === 0) {
3270 + // translators: %d is the number of tasks that failed to archive
3271 + $message = sprintf(__('Failed to archive %d tasks', 'fluent-boards'), $failureCount);
3272 + } else {
3273 + // translators: 1: number of tasks archived successfully; 2: number of tasks failed to archive
3274 + $message = sprintf(__('%1$d tasks archived successfully, %2$d failed', 'fluent-boards'), $successCount, $failureCount);
3275 + }
3276 +
3277 + return [
3278 + 'successful_tasks' => $successfulTasks,
3279 + 'failed_tasks' => $failedTasks,
3280 + 'message' => $message
3281 + ];
3282 + }
3283 +
3284 + /**
3285 + * Bulk change task priority
3286 + */
3287 + private function bulkChangePriority($tasks, $params)
3288 + {
3289 + $priority = $params['priority'] ?? null;
3290 +
3291 + // Get valid priorities including custom ones added by hooks
3292 + $validPriorities = array_keys(apply_filters('fluent_boards/task_priorities', [
3293 + '' => __('No priority', 'fluent-boards'),
3294 + 'urgent' => __('Urgent', 'fluent-boards'),
3295 + 'high' => __('High', 'fluent-boards'),
3296 + 'medium' => __('Medium', 'fluent-boards'),
3297 + 'low' => __('Low', 'fluent-boards')
3298 + ]));
3299 +
3300 + if (!in_array($priority, $validPriorities, true)) {
3301 + throw new \Exception(esc_html__('Invalid priority level', 'fluent-boards'));
3302 + }
3303 +
3304 + $successfulTasks = [];
3305 + $failedTasks = [];
3306 +
3307 + foreach ($tasks as $task) {
3308 + try {
3309 + // Use the same logic as single task priority update
3310 + $this->updateTaskProperty('priority', $priority, $task);
3311 +
3312 + // Reload task with all relationships
3313 + $task->load(['labels', 'assignees', 'board', 'stage', 'watchers', 'taskCustomFields']);
3314 +
3315 + $successfulTasks[] = $task;
3316 + } catch (\Exception $e) {
3317 + $failedTasks[] = [
3318 + 'id' => $task->id,
3319 + 'title' => $task->title,
3320 + 'error' => $e->getMessage()
3321 + ];
3322 + }
3323 + }
3324 +
3325 + $successCount = count($successfulTasks);
3326 + $failureCount = count($failedTasks);
3327 +
3328 + $message = '';
3329 + if ($failureCount === 0) {
3330 + // translators: %d is the number of tasks whose priorities were updated successfully
3331 + $message = sprintf(__('%d task priorities updated successfully', 'fluent-boards'), $successCount);
3332 + } elseif ($successCount === 0) {
3333 + // translators: %d is the number of tasks whose priorities failed to update
3334 + $message = sprintf(__('Failed to update %d task priorities', 'fluent-boards'), $failureCount);
3335 + } else {
3336 + // translators: 1: number of tasks priorities updated; 2: number of tasks priorities failed to update
3337 + $message = sprintf(__('%1$d task priorities updated successfully, %2$d failed', 'fluent-boards'), $successCount, $failureCount);
3338 + }
3339 +
3340 + return [
3341 + 'successful_tasks' => $successfulTasks,
3342 + 'failed_tasks' => $failedTasks,
3343 + 'message' => $message
3344 + ];
3345 + }
3346 +
3347 + /**
3348 + * Bulk assign members to tasks
3349 + */
3350 + private function bulkAssignMembers($tasks, $params, $boardId)
3351 + {
3352 + $userIds = $params['user_ids'] ?? [];
3353 + if (!is_array($userIds)) {
3354 + throw new \Exception(esc_html__('User IDs must be an array', 'fluent-boards'));
3355 + }
3356 +
3357 + // Validate that all users are valid WordPress users
3358 + $validUsers = get_users(['include' => $userIds]);
3359 + $validUserIds = array_map(function($user) {
3360 + return $user->ID;
3361 + }, $validUsers);
3362 +
3363 + if (count($validUserIds) !== count($userIds)) {
3364 + throw new \Exception(esc_html__('Some user IDs are invalid', 'fluent-boards'));
3365 + }
3366 +
3367 + // Filter only users who are already board members (skip non-members)
3368 + $boardService = new \FluentBoards\App\Services\BoardService();
3369 + $boardMemberIds = [];
3370 + foreach ($validUserIds as $userId) {
3371 + if ($boardService->isAlreadyMember($boardId, $userId)) {
3372 + $boardMemberIds[] = $userId;
3373 + }
3374 + }
3375 +
3376 + // If no valid board members, skip assignment silently
3377 + if (empty($boardMemberIds)) {
3378 + return [
3379 + 'successful_tasks' => [],
3380 + 'failed_tasks' => [],
3381 + 'message' => __('No valid board members selected for assignment', 'fluent-boards')
3382 + ];
3383 + }
3384 +
3385 + // Use only board members for assignment
3386 + $validUserIds = $boardMemberIds;
3387 +
3388 + $successfulTasks = [];
3389 + $failedTasks = [];
3390 +
3391 + foreach ($tasks as $task) {
3392 + try {
3393 + // Use pure "add-only" logic for bulk assignment - never remove existing assignees
3394 + $currentAssigneeIds = $task->assignees->pluck('ID')->toArray();
3395 + $newAssignees = [];
3396 +
3397 + foreach ($validUserIds as $userId) {
3398 + // Only add if not already assigned
3399 + if (!in_array($userId, $currentAssigneeIds)) {
3400 + $newAssignees[] = $userId;
3401 + }
3402 + }
3403 +
3404 + // Add all new assignees at once
3405 + if (!empty($newAssignees)) {
3406 + $assigneeData = [];
3407 + foreach ($newAssignees as $userId) {
3408 + $assigneeData[$userId] = ['object_type' => Constant::OBJECT_TYPE_TASK_ASSIGNEE];
3409 + }
3410 + $task->assignees()->syncWithoutDetaching($assigneeData);
3411 +
3412 + // Add as watchers
3413 + $watcherData = [];
3414 + foreach ($newAssignees as $userId) {
3415 + $watcherData[$userId] = ['object_type' => Constant::OBJECT_TYPE_USER_TASK_WATCH];
3416 + }
3417 + $task->watchers()->syncWithoutDetaching($watcherData);
3418 +
3419 + // Send notifications and actions only for new assignees
3420 + foreach ($newAssignees as $userId) {
3421 + // Send email notification if enabled and not current user
3422 + if ((new \FluentBoards\App\Services\NotificationService())->checkIfEmailEnable($userId, Constant::BOARD_EMAIL_TASK_ASSIGN, $task->board_id) && $userId != get_current_user_id()) {
3423 + $this->sendMailAfterTaskModify('add_assignee', $userId, $task->id);
3424 + }
3425 +
3426 + // Dispatch WordPress actions
3427 + //currently commented, need to check in future for bulk action
3428 +// do_action('fluent_boards/task_assignee_added', $task, $userId);
3429 +// if ($userId != get_current_user_id()) {
3430 +// do_action('fluent_boards/assign_another_user', $task, $userId);
3431 +// }
3432 + }
3433 + }
3434 +
3435 + // Update task timestamp and reload all relationships
3436 + $task->updated_at = current_time('mysql');
3437 + $task->save();
3438 + $task->load(['labels', 'assignees', 'board', 'stage', 'watchers', 'taskCustomFields']);
3439 +
3440 + $successfulTasks[] = $task;
3441 + } catch (\Exception $e) {
3442 + $failedTasks[] = [
3443 + 'id' => $task->id,
3444 + 'title' => $task->title,
3445 + 'error' => $e->getMessage()
3446 + ];
3447 + }
3448 + }
3449 +
3450 + $successCount = count($successfulTasks);
3451 + $failureCount = count($failedTasks);
3452 +
3453 + $message = '';
3454 + if ($failureCount === 0) {
3455 + // translators: %d is the number of tasks where members were assigned successfully
3456 + $message = sprintf(__('%d tasks assigned members successfully', 'fluent-boards'), $successCount);
3457 + } elseif ($successCount === 0) {
3458 + // translators: %d is the number of tasks where assigning members failed
3459 + $message = sprintf(__('Failed to assign members to %d tasks', 'fluent-boards'), $failureCount);
3460 + } else {
3461 + // translators: 1: number of tasks with members assigned successfully; 2: number of tasks where assigning members failed
3462 + $message = sprintf(__('%1$d tasks assigned members successfully, %2$d failed', 'fluent-boards'), $successCount, $failureCount);
3463 + }
3464 +
3465 + return [
3466 + 'successful_tasks' => $successfulTasks,
3467 + 'failed_tasks' => $failedTasks,
3468 + 'message' => $message
3469 + ];
3470 + }
3471 +
3472 + /**
3473 + * Bulk add labels to tasks
3474 + */
3475 + private function bulkAddLabels($tasks, $params, $boardId)
3476 + {
3477 + $labelIds = $params['label_ids'] ?? [];
3478 + if (!is_array($labelIds)) {
3479 + throw new \Exception(esc_html__('Label IDs must be an array', 'fluent-boards'));
3480 + }
3481 +
3482 + // Validate that all labels exist and belong to the board
3483 + $validLabels = \FluentBoards\App\Models\Label::whereIn('id', $labelIds)
3484 + ->where('board_id', $boardId)
3485 + ->whereNull('archived_at')
3486 + ->get();
3487 +
3488 + if (count($validLabels) !== count($labelIds)) {
3489 + throw new \Exception(esc_html__('Some label IDs are invalid or do not belong to this board', 'fluent-boards'));
3490 + }
3491 +
3492 + $successfulTasks = [];
3493 + $failedTasks = [];
3494 +
3495 + foreach ($tasks as $task) {
3496 + try {
3497 + // Load existing labels first to avoid query issues
3498 + $task->load('labels');
3499 + $existingLabelIds = $task->labels->pluck('id')->toArray();
3500 +
3501 + // Use the same logic as single task label adding
3502 + foreach ($validLabels as $label) {
3503 + // Check if label is already attached
3504 + if (!in_array($label->id, $existingLabelIds)) {
3505 + // Add the label using syncWithoutDetaching to avoid duplicates
3506 + $task->labels()->syncWithoutDetaching([
3507 + $label->id => ['object_type' => Constant::OBJECT_TYPE_TASK_LABEL]
3508 + ]);
3509 +
3510 + // Dispatch WordPress action for label addition
3511 + //currently commented, need to check in future for bulk action
3512 +// do_action('fluent_boards/task_label', $task, $label, 'added');
3513 + }
3514 + }
3515 +
3516 + // Reload the task with all relationships
3517 + $task->load(['labels', 'assignees', 'board', 'stage', 'watchers', 'taskCustomFields']);
3518 +
3519 + $successfulTasks[] = $task;
3520 + } catch (\Exception $e) {
3521 + $failedTasks[] = [
3522 + 'id' => $task->id,
3523 + 'title' => $task->title,
3524 + 'error' => $e->getMessage()
3525 + ];
3526 + }
3527 + }
3528 +
3529 + $successCount = count($successfulTasks);
3530 + $failureCount = count($failedTasks);
3531 +
3532 + $message = '';
3533 + if ($failureCount === 0) {
3534 + // translators: %d is the number of tasks labeled successfully
3535 + $message = sprintf(__('%d tasks labeled successfully', 'fluent-boards'), $successCount);
3536 + } elseif ($successCount === 0) {
3537 + // translators: %d is the number of tasks that failed to label
3538 + $message = sprintf(__('Failed to label %d tasks', 'fluent-boards'), $failureCount);
3539 + } else {
3540 + // translators: 1: number of tasks labeled successfully; 2: number of tasks failed to label
3541 + $message = sprintf(__('%1$d tasks labeled successfully, %2$d failed', 'fluent-boards'), $successCount, $failureCount);
3542 + }
3543 +
3544 + return [
3545 + 'successful_tasks' => $successfulTasks,
3546 + 'failed_tasks' => $failedTasks,
3547 + 'message' => $message
3548 + ];
3549 + }
3550 +
3551 + /**
3552 + * Delete time tracking records for one or multiple tasks when the table exists.
3553 + *
3554 + * @param int|array $taskIds Single task ID or array of task IDs.
3555 + * @param bool $suppressErrors Whether cleanup failures should be ignored.
3556 + * @return void
3557 + */
3558 + public function deleteTimeTrackingRecords($taskIds, $suppressErrors = true)
3559 + {
3560 + if (!class_exists(TimeTrack::class)) {
3561 + return;
3562 + }
3563 +
3564 + try {
3565 + // Disabled modules still need cleanup, but never-enabled modules may have no table.
3566 + if (method_exists(TimeTrackingHelper::class, 'timeTrackingTableExists')) {
3567 + $tableExists = TimeTrackingHelper::timeTrackingTableExists();
3568 + } else {
3569 + // Older Pro versions do not provide the table-existence helper.
3570 + global $wpdb;
3571 + $table = $wpdb->prefix . (new TimeTrack())->getTable();
3572 + $tableExists = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table))) === $table;
3573 + }
3574 +
3575 + if (!$tableExists) {
3576 + return;
3577 + }
3578 +
3579 + // Handle single task ID or array of task IDs
3580 + if (is_array($taskIds)) {
3581 + if (!empty($taskIds)) {
3582 + TimeTrack::whereIn('task_id', $taskIds)->delete();
3583 + }
3584 + } else {
3585 + if (is_numeric($taskIds) && $taskIds > 0) {
3586 + TimeTrack::where('task_id', (int) $taskIds)->delete();
3587 + }
3588 + }
3589 + } catch (\Throwable $e) {
3590 + if (!$suppressErrors) {
3591 + throw $e;
3592 + }
3593 + }
3594 + }
720 3595
721 3596 }