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