From 573d834905f876bace2032c6f2901d0dbc564217 Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Fri, 4 Sep 2026 09:17:42 +0700 Subject: [PATCH] feat: implement Assignment and Submission management; add controllers, requests, and services for assignment functionality --- BEST_PRACTICE.md | 37 ++++++++++++++++++- .../{ => Assignment}/AssignmentController.php | 10 +++-- .../{ => Assignment}/SubmissionController.php | 23 +++--------- .../{ => Assignment}/AssignmentRequest.php | 12 ++++-- .../Assignment/GradeSubmissionRequest.php | 21 +++++++++++ .../SubmitAssignmentRequest.php | 2 +- .../GradeSubmissionRequest.php | 31 ---------------- app/Models/User.php | 13 +++++++ .../{ => Assignment}/AssignmentService.php | 22 ++++++----- .../{ => Assignment}/SubmissionService.php | 2 +- .../assignments/submissions.tsx | 18 +++++---- resources/js/types/assignment.ts | 2 - routes/admin.php | 4 +- 13 files changed, 116 insertions(+), 81 deletions(-) rename app/Http/Controllers/Admin/AcademicClasses/{ => Assignment}/AssignmentController.php (87%) rename app/Http/Controllers/Admin/AcademicClasses/{ => Assignment}/SubmissionController.php (68%) rename app/Http/Requests/Admin/AcademicClasses/{ => Assignment}/AssignmentRequest.php (70%) create mode 100644 app/Http/Requests/Admin/AcademicClasses/Assignment/GradeSubmissionRequest.php rename app/Http/Requests/Admin/AcademicClasses/{ => Assignment}/SubmitAssignmentRequest.php (96%) delete mode 100644 app/Http/Requests/Admin/AcademicClasses/GradeSubmissionRequest.php rename app/Services/Admin/AcademicClasses/{ => Assignment}/AssignmentService.php (76%) rename app/Services/Admin/AcademicClasses/{ => Assignment}/SubmissionService.php (96%) diff --git a/BEST_PRACTICE.md b/BEST_PRACTICE.md index 4fe93e6..5eacb6b 100644 --- a/BEST_PRACTICE.md +++ b/BEST_PRACTICE.md @@ -178,7 +178,42 @@ ## 4. Controller, Request, dan Service ``` - Otorisasi berbasis permission (Spatie) dicek di dua tempat: - Route-level: middleware `permission:create-xxx` dipasang per-route/per-group. - - Object-level (mis. user hanya boleh mengubah record miliknya sendiri): di `authorize()` milik FormRequest, kombinasikan `$this->user()->can('permission-name')` dengan pengecekan kepemilikan record. + - Object-level (mis. user hanya boleh mengubah record miliknya sendiri): di `authorize()` milik FormRequest, kombinasikan `$this->user()->can('permission-name')` dengan pengecekan kepemilikan record. Untuk route tanpa FormRequest (mis. `destroy()` yang tidak butuh validasi input), pengecekan yang sama dilakukan inline dengan `abort_if()`/`abort_unless()` di Controller. + - **Predikat kepemilikan itu sendiri ditaruh sebagai method di Model (`User`, atau model pemilik lain yang relevan), bukan didefinisikan ulang di tiap Controller/FormRequest yang butuh.** Satu aturan bisnis harus punya satu sumber kebenaran — supaya konsisten dan gampang di-test. Penamaan: `isOf($target)` untuk peran yang punya nama (mis. `isAdvisorOf`), atau `canManage($target)` untuk gate umum "boleh mengubah/menghapus record ini". + ```php + // Bad — predikat yang sama diulang di FormRequest DAN Controller + // (FormRequest) + public function authorize(): bool + { + $assignment = $this->route('assignment'); + return $this->user()->can('update-assignments') + && (! $this->user()->hasRole('dosen') || $assignment->courseClass->lecturer_id === $this->user()->lecturer?->id); + } + // (Controller::destroy(), butuh predikat yang sama karena tidak ada FormRequest) + private function abortUnlessLecturerOwnsAssignment(Assignment $assignment): void + { + $user = request()->user(); + abort_if($user->hasRole('dosen') && $assignment->courseClass?->lecturer_id !== $user->lecturer?->id, 403); + } + + // Good — predikat di Model, dipakai dari FormRequest maupun Controller + // (User model) + public function canManageAssignment(Assignment $assignment): bool + { + if ($this->hasRole(UserRole::Dosen->value)) { + return $assignment->courseClass->lecturer_id === $this->lecturer?->id; + } + return true; + } + // (FormRequest) + public function authorize(): bool + { + return $this->user()->can('update-assignments') + && $this->user()->canManageAssignment($this->route('assignment')); + } + // (Controller::destroy()) + abort_unless(request()->user()->canManageAssignment($assignment), 403); + ``` - Constructor injection untuk dependency (Service, Model), jangan `new Xxx` langsung di dalam method: ```php // Bad diff --git a/app/Http/Controllers/Admin/AcademicClasses/AssignmentController.php b/app/Http/Controllers/Admin/AcademicClasses/Assignment/AssignmentController.php similarity index 87% rename from app/Http/Controllers/Admin/AcademicClasses/AssignmentController.php rename to app/Http/Controllers/Admin/AcademicClasses/Assignment/AssignmentController.php index 2c46da5..d852068 100644 --- a/app/Http/Controllers/Admin/AcademicClasses/AssignmentController.php +++ b/app/Http/Controllers/Admin/AcademicClasses/Assignment/AssignmentController.php @@ -1,13 +1,13 @@ user()->canManageAssignment($assignment), 403); + $this->service->delete($assignment); return Inertia::flash('toast', ['type' => 'success', 'message' => 'Tugas berhasil dihapus.'])->back(); diff --git a/app/Http/Controllers/Admin/AcademicClasses/SubmissionController.php b/app/Http/Controllers/Admin/AcademicClasses/Assignment/SubmissionController.php similarity index 68% rename from app/Http/Controllers/Admin/AcademicClasses/SubmissionController.php rename to app/Http/Controllers/Admin/AcademicClasses/Assignment/SubmissionController.php index 6570f31..e25c95c 100644 --- a/app/Http/Controllers/Admin/AcademicClasses/SubmissionController.php +++ b/app/Http/Controllers/Admin/AcademicClasses/Assignment/SubmissionController.php @@ -1,13 +1,13 @@ abortUnlessLecturerOwnsAssignment($assignment); + abort_unless(request()->user()->canManageAssignment($assignment), 403); $assignment->load(['courseClass.course', 'courseClass.lecturer.user.profile', 'courseClass.academicTerm']); @@ -45,17 +45,4 @@ public function submit(SubmitAssignmentRequest $request, Assignment $assignment) return to_route('admin.academic-classes.assignments.index'); } - - /** - * A dosen may only manage submissions for classes they lecture. - */ - private function abortUnlessLecturerOwnsAssignment(Assignment $assignment): void - { - $user = request()->user(); - - abort_if( - $user->hasRole('dosen') && $assignment->courseClass?->lecturer_id !== $user->lecturer?->id, - 403, - ); - } } diff --git a/app/Http/Requests/Admin/AcademicClasses/AssignmentRequest.php b/app/Http/Requests/Admin/AcademicClasses/Assignment/AssignmentRequest.php similarity index 70% rename from app/Http/Requests/Admin/AcademicClasses/AssignmentRequest.php rename to app/Http/Requests/Admin/AcademicClasses/Assignment/AssignmentRequest.php index f300437..1efd0f7 100644 --- a/app/Http/Requests/Admin/AcademicClasses/AssignmentRequest.php +++ b/app/Http/Requests/Admin/AcademicClasses/Assignment/AssignmentRequest.php @@ -1,8 +1,9 @@ user()->can($this->isMethod('post') ? 'create-assignments' : 'update-assignments'); + if ($this->isMethod('post')) { + return $this->user()->can('create-assignments'); + } + + return $this->user()->can('update-assignments') + && $this->user()->canManageAssignment($this->route('assignment')); } public function rules(): array @@ -19,7 +25,7 @@ public function rules(): array $courseClassRule = Rule::exists('course_classes', 'id'); - if ($user->hasRole('dosen')) { + if ($user->hasRole(UserRole::Dosen->value)) { $courseClassRule->where('lecturer_id', $user->lecturer?->id); } diff --git a/app/Http/Requests/Admin/AcademicClasses/Assignment/GradeSubmissionRequest.php b/app/Http/Requests/Admin/AcademicClasses/Assignment/GradeSubmissionRequest.php new file mode 100644 index 0000000..09458a6 --- /dev/null +++ b/app/Http/Requests/Admin/AcademicClasses/Assignment/GradeSubmissionRequest.php @@ -0,0 +1,21 @@ +user()->can('update-assignment-submissions') + && $this->user()->canManageAssignment($this->route('assignment')); + } + + public function rules(): array + { + return [ + 'score' => ['nullable', 'numeric', 'min:0', 'max:100'], + ]; + } +} diff --git a/app/Http/Requests/Admin/AcademicClasses/SubmitAssignmentRequest.php b/app/Http/Requests/Admin/AcademicClasses/Assignment/SubmitAssignmentRequest.php similarity index 96% rename from app/Http/Requests/Admin/AcademicClasses/SubmitAssignmentRequest.php rename to app/Http/Requests/Admin/AcademicClasses/Assignment/SubmitAssignmentRequest.php index fcb7dfe..53a506d 100644 --- a/app/Http/Requests/Admin/AcademicClasses/SubmitAssignmentRequest.php +++ b/app/Http/Requests/Admin/AcademicClasses/Assignment/SubmitAssignmentRequest.php @@ -1,6 +1,6 @@ user()->can('update-assignment-submissions')) { - return false; - } - - $user = $this->user(); - $assignment = $this->route('assignment'); - - if ($user->hasRole('dosen') && $assignment?->courseClass?->lecturer_id !== $user->lecturer?->id) { - return false; - } - - return true; - } - - public function rules(): array - { - return [ - 'score' => ['nullable', 'numeric', 'min:0', 'max:100'], - ]; - } -} diff --git a/app/Models/User.php b/app/Models/User.php index 61fd528..556897e 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -121,6 +121,19 @@ public function canManageMaterial(Material $material): bool return true; } + /** + * A dosen may only manage assignments (update/delete) and their submissions + * (view/grade) for classes they lecture. + */ + public function canManageAssignment(Assignment $assignment): bool + { + if ($this->hasRole(UserRole::Dosen->value)) { + return $assignment->courseClass->lecturer_id === $this->lecturer?->id; + } + + return true; + } + public function isAdvisorOf(Student $student): bool { return $this->hasRole(UserRole::Dosen->value) && $this->lecturer && $student->academic_advisor_id === $this->lecturer->id; diff --git a/app/Services/Admin/AcademicClasses/AssignmentService.php b/app/Services/Admin/AcademicClasses/Assignment/AssignmentService.php similarity index 76% rename from app/Services/Admin/AcademicClasses/AssignmentService.php rename to app/Services/Admin/AcademicClasses/Assignment/AssignmentService.php index 161d24d..339167b 100644 --- a/app/Services/Admin/AcademicClasses/AssignmentService.php +++ b/app/Services/Admin/AcademicClasses/Assignment/AssignmentService.php @@ -1,9 +1,10 @@ with(['courseClass' => fn ($q) => $q->withCount('enrollments') ->with(['course:id,code,name', 'academicTerm:id,academic_year,semester,start_date,end_date'])]) - ->when($user->hasRole('mahasiswa'), fn ($q) => $q->with(['submissions' => function ($q) use ($user) { + ->when($user->hasRole(UserRole::Mahasiswa->value), fn ($q) => $q->with(['submissions' => function ($q) use ($user) { $q->select(['id', 'assignment_id', 'student_id', 'notes', 'status', 'submitted_at']) ->where('student_id', $user->student?->id); }])) ->when($search, fn ($q) => $q->where('title', 'like', "%{$search}%")) ->when($courseClassId, fn ($q) => $q->where('course_class_id', $courseClassId)) ->when($academicTermId, fn ($q) => $q->whereHas('courseClass', fn ($q) => $q->where('academic_term_id', $academicTermId))) - ->when($user->hasRole('dosen'), fn ($q) => $q->whereHas('courseClass', fn ($q) => $q->where('lecturer_id', $user->lecturer?->id))) - ->when($user->hasRole('mahasiswa'), fn ($q) => $q->whereHas('courseClass', function ($q) use ($user) { + ->when($user->hasRole(UserRole::Dosen->value), fn ($q) => $q->whereHas('courseClass', fn ($q) => $q->where('lecturer_id', $user->lecturer?->id))) + ->when($user->hasRole(UserRole::Mahasiswa->value), fn ($q) => $q->whereHas('courseClass', function ($q) use ($user) { $q->whereHas('registrations', function ($q) use ($user) { $q->where('student_id', $user->student?->id) ->whereHas('submission', fn ($q) => $q->where('status', RegistrationStatus::Approved)); @@ -58,12 +59,13 @@ public function create(array $data, ?UploadedFile $file): Assignment public function update(Assignment $assignment, array $data, ?UploadedFile $file): Assignment { - $assignment->course_class_id = $data['course_class_id']; - $assignment->title = $data['title']; - $assignment->description = $data['description'] ?? null; - $assignment->deadline = $data['deadline']; - $assignment->status = $data['status'] ?? $assignment->status; - $assignment->update(); + $assignment->update([ + 'course_class_id' => $data['course_class_id'], + 'title' => $data['title'], + 'description' => $data['description'] ?? null, + 'deadline' => $data['deadline'], + 'status' => $data['status'] ?? $assignment->status, + ]); if ($file) { $assignment->addMedia($file)->toMediaCollection('assignment_attachment'); diff --git a/app/Services/Admin/AcademicClasses/SubmissionService.php b/app/Services/Admin/AcademicClasses/Assignment/SubmissionService.php similarity index 96% rename from app/Services/Admin/AcademicClasses/SubmissionService.php rename to app/Services/Admin/AcademicClasses/Assignment/SubmissionService.php index 389ae5d..4c8194c 100644 --- a/app/Services/Admin/AcademicClasses/SubmissionService.php +++ b/app/Services/Admin/AcademicClasses/Assignment/SubmissionService.php @@ -1,6 +1,6 @@ [] = [ { - accessorKey: 'student.student_number', + id: 'identity', header: () => NIM, - cell: ({ row }) => row.original.student?.student_number ?? '-', - }, - { - accessorKey: 'student.user.profile.full_name', - header: () => Nama Mahasiswa, - cell: ({ row }) => - row.original.student?.user?.profile?.full_name ?? '-', + cell: ({ row }) => ( +
+ {row.original.student?.student_number ?? '-'} + + {row.original.student?.user?.profile?.full_name ?? + '-'} + +
+ ), }, { accessorKey: 'status', diff --git a/resources/js/types/assignment.ts b/resources/js/types/assignment.ts index 44efea8..03b2970 100644 --- a/resources/js/types/assignment.ts +++ b/resources/js/types/assignment.ts @@ -42,6 +42,4 @@ export type Assignment = { graded_submissions_count: number; /** Only present for the logged-in mahasiswa: their own submission, if any. */ submissions?: MySubmission[]; - created_at: string; - updated_at: string; }; diff --git a/routes/admin.php b/routes/admin.php index 1117ce0..629f708 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -1,10 +1,10 @@