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 +2689 -219 1.412.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)
@@ -134,76 +383,135 @@
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 386 'type',
138 - 'reminder_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 - if($col == 'settings' && $value['cover']['backgroundColor']) {
400 + if ($col === 'remind_at') {
401 + $value = Helper::normalizeDateValue($value);
402 + }
403 +
404 + if ($col == 'settings' && isset($value['cover']['backgroundColor']) && $value['cover']['backgroundColor']) {
146 405 $settings = $task->settings;
147 406 $this->deleteTaskCoverImage($settings);
148 407 unset($value['cover']['imageId']);
149 408 unset($value['cover']['backgroundImage']);
150 409 }
151 - $task->{$col} = $value;
410 + $task->{$col} = $value ?: null;
152 411 $task->save();
153 412 // do_action('fluent_boards/task_prop_changed', $col, $task, $oldTask);
154 - } elseif ('assignees' == $col) {
155 - if (is_array($value)) {
156 - foreach ($value as $id) {
157 - $this->updateAssignee($id, $task);
158 - }
159 - } else {
160 - $this->updateAssignee($value, $task);
161 - }
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;
162 424
163 - } elseif ('crm_contact_id' == $col) {
164 - $this->updateAssociate($value, $task);
165 - } elseif ('archived_at' == $col) {
166 - $this->updateArchive($value, $task);
167 - } elseif ('status' == $col) {
168 - $this->updateStatus($value, $task);
169 - } elseif ('parent_id' == $col) {
170 - $this->updateParent($value, $task);
171 - } elseif ('title' == $col) {
172 - $this->updateTitle($col, $value, $task, $oldTask);
173 - } elseif ('description' == $col) {
174 - $this->updateDescription($col, $value, $task, $oldTask);
175 - } elseif ($col == 'due_at') {
176 - $this->updateDueDate($value, $task);
177 - } elseif ($col == 'started_at') {
178 - $this->updateStartedDate($value, $task);
179 - } elseif ($col == 'priority') {
180 - $this->updatePriority($value, $task);
181 - } elseif ($col == 'is_watching') {
182 - $this->updateObservationOfCurrentUser($value, $task);
183 - } elseif ($col == 'last_completed_at') {
184 - $isClosed = $value == 'true' || $value === true;
185 - if ($isClosed) {
186 - $task = $task->close();
187 - } else {
188 - $task = $task->reopen();
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;
189 513 }
190 - $task->save();
191 - } elseif ($col == 'attachment_count') {
192 - $settings = $task->settings;
193 - $settings['attachment_count'] = $task->attachments()->count();
194 - $task->settings = $settings;
195 - $task->save();
196 - } elseif ($col == 'subtask_count') {
197 - $settings = $task->settings;
198 - $subtasksCount = Task::where('parent_id', $task->id)->count();
199 - $settings['subtask_count'] = $subtasksCount;
200 - $task->settings = $settings;
201 - $task->save();
202 - } elseif ($col == 'is_template') {
203 - if (defined('FLUENT_BOARDS_PRO')) {
204 - $task->updateMeta(Constant::IS_TASK_TEMPLATE, $value);
205 - }
206 514 }
207 515
208 516 return $task;
209 517 }
@@ -216,9 +524,9 @@
216 524
217 525 $task->save();
218 526
219 527 if ($operation == 'added') {
220 - 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()) {
221 529 $this->sendMailAfterTaskModify('add_assignee', $payloadAssigneeId, $task->id);
222 530 }
223 531 // $assigneeIdsToSendEmail = $this->filterAssigneeToSendEmail($task, $idArray, Constant::BOARD_EMAIL_TASK_ASSIGN);
224 532 // $this->sendMailAfterAddAssignees($assigneeIdsToSendEmail, $task->id);
@@ -226,9 +534,9 @@
226 534 if($payloadAssigneeId != get_current_user_id()){
227 535 do_action('fluent_boards/assign_another_user', $task, $payloadAssigneeId);
228 536 }
229 537 } else {
230 - 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()) {
231 539 $this->sendMailAfterTaskModify('remove_assignee', $payloadAssigneeId, $task->id);
232 540 }
233 541 do_action('fluent_boards/task_assignee_removed', $task, $payloadAssigneeId);
234 542 }
@@ -284,17 +592,34 @@
284 592
285 593 private function updateArchive($value, $task)
286 594 {
287 595 if ($value != null) {
596 + // Archiving task
288 597 $task->position = 0;
289 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 +
290 612 $task->moveToNewPosition(1);
613 +
614 + // Clean up archived_by_stage meta when task is manually restored
615 + $this->cleanupArchivedByStageMetaIfExists($task->id);
291 616 }
292 617 $task->archived_at = $value == null ? null : current_time('mysql');
293 618 $task->save();
294 619 do_action('fluent_boards/task_archived', $task);
295 - $wathersToSendEmail = (new NotificationService())->filterAssigneeToSendEmail($task->id, Constant::BOARD_EMAIL_TASK_ARCHIVE);
296 - $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);
297 622 }
298 623
299 624 private function updateStatus($value, $task)
300 625 {
@@ -321,9 +646,9 @@
321 646 }
322 647
323 648 private function updateDescription($col, $value, $task, $oldTask)
324 649 {
325 - $task->description = $value;
650 + $task->description = DescriptionMarkdownConverter::normalize($value);
326 651 $task->save();
327 652 do_action('fluent_boards/task_content_updated', $task, $col, $oldTask);
328 653 }
329 654
@@ -329,15 +654,19 @@
329 654
330 655 private function updateDueDate($value, $task)
331 656 {
332 657 $oldValue = $task->due_at;
333 - $value = $this->filterNullDate($value);
658 + $value = Helper::normalizeDateValue($value);
334 659 $task->due_at = $value;
335 660 $task->save();
336 661
337 662 $task = $task->reopen();
338 663
339 - 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 + }
340 669
341 670 $wathersToSendEmail = (new NotificationService())->filterAssigneeToSendEmail($task->id, Constant::BOARD_EMAIL_DUE_DATE_CHANGE);
342 671 $this->sendMailAfterTaskModify('due_date_update', $wathersToSendEmail, $task->id);
343 672 }
@@ -344,13 +673,15 @@
344 673
345 674 private function updateStartedDate($value, $task)
346 675 {
347 676 $oldValue = $task->started_at;
348 - $value = $this->filterNullDate($value);
677 + $value = Helper::normalizeDateValue($value);
349 678 $task->started_at = $value;
350 679 $task->save();
351 680
352 - 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 + }
353 684 }
354 685
355 686 private function updatePriority($value, $task)
356 687 {
@@ -359,30 +690,34 @@
359 690 $task->save();
360 691 do_action('fluent_boards/task_priority_changed', $task, $oldPriority);
361 692 }
362 693
363 - public function updateObservationOfCurrentUser($value, $task)
364 - {
365 - $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 + }
366 703
367 - if ($value == 'stop') {
368 - $task->watchers()->detach($currentUserId);
369 - } else {
370 - $task->watchers()->syncWithoutDetaching([$currentUserId => ['object_type' => Constant::OBJECT_TYPE_USER_TASK_WATCH]]);
371 - }
372 - $task->updated_at = current_time('mysql');
373 - $task->save();
704 + if (!$userId || !in_array($action, ['stop', 'start'])) {
705 + return;
706 + }
374 707
375 - if ($value == 'stop') {
376 - $task->is_watching = false;
377 - } else {
378 - $task->is_watching = true;
379 - }
708 + if ($action == 'stop') {
709 + $task->watchers()->detach($userId);
710 + } else {
711 + $task->watchers()->syncWithoutDetaching([$userId => ['object_type' => Constant::OBJECT_TYPE_USER_TASK_WATCH]]);
380 712 }
713 + $task->updated_at = current_time('mysql');
714 + $task->save();
715 +}
381 716
382 - public function taskCoverPhotoUpdate($taskId, $imagePath)
717 + public function taskCoverPhotoUpdate($taskId, $imagePath, $boardId = null)
383 718 {
384 - $task = Task::find($taskId);
719 + $task = $boardId ? $this->findTaskOnBoard($taskId, $boardId) : Task::find($taskId);
385 720 if (!$task) {
386 721 return null;
387 722 }
388 723
@@ -397,11 +732,11 @@
397 732
398 733 return $task;
399 734 }
400 735
401 - public function taskStatusUpdate($taskId, $integrationType)
736 + public function taskStatusUpdate($taskId, $integrationType, $boardId = null)
402 737 {
403 - $task = Task::find($taskId);
738 + $task = $boardId ? $this->findTaskOnBoard($taskId, $boardId) : Task::find($taskId);
404 739 if (!$task) {
405 740 return null;
406 741 }
407 742
@@ -414,9 +749,9 @@
414 749 }
415 750
416 751 public function assignYourselfInTask($boardId, $taskId)
417 752 {
418 - $task = Task::find($taskId);
753 + $task = $this->findTaskOnBoard($taskId, $boardId);
419 754 $authUserId = get_current_user_id();
420 755
421 756 $boardService = new BoardService();
422 757 if (!$boardService->isAlreadyMember($boardId, $authUserId)) {
@@ -423,9 +758,8 @@
423 758 $boardService->addMembersInBoard($boardId, $authUserId);
424 759 }
425 760
426 761 $task->addOrRemoveAssignee($authUserId);
427 - do_action('fluent_boards/task_assignee_added', $task, $authUserId);
428 762 // when user assign himself then he will be watching that task
429 763 $task->watchers()->syncWithoutDetaching([$authUserId => ['object_type' => Constant::OBJECT_TYPE_USER_TASK_WATCH]]);
430 764
431 765 $task->load('assignees');
@@ -435,9 +769,9 @@
435 769 }
436 770
437 771 public function detachYourselfFromTask($boardId, $taskId)
438 772 {
439 - $task = Task::find($taskId);
773 + $task = $this->findTaskOnBoard($taskId, $boardId);
440 774 $currentUserId = get_current_user_id();
441 775 $task->addOrRemoveAssignee($currentUserId);
442 776 $task->load('assignees');
443 777 do_action('fluent_boards/task_assignee_removed', $task, $currentUserId);
@@ -446,8 +780,158 @@
446 780 }
447 781
448 782 public function deleteTask($task)
449 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 +
450 934 $deleted = $task->delete();
451 935
452 936 if ($deleted) {
453 937
@@ -463,23 +947,18 @@
463 947 //task labels removed
464 948 $task->labels()->detach();
465 949
466 950 //task custom field value
467 - $task->customFields()->detach();
951 + if (defined('FLUENT_BOARDS_PRO_VERSION')) {
952 + $task->customFields()->detach();
953 + $this->deleteTaskAttachments($task);
954 + }
468 955
469 - 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.
470 958 TaskMeta::where('task_id', $task->id)->delete();
471 959 }
472 960 }
473 -
474 - public function filterNullDate($date)
475 - {
476 - if ('0000-00-00 00:00:00' == $date || false === strtotime($date)) {
477 - return null;
478 - }
479 - return $date;
480 - }
481 -
482 961 // this is invoked when task is moved to another board
483 962
484 963 /**
485 964 * @throws \Exception
@@ -485,54 +964,383 @@
485 964 * @throws \Exception
486 965 */
487 966 public function changeBoardByTask($task, $targetBoardId)
488 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 +
489 974 if ($task->board_id == $targetBoardId) {
490 975 return $task;
491 976 }
492 977
493 978 $oldBoard = Board::find($task->board_id);
979 + $newBoard = Board::find($targetBoardId);
494 980
495 - $newBoard = Board::find($targetBoardId);
981 + if (!$oldBoard) {
982 + throw new \Exception(esc_html__('Source board not found', 'fluent-boards'), 404);
983 + }
984 +
496 985 if (!$newBoard) {
497 - throw new \Exception('Invalid board id', 400);
986 + throw new \Exception(esc_html__('Target board not found', 'fluent-boards'), 404);
498 987 }
499 - $task->board_id = $targetBoardId;
500 - $task->save();
501 - //delete labels of that task because labels have board dependencies
502 - $task->labels()->detach();
503 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 +
504 1026 do_action('fluent_boards/task_moved_from_board', $task, $oldBoard, $newBoard);
1027 + return $task;
1028 + }
505 1029
506 - 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 + }
507 1069 }
508 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 + }
509 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 +
510 1186 public function getIdeaVoteStatistics($taskId)
511 1187 {
512 - return IdeaReaction::where('object_id', $taskId)
513 - ->where('object_type', 'idea')
514 - ->where('type', 'upvote')
515 - ->count();
1188 + $taskId = absint($taskId);
1189 + $voteStatistics = $this->getIdeaVoteStatisticsByTaskIds([$taskId]);
1190 +
1191 + return $voteStatistics[$taskId] ?? 0;
516 1192 }
517 1193
1194 + public function loadIdeaVoteStatistics($tasks)
1195 + {
1196 + $taskIds = [];
518 1197
1198 + foreach ($tasks as $task) {
1199 + $taskId = absint($task->id);
1200 + if ($taskId) {
1201 + $taskIds[] = $taskId;
1202 + }
1203 + }
1204 +
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;
1212 + }
1213 +
1214 + private function getIdeaVoteStatisticsByTaskIds(array $taskIds)
1215 + {
1216 + global $wpdb;
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 +
519 1258 /**
520 - * Summary of getArchivedOrCompletedTasks
521 - * this function will return completd tasks or archived tasks based on users input and also can search by name
522 - * @param mixed $data
523 - * @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
524 1327 * @return mixed
525 1328 * @throws \Exception
526 1329 */
527 1330 public function getArchivedTasks($data, $boardId)
528 1331 {
529 - $per_page = isset($data['per_page']) ? $data['per_page'] : 25;
530 - $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));
531 1339 $tasksQuery = Task::where('board_id', $boardId)->whereNotNull('archived_at');
532 1340
533 - if (isset($data['searchInput'])) {
534 - $query = strtolower($data['searchInput']);
1341 + if (!empty($data['query'])) {
1342 + $query = strtolower($data['query']);
535 1343 $firstThreeChars = substr($query, 0, 3);
536 1344
537 1345 if($firstThreeChars == 'id:') {
538 1346 $idPart = substr($query, 3);
@@ -538,20 +1346,528 @@
538 1346 $idPart = substr($query, 3);
539 1347 $idPart = preg_replace('/[^a-zA-Z0-9]/', '', $idPart);
540 1348 $tasksQuery = $tasksQuery->where('id', 'LIKE', '%' . $idPart . '%');
541 1349 } else {
542 - $tasksQuery = $tasksQuery->where('title', 'LIKE', '%' . $data['searchInput'] . '%');
1350 + $tasksQuery = $tasksQuery->where('title', 'LIKE', '%' . $data['query'] . '%');
543 1351 }
544 1352 }
545 1353
546 - // if board_id is not passed then throw an exception
547 - if (!$boardId) {
548 - 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;
549 1361 }
550 1362
551 - 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;
552 1397 }
553 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 +
554 1870 public function sendMailAfterTaskModify($column, $assigneeIds, $taskId)
555 1871 {
556 1872 $current_user_id = get_current_user_id();
557 1873 /* this will run in background as soon as possible */
@@ -562,14 +1878,17 @@
562 1878
563 1879 public function getStageByTask($task_id)
564 1880 {
565 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 + }
566 1885 return $task->stage;
567 1886 }
568 1887
569 - public function moveTaskToNextStage($task_id)
1888 + public function moveTaskToNextStage($task_id, $boardId = null)
570 1889 {
571 - $task = Task::findOrFail($task_id);
1890 + $task = $boardId ? $this->findTaskOnBoard($task_id, $boardId) : Task::findOrFail($task_id);
572 1891
573 1892 $oldStage = $task->stage;
574 1893
575 1894 $nextStage = Stage::where('board_id', $task->board_id)
@@ -587,8 +1906,11 @@
587 1906 $task->last_completed_at = current_time('mysql');
588 1907 }
589 1908 }
590 1909
1910 + // Clean up archived_by_stage meta when moving to different stage
1911 + $this->cleanupArchivedByStageMetaIfExists($task->id);
1912 +
591 1913 $task->stage_id = $nextStage->id;
592 1914 $task->save();
593 1915
594 1916 $task->load(['board', 'stage', 'attachments']);
@@ -618,27 +1940,36 @@
618 1940 $activityQuery = $activityQuery->latest();
619 1941 } else if ($filter == 'oldest') {
620 1942 $activityQuery = $activityQuery->oldest();
621 1943 }
622 - return $activityQuery->with('user')->paginate($perPage);
1944 + $activities = $activityQuery->with('user')->paginate($perPage);
1945 +
1946 + Helper::translateActivities($activities);
1947 +
1948 + return $activities;
623 1949 }
624 1950
625 - public function getLastOneMinuteUpdatedTasks($boardId, $lastUpdated = null)
1951 + public function getLastOneMinuteUpdatedTasks($boardId, $lastUpdated = null, $includeArchived = true)
626 1952 {
627 1953 if (!$lastUpdated) {
628 - $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
629 1955 }
630 1956
631 - $tasks = Task::query()
1957 + $tasksQuery = Task::query()
632 1958 ->where([
633 1959 'board_id' => $boardId,
634 1960 'parent_id' => null,
635 1961 ])
636 - ->where('updated_at', '>', $lastUpdated)
1962 + ->where('updated_at', '>=', $lastUpdated) // updated since the sync cursor
637 1963 ->with(['assignees', 'labels', 'watchers', 'taskCustomFields'])
638 - ->orderBy('due_at', 'ASC')
639 - ->get();
1964 + ->orderBy('due_at', 'ASC');
640 1965
1966 + if (!$includeArchived) {
1967 + $tasksQuery->whereNull('archived_at');
1968 + }
1969 +
1970 + $tasks = $tasksQuery->get();
1971 +
641 1972 foreach ($tasks as $task) {
642 1973 $task->isOverdue = $task->isOverdue();
643 1974 $task->isUpcoming = $task->upcoming();
644 1975 $task->is_watching = $task->isWatching();
@@ -661,88 +1992,212 @@
661 1992
662 1993 return $lastPosition + 1;
663 1994 }
664 1995
665 - 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)
666 2004 {
667 - $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()
668 2047 ->where('crm_contact_id', $associatedId)
669 - ->with(['board', 'stage', 'assignees', 'labels', 'watchers',])
670 - ->orderBy('due_at', 'ASC')
671 - ->get();
2048 + ->with(['board', 'stage', 'assignees', 'subtaskGroup', 'subtaskGroup.subtasks', 'subtaskGroup.subtasks.assignees'])
2049 + ->orderBy('due_at', 'ASC');
672 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 +
673 2067 foreach ($tasks as $task) {
674 2068 $task->isOverdue = $task->isOverdue();
675 2069 $task->isUpcoming = $task->upcoming();
676 - $task->contact = Task::lead_contact($task->crm_contact_id);
677 - $task->is_watching = $task->isWatching();
2070 + $task->can_edit = $isAdmin || in_array((int)$task->board_id, $editableBoardIds, true);
678 2071
679 2072 $task->assignees = Helper::sanitizeUserCollections($task->assignees);
680 - $task->watchers = Helper::sanitizeUserCollections($task->watchers);
681 2073
682 - if(!!defined('FLUENT_BOARDS_PRO_VERSION')) {
683 - $task->time_tracks= [
684 - 'tracks' => (new ProTaskService())->getTaskTimeTrack($task->board_id, $task->id),
685 - 'estimated_minutes' => TimeTrackingHelper::getTaskEstimation($task->id)
686 - ];
2074 + foreach ($task->subtaskGroup as $group) {
2075 + foreach ($group->subtasks as $subtask) {
2076 + $subtask->assignees = Helper::sanitizeUserCollections($subtask->assignees);
2077 + }
687 2078 }
2079 + $task->subtask_group = $task->subtaskGroup;
2080 + }
688 2081
2082 + return $tasks;
2083 + }
689 2084
690 - $subTasks = Task::query()
691 - ->where('parent_id', $task->id)
692 - ->with(['assignees'])
693 - ->whereNull('archived_at')
694 - ->orderBy('position', 'ASC')
695 - ->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 + ]);
696 2094
697 - foreach ($subTasks as $subTask) {
698 - $subTask->assignees = Helper::sanitizeUserCollections($subTask->assignees);
699 - }
700 -
701 - $task->subtasks = $subTasks;
2095 + $subtaskGroupMap[$group->id] = $newGroup->id;
702 2096 }
703 2097
704 - return $tasks;
2098 + return $subtaskGroupMap;
705 2099 }
706 2100
707 - public function copyTasks($boardId, $stageMap, $newBoard, $labelMap = [])
2101 + public function copyTasks($boardId, $stageMap, $newBoard, $labelMap = [],$isWithTemplates='no')
708 2102 {
709 2103 $allActiveTasks = Task::where('board_id', $boardId)->whereNull('archived_at')->get();
710 2104 $taskMap = [];
2105 + $subtaskGroupMap = [];
711 2106 $parentTaskCount = 0;
712 - foreach ($allActiveTasks as $task) {
713 - $newTask = array();
714 - $newTask['title'] = $task->title;
715 - $newTask['parent_id'] = $task->parent_id ? $taskMap[$task->parent_id] : null;
716 - $newTask['description'] = $task->description;
717 - $newTask['board_id'] = $newBoard->id;
718 - $newTask['stage_id'] = $stageMap[$task->stage_id];
719 - $newTask['status'] = $task->status;
720 - $newTask['priority'] = $task->priority;
721 - $newTask['position'] = $task->position;
722 - $newTask['due_at'] = $task->due_at;
723 - $newTask = Task::create($newTask);
724 - if(!$task->parent_id){
725 - ++$parentTaskCount;
726 - $taskMap[$task['id']] = $newTask->id;
727 - //duplicate labels to task
728 - $labelIds = $task->labels->pluck('id')->toArray();
729 - if($labelIds){
730 - $flipLabelIds = array_flip($labelIds);
731 - $labelsToAttach = array_intersect_key($labelMap, $flipLabelIds);
2107 + $attachmentFileService = new AttachmentFileService();
2108 + $dbInstance = App::getInstance('db');
732 2109
733 - $newTask->labels()->attach($labelsToAttach, [
734 - 'object_type' => Constant::OBJECT_TYPE_TASK_LABEL
735 - ]);
2110 + $dbInstance->beginTransaction();
2111 +
2112 + try {
2113 + foreach ($allActiveTasks as $task) {
2114 + if ($task->parent_id && empty($taskMap[$task->parent_id])) {
2115 + continue;
736 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 + }
737 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;
738 2199 }
739 -
740 - $board = Board::findOrFail($newBoard->id);
741 - $settings = [];
742 - $settings['tasks_count'] = $parentTaskCount;
743 - $board->settings = $settings;
744 - $board->save();
745 2200 }
746 2201
747 2202 private function subtaskCountUpdate($taskId){
748 2203 $parentTask = Task::findOrFail($taskId);
@@ -754,31 +2209,40 @@
754 2209
755 2210 /**
756 2211 * @param $taskId
757 2212 * @param $perPage
758 - * @param $offset
2213 + * @param $page
759 2214 * @param string $filter
2215 + * @param $boardId
2216 + * @param string $feedType
760 2217 * @return array
761 2218 */
762 - 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
763 2220 {
764 2221 // Fetch the task
765 - $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';
766 2224
767 2225 // Fetch comments and activities separately
768 - $comments = $task->comments()->with('user')->orderBy('created_at', 'desc')->get()->toArray();
769 - $activities = $task->activities()
770 - ->with('user')
771 - ->where(function($query) {
772 - $query->whereNotIn('column', [ 'comment', 'a reply'])
773 - ->orWhere(function($subQuery) {
774 - $subQuery->whereNotIn('action', ['added', 'updated']);
775 - });
776 - })
777 - ->orderBy('created_at', 'desc')
778 - ->get()
779 - ->toArray();
2226 + $comments = [];
2227 + if ($feedType !== 'activities') {
2228 + $comments = $task->comments()->with(['user', 'replies.user'])->orderBy('created_at', 'desc')->get()->toArray();
2229 + }
780 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 + }
781 2245
782 2246
783 2247 // Merge comments and activities into a single array
784 2248 $commentsAndActivities = array_merge($comments, $activities);
@@ -874,9 +2338,9 @@
874 2338 * @param $title
875 2339 * @param $UrlMeta
876 2340 * @return mixed|string
877 2341 */
878 - private function setTitle($type, $title, $UrlMeta)
2342 + public function setTitle($type, $title, $UrlMeta)
879 2343 {
880 2344 if($type != 'url') {
881 2345 return sanitize_file_name($title);
882 2346 }
@@ -897,8 +2361,32 @@
897 2361 }
898 2362 }
899 2363 }
900 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 +
901 2389 public function setDefaultAssigneesToEveryTasks($stage)
902 2390 {
903 2391 $tasks = $stage->tasks->whereNull('archived_at');
904 2392 foreach ($tasks as $task) {
@@ -905,17 +2393,999 @@
905 2393 $this->manageDefaultAssignees($task, $stage->id);
906 2394 }
907 2395 }
908 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 + }
909 2455 public function deleteTaskCoverImage($settings)
910 2456 {
911 2457 if (isset($settings['cover']['imageId']) && $settings['cover']['imageId']) {
912 2458 $image = TaskImage::find($settings['cover']['imageId']);
913 - $deletedImage = clone $image;
914 - $deletedImage->delete();
2459 + if ($image) {
2460 + $deletedImage = clone $image;
2461 + $deletedImage->delete();
915 2462
916 - do_action('fluent_boards/task_attachment_deleted', $deletedImage);
2463 + do_action('fluent_boards/task_attachment_deleted', $deletedImage);
2464 + }
917 2465 }
918 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 + }
919 3389 }
920 3390
921 3391 }