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 +294 -21 1.31 → 2.2.0 View file →
@@ -6,14 +6,15 @@
6 6 use FluentBoards\App\Services\Helper;
7 7 use FluentBoards\App\Services\TaskService;
8 8 use FluentBoards\Framework\Database\Orm\Builder;
9 9 use FluentBoardsPro\App\Models\TaskAttachment;
10 -use FluentBoardsPro\App\Models\CustomField;
10 +use FluentBoards\App\Models\CustomField;
11 11 use FluentBoardsPro\App\Services\Constant as ProConstant;
12 12 use FluentCrm\App\Models\Subscriber;
13 13
14 14 class Task extends Model
15 15 {
16 +
16 17 protected $table = 'fbs_tasks';
17 18
18 19 protected $guarded = ['id'];
19 20
@@ -44,17 +45,35 @@
44 45 'comments_count',
45 46 'created_by',
46 47 ];
47 48
48 - protected $appends = ['meta', 'repeat_task_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 }
@@ -71,9 +90,9 @@
71 90 $model->position = $model->position
72 91 ?: (new TaskService())->getLastPositionOfTasks($model->stage_id);
73 92 });
74 93 static::created(function ($model) {
75 - if ( ! $model->parent_id) {
94 + if (!$model->parent_id && !static::$skipTaskCreatedEvent) {
76 95 do_action('fluent_boards/task_created', $model);
77 96 if ($model->crm_contact_id) {
78 97 do_action('fluent_boards/contact_added_to_task', $model);
79 98 }
@@ -81,9 +100,19 @@
81 100 self::adjustSubtaskCount($model->parent_id);
82 101 }
83 102 });
84 103
85 - /* 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 */
86 115 static::addGlobalScope('type', function (Builder $builder) {
87 116 $builder->where('type', '=', 'task')
88 117 ->orWhere('type', '=', 'roadmap');
89 118 });
@@ -121,9 +150,27 @@
121 150 return $query->where('status', 'open')
122 151 ->where('due_at', '>=', current_time('mysql'));
123 152 }
124 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);
125 166
167 + return $query->whereNull('last_completed_at')
168 + ->where('status', 'open')
169 + ->whereBetween('due_at', [$startOfToday, $endOfToday]);
170 + }
171 +
172 +
126 173 public function setSettingsAttribute($settings)
127 174 {
128 175 $this->attributes['settings'] = \maybe_serialize($settings);
129 176 }
@@ -186,8 +233,30 @@
186 233 {
187 234 return $this->belongsTo(Board::class, 'board_id', 'id');
188 235 }
189 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 +
190 259 public function assignees()
191 260 {
192 261 return $this->belongsToMany(
193 262 User::class,
@@ -214,8 +283,13 @@
214 283 }
215 284
216 285 public function attachments() //may not need in future
217 286 {
287 + if (!defined('FLUENT_BOARDS_PRO_VERSION')) {
288 + return $this->hasMany(Attachment::class, 'id', 'id')
289 + ->where('id', 0);
290 + }
291 +
218 292 return $this->hasMany(TaskAttachment::class,
219 293 'object_id', 'id')
220 294 ->where('object_type', Constant::OBJECT_TYPE_TASK);
221 295 }
@@ -232,8 +306,44 @@
232 306 Constant::OBJECT_TYPE_USER_TASK_WATCH)
233 307 ->withTimestamps();
234 308 }
235 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 +
236 346 public function parentTask($id)
237 347 {
238 348 return self::find($id);
239 349 }
@@ -257,10 +367,15 @@
257 367 return 0;
258 368 }
259 369 }
260 370
371 + /** Use batch-loaded metadata when preparing task-list responses. */
261 372 public function getMetaAttribute()
262 373 {
374 + if ($this->relationLoaded('taskMeta')) {
375 + return $this->getRelation('taskMeta')->pluck('value', 'key');
376 + }
377 +
263 378 return $this->taskMeta()->get()->pluck('value', 'key');
264 379 }
265 380
266 381 public function stage()
@@ -297,10 +412,16 @@
297 412
298 413 public function createTask($data)
299 414 {
300 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 +
301 422 $createdTask = Task::create($data);
302 -
423 +
303 424 if ( ! empty($data['assignees'])) {
304 425 $assignees = array_filter(array_map('intval', $data['assignees']));
305 426 if ($assignees) {
306 427 $assigneeData = array_fill_keys($assignees,
@@ -305,8 +426,15 @@
305 426 if ($assignees) {
306 427 $assigneeData = array_fill_keys($assignees,
307 428 ['object_type' => Constant::OBJECT_TYPE_TASK_ASSIGNEE]);
308 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 + }
309 437 }
310 438 }
311 439
312 440 if ( ! empty($data['labels'])) {
@@ -340,12 +468,12 @@
340 468
341 469 return $operation;
342 470 }
343 471
344 - public function getArchivedAttribute()
345 - {
346 - return (bool) $this->attributes['is_archived'];
347 - }
472 + // public function getArchivedAttribute()
473 + // {
474 + // return (bool) $this->attributes['is_archived'];
475 + // }
348 476
349 477 public function setArchivedAttribute($value)
350 478 {
351 479 if (true === $value) {
@@ -411,8 +539,9 @@
411 539
412 540 return $default;
413 541 }
414 542
543 + /** Persist metadata and invalidate any snapshot loaded for serialization. */
415 544 public function updateMeta($key, $value)
416 545 {
417 546 $exist = TaskMeta::where('task_id', $this->id)->where('key', $key)
418 547 ->first();
@@ -427,11 +556,27 @@
427 556 'value' => $value,
428 557 ]);
429 558 }
430 559
560 + $this->unsetRelation('taskMeta');
561 +
431 562 return $exist;
432 563 }
433 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 +
434 579 public function moveToNewPosition($newIndex)
435 580 {
436 581 $newIndex = (int) $newIndex;
437 582 if ($newIndex < 1) {
@@ -512,8 +657,116 @@
512 657
513 658 return $this;
514 659 }
515 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 +
516 769 public static function reIndexTasksPositions($task)
517 770 {
518 771 if (isset($task['parent_id'])) {
519 772 $tasksQuery = self::where('parent_id', $task['parent_id']);
@@ -550,9 +803,9 @@
550 803 }
551 804
552 805 public function close()
553 806 {
554 - if ($this->status == 'closed') {
807 + if ($this->status == 'closed' && $this->last_completed_at) {
555 808 return $this;
556 809 }
557 810
558 811 $this->status = 'closed';
@@ -565,14 +818,14 @@
565 818 }
566 819
567 820 public function reopen()
568 821 {
569 - if ($this->status == 'open') {
822 + if ($this->status == 'open' && $this->last_completed_at == null) {
570 823 return $this;
571 824 }
572 825
573 826 $this->status = 'open';
574 - $this->last_completed_at = null;
827 + $this->last_completed_at = NULL;
575 828 $this->save();
576 829 if ($this->parent_id) {
577 830 self::adjustSubtaskCount($this->parent_id);
578 831 }
@@ -588,15 +841,24 @@
588 841 */
589 842 public static function mappables()
590 843 {
591 844 return [
592 - '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'),
593 850 'description' => __('Description', 'fluent-boards'),
594 851 'priority' => __('Priority', 'fluent-boards'),
595 852 'due_at' => __('Due Date', 'fluent-boards'),
853 + 'started_at' => __('Start Date', 'fluent-boards'),
854 + 'archived_at' => __('Archive Date', 'fluent-boards'),
596 855 'stage' => __('Stage', 'fluent-boards'),
597 856 'board' => __('Board', 'fluent-boards'),
598 857 'source' => __('Source', 'fluent-boards'),
858 + 'position' => __('Position', 'fluent-boards'),
859 + 'subtasks' => __('Subtasks', 'fluent-boards'),
860 + 'completion' => __('Completion', 'fluent-boards'),
599 861 ];
600 862 }
601 863 public static function mappableFields()
602 864 {
@@ -634,9 +896,9 @@
634 896 'priority' => [
635 897 'field' => __('Priority', 'fluent-boards'),
636 898 'type' => 'text',
637 899 'rules' => 'optional',
638 - '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'),
639 901 ],
640 902 'due_at' => [
641 903 'field' => __('Due Date', 'fluent-boards'),
642 904 'type' => 'date',
@@ -701,13 +963,8 @@
701 963
702 964 return $fields;
703 965 }
704 966
705 - public function subtasks()
706 - {
707 - return $this->hasMany(Task::class, 'parent_id', 'id');
708 - }
709 -
710 967 public function customFields()
711 968 {
712 969 return $this->belongsToMany(
713 970 CustomField::class,
@@ -723,10 +980,16 @@
723 980 public function repeatTaskMeta()
724 981 {
725 982 return $this->hasOne(Meta::class, 'object_id', 'id')->where('object_type', Constant::REPEAT_TASK_META);
726 983 }
984 +
985 + /** Reuse batch-loaded repeat settings, including a known missing relation. */
727 986 public function getRepeatTaskMetaAttribute()
728 987 {
988 + if ($this->relationLoaded('repeatTaskMeta')) {
989 + return $this->getRelation('repeatTaskMeta');
990 + }
991 +
729 992 return $this->repeatTaskMeta()->first();
730 993 }
731 994 public function taskCustomFields()
732 995 {
@@ -731,7 +994,17 @@
731 994 public function taskCustomFields()
732 995 {
733 996 return $this->hasMany(Relation::class, 'object_id', 'id')
734 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);
735 1008 }
736 1009
737 1010 }