PluginProbe
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration / 2.2.0
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration v2.2.0
2.2.1 2.2.0 2.1.0 2.0.15 2.0.12 2.0.10 2.0.4 2.0.1 2.0.0 1.95.3 1.95.2 1.95 1.91.6 trunk 1.11 1.12 1.13 1.20 1.21 1.22 1.23 1.30 1.31 1.32 1.35 All 44 releases
fluent-boards / app / Services / TaskService.php

TaskService.php in FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration 2.2.0, at app/Services/TaskService.php

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