diff --git a/app/Actions/AssignToQuizAction.php b/app/Actions/AssignToQuizAction.php new file mode 100644 index 00000000..34c20f3a --- /dev/null +++ b/app/Actions/AssignToQuizAction.php @@ -0,0 +1,24 @@ + $users + */ + public function execute(Quiz $quiz, Collection $users): void + { + $assignedUsers = $quiz->assignedUsers; + $users = User::query()->whereIn("id", $users)->get(); + + $users = $users->filter(fn(User $user) => !$assignedUsers->contains($user)); + $quiz->assignedUsers()->attach($users); + } +} diff --git a/app/Actions/UnassignToQuizAction.php b/app/Actions/UnassignToQuizAction.php new file mode 100644 index 00000000..d17a2748 --- /dev/null +++ b/app/Actions/UnassignToQuizAction.php @@ -0,0 +1,23 @@ + $users + */ + public function execute(Quiz $quiz, Collection $users): void + { + $assignedUsers = $quiz->assignedUsers; + $users = User::query()->whereIn("id", $users)->get(); + $users = $users->filter(fn(User $user) => $assignedUsers->contains($user)); + $quiz->assignedUsers()->detach($users); + } +} diff --git a/app/Helpers/SortHelper.php b/app/Helpers/SortHelper.php new file mode 100644 index 00000000..a58abc23 --- /dev/null +++ b/app/Helpers/SortHelper.php @@ -0,0 +1,70 @@ + $allowedFields + * @param array $ignoredFields + */ + public function sort(Builder $query, array $allowedFields, array $ignoredFields): Builder + { + [$field, $order] = $this->getSortParameters(); + + if (!in_array($field, $allowedFields, true)) { + if (in_array($field, $ignoredFields, true)) { + return $query; + } + + abort(Status::HTTP_BAD_REQUEST, Lang::get("validation.custom.sorting.unsupported_field", ["attribute" => $field])); + } + + return $query->orderBy($field, $order); + } + + /** + * @return array + */ + public function getSortParameters(): array + { + $field = $this->request->query("sort", "id"); + $ascending = $this->request->query("order", "asc") === "asc"; + + return [$field, $ascending ? "asc" : "desc"]; + } + + public function search(Builder $query, string $field): Builder + { + $searchText = $this->request->query("search"); + + if ($searchText) { + return $query->where($field, "ilike", "%$searchText%"); + } + + return $query; + } + + public function paginate(Builder $query): LengthAwarePaginator + { + $limit = (int)$this->request->query("limit", "50"); + + if (!$limit || $limit < 0) { + $limit = 50; + } + + return $query->paginate($limit); + } +} diff --git a/app/Http/Controllers/InviteController.php b/app/Http/Controllers/InviteController.php new file mode 100644 index 00000000..aeea3fac --- /dev/null +++ b/app/Http/Controllers/InviteController.php @@ -0,0 +1,111 @@ +authorize("invite", $quiz); + $query = User::query()->role("user")->with("school")->whereNotNull("email_verified_at"); + $query = $sort->sort($query, ["id"], ["name", "school"]); + $query = $this->sortByName($query, $sort); + $query = $this->sortBySchool($query, $sort); + $query = $this->filterByName($query, $request); + $query = $this->filterBySchool($query, $request); + $schools = SchoolResource::collection(School::all()); + + return Inertia::render("Admin/Invite", [ + "users" => UserResource::collection($sort->paginate($query)), + "quiz" => QuizResource::make($quiz), + "schools" => $schools, + "assigned" => $quiz->assignedUsers->pluck("id"), + ]); + } + + public function assign(Quiz $quiz, InviteQuizRequest $request, AssignToQuizAction $assignAction): RedirectResponse + { + $this->authorize("invite", $quiz); + + $assignAction->execute($quiz, collect($request->input("ids"))); + + return redirect() + ->back() + ->with("status", "Użytkownicy zostali przypisani do quizu."); + } + + public function unassign(Quiz $quiz, InviteQuizRequest $request, UnassignToQuizAction $unassignAction): RedirectResponse + { + $this->authorize("invite", $quiz); + + $unassignAction->execute($quiz, collect($request->input("ids"))); + + return redirect() + ->back() + ->with("status", "Użytkownicy zostali wypisani z quizu."); + } + + private function filterByName(Builder $query, Request $request): Builder + { + $searchName = $request->query("search"); + + if ($searchName) { + return $query->where("users.name", "ilike", "%$searchName%") + ->orWhere("users.surname", "ilike", "%$searchName%"); + } + + return $query; + } + + private function filterBySchool(Builder $query, Request $request): Builder + { + $searchSchool = $request->query("schoolId"); + + if ($searchSchool) { + return $query->where("school_id", $searchSchool); + } + + return $query; + } + + private function sortByName(Builder $query, SortHelper $sorter): Builder + { + [$field, $order] = $sorter->getSortParameters(); + + if ($field === "name") { + return $query->orderBy("surname", $order) + ->orderBy("name", $order); + } + + return $query; + } + + private function sortBySchool(Builder $query, SortHelper $sorter): Builder + { + [$field, $order] = $sorter->getSortParameters(); + + if ($field === "schoolId") { + return $query->orderBy("school.name", $order); + } + + return $query; + } +} diff --git a/app/Http/Requests/InviteQuizRequest.php b/app/Http/Requests/InviteQuizRequest.php new file mode 100644 index 00000000..13f4bbc7 --- /dev/null +++ b/app/Http/Requests/InviteQuizRequest.php @@ -0,0 +1,26 @@ + + */ + public function rules(): array + { + return [ + "ids.*" => ["required", "integer"], + ]; + } +} diff --git a/app/Http/Resources/InviteResource.php b/app/Http/Resources/InviteResource.php new file mode 100644 index 00000000..c19239e9 --- /dev/null +++ b/app/Http/Resources/InviteResource.php @@ -0,0 +1,27 @@ + + */ + public function toArray(Request $request): array + { + return [ + "user" => [ + "id" => $this->user->id, + "name" => $this->user->name, + "surname" => $this->user->surname, + "school" => $this->user->school, + ], + "points" => $this->points, + ]; + } +} diff --git a/app/Jobs/SendInviteJob.php b/app/Jobs/SendInviteJob.php new file mode 100644 index 00000000..71866dd4 --- /dev/null +++ b/app/Jobs/SendInviteJob.php @@ -0,0 +1,34 @@ +user->notify(new InviteUserNotification($this->quiz)); + } +} diff --git a/app/Jobs/SendPasswordResetJob.php b/app/Jobs/SendPasswordResetJob.php new file mode 100644 index 00000000..9e5b87d8 --- /dev/null +++ b/app/Jobs/SendPasswordResetJob.php @@ -0,0 +1,31 @@ +user->notify(new ResetPasswordNotification($this->token)); + } +} diff --git a/app/Jobs/SendVerificationEmailJob.php b/app/Jobs/SendVerificationEmailJob.php new file mode 100644 index 00000000..b2ee8682 --- /dev/null +++ b/app/Jobs/SendVerificationEmailJob.php @@ -0,0 +1,30 @@ +user->notify(new SendVerificationEmail()); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index d31b618a..672cafdf 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -4,8 +4,8 @@ namespace App\Models; -use App\Notifications\ResetPasswordNotification; -use App\Notifications\SendVerificationEmail; +use App\Jobs\SendPasswordResetJob; +use App\Jobs\SendVerificationEmailJob; use Carbon\Carbon; use Illuminate\Contracts\Auth\CanResetPassword; use Illuminate\Contracts\Auth\MustVerifyEmail; @@ -57,12 +57,12 @@ public function school(): BelongsTo public function sendEmailVerificationNotification(): void { - $this->notify(new SendVerificationEmail()); + SendVerificationEmailJob::dispatch($this); } public function sendPasswordResetNotification($token): void { - $this->notify(new ResetPasswordNotification($token)); + SendPasswordResetJob::dispatch($this, $token); } public function userQuizzes(): HasMany diff --git a/app/Notifications/InviteUserNotification.php b/app/Notifications/InviteUserNotification.php new file mode 100644 index 00000000..8011b4b9 --- /dev/null +++ b/app/Notifications/InviteUserNotification.php @@ -0,0 +1,35 @@ +subject("Zaproszenie do Quizu") + ->view("emails.auth.invite-user", [ + "user" => $notifiable, + "quiz" => $this->quiz, + ]); + } +} diff --git a/app/Notifications/ResetPasswordNotification.php b/app/Notifications/ResetPasswordNotification.php index cdafa2fb..858a0e69 100644 --- a/app/Notifications/ResetPasswordNotification.php +++ b/app/Notifications/ResetPasswordNotification.php @@ -5,10 +5,11 @@ namespace App\Notifications; use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Notifications\Notification; -class ResetPasswordNotification extends Notification +class ResetPasswordNotification extends Notification implements ShouldQueue { use Queueable; diff --git a/app/Policies/QuizPolicy.php b/app/Policies/QuizPolicy.php index 15af4039..9f1479ee 100644 --- a/app/Policies/QuizPolicy.php +++ b/app/Policies/QuizPolicy.php @@ -47,7 +47,7 @@ public function assign(User $user, Quiz $quiz): bool public function viewAdminRanking(User $user, Quiz $quiz): Response { - return ($quiz->isLocked && $user->hasRole("admin|super_admin")) ? Response::allow() : Response::deny("Nie masz uprawnień do zobaczenia rankingu."); + return ($quiz->isPublished && $user->hasRole("admin|super_admin")) ? Response::allow() : Response::deny("Nie masz uprawnień do zobaczenia rankingu."); } public function viewUserRanking(User $user, Quiz $quiz): Response @@ -67,4 +67,9 @@ public function publish(User $user, Quiz $quiz): bool { return $quiz->isLocked && $user->hasRole("admin|super_admin") && $quiz->userQuizzes->isNotEmpty(); } + + public function invite(User $user, Quiz $quiz): Response + { + return $user->hasRole("admin|super_admin") ? (!$quiz->isPublished && $quiz->isLocked) ? Response::allow() : Response::deny("Stan quizu uniemożliwia Ci zaproszenie użytkowników do niego.") : Response::deny("Nie masz uprawnień do zapraszania użytkowników do quizu."); + } } diff --git a/database/factories/QuizFactory.php b/database/factories/QuizFactory.php index b4be3a88..166a0e55 100644 --- a/database/factories/QuizFactory.php +++ b/database/factories/QuizFactory.php @@ -28,4 +28,13 @@ public function locked(): static "locked_at" => Carbon::now(), ]); } + + public function published(): static + { + return $this->state(fn(array $attributes): array => [ + "scheduled_at" => Carbon::now()->subMinutes(30), + "locked_at" => Carbon::now(), + "ranking_published_at" => null, + ]); + } } diff --git a/database/seeders/UserQuizSeeder.php b/database/seeders/UserQuizSeeder.php index fd0c6e34..a65fabdc 100644 --- a/database/seeders/UserQuizSeeder.php +++ b/database/seeders/UserQuizSeeder.php @@ -29,7 +29,7 @@ public function run(): void Answer::factory()->count(4), ), ) - ->locked() + ->published() ->create(); foreach ($this->quiz->questions as $question) { diff --git a/lang/pl/validation.php b/lang/pl/validation.php index c690cb35..0c5aad27 100644 --- a/lang/pl/validation.php +++ b/lang/pl/validation.php @@ -131,6 +131,9 @@ "questions.*.text" => [ "required" => "Treść pytania jest wymagana.", ], + "sorting" => [ + "unsupported_field" => "Sortowanie po polu :attribute nie jest wspierane.", + ], ], "attributes" => [ "name" => "imię", diff --git a/resources/js/Helpers/Params.ts b/resources/js/Helpers/Params.ts new file mode 100644 index 00000000..7483c8c3 --- /dev/null +++ b/resources/js/Helpers/Params.ts @@ -0,0 +1,10 @@ +export function useParams(): Record { + const searchParams = new URL(window.location.href).searchParams + let params: Record = {} + + for (const [key, value] of searchParams.entries()) { + params[key] = value + } + + return params +} diff --git a/resources/js/Pages/Admin/Invite.vue b/resources/js/Pages/Admin/Invite.vue new file mode 100644 index 00000000..b57d441e --- /dev/null +++ b/resources/js/Pages/Admin/Invite.vue @@ -0,0 +1,144 @@ + + + diff --git a/resources/js/Pages/Admin/Ranking.vue b/resources/js/Pages/Admin/Ranking.vue index abcdcedc..8519c387 100644 --- a/resources/js/Pages/Admin/Ranking.vue +++ b/resources/js/Pages/Admin/Ranking.vue @@ -19,7 +19,7 @@ const grouped = computed(() => groupBy('points', sorted.value))