feat: implement Assignment and Submission management; add controllers, requests, and services for assignment functionality

This commit is contained in:
Yoga Pangestu 2026-09-04 09:17:42 +07:00
parent 024dc4dd5a
commit 573d834905
13 changed files with 116 additions and 81 deletions

View File

@ -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: `is<Peran>Of($target)` untuk peran yang punya nama (mis. `isAdvisorOf`), atau `canManage<Model>($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

View File

@ -1,13 +1,13 @@
<?php
namespace App\Http\Controllers\Admin\AcademicClasses;
namespace App\Http\Controllers\Admin\AcademicClasses\Assignment;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\AcademicClasses\AssignmentRequest;
use App\Http\Requests\Admin\AcademicClasses\Assignment\AssignmentRequest;
use App\Http\Requests\PaginatedRequest;
use App\Models\Assignment;
use App\Services\Admin\AcademicClasses\AssignmentService;
use App\Services\Admin\Manage\CourseClassService;
use App\Services\Admin\AcademicClasses\Assignment\AssignmentService;
use App\Services\Admin\Manage\CourseClass\CourseClassService;
use App\Services\Admin\Master\AcademicTermService;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
@ -63,6 +63,8 @@ public function update(AssignmentRequest $request, Assignment $assignment): Redi
public function destroy(Assignment $assignment): RedirectResponse
{
abort_unless(request()->user()->canManageAssignment($assignment), 403);
$this->service->delete($assignment);
return Inertia::flash('toast', ['type' => 'success', 'message' => 'Tugas berhasil dihapus.'])->back();

View File

@ -1,13 +1,13 @@
<?php
namespace App\Http\Controllers\Admin\AcademicClasses;
namespace App\Http\Controllers\Admin\AcademicClasses\Assignment;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\AcademicClasses\GradeSubmissionRequest;
use App\Http\Requests\Admin\AcademicClasses\SubmitAssignmentRequest;
use App\Http\Requests\Admin\AcademicClasses\Assignment\SubmitAssignmentRequest;
use App\Http\Requests\Admin\AcademicClasses\Assignment\GradeSubmissionRequest;
use App\Models\Assignment;
use App\Models\Submission;
use App\Services\Admin\AcademicClasses\SubmissionService;
use App\Services\Admin\AcademicClasses\Assignment\SubmissionService;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
use Inertia\Response;
@ -20,7 +20,7 @@ public function __construct(
public function index(Assignment $assignment): Response
{
$this->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,
);
}
}

View File

@ -1,8 +1,9 @@
<?php
namespace App\Http\Requests\Admin\AcademicClasses;
namespace App\Http\Requests\Admin\AcademicClasses\Assignment;
use App\Enums\AssignmentStatus;
use App\Enums\UserRole;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
@ -10,7 +11,12 @@ class AssignmentRequest extends FormRequest
{
public function authorize(): bool
{
return $this->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);
}

View File

@ -0,0 +1,21 @@
<?php
namespace App\Http\Requests\Admin\AcademicClasses\Assignment;
use Illuminate\Foundation\Http\FormRequest;
class GradeSubmissionRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->can('update-assignment-submissions')
&& $this->user()->canManageAssignment($this->route('assignment'));
}
public function rules(): array
{
return [
'score' => ['nullable', 'numeric', 'min:0', 'max:100'],
];
}
}

View File

@ -1,6 +1,6 @@
<?php
namespace App\Http\Requests\Admin\AcademicClasses;
namespace App\Http\Requests\Admin\AcademicClasses\Assignment;
use App\Enums\AssignmentStatus;
use App\Enums\RegistrationStatus;

View File

@ -1,31 +0,0 @@
<?php
namespace App\Http\Requests\Admin\AcademicClasses;
use Illuminate\Foundation\Http\FormRequest;
class GradeSubmissionRequest extends FormRequest
{
public function authorize(): bool
{
if (! $this->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'],
];
}
}

View File

@ -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;

View File

@ -1,9 +1,10 @@
<?php
namespace App\Services\Admin\AcademicClasses;
namespace App\Services\Admin\AcademicClasses\Assignment;
use App\Enums\AssignmentStatus;
use App\Enums\RegistrationStatus;
use App\Enums\UserRole;
use App\Models\Assignment;
use App\Models\User;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
@ -21,15 +22,15 @@ public function paginated(User $user, int $perPage = 25, string $search = '', ?i
])
->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');

View File

@ -1,6 +1,6 @@
<?php
namespace App\Services\Admin\AcademicClasses;
namespace App\Services\Admin\AcademicClasses\Assignment;
use App\Enums\SubmissionStatus;
use App\Models\Assignment;

View File

@ -94,15 +94,17 @@ export default function SubmissionIndex({ assignment, submissions }: Props) {
const columns: ColumnDef<Submission>[] = [
{
accessorKey: 'student.student_number',
id: 'identity',
header: () => <span>NIM</span>,
cell: ({ row }) => row.original.student?.student_number ?? '-',
},
{
accessorKey: 'student.user.profile.full_name',
header: () => <span>Nama Mahasiswa</span>,
cell: ({ row }) =>
row.original.student?.user?.profile?.full_name ?? '-',
cell: ({ row }) => (
<div className="flex flex-col">
<span>{row.original.student?.student_number ?? '-'}</span>
<span className="text-sm text-muted-foreground">
{row.original.student?.user?.profile?.full_name ??
'-'}
</span>
</div>
),
},
{
accessorKey: 'status',

View File

@ -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;
};

View File

@ -1,10 +1,10 @@
<?php
use App\Http\Controllers\Admin\AcademicClasses\AssignmentController;
use App\Http\Controllers\Admin\AcademicClasses\Assignment\AssignmentController;
use App\Http\Controllers\Admin\AcademicClasses\Assignment\SubmissionController;
use App\Http\Controllers\Admin\AcademicClasses\AttendanceController;
use App\Http\Controllers\Admin\AcademicClasses\MaterialController;
use App\Http\Controllers\Admin\AcademicClasses\ScheduleController;
use App\Http\Controllers\Admin\AcademicClasses\SubmissionController;
use App\Http\Controllers\Admin\Developer\LogController;
use App\Http\Controllers\Admin\Developer\RolePermissionController;
use App\Http\Controllers\Admin\FeedbackController;