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