diff --git a/app/Console/Commands/SendTaskNotifications.php b/app/Console/Commands/SendTaskNotifications.php new file mode 100644 index 000000000..90ddf8e2c --- /dev/null +++ b/app/Console/Commands/SendTaskNotifications.php @@ -0,0 +1,79 @@ +where('assignee_notified', false)->get(); + + // For senders who have not yet been notified + $completedTasks = Task::where('status', TaskStatus::COMPLETED)->where('creator_notified', false)->get(); + $declinedTasks = Task::where('status', TaskStatus::DECLINED)->where('creator_notified', false)->get(); + + // Put together the list of email recipients + $tasks = $pendingTasks->merge($completedTasks)->merge($declinedTasks); + $usersRecipients = $pendingTasks->pluck('assignee_user_id')->merge($completedTasks->pluck('creator_user_id'))->merge($declinedTasks->pluck('creator_user_id'))->unique(); + $userModels = User::whereIn('id', $usersRecipients)->get(); + + foreach ($userModels as $user) { + + // If no tasks for this user, skip + if (! $tasks->where('assignee_user_id', $user->id)->count() && ! $tasks->where('creator_user_id', $user->id)->count()) { + continue; + } + + // If user has disabled task notifications, mark as notified and skip + if (! $user->setting_notify_tasks) { + + $tasks->where('assignee_user_id', $user->id)->each(function ($task) { + $task->assignee_notified = true; + $task->save(); + }); + + $tasks->where('creator_user_id', $user->id)->each(function ($task) { + $task->creator_notified = true; + $task->save(); + }); + + continue; + } + + $user->notify(new TaskNotification( + $user, + $tasks->where('assignee_user_id', $user->id), + $tasks->where('creator_user_id', $user->id)->whereIn('status', [TaskStatus::COMPLETED, TaskStatus::DECLINED]), + )); + } + + return Command::SUCCESS; + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 32ef0cd54..362333a3e 100755 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -75,6 +75,10 @@ protected function schedule(Schedule $schedule) $schedule->command('update:workmails') ->daily(); + // Send task notifications + $schedule->command('send:task:notifications') + ->hourly(); + // Send telemetry data if (Setting::get('telemetryEnabled')) { $schedule->command('send:telemetry') diff --git a/app/Helpers/TaskStatus.php b/app/Helpers/TaskStatus.php new file mode 100644 index 000000000..55e693158 --- /dev/null +++ b/app/Helpers/TaskStatus.php @@ -0,0 +1,13 @@ +authorize('update', Task::class); + + if ($activeFilter == 'sent') { + $tasks = Task::where('creator_user_id', $user->id)->get()->sortBy('created_at'); + } elseif ($activeFilter == 'archived') { + $tasks = Task::where('assignee_user_id', $user->id)->whereIn('status', [TaskStatus::COMPLETED->value, TaskStatus::DECLINED->value])->get()->sortBy('created_at'); + } else { + $tasks = Task::where('assignee_user_id', $user->id)->where('status', TaskStatus::PENDING->value)->with('creator', 'subject', 'assignee', 'subjectTraining')->get()->sortBy('created_at'); + } + + return view('tasks.index', compact('tasks', 'activeFilter')); + } + + /** + * Store a newly created task in storage. + */ + public function store(Request $request, Authenticatable $user): RedirectResponse + { + + $this->authorize('create', Task::class); + + $data = $request->validate([ + 'type' => ['required', new ValidTaskType], + 'message' => 'sometimes|min:3|max:256', + 'subject_user_id' => 'required|exists:users,id', + 'subject_training_id' => 'required|exists:trainings,id', + 'assignee_user_id' => 'required|exists:users,id', + ]); + + $data['creator_user_id'] = $user->id; + $data['created_at'] = now(); + + // Check if recipient is mentor or above + $recipient = User::findOrFail($data['assignee_user_id']); + + // Policy check if recpient can recieve a task + if ($recipient->can('receive', Task::class)) { + // Create the model + $task = Task::create($data); + + // Run the create method on the task type to trigger type specific actions on creation + $task->type()->create($task); + + return redirect()->back()->with('success', 'Task created successfully.'); + } + + return redirect()->back()->withErrors('Recipient is not allowed to receive tasks.'); + + } + + /** + * Close the specified task with a given status. + */ + protected function close(Task|int $task, TaskStatus $status): Task + { + $this->authorize('update', Task::class); + $task = Task::findOrFail($task); + $task->status = $status; + $task->closed_at = now(); + $task->save(); + + return $task; + } + + /** + * Complete the specified task. + */ + public function complete(Request $request, Task|int $task): RedirectResponse + { + $completed = self::close($task, TaskStatus::COMPLETED); + + // Run the complete method on the task type to trigger type specific actions on completion + $completed->type()->complete($completed); + + return redirect()->back()->with('success', sprintf('Completed task regarding %s from %s.', $completed->subject->name, $completed->creator->name)); + } + + /** + * Decline the specified task. + */ + public function decline(Request $request, Task|int $task): RedirectResponse + { + $declined = self::close($task, TaskStatus::DECLINED); + + // Run the decline method on the task type to trigger type specific actions on decline + $declined->type()->decline($declined); + + return redirect()->back()->with('success', sprintf('Declined task regarding %s from %s.', $declined->subject->name, $declined->creator->name)); + } + + /** + * Return the task type classes + * + * @return array + */ + public static function getTypes() + { + // Specify the directory where your subclasses are located + $subclassesDirectory = app_path('Tasks/Types'); + + // Initialize an array to store the subclasses + $subclasses = []; + + // Get all PHP files in the directory + $files = File::files($subclassesDirectory); + + foreach ($files as $file) { + // Get the class name from the file path + $className = 'App\\Tasks\\Types\\' . pathinfo($file, PATHINFO_FILENAME); + + // Check if the class exists and is a subclass of Types + if (class_exists($className) && is_subclass_of($className, 'App\\Tasks\\Types\\Types')) { + $subclasses[] = new $className(); + } + } + + return $subclasses; + } + + /** + * Check if a task type is valid + * + * @param string $type + * @return bool + */ + public static function isValidType($type) + { + $types = self::getTypes(); + + foreach ($types as $taskType) { + if ($taskType::class == $type) { + return true; + } + } + + return false; + } +} diff --git a/app/Http/Controllers/TrainingController.php b/app/Http/Controllers/TrainingController.php index b469cbddb..1ebcc9d91 100644 --- a/app/Http/Controllers/TrainingController.php +++ b/app/Http/Controllers/TrainingController.php @@ -343,7 +343,9 @@ public function show(Training $training) $trainingInterests = TrainingInterest::where('training_id', $training->id)->orderBy('created_at', 'DESC')->get(); $activeTrainingInterest = TrainingInterest::where('training_id', $training->id)->where('expired', false)->get()->count(); - return view('training.show', compact('training', 'reportsAndExams', 'trainingMentors', 'statuses', 'types', 'experiences', 'activities', 'trainingInterests', 'activeTrainingInterest')); + $requestTypes = TaskController::getTypes(); + + return view('training.show', compact('training', 'reportsAndExams', 'trainingMentors', 'statuses', 'types', 'experiences', 'activities', 'trainingInterests', 'activeTrainingInterest', 'requestTypes')); } /** diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index 984669900..937f87d1b 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -286,6 +286,7 @@ public function settings_update(Request $request, User $user) 'setting_notify_newreq' => '', 'setting_notify_closedreq' => '', 'setting_notify_newexamreport' => '', + 'setting_notify_tasks' => '', 'setting_workmail_address' => 'nullable|email|max:64|regex:/(.*)' . Setting::get('linkDomain') . '$/i', ]); @@ -293,11 +294,13 @@ public function settings_update(Request $request, User $user) isset($data['setting_notify_newreq']) ? $setting_notify_newreq = true : $setting_notify_newreq = false; isset($data['setting_notify_closedreq']) ? $setting_notify_closedreq = true : $setting_notify_closedreq = false; isset($data['setting_notify_newexamreport']) ? $setting_notify_newexamreport = true : $setting_notify_newexamreport = false; + isset($data['setting_notify_tasks']) ? $setting_notify_tasks = true : $setting_notify_tasks = false; $user->setting_notify_newreport = $setting_notify_newreport; $user->setting_notify_newreq = $setting_notify_newreq; $user->setting_notify_closedreq = $setting_notify_closedreq; $user->setting_notify_newexamreport = $setting_notify_newexamreport; + $user->setting_notify_tasks = $setting_notify_tasks; if (! $user->setting_workmail_address && isset($data['setting_workmail_address'])) { $user->setting_workmail_address = $data['setting_workmail_address']; diff --git a/app/Mail/TaskMail.php b/app/Mail/TaskMail.php new file mode 100644 index 000000000..918ab7a02 --- /dev/null +++ b/app/Mail/TaskMail.php @@ -0,0 +1,48 @@ +mailSubject = $mailSubject; + $this->user = $user; + $this->textLines = $textLines; + } + + /** + * Build the message. + * + * @return $this + */ + public function build() + { + return $this->subject($this->mailSubject)->markdown('mail.tasks', [ + 'firstName' => $this->user->first_name, + 'textLines' => $this->textLines, + 'actionUrl' => route('tasks'), + ]); + } +} diff --git a/app/Models/Task.php b/app/Models/Task.php new file mode 100644 index 000000000..95a306c13 --- /dev/null +++ b/app/Models/Task.php @@ -0,0 +1,47 @@ + TaskStatus::class, + ]; + + public function creator() + { + return $this->belongsTo(User::class, 'creator_user_id'); + } + + public function subject() + { + return $this->belongsTo(User::class, 'subject_user_id'); + } + + public function subjectTraining() + { + return $this->belongsTo(Training::class, 'subject_training_id'); + } + + public function assignee() + { + return $this->belongsTo(User::class, 'assignee_user_id'); + } + + public function type() + { + if ($this->type) { + return app($this->type); + } else { + throw new \Exception('Invalid task type: ' . $this->type); + } + } +} diff --git a/app/Models/Training.php b/app/Models/Training.php index 55786af61..f151e703a 100644 --- a/app/Models/Training.php +++ b/app/Models/Training.php @@ -92,8 +92,13 @@ public function updateStatus(int $newStatus, bool $expiredInterest = false) * * @return string */ - public function getInlineRatings() + public function getInlineRatings(bool $vatsimRatingOnly = false) { + + if ($vatsimRatingOnly) { + return $this->ratings->where('vatsim_rating', true)->pluck('name')->implode(' + '); + } + return $this->ratings->pluck('name')->implode(' + '); } diff --git a/app/Models/User.php b/app/Models/User.php index 9cc16383e..5bbbe97d9 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -118,6 +118,11 @@ public function vote() return $this->hasMany(Vote::class); } + public function tasks() + { + return $this->hasMany(Task::class, 'assignee_user_id'); + } + public function atcActivity() { return $this->hasOne(AtcActivity::class); diff --git a/app/Notifications/TaskNotification.php b/app/Notifications/TaskNotification.php new file mode 100644 index 000000000..849b9868a --- /dev/null +++ b/app/Notifications/TaskNotification.php @@ -0,0 +1,91 @@ +user = $user; + $this->receivedTasks = $receivedTasks; + $this->updatedTasks = $updatedTasks; + } + + /** + * Get the notification's delivery channels. + * + * @param mixed $notifiable + * @return array + */ + public function via($notifiable) + { + return ['mail']; + } + + /** + * Get the mail representation of the notification. + * + * @param mixed $notifiable + * @return \Illuminate\Notifications\Messages\MailMessage + */ + public function toMail($notifiable) + { + + $textLines = []; + $textLines[] = 'There is an update for some of your tasks.'; + + if ($this->receivedTasks->count()) { + $textLines[] = '## New tasks'; + + foreach ($this->receivedTasks as $task) { + $textLines[] = '- **' . $task->type()->getName() . '** from ' . $task->creator->name . ' (' . $task->creator->id . ')'; + $task->assignee_notified = true; + $task->save(); + } + + } + + if ($this->updatedTasks->count()) { + $textLines[] = '## Updated tasks'; + + foreach ($this->updatedTasks as $task) { + $textLines[] = '- **' . $task->type()->getName() . '** for ' . $task->subject->name . ' (' . $task->subject->id . ') is ' . strtolower($task->status->name); + $task->creator_notified = true; + $task->save(); + } + + } + + // Return the mail message + return (new TaskMail('Task Digest', $this->user, $textLines)) + ->to($this->user->email); + } + + /** + * Get the array representation of the notification. + * + * @param mixed $notifiable + * @return array + */ + public function toArray($notifiable) + { + return []; + } +} diff --git a/app/Policies/TaskPolicy.php b/app/Policies/TaskPolicy.php new file mode 100644 index 000000000..73581b362 --- /dev/null +++ b/app/Policies/TaskPolicy.php @@ -0,0 +1,41 @@ +isMentorOrAbove(); + } + + /** + * Determine whether the user can update the task. + * + * @return bool + */ + public function update(User $user) + { + return $user->isMentorOrAbove(); + } + + /** + * Determine if user is able to receive a task + * + * @return bool + */ + public function receive(User $user) + { + return $user->isMentorOrAbove(); + } +} diff --git a/app/Rules/ValidTaskType.php b/app/Rules/ValidTaskType.php new file mode 100644 index 000000000..3f290a925 --- /dev/null +++ b/app/Rules/ValidTaskType.php @@ -0,0 +1,41 @@ +message; + } + + public function getLink(Task $model) + { + return false; + } + + public function allowMessage() + { + return true; + } + + public function create(Task $model) + { + parent::onCreated($model); + } + + public function complete(Task $model) + { + parent::onCompleted($model); + } + + public function decline(Task $model) + { + parent::onDeclined($model); + } + + public function showConnectedRatings() + { + return false; + } +} diff --git a/app/Tasks/Types/RatingUpgrade.php b/app/Tasks/Types/RatingUpgrade.php new file mode 100644 index 000000000..1e687ed58 --- /dev/null +++ b/app/Tasks/Types/RatingUpgrade.php @@ -0,0 +1,57 @@ +subject_training_id)->getInlineRatings(true); + } + + public function getLink(Task $model) + { + $user = User::find($model->subject_user_id); + $userEud = $user->division == 'EUD'; + + if ($userEud) { + return 'https://www.atsimtest.com/index.php?cmd=admin&sub=memberdetail&memberid=' . $model->subject_user_id; + } + + return false; + } + + public function create(Task $model) + { + parent::onCreated($model); + } + + public function complete(Task $model) + { + parent::onCompleted($model); + } + + public function decline(Task $model) + { + parent::onDeclined($model); + } + + public function showConnectedRatings() + { + return true; + } +} diff --git a/app/Tasks/Types/SoloEndorsement.php b/app/Tasks/Types/SoloEndorsement.php new file mode 100644 index 000000000..6d3c5fb91 --- /dev/null +++ b/app/Tasks/Types/SoloEndorsement.php @@ -0,0 +1,48 @@ +subject_user_id); + } + + public function create(Task $model) + { + parent::onCreated($model); + } + + public function complete(Task $model) + { + parent::onCompleted($model); + } + + public function decline(Task $model) + { + parent::onDeclined($model); + } + + public function showConnectedRatings() + { + return false; + } +} diff --git a/app/Tasks/Types/TheoreticalExam.php b/app/Tasks/Types/TheoreticalExam.php new file mode 100644 index 000000000..ed388ee00 --- /dev/null +++ b/app/Tasks/Types/TheoreticalExam.php @@ -0,0 +1,55 @@ +subjectTraining->getInlineRatings(true); + } + + public function getLink(Task $model) + { + $user = $model->subject; + $userEud = $user->division == 'EUD'; + + if ($userEud) { + return 'https://www.atsimtest.com/index.php?cmd=admin&sub=memberdetail&memberid=' . $model->subject_user_id; + } + + return false; + } + + public function create(Task $model) + { + parent::onCreated($model); + } + + public function complete(Task $model) + { + parent::onCompleted($model); + } + + public function decline(Task $model) + { + parent::onDeclined($model); + } + + public function showConnectedRatings() + { + return true; + } +} diff --git a/app/Tasks/Types/Types.php b/app/Tasks/Types/Types.php new file mode 100644 index 000000000..d4ca38d9b --- /dev/null +++ b/app/Tasks/Types/Types.php @@ -0,0 +1,58 @@ +name = $this->getName(); + $this->icon = $this->getIcon(); + } + + public function onCreated(Task $model) + { + // Default behaviour when task is created + } + + public function onCompleted(Task $model) + { + // Default behaviour is completed + } + + public function onDeclined(Task $model) + { + // Default behaviour is declined + } + + public function allowMessage() + { + return false; + } + + abstract public function getName(); + + abstract public function getIcon(); + + abstract public function getText(Task $model); + + abstract public function getLink(Task $model); + + abstract public function create(Task $model); + + abstract public function complete(Task $model); + + abstract public function decline(Task $model); + + abstract public function showConnectedRatings(); +} diff --git a/composer.json b/composer.json index fc24fd20e..4911f40af 100755 --- a/composer.json +++ b/composer.json @@ -48,6 +48,7 @@ "psr-4": { "App\\": "app/", "App\\Helpers\\": "app/helpers", + "App\\Tasks\\Types\\": "app/Tasks/Types/", "Database\\Factories\\": "database/factories/", "Database\\Seeders\\": "database/seeders/" }, diff --git a/database/migrations/2023_10_04_085425_tasks.php b/database/migrations/2023_10_04_085425_tasks.php new file mode 100644 index 000000000..7692a709e --- /dev/null +++ b/database/migrations/2023_10_04_085425_tasks.php @@ -0,0 +1,42 @@ +id(); + $table->string('type'); + $table->tinyInteger('status')->default(0); + $table->string('status_comment', 256)->nullable(); + $table->string('message', 256)->nullable(); + $table->foreignId('subject_user_id')->constrained('users')->onDelete('cascade'); + $table->foreignId('subject_training_id')->constrained('trainings')->onDelete('cascade'); + $table->foreignId('assignee_user_id')->constrained('users')->onDelete('cascade'); + $table->foreignId('creator_user_id')->nullable()->constrained('users')->onDelete('cascade'); + $table->boolean('assignee_notified')->default(false); + $table->boolean('creator_notified')->default(false); + $table->timestamps(); + $table->timestamp('closed_at')->nullable(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('tasks'); + } +}; diff --git a/database/migrations/2023_10_05_182438_add_notify_user_setting.php b/database/migrations/2023_10_05_182438_add_notify_user_setting.php new file mode 100644 index 000000000..5443bee1e --- /dev/null +++ b/database/migrations/2023_10_05_182438_add_notify_user_setting.php @@ -0,0 +1,32 @@ +boolean('setting_notify_tasks')->default(true)->after('setting_notify_newexamreport'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('setting_notify_tasks'); + }); + } +}; diff --git a/resources/sass/_global.scss b/resources/sass/_global.scss index f5f7b43e1..4ec93fc19 100755 --- a/resources/sass/_global.scss +++ b/resources/sass/_global.scss @@ -238,7 +238,11 @@ table{ a { text-decoration: none; - &:hover{ + &.dotted-underline{ + text-decoration: dotted underline; + } + + &:hover:not([role="button"]){ text-decoration: underline; } } diff --git a/resources/sass/_variables.scss b/resources/sass/_variables.scss index bce609b2e..7d8d30937 100755 --- a/resources/sass/_variables.scss +++ b/resources/sass/_variables.scss @@ -78,3 +78,13 @@ $transition-collapse: height 0.15s ease !default; // Dropdowns $dropdown-font-size: 0.85rem; $dropdown-border-color: $border-color; + +// Relative position values +$position-values: ( + 0: 0, + 25: 25%, + 40: 40%, + 50: 50%, + 75: 75%, + 100: 100% +); \ No newline at end of file diff --git a/resources/sass/navs/_sidebar.scss b/resources/sass/navs/_sidebar.scss index 439f63863..f0396d54a 100755 --- a/resources/sass/navs/_sidebar.scss +++ b/resources/sass/navs/_sidebar.scss @@ -86,6 +86,11 @@ margin: 0 0 1rem; } + .badge{ + font-size: 0.7rem !important; + border-radius: 50%; + } + .nav-item { // Accordion diff --git a/resources/views/layouts/sidebar.blade.php b/resources/views/layouts/sidebar.blade.php index 024d27c2e..31bc3dee0 100644 --- a/resources/views/layouts/sidebar.blade.php +++ b/resources/views/layouts/sidebar.blade.php @@ -24,6 +24,18 @@ Dashboard + @can('update', [\App\Models\Task::class]) +
Created | +Subject | +Request | +{{ (!in_array($activeFilter, ['sent'])) ? 'Creator' : 'Assigned to' }} | +{{ (!in_array($activeFilter, ['sent', 'archived'])) ? 'Actions' : 'Status' }} | +
---|
+ You have no tasks +
+