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 +342 -27 1.21 → 2.2.0 View file →
@@ -5,14 +5,16 @@
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\CustomField;
9 +use FluentBoardsPro\App\Models\TaskAttachment;
10 +use FluentBoards\App\Models\CustomField;
10 11 use FluentBoardsPro\App\Services\Constant as ProConstant;
11 12 use FluentCrm\App\Models\Subscriber;
12 13
13 14 class Task extends Model
14 15 {
16 +
15 17 protected $table = 'fbs_tasks';
16 18
17 19 protected $guarded = ['id'];
18 20
@@ -43,17 +45,35 @@
43 45 'comments_count',
44 46 'created_by',
45 47 ];
46 48
47 - protected $appends = ['meta'];
49 + protected $appends = ['meta', 'repeat_task_meta', 'is_pinned'];
48 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 +
49 69 public static function boot()
50 70 {
51 71 parent::boot();
52 72 static::creating(function ($model) {
73 + $board = Board::find($model->board_id);
53 74 $model->created_by = $model->created_by ?: get_current_user_id();
54 - $model->type = $model->type
55 - ?: 'task'; // default task type is task
75 + $model->type = $board->type === 'roadmap' ? 'roadmap' : 'task'; // default task type is task
56 76
57 77 if (empty($model->slug)) {
58 78 $model->slug = sanitize_title($model->title, 'idea-'.time());
59 79 }
@@ -64,14 +84,15 @@
64 84 'backgroundColor' => '',
65 85 ],
66 86 'subtask_count' => 0,
67 87 'attachment_count' => 0,
88 + 'subtask_completed_count' => 0,
68 89 ];
69 90 $model->position = $model->position
70 91 ?: (new TaskService())->getLastPositionOfTasks($model->stage_id);
71 92 });
72 93 static::created(function ($model) {
73 - if ( ! $model->parent_id) {
94 + if (!$model->parent_id && !static::$skipTaskCreatedEvent) {
74 95 do_action('fluent_boards/task_created', $model);
75 96 if ($model->crm_contact_id) {
76 97 do_action('fluent_boards/contact_added_to_task', $model);
77 98 }
@@ -79,9 +100,19 @@
79 100 self::adjustSubtaskCount($model->parent_id);
80 101 }
81 102 });
82 103
83 - /* 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 */
84 115 static::addGlobalScope('type', function (Builder $builder) {
85 116 $builder->where('type', '=', 'task')
86 117 ->orWhere('type', '=', 'roadmap');
87 118 });
@@ -119,9 +150,27 @@
119 150 return $query->where('status', 'open')
120 151 ->where('due_at', '>=', current_time('mysql'));
121 152 }
122 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);
123 166
167 + return $query->whereNull('last_completed_at')
168 + ->where('status', 'open')
169 + ->whereBetween('due_at', [$startOfToday, $endOfToday]);
170 + }
171 +
172 +
124 173 public function setSettingsAttribute($settings)
125 174 {
126 175 $this->attributes['settings'] = \maybe_serialize($settings);
127 176 }
@@ -152,9 +201,11 @@
152 201 * @return \FluentBoards\Framework\Database\Orm\Relations\hasMany
153 202 */
154 203 public function comments()
155 204 {
156 - return $this->hasMany(Comment::class, 'task_id', 'id');
205 + return $this->hasMany(Comment::class, 'task_id', 'id')
206 + ->where('type', 'comment')
207 + ->where('parent_id', null);
157 208 }
158 209
159 210 /**
160 211 * One2Many: Task has many notifications
@@ -182,8 +233,30 @@
182 233 {
183 234 return $this->belongsTo(Board::class, 'board_id', 'id');
184 235 }
185 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 +
186 259 public function assignees()
187 260 {
188 261 return $this->belongsToMany(
189 262 User::class,
@@ -210,9 +283,14 @@
210 283 }
211 284
212 285 public function attachments() //may not need in future
213 286 {
214 - return $this->hasMany(\FluentBoardsPro\App\Models\Attachment::class,
287 + if (!defined('FLUENT_BOARDS_PRO_VERSION')) {
288 + return $this->hasMany(Attachment::class, 'id', 'id')
289 + ->where('id', 0);
290 + }
291 +
292 + return $this->hasMany(TaskAttachment::class,
215 293 'object_id', 'id')
216 294 ->where('object_type', Constant::OBJECT_TYPE_TASK);
217 295 }
218 296
@@ -228,8 +306,44 @@
228 306 Constant::OBJECT_TYPE_USER_TASK_WATCH)
229 307 ->withTimestamps();
230 308 }
231 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 +
232 346 public function parentTask($id)
233 347 {
234 348 return self::find($id);
235 349 }
@@ -243,10 +357,25 @@
243 357 {
244 358 return $this->hasMany(TaskMeta::class, 'task_id', 'id');
245 359 }
246 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. */
247 372 public function getMetaAttribute()
248 373 {
374 + if ($this->relationLoaded('taskMeta')) {
375 + return $this->getRelation('taskMeta')->pluck('value', 'key');
376 + }
377 +
249 378 return $this->taskMeta()->get()->pluck('value', 'key');
250 379 }
251 380
252 381 public function stage()
@@ -282,10 +411,17 @@
282 411 }
283 412
284 413 public function createTask($data)
285 414 {
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 +
286 422 $createdTask = Task::create($data);
287 -
423 +
288 424 if ( ! empty($data['assignees'])) {
289 425 $assignees = array_filter(array_map('intval', $data['assignees']));
290 426 if ($assignees) {
291 427 $assigneeData = array_fill_keys($assignees,
@@ -290,8 +426,15 @@
290 426 if ($assignees) {
291 427 $assigneeData = array_fill_keys($assignees,
292 428 ['object_type' => Constant::OBJECT_TYPE_TASK_ASSIGNEE]);
293 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 + }
294 437 }
295 438 }
296 439
297 440 if ( ! empty($data['labels'])) {
@@ -325,12 +468,12 @@
325 468
326 469 return $operation;
327 470 }
328 471
329 - public function getArchivedAttribute()
330 - {
331 - return (bool) $this->attributes['is_archived'];
332 - }
472 + // public function getArchivedAttribute()
473 + // {
474 + // return (bool) $this->attributes['is_archived'];
475 + // }
333 476
334 477 public function setArchivedAttribute($value)
335 478 {
336 479 if (true === $value) {
@@ -396,8 +539,9 @@
396 539
397 540 return $default;
398 541 }
399 542
543 + /** Persist metadata and invalidate any snapshot loaded for serialization. */
400 544 public function updateMeta($key, $value)
401 545 {
402 546 $exist = TaskMeta::where('task_id', $this->id)->where('key', $key)
403 547 ->first();
@@ -412,11 +556,27 @@
412 556 'value' => $value,
413 557 ]);
414 558 }
415 559
560 + $this->unsetRelation('taskMeta');
561 +
416 562 return $exist;
417 563 }
418 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 +
419 579 public function moveToNewPosition($newIndex)
420 580 {
421 581 $newIndex = (int) $newIndex;
422 582 if ($newIndex < 1) {
@@ -497,8 +657,116 @@
497 657
498 658 return $this;
499 659 }
500 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 +
501 769 public static function reIndexTasksPositions($task)
502 770 {
503 771 if (isset($task['parent_id'])) {
504 772 $tasksQuery = self::where('parent_id', $task['parent_id']);
@@ -519,11 +787,18 @@
519 787 {
520 788 if ( ! $subTaskParentId) {
521 789 return;
522 790 }
523 - $parentTask = Task::findOrFail($subTaskParentId);
791 + $parentTask = Task::find($subTaskParentId);
792 + if( !$parentTask) {
793 + return;
794 + }
795 + $subtasks = $parentTask->subtasks;
524 796 $parentTaskSettings = $parentTask->settings;
525 - $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();
526 801 $parentTask->settings = $parentTaskSettings;
527 802 $parentTask->save();
528 803 }
529 804
@@ -528,9 +803,9 @@
528 803 }
529 804
530 805 public function close()
531 806 {
532 - if ($this->status == 'closed') {
807 + if ($this->status == 'closed' && $this->last_completed_at) {
533 808 return $this;
534 809 }
535 810
536 811 $this->status = 'closed';
@@ -535,22 +810,26 @@
535 810
536 811 $this->status = 'closed';
537 812 $this->last_completed_at = current_time('mysql');
538 813 $this->save();
539 -
814 + if ($this->parent_id) {
815 + self::adjustSubtaskCount($this->parent_id);
816 + }
540 817 return $this;
541 818 }
542 819
543 820 public function reopen()
544 821 {
545 - if ($this->status == 'open') {
822 + if ($this->status == 'open' && $this->last_completed_at == null) {
546 823 return $this;
547 824 }
548 825
549 826 $this->status = 'open';
550 - $this->last_completed_at = null;
827 + $this->last_completed_at = NULL;
551 828 $this->save();
552 -
829 + if ($this->parent_id) {
830 + self::adjustSubtaskCount($this->parent_id);
831 + }
553 832 return $this;
554 833 }
555 834
556 835 /*
@@ -562,15 +841,24 @@
562 841 */
563 842 public static function mappables()
564 843 {
565 844 return [
566 - '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'),
567 850 'description' => __('Description', 'fluent-boards'),
568 851 'priority' => __('Priority', 'fluent-boards'),
569 852 'due_at' => __('Due Date', 'fluent-boards'),
853 + 'started_at' => __('Start Date', 'fluent-boards'),
854 + 'archived_at' => __('Archive Date', 'fluent-boards'),
570 855 'stage' => __('Stage', 'fluent-boards'),
571 856 'board' => __('Board', 'fluent-boards'),
572 857 'source' => __('Source', 'fluent-boards'),
858 + 'position' => __('Position', 'fluent-boards'),
859 + 'subtasks' => __('Subtasks', 'fluent-boards'),
860 + 'completion' => __('Completion', 'fluent-boards'),
573 861 ];
574 862 }
575 863 public static function mappableFields()
576 864 {
@@ -608,9 +896,9 @@
608 896 'priority' => [
609 897 'field' => __('Priority', 'fluent-boards'),
610 898 'type' => 'text',
611 899 'rules' => 'optional',
612 - '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'),
613 901 ],
614 902 'due_at' => [
615 903 'field' => __('Due Date', 'fluent-boards'),
616 904 'type' => 'date',
@@ -674,13 +962,8 @@
674 962 ];
675 963
676 964 return $fields;
677 965 }
678 -
679 - public function subtasks()
680 - {
681 - return $this->hasMany(Task::class, 'parent_id', 'id');
682 - }
683 966
684 967 public function customFields()
685 968 {
686 969 return $this->belongsToMany(
@@ -691,5 +974,37 @@
691 974 )->withPivot('settings')
692 975 ->wherePivot('object_type', ProConstant::TASK_CUSTOM_FIELD)
693 976 ->withTimestamps();
694 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);
1008 + }
1009 +
695 1010 }