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