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