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 +2735 -226 1.312.1.0 View file →
@@ -1,42 +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;
5 7 use FluentBoards\App\Models\Attachment;
6 8 use FluentBoards\App\Models\Comment;
9 +use FluentBoards\App\Models\Notification;
7 10 use FluentBoards\App\Models\NotificationUser;
8 11 use FluentBoards\App\Models\TaskImage;
9 12 use FluentBoards\App\Services\Constant;
13 +use FluentBoards\App\Models\Label;
10 14 use FluentBoards\App\Models\Stage;
11 15 use FluentBoards\App\Models\Task;
12 16 use FluentBoards\App\Models\Board;
13 17 use FluentBoards\App\Models\TaskMeta;
18 +use FluentBoards\App\Models\Meta;
14 19 use FluentBoards\App\Models\Activity;
15 -use FluentBoards\App\Models\BoardTerm;
20 +use FluentBoards\App\Models\CommentImage;
21 +use FluentBoards\App\Models\Relation;
16 22 use FluentBoards\Framework\Support\Arr;
17 -use FluentBoardsPro\App\Modules\TimeTracking\TimeTrackingHelper;
18 -use FluentBoardsPro\App\Services\ProTaskService;
23 +use FluentBoardsPro\App\Models\TaskAttachment;
24 +use FluentBoardsPro\App\Services\AttachmentService;
19 25 use FluentBoardsPro\App\Services\RemoteUrlParser;
20 26 use FluentRoadmap\App\Models\IdeaReaction;
21 27
22 28 class TaskService
23 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 +
24 87 public function createTask($data, $boardId)
25 88 {
26 89 $board = Board::select('id', 'type')->find($boardId);
27 90
28 91 if (!$board) {
29 - throw new \Exception(__("Board doesn't exists", 'fluent-boards'));
92 + throw new \Exception(esc_html__("Board doesn't exists", 'fluent-boards'));
30 93 }
31 94
32 95 $stage = Stage::find($data['stage_id']);
33 96 if (!$stage) {
34 - throw new \Exception(__("Stage doesn't exists", 'fluent-boards'));
97 + throw new \Exception(esc_html__("Stage doesn't exists", 'fluent-boards'));
35 98 }
36 99
100 + if ((int) $stage->board_id !== (int) $boardId) {
101 + throw new \Exception(esc_html__("Stage doesn't exists", 'fluent-boards'));
102 + }
103 +
37 104 $data['status'] = $stage->defaultTaskStatus();
38 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 +
39 115 if ($board->type == 'roadmap') {
40 116 $current_user = wp_get_current_user();
41 117 $settingData = array(
42 118 'integration_type' => 'feature',
@@ -44,10 +120,14 @@
44 120 'author' => [
45 121 'email' => $current_user->user_email // email of who posted this feature
46 122 ],
47 123 );
48 - $data['settings'] = $settingData;
124 + $data['settings'] = array_merge($taskSettings, $settingData);
49 125 $data['type'] = 'roadmap';
126 + } elseif ($taskSettings) {
127 + $data['settings'] = $taskSettings;
128 + } else {
129 + unset($data['settings']);
50 130 }
51 131
52 132 $providerPosition = Arr::get($data, 'position');
53 133
@@ -53,11 +133,19 @@
53 133
54 134 $data['position'] = $this->getLastPositionOfTasks($stage->id);
55 135
56 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 + }
57 141
58 142 $data = array_filter($data);
59 143 $task = (new Task())->createTask($data);
144 +
145 + $this->manageDefaultAssignees($task, $stage->id);
146 + $this->manageDefaultWatchers($task, $stage->id);
147 +
60 148 if (isset($data['is_template']) && $data['is_template'] == 'yes') {
61 149 $task->updateMeta(Constant::IS_TASK_TEMPLATE, $data['is_template']);
62 150 }
63 151
@@ -83,21 +171,38 @@
83 171 }
84 172
85 173 public function getTasksForBoards($filters = ['overdue', 'upcoming'], $limit = 5, $task_ids = [])
86 174 {
175 + $assigned = $this->getTasksForBoardsByCategory('assigned', $limit, $task_ids);
87 176 $overDue = $this->getTasksForBoardsByCategory('overdue', $limit, $task_ids);
177 + $dueToday = $this->getTasksForBoardsByCategory('due_today', $limit, $task_ids);
88 178 $completed = $this->getTasksForBoardsByCategory('completed', $limit, $task_ids);
179 + $mentioned = $this->getTasksForBoardsByCategory('mentioned', $limit, $task_ids);
89 180 $upcoming = $this->getTasksForBoardsByCategory('upcoming', $limit, $task_ids);
90 181 $others = $this->getTasksForBoardsByCategory('others', $limit, $task_ids);
91 182
92 183 return [
184 + 'assigned' => $assigned ?? [],
93 185 'overdue' => $overDue ?? [],
186 + 'due_today' => $dueToday ?? [],
94 187 'upcoming' => $upcoming ?? [],
188 + 'mentioned' => $mentioned ?? [],
95 189 'completed' => $completed ?? [],
96 190 'others' => $others ?? []
97 191 ];
98 192 }
99 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 +
100 205 public function getTasksForBoardsByCategory($category, $limit, $taskIds)
101 206 {
102 207 unset($taskQuery);
103 208 $taskQuery = Task::whereIn('id', $taskIds)
@@ -103,20 +208,67 @@
103 208 $taskQuery = Task::whereIn('id', $taskIds)
104 209 ->with(['assignees', 'board', 'stage'])
105 210 ->whereNull('archived_at')
106 211 ->where('parent_id', null)
107 - ->orderBy('due_at', 'ASC');
212 + ->onActiveAvailableBoards()
213 + ->orderBy('due_at', 'DESC');
108 214
109 - if ('overdue' == $category) {
110 - $taskQuery->overdue();
111 - } elseif ('upcoming' == $category) {
112 - $taskQuery->upcoming();
113 - } elseif ('others' == $category) {
114 - $taskQuery->whereNull('due_at');
115 - } elseif ('completed' == $category) {
116 - $taskQuery->where('status', 'closed');
117 - } else {
118 - 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 [];
119 271 }
120 272
121 273 $tasks = $taskQuery->take($limit)->get();
122 274
@@ -122,8 +274,108 @@
122 274
123 275 return $tasks->toArray();
124 276 }
125 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 +
126 378 /*
127 379 * TODO: Refactor this function - For me.
128 380 */
129 381 public function updateTaskProperty($col, $value, $task)
@@ -130,70 +382,135 @@
130 382 {
131 383 $oldTask = clone $task; // normal assigning won't work here. because objects are passed by reference in php
132 384 $validColumns = [
133 385 'board_id',
134 - 'task_type',
135 - 'reminder_type',
386 + 'type',
387 +// 'reminder_type',
136 388 'remind_at',
137 389 'log_minutes',
390 + 'source',
391 + 'source_id',
138 392 'settings'
139 393 ];
140 394
395 + if ($col === 'description') {
396 + $value = DescriptionMarkdownConverter::normalize($value);
397 + }
398 +
141 399 if (in_array($col, $validColumns) && $task->{$col} != $value) {
142 - $task->{$col} = $value;
143 - $task->save();
144 - // do_action('fluent_boards/task_prop_changed', $col, $task, $oldTask);
145 - } elseif ('assignees' == $col) {
146 - if (is_array($value)) {
147 - foreach ($value as $id) {
148 - $this->updateAssignee($id, $task);
149 - }
150 - } else {
151 - $this->updateAssignee($value, $task);
400 + if ($col === 'remind_at') {
401 + $value = Helper::normalizeDateValue($value);
152 402 }
153 403
154 - } elseif ('crm_contact_id' == $col) {
155 - $this->updateAssociate($value, $task);
156 - } elseif ('archived_at' == $col) {
157 - $this->updateArchive($value, $task);
158 - } elseif ('status' == $col) {
159 - $this->updateStatus($value, $task);
160 - } elseif ('parent_id' == $col) {
161 - $this->updateParent($value, $task);
162 - } elseif ('title' == $col) {
163 - $this->updateTitle($col, $value, $task, $oldTask);
164 - } elseif ('description' == $col) {
165 - $this->updateDescription($col, $value, $task, $oldTask);
166 - } elseif ($col == 'due_at') {
167 - $this->updateDueDate($value, $task);
168 - } elseif ($col == 'started_at') {
169 - $this->updateStartedDate($value, $task);
170 - } elseif ($col == 'priority') {
171 - $this->updatePriority($value, $task);
172 - } elseif ($col == 'is_watching') {
173 - $this->updateObservationOfCurrentUser($value, $task);
174 - } elseif ($col == 'last_completed_at') {
175 - $isClosed = $value == 'true' || $value === true;
176 - if ($isClosed) {
177 - $task = $task->close();
178 - } else {
179 - $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']);
180 409 }
410 + $task->{$col} = $value ?: null;
181 411 $task->save();
182 - } elseif ($col == 'attachment_count') {
183 - $settings = $task->settings;
184 - $settings['attachment_count'] = $task->attachments()->count();
185 - $task->settings = $settings;
186 - $task->save();
187 - } elseif ($col == 'subtask_count') {
188 - $settings = $task->settings;
189 - $subtasksCount = Task::where('parent_id', $task->id)->count();
190 - $settings['subtask_count'] = $subtasksCount;
191 - $task->settings = $settings;
192 - $task->save();
193 - } elseif ($col == 'is_template') {
194 - if (defined('FLUENT_BOARDS_PRO')) {
195 - $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;
196 513 }
197 514 }
198 515
199 516 return $task;
@@ -207,9 +524,9 @@
207 524
208 525 $task->save();
209 526
210 527 if ($operation == 'added') {
211 - 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()) {
212 529 $this->sendMailAfterTaskModify('add_assignee', $payloadAssigneeId, $task->id);
213 530 }
214 531 // $assigneeIdsToSendEmail = $this->filterAssigneeToSendEmail($task, $idArray, Constant::BOARD_EMAIL_TASK_ASSIGN);
215 532 // $this->sendMailAfterAddAssignees($assigneeIdsToSendEmail, $task->id);
@@ -217,9 +534,9 @@
217 534 if($payloadAssigneeId != get_current_user_id()){
218 535 do_action('fluent_boards/assign_another_user', $task, $payloadAssigneeId);
219 536 }
220 537 } else {
221 - 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()) {
222 539 $this->sendMailAfterTaskModify('remove_assignee', $payloadAssigneeId, $task->id);
223 540 }
224 541 do_action('fluent_boards/task_assignee_removed', $task, $payloadAssigneeId);
225 542 }
@@ -275,17 +592,34 @@
275 592
276 593 private function updateArchive($value, $task)
277 594 {
278 595 if ($value != null) {
596 + // Archiving task
279 597 $task->position = 0;
280 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 +
281 612 $task->moveToNewPosition(1);
613 +
614 + // Clean up archived_by_stage meta when task is manually restored
615 + $this->cleanupArchivedByStageMetaIfExists($task->id);
282 616 }
283 617 $task->archived_at = $value == null ? null : current_time('mysql');
284 618 $task->save();
285 619 do_action('fluent_boards/task_archived', $task);
286 - $wathersToSendEmail = (new NotificationService())->filterAssigneeToSendEmail($task->id, Constant::BOARD_EMAIL_TASK_ARCHIVE);
287 - $this->sendMailAfterTaskModify('task_archived', $wathersToSendEmail, $task->id);
620 + $watchersToSendEmail = (new NotificationService())->filterAssigneeToSendEmail($task->id, Constant::BOARD_EMAIL_TASK_ARCHIVE);
621 + $this->sendMailAfterTaskModify('task_archived', $watchersToSendEmail, $task->id);
288 622 }
289 623
290 624 private function updateStatus($value, $task)
291 625 {
@@ -312,9 +646,9 @@
312 646 }
313 647
314 648 private function updateDescription($col, $value, $task, $oldTask)
315 649 {
316 - $task->description = $value;
650 + $task->description = DescriptionMarkdownConverter::normalize($value);
317 651 $task->save();
318 652 do_action('fluent_boards/task_content_updated', $task, $col, $oldTask);
319 653 }
320 654
@@ -320,15 +654,19 @@
320 654
321 655 private function updateDueDate($value, $task)
322 656 {
323 657 $oldValue = $task->due_at;
324 - $value = $this->filterNullDate($value);
658 + $value = Helper::normalizeDateValue($value);
325 659 $task->due_at = $value;
326 660 $task->save();
327 661
328 662 $task = $task->reopen();
329 663
330 - do_action('fluent_boards/task_due_date_changed', $task, $oldValue);
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 + }
331 669
332 670 $wathersToSendEmail = (new NotificationService())->filterAssigneeToSendEmail($task->id, Constant::BOARD_EMAIL_DUE_DATE_CHANGE);
333 671 $this->sendMailAfterTaskModify('due_date_update', $wathersToSendEmail, $task->id);
334 672 }
@@ -335,13 +673,15 @@
335 673
336 674 private function updateStartedDate($value, $task)
337 675 {
338 676 $oldValue = $task->started_at;
339 - $value = $this->filterNullDate($value);
677 + $value = Helper::normalizeDateValue($value);
340 678 $task->started_at = $value;
341 679 $task->save();
342 680
343 - do_action('fluent_boards/task_start_date_changed', $task, $oldValue);
681 + if($value){
682 + do_action('fluent_boards/task_start_date_changed', $task, $oldValue);
683 + }
344 684 }
345 685
346 686 private function updatePriority($value, $task)
347 687 {
@@ -350,46 +690,53 @@
350 690 $task->save();
351 691 do_action('fluent_boards/task_priority_changed', $task, $oldPriority);
352 692 }
353 693
354 - public function updateObservationOfCurrentUser($value, $task)
355 - {
356 - $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 + }
357 703
358 - if ($value == 'stop') {
359 - $task->watchers()->detach($currentUserId);
360 - } else {
361 - $task->watchers()->syncWithoutDetaching([$currentUserId => ['object_type' => Constant::OBJECT_TYPE_USER_TASK_WATCH]]);
362 - }
363 - $task->updated_at = current_time('mysql');
364 - $task->save();
704 + if (!$userId || !in_array($action, ['stop', 'start'])) {
705 + return;
706 + }
365 707
366 - if ($value == 'stop') {
367 - $task->is_watching = false;
368 - } else {
369 - $task->is_watching = true;
370 - }
708 + if ($action == 'stop') {
709 + $task->watchers()->detach($userId);
710 + } else {
711 + $task->watchers()->syncWithoutDetaching([$userId => ['object_type' => Constant::OBJECT_TYPE_USER_TASK_WATCH]]);
371 712 }
713 + $task->updated_at = current_time('mysql');
714 + $task->save();
715 +}
372 716
373 - public function taskCoverPhotoUpdate($taskId, $imagePath)
717 + public function taskCoverPhotoUpdate($taskId, $imagePath, $boardId = null)
374 718 {
375 - $task = Task::find($taskId);
719 + $task = $boardId ? $this->findTaskOnBoard($taskId, $boardId) : Task::find($taskId);
376 720 if (!$task) {
377 721 return null;
378 722 }
379 723
380 - $settings = unserialize($task->settings);
724 + $settings = $task->settings;
725 + if (!is_array($settings)) {
726 + $settings = [];
727 + }
381 728
382 729 $settings['logo'] = $imagePath;
383 - $task->settings = serialize($settings);
730 + $task->settings = $settings;
384 731 $task->save();
385 732
386 733 return $task;
387 734 }
388 735
389 - public function taskStatusUpdate($taskId, $integrationType)
736 + public function taskStatusUpdate($taskId, $integrationType, $boardId = null)
390 737 {
391 - $task = Task::find($taskId);
738 + $task = $boardId ? $this->findTaskOnBoard($taskId, $boardId) : Task::find($taskId);
392 739 if (!$task) {
393 740 return null;
394 741 }
395 742
@@ -394,9 +741,9 @@
394 741 }
395 742
396 743 $settings = $task->settings;
397 744 $settings['integration_type'] = $integrationType;
398 - $task->settings = serialize($settings);
745 + $task->settings = $settings;
399 746 $task->save();
400 747
401 748 return $task;
402 749 }
@@ -402,9 +749,9 @@
402 749 }
403 750
404 751 public function assignYourselfInTask($boardId, $taskId)
405 752 {
406 - $task = Task::find($taskId);
753 + $task = $this->findTaskOnBoard($taskId, $boardId);
407 754 $authUserId = get_current_user_id();
408 755
409 756 $boardService = new BoardService();
410 757 if (!$boardService->isAlreadyMember($boardId, $authUserId)) {
@@ -415,8 +762,9 @@
415 762 // when user assign himself then he will be watching that task
416 763 $task->watchers()->syncWithoutDetaching([$authUserId => ['object_type' => Constant::OBJECT_TYPE_USER_TASK_WATCH]]);
417 764
418 765 $task->load('assignees');
766 + do_action('fluent_boards/task_assignee_added', $task, $authUserId);
419 767
420 768 return $task;
421 769 }
422 770
@@ -421,11 +769,13 @@
421 769 }
422 770
423 771 public function detachYourselfFromTask($boardId, $taskId)
424 772 {
425 - $task = Task::find($taskId);
426 - $task->addOrRemoveAssignee(get_current_user_id());
773 + $task = $this->findTaskOnBoard($taskId, $boardId);
774 + $currentUserId = get_current_user_id();
775 + $task->addOrRemoveAssignee($currentUserId);
427 776 $task->load('assignees');
777 + do_action('fluent_boards/task_assignee_removed', $task, $currentUserId);
428 778
429 779 return $task;
430 780 }
431 781
@@ -430,8 +780,158 @@
430 780 }
431 781
432 782 public function deleteTask($task)
433 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 +
434 934 $deleted = $task->delete();
435 935
436 936 if ($deleted) {
437 937
@@ -447,23 +947,18 @@
447 947 //task labels removed
448 948 $task->labels()->detach();
449 949
450 950 //task custom field value
451 - $task->customFields()->detach();
951 + if (defined('FLUENT_BOARDS_PRO_VERSION')) {
952 + $task->customFields()->detach();
953 + $this->deleteTaskAttachments($task);
954 + }
452 955
453 - 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.
454 958 TaskMeta::where('task_id', $task->id)->delete();
455 959 }
456 960 }
457 -
458 - public function filterNullDate($date)
459 - {
460 - if ('0000-00-00 00:00:00' == $date || false === strtotime($date)) {
461 - return null;
462 - }
463 - return $date;
464 - }
465 -
466 961 // this is invoked when task is moved to another board
467 962
468 963 /**
469 964 * @throws \Exception
@@ -469,77 +964,383 @@
469 964 * @throws \Exception
470 965 */
471 966 public function changeBoardByTask($task, $targetBoardId)
472 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 +
473 974 if ($task->board_id == $targetBoardId) {
474 975 return $task;
475 976 }
476 977
477 978 $oldBoard = Board::find($task->board_id);
979 + $newBoard = Board::find($targetBoardId);
478 980
479 - $newBoard = Board::find($targetBoardId);
981 + if (!$oldBoard) {
982 + throw new \Exception(esc_html__('Source board not found', 'fluent-boards'), 404);
983 + }
984 +
480 985 if (!$newBoard) {
481 - throw new \Exception('Invalid board id', 400);
986 + throw new \Exception(esc_html__('Target board not found', 'fluent-boards'), 404);
482 987 }
483 - $task->board_id = $targetBoardId;
484 - $task->save();
485 - //delete labels of that task because labels have board dependencies
486 - $task->labels()->detach();
487 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 +
488 1026 do_action('fluent_boards/task_moved_from_board', $task, $oldBoard, $newBoard);
1027 + return $task;
1028 + }
489 1029
490 - 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 + }
491 1069 }
492 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 + }
493 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 +
494 1186 public function getIdeaVoteStatistics($taskId)
495 1187 {
496 - $reactionTypes = [
497 - [
498 - 'label' => 'Upvote',
499 - 'type' => 'upvote'
500 - ],
501 - [
502 - 'label' => 'Downvote',
503 - 'type' => 'downvote'
504 - ]
505 - ];
1188 + $taskId = absint($taskId);
1189 + $voteStatistics = $this->getIdeaVoteStatisticsByTaskIds([$taskId]);
506 1190
507 - $reactionCounts = [];
1191 + return $voteStatistics[$taskId] ?? 0;
1192 + }
508 1193
509 - foreach ($reactionTypes as $reactionType) {
510 - $count = IdeaReaction::where('object_id', $taskId)
511 - ->where('object_type', 'idea')
512 - ->where('type', $reactionType['type'])
513 - ->count();
1194 + public function loadIdeaVoteStatistics($tasks)
1195 + {
1196 + $taskIds = [];
514 1197
515 - $reactionCounts[] = [
516 - 'label' => $reactionType['label'],
517 - 'type' => $reactionType['type'],
518 - 'count' => $count
519 - ];
1198 + foreach ($tasks as $task) {
1199 + $taskId = absint($task->id);
1200 + if ($taskId) {
1201 + $taskIds[] = $taskId;
1202 + }
520 1203 }
521 1204
522 - 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;
523 1212 }
524 1213
1214 + private function getIdeaVoteStatisticsByTaskIds(array $taskIds)
1215 + {
1216 + global $wpdb;
525 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 +
526 1258 /**
527 - * Summary of getArchivedOrCompletedTasks
528 - * this function will return completd tasks or archived tasks based on users input and also can search by name
529 - * @param mixed $data
530 - * @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
531 1327 * @return mixed
532 1328 * @throws \Exception
533 1329 */
534 1330 public function getArchivedTasks($data, $boardId)
535 1331 {
536 - $per_page = isset($data['per_page']) ? $data['per_page'] : 25;
537 - $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));
538 1339 $tasksQuery = Task::where('board_id', $boardId)->whereNotNull('archived_at');
539 1340
540 - if (isset($data['searchInput'])) {
541 - $query = strtolower($data['searchInput']);
1341 + if (!empty($data['query'])) {
1342 + $query = strtolower($data['query']);
542 1343 $firstThreeChars = substr($query, 0, 3);
543 1344
544 1345 if($firstThreeChars == 'id:') {
545 1346 $idPart = substr($query, 3);
@@ -545,20 +1346,528 @@
545 1346 $idPart = substr($query, 3);
546 1347 $idPart = preg_replace('/[^a-zA-Z0-9]/', '', $idPart);
547 1348 $tasksQuery = $tasksQuery->where('id', 'LIKE', '%' . $idPart . '%');
548 1349 } else {
549 - $tasksQuery = $tasksQuery->where('title', 'LIKE', '%' . $data['searchInput'] . '%');
1350 + $tasksQuery = $tasksQuery->where('title', 'LIKE', '%' . $data['query'] . '%');
550 1351 }
551 1352 }
552 1353
553 - // if board_id is not passed then throw an exception
554 - if (!$boardId) {
555 - 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;
556 1361 }
557 1362
558 - 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;
559 1397 }
560 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 +
561 1870 public function sendMailAfterTaskModify($column, $assigneeIds, $taskId)
562 1871 {
563 1872 $current_user_id = get_current_user_id();
564 1873 /* this will run in background as soon as possible */
@@ -569,14 +1878,17 @@
569 1878
570 1879 public function getStageByTask($task_id)
571 1880 {
572 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 + }
573 1885 return $task->stage;
574 1886 }
575 1887
576 - public function moveTaskToNextStage($task_id)
1888 + public function moveTaskToNextStage($task_id, $boardId = null)
577 1889 {
578 - $task = Task::findOrFail($task_id);
1890 + $task = $boardId ? $this->findTaskOnBoard($task_id, $boardId) : Task::findOrFail($task_id);
579 1891
580 1892 $oldStage = $task->stage;
581 1893
582 1894 $nextStage = Stage::where('board_id', $task->board_id)
@@ -594,8 +1906,11 @@
594 1906 $task->last_completed_at = current_time('mysql');
595 1907 }
596 1908 }
597 1909
1910 + // Clean up archived_by_stage meta when moving to different stage
1911 + $this->cleanupArchivedByStageMetaIfExists($task->id);
1912 +
598 1913 $task->stage_id = $nextStage->id;
599 1914 $task->save();
600 1915
601 1916 $task->load(['board', 'stage', 'attachments']);
@@ -625,27 +1940,36 @@
625 1940 $activityQuery = $activityQuery->latest();
626 1941 } else if ($filter == 'oldest') {
627 1942 $activityQuery = $activityQuery->oldest();
628 1943 }
629 - return $activityQuery->with('user')->paginate($perPage);
1944 + $activities = $activityQuery->with('user')->paginate($perPage);
1945 +
1946 + Helper::translateActivities($activities);
1947 +
1948 + return $activities;
630 1949 }
631 1950
632 - public function getLastOneMinuteUpdatedTasks($boardId, $lastUpdated = null)
1951 + public function getLastOneMinuteUpdatedTasks($boardId, $lastUpdated = null, $includeArchived = true)
633 1952 {
634 1953 if (!$lastUpdated) {
635 - $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
636 1955 }
637 1956
638 - $tasks = Task::query()
1957 + $tasksQuery = Task::query()
639 1958 ->where([
640 1959 'board_id' => $boardId,
641 1960 'parent_id' => null,
642 1961 ])
643 - ->where('updated_at', '>', $lastUpdated)
1962 + ->where('updated_at', '>=', $lastUpdated) // updated since the sync cursor
644 1963 ->with(['assignees', 'labels', 'watchers', 'taskCustomFields'])
645 - ->orderBy('due_at', 'ASC')
646 - ->get();
1964 + ->orderBy('due_at', 'ASC');
647 1965
1966 + if (!$includeArchived) {
1967 + $tasksQuery->whereNull('archived_at');
1968 + }
1969 +
1970 + $tasks = $tasksQuery->get();
1971 +
648 1972 foreach ($tasks as $task) {
649 1973 $task->isOverdue = $task->isOverdue();
650 1974 $task->isUpcoming = $task->upcoming();
651 1975 $task->is_watching = $task->isWatching();
@@ -668,88 +1992,212 @@
668 1992
669 1993 return $lastPosition + 1;
670 1994 }
671 1995
672 - 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)
673 2004 {
674 - $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()
675 2047 ->where('crm_contact_id', $associatedId)
676 - ->with(['board', 'stage', 'assignees', 'labels', 'watchers',])
677 - ->orderBy('due_at', 'ASC')
678 - ->get();
2048 + ->with(['board', 'stage', 'assignees', 'subtaskGroup', 'subtaskGroup.subtasks', 'subtaskGroup.subtasks.assignees'])
2049 + ->orderBy('due_at', 'ASC');
679 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 +
680 2067 foreach ($tasks as $task) {
681 2068 $task->isOverdue = $task->isOverdue();
682 2069 $task->isUpcoming = $task->upcoming();
683 - $task->contact = Task::lead_contact($task->crm_contact_id);
684 - $task->is_watching = $task->isWatching();
2070 + $task->can_edit = $isAdmin || in_array((int)$task->board_id, $editableBoardIds, true);
685 2071
686 2072 $task->assignees = Helper::sanitizeUserCollections($task->assignees);
687 - $task->watchers = Helper::sanitizeUserCollections($task->watchers);
688 2073
689 - if(!!defined('FLUENT_BOARDS_PRO_VERSION')) {
690 - $task->time_tracks= [
691 - 'tracks' => (new ProTaskService())->getTaskTimeTrack($task->board_id, $task->id),
692 - 'estimated_minutes' => TimeTrackingHelper::getTaskEstimation($task->id)
693 - ];
2074 + foreach ($task->subtaskGroup as $group) {
2075 + foreach ($group->subtasks as $subtask) {
2076 + $subtask->assignees = Helper::sanitizeUserCollections($subtask->assignees);
2077 + }
694 2078 }
2079 + $task->subtask_group = $task->subtaskGroup;
2080 + }
695 2081
2082 + return $tasks;
2083 + }
696 2084
697 - $subTasks = Task::query()
698 - ->where('parent_id', $task->id)
699 - ->with(['assignees'])
700 - ->whereNull('archived_at')
701 - ->orderBy('position', 'ASC')
702 - ->get();
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 + ]);
703 2094
704 - foreach ($subTasks as $subTask) {
705 - $subTask->assignees = Helper::sanitizeUserCollections($subTask->assignees);
706 - }
707 -
708 - $task->subtasks = $subTasks;
2095 + $subtaskGroupMap[$group->id] = $newGroup->id;
709 2096 }
710 2097
711 - return $tasks;
2098 + return $subtaskGroupMap;
712 2099 }
713 2100
714 - public function copyTasks($boardId, $stageMap, $newBoard, $labelMap = [])
2101 + public function copyTasks($boardId, $stageMap, $newBoard, $labelMap = [],$isWithTemplates='no')
715 2102 {
716 2103 $allActiveTasks = Task::where('board_id', $boardId)->whereNull('archived_at')->get();
717 2104 $taskMap = [];
2105 + $subtaskGroupMap = [];
718 2106 $parentTaskCount = 0;
719 - foreach ($allActiveTasks as $task) {
720 - $newTask = array();
721 - $newTask['title'] = $task->title;
722 - $newTask['parent_id'] = $task->parent_id ? $taskMap[$task->parent_id] : null;
723 - $newTask['description'] = $task->description;
724 - $newTask['board_id'] = $newBoard->id;
725 - $newTask['stage_id'] = $stageMap[$task->stage_id];
726 - $newTask['status'] = $task->status;
727 - $newTask['priority'] = $task->priority;
728 - $newTask['position'] = $task->position;
729 - $newTask['due_at'] = $task->due_at;
730 - $newTask = Task::create($newTask);
731 - if(!$task->parent_id){
732 - ++$parentTaskCount;
733 - $taskMap[$task['id']] = $newTask->id;
734 - //duplicate labels to task
735 - $labelIds = $task->labels->pluck('id')->toArray();
736 - if($labelIds){
737 - $flipLabelIds = array_flip($labelIds);
738 - $labelsToAttach = array_intersect_key($labelMap, $flipLabelIds);
2107 + $attachmentFileService = new AttachmentFileService();
2108 + $dbInstance = App::getInstance('db');
739 2109
740 - $newTask->labels()->attach($labelsToAttach, [
741 - 'object_type' => Constant::OBJECT_TYPE_TASK_LABEL
742 - ]);
2110 + $dbInstance->beginTransaction();
2111 +
2112 + try {
2113 + foreach ($allActiveTasks as $task) {
2114 + if ($task->parent_id && empty($taskMap[$task->parent_id])) {
2115 + continue;
743 2116 }
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 + }
744 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;
745 2199 }
746 -
747 - $board = Board::findOrFail($newBoard->id);
748 - $settings = [];
749 - $settings['tasks_count'] = $parentTaskCount;
750 - $board->settings = $settings;
751 - $board->save();
752 2200 }
753 2201
754 2202 private function subtaskCountUpdate($taskId){
755 2203 $parentTask = Task::findOrFail($taskId);
@@ -761,21 +2209,42 @@
761 2209
762 2210 /**
763 2211 * @param $taskId
764 2212 * @param $perPage
765 - * @param $offset
2213 + * @param $page
766 2214 * @param string $filter
2215 + * @param $boardId
2216 + * @param string $feedType
767 2217 * @return array
768 2218 */
769 - public function getCommentsAndActivities($taskId, $perPage, $page, string $filter = 'newest'): array
2219 + public function getCommentsAndActivities($taskId, $perPage, $page, string $filter = 'newest', $boardId = null, string $feedType = 'all'): array
770 2220 {
771 2221 // Fetch the task
772 - $task = Task::findOrFail($taskId);
2222 + $task = $boardId ? $this->findTaskOnBoard($taskId, $boardId) : Task::findOrFail($taskId);
2223 + $feedType = in_array($feedType, ['all', 'comments', 'activities'], true) ? $feedType : 'all';
773 2224
774 2225 // Fetch comments and activities separately
775 - $comments = $task->comments()->with('user')->orderBy('created_at', 'desc')->get()->toArray();
776 - $activities = $task->activities()->with('user')->orderBy('created_at', 'desc')->get()->toArray();
2226 + $comments = [];
2227 + if ($feedType !== 'activities') {
2228 + $comments = $task->comments()->with(['user', 'replies.user'])->orderBy('created_at', 'desc')->get()->toArray();
2229 + }
777 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 +
778 2247 // Merge comments and activities into a single array
779 2248 $commentsAndActivities = array_merge($comments, $activities);
780 2249
781 2250 // Sort the merged array by created_at date in ascending or descending order
@@ -869,9 +2338,9 @@
869 2338 * @param $title
870 2339 * @param $UrlMeta
871 2340 * @return mixed|string
872 2341 */
873 - private function setTitle($type, $title, $UrlMeta)
2342 + public function setTitle($type, $title, $UrlMeta)
874 2343 {
875 2344 if($type != 'url') {
876 2345 return sanitize_file_name($title);
877 2346 }
@@ -877,6 +2346,1046 @@
877 2346 }
878 2347 return $title ?? $UrlMeta['title'] ?? '';
879 2348 }
880 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 + }
881 3390
882 3391 }