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