PluginProbe
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration / trunk
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration vtrunk
2.0.15 2.0.12 2.0.10 2.0.4 2.0.1 2.0.0 1.95.3 1.95.2 1.95 1.91.6 trunk 1.11 1.12 1.13 1.20 1.21 1.22 1.23 1.30 1.31 1.32 1.35 1.40 1.41 1.45 All 41 releases
← All changes | app/Models/Task.php +335 -31 1.13trunk View file →
@@ -5,8 +5,11 @@
5 5 use FluentBoards\App\Services\Constant;
6 6 use FluentBoards\App\Services\Helper;
7 7 use FluentBoards\App\Services\TaskService;
8 8 use FluentBoards\Framework\Database\Orm\Builder;
9 +use FluentBoardsPro\App\Models\TaskAttachment;
10 +use FluentBoardsPro\App\Models\CustomField;
11 +use FluentBoardsPro\App\Services\Constant as ProConstant;
9 12 use FluentCrm\App\Models\Subscriber;
10 13
11 14 class Task extends Model
12 15 {
@@ -13,10 +16,9 @@
13 16 protected $table = 'fbs_tasks';
14 17
15 18 protected $guarded = ['id'];
16 19
17 - protected $fillable
18 - = [
20 + protected $fillable = [
19 21 'title',
20 22 'slug',
21 23 'board_id',
22 24 'parent_id',
@@ -42,17 +44,35 @@
42 44 'comments_count',
43 45 'created_by',
44 46 ];
45 47
46 - protected $appends = ['meta'];
48 + protected $appends = ['meta', 'repeat_task_meta', 'is_pinned'];
47 49
50 + protected $nullableTimestampAttributes = [
51 + 'due_at',
52 + 'started_at',
53 + 'last_completed_at',
54 + 'archived_at',
55 + 'remind_at',
56 + ];
57 +
58 + protected static $skipTaskCreatedEvent = false;
59 +
60 + public static function withoutTaskCreatedEvent($callback)
61 + {
62 + static::$skipTaskCreatedEvent = true;
63 + $result = $callback();
64 + static::$skipTaskCreatedEvent = false;
65 + return $result;
66 + }
67 +
48 68 public static function boot()
49 69 {
50 70 parent::boot();
51 71 static::creating(function ($model) {
72 + $board = Board::find($model->board_id);
52 73 $model->created_by = $model->created_by ?: get_current_user_id();
53 - $model->type = $model->type
54 - ?: 'task'; // default task type is task
74 + $model->type = $board->type === 'roadmap' ? 'roadmap' : 'task'; // default task type is task
55 75
56 76 if (empty($model->slug)) {
57 77 $model->slug = sanitize_title($model->title, 'idea-'.time());
58 78 }
@@ -63,22 +83,25 @@
63 83 'backgroundColor' => '',
64 84 ],
65 85 'subtask_count' => 0,
66 86 'attachment_count' => 0,
87 + 'subtask_completed_count' => 0,
67 88 ];
68 89 $model->position = $model->position
69 90 ?: (new TaskService())->getLastPositionOfTasks($model->stage_id);
70 91 });
71 92 static::created(function ($model) {
72 - if ( ! $model->parent_id) {
93 + if (!$model->parent_id && !static::$skipTaskCreatedEvent) {
73 94 do_action('fluent_boards/task_created', $model);
74 95 if ($model->crm_contact_id) {
75 96 do_action('fluent_boards/contact_added_to_task', $model);
76 97 }
98 + } else {
99 + self::adjustSubtaskCount($model->parent_id);
77 100 }
78 101 });
79 102
80 - /* global scope for task type which means only task_type = task will be fetched from everywhere in */
103 + /* global scope for task type which means only type = task will be fetched from everywhere in */
81 104 static::addGlobalScope('type', function (Builder $builder) {
82 105 $builder->where('type', '=', 'task')
83 106 ->orWhere('type', '=', 'roadmap');
84 107 });
@@ -116,9 +139,27 @@
116 139 return $query->where('status', 'open')
117 140 ->where('due_at', '>=', current_time('mysql'));
118 141 }
119 142
143 + /**
144 + * scope of getting open tasks due today
145 + *
146 + * @param $query \FluentBoards\Framework\Database\Query\Builder
147 + *
148 + * @return \FluentBoards\Framework\Database\Query\Builder
149 + */
150 + public function scopeDueToday($query)
151 + {
152 + $todayTimestamp = current_time('timestamp');
153 + $startOfToday = gmdate('Y-m-d 00:00:00', $todayTimestamp);
154 + $endOfToday = gmdate('Y-m-d 23:59:59', $todayTimestamp);
120 155
156 + return $query->whereNull('last_completed_at')
157 + ->where('status', 'open')
158 + ->whereBetween('due_at', [$startOfToday, $endOfToday]);
159 + }
160 +
161 +
121 162 public function setSettingsAttribute($settings)
122 163 {
123 164 $this->attributes['settings'] = \maybe_serialize($settings);
124 165 }
@@ -149,9 +190,11 @@
149 190 * @return \FluentBoards\Framework\Database\Orm\Relations\hasMany
150 191 */
151 192 public function comments()
152 193 {
153 - return $this->hasMany(Comment::class, 'task_id', 'id');
194 + return $this->hasMany(Comment::class, 'task_id', 'id')
195 + ->where('type', 'comment')
196 + ->where('parent_id', null);
154 197 }
155 198
156 199 /**
157 200 * One2Many: Task has many notifications
@@ -179,8 +222,30 @@
179 222 {
180 223 return $this->belongsTo(Board::class, 'board_id', 'id');
181 224 }
182 225
226 + /**
227 + * Exclude tasks that belong to template boards.
228 + */
229 + public function scopeExcludeTemplateBoards($query)
230 + {
231 + return $query->whereHas('board', function ($boardQuery) {
232 + $boardQuery->excludeTemplates();
233 + });
234 + }
235 +
236 + /**
237 + * Limit tasks to active, non-template boards available in this install.
238 + */
239 + public function scopeOnActiveAvailableBoards($query)
240 + {
241 + return $query->whereHas('board', function ($boardQuery) {
242 + $boardQuery->whereNull('archived_at')
243 + ->excludeTemplates()
244 + ->availableInCurrentInstall();
245 + });
246 + }
247 +
183 248 public function assignees()
184 249 {
185 250 return $this->belongsToMany(
186 251 User::class,
@@ -207,9 +272,14 @@
207 272 }
208 273
209 274 public function attachments() //may not need in future
210 275 {
211 - return $this->hasMany(\FluentBoardsPro\App\Models\Attachment::class,
276 + if (!defined('FLUENT_BOARDS_PRO_VERSION')) {
277 + return $this->hasMany(Attachment::class, 'id', 'id')
278 + ->where('id', 0);
279 + }
280 +
281 + return $this->hasMany(TaskAttachment::class,
212 282 'object_id', 'id')
213 283 ->where('object_type', Constant::OBJECT_TYPE_TASK);
214 284 }
215 285
@@ -225,8 +295,44 @@
225 295 Constant::OBJECT_TYPE_USER_TASK_WATCH)
226 296 ->withTimestamps();
227 297 }
228 298
299 + /**
300 + * Tasks that must finish before this task can start (this task is the successor/blocked).
301 + * In fbs_relations: object_id = predecessor, foreign_id = this task (successor).
302 + *
303 + * @return \FluentBoards\Framework\Database\Orm\Relations\BelongsToMany
304 + */
305 + public function predecessors()
306 + {
307 + return $this->belongsToMany(
308 + Task::class,
309 + 'fbs_relations',
310 + 'foreign_id',
311 + 'object_id'
312 + )->wherePivot('object_type', Constant::OBJECT_TYPE_TASK_DEPENDENCY)
313 + ->withPivot('settings')
314 + ->withTimestamps();
315 + }
316 +
317 + /**
318 + * Tasks that are waiting on this task to finish (this task is the predecessor/blocker).
319 + * In fbs_relations: object_id = this task (predecessor), foreign_id = successor.
320 + *
321 + * @return \FluentBoards\Framework\Database\Orm\Relations\BelongsToMany
322 + */
323 + public function successors()
324 + {
325 + return $this->belongsToMany(
326 + Task::class,
327 + 'fbs_relations',
328 + 'object_id',
329 + 'foreign_id'
330 + )->wherePivot('object_type', Constant::OBJECT_TYPE_TASK_DEPENDENCY)
331 + ->withPivot('settings')
332 + ->withTimestamps();
333 + }
334 +
229 335 public function parentTask($id)
230 336 {
231 337 return self::find($id);
232 338 }
@@ -240,8 +346,18 @@
240 346 {
241 347 return $this->hasMany(TaskMeta::class, 'task_id', 'id');
242 348 }
243 349
350 + public function getPopularCount()
351 + {
352 + $interactions = $this->taskMeta()->get()->pluck('value', 'key');
353 + if($interactions) {
354 + return ($interactions['upvote'] ?? 0) + ($interactions['comments_count'] ?? 0);
355 + } else {
356 + return 0;
357 + }
358 + }
359 +
244 360 public function getMetaAttribute()
245 361 {
246 362 return $this->taskMeta()->get()->pluck('value', 'key');
247 363 }
@@ -279,10 +395,17 @@
279 395 }
280 396
281 397 public function createTask($data)
282 398 {
399 + $data = apply_filters('fluent_boards/before_task_create', $data);
400 +
401 + // Keep newly-created tasks unprioritized unless a priority is explicitly selected.
402 + if (!array_key_exists('priority', $data)) {
403 + $data['priority'] = '';
404 + }
405 +
283 406 $createdTask = Task::create($data);
284 -
407 +
285 408 if ( ! empty($data['assignees'])) {
286 409 $assignees = array_filter(array_map('intval', $data['assignees']));
287 410 if ($assignees) {
288 411 $assigneeData = array_fill_keys($assignees,
@@ -287,8 +410,15 @@
287 410 if ($assignees) {
288 411 $assigneeData = array_fill_keys($assignees,
289 412 ['object_type' => Constant::OBJECT_TYPE_TASK_ASSIGNEE]);
290 413 $createdTask->assignees()->syncWithoutDetaching($assigneeData);
414 +
415 + // Add assignees as watchers
416 + foreach ($assignees as $assigneeId) {
417 + $createdTask->watchers()->syncWithoutDetaching([
418 + $assigneeId => ['object_type' => Constant::OBJECT_TYPE_USER_TASK_WATCH]
419 + ]);
420 + }
291 421 }
292 422 }
293 423
294 424 if ( ! empty($data['labels'])) {
@@ -322,12 +452,12 @@
322 452
323 453 return $operation;
324 454 }
325 455
326 - public function getArchivedAttribute()
327 - {
328 - return (bool) $this->attributes['is_archived'];
329 - }
456 + // public function getArchivedAttribute()
457 + // {
458 + // return (bool) $this->attributes['is_archived'];
459 + // }
330 460
331 461 public function setArchivedAttribute($value)
332 462 {
333 463 if (true === $value) {
@@ -412,8 +542,22 @@
412 542
413 543 return $exist;
414 544 }
415 545
546 + /**
547 + * Virtual attribute: pinned state from task meta (for API/frontend).
548 + * Tasks without is_pinned meta are treated as unpinned (0).
549 + * Reads from the already-loaded 'meta' attribute to avoid an extra DB query per task.
550 + *
551 + * @return int 1 if pinned, 0 otherwise
552 + */
553 + public function getIsPinnedAttribute()
554 + {
555 + $meta = $this->meta;
556 +
557 + return (int) ($meta[Constant::IS_TASK_PINNED] ?? 0);
558 + }
559 +
416 560 public function moveToNewPosition($newIndex)
417 561 {
418 562 $newIndex = (int) $newIndex;
419 563 if ($newIndex < 1) {
@@ -494,8 +638,116 @@
494 638
495 639 return $this;
496 640 }
497 641
642 + public function moveBetweenTasks($prevTaskId = null, $nextTaskId = null)
643 + {
644 + $prevTaskId = absint($prevTaskId);
645 + $nextTaskId = absint($nextTaskId);
646 + // Exclude the current task so cross-stage moves can reuse the same
647 + // neighbour lookup logic after the stage_id has already been reassigned.
648 + $taskQuery = $this->getTaskOrderingQuery();
649 +
650 + $prevTask = null;
651 + if ($prevTaskId) {
652 + $prevTask = (clone $taskQuery)
653 + ->where('id', $prevTaskId)
654 + ->first();
655 + }
656 +
657 + $nextTask = null;
658 + if ($nextTaskId) {
659 + $nextTask = (clone $taskQuery)
660 + ->where('id', $nextTaskId)
661 + ->first();
662 + }
663 +
664 + if (!$prevTask && !$nextTask) {
665 + $firstItem = (clone $taskQuery)
666 + ->orderBy('position', 'asc')
667 + ->first();
668 +
669 + if (!$firstItem) {
670 + $this->position = 1;
671 + $this->save();
672 +
673 + return $this;
674 + }
675 +
676 + if ($firstItem->position < 0.02) {
677 + self::reIndexTasksPositions($this->toArray());
678 +
679 + return $this->moveBetweenTasks($prevTaskId, $nextTaskId);
680 + }
681 +
682 + $this->position = round($firstItem->position / 2, 2);
683 + $this->save();
684 +
685 + return $this;
686 + }
687 +
688 + if (!$prevTask && $nextTask) {
689 + // Insert before the first visible neighbour by splitting the leading gap.
690 + if ($nextTask->position < 0.02) {
691 + self::reIndexTasksPositions($this->toArray());
692 +
693 + return $this->moveBetweenTasks($prevTaskId, $nextTaskId);
694 + }
695 +
696 + $this->position = round($nextTask->position / 2, 2);
697 + $this->save();
698 +
699 + return $this;
700 + }
701 +
702 + if ($prevTask && !$nextTask) {
703 + // Insert after the last visible neighbour without touching the rest
704 + // of the stage unless the sparse ordering needs a later reindex.
705 + $this->position = $prevTask->position + 1;
706 + $this->save();
707 +
708 + return $this;
709 + }
710 +
711 + if ($prevTask->position >= $nextTask->position) {
712 + self::reIndexTasksPositions($this->toArray());
713 +
714 + return $this->moveBetweenTasks($prevTaskId, $nextTaskId);
715 + }
716 +
717 + // Middle inserts keep reordering cheap by taking the midpoint between
718 + // the two neighbour positions instead of renumbering the whole stage.
719 + $newPosition = ($prevTask->position + $nextTask->position) / 2;
720 +
721 + $exists = (clone $taskQuery)
722 + ->where('position', $newPosition)
723 + ->first();
724 +
725 + if ($exists) {
726 + self::reIndexTasksPositions($this->toArray());
727 +
728 + return $this->moveBetweenTasks($prevTaskId, $nextTaskId);
729 + }
730 +
731 + $this->position = $newPosition;
732 + $this->save();
733 +
734 + return $this;
735 + }
736 +
737 + private function getTaskOrderingQuery()
738 + {
739 + if (isset($this->parent_id)) {
740 + return self::where('parent_id', $this->parent_id)
741 + ->whereNull('archived_at')
742 + ->where('id', '!=', $this->id);
743 + }
744 +
745 + return self::where('stage_id', $this->stage_id)
746 + ->whereNull('archived_at')
747 + ->where('id', '!=', $this->id);
748 + }
749 +
498 750 public static function reIndexTasksPositions($task)
499 751 {
500 752 if (isset($task['parent_id'])) {
501 753 $tasksQuery = self::where('parent_id', $task['parent_id']);
@@ -516,24 +768,25 @@
516 768 {
517 769 if ( ! $subTaskParentId) {
518 770 return;
519 771 }
520 - $parentTask = Task::findOrFail($subTaskParentId);
521 -
522 - if ($parentTask->settings
523 - && array_key_exists('subtask_count', $parentTask->settings)
524 - ) {
525 - $parentTaskSettings = $parentTask->settings;
526 - $count = $parentTaskSettings['subtask_count'] - 1 > 0 ?? 0;
527 - $parentTaskSettings['subtask_count'] = $count;
528 - $parentTask->settings = $parentTaskSettings;
529 - $parentTask->save();
772 + $parentTask = Task::find($subTaskParentId);
773 + if( !$parentTask) {
774 + return;
530 775 }
776 + $subtasks = $parentTask->subtasks;
777 + $parentTaskSettings = $parentTask->settings;
778 + $parentTaskSettings['subtask_count'] = $subtasks->count();
779 + $parentTaskSettings['subtask_completed_count'] = $subtasks->filter(function ($subtask) {
780 + return $subtask->status === 'closed';
781 + })->count();
782 + $parentTask->settings = $parentTaskSettings;
783 + $parentTask->save();
531 784 }
532 785
533 786 public function close()
534 787 {
535 - if ($this->status == 'closed') {
788 + if ($this->status == 'closed' && $this->last_completed_at) {
536 789 return $this;
537 790 }
538 791
539 792 $this->status = 'closed';
@@ -538,22 +791,26 @@
538 791
539 792 $this->status = 'closed';
540 793 $this->last_completed_at = current_time('mysql');
541 794 $this->save();
542 -
795 + if ($this->parent_id) {
796 + self::adjustSubtaskCount($this->parent_id);
797 + }
543 798 return $this;
544 799 }
545 800
546 801 public function reopen()
547 802 {
548 - if ($this->status == 'open') {
803 + if ($this->status == 'open' && $this->last_completed_at == null) {
549 804 return $this;
550 805 }
551 806
552 807 $this->status = 'open';
553 - $this->last_completed_at = null;
808 + $this->last_completed_at = NULL;
554 809 $this->save();
555 -
810 + if ($this->parent_id) {
811 + self::adjustSubtaskCount($this->parent_id);
812 + }
556 813 return $this;
557 814 }
558 815
559 816 /*
@@ -565,15 +822,24 @@
565 822 */
566 823 public static function mappables()
567 824 {
568 825 return [
569 - 'title' => __('Title', 'fluent-boards'),
826 + 'task_title' => __('Task Title', 'fluent-boards'),
827 + 'slug' => __('Slug', 'fluent-boards'),
828 + 'board_title' => __('Board Title', 'fluent-boards'),
829 + 'status' => __('Status', 'fluent-boards'),
830 + 'type' => __('Type', 'fluent-boards'),
570 831 'description' => __('Description', 'fluent-boards'),
571 832 'priority' => __('Priority', 'fluent-boards'),
572 833 'due_at' => __('Due Date', 'fluent-boards'),
834 + 'started_at' => __('Start Date', 'fluent-boards'),
835 + 'archived_at' => __('Archive Date', 'fluent-boards'),
573 836 'stage' => __('Stage', 'fluent-boards'),
574 837 'board' => __('Board', 'fluent-boards'),
575 838 'source' => __('Source', 'fluent-boards'),
839 + 'position' => __('Position', 'fluent-boards'),
840 + 'subtasks' => __('Subtasks', 'fluent-boards'),
841 + 'completion' => __('Completion', 'fluent-boards'),
576 842 ];
577 843 }
578 844 public static function mappableFields()
579 845 {
@@ -611,9 +877,9 @@
611 877 'priority' => [
612 878 'field' => __('Priority', 'fluent-boards'),
613 879 'type' => 'text',
614 880 'rules' => 'optional',
615 - 'description' => __('Priority of the task (low | medium | high). Example: "medium" ', 'fluent-boards'),
881 + 'description' => __('Priority of the task (urgent | high | medium | low). Leave empty for no priority. Example: "medium" ', 'fluent-boards'),
616 882 ],
617 883 'due_at' => [
618 884 'field' => __('Due Date', 'fluent-boards'),
619 885 'type' => 'date',
@@ -677,5 +943,43 @@
677 943 ];
678 944
679 945 return $fields;
680 946 }
947 +
948 + public function customFields()
949 + {
950 + return $this->belongsToMany(
951 + CustomField::class,
952 + 'fbs_relations',
953 + 'object_id',
954 + 'foreign_id'
955 + )->withPivot('settings')
956 + ->wherePivot('object_type', ProConstant::TASK_CUSTOM_FIELD)
957 + ->withTimestamps();
958 + }
959 +
960 +
961 + public function repeatTaskMeta()
962 + {
963 + return $this->hasOne(Meta::class, 'object_id', 'id')->where('object_type', Constant::REPEAT_TASK_META);
964 + }
965 + public function getRepeatTaskMetaAttribute()
966 + {
967 + return $this->repeatTaskMeta()->first();
968 + }
969 + public function taskCustomFields()
970 + {
971 + return $this->hasMany(Relation::class, 'object_id', 'id')
972 + ->where('object_type', Constant::TASK_CUSTOM_FIELD);
973 + }
974 +
975 + public function subtasks()
976 + {
977 + return $this->hasMany(Task::class, 'parent_id', 'id');
978 + }
979 +
980 + public function subtaskGroup()
981 + {
982 + return $this->hasMany(TaskMeta::class, 'task_id')->where('key', Constant::SUBTASK_GROUP_NAME);
983 + }
984 +
681 985 }