PluginProbe
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration / 2.2.0
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration v2.2.0
2.2.1 2.2.0 2.1.0 2.0.15 2.0.12 2.0.10 2.0.4 2.0.1 2.0.0 1.95.3 1.95.2 1.95 1.91.6 trunk 1.11 1.12 1.13 1.20 1.21 1.22 1.23 1.30 1.31 1.32 1.35 All 44 releases
← All changes | app/Services/TaskService.php +2939 -265 1.35 → 2.2.0 View file →
@@ -1,42 +1,180 @@
1 1 <?php
2 2
3 3 namespace FluentBoards\App\Services;
4 4
5 +use FluentBoards\Framework\Database\Orm\ModelNotFoundException;
6 +use FluentBoards\App\App;
7 +use FluentBoards\App\Hooks\Handlers\FileHandler;
5 8 use FluentBoards\App\Models\Attachment;
6 9 use FluentBoards\App\Models\Comment;
10 +use FluentBoards\App\Models\Notification;
7 11 use FluentBoards\App\Models\NotificationUser;
8 12 use FluentBoards\App\Models\TaskImage;
9 13 use FluentBoards\App\Services\Constant;
14 +use FluentBoards\App\Models\Label;
10 15 use FluentBoards\App\Models\Stage;
11 16 use FluentBoards\App\Models\Task;
12 17 use FluentBoards\App\Models\Board;
13 18 use FluentBoards\App\Models\TaskMeta;
19 +use FluentBoards\App\Models\Meta;
14 20 use FluentBoards\App\Models\Activity;
15 -use FluentBoards\App\Models\BoardTerm;
21 +use FluentBoards\App\Models\CommentImage;
22 +use FluentBoards\App\Models\Relation;
16 23 use FluentBoards\Framework\Support\Arr;
24 +use FluentBoardsPro\App\Models\TaskAttachment;
25 +use FluentBoardsPro\App\Modules\TimeTracking\Model\TimeTrack;
17 26 use FluentBoardsPro\App\Modules\TimeTracking\TimeTrackingHelper;
18 -use FluentBoardsPro\App\Services\ProTaskService;
27 +use FluentBoardsPro\App\Services\AttachmentService;
19 28 use FluentBoardsPro\App\Services\RemoteUrlParser;
20 29 use FluentRoadmap\App\Models\IdeaReaction;
21 30
22 31 class TaskService
23 32 {
33 + private static $physicalTableNameCache = [];
34 +
35 + /** Load appended task metadata in two queries without exposing internal relation rows. */
36 + public function loadTaskListMetadata($tasks)
37 + {
38 + $items = [];
39 + foreach ($tasks as $task) {
40 + $items[] = $task;
41 + }
42 +
43 + $collection = (new Task())->newCollection($items);
44 + $collection->load(['taskMeta', 'repeatTaskMeta']);
45 +
46 + foreach ($collection as $task) {
47 + // Keep the existing meta/repeat_task_meta API fields, not duplicate raw relations.
48 + $task->makeHidden(['taskMeta', 'repeatTaskMeta']);
49 + }
50 + }
51 +
52 + /**
53 + * Preserve the original CRM template copy when Pro lacks automation-context support.
54 + * Copies parent data, assignees and labels; full related-data copying requires updated Pro.
55 + * Context is provided by the automation handler, not directly from an HTTP request.
56 + */
57 + public function createAutomationTaskFromLegacyTemplate(Task $template, array $data, array $context): Task
58 + {
59 + $stage = Stage::where('id', absint($data['stage_id']))
60 + ->where('board_id', absint($data['board_id']))->firstOrFail();
61 + $task = new Task();
62 + $task->fill($template->toArray());
63 + $task->title = sanitize_text_field($data['title']);
64 + $task->board_id = $stage->board_id;
65 + $task->stage_id = $stage->id;
66 + $task->parent_id = null;
67 + $task->created_by = absint($context['created_by']);
68 + $task->crm_contact_id = absint($context['crm_contact_id']);
69 + $task->source = sanitize_key($context['source']);
70 + $task->comments_count = 0;
71 + $settings = $task->settings ?: [];
72 + $settings['subtask_count'] = 0;
73 + $settings['subtask_completed_count'] = 0;
74 + $task->settings = $settings;
75 + $task->moveToNewPosition(1);
76 + $task->save();
77 +
78 + $assigneeIds = array_key_exists('assignees', $context)
79 + ? $context['assignees']
80 + : $template->assignees()->get()->pluck('ID')->toArray();
81 + foreach (array_unique(array_filter(array_map('absint', (array) $assigneeIds))) as $assigneeId) {
82 + $this->updateAssignee($assigneeId, $task);
83 + }
84 + foreach ($template->labels as $label) {
85 + $task->labels()->syncWithoutDetaching([$label->id => ['object_type' => Constant::OBJECT_TYPE_TASK_LABEL]]);
86 + }
87 +
88 + return $task;
89 + }
90 +
91 + /**
92 + * Resolve a task only when it belongs to the requested board.
93 + *
94 + * Subtasks normally carry the same board_id as their parent, but the parent
95 + * fallback protects older data where that relationship may be incomplete.
96 + * Missing or mismatched tasks use the router's deliberate 404 response.
97 + *
98 + * @param int $taskId
99 + * @param int $boardId
100 + * @param bool $allowParentFallback
101 + * @return Task
102 + * @throws ModelNotFoundException
103 + */
104 + public function findTaskOnBoard($taskId, $boardId, $allowParentFallback = true)
105 + {
106 + $taskId = absint($taskId);
107 + $boardId = absint($boardId);
108 +
109 + if (!$taskId || !$boardId) {
110 + throw new ModelNotFoundException(esc_html__('Task not found', 'fluent-boards'));
111 + }
112 +
113 + $task = Task::where('id', $taskId)
114 + ->where('board_id', $boardId)
115 + ->first();
116 +
117 + if ($task) {
118 + return $this->normalizeTaskDescriptionForEditor($task);
119 + }
120 +
121 + if ($allowParentFallback) {
122 + $task = Task::where('id', $taskId)
123 + ->whereNull('board_id')
124 + ->whereNotNull('parent_id')
125 + ->first();
126 +
127 + if ($task) {
128 + $parentBoardId = Task::where('id', $task->parent_id)->value('board_id');
129 +
130 + if ((int) $parentBoardId === $boardId) {
131 + return $this->normalizeTaskDescriptionForEditor($task);
132 + }
133 + }
134 + }
135 +
136 + throw new ModelNotFoundException(esc_html__('Task not found', 'fluent-boards'));
137 + }
138 +
139 + private function normalizeTaskDescriptionForEditor(Task $task)
140 + {
141 + $task->description = DescriptionMarkdownConverter::normalize($task->description);
142 +
143 + return $task;
144 + }
145 +
146 + /**
147 + * Create a board task and notify eligible selected assignees before applying stage defaults.
148 + */
24 149 public function createTask($data, $boardId)
25 150 {
26 151 $board = Board::select('id', 'type')->find($boardId);
27 152
28 153 if (!$board) {
29 - throw new \Exception(__("Board doesn't exists", 'fluent-boards'));
154 + throw new \Exception(esc_html__("Board doesn't exists", 'fluent-boards'));
30 155 }
31 156
32 157 $stage = Stage::find($data['stage_id']);
33 158 if (!$stage) {
34 - throw new \Exception(__("Stage doesn't exists", 'fluent-boards'));
159 + throw new \Exception(esc_html__("Stage doesn't exists", 'fluent-boards'));
35 160 }
36 161
162 + if ((int) $stage->board_id !== (int) $boardId) {
163 + throw new \Exception(esc_html__("Stage doesn't exists", 'fluent-boards'));
164 + }
165 +
37 166 $data['status'] = $stage->defaultTaskStatus();
38 167
168 + // Image covers require a persisted task, so creation only accepts a sanitized color cover.
169 + $coverColor = sanitize_hex_color(Arr::get($data, 'settings.cover.backgroundColor', ''));
170 + $taskSettings = [];
171 + if ($coverColor) {
172 + $taskSettings['cover'] = [
173 + 'backgroundColor' => $coverColor,
174 + ];
175 + }
176 +
39 177 if ($board->type == 'roadmap') {
40 178 $current_user = wp_get_current_user();
41 179 $settingData = array(
42 180 'integration_type' => 'feature',
@@ -44,10 +182,14 @@
44 182 'author' => [
45 183 'email' => $current_user->user_email // email of who posted this feature
46 184 ],
47 185 );
48 - $data['settings'] = $settingData;
186 + $data['settings'] = array_merge($taskSettings, $settingData);
49 187 $data['type'] = 'roadmap';
188 + } elseif ($taskSettings) {
189 + $data['settings'] = $taskSettings;
190 + } else {
191 + unset($data['settings']);
50 192 }
51 193
52 194 $providerPosition = Arr::get($data, 'position');
53 195
@@ -53,13 +195,28 @@
53 195
54 196 $data['position'] = $this->getLastPositionOfTasks($stage->id);
55 197
56 198 $data['board_id'] = $boardId;
199 + $data = Helper::normalizeDates($data, ['due_at', 'started_at', 'last_completed_at', 'archived_at', 'remind_at']);
200 + if (isset($data['description'])) {
201 + $data['description'] = DescriptionMarkdownConverter::normalize($data['description']);
202 + }
57 203
58 204 $data = array_filter($data);
59 205 $task = (new Task())->createTask($data);
60 206
207 + // Selected assignees are already attached; toggling them via updateAssignee would remove them.
208 + $task->load('assignees');
209 + $currentUserId = get_current_user_id();
210 + $assigneeIds = array_diff($task->assignees->pluck('ID')->all(), [$currentUserId]);
211 + $notificationService = new NotificationService();
212 + $recipientIds = $notificationService->getEmailEnabledUserIds($task->board_id, $assigneeIds, Constant::BOARD_EMAIL_TASK_ASSIGN);
213 + foreach ($recipientIds as $recipientId) {
214 + $this->sendMailAfterTaskModify('add_assignee', $recipientId, $task->id);
215 + }
216 +
61 217 $this->manageDefaultAssignees($task, $stage->id);
218 + $this->manageDefaultWatchers($task, $stage->id);
62 219
63 220 if (isset($data['is_template']) && $data['is_template'] == 'yes') {
64 221 $task->updateMeta(Constant::IS_TASK_TEMPLATE, $data['is_template']);
65 222 }
@@ -86,21 +243,38 @@
86 243 }
87 244
88 245 public function getTasksForBoards($filters = ['overdue', 'upcoming'], $limit = 5, $task_ids = [])
89 246 {
247 + $assigned = $this->getTasksForBoardsByCategory('assigned', $limit, $task_ids);
90 248 $overDue = $this->getTasksForBoardsByCategory('overdue', $limit, $task_ids);
249 + $dueToday = $this->getTasksForBoardsByCategory('due_today', $limit, $task_ids);
91 250 $completed = $this->getTasksForBoardsByCategory('completed', $limit, $task_ids);
251 + $mentioned = $this->getTasksForBoardsByCategory('mentioned', $limit, $task_ids);
92 252 $upcoming = $this->getTasksForBoardsByCategory('upcoming', $limit, $task_ids);
93 253 $others = $this->getTasksForBoardsByCategory('others', $limit, $task_ids);
94 254
95 255 return [
256 + 'assigned' => $assigned ?? [],
96 257 'overdue' => $overDue ?? [],
258 + 'due_today' => $dueToday ?? [],
97 259 'upcoming' => $upcoming ?? [],
260 + 'mentioned' => $mentioned ?? [],
98 261 'completed' => $completed ?? [],
99 262 'others' => $others ?? []
100 263 ];
101 264 }
102 265
266 + public function getTaskCountsForBoards($categories = ['assigned', 'overdue', 'upcoming', 'completed', 'others'], $taskIds = [])
267 + {
268 + $counts = [];
269 +
270 + foreach ($categories as $category) {
271 + $counts[$category] = $this->getTaskCountForBoardsByCategory($category, $taskIds);
272 + }
273 +
274 + return $counts;
275 + }
276 +
103 277 public function getTasksForBoardsByCategory($category, $limit, $taskIds)
104 278 {
105 279 unset($taskQuery);
106 280 $taskQuery = Task::whereIn('id', $taskIds)
@@ -106,20 +280,67 @@
106 280 $taskQuery = Task::whereIn('id', $taskIds)
107 281 ->with(['assignees', 'board', 'stage'])
108 282 ->whereNull('archived_at')
109 283 ->where('parent_id', null)
110 - ->orderBy('due_at', 'ASC');
284 + ->onActiveAvailableBoards()
285 + ->orderBy('due_at', 'DESC');
111 286
112 - if ('overdue' == $category) {
113 - $taskQuery->overdue();
114 - } elseif ('upcoming' == $category) {
115 - $taskQuery->upcoming();
116 - } elseif ('others' == $category) {
117 - $taskQuery->whereNull('due_at');
118 - } elseif ('completed' == $category) {
119 - $taskQuery->where('status', 'closed');
120 - } else {
121 - return [];
287 + switch ($category) {
288 + case 'overdue':
289 + $taskQuery->overdue();
290 + break;
291 + case 'upcoming':
292 + $taskQuery->upcoming();
293 + break;
294 + case 'due_today':
295 + $taskQuery->dueToday();
296 + break;
297 + case 'others':
298 + $taskQuery->whereNull('due_at');
299 + break;
300 + case 'completed':
301 + $taskQuery->where('status', 'closed');
302 + break;
303 + case 'assigned':
304 + // Rebuild query to order by latest assignment (pivot created_at) so the most recently assigned tasks come first.
305 + $currentUserId = get_current_user_id();
306 + $taskQuery = Task::query()
307 + ->select('fbs_tasks.*')
308 + ->distinct()
309 + ->with(['assignees', 'board', 'stage'])
310 + ->whereIn('fbs_tasks.id', $taskIds)
311 + ->whereNull('fbs_tasks.archived_at')
312 + ->whereNull('fbs_tasks.parent_id')
313 + ->where('fbs_tasks.status', '!=', 'closed')
314 + ->onActiveAvailableBoards()
315 + ->join('fbs_relations as rel', function ($join) use ($currentUserId) {
316 + $join->on('rel.object_id', '=', 'fbs_tasks.id')
317 + ->where('rel.object_type', Constant::OBJECT_TYPE_TASK_ASSIGNEE)
318 + ->where('rel.foreign_id', $currentUserId);
319 + })
320 + ->orderBy('rel.created_at', 'DESC')
321 + ->orderBy('fbs_tasks.updated_at', 'DESC');
322 + break;
323 + case 'mentioned':
324 + $currentUserId = get_current_user_id();
325 + $userNotifications = NotificationUser::where('user_id', $currentUserId)
326 + ->with(['notification' => function ($query) {
327 + $query->where('action', 'task_comment_mentioned');
328 + }])
329 + ->orderBy('created_at', 'desc')
330 + ->get();
331 + $taskIds = $userNotifications->filter(function ($userNotification) {
332 + $notification = $userNotification->notification;
333 + return $notification && $notification->task && is_null($notification->task->archived_at) && is_null($notification->task->parent_id);
334 + })->pluck('notification.task_id')->unique();
335 + $validTasks = Task::whereIn('id', $taskIds)
336 + ->with(['assignees', 'board', 'stage'])
337 + ->onActiveAvailableBoards()
338 + ->get();
339 +
340 + return $validTasks->toArray();
341 + default:
342 + return [];
122 343 }
123 344
124 345 $tasks = $taskQuery->take($limit)->get();
125 346
@@ -125,8 +346,108 @@
125 346
126 347 return $tasks->toArray();
127 348 }
128 349
350 + public function getTaskCountForBoardsByCategory($category, $taskIds)
351 + {
352 + if (empty($taskIds)) {
353 + return 0;
354 + }
355 +
356 + $taskQuery = Task::query()
357 + ->whereIn('id', $taskIds)
358 + ->whereNull('archived_at')
359 + ->whereNull('parent_id')
360 + ->onActiveAvailableBoards();
361 +
362 + switch ($category) {
363 + case 'overdue':
364 + $taskQuery->overdue();
365 + break;
366 + case 'upcoming':
367 + $taskQuery->upcoming();
368 + break;
369 + case 'due_today':
370 + $taskQuery->dueToday();
371 + break;
372 + case 'completed':
373 + $taskQuery->where('status', 'closed');
374 + break;
375 + case 'others':
376 + $taskQuery->whereNull('due_at');
377 + break;
378 + case 'assigned':
379 + $currentUserId = get_current_user_id();
380 + $taskQuery = Task::query()
381 + ->select('fbs_tasks.id')
382 + ->whereIn('fbs_tasks.id', $taskIds)
383 + ->whereNull('fbs_tasks.archived_at')
384 + ->whereNull('fbs_tasks.parent_id')
385 + ->where('fbs_tasks.status', '!=', 'closed')
386 + ->onActiveAvailableBoards()
387 + ->join('fbs_relations as rel', function ($join) use ($currentUserId) {
388 + $join->on('rel.object_id', '=', 'fbs_tasks.id')
389 + ->where('rel.object_type', Constant::OBJECT_TYPE_TASK_ASSIGNEE)
390 + ->where('rel.foreign_id', $currentUserId);
391 + });
392 +
393 + return (int) $taskQuery->distinct()->count('fbs_tasks.id');
394 + case 'mentioned':
395 + $currentUserId = get_current_user_id();
396 + $taskIds = NotificationUser::where('user_id', $currentUserId)
397 + ->with(['notification' => function ($query) {
398 + $query->where('action', 'task_comment_mentioned')
399 + ->with('task');
400 + }])
401 + ->get()
402 + ->filter(function ($userNotification) {
403 + $notification = $userNotification->notification;
404 +
405 + return $notification && $notification->task && is_null($notification->task->archived_at) && is_null($notification->task->parent_id);
406 + })
407 + ->pluck('notification.task_id')
408 + ->unique();
409 +
410 + return Task::whereIn('id', $taskIds)->onActiveAvailableBoards()->count();
411 + default:
412 + return 0;
413 + }
414 +
415 + return (int) $taskQuery->count();
416 + }
417 +
418 + /**
419 + * Unlink a Fluent Support ticket from a board-scoped task.
420 + *
421 + * @param int $taskId
422 + * @param int $boardId
423 + * @return Task
424 + * @throws \Exception
425 + */
426 + public function removeSupportTicketLink($taskId, $boardId)
427 + {
428 + $taskId = absint($taskId);
429 + $boardId = absint($boardId);
430 + $task = $this->findTaskOnBoard($taskId, $boardId);
431 +
432 + if ($task->source !== Constant::TASK_SOURCE_FLUENT_SUPPORT || !$task->source_id) {
433 + return $task;
434 + }
435 +
436 + $ticketId = $task->source_id;
437 + $settings = is_array($task->settings) ? $task->settings : [];
438 + unset($settings['author']);
439 +
440 + $task->source = null;
441 + $task->source_id = null;
442 + $task->settings = $settings ?: null;
443 + $task->save();
444 +
445 + do_action('fluent_boards/support_ticket_unlinked', $task, $ticketId);
446 +
447 + return $task;
448 + }
449 +
129 450 /*
130 451 * TODO: Refactor this function - For me.
131 452 */
132 453 public function updateTaskProperty($col, $value, $task)
@@ -133,70 +454,135 @@
133 454 {
134 455 $oldTask = clone $task; // normal assigning won't work here. because objects are passed by reference in php
135 456 $validColumns = [
136 457 'board_id',
137 - 'task_type',
138 - 'reminder_type',
458 + 'type',
459 +// 'reminder_type',
139 460 'remind_at',
140 461 'log_minutes',
462 + 'source',
463 + 'source_id',
141 464 'settings'
142 465 ];
143 466
467 + if ($col === 'description') {
468 + $value = DescriptionMarkdownConverter::normalize($value);
469 + }
470 +
144 471 if (in_array($col, $validColumns) && $task->{$col} != $value) {
145 - $task->{$col} = $value;
146 - $task->save();
147 - // do_action('fluent_boards/task_prop_changed', $col, $task, $oldTask);
148 - } elseif ('assignees' == $col) {
149 - if (is_array($value)) {
150 - foreach ($value as $id) {
151 - $this->updateAssignee($id, $task);
152 - }
153 - } else {
154 - $this->updateAssignee($value, $task);
472 + if ($col === 'remind_at') {
473 + $value = Helper::normalizeDateValue($value);
155 474 }
156 475
157 - } elseif ('crm_contact_id' == $col) {
158 - $this->updateAssociate($value, $task);
159 - } elseif ('archived_at' == $col) {
160 - $this->updateArchive($value, $task);
161 - } elseif ('status' == $col) {
162 - $this->updateStatus($value, $task);
163 - } elseif ('parent_id' == $col) {
164 - $this->updateParent($value, $task);
165 - } elseif ('title' == $col) {
166 - $this->updateTitle($col, $value, $task, $oldTask);
167 - } elseif ('description' == $col) {
168 - $this->updateDescription($col, $value, $task, $oldTask);
169 - } elseif ($col == 'due_at') {
170 - $this->updateDueDate($value, $task);
171 - } elseif ($col == 'started_at') {
172 - $this->updateStartedDate($value, $task);
173 - } elseif ($col == 'priority') {
174 - $this->updatePriority($value, $task);
175 - } elseif ($col == 'is_watching') {
176 - $this->updateObservationOfCurrentUser($value, $task);
177 - } elseif ($col == 'last_completed_at') {
178 - $isClosed = $value == 'true' || $value === true;
179 - if ($isClosed) {
180 - $task = $task->close();
181 - } else {
182 - $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']);
183 481 }
482 + $task->{$col} = $value ?: null;
184 483 $task->save();
185 - } elseif ($col == 'attachment_count') {
186 - $settings = $task->settings;
187 - $settings['attachment_count'] = $task->attachments()->count();
188 - $task->settings = $settings;
189 - $task->save();
190 - } elseif ($col == 'subtask_count') {
191 - $settings = $task->settings;
192 - $subtasksCount = Task::where('parent_id', $task->id)->count();
193 - $settings['subtask_count'] = $subtasksCount;
194 - $task->settings = $settings;
195 - $task->save();
196 - } elseif ($col == 'is_template') {
197 - if (defined('FLUENT_BOARDS_PRO')) {
198 - $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;
199 585 }
200 586 }
201 587
202 588 return $task;
@@ -210,9 +596,9 @@
210 596
211 597 $task->save();
212 598
213 599 if ($operation == 'added') {
214 - 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()) {
215 601 $this->sendMailAfterTaskModify('add_assignee', $payloadAssigneeId, $task->id);
216 602 }
217 603 // $assigneeIdsToSendEmail = $this->filterAssigneeToSendEmail($task, $idArray, Constant::BOARD_EMAIL_TASK_ASSIGN);
218 604 // $this->sendMailAfterAddAssignees($assigneeIdsToSendEmail, $task->id);
@@ -220,9 +606,9 @@
220 606 if($payloadAssigneeId != get_current_user_id()){
221 607 do_action('fluent_boards/assign_another_user', $task, $payloadAssigneeId);
222 608 }
223 609 } else {
224 - 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()) {
225 611 $this->sendMailAfterTaskModify('remove_assignee', $payloadAssigneeId, $task->id);
226 612 }
227 613 do_action('fluent_boards/task_assignee_removed', $task, $payloadAssigneeId);
228 614 }
@@ -278,17 +664,36 @@
278 664
279 665 private function updateArchive($value, $task)
280 666 {
281 667 if ($value != null) {
668 + // Archiving task
282 669 $task->position = 0;
283 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 +
284 684 $task->moveToNewPosition(1);
685 +
686 + // Clean up archived_by_stage meta when task is manually restored
687 + $this->cleanupArchivedByStageMetaIfExists($task->id);
285 688 }
286 689 $task->archived_at = $value == null ? null : current_time('mysql');
287 690 $task->save();
288 691 do_action('fluent_boards/task_archived', $task);
289 - $wathersToSendEmail = (new NotificationService())->filterAssigneeToSendEmail($task->id, Constant::BOARD_EMAIL_TASK_ARCHIVE);
290 - $this->sendMailAfterTaskModify('task_archived', $wathersToSendEmail, $task->id);
692 + if ($value != null) {
693 + $watchersToSendEmail = (new NotificationService())->filterAssigneeToSendEmail($task->id, Constant::BOARD_EMAIL_TASK_ARCHIVE);
694 + $this->sendMailAfterTaskModify('task_archived', $watchersToSendEmail, $task->id);
695 + }
291 696 }
292 697
293 698 private function updateStatus($value, $task)
294 699 {
@@ -315,9 +720,9 @@
315 720 }
316 721
317 722 private function updateDescription($col, $value, $task, $oldTask)
318 723 {
319 - $task->description = $value;
724 + $task->description = DescriptionMarkdownConverter::normalize($value);
320 725 $task->save();
321 726 do_action('fluent_boards/task_content_updated', $task, $col, $oldTask);
322 727 }
323 728
@@ -323,15 +728,19 @@
323 728
324 729 private function updateDueDate($value, $task)
325 730 {
326 731 $oldValue = $task->due_at;
327 - $value = $this->filterNullDate($value);
732 + $value = Helper::normalizeDateValue($value);
328 733 $task->due_at = $value;
329 734 $task->save();
330 735
331 736 $task = $task->reopen();
332 737
333 - do_action('fluent_boards/task_due_date_changed', $task, $oldValue);
738 + if($value){
739 + do_action('fluent_boards/task_due_date_changed', $task, $oldValue);
740 + } else {
741 + do_action('fluent_boards/task_due_date_removed', $task);
742 + }
334 743
335 744 $wathersToSendEmail = (new NotificationService())->filterAssigneeToSendEmail($task->id, Constant::BOARD_EMAIL_DUE_DATE_CHANGE);
336 745 $this->sendMailAfterTaskModify('due_date_update', $wathersToSendEmail, $task->id);
337 746 }
@@ -338,13 +747,15 @@
338 747
339 748 private function updateStartedDate($value, $task)
340 749 {
341 750 $oldValue = $task->started_at;
342 - $value = $this->filterNullDate($value);
751 + $value = Helper::normalizeDateValue($value);
343 752 $task->started_at = $value;
344 753 $task->save();
345 754
346 - do_action('fluent_boards/task_start_date_changed', $task, $oldValue);
755 + if($value){
756 + do_action('fluent_boards/task_start_date_changed', $task, $oldValue);
757 + }
347 758 }
348 759
349 760 private function updatePriority($value, $task)
350 761 {
@@ -353,46 +764,53 @@
353 764 $task->save();
354 765 do_action('fluent_boards/task_priority_changed', $task, $oldPriority);
355 766 }
356 767
357 - public function updateObservationOfCurrentUser($value, $task)
358 - {
359 - $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 + }
360 777
361 - if ($value == 'stop') {
362 - $task->watchers()->detach($currentUserId);
363 - } else {
364 - $task->watchers()->syncWithoutDetaching([$currentUserId => ['object_type' => Constant::OBJECT_TYPE_USER_TASK_WATCH]]);
365 - }
366 - $task->updated_at = current_time('mysql');
367 - $task->save();
778 + if (!$userId || !in_array($action, ['stop', 'start'])) {
779 + return;
780 + }
368 781
369 - if ($value == 'stop') {
370 - $task->is_watching = false;
371 - } else {
372 - $task->is_watching = true;
373 - }
782 + if ($action == 'stop') {
783 + $task->watchers()->detach($userId);
784 + } else {
785 + $task->watchers()->syncWithoutDetaching([$userId => ['object_type' => Constant::OBJECT_TYPE_USER_TASK_WATCH]]);
374 786 }
787 + $task->updated_at = current_time('mysql');
788 + $task->save();
789 +}
375 790
376 - public function taskCoverPhotoUpdate($taskId, $imagePath)
791 + public function taskCoverPhotoUpdate($taskId, $imagePath, $boardId = null)
377 792 {
378 - $task = Task::find($taskId);
793 + $task = $boardId ? $this->findTaskOnBoard($taskId, $boardId) : Task::find($taskId);
379 794 if (!$task) {
380 795 return null;
381 796 }
382 797
383 - $settings = unserialize($task->settings);
798 + $settings = $task->settings;
799 + if (!is_array($settings)) {
800 + $settings = [];
801 + }
384 802
385 803 $settings['logo'] = $imagePath;
386 - $task->settings = serialize($settings);
804 + $task->settings = $settings;
387 805 $task->save();
388 806
389 807 return $task;
390 808 }
391 809
392 - public function taskStatusUpdate($taskId, $integrationType)
810 + public function taskStatusUpdate($taskId, $integrationType, $boardId = null)
393 811 {
394 - $task = Task::find($taskId);
812 + $task = $boardId ? $this->findTaskOnBoard($taskId, $boardId) : Task::find($taskId);
395 813 if (!$task) {
396 814 return null;
397 815 }
398 816
@@ -397,9 +815,9 @@
397 815 }
398 816
399 817 $settings = $task->settings;
400 818 $settings['integration_type'] = $integrationType;
401 - $task->settings = serialize($settings);
819 + $task->settings = $settings;
402 820 $task->save();
403 821
404 822 return $task;
405 823 }
@@ -405,9 +823,9 @@
405 823 }
406 824
407 825 public function assignYourselfInTask($boardId, $taskId)
408 826 {
409 - $task = Task::find($taskId);
827 + $task = $this->findTaskOnBoard($taskId, $boardId);
410 828 $authUserId = get_current_user_id();
411 829
412 830 $boardService = new BoardService();
413 831 if (!$boardService->isAlreadyMember($boardId, $authUserId)) {
@@ -414,13 +832,13 @@
414 832 $boardService->addMembersInBoard($boardId, $authUserId);
415 833 }
416 834
417 835 $task->addOrRemoveAssignee($authUserId);
418 - do_action('fluent_boards/task_assignee_added', $task, $authUserId);
419 836 // when user assign himself then he will be watching that task
420 837 $task->watchers()->syncWithoutDetaching([$authUserId => ['object_type' => Constant::OBJECT_TYPE_USER_TASK_WATCH]]);
421 838
422 839 $task->load('assignees');
840 + do_action('fluent_boards/task_assignee_added', $task, $authUserId);
423 841
424 842 return $task;
425 843 }
426 844
@@ -425,14 +843,13 @@
425 843 }
426 844
427 845 public function detachYourselfFromTask($boardId, $taskId)
428 846 {
429 - $task = Task::find($taskId);
430 - $authUser = get_current_user_id();
431 - $task->addOrRemoveAssignee($authUser);
432 - do_action('fluent_boards/task_assignee_removed', $task, $authUser);
433 -
847 + $task = $this->findTaskOnBoard($taskId, $boardId);
848 + $currentUserId = get_current_user_id();
849 + $task->addOrRemoveAssignee($currentUserId);
434 850 $task->load('assignees');
851 + do_action('fluent_boards/task_assignee_removed', $task, $currentUserId);
435 852
436 853 return $task;
437 854 }
438 855
@@ -437,40 +854,216 @@
437 854 }
438 855
439 856 public function deleteTask($task)
440 857 {
441 - $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 + }
442 866
443 - if ($deleted) {
867 + $this->deleteTasksBatch([$task]);
868 + }
444 869
445 - //task assignees watchers removed
446 - $task->watchers()->detach();
447 - $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 + }
448 883
449 - //removing all task related notifications
450 - $notificationIds = $task->notifications->pluck('id');
451 - $task->notifications()->delete();
884 + $deletedTasks = [];
885 + $taskBoardIds = [];
886 + foreach ($tasks as $task) {
887 + if (!$task instanceof Task) {
888 + continue;
889 + }
890 +
891 + $taskId = (int) $task->id;
892 + if ($taskId < 1) {
893 + continue;
894 + }
895 +
896 + $deletedTasks[$taskId] = clone $task;
897 + $taskBoardIds[$taskId] = (int) $task->board_id;
898 + }
899 +
900 + if (!$deletedTasks) {
901 + return;
902 + }
903 +
904 + ksort($deletedTasks, SORT_NUMERIC);
905 + $taskIds = array_keys($deletedTasks);
906 + $dbInstance = App::getInstance('db');
907 +
908 + if (!$manageTransaction && !$dbInstance->inTransaction()) {
909 + throw new \RuntimeException(__('An active transaction is required for caller-managed task deletion.', 'fluent-boards'));
910 + }
911 +
912 + if ($manageTransaction) {
913 + $dbInstance->beginTransaction();
914 + }
915 +
916 + try {
917 + $relationTypes = [
918 + Constant::OBJECT_TYPE_USER_TASK_WATCH,
919 + Constant::OBJECT_TYPE_TASK_ASSIGNEE,
920 + Constant::OBJECT_TYPE_TASK_LABEL,
921 + ];
922 +
923 + if (defined('FLUENT_BOARDS_PRO_VERSION')) {
924 + $relationTypes[] = \FluentBoardsPro\App\Services\Constant::TASK_CUSTOM_FIELD;
925 + }
926 +
927 + Relation::whereIn('object_id', $taskIds)
928 + ->whereIn('object_type', $relationTypes)
929 + ->delete();
930 +
931 + Relation::where('object_type', Constant::OBJECT_TYPE_TASK_DEPENDENCY)
932 + ->where(function ($query) use ($taskIds) {
933 + $query->whereIn('object_id', $taskIds)
934 + ->orWhereIn('foreign_id', $taskIds);
935 + })
936 + ->delete();
937 +
938 + $notificationIds = Notification::whereIn('task_id', $taskIds)->pluck('id')->toArray();
452 939 NotificationUser::whereIn('notification_id', $notificationIds)->delete();
940 + Notification::whereIn('task_id', $taskIds)->delete();
453 941
454 - //task labels removed
455 - $task->labels()->detach();
942 + $this->deleteTaskAttachmentsBatch($taskIds, $taskBoardIds);
943 + $this->deleteTaskCommentsBatch($taskIds, $taskBoardIds, $dbInstance);
944 + Activity::whereIn('object_id', $taskIds)
945 + ->where('object_type', Constant::ACTIVITY_TASK)
946 + ->delete();
947 + Meta::whereIn('object_id', $taskIds)
948 + ->where('object_type', Constant::REPEAT_TASK_META)
949 + ->delete();
950 + $this->deleteTimeTrackingRecords($taskIds, false);
951 + TaskMeta::whereIn('task_id', $taskIds)->delete();
456 952
457 - //task custom field value
458 - $task->customFields()->detach();
953 + $deletedCount = Task::whereIn('id', $taskIds)->delete();
954 + if ($deletedCount !== count($taskIds)) {
955 + throw new \RuntimeException(__('Task could not be deleted.', 'fluent-boards'));
956 + }
459 957
460 - do_action('fluent_boards/task_deleted', $task);
461 - TaskMeta::where('task_id', $task->id)->delete();
958 + $this->dispatchTaskDeletedHooksAfterCommit($dbInstance, $deletedTasks);
959 +
960 + if ($manageTransaction) {
961 + $dbInstance->commit();
962 + }
963 + } catch (\Throwable $e) {
964 + if ($manageTransaction) {
965 + $dbInstance->rollBack();
966 + }
967 +
968 + throw $e;
462 969 }
463 970 }
464 971
465 - public function filterNullDate($date)
972 + /** Remove comment rows transactionally; file cleanup must wait for commit. */
973 + private function deleteTaskCommentsBatch($taskIds, $taskBoardIds, $dbInstance)
466 974 {
467 - if ('0000-00-00 00:00:00' == $date || false === strtotime($date)) {
468 - return null;
975 + $commentTasks = Comment::whereIn('task_id', $taskIds)->pluck('task_id', 'id')->toArray();
976 + if (!$commentTasks) {
977 + return;
469 978 }
470 - 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 + });
471 1030 }
472 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 + }
473 1066 // this is invoked when task is moved to another board
474 1067
475 1068 /**
476 1069 * @throws \Exception
@@ -476,77 +1069,383 @@
476 1069 * @throws \Exception
477 1070 */
478 1071 public function changeBoardByTask($task, $targetBoardId)
479 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 +
480 1079 if ($task->board_id == $targetBoardId) {
481 1080 return $task;
482 1081 }
483 1082
484 1083 $oldBoard = Board::find($task->board_id);
1084 + $newBoard = Board::find($targetBoardId);
485 1085
486 - $newBoard = Board::find($targetBoardId);
1086 + if (!$oldBoard) {
1087 + throw new \Exception(esc_html__('Source board not found', 'fluent-boards'), 404);
1088 + }
1089 +
487 1090 if (!$newBoard) {
488 - throw new \Exception('Invalid board id', 400);
1091 + throw new \Exception(esc_html__('Target board not found', 'fluent-boards'), 404);
489 1092 }
490 - $task->board_id = $targetBoardId;
491 - $task->save();
492 - //delete labels of that task because labels have board dependencies
493 - $task->labels()->detach();
494 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 +
495 1131 do_action('fluent_boards/task_moved_from_board', $task, $oldBoard, $newBoard);
1132 + return $task;
1133 + }
496 1134
497 - 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 + }
498 1174 }
499 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 + }
500 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 +
501 1291 public function getIdeaVoteStatistics($taskId)
502 1292 {
503 - $reactionTypes = [
504 - [
505 - 'label' => 'Upvote',
506 - 'type' => 'upvote'
507 - ],
508 - [
509 - 'label' => 'Downvote',
510 - 'type' => 'downvote'
511 - ]
512 - ];
1293 + $taskId = absint($taskId);
1294 + $voteStatistics = $this->getIdeaVoteStatisticsByTaskIds([$taskId]);
513 1295
514 - $reactionCounts = [];
1296 + return $voteStatistics[$taskId] ?? 0;
1297 + }
515 1298
516 - foreach ($reactionTypes as $reactionType) {
517 - $count = IdeaReaction::where('object_id', $taskId)
518 - ->where('object_type', 'idea')
519 - ->where('type', $reactionType['type'])
520 - ->count();
1299 + public function loadIdeaVoteStatistics($tasks)
1300 + {
1301 + $taskIds = [];
521 1302
522 - $reactionCounts[] = [
523 - 'label' => $reactionType['label'],
524 - 'type' => $reactionType['type'],
525 - 'count' => $count
526 - ];
1303 + foreach ($tasks as $task) {
1304 + $taskId = absint($task->id);
1305 + if ($taskId) {
1306 + $taskIds[] = $taskId;
1307 + }
527 1308 }
528 1309
529 - 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;
530 1317 }
531 1318
1319 + private function getIdeaVoteStatisticsByTaskIds(array $taskIds)
1320 + {
1321 + global $wpdb;
532 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 +
533 1363 /**
534 - * Summary of getArchivedOrCompletedTasks
535 - * this function will return completd tasks or archived tasks based on users input and also can search by name
536 - * @param mixed $data
537 - * @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
538 1432 * @return mixed
539 1433 * @throws \Exception
540 1434 */
541 1435 public function getArchivedTasks($data, $boardId)
542 1436 {
543 - $per_page = isset($data['per_page']) ? $data['per_page'] : 25;
544 - $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));
545 1444 $tasksQuery = Task::where('board_id', $boardId)->whereNotNull('archived_at');
546 1445
547 - if (isset($data['searchInput'])) {
548 - $query = strtolower($data['searchInput']);
1446 + if (!empty($data['query'])) {
1447 + $query = strtolower($data['query']);
549 1448 $firstThreeChars = substr($query, 0, 3);
550 1449
551 1450 if($firstThreeChars == 'id:') {
552 1451 $idPart = substr($query, 3);
@@ -552,22 +1451,560 @@
552 1451 $idPart = substr($query, 3);
553 1452 $idPart = preg_replace('/[^a-zA-Z0-9]/', '', $idPart);
554 1453 $tasksQuery = $tasksQuery->where('id', 'LIKE', '%' . $idPart . '%');
555 1454 } else {
556 - $tasksQuery = $tasksQuery->where('title', 'LIKE', '%' . $data['searchInput'] . '%');
1455 + $tasksQuery = $tasksQuery->where('title', 'LIKE', '%' . $data['query'] . '%');
557 1456 }
558 1457 }
559 1458
560 - // if board_id is not passed then throw an exception
561 - if (!$boardId) {
562 - 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;
563 1466 }
564 1467
565 - 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;
566 1502 }
567 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 +
568 2001 public function sendMailAfterTaskModify($column, $assigneeIds, $taskId)
569 2002 {
2003 + if (empty($assigneeIds)) {
2004 + return;
2005 + }
2006 +
570 2007 $current_user_id = get_current_user_id();
571 2008 /* this will run in background as soon as possible */
572 2009 /* sending Model or Model Instance won't work here */
573 2010
@@ -576,14 +2013,17 @@
576 2013
577 2014 public function getStageByTask($task_id)
578 2015 {
579 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 + }
580 2020 return $task->stage;
581 2021 }
582 2022
583 - public function moveTaskToNextStage($task_id)
2023 + public function moveTaskToNextStage($task_id, $boardId = null)
584 2024 {
585 - $task = Task::findOrFail($task_id);
2025 + $task = $boardId ? $this->findTaskOnBoard($task_id, $boardId) : Task::findOrFail($task_id);
586 2026
587 2027 $oldStage = $task->stage;
588 2028
589 2029 $nextStage = Stage::where('board_id', $task->board_id)
@@ -601,8 +2041,11 @@
601 2041 $task->last_completed_at = current_time('mysql');
602 2042 }
603 2043 }
604 2044
2045 + // Clean up archived_by_stage meta when moving to different stage
2046 + $this->cleanupArchivedByStageMetaIfExists($task->id);
2047 +
605 2048 $task->stage_id = $nextStage->id;
606 2049 $task->save();
607 2050
608 2051 $task->load(['board', 'stage', 'attachments']);
@@ -625,8 +2068,9 @@
625 2068 }
626 2069
627 2070 public function getActivities($taskId, $perPage, $filter = 'newest')
628 2071 {
2072 + $perPage = Sanitize::perPage($perPage);
629 2073 $activityQuery = Activity::where('object_id', $taskId)
630 2074 ->where('object_type', Constant::ACTIVITY_TASK);
631 2075 if ($filter == 'newest') {
632 2076 $activityQuery = $activityQuery->latest();
@@ -632,27 +2076,36 @@
632 2076 $activityQuery = $activityQuery->latest();
633 2077 } else if ($filter == 'oldest') {
634 2078 $activityQuery = $activityQuery->oldest();
635 2079 }
636 - return $activityQuery->with('user')->paginate($perPage);
2080 + $activities = $activityQuery->with('user')->paginate($perPage);
2081 +
2082 + Helper::translateActivities($activities);
2083 +
2084 + return $activities;
637 2085 }
638 2086
639 - public function getLastOneMinuteUpdatedTasks($boardId, $lastUpdated = null)
2087 + public function getLastOneMinuteUpdatedTasks($boardId, $lastUpdated = null, $includeArchived = true)
640 2088 {
641 2089 if (!$lastUpdated) {
642 - $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
643 2091 }
644 2092
645 - $tasks = Task::query()
2093 + $tasksQuery = Task::query()
646 2094 ->where([
647 2095 'board_id' => $boardId,
648 2096 'parent_id' => null,
649 2097 ])
650 - ->where('updated_at', '>', $lastUpdated)
2098 + ->where('updated_at', '>=', $lastUpdated) // updated since the sync cursor
651 2099 ->with(['assignees', 'labels', 'watchers', 'taskCustomFields'])
652 - ->orderBy('due_at', 'ASC')
653 - ->get();
2100 + ->orderBy('due_at', 'ASC');
654 2101
2102 + if (!$includeArchived) {
2103 + $tasksQuery->whereNull('archived_at');
2104 + }
2105 +
2106 + $tasks = $tasksQuery->get();
2107 +
655 2108 foreach ($tasks as $task) {
656 2109 $task->isOverdue = $task->isOverdue();
657 2110 $task->isUpcoming = $task->upcoming();
658 2111 $task->is_watching = $task->isWatching();
@@ -675,88 +2128,239 @@
675 2128
676 2129 return $lastPosition + 1;
677 2130 }
678 2131
679 - 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)
680 2140 {
681 - $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()
682 2183 ->where('crm_contact_id', $associatedId)
683 - ->with(['board', 'stage', 'assignees', 'labels', 'watchers',])
684 - ->orderBy('due_at', 'ASC')
685 - ->get();
2184 + ->with(['board', 'stage', 'assignees', 'subtaskGroup', 'subtaskGroup.subtasks', 'subtaskGroup.subtasks.assignees'])
2185 + ->orderBy('due_at', 'ASC');
686 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 +
687 2203 foreach ($tasks as $task) {
688 2204 $task->isOverdue = $task->isOverdue();
689 2205 $task->isUpcoming = $task->upcoming();
690 - $task->contact = Task::lead_contact($task->crm_contact_id);
691 - $task->is_watching = $task->isWatching();
2206 + $task->can_edit = $isAdmin || in_array((int)$task->board_id, $editableBoardIds, true);
692 2207
693 2208 $task->assignees = Helper::sanitizeUserCollections($task->assignees);
694 - $task->watchers = Helper::sanitizeUserCollections($task->watchers);
695 2209
696 - if(!!defined('FLUENT_BOARDS_PRO_VERSION')) {
697 - $task->time_tracks= [
698 - 'tracks' => (new ProTaskService())->getTaskTimeTrack($task->board_id, $task->id),
699 - 'estimated_minutes' => TimeTrackingHelper::getTaskEstimation($task->id)
700 - ];
2210 + foreach ($task->subtaskGroup as $group) {
2211 + foreach ($group->subtasks as $subtask) {
2212 + $subtask->assignees = Helper::sanitizeUserCollections($subtask->assignees);
2213 + }
701 2214 }
2215 + $task->subtask_group = $task->subtaskGroup;
2216 + }
702 2217
2218 + return $tasks;
2219 + }
703 2220
704 - $subTasks = Task::query()
705 - ->where('parent_id', $task->id)
706 - ->with(['assignees'])
707 - ->whereNull('archived_at')
708 - ->orderBy('position', 'ASC')
709 - ->get();
2221 + public function copySubtaskGroup($task, $newTask, $subtaskGroupMap)
2222 + {
2223 + $subtaskGroups = TaskMeta::where('task_id', $task->id)->where('key', Constant::SUBTASK_GROUP_NAME)->get();
2224 + foreach ($subtaskGroups as $group) {
2225 + $newGroup = TaskMeta::create([
2226 + 'task_id' => $newTask->id,
2227 + 'key' => Constant::SUBTASK_GROUP_NAME,
2228 + 'value' => $group->value
2229 + ]);
710 2230
711 - foreach ($subTasks as $subTask) {
712 - $subTask->assignees = Helper::sanitizeUserCollections($subTask->assignees);
713 - }
714 -
715 - $task->subtasks = $subTasks;
2231 + $subtaskGroupMap[$group->id] = $newGroup->id;
716 2232 }
717 2233
718 - return $tasks;
2234 + return $subtaskGroupMap;
719 2235 }
720 2236
721 - public function copyTasks($boardId, $stageMap, $newBoard, $labelMap = [])
2237 + public function copyTasks($boardId, $stageMap, $newBoard, $labelMap = [], $isWithTemplates = 'no', $customFieldMap = [])
722 2238 {
723 - $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();
724 2243 $taskMap = [];
2244 + $subtaskGroupMap = [];
725 2245 $parentTaskCount = 0;
726 - foreach ($allActiveTasks as $task) {
727 - $newTask = array();
728 - $newTask['title'] = $task->title;
729 - $newTask['parent_id'] = $task->parent_id ? $taskMap[$task->parent_id] : null;
730 - $newTask['description'] = $task->description;
731 - $newTask['board_id'] = $newBoard->id;
732 - $newTask['stage_id'] = $stageMap[$task->stage_id];
733 - $newTask['status'] = $task->status;
734 - $newTask['priority'] = $task->priority;
735 - $newTask['position'] = $task->position;
736 - $newTask['due_at'] = $task->due_at;
737 - $newTask = Task::create($newTask);
738 - if(!$task->parent_id){
739 - ++$parentTaskCount;
740 - $taskMap[$task['id']] = $newTask->id;
741 - //duplicate labels to task
742 - $labelIds = $task->labels->pluck('id')->toArray();
743 - if($labelIds){
744 - $flipLabelIds = array_flip($labelIds);
745 - $labelsToAttach = array_intersect_key($labelMap, $flipLabelIds);
2246 + $attachmentFileService = new AttachmentFileService();
2247 + $dbInstance = App::getInstance('db');
746 2248
747 - $newTask->labels()->attach($labelsToAttach, [
748 - 'object_type' => Constant::OBJECT_TYPE_TASK_LABEL
749 - ]);
2249 + $dbInstance->beginTransaction();
2250 +
2251 + try {
2252 + foreach ($allActiveTasks as $task) {
2253 + if ($task->parent_id && empty($taskMap[$task->parent_id])) {
2254 + continue;
750 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 + }
751 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;
752 2339 }
2340 + }
753 2341
754 - $board = Board::findOrFail($newBoard->id);
755 - $settings = [];
756 - $settings['tasks_count'] = $parentTaskCount;
757 - $board->settings = $settings;
758 - $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 + }
759 2363 }
760 2364
761 2365 private function subtaskCountUpdate($taskId){
762 2366 $parentTask = Task::findOrFail($taskId);
@@ -768,80 +2372,110 @@
768 2372
769 2373 /**
770 2374 * @param $taskId
771 2375 * @param $perPage
772 - * @param $offset
2376 + * @param $page
773 2377 * @param string $filter
2378 + * @param $boardId
2379 + * @param string $feedType
774 2380 * @return array
775 2381 */
776 - public function getCommentsAndActivities($taskId, $perPage, $page, string $filter = 'newest'): array
2382 + public function getCommentsAndActivities($taskId, $perPage, $page, string $filter = 'newest', $boardId = null, string $feedType = 'all'): array
777 2383 {
2384 + $perPage = Sanitize::perPage($perPage);
2385 + $page = max(1, (int) $page);
778 2386 // Fetch the task
779 - $task = Task::findOrFail($taskId);
2387 + $task = $boardId ? $this->findTaskOnBoard($taskId, $boardId) : Task::findOrFail($taskId);
2388 + $feedType = in_array($feedType, ['all', 'comments', 'activities'], true) ? $feedType : 'all';
780 2389
781 - // Fetch comments and activities separately
782 - $comments = $task->comments()->with('user')->orderBy('created_at', 'desc')->get()->toArray();
783 - $activities = $task->activities()
784 - ->with('user')
2390 + $direction = $filter == 'newest' ? 'desc' : 'asc';
2391 + $offset = ($page - 1) * $perPage;
2392 + // Each source needs at most offset + perPage rows to fill the requested page.
2393 + $limit = $offset + $perPage;
2394 +
2395 + $commentQuery = $task->comments();
2396 + $activityQuery = $task->activities()
785 2397 ->where(function($query) {
786 2398 $query->whereNotIn('column', [ 'comment', 'a reply'])
787 2399 ->orWhere(function($subQuery) {
788 2400 $subQuery->whereNotIn('action', ['added', 'updated']);
789 2401 });
790 - })
791 - ->orderBy('created_at', 'desc')
792 - ->get()
793 - ->toArray();
2402 + });
794 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 + }
795 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);
796 2425
797 - // Merge comments and activities into a single array
798 - $commentsAndActivities = array_merge($comments, $activities);
2426 + // Hydrate relations only for the selected page.
2427 + $selectedIds = ['comment' => [], 'activity' => []];
2428 + foreach ($entries as $entry) {
2429 + $selectedIds[$entry[0]][] = $entry[1];
2430 + }
2431 + $models = ['comment' => [], 'activity' => []];
2432 + if ($selectedIds['comment']) {
2433 + $models['comment'] = $commentQuery->with(['user', 'replies.user'])->whereIn('id', $selectedIds['comment'])->get()->keyBy('id')->all();
2434 + }
2435 + if ($selectedIds['activity']) {
2436 + $models['activity'] = $activityQuery->with('user')->whereIn('id', $selectedIds['activity'])->get()->keyBy('id')->all();
2437 + }
799 2438
800 - // Sort the merged array by created_at date in ascending or descending order
801 - $order = $filter == 'newest' ? -1 : 1;
802 - usort($commentsAndActivities, function ($a, $b) use ($order) {
803 - return $order * (strtotime($a['created_at']) - strtotime($b['created_at']));
804 - });
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 + }
805 2445
806 - // Paginate the results
807 - $offset = ($page - 1) * $perPage; // Calculate the offset for slicing the array
808 - $paginatedResults = array_slice($commentsAndActivities, $offset, $perPage);
809 -
810 - // Get the total count of comments and activities
811 - $total = count($commentsAndActivities);
812 2446 $lastPage = (int) ceil($total / $perPage);
813 2447
814 2448 // Construct pagination metadata
815 - $path = "https://wordpress.test/wp-json/fluent-boards/v2/projects/{$task->board_id}/tasks/{$task->id}/comments-and-activities";
2449 + $path = rest_url("fluent-boards/v2/projects/{$task->board_id}/tasks/{$task->id}/comments-and-activities");
816 2450 return [
817 2451 'current_page' => (int) $page,
818 2452 'data' => $paginatedResults,
819 - 'first_page_url' => "{$path}?page=1",
2453 + 'first_page_url' => add_query_arg('page', 1, $path),
820 2454 'from' => $total > 0 ? (int) ($offset + 1) : null,
821 2455 'last_page' => (int) $lastPage,
822 - 'last_page_url' => "{$path}?page={$lastPage}",
2456 + 'last_page_url' => add_query_arg('page', $lastPage, $path),
823 2457 'links' => [
824 2458 [
825 - 'url' => $page > 1 ? "{$path}?page=" . ($page - 1) : null,
2459 + 'url' => $page > 1 ? add_query_arg('page', ($page - 1), $path) : null,
826 2460 'label' => 'pagination.previous',
827 2461 'active' => false
828 2462 ],
829 2463 [
830 - 'url' => "{$path}?page={$page}",
2464 + 'url' => add_query_arg('page', $page, $path),
831 2465 'label' => (int) $page,
832 2466 'active' => true
833 2467 ],
834 2468 [
835 - 'url' => $page < $lastPage ? "{$path}?page=" . ($page + 1) : null,
2469 + 'url' => $page < $lastPage ? add_query_arg('page', ($page + 1), $path) : null,
836 2470 'label' => 'pagination.next',
837 2471 'active' => false
838 2472 ]
839 2473 ],
840 - 'next_page_url' => $page < $lastPage ? "{$path}?page=" . ($page + 1) : null,
2474 + 'next_page_url' => $page < $lastPage ? add_query_arg('page', ($page + 1), $path) : null,
841 2475 'path' => $path,
842 2476 'per_page' => (int) $perPage,
843 - 'prev_page_url' => $page > 1 ? "{$path}?page=" . ($page - 1) : null,
2477 + 'prev_page_url' => $page > 1 ? add_query_arg('page', ($page - 1), $path) : null,
844 2478 'to' => $total > 0 ? (int) min($offset + $perPage, $total) : null,
845 2479 'total' => (int) $total
846 2480 ];
847 2481 }
@@ -888,9 +2522,9 @@
888 2522 * @param $title
889 2523 * @param $UrlMeta
890 2524 * @return mixed|string
891 2525 */
892 - private function setTitle($type, $title, $UrlMeta)
2526 + public function setTitle($type, $title, $UrlMeta)
893 2527 {
894 2528 if($type != 'url') {
895 2529 return sanitize_file_name($title);
896 2530 }
@@ -896,11 +2530,15 @@
896 2530 }
897 2531 return $title ?? $UrlMeta['title'] ?? '';
898 2532 }
899 2533
900 - public function manageDefaultAssignees($task, $stageId)
2534 + /** Apply missing stage assignees and report whether any assignments were added. */
2535 + public function manageDefaultAssignees($task, $stageId, ?Stage $stage = null)
901 2536 {
902 - $stage = Stage::findOrFail($stageId);
2537 + if (!$stage || (int) $stage->id !== (int) $stageId) {
2538 + $stage = Stage::findOrFail($stageId);
2539 + }
2540 + $assignmentsChanged = false;
903 2541 if ($stage && isset($stage->settings['default_task_assignees'])) {
904 2542 $defaultAssignees = $stage->settings['default_task_assignees'];
905 2543 foreach ($defaultAssignees as $assigneeId) {
906 2544 $alreadyAssigneeIds = $task->assignees->pluck('ID')->toArray();
@@ -906,13 +2544,39 @@
906 2544 $alreadyAssigneeIds = $task->assignees->pluck('ID')->toArray();
907 2545 $IfAlreadyAssignee = in_array($assigneeId, $alreadyAssigneeIds);
908 2546 if (!$IfAlreadyAssignee) {
909 2547 $this->updateAssignee($assigneeId, $task);
2548 + $assignmentsChanged = true;
910 2549 }
911 2550 }
912 2551 }
2552 + return $assignmentsChanged;
913 2553 }
914 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 +
915 2579 public function setDefaultAssigneesToEveryTasks($stage)
916 2580 {
917 2581 $tasks = $stage->tasks->whereNull('archived_at');
918 2582 foreach ($tasks as $task) {
@@ -918,5 +2582,1015 @@
918 2582 foreach ($tasks as $task) {
919 2583 $this->manageDefaultAssignees($task, $stage->id);
920 2584 }
921 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 + }
3595 +
922 3596 }