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.0 2.1.0 2.0.15 2.0.12 2.0.10 2.0.4 2.0.1 2.0.0 1.95.3 1.95.2 1.95 1.91.6 trunk 1.11 1.12 1.13 1.20 1.21 1.22 1.23 1.30 1.31 1.32 1.35 1.40 All 43 releases
← All changes | app/Models/Task.php +331 -24 1.23 → 2.2.0 View file →
@@ -6,14 +6,15 @@
6 6 use FluentBoards\App\Services\Helper;
7 7 use FluentBoards\App\Services\TaskService;
8 8 use FluentBoards\Framework\Database\Orm\Builder;
9 9 use FluentBoardsPro\App\Models\TaskAttachment;
10 -use FluentBoardsPro\App\Models\CustomField;
10 +use FluentBoards\App\Models\CustomField;
11 11 use FluentBoardsPro\App\Services\Constant as ProConstant;
12 12 use FluentCrm\App\Models\Subscriber;
13 13
14 14 class Task extends Model
15 15 {
16 +
16 17 protected $table = 'fbs_tasks';
17 18
18 19 protected $guarded = ['id'];
19 20
@@ -44,17 +45,35 @@
44 45 'comments_count',
45 46 'created_by',
46 47 ];
47 48
48 - protected $appends = ['meta'];
49 + protected $appends = ['meta', 'repeat_task_meta', 'is_pinned'];
49 50
51 + protected $nullableTimestampAttributes = [
52 + 'due_at',
53 + 'started_at',
54 + 'last_completed_at',
55 + 'archived_at',
56 + 'remind_at',
57 + ];
58 +
59 + protected static $skipTaskCreatedEvent = false;
60 +
61 + public static function withoutTaskCreatedEvent($callback)
62 + {
63 + static::$skipTaskCreatedEvent = true;
64 + $result = $callback();
65 + static::$skipTaskCreatedEvent = false;
66 + return $result;
67 + }
68 +
50 69 public static function boot()
51 70 {
52 71 parent::boot();
53 72 static::creating(function ($model) {
73 + $board = Board::find($model->board_id);
54 74 $model->created_by = $model->created_by ?: get_current_user_id();
55 - $model->type = $model->type
56 - ?: 'task'; // default task type is task
75 + $model->type = $board->type === 'roadmap' ? 'roadmap' : 'task'; // default task type is task
57 76
58 77 if (empty($model->slug)) {
59 78 $model->slug = sanitize_title($model->title, 'idea-'.time());
60 79 }
@@ -65,14 +84,15 @@
65 84 'backgroundColor' => '',
66 85 ],
67 86 'subtask_count' => 0,
68 87 'attachment_count' => 0,
88 + 'subtask_completed_count' => 0,
69 89 ];
70 90 $model->position = $model->position
71 91 ?: (new TaskService())->getLastPositionOfTasks($model->stage_id);
72 92 });
73 93 static::created(function ($model) {
74 - if ( ! $model->parent_id) {
94 + if (!$model->parent_id && !static::$skipTaskCreatedEvent) {
75 95 do_action('fluent_boards/task_created', $model);
76 96 if ($model->crm_contact_id) {
77 97 do_action('fluent_boards/contact_added_to_task', $model);
78 98 }
@@ -80,9 +100,19 @@
80 100 self::adjustSubtaskCount($model->parent_id);
81 101 }
82 102 });
83 103
84 - /* global scope for task type which means only task_type = task will be fetched from everywhere in */
104 + static::updated(function ($model) {
105 + if ($model->isDirty('status') && $model->getOriginal('status') === 'open' && $model->status === 'closed') {
106 + // Use persisted attributes without stale stage/board relations from the move.
107 + $closedTask = $model->fresh();
108 + if ($closedTask && $closedTask->status === 'closed') {
109 + do_action('fluent_boards/task_closed', $closedTask);
110 + }
111 + }
112 + });
113 +
114 + /* global scope for task type which means only type = task will be fetched from everywhere in */
85 115 static::addGlobalScope('type', function (Builder $builder) {
86 116 $builder->where('type', '=', 'task')
87 117 ->orWhere('type', '=', 'roadmap');
88 118 });
@@ -120,9 +150,27 @@
120 150 return $query->where('status', 'open')
121 151 ->where('due_at', '>=', current_time('mysql'));
122 152 }
123 153
154 + /**
155 + * scope of getting open tasks due today
156 + *
157 + * @param $query \FluentBoards\Framework\Database\Query\Builder
158 + *
159 + * @return \FluentBoards\Framework\Database\Query\Builder
160 + */
161 + public function scopeDueToday($query)
162 + {
163 + $todayTimestamp = current_time('timestamp');
164 + $startOfToday = gmdate('Y-m-d 00:00:00', $todayTimestamp);
165 + $endOfToday = gmdate('Y-m-d 23:59:59', $todayTimestamp);
124 166
167 + return $query->whereNull('last_completed_at')
168 + ->where('status', 'open')
169 + ->whereBetween('due_at', [$startOfToday, $endOfToday]);
170 + }
171 +
172 +
125 173 public function setSettingsAttribute($settings)
126 174 {
127 175 $this->attributes['settings'] = \maybe_serialize($settings);
128 176 }
@@ -185,8 +233,30 @@
185 233 {
186 234 return $this->belongsTo(Board::class, 'board_id', 'id');
187 235 }
188 236
237 + /**
238 + * Exclude tasks that belong to template boards.
239 + */
240 + public function scopeExcludeTemplateBoards($query)
241 + {
242 + return $query->whereHas('board', function ($boardQuery) {
243 + $boardQuery->excludeTemplates();
244 + });
245 + }
246 +
247 + /**
248 + * Limit tasks to active, non-template boards available in this install.
249 + */
250 + public function scopeOnActiveAvailableBoards($query)
251 + {
252 + return $query->whereHas('board', function ($boardQuery) {
253 + $boardQuery->whereNull('archived_at')
254 + ->excludeTemplates()
255 + ->availableInCurrentInstall();
256 + });
257 + }
258 +
189 259 public function assignees()
190 260 {
191 261 return $this->belongsToMany(
192 262 User::class,
@@ -213,8 +283,13 @@
213 283 }
214 284
215 285 public function attachments() //may not need in future
216 286 {
287 + if (!defined('FLUENT_BOARDS_PRO_VERSION')) {
288 + return $this->hasMany(Attachment::class, 'id', 'id')
289 + ->where('id', 0);
290 + }
291 +
217 292 return $this->hasMany(TaskAttachment::class,
218 293 'object_id', 'id')
219 294 ->where('object_type', Constant::OBJECT_TYPE_TASK);
220 295 }
@@ -231,8 +306,44 @@
231 306 Constant::OBJECT_TYPE_USER_TASK_WATCH)
232 307 ->withTimestamps();
233 308 }
234 309
310 + /**
311 + * Tasks that must finish before this task can start (this task is the successor/blocked).
312 + * In fbs_relations: object_id = predecessor, foreign_id = this task (successor).
313 + *
314 + * @return \FluentBoards\Framework\Database\Orm\Relations\BelongsToMany
315 + */
316 + public function predecessors()
317 + {
318 + return $this->belongsToMany(
319 + Task::class,
320 + 'fbs_relations',
321 + 'foreign_id',
322 + 'object_id'
323 + )->wherePivot('object_type', Constant::OBJECT_TYPE_TASK_DEPENDENCY)
324 + ->withPivot('settings')
325 + ->withTimestamps();
326 + }
327 +
328 + /**
329 + * Tasks that are waiting on this task to finish (this task is the predecessor/blocker).
330 + * In fbs_relations: object_id = this task (predecessor), foreign_id = successor.
331 + *
332 + * @return \FluentBoards\Framework\Database\Orm\Relations\BelongsToMany
333 + */
334 + public function successors()
335 + {
336 + return $this->belongsToMany(
337 + Task::class,
338 + 'fbs_relations',
339 + 'object_id',
340 + 'foreign_id'
341 + )->wherePivot('object_type', Constant::OBJECT_TYPE_TASK_DEPENDENCY)
342 + ->withPivot('settings')
343 + ->withTimestamps();
344 + }
345 +
235 346 public function parentTask($id)
236 347 {
237 348 return self::find($id);
238 349 }
@@ -246,10 +357,25 @@
246 357 {
247 358 return $this->hasMany(TaskMeta::class, 'task_id', 'id');
248 359 }
249 360
361 + public function getPopularCount()
362 + {
363 + $interactions = $this->taskMeta()->get()->pluck('value', 'key');
364 + if($interactions) {
365 + return ($interactions['upvote'] ?? 0) + ($interactions['comments_count'] ?? 0);
366 + } else {
367 + return 0;
368 + }
369 + }
370 +
371 + /** Use batch-loaded metadata when preparing task-list responses. */
250 372 public function getMetaAttribute()
251 373 {
374 + if ($this->relationLoaded('taskMeta')) {
375 + return $this->getRelation('taskMeta')->pluck('value', 'key');
376 + }
377 +
252 378 return $this->taskMeta()->get()->pluck('value', 'key');
253 379 }
254 380
255 381 public function stage()
@@ -286,10 +412,16 @@
286 412
287 413 public function createTask($data)
288 414 {
289 415 $data = apply_filters('fluent_boards/before_task_create', $data);
416 +
417 + // Keep newly-created tasks unprioritized unless a priority is explicitly selected.
418 + if (!array_key_exists('priority', $data)) {
419 + $data['priority'] = '';
420 + }
421 +
290 422 $createdTask = Task::create($data);
291 -
423 +
292 424 if ( ! empty($data['assignees'])) {
293 425 $assignees = array_filter(array_map('intval', $data['assignees']));
294 426 if ($assignees) {
295 427 $assigneeData = array_fill_keys($assignees,
@@ -294,8 +426,15 @@
294 426 if ($assignees) {
295 427 $assigneeData = array_fill_keys($assignees,
296 428 ['object_type' => Constant::OBJECT_TYPE_TASK_ASSIGNEE]);
297 429 $createdTask->assignees()->syncWithoutDetaching($assigneeData);
430 +
431 + // Add assignees as watchers
432 + foreach ($assignees as $assigneeId) {
433 + $createdTask->watchers()->syncWithoutDetaching([
434 + $assigneeId => ['object_type' => Constant::OBJECT_TYPE_USER_TASK_WATCH]
435 + ]);
436 + }
298 437 }
299 438 }
300 439
301 440 if ( ! empty($data['labels'])) {
@@ -329,12 +468,12 @@
329 468
330 469 return $operation;
331 470 }
332 471
333 - public function getArchivedAttribute()
334 - {
335 - return (bool) $this->attributes['is_archived'];
336 - }
472 + // public function getArchivedAttribute()
473 + // {
474 + // return (bool) $this->attributes['is_archived'];
475 + // }
337 476
338 477 public function setArchivedAttribute($value)
339 478 {
340 479 if (true === $value) {
@@ -400,8 +539,9 @@
400 539
401 540 return $default;
402 541 }
403 542
543 + /** Persist metadata and invalidate any snapshot loaded for serialization. */
404 544 public function updateMeta($key, $value)
405 545 {
406 546 $exist = TaskMeta::where('task_id', $this->id)->where('key', $key)
407 547 ->first();
@@ -416,11 +556,27 @@
416 556 'value' => $value,
417 557 ]);
418 558 }
419 559
560 + $this->unsetRelation('taskMeta');
561 +
420 562 return $exist;
421 563 }
422 564
565 + /**
566 + * Virtual attribute: pinned state from task meta (for API/frontend).
567 + * Tasks without is_pinned meta are treated as unpinned (0).
568 + * Uses the batch-loaded metadata relation when preparing task lists.
569 + *
570 + * @return int 1 if pinned, 0 otherwise
571 + */
572 + public function getIsPinnedAttribute()
573 + {
574 + $meta = $this->meta;
575 +
576 + return (int) ($meta[Constant::IS_TASK_PINNED] ?? 0);
577 + }
578 +
423 579 public function moveToNewPosition($newIndex)
424 580 {
425 581 $newIndex = (int) $newIndex;
426 582 if ($newIndex < 1) {
@@ -501,8 +657,116 @@
501 657
502 658 return $this;
503 659 }
504 660
661 + public function moveBetweenTasks($prevTaskId = null, $nextTaskId = null)
662 + {
663 + $prevTaskId = absint($prevTaskId);
664 + $nextTaskId = absint($nextTaskId);
665 + // Exclude the current task so cross-stage moves can reuse the same
666 + // neighbour lookup logic after the stage_id has already been reassigned.
667 + $taskQuery = $this->getTaskOrderingQuery();
668 +
669 + $prevTask = null;
670 + if ($prevTaskId) {
671 + $prevTask = (clone $taskQuery)
672 + ->where('id', $prevTaskId)
673 + ->first();
674 + }
675 +
676 + $nextTask = null;
677 + if ($nextTaskId) {
678 + $nextTask = (clone $taskQuery)
679 + ->where('id', $nextTaskId)
680 + ->first();
681 + }
682 +
683 + if (!$prevTask && !$nextTask) {
684 + $firstItem = (clone $taskQuery)
685 + ->orderBy('position', 'asc')
686 + ->first();
687 +
688 + if (!$firstItem) {
689 + $this->position = 1;
690 + $this->save();
691 +
692 + return $this;
693 + }
694 +
695 + if ($firstItem->position < 0.02) {
696 + self::reIndexTasksPositions($this->toArray());
697 +
698 + return $this->moveBetweenTasks($prevTaskId, $nextTaskId);
699 + }
700 +
701 + $this->position = round($firstItem->position / 2, 2);
702 + $this->save();
703 +
704 + return $this;
705 + }
706 +
707 + if (!$prevTask && $nextTask) {
708 + // Insert before the first visible neighbour by splitting the leading gap.
709 + if ($nextTask->position < 0.02) {
710 + self::reIndexTasksPositions($this->toArray());
711 +
712 + return $this->moveBetweenTasks($prevTaskId, $nextTaskId);
713 + }
714 +
715 + $this->position = round($nextTask->position / 2, 2);
716 + $this->save();
717 +
718 + return $this;
719 + }
720 +
721 + if ($prevTask && !$nextTask) {
722 + // Insert after the last visible neighbour without touching the rest
723 + // of the stage unless the sparse ordering needs a later reindex.
724 + $this->position = $prevTask->position + 1;
725 + $this->save();
726 +
727 + return $this;
728 + }
729 +
730 + if ($prevTask->position >= $nextTask->position) {
731 + self::reIndexTasksPositions($this->toArray());
732 +
733 + return $this->moveBetweenTasks($prevTaskId, $nextTaskId);
734 + }
735 +
736 + // Middle inserts keep reordering cheap by taking the midpoint between
737 + // the two neighbour positions instead of renumbering the whole stage.
738 + $newPosition = ($prevTask->position + $nextTask->position) / 2;
739 +
740 + $exists = (clone $taskQuery)
741 + ->where('position', $newPosition)
742 + ->first();
743 +
744 + if ($exists) {
745 + self::reIndexTasksPositions($this->toArray());
746 +
747 + return $this->moveBetweenTasks($prevTaskId, $nextTaskId);
748 + }
749 +
750 + $this->position = $newPosition;
751 + $this->save();
752 +
753 + return $this;
754 + }
755 +
756 + private function getTaskOrderingQuery()
757 + {
758 + if (isset($this->parent_id)) {
759 + return self::where('parent_id', $this->parent_id)
760 + ->whereNull('archived_at')
761 + ->where('id', '!=', $this->id);
762 + }
763 +
764 + return self::where('stage_id', $this->stage_id)
765 + ->whereNull('archived_at')
766 + ->where('id', '!=', $this->id);
767 + }
768 +
505 769 public static function reIndexTasksPositions($task)
506 770 {
507 771 if (isset($task['parent_id'])) {
508 772 $tasksQuery = self::where('parent_id', $task['parent_id']);
@@ -527,10 +791,14 @@
527 791 $parentTask = Task::find($subTaskParentId);
528 792 if( !$parentTask) {
529 793 return;
530 794 }
795 + $subtasks = $parentTask->subtasks;
531 796 $parentTaskSettings = $parentTask->settings;
532 - $parentTaskSettings['subtask_count'] = $parentTask->subtasks->count();
797 + $parentTaskSettings['subtask_count'] = $subtasks->count();
798 + $parentTaskSettings['subtask_completed_count'] = $subtasks->filter(function ($subtask) {
799 + return $subtask->status === 'closed';
800 + })->count();
533 801 $parentTask->settings = $parentTaskSettings;
534 802 $parentTask->save();
535 803 }
536 804
@@ -535,9 +803,9 @@
535 803 }
536 804
537 805 public function close()
538 806 {
539 - if ($this->status == 'closed') {
807 + if ($this->status == 'closed' && $this->last_completed_at) {
540 808 return $this;
541 809 }
542 810
543 811 $this->status = 'closed';
@@ -542,22 +810,26 @@
542 810
543 811 $this->status = 'closed';
544 812 $this->last_completed_at = current_time('mysql');
545 813 $this->save();
546 -
814 + if ($this->parent_id) {
815 + self::adjustSubtaskCount($this->parent_id);
816 + }
547 817 return $this;
548 818 }
549 819
550 820 public function reopen()
551 821 {
552 - if ($this->status == 'open') {
822 + if ($this->status == 'open' && $this->last_completed_at == null) {
553 823 return $this;
554 824 }
555 825
556 826 $this->status = 'open';
557 - $this->last_completed_at = null;
827 + $this->last_completed_at = NULL;
558 828 $this->save();
559 -
829 + if ($this->parent_id) {
830 + self::adjustSubtaskCount($this->parent_id);
831 + }
560 832 return $this;
561 833 }
562 834
563 835 /*
@@ -569,15 +841,24 @@
569 841 */
570 842 public static function mappables()
571 843 {
572 844 return [
573 - 'title' => __('Title', 'fluent-boards'),
845 + 'task_title' => __('Task Title', 'fluent-boards'),
846 + 'slug' => __('Slug', 'fluent-boards'),
847 + 'board_title' => __('Board Title', 'fluent-boards'),
848 + 'status' => __('Status', 'fluent-boards'),
849 + 'type' => __('Type', 'fluent-boards'),
574 850 'description' => __('Description', 'fluent-boards'),
575 851 'priority' => __('Priority', 'fluent-boards'),
576 852 'due_at' => __('Due Date', 'fluent-boards'),
853 + 'started_at' => __('Start Date', 'fluent-boards'),
854 + 'archived_at' => __('Archive Date', 'fluent-boards'),
577 855 'stage' => __('Stage', 'fluent-boards'),
578 856 'board' => __('Board', 'fluent-boards'),
579 857 'source' => __('Source', 'fluent-boards'),
858 + 'position' => __('Position', 'fluent-boards'),
859 + 'subtasks' => __('Subtasks', 'fluent-boards'),
860 + 'completion' => __('Completion', 'fluent-boards'),
580 861 ];
581 862 }
582 863 public static function mappableFields()
583 864 {
@@ -615,9 +896,9 @@
615 896 'priority' => [
616 897 'field' => __('Priority', 'fluent-boards'),
617 898 'type' => 'text',
618 899 'rules' => 'optional',
619 - 'description' => __('Priority of the task (low | medium | high). Example: "medium" ', 'fluent-boards'),
900 + 'description' => __('Priority of the task (urgent | high | medium | low). Leave empty for no priority. Example: "medium"', 'fluent-boards'),
620 901 ],
621 902 'due_at' => [
622 903 'field' => __('Due Date', 'fluent-boards'),
623 904 'type' => 'date',
@@ -682,13 +963,8 @@
682 963
683 964 return $fields;
684 965 }
685 966
686 - public function subtasks()
687 - {
688 - return $this->hasMany(Task::class, 'parent_id', 'id');
689 - }
690 -
691 967 public function customFields()
692 968 {
693 969 return $this->belongsToMany(
694 970 CustomField::class,
@@ -697,7 +973,38 @@
697 973 'foreign_id'
698 974 )->withPivot('settings')
699 975 ->wherePivot('object_type', ProConstant::TASK_CUSTOM_FIELD)
700 976 ->withTimestamps();
977 + }
978 +
979 +
980 + public function repeatTaskMeta()
981 + {
982 + return $this->hasOne(Meta::class, 'object_id', 'id')->where('object_type', Constant::REPEAT_TASK_META);
983 + }
984 +
985 + /** Reuse batch-loaded repeat settings, including a known missing relation. */
986 + public function getRepeatTaskMetaAttribute()
987 + {
988 + if ($this->relationLoaded('repeatTaskMeta')) {
989 + return $this->getRelation('repeatTaskMeta');
990 + }
991 +
992 + return $this->repeatTaskMeta()->first();
993 + }
994 + public function taskCustomFields()
995 + {
996 + return $this->hasMany(Relation::class, 'object_id', 'id')
997 + ->where('object_type', Constant::TASK_CUSTOM_FIELD);
998 + }
999 +
1000 + public function subtasks()
1001 + {
1002 + return $this->hasMany(Task::class, 'parent_id', 'id');
1003 + }
1004 +
1005 + public function subtaskGroup()
1006 + {
1007 + return $this->hasMany(TaskMeta::class, 'task_id')->where('key', Constant::SUBTASK_GROUP_NAME);
701 1008 }
702 1009
703 1010 }