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 +317 -28 1.20trunk View file →
@@ -5,8 +5,9 @@
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;
9 10 use FluentBoardsPro\App\Models\CustomField;
10 11 use FluentBoardsPro\App\Services\Constant as ProConstant;
11 12 use FluentCrm\App\Models\Subscriber;
12 13
@@ -15,10 +16,9 @@
15 16 protected $table = 'fbs_tasks';
16 17
17 18 protected $guarded = ['id'];
18 19
19 - protected $fillable
20 - = [
20 + protected $fillable = [
21 21 'title',
22 22 'slug',
23 23 'board_id',
24 24 'parent_id',
@@ -44,17 +44,35 @@
44 44 'comments_count',
45 45 'created_by',
46 46 ];
47 47
48 - protected $appends = ['meta'];
48 + protected $appends = ['meta', 'repeat_task_meta', 'is_pinned'];
49 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 +
50 68 public static function boot()
51 69 {
52 70 parent::boot();
53 71 static::creating(function ($model) {
72 + $board = Board::find($model->board_id);
54 73 $model->created_by = $model->created_by ?: get_current_user_id();
55 - $model->type = $model->type
56 - ?: 'task'; // default task type is task
74 + $model->type = $board->type === 'roadmap' ? 'roadmap' : 'task'; // default task type is task
57 75
58 76 if (empty($model->slug)) {
59 77 $model->slug = sanitize_title($model->title, 'idea-'.time());
60 78 }
@@ -65,14 +83,15 @@
65 83 'backgroundColor' => '',
66 84 ],
67 85 'subtask_count' => 0,
68 86 'attachment_count' => 0,
87 + 'subtask_completed_count' => 0,
69 88 ];
70 89 $model->position = $model->position
71 90 ?: (new TaskService())->getLastPositionOfTasks($model->stage_id);
72 91 });
73 92 static::created(function ($model) {
74 - if ( ! $model->parent_id) {
93 + if (!$model->parent_id && !static::$skipTaskCreatedEvent) {
75 94 do_action('fluent_boards/task_created', $model);
76 95 if ($model->crm_contact_id) {
77 96 do_action('fluent_boards/contact_added_to_task', $model);
78 97 }
@@ -80,9 +99,9 @@
80 99 self::adjustSubtaskCount($model->parent_id);
81 100 }
82 101 });
83 102
84 - /* 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 */
85 104 static::addGlobalScope('type', function (Builder $builder) {
86 105 $builder->where('type', '=', 'task')
87 106 ->orWhere('type', '=', 'roadmap');
88 107 });
@@ -120,9 +139,27 @@
120 139 return $query->where('status', 'open')
121 140 ->where('due_at', '>=', current_time('mysql'));
122 141 }
123 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);
124 155
156 + return $query->whereNull('last_completed_at')
157 + ->where('status', 'open')
158 + ->whereBetween('due_at', [$startOfToday, $endOfToday]);
159 + }
160 +
161 +
125 162 public function setSettingsAttribute($settings)
126 163 {
127 164 $this->attributes['settings'] = \maybe_serialize($settings);
128 165 }
@@ -153,9 +190,11 @@
153 190 * @return \FluentBoards\Framework\Database\Orm\Relations\hasMany
154 191 */
155 192 public function comments()
156 193 {
157 - 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);
158 197 }
159 198
160 199 /**
161 200 * One2Many: Task has many notifications
@@ -183,8 +222,30 @@
183 222 {
184 223 return $this->belongsTo(Board::class, 'board_id', 'id');
185 224 }
186 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 +
187 248 public function assignees()
188 249 {
189 250 return $this->belongsToMany(
190 251 User::class,
@@ -211,9 +272,14 @@
211 272 }
212 273
213 274 public function attachments() //may not need in future
214 275 {
215 - 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,
216 282 'object_id', 'id')
217 283 ->where('object_type', Constant::OBJECT_TYPE_TASK);
218 284 }
219 285
@@ -229,8 +295,44 @@
229 295 Constant::OBJECT_TYPE_USER_TASK_WATCH)
230 296 ->withTimestamps();
231 297 }
232 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 +
233 335 public function parentTask($id)
234 336 {
235 337 return self::find($id);
236 338 }
@@ -244,8 +346,18 @@
244 346 {
245 347 return $this->hasMany(TaskMeta::class, 'task_id', 'id');
246 348 }
247 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 +
248 360 public function getMetaAttribute()
249 361 {
250 362 return $this->taskMeta()->get()->pluck('value', 'key');
251 363 }
@@ -283,10 +395,17 @@
283 395 }
284 396
285 397 public function createTask($data)
286 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 +
287 406 $createdTask = Task::create($data);
288 -
407 +
289 408 if ( ! empty($data['assignees'])) {
290 409 $assignees = array_filter(array_map('intval', $data['assignees']));
291 410 if ($assignees) {
292 411 $assigneeData = array_fill_keys($assignees,
@@ -291,8 +410,15 @@
291 410 if ($assignees) {
292 411 $assigneeData = array_fill_keys($assignees,
293 412 ['object_type' => Constant::OBJECT_TYPE_TASK_ASSIGNEE]);
294 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 + }
295 421 }
296 422 }
297 423
298 424 if ( ! empty($data['labels'])) {
@@ -326,12 +452,12 @@
326 452
327 453 return $operation;
328 454 }
329 455
330 - public function getArchivedAttribute()
331 - {
332 - return (bool) $this->attributes['is_archived'];
333 - }
456 + // public function getArchivedAttribute()
457 + // {
458 + // return (bool) $this->attributes['is_archived'];
459 + // }
334 460
335 461 public function setArchivedAttribute($value)
336 462 {
337 463 if (true === $value) {
@@ -416,8 +542,22 @@
416 542
417 543 return $exist;
418 544 }
419 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 +
420 560 public function moveToNewPosition($newIndex)
421 561 {
422 562 $newIndex = (int) $newIndex;
423 563 if ($newIndex < 1) {
@@ -498,8 +638,116 @@
498 638
499 639 return $this;
500 640 }
501 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 +
502 750 public static function reIndexTasksPositions($task)
503 751 {
504 752 if (isset($task['parent_id'])) {
505 753 $tasksQuery = self::where('parent_id', $task['parent_id']);
@@ -520,11 +768,18 @@
520 768 {
521 769 if ( ! $subTaskParentId) {
522 770 return;
523 771 }
524 - $parentTask = Task::findOrFail($subTaskParentId);
772 + $parentTask = Task::find($subTaskParentId);
773 + if( !$parentTask) {
774 + return;
775 + }
776 + $subtasks = $parentTask->subtasks;
525 777 $parentTaskSettings = $parentTask->settings;
526 - $parentTaskSettings['subtask_count'] = $parentTask->subtasks->count();
778 + $parentTaskSettings['subtask_count'] = $subtasks->count();
779 + $parentTaskSettings['subtask_completed_count'] = $subtasks->filter(function ($subtask) {
780 + return $subtask->status === 'closed';
781 + })->count();
527 782 $parentTask->settings = $parentTaskSettings;
528 783 $parentTask->save();
529 784 }
530 785
@@ -529,9 +784,9 @@
529 784 }
530 785
531 786 public function close()
532 787 {
533 - if ($this->status == 'closed') {
788 + if ($this->status == 'closed' && $this->last_completed_at) {
534 789 return $this;
535 790 }
536 791
537 792 $this->status = 'closed';
@@ -536,22 +791,26 @@
536 791
537 792 $this->status = 'closed';
538 793 $this->last_completed_at = current_time('mysql');
539 794 $this->save();
540 -
795 + if ($this->parent_id) {
796 + self::adjustSubtaskCount($this->parent_id);
797 + }
541 798 return $this;
542 799 }
543 800
544 801 public function reopen()
545 802 {
546 - if ($this->status == 'open') {
803 + if ($this->status == 'open' && $this->last_completed_at == null) {
547 804 return $this;
548 805 }
549 806
550 807 $this->status = 'open';
551 - $this->last_completed_at = null;
808 + $this->last_completed_at = NULL;
552 809 $this->save();
553 -
810 + if ($this->parent_id) {
811 + self::adjustSubtaskCount($this->parent_id);
812 + }
554 813 return $this;
555 814 }
556 815
557 816 /*
@@ -563,15 +822,24 @@
563 822 */
564 823 public static function mappables()
565 824 {
566 825 return [
567 - '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'),
568 831 'description' => __('Description', 'fluent-boards'),
569 832 'priority' => __('Priority', 'fluent-boards'),
570 833 'due_at' => __('Due Date', 'fluent-boards'),
834 + 'started_at' => __('Start Date', 'fluent-boards'),
835 + 'archived_at' => __('Archive Date', 'fluent-boards'),
571 836 'stage' => __('Stage', 'fluent-boards'),
572 837 'board' => __('Board', 'fluent-boards'),
573 838 'source' => __('Source', 'fluent-boards'),
839 + 'position' => __('Position', 'fluent-boards'),
840 + 'subtasks' => __('Subtasks', 'fluent-boards'),
841 + 'completion' => __('Completion', 'fluent-boards'),
574 842 ];
575 843 }
576 844 public static function mappableFields()
577 845 {
@@ -609,9 +877,9 @@
609 877 'priority' => [
610 878 'field' => __('Priority', 'fluent-boards'),
611 879 'type' => 'text',
612 880 'rules' => 'optional',
613 - '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'),
614 882 ],
615 883 'due_at' => [
616 884 'field' => __('Due Date', 'fluent-boards'),
617 885 'type' => 'date',
@@ -675,13 +943,8 @@
675 943 ];
676 944
677 945 return $fields;
678 946 }
679 -
680 - public function subtasks()
681 - {
682 - return $this->hasMany(Task::class, 'parent_id', 'id');
683 - }
684 947
685 948 public function customFields()
686 949 {
687 950 return $this->belongsToMany(
@@ -692,5 +955,31 @@
692 955 )->withPivot('settings')
693 956 ->wherePivot('object_type', ProConstant::TASK_CUSTOM_FIELD)
694 957 ->withTimestamps();
695 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 +
696 985 }