From 33be0c9a0dded668e6fe8601cf383628426f38e5 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 12 Mar 2026 17:27:49 -0400 Subject: [PATCH] add class progress and fix endpoints --- app/Config/progress.php | 23 +- .../Controllers/Api/Auth/AuthController.php | 6 + .../Api/ClassPrep/ClassPrepController.php | 53 ---- .../ClassPreparationController.php | 44 +++- .../ClassProgress/ClassProgressController.php | 167 ++++++++++++ .../Api/Students/StudentController.php | 14 +- .../ClassProgressFormRequest.php | 21 ++ .../ClassProgressIndexRequest.php | 32 +++ .../ClassProgressMetaRequest.php | 21 ++ .../ClassProgressStoreRequest.php | 54 ++++ .../ClassProgressUpdateRequest.php | 39 +++ .../ClassProgressAttachmentResource.php | 21 ++ .../ClassProgressGroupCollection.php | 48 ++++ .../ClassProgressReportResource.php | 35 +++ .../ClassProgressShowResource.php | 20 ++ app/Models/ClassProgressAttachment.php | 4 +- app/Models/ClassProgressReport.php | 6 +- app/Policies/ClassProgressReportPolicy.php | 55 ++++ app/Providers/AppServiceProvider.php | 3 + .../ClassProgressAttachmentService.php | 89 +++++++ .../ClassProgressMetaService.php | 48 ++++ .../ClassProgressMutationService.php | 180 +++++++++++++ .../ClassProgressQueryService.php | 140 ++++++++++ .../ClassProgressRuleService.php | 65 +++++ .../Students/StudentDirectoryService.php | 3 +- config/progress.php | 42 +++ .../ClassProgressAttachmentFactory.php | 23 ++ .../factories/ClassProgressReportFactory.php | 34 +++ resources/docs/controllers.md | 14 +- resources/docs/openapi.json | 212 +++++++++++++-- routes/api.php | 33 ++- .../V1/ClassPrep/ClassPrepControllerTest.php | 203 --------------- .../ClassPreparationControllerTest.php | 246 +++++++++--------- .../ClassProgressControllerTest.php | 244 +++++++++++++++++ .../ClassProgressReportResourceTest.php | 25 ++ .../ClassProgressAttachmentServiceTest.php | 31 +++ .../ClassProgressMetaServiceTest.php | 19 ++ .../ClassProgressMutationServiceTest.php | 121 +++++++++ .../ClassProgressQueryServiceTest.php | 59 +++++ .../ClassProgressRuleServiceTest.php | 27 ++ 40 files changed, 2086 insertions(+), 438 deletions(-) delete mode 100644 app/Http/Controllers/Api/ClassPrep/ClassPrepController.php create mode 100644 app/Http/Controllers/Api/ClassProgress/ClassProgressController.php create mode 100644 app/Http/Requests/ClassProgress/ClassProgressFormRequest.php create mode 100644 app/Http/Requests/ClassProgress/ClassProgressIndexRequest.php create mode 100644 app/Http/Requests/ClassProgress/ClassProgressMetaRequest.php create mode 100644 app/Http/Requests/ClassProgress/ClassProgressStoreRequest.php create mode 100644 app/Http/Requests/ClassProgress/ClassProgressUpdateRequest.php create mode 100644 app/Http/Resources/ClassProgress/ClassProgressAttachmentResource.php create mode 100644 app/Http/Resources/ClassProgress/ClassProgressGroupCollection.php create mode 100644 app/Http/Resources/ClassProgress/ClassProgressReportResource.php create mode 100644 app/Http/Resources/ClassProgress/ClassProgressShowResource.php create mode 100644 app/Policies/ClassProgressReportPolicy.php create mode 100644 app/Services/ClassProgress/ClassProgressAttachmentService.php create mode 100644 app/Services/ClassProgress/ClassProgressMetaService.php create mode 100644 app/Services/ClassProgress/ClassProgressMutationService.php create mode 100644 app/Services/ClassProgress/ClassProgressQueryService.php create mode 100644 app/Services/ClassProgress/ClassProgressRuleService.php create mode 100644 config/progress.php create mode 100644 database/factories/ClassProgressAttachmentFactory.php create mode 100644 database/factories/ClassProgressReportFactory.php delete mode 100644 tests/Feature/Api/V1/ClassPrep/ClassPrepControllerTest.php create mode 100644 tests/Feature/Api/V1/ClassProgress/ClassProgressControllerTest.php create mode 100644 tests/Unit/Resources/ClassProgress/ClassProgressReportResourceTest.php create mode 100644 tests/Unit/Services/ClassProgress/ClassProgressAttachmentServiceTest.php create mode 100644 tests/Unit/Services/ClassProgress/ClassProgressMetaServiceTest.php create mode 100644 tests/Unit/Services/ClassProgress/ClassProgressMutationServiceTest.php create mode 100644 tests/Unit/Services/ClassProgress/ClassProgressQueryServiceTest.php create mode 100644 tests/Unit/Services/ClassProgress/ClassProgressRuleServiceTest.php diff --git a/app/Config/progress.php b/app/Config/progress.php index 99f923fe..92644165 100644 --- a/app/Config/progress.php +++ b/app/Config/progress.php @@ -8,12 +8,9 @@ return [ |-------------------------------------------------------------------------- */ 'status_options' => [ - // Keep these aligned with your existing values in DB - 'draft' => 'Draft', - 'submitted' => 'Submitted', - 'reviewed' => 'Reviewed', - 'approved' => 'Approved', - 'rejected' => 'Rejected', + 'on_track' => 'On track', + 'slightly_behind' => 'Slightly behind', + 'behind' => 'Behind', ], /* @@ -24,10 +21,14 @@ return [ |-------------------------------------------------------------------------- */ 'subject_sections' => [ - // Example placeholders only: - // 'quran' => ['Memorization', 'Reading', 'Tajweed'], - // 'islamic_studies' => ['Aqeedah', 'Fiqh', 'Seerah'], - // 'arabic' => ['Reading', 'Writing', 'Vocabulary'], + 'islamic' => [ + 'label' => 'Islamic Studies', + 'db_subject' => 'Islamic Studies', + ], + 'quran' => [ + 'label' => 'Quran/Arabic', + 'db_subject' => 'Quran/Arabic', + ], ], -]; \ No newline at end of file +]; diff --git a/app/Http/Controllers/Api/Auth/AuthController.php b/app/Http/Controllers/Api/Auth/AuthController.php index d2626f90..1228831e 100644 --- a/app/Http/Controllers/Api/Auth/AuthController.php +++ b/app/Http/Controllers/Api/Auth/AuthController.php @@ -41,6 +41,11 @@ class AuthController extends BaseApiController $jwtToken = JWTAuth::fromUser($user); $sanctumToken = $user->createToken('api')->plainTextToken; + $roles = $user->roles() + ->where('roles.is_active', 1) + ->pluck('roles.name') + ->values() + ->all(); return $this->respondSuccess([ 'user' => [ @@ -48,6 +53,7 @@ class AuthController extends BaseApiController 'firstname' => $user->firstname, 'lastname' => $user->lastname, 'email' => $user->email, + 'roles' => $roles, ], 'jwt' => [ 'access_token' => $jwtToken, diff --git a/app/Http/Controllers/Api/ClassPrep/ClassPrepController.php b/app/Http/Controllers/Api/ClassPrep/ClassPrepController.php deleted file mode 100644 index 003c13a6..00000000 --- a/app/Http/Controllers/Api/ClassPrep/ClassPrepController.php +++ /dev/null @@ -1,53 +0,0 @@ -stickerCounts = $stickerCounts; - $this->roster = $roster; - } - - public function stickerCounts(Request $request): JsonResponse - { - $schoolYear = (string) ($request->query('school_year') ?? ''); - $semester = (string) ($request->query('semester') ?? ''); - $classSectionId = $request->query('class_section_id') ?? $request->query('class_id'); - $classSectionId = is_numeric($classSectionId) ? (int) $classSectionId : null; - - if ($classSectionId !== null && $classSectionId > 0) { - $payload = $this->stickerCounts->listForClass($schoolYear, $semester, $classSectionId); - } else { - $payload = $this->stickerCounts->listAll($schoolYear, $semester); - } - - return response()->json([ - 'ok' => true, - 'data' => $payload, - ]); - } - - public function studentsByClass(Request $request, int $classSectionId): JsonResponse - { - $schoolYear = (string) ($request->query('school_year') ?? ''); - - $students = $this->roster->listStudentsByClass($classSectionId, $schoolYear !== '' ? $schoolYear : null); - - return response()->json([ - 'ok' => true, - 'students' => $students, - ]); - } -} diff --git a/app/Http/Controllers/Api/ClassPreparation/ClassPreparationController.php b/app/Http/Controllers/Api/ClassPreparation/ClassPreparationController.php index da68969b..ca98f592 100644 --- a/app/Http/Controllers/Api/ClassPreparation/ClassPreparationController.php +++ b/app/Http/Controllers/Api/ClassPreparation/ClassPreparationController.php @@ -9,19 +9,30 @@ use App\Http\Requests\ClassPreparation\ClassPreparationMarkPrintedRequest; use App\Http\Requests\ClassPreparation\ClassPreparationPrintRequest; use App\Http\Resources\ClassPreparation\ClassPreparationPrintResource; use App\Http\Resources\ClassPreparation\ClassPreparationResultResource; +use App\Services\ClassPrep\ClassRosterService; +use App\Services\ClassPrep\StickerCountService; use App\Services\ClassPreparation\ClassPreparationService; use Illuminate\Http\JsonResponse; +use Illuminate\Http\Request; use Illuminate\Support\Facades\Log; use Symfony\Component\HttpFoundation\Response; class ClassPreparationController extends BaseApiController { private ClassPreparationService $service; + private StickerCountService $stickerCounts; + private ClassRosterService $roster; - public function __construct(ClassPreparationService $service) + public function __construct( + ClassPreparationService $service, + StickerCountService $stickerCounts, + ClassRosterService $roster + ) { parent::__construct(); $this->service = $service; + $this->stickerCounts = $stickerCounts; + $this->roster = $roster; } public function index(ClassPreparationIndexRequest $request): JsonResponse @@ -93,4 +104,35 @@ class ClassPreparationController extends BaseApiController 'print' => new ClassPreparationPrintResource($payload), ], 'Class preparation logged.'); } + + public function stickerCounts(Request $request): JsonResponse + { + $schoolYear = (string) ($request->query('school_year') ?? ''); + $semester = (string) ($request->query('semester') ?? ''); + $classSectionId = $request->query('class_section_id') ?? $request->query('class_id'); + $classSectionId = is_numeric($classSectionId) ? (int) $classSectionId : null; + + if ($classSectionId !== null && $classSectionId > 0) { + $payload = $this->stickerCounts->listForClass($schoolYear, $semester, $classSectionId); + } else { + $payload = $this->stickerCounts->listAll($schoolYear, $semester); + } + + return response()->json([ + 'ok' => true, + 'data' => $payload, + ]); + } + + public function studentsByClass(Request $request, int $classSectionId): JsonResponse + { + $schoolYear = (string) ($request->query('school_year') ?? ''); + + $students = $this->roster->listStudentsByClass($classSectionId, $schoolYear !== '' ? $schoolYear : null); + + return response()->json([ + 'ok' => true, + 'students' => $students, + ]); + } } diff --git a/app/Http/Controllers/Api/ClassProgress/ClassProgressController.php b/app/Http/Controllers/Api/ClassProgress/ClassProgressController.php new file mode 100644 index 00000000..f92f6797 --- /dev/null +++ b/app/Http/Controllers/Api/ClassProgress/ClassProgressController.php @@ -0,0 +1,167 @@ +authorizeResource(ClassProgressReport::class, 'class_progress'); + } + + public function meta(ClassProgressMetaRequest $request): JsonResponse + { + $classId = $request->validated('class_id'); + $sundayCount = (int) ($request->validated('sunday_count') ?? 12); + + $payload = [ + 'subject_sections' => $this->metaService->subjectSections(), + 'status_options' => $this->metaService->statusOptions(), + 'sunday_options' => $this->metaService->sundayOptions($sundayCount), + 'curriculum' => $this->metaService->curriculumOptions($classId ? (int) $classId : null), + ]; + + return $this->respondSuccess($payload, 'Progress metadata loaded.'); + } + + public function index(ClassProgressIndexRequest $request): JsonResponse + { + $filters = $request->validated(); + $paginator = $this->queryService->listReports($request->user(), $filters); + + if (!empty($filters['group_by_week'])) { + $grouped = ClassProgressGroupCollection::fromPaginator($paginator); + return $this->respondSuccess($grouped, 'Progress reports retrieved.'); + } + + return $this->respondSuccess([ + 'items' => ClassProgressReportResource::collection($paginator->items()), + 'pagination' => [ + 'current_page' => $paginator->currentPage(), + 'per_page' => $paginator->perPage(), + 'total' => $paginator->total(), + 'last_page' => $paginator->lastPage(), + ], + ], 'Progress reports retrieved.'); + } + + public function store(ClassProgressStoreRequest $request): JsonResponse + { + try { + $filesBySubject = $request->filesBySubject(); + $reports = $this->mutationService->createReports($request->user(), $request->validated(), $filesBySubject); + } catch (\InvalidArgumentException $e) { + return $this->respondError($e->getMessage(), 422); + } catch (\Throwable $e) { + Log::error('Failed to create class progress reports.', [ + 'error' => $e->getMessage(), + ]); + return $this->respondError('Unable to save progress reports.', 500); + } + + return $this->respondCreated(ClassProgressReportResource::collection($reports), 'Progress reports saved.'); + } + + public function show(ClassProgressReport $class_progress): JsonResponse + { + $weeklyReports = $this->queryService->weeklyReports($this->laravelRequest->user(), $class_progress); + + return $this->respondSuccess( + new ClassProgressShowResource([ + 'report' => $class_progress, + 'weekly_reports' => $weeklyReports, + 'status_options' => $this->metaService->statusOptions(), + ]), + 'Progress report retrieved.' + ); + } + + public function update(ClassProgressUpdateRequest $request, ClassProgressReport $class_progress): JsonResponse + { + try { + $updated = $this->mutationService->updateReport($class_progress, $request->validated(), $request->attachments()); + } catch (\InvalidArgumentException $e) { + return $this->respondError($e->getMessage(), 422); + } catch (\Throwable $e) { + Log::error('Failed to update class progress report.', [ + 'error' => $e->getMessage(), + ]); + return $this->respondError('Unable to update progress report.', 500); + } + + return $this->respondSuccess(new ClassProgressReportResource($updated), 'Progress report updated.'); + } + + public function destroy(ClassProgressReport $class_progress): JsonResponse + { + try { + $this->mutationService->deleteReport($class_progress); + } catch (\Throwable $e) { + Log::error('Failed to delete class progress report.', [ + 'error' => $e->getMessage(), + ]); + return $this->respondError('Unable to delete progress report.', 500); + } + + return $this->respondDeleted(null, 'Progress report deleted.'); + } + + public function downloadAttachment(int $attachment): BinaryFileResponse|JsonResponse + { + $record = ClassProgressAttachment::query()->find($attachment); + if (!$record || !$record->file_path) { + return $this->respondError('Attachment not found.', 404); + } + + if ($record->report) { + $this->authorize('view', $record->report); + } + + $path = $this->attachmentService->resolvePath($record->file_path); + if (!$path) { + return $this->respondError('Attachment missing.', 404); + } + + $downloadName = $record->original_name ?: basename($path); + return response()->download($path, $downloadName); + } + + public function downloadLegacyAttachment(ClassProgressReport $class_progress): BinaryFileResponse|JsonResponse + { + $this->authorize('view', $class_progress); + + if (!$class_progress->attachment_path) { + return $this->respondError('Attachment not found.', 404); + } + + $path = $this->attachmentService->resolvePath($class_progress->attachment_path); + if (!$path) { + return $this->respondError('Attachment missing.', 404); + } + + return response()->download($path, basename($path)); + } +} diff --git a/app/Http/Controllers/Api/Students/StudentController.php b/app/Http/Controllers/Api/Students/StudentController.php index f2a738a4..7cc0b02e 100644 --- a/app/Http/Controllers/Api/Students/StudentController.php +++ b/app/Http/Controllers/Api/Students/StudentController.php @@ -67,6 +67,9 @@ class StudentController extends BaseApiController $data = array_merge($request->query->all(), $request->all(), $request->json()->all()); $validator = Validator::make($data, [ 'school_year' => ['nullable', 'string', 'max:50'], + 'parent_id' => ['nullable', 'integer', 'min:1'], + 'parent_ids' => ['nullable', 'array'], + 'parent_ids.*' => ['integer', 'min:1'], ]); if ($validator->fails()) { @@ -77,7 +80,16 @@ class StudentController extends BaseApiController } $payload = $validator->validated(); - $data = $this->directoryService->listStudents($payload['school_year'] ?? null); + $parentIds = []; + if (!empty($payload['parent_ids'])) { + $parentIds = array_values(array_unique(array_map('intval', $payload['parent_ids']))); + } + if (!empty($payload['parent_id'])) { + $parentIds[] = (int) $payload['parent_id']; + } + $parentIds = array_values(array_unique(array_filter($parentIds))); + + $data = $this->directoryService->listStudents($payload['school_year'] ?? null, $parentIds ?: null); return response()->json([ 'ok' => true, diff --git a/app/Http/Requests/ClassProgress/ClassProgressFormRequest.php b/app/Http/Requests/ClassProgress/ClassProgressFormRequest.php new file mode 100644 index 00000000..64cdefef --- /dev/null +++ b/app/Http/Requests/ClassProgress/ClassProgressFormRequest.php @@ -0,0 +1,21 @@ +json([ + 'status' => false, + 'message' => 'Validation failed.', + 'errors' => $validator->errors(), + ], 422) + ); + } +} diff --git a/app/Http/Requests/ClassProgress/ClassProgressIndexRequest.php b/app/Http/Requests/ClassProgress/ClassProgressIndexRequest.php new file mode 100644 index 00000000..27b72938 --- /dev/null +++ b/app/Http/Requests/ClassProgress/ClassProgressIndexRequest.php @@ -0,0 +1,32 @@ + ['nullable', 'integer', 'min:1'], + 'teacher_id' => ['nullable', 'integer', 'min:1'], + 'week_start' => ['nullable', 'date_format:Y-m-d'], + 'week_end' => ['nullable', 'date_format:Y-m-d'], + 'subject' => ['nullable', 'string', 'max:120'], + 'status' => $statuses ? ['nullable', 'in:' . implode(',', $statuses)] : ['nullable', 'string'], + 'group_by_week' => ['nullable', 'boolean'], + 'page' => ['nullable', 'integer', 'min:1'], + 'per_page' => ['nullable', 'integer', 'min:1', 'max:100'], + 'sort_by' => ['nullable', 'in:week_start,week_end,subject,status,created_at'], + 'sort_dir' => ['nullable', 'in:asc,desc'], + ]; + } +} diff --git a/app/Http/Requests/ClassProgress/ClassProgressMetaRequest.php b/app/Http/Requests/ClassProgress/ClassProgressMetaRequest.php new file mode 100644 index 00000000..cb7f61ad --- /dev/null +++ b/app/Http/Requests/ClassProgress/ClassProgressMetaRequest.php @@ -0,0 +1,21 @@ + ['nullable', 'integer', 'min:1'], + 'sunday_count' => ['nullable', 'integer', 'min:1', 'max:52'], + ]; + } +} diff --git a/app/Http/Requests/ClassProgress/ClassProgressStoreRequest.php b/app/Http/Requests/ClassProgress/ClassProgressStoreRequest.php new file mode 100644 index 00000000..f410a9c5 --- /dev/null +++ b/app/Http/Requests/ClassProgress/ClassProgressStoreRequest.php @@ -0,0 +1,54 @@ + ['required', 'integer', 'exists:classSection,class_section_id'], + 'week_start' => ['required', 'date_format:Y-m-d'], + 'week_end' => ['nullable', 'date_format:Y-m-d'], + 'support_needed' => ['nullable', 'string'], + 'flags' => ['nullable', 'array'], + ]; + + $sections = (array) config('progress.subject_sections', []); + $maxFileKb = (int) config('progress.attachments.max_kb', 5120); + $mimes = (array) config('progress.attachments.mimes', ['pdf', 'jpg', 'jpeg', 'png']); + $mimeRule = 'mimes:' . implode(',', $mimes); + + foreach ($sections as $slug => $section) { + $rules["covered_{$slug}"] = ['required', 'string']; + $rules["homework_{$slug}"] = ['nullable', 'string']; + $rules["unit_{$slug}"] = ['nullable', 'array']; + $rules["unit_{$slug}.*"] = ['nullable', 'string', 'max:120']; + $rules["chapter_{$slug}"] = ['nullable', 'array']; + $rules["chapter_{$slug}.*"] = ['nullable', 'string', 'max:120']; + $rules["attachment_{$slug}"] = ['nullable', 'array']; + $rules["attachment_{$slug}.*"] = ['nullable', 'file', $mimeRule, 'max:' . $maxFileKb]; + } + + return $rules; + } + + public function filesBySubject(): array + { + $sections = (array) config('progress.subject_sections', []); + $filesBySubject = []; + + foreach ($sections as $slug => $section) { + $filesBySubject[$slug] = $this->file("attachment_{$slug}") ?? []; + } + + return $filesBySubject; + } +} diff --git a/app/Http/Requests/ClassProgress/ClassProgressUpdateRequest.php b/app/Http/Requests/ClassProgress/ClassProgressUpdateRequest.php new file mode 100644 index 00000000..1919b7e5 --- /dev/null +++ b/app/Http/Requests/ClassProgress/ClassProgressUpdateRequest.php @@ -0,0 +1,39 @@ + ['nullable', 'date_format:Y-m-d'], + 'week_end' => ['nullable', 'date_format:Y-m-d'], + 'covered' => ['nullable', 'string'], + 'homework' => ['nullable', 'string'], + 'unit_title' => ['nullable', 'string', 'max:120'], + 'status' => $statuses ? ['nullable', 'in:' . implode(',', $statuses)] : ['nullable', 'string'], + 'support_needed' => ['nullable', 'string'], + 'flags' => ['nullable', 'array'], + 'attachments' => ['nullable', 'array'], + 'attachments.*' => ['nullable', 'file', $mimeRule, 'max:' . $maxFileKb], + ]; + } + + public function attachments(): array + { + return $this->file('attachments') ?? []; + } +} diff --git a/app/Http/Resources/ClassProgress/ClassProgressAttachmentResource.php b/app/Http/Resources/ClassProgress/ClassProgressAttachmentResource.php new file mode 100644 index 00000000..2e40e7bf --- /dev/null +++ b/app/Http/Resources/ClassProgress/ClassProgressAttachmentResource.php @@ -0,0 +1,21 @@ +id ?? null; + + return [ + 'id' => $id ? (int) $id : null, + 'name' => $this['name'] ?? $this->original_name ?? null, + 'file_path' => $this['file_path'] ?? $this->file_path ?? null, + 'download_url' => $id ? url("/api/v1/class-progress/attachments/{$id}") : null, + ]; + } +} diff --git a/app/Http/Resources/ClassProgress/ClassProgressGroupCollection.php b/app/Http/Resources/ClassProgress/ClassProgressGroupCollection.php new file mode 100644 index 00000000..7b4603b8 --- /dev/null +++ b/app/Http/Resources/ClassProgress/ClassProgressGroupCollection.php @@ -0,0 +1,48 @@ +collection->toArray(); + } + + public static function fromPaginator(LengthAwarePaginator $paginator): array + { + $groups = []; + foreach ($paginator->items() as $report) { + $key = $report->week_start?->format('Y-m-d') ?? ''; + if ($key === '') { + continue; + } + + if (!isset($groups[$key])) { + $groups[$key] = [ + 'week_start' => $key, + 'week_end' => $report->week_end?->format('Y-m-d'), + 'class_section_id' => $report->class_section_id, + 'class_section_name' => $report->classSection?->class_section_name ?? '', + 'reports' => [], + ]; + } + + $groups[$key]['reports'][$report->subject] = (new ClassProgressReportResource($report))->toArray(request()); + } + + return [ + 'items' => ProgressGroupResource::collection(collect(array_values($groups))), + 'pagination' => [ + 'current_page' => $paginator->currentPage(), + 'per_page' => $paginator->perPage(), + 'total' => $paginator->total(), + 'last_page' => $paginator->lastPage(), + ], + ]; + } +} diff --git a/app/Http/Resources/ClassProgress/ClassProgressReportResource.php b/app/Http/Resources/ClassProgress/ClassProgressReportResource.php new file mode 100644 index 00000000..6b11af36 --- /dev/null +++ b/app/Http/Resources/ClassProgress/ClassProgressReportResource.php @@ -0,0 +1,35 @@ +attachments ?? $this['attachments'] ?? []; + + return [ + 'id' => $this->id, + 'teacher_id' => $this->teacher_id, + 'class_section_id' => $this->class_section_id, + 'class_section_name' => $this->classSection?->class_section_name, + 'week_start' => $this->week_start?->format('Y-m-d'), + 'week_end' => $this->week_end?->format('Y-m-d'), + 'subject' => $this->subject, + 'unit_title' => $this->unit_title, + 'covered' => $this->covered, + 'homework' => $this->homework, + 'status' => $this->status, + 'status_label' => $this->status_label ?? (config('progress.status_options')[$this->status] ?? null), + 'support_needed' => $this->support_needed, + 'flags' => $this->flags_json, + 'attachment_path' => $this->attachment_path, + 'attachments' => ClassProgressAttachmentResource::collection(collect($attachments)), + 'created_at' => $this->created_at?->toIso8601String(), + 'updated_at' => $this->updated_at?->toIso8601String(), + ]; + } +} diff --git a/app/Http/Resources/ClassProgress/ClassProgressShowResource.php b/app/Http/Resources/ClassProgress/ClassProgressShowResource.php new file mode 100644 index 00000000..ae42438d --- /dev/null +++ b/app/Http/Resources/ClassProgress/ClassProgressShowResource.php @@ -0,0 +1,20 @@ + new ClassProgressReportResource($this['report']), + 'weekly_reports' => ClassProgressReportResource::collection(collect($weekly)), + 'status_options' => $this['status_options'] ?? [], + ]; + } +} diff --git a/app/Models/ClassProgressAttachment.php b/app/Models/ClassProgressAttachment.php index 32b9b0fa..8602c436 100644 --- a/app/Models/ClassProgressAttachment.php +++ b/app/Models/ClassProgressAttachment.php @@ -3,9 +3,11 @@ namespace App\Models; use App\Models\BaseModel; +use Illuminate\Database\Eloquent\Factories\HasFactory; class ClassProgressAttachment extends BaseModel { + use HasFactory; protected $table = 'class_progress_attachments'; // CI: useTimestamps = false @@ -31,4 +33,4 @@ class ClassProgressAttachment extends BaseModel // replace ClassProgressReport::class with your actual report model if different return $this->belongsTo(ClassProgressReport::class, 'report_id'); } -} \ No newline at end of file +} diff --git a/app/Models/ClassProgressReport.php b/app/Models/ClassProgressReport.php index e1bed27e..a7de7ae0 100644 --- a/app/Models/ClassProgressReport.php +++ b/app/Models/ClassProgressReport.php @@ -3,9 +3,11 @@ namespace App\Models; use App\Models\BaseModel; +use Illuminate\Database\Eloquent\Factories\HasFactory; class ClassProgressReport extends BaseModel { + use HasFactory; protected $table = 'class_progress_reports'; // ✅ CI: useTimestamps = true (created_at/updated_at) @@ -48,7 +50,7 @@ class ClassProgressReport extends BaseModel /* Optional relationships */ public function classSection() { - return $this->belongsTo(ClassSection::class, 'class_section_id'); + return $this->belongsTo(ClassSection::class, 'class_section_id', 'class_section_id'); } public function teacher() @@ -60,4 +62,4 @@ class ClassProgressReport extends BaseModel { return $this->hasMany(ClassProgressAttachment::class, 'report_id'); } -} \ No newline at end of file +} diff --git a/app/Policies/ClassProgressReportPolicy.php b/app/Policies/ClassProgressReportPolicy.php new file mode 100644 index 00000000..2ab3fb01 --- /dev/null +++ b/app/Policies/ClassProgressReportPolicy.php @@ -0,0 +1,55 @@ +owns($user, $report) || $this->isAdmin($user); + } + + public function create(User $user): bool + { + return $user !== null; + } + + public function update(User $user, ClassProgressReport $report): bool + { + return $this->owns($user, $report) || $this->isAdmin($user); + } + + public function delete(User $user, ClassProgressReport $report): bool + { + return $this->owns($user, $report) || $this->isAdmin($user); + } + + private function owns(User $user, ClassProgressReport $report): bool + { + return (int) $report->teacher_id === (int) $user->id; + } + + private function isAdmin(User $user): bool + { + $roles = $user->roles() + ->pluck('roles.name') + ->map(fn ($name) => strtolower((string) $name)) + ->toArray(); + + foreach (['administrator', 'admin', 'principal', 'vice_principal', 'vice-principal'] as $role) { + if (in_array($role, $roles, true)) { + return true; + } + } + + return false; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 812b79af..2f646ba0 100755 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -21,6 +21,7 @@ use App\Models\Preferences; use App\Models\Staff; use App\Models\SupportRequest; use App\Models\Setting; +use App\Models\ClassProgressReport; use App\Policies\NavItemPolicy; use App\Policies\ClassSectionPolicy; use App\Policies\IpAttemptPolicy; @@ -30,6 +31,7 @@ use App\Policies\PreferencesPolicy; use App\Policies\StaffPolicy; use App\Policies\SupportRequestPolicy; use App\Policies\SettingPolicy; +use App\Policies\ClassProgressReportPolicy; use Illuminate\Support\Facades\Gate; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Routing\Redirector; @@ -76,6 +78,7 @@ class AppServiceProvider extends ServiceProvider Gate::policy(Staff::class, StaffPolicy::class); Gate::policy(SupportRequest::class, SupportRequestPolicy::class); Gate::policy(Setting::class, SettingPolicy::class); + Gate::policy(ClassProgressReport::class, ClassProgressReportPolicy::class); $this->app->resolving(FormRequest::class, function (FormRequest $request, $app): void { $request->setContainer($app)->setRedirector($app->make(Redirector::class)); diff --git a/app/Services/ClassProgress/ClassProgressAttachmentService.php b/app/Services/ClassProgress/ClassProgressAttachmentService.php new file mode 100644 index 00000000..b5b8559c --- /dev/null +++ b/app/Services/ClassProgress/ClassProgressAttachmentService.php @@ -0,0 +1,89 @@ +isValid()) { + continue; + } + + $path = $this->storeAttachment($file); + $stored[] = [ + 'report_id' => $reportId, + 'file_path' => $path, + 'original_name' => $file->getClientOriginalName(), + 'mime_type' => $file->getClientMimeType(), + 'file_size' => $file->getSize(), + 'created_at' => now(), + ]; + } + + if ($stored !== []) { + ClassProgressAttachment::query()->insert($stored); + } + + return $stored; + } + + public function resolvePath(string $path): ?string + { + $path = trim($path); + if ($path === '') { + return null; + } + + $candidates = []; + if (str_starts_with($path, 'storage/')) { + $relative = ltrim(substr($path, strlen('storage/')), '/'); + $candidates[] = storage_path('app/public/' . $relative); + } + + if (str_starts_with($path, 'writable/uploads/')) { + $relative = ltrim(substr($path, strlen('writable/uploads/')), '/'); + $candidates[] = storage_path('app/' . $relative); + $candidates[] = storage_path('app/public/' . $relative); + } + + $candidates[] = storage_path('app/' . ltrim($path, '/')); + $candidates[] = storage_path('app/public/' . ltrim($path, '/')); + + foreach ($candidates as $candidate) { + if (is_file($candidate)) { + return $candidate; + } + } + + return null; + } + + private function storeAttachment(UploadedFile $file): string + { + $disk = (string) config('progress.attachments.disk', 'public'); + $directory = (string) config('progress.attachments.directory', 'class_material'); + + try { + $path = $file->store($directory, $disk); + } catch (\Throwable $e) { + Log::error('Failed to store class progress attachment.', [ + 'error' => $e->getMessage(), + ]); + throw $e; + } + + return 'storage/' . ltrim($path, '/'); + } +} diff --git a/app/Services/ClassProgress/ClassProgressMetaService.php b/app/Services/ClassProgress/ClassProgressMetaService.php new file mode 100644 index 00000000..5a80d458 --- /dev/null +++ b/app/Services/ClassProgress/ClassProgressMetaService.php @@ -0,0 +1,48 @@ +format('w') !== 0) { + $start = $start->next('Sunday'); + } + + $options = []; + for ($i = 0; $i < $count; $i++) { + $options[] = $start->format('Y-m-d'); + $start = $start->addDays(7); + } + + return $options; + } + + public function curriculumOptions(?int $classId): array + { + if (!$classId) { + return []; + } + + $options = []; + foreach ($this->subjectSections() as $slug => $section) { + $options[$slug] = SubjectCurriculum::getOptionsForClass((int) $classId, (string) $slug); + } + + return $options; + } +} diff --git a/app/Services/ClassProgress/ClassProgressMutationService.php b/app/Services/ClassProgress/ClassProgressMutationService.php new file mode 100644 index 00000000..9ba34b9f --- /dev/null +++ b/app/Services/ClassProgress/ClassProgressMutationService.php @@ -0,0 +1,180 @@ +assertTeacherAssignment($user, $classSectionId); + + $weekStart = (string) ($payload['week_start'] ?? ''); + $weekEnd = $this->rules->ensureWeekEnd($weekStart, $payload['week_end'] ?? null); + + if (!$this->rules->isWeekEndValid($weekStart, $weekEnd)) { + throw new \InvalidArgumentException('Week end must be the same as or after the week start.'); + } + + $reports = DB::transaction(function () use ($user, $payload, $subjectSections, $classSectionId, $weekStart, $weekEnd, $filesBySubject) { + $created = collect(); + + foreach ($subjectSections as $slug => $section) { + $covered = trim((string) ($payload["covered_{$slug}"] ?? '')); + if ($covered === '') { + continue; + } + + $homework = trim((string) ($payload["homework_{$slug}"] ?? '')); + $unitValues = (array) ($payload["unit_{$slug}"] ?? []); + $chapterValues = (array) ($payload["chapter_{$slug}"] ?? []); + $unitTitle = $this->rules->buildUnitChapterSummary($unitValues, $chapterValues); + + $report = ClassProgressReport::query()->create([ + 'teacher_id' => $user->id, + 'class_section_id' => $classSectionId, + 'week_start' => $weekStart, + 'week_end' => $weekEnd, + 'subject' => $section['db_subject'] ?? $section['label'] ?? $slug, + 'unit_title' => $unitTitle, + 'covered' => $covered, + 'homework' => $homework !== '' ? $homework : null, + 'status' => $this->rules->defaultStatus(), + 'support_needed' => $payload['support_needed'] ?? null, + 'flags_json' => $this->rules->normalizeFlags($payload['flags'] ?? null), + ]); + + $attachments = $filesBySubject[$slug] ?? []; + $stored = $this->attachments->storeAttachments($report->id, $attachments); + if ($stored !== []) { + $report->update(['attachment_path' => $stored[0]['file_path'] ?? null]); + } + + $created->push($report); + } + + if ($created->isEmpty()) { + throw new \InvalidArgumentException('Please provide progress for at least one subject.'); + } + + return $created; + }); + + Log::info('Class progress reports created.', [ + 'teacher_id' => $user->id, + 'class_section_id' => $classSectionId, + 'count' => $reports->count(), + ]); + + return $reports; + } + + public function updateReport(ClassProgressReport $report, array $payload, array $attachments): ClassProgressReport + { + return DB::transaction(function () use ($report, $payload, $attachments) { + $weekStart = $payload['week_start'] ?? $report->week_start?->format('Y-m-d'); + $weekEnd = $this->rules->ensureWeekEnd((string) $weekStart, $payload['week_end'] ?? null); + if ($weekStart && $weekEnd && !$this->rules->isWeekEndValid((string) $weekStart, (string) $weekEnd)) { + throw new \InvalidArgumentException('Week end must be the same as or after the week start.'); + } + + $data = []; + if ($weekStart) { + $data['week_start'] = $weekStart; + } + if ($weekEnd) { + $data['week_end'] = $weekEnd; + } + if (array_key_exists('covered', $payload)) { + $data['covered'] = $payload['covered']; + } + if (array_key_exists('homework', $payload)) { + $data['homework'] = $payload['homework']; + } + if (array_key_exists('unit_title', $payload)) { + $data['unit_title'] = $payload['unit_title']; + } + if (array_key_exists('status', $payload)) { + $data['status'] = $payload['status']; + } + if (array_key_exists('support_needed', $payload)) { + $data['support_needed'] = $payload['support_needed']; + } + if (array_key_exists('flags', $payload)) { + $data['flags_json'] = $this->rules->normalizeFlags($payload['flags']); + } + + if ($data !== []) { + $report->fill($data); + $report->save(); + } + + if ($attachments !== []) { + $stored = $this->attachments->storeAttachments($report->id, $attachments); + if ($stored !== []) { + $report->update(['attachment_path' => $stored[0]['file_path'] ?? $report->attachment_path]); + } + } + + return $report->fresh(['classSection', 'attachments']); + }); + } + + public function deleteReport(ClassProgressReport $report): void + { + DB::transaction(function () use ($report) { + $report->attachments()->delete(); + $report->delete(); + }); + } + + private function assertTeacherAssignment(User $user, int $classSectionId): void + { + if ($classSectionId <= 0) { + throw new \InvalidArgumentException('No class assignment found for this report.'); + } + + if ($this->isAdmin($user)) { + return; + } + + $assigned = TeacherClass::query() + ->where('teacher_id', $user->id) + ->where('class_section_id', $classSectionId) + ->exists(); + + if (!$assigned) { + throw new \InvalidArgumentException('No class assignment found for this report.'); + } + } + + private function isAdmin(User $user): bool + { + $roles = $user->roles() + ->pluck('roles.name') + ->map(fn ($name) => strtolower((string) $name)) + ->toArray(); + + foreach (['administrator', 'admin', 'principal', 'vice_principal', 'vice-principal'] as $role) { + if (in_array($role, $roles, true)) { + return true; + } + } + + return false; + } +} diff --git a/app/Services/ClassProgress/ClassProgressQueryService.php b/app/Services/ClassProgress/ClassProgressQueryService.php new file mode 100644 index 00000000..a090d656 --- /dev/null +++ b/app/Services/ClassProgress/ClassProgressQueryService.php @@ -0,0 +1,140 @@ +with('classSection') + ->orderBy($filters['sort_by'] ?? 'week_start', $filters['sort_dir'] ?? 'desc'); + + if (!$this->isAdmin($user)) { + $query->where('teacher_id', $user->id); + } elseif (!empty($filters['teacher_id'])) { + $query->where('teacher_id', (int) $filters['teacher_id']); + } + + if (!empty($filters['class_section_id'])) { + $query->where('class_section_id', (int) $filters['class_section_id']); + } + + if (!empty($filters['subject'])) { + $query->where('subject', (string) $filters['subject']); + } + + if (!empty($filters['status'])) { + $query->where('status', (string) $filters['status']); + } + + if (!empty($filters['week_start'])) { + $query->whereDate('week_start', '>=', $filters['week_start']); + } + + if (!empty($filters['week_end'])) { + $query->whereDate('week_end', '<=', $filters['week_end']); + } + + $perPage = (int) ($filters['per_page'] ?? 20); + + return $query->paginate($perPage); + } + + public function getReport(User $user, int $reportId): ClassProgressReport + { + $query = ClassProgressReport::query()->with('classSection'); + if (!$this->isAdmin($user)) { + $query->where('teacher_id', $user->id); + } + + return $query->findOrFail($reportId); + } + + public function weeklyReports(User $user, ClassProgressReport $report): array + { + $query = ClassProgressReport::query() + ->with('classSection') + ->where('class_section_id', $report->class_section_id) + ->whereDate('week_start', $report->week_start) + ->orderBy('subject', 'asc'); + + if (!$this->isAdmin($user)) { + $query->where('teacher_id', $user->id); + } + + $rows = $query->get(); + $attachments = $this->attachmentMap($rows->pluck('id')->all()); + + return $rows->map(function (ClassProgressReport $row) use ($attachments) { + $row->setAttribute('status_label', $this->statusLabel($row->status)); + $row->setAttribute('attachments', $attachments[$row->id] ?? []); + return $row; + })->all(); + } + + public function attachmentMap(array $reportIds): array + { + $reportIds = array_values(array_filter(array_map('intval', $reportIds))); + if ($reportIds === []) { + return []; + } + + $rows = ClassProgressAttachment::query() + ->whereIn('report_id', $reportIds) + ->orderBy('id', 'asc') + ->get(); + + $map = []; + foreach ($rows as $row) { + $map[$row->report_id][] = [ + 'id' => $row->id, + 'name' => $row->original_name ?: basename((string) $row->file_path), + 'file_path' => $row->file_path, + ]; + } + + return $map; + } + + public function teacherAssignments(User $user, ?string $schoolYear, ?string $semester): array + { + if ($schoolYear === null || $schoolYear === '') { + $schoolYear = (string) (config('school.school_year') ?? ''); + } + + return TeacherClass::getClassAssignmentsByUserId($user->id, $schoolYear, $semester); + } + + private function statusLabel(?string $status): string + { + $options = (array) config('progress.status_options', []); + return $options[$status] ?? 'Unknown'; + } + + private function isAdmin(User $user): bool + { + $roles = $user->roles() + ->pluck('roles.name') + ->map(fn ($name) => strtolower((string) $name)) + ->toArray(); + + foreach (['administrator', 'admin', 'principal', 'vice_principal', 'vice-principal'] as $role) { + if (in_array($role, $roles, true)) { + return true; + } + } + + return false; + } +} diff --git a/app/Services/ClassProgress/ClassProgressRuleService.php b/app/Services/ClassProgress/ClassProgressRuleService.php new file mode 100644 index 00000000..e6fbd985 --- /dev/null +++ b/app/Services/ClassProgress/ClassProgressRuleService.php @@ -0,0 +1,65 @@ + $item !== '' && $item !== null)); + return $values === [] ? null : $values; + } + + public function buildUnitChapterSummary(array $unitValues, array $chapterValues): ?string + { + $parts = []; + $count = max(count($unitValues), count($chapterValues)); + for ($i = 0; $i < $count; $i++) { + $unit = trim((string) ($unitValues[$i] ?? '')); + $chapter = trim((string) ($chapterValues[$i] ?? '')); + if ($unit === '' && $chapter === '') { + continue; + } + $segment = $unit; + if ($chapter !== '') { + $segment = $segment !== '' ? $segment . ' / ' . $chapter : $chapter; + } + if ($segment === '') { + continue; + } + $parts[] = $segment; + } + + if ($parts === []) { + return null; + } + + $summary = implode(' ; ', $parts); + return mb_strlen($summary) > 120 ? mb_substr($summary, 0, 120) : $summary; + } + + public function ensureWeekEnd(string $weekStart, ?string $weekEnd): string + { + if ($weekEnd) { + return $weekEnd; + } + + try { + $dt = new \DateTime($weekStart); + $dt->modify('+6 days'); + return $dt->format('Y-m-d'); + } catch (\Exception $e) { + return $weekStart; + } + } + + public function isWeekEndValid(string $weekStart, string $weekEnd): bool + { + return strtotime($weekEnd) >= strtotime($weekStart); + } +} diff --git a/app/Services/Students/StudentDirectoryService.php b/app/Services/Students/StudentDirectoryService.php index 62f130a7..b57e877e 100644 --- a/app/Services/Students/StudentDirectoryService.php +++ b/app/Services/Students/StudentDirectoryService.php @@ -162,7 +162,7 @@ class StudentDirectoryService ]; } - public function listStudents(?string $schoolYear = null): array + public function listStudents(?string $schoolYear = null, ?array $parentIds = null): array { $context = $this->configService->context(); $selectedYear = $schoolYear ?: (string) ($context['school_year'] ?? ''); @@ -189,6 +189,7 @@ class StudentDirectoryService 'school_year', ]) ->when($selectedYear !== '', fn ($q) => $q->where('school_year', $selectedYear)) + ->when(!empty($parentIds), fn ($q) => $q->whereIn('parent_id', $parentIds)) ->orderBy('lastname') ->orderBy('firstname') ->get() diff --git a/config/progress.php b/config/progress.php new file mode 100644 index 00000000..3c99742b --- /dev/null +++ b/config/progress.php @@ -0,0 +1,42 @@ + [ + 'on_track' => 'On track', + 'slightly_behind' => 'Slightly behind', + 'behind' => 'Behind', + ], + + /* + |-------------------------------------------------------------------------- + | Subject Sections Mapping + |-------------------------------------------------------------------------- + */ + 'subject_sections' => [ + 'islamic' => [ + 'label' => 'Islamic Studies', + 'db_subject' => 'Islamic Studies', + ], + 'quran' => [ + 'label' => 'Quran/Arabic', + 'db_subject' => 'Quran/Arabic', + ], + ], + + /* + |-------------------------------------------------------------------------- + | Attachment Settings + |-------------------------------------------------------------------------- + */ + 'attachments' => [ + 'disk' => 'public', + 'directory' => 'class_material', + 'max_kb' => 5120, + 'mimes' => ['pdf', 'jpg', 'jpeg', 'png'], + ], +]; diff --git a/database/factories/ClassProgressAttachmentFactory.php b/database/factories/ClassProgressAttachmentFactory.php new file mode 100644 index 00000000..9fd6294f --- /dev/null +++ b/database/factories/ClassProgressAttachmentFactory.php @@ -0,0 +1,23 @@ + 1, + 'file_path' => 'storage/class_material/sample.pdf', + 'original_name' => 'sample.pdf', + 'mime_type' => 'application/pdf', + 'file_size' => 12345, + 'created_at' => now(), + ]; + } +} diff --git a/database/factories/ClassProgressReportFactory.php b/database/factories/ClassProgressReportFactory.php new file mode 100644 index 00000000..c03f46af --- /dev/null +++ b/database/factories/ClassProgressReportFactory.php @@ -0,0 +1,34 @@ +startOfWeek(Carbon::SUNDAY)->startOfDay(); + + return [ + 'class_section_id' => 101, + 'teacher_id' => 1, + 'week_start' => $weekStart, + 'week_end' => (clone $weekStart)->addDays(6), + 'subject' => 'Islamic Studies', + 'unit_title' => 'Unit 1 / Chapter A', + 'covered' => 'Lesson overview', + 'homework' => 'Practice reading', + 'status' => 'on_track', + 'support_needed' => null, + 'flags_json' => ['flag'], + 'attachment_path' => null, + 'created_at' => now(), + 'updated_at' => now(), + ]; + } +} diff --git a/resources/docs/controllers.md b/resources/docs/controllers.md index 70117b9c..07906c6e 100755 --- a/resources/docs/controllers.md +++ b/resources/docs/controllers.md @@ -1271,19 +1271,15 @@ This document lists every API controller alphabetically with any inline route co - `PATCH /api/v1/refunds/{id}` – Update status, payout amounts/methods, or notes; stamps approval/refunded timestamps when applicable. ## RegisterController -**File:** `app/Http/Controllers/Api/RegisterController.php` +**File:** `app/Http/Controllers/Api/Auth/RegisterController.php` **Purpose:** Public registration portal for parents/guests, including CAPTCHA and confirmation email handling. Handles user registration with validation, role assignment, optional second parent information, and email confirmation. **Endpoints:** -- `GET /api/v1/register` (`index`) – Generate or retrieve CAPTCHA challenge for registration form. Returns: - - `captcha_question` – Random CAPTCHA string (4-8 characters) stored in session +- `GET /api/v1/auth/register/captcha` (`captcha`) – Generate or retrieve CAPTCHA challenge for registration form. Returns: + - `captcha` – Random CAPTCHA string (4-10 characters) stored in session - If CAPTCHA already exists in session, returns existing value - Used to prevent automated registrations -- `GET /api/v1/register/success` (`registrationSuccess`) – Get registration success data. Returns: - - `email` – Email address from registration session - - Returns 400 error if no registration session exists - - Used to display confirmation screen after successful registration -- `POST /api/v1/register` (`register`) – Submit registration form. Body parameters: +- `POST /api/v1/auth/register` (`store`) – Submit registration form. Body parameters: - **Required fields:** - `firstname` (string, required) – First name (2-30 chars, letters/spaces/hyphens only) - `lastname` (string, required) – Last name (2-30 chars, letters/spaces/hyphens only) @@ -1781,7 +1777,7 @@ This document lists every API controller alphabetically with any inline route co **Purpose:** Full REST API for managing students, class assignments, health information, and automated distribution. **Endpoints:** -- `GET /api/v1/students` (`index`) – Requires auth. Supports pagination and filters (`parent_id`, `class_id`). Augments each record with the student's current class info. Returns paginated list of students. +- `GET /api/v1/students` (`index`) – Requires auth. Supports filters (`parent_id`, `parent_ids[]`). Returns list of students for the selected school year. - `GET /api/v1/students/{id}` (`show`) – Requires auth. Returns detailed student profile plus current class section information. - `POST /api/v1/students` (`store`) – Requires auth. Creates a new student. Validates demographic fields (firstname, lastname, dob, gender, parent_id) and automatically stamps school year, semester, and registration date. - `PATCH /api/v1/students/{id}` (`update`) – Requires auth. Updates student information. Accepts fields: `school_id`, `firstname`, `lastname`, `dob` (automatically calculates age), `gender`, `registration_grade`, `photo_consent`, `parent_id`, `registration_date`, `tuition_paid`, `year_of_registration`, `school_year`, `rfid_tag`, `semester`, `is_new`. Also supports health information: `medical_conditions` (comma/semicolon/newline separated list), `allergies` (comma/semicolon/newline separated list), `medical_touched`, `allergies_touched` (flags to indicate user interaction). Health lists are normalized, deduplicated, and synced with related tables. Only updates fields that are provided and non-empty. diff --git a/resources/docs/openapi.json b/resources/docs/openapi.json index 3217b3b5..9bf6c8ac 100755 --- a/resources/docs/openapi.json +++ b/resources/docs/openapi.json @@ -222,18 +222,69 @@ }, "message": { "type": "string", - "example": "Login successful" + "example": "Authenticated." }, "data": { "type": "object", "properties": { - "token": { - "type": "string", - "example": "eyJ0eXAiOiJKV1QiLCJh..." + "user": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "example": 2 + }, + "firstname": { + "type": "string", + "example": "makamak" + }, + "lastname": { + "type": "string", + "example": "mkmkmkm" + }, + "email": { + "type": "string", + "format": "email", + "example": "mkdfso2123@gmail.com" + }, + "roles": { + "type": "array", + "items": { + "type": "string", + "example": "parent" + } + } + } }, - "expires_in": { - "type": "integer", - "example": 3600 + "jwt": { + "type": "object", + "properties": { + "access_token": { + "type": "string", + "example": "eyJ0eXAiOiJKV1QiLCJh..." + }, + "token_type": { + "type": "string", + "example": "bearer" + }, + "expires_in": { + "type": "integer", + "example": 216000 + } + } + }, + "sanctum": { + "type": "object", + "properties": { + "access_token": { + "type": "string", + "example": "2|5NJcqQ8YixX8NIhvKZpGoW1x4BUzhEsshSiIbCFFce48f27f" + }, + "token_type": { + "type": "string", + "example": "bearer" + } + } } } } @@ -241,7 +292,19 @@ }, "RegisterRequest": { "type": "object", - "required": ["firstname", "lastname", "email", "password", "captcha_answer"], + "required": [ + "firstname", + "lastname", + "gender", + "email", + "confirm_email", + "cellphone", + "city", + "state", + "zip", + "accept_school_policy", + "captcha" + ], "properties": { "firstname": { "type": "string", @@ -251,22 +314,80 @@ "type": "string", "example": "Khalil" }, + "gender": { + "type": "string", + "example": "Female" + }, "email": { "type": "string", "format": "email", "example": "parent@example.com" }, - "password": { + "confirm_email": { "type": "string", - "example": "Str0ngPass!" + "format": "email", + "example": "parent@example.com" }, - "phone": { + "cellphone": { "type": "string", - "example": "1234567890" + "example": "203-555-1234" }, - "captcha_answer": { + "address_street": { "type": "string", - "example": "42" + "example": "123 Main St" + }, + "apt": { + "type": "string", + "example": "Apt 4B" + }, + "city": { + "type": "string", + "example": "New Haven" + }, + "state": { + "type": "string", + "example": "CT" + }, + "zip": { + "type": "string", + "example": "06510" + }, + "accept_school_policy": { + "type": "boolean", + "example": true + }, + "captcha": { + "type": "string", + "example": "A1B2" + }, + "is_parent": { + "type": "boolean", + "example": true + }, + "no_second_parent_info": { + "type": "boolean", + "example": false + }, + "second_firstname": { + "type": "string", + "example": "Omar" + }, + "second_lastname": { + "type": "string", + "example": "Khalil" + }, + "second_gender": { + "type": "string", + "example": "Male" + }, + "second_email": { + "type": "string", + "format": "email", + "example": "second.parent@example.com" + }, + "second_cellphone": { + "type": "string", + "example": "203-555-4567" } } }, @@ -303,23 +424,37 @@ "RegisterResponse": { "type": "object", "properties": { - "status": { + "ok": { "type": "boolean", "example": true }, - "message": { - "type": "string", - "example": "User registered successfully" - }, - "data": { + "registration": { "type": "object", "properties": { - "user": { - "$ref": "#/components/schemas/UserSummary" + "user_id": { + "type": "integer", + "example": 4321 }, - "message": { + "email": { "type": "string", - "example": "Registration successful. Please check your email for verification." + "format": "email", + "example": "parent@example.com" + }, + "firstname": { + "type": "string", + "example": "Yara" + }, + "lastname": { + "type": "string", + "example": "Khalil" + }, + "role": { + "type": "string", + "example": "parent" + }, + "activation_sent": { + "type": "boolean", + "example": true } } } @@ -1074,7 +1209,7 @@ } ], "paths": { - "/api/v1/register": { + "/api/v1/auth/register": { "post": { "operationId": "register", "tags": ["Auth"], @@ -1473,6 +1608,16 @@ "type": "integer" } }, + { + "name": "parent_ids[]", + "in": "query", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + } + }, { "name": "class_id", "in": "query", @@ -4624,6 +4769,23 @@ "schema": { "type": "string" } + }, + { + "name": "parent_id", + "in": "query", + "schema": { + "type": "integer" + } + }, + { + "name": "parent_ids[]", + "in": "query", + "schema": { + "type": "array", + "items": { + "type": "integer" + } + } } ], "responses": { diff --git a/routes/api.php b/routes/api.php index d284e2cd..946123d0 100755 --- a/routes/api.php +++ b/routes/api.php @@ -26,7 +26,7 @@ use App\Http\Controllers\Api\Email\BroadcastEmailController; use App\Http\Controllers\Api\Settings\ConfigurationController; use App\Http\Controllers\Api\Staff\StaffController; use App\Http\Controllers\Api\ClassPreparation\ClassPreparationController; -use App\Http\Controllers\Api\ClassPrep\ClassPrepController; +use App\Http\Controllers\Api\ClassProgress\ClassProgressController; use App\Http\Controllers\Api\Email\EmailController; use App\Http\Controllers\Api\Email\EmailExtractorController; use App\Http\Controllers\Api\Communication\CommunicationController; @@ -828,8 +828,19 @@ Route::prefix('v1')->group(function () { Route::post('mark-printed', [ClassPreparationController::class, 'markPrinted']); Route::post('adjustments', [ClassPreparationController::class, 'saveAdjustments']); Route::post('print/{classSectionId}/{schoolYear}', [ClassPreparationController::class, 'print']); - Route::get('sticker-counts', [ClassPrepController::class, 'stickerCounts']); - Route::get('classes/{classSectionId}/students', [ClassPrepController::class, 'studentsByClass']); + Route::get('sticker-counts', [ClassPreparationController::class, 'stickerCounts']); + Route::get('classes/{classSectionId}/students', [ClassPreparationController::class, 'studentsByClass']); + }); + + Route::prefix('class-progress')->group(function () { + Route::get('meta', [ClassProgressController::class, 'meta']); + Route::get('/', [ClassProgressController::class, 'index']); + Route::post('/', [ClassProgressController::class, 'store']); + Route::get('{class_progress}', [ClassProgressController::class, 'show']); + Route::patch('{class_progress}', [ClassProgressController::class, 'update']); + Route::delete('{class_progress}', [ClassProgressController::class, 'destroy']); + Route::get('{class_progress}/attachment', [ClassProgressController::class, 'downloadLegacyAttachment']); + Route::get('attachments/{attachment}', [ClassProgressController::class, 'downloadAttachment']); }); Route::prefix('badges')->group(function () { @@ -876,12 +887,12 @@ Route::middleware('auth:api')->group(function () { Route::post('log-print', [BadgeController::class, 'logPrint']); }); - Route::prefix('class-prep')->group(function () { - Route::get('/', [ClassPreparationController::class, 'index']); - Route::post('mark-printed', [ClassPreparationController::class, 'markPrinted']); - Route::post('adjustments', [ClassPreparationController::class, 'saveAdjustments']); - Route::post('print/{classSectionId}/{schoolYear}', [ClassPreparationController::class, 'print']); - Route::get('sticker-counts', [ClassPrepController::class, 'stickerCounts']); - Route::get('classes/{classSectionId}/students', [ClassPrepController::class, 'studentsByClass']); - }); + Route::prefix('class-prep')->group(function () { + Route::get('/', [ClassPreparationController::class, 'index']); + Route::post('mark-printed', [ClassPreparationController::class, 'markPrinted']); + Route::post('adjustments', [ClassPreparationController::class, 'saveAdjustments']); + Route::post('print/{classSectionId}/{schoolYear}', [ClassPreparationController::class, 'print']); + Route::get('sticker-counts', [ClassPreparationController::class, 'stickerCounts']); + Route::get('classes/{classSectionId}/students', [ClassPreparationController::class, 'studentsByClass']); + }); }); diff --git a/tests/Feature/Api/V1/ClassPrep/ClassPrepControllerTest.php b/tests/Feature/Api/V1/ClassPrep/ClassPrepControllerTest.php deleted file mode 100644 index db9c7103..00000000 --- a/tests/Feature/Api/V1/ClassPrep/ClassPrepControllerTest.php +++ /dev/null @@ -1,203 +0,0 @@ -seedStickerData(); - - $user = $this->createUser(); - Sanctum::actingAs($user); - - $response = $this->getJson('/api/v1/class-prep/sticker-counts?school_year=2025-2026&semester=Fall'); - - $response->assertOk(); - $response->assertJson([ - 'ok' => true, - 'data' => [ - 'totals' => [ - 'primary' => 6, - 'secondary' => 1, - 'students' => 2, - ], - ], - ]); - } - - public function test_students_by_class_returns_roster(): void - { - $this->seedStickerData(); - - $user = $this->createUser(); - Sanctum::actingAs($user); - - $response = $this->getJson('/api/v1/class-prep/classes/101/students?school_year=2025-2026'); - - $response->assertOk(); - $this->assertCount(1, $response->json('students')); - $this->assertSame('1-A', $response->json('students.0.registration_grade')); - } - - private function seedStickerData(): void - { - DB::table('configuration')->insert([ - ['id' => 1, 'config_key' => 'school_year', 'config_value' => '2025-2026'], - ['id' => 2, 'config_key' => 'semester', 'config_value' => 'Fall'], - ]); - - DB::table('classes')->insert([ - [ - 'id' => 1, - 'class_name' => 'Class 1', - 'schedule' => 'Sun', - 'capacity' => 20, - 'semester' => 'Fall', - 'school_year' => '2025-2026', - ], - [ - 'id' => 2, - 'class_name' => 'Class 2', - 'schedule' => 'Sun', - 'capacity' => 20, - 'semester' => 'Fall', - 'school_year' => '2025-2026', - ], - [ - 'id' => 3, - 'class_name' => 'Class 3', - 'schedule' => 'Sun', - 'capacity' => 20, - 'semester' => 'Fall', - 'school_year' => '2025-2026', - ], - ]); - - DB::table('classSection')->insert([ - [ - 'id' => 1, - 'class_id' => 1, - 'class_section_id' => 101, - 'class_section_name' => '1-A', - 'semester' => 'Fall', - 'school_year' => '2025-2026', - ], - [ - 'id' => 2, - 'class_id' => 2, - 'class_section_id' => 102, - 'class_section_name' => '5-B', - 'semester' => 'Fall', - 'school_year' => '2025-2026', - ], - [ - 'id' => 3, - 'class_id' => 3, - 'class_section_id' => 103, - 'class_section_name' => 'Youth-1', - 'semester' => 'Fall', - 'school_year' => '2025-2026', - ], - ]); - - DB::table('students')->insert([ - [ - 'school_id' => 'S-1', - 'firstname' => 'Student', - 'lastname' => 'One', - 'age' => 8, - 'gender' => 'Male', - 'photo_consent' => 1, - 'parent_id' => 1, - 'year_of_registration' => '2025', - 'school_year' => '2025-2026', - 'semester' => 'Fall', - 'is_active' => 1, - 'is_new' => 0, - ], - [ - 'school_id' => 'S-2', - 'firstname' => 'Student', - 'lastname' => 'Two', - 'age' => 10, - 'gender' => 'Female', - 'photo_consent' => 1, - 'parent_id' => 1, - 'year_of_registration' => '2025', - 'school_year' => '2025-2026', - 'semester' => 'Fall', - 'is_active' => 1, - 'is_new' => 0, - ], - [ - 'school_id' => 'S-3', - 'firstname' => 'Student', - 'lastname' => 'Three', - 'age' => 12, - 'gender' => 'Female', - 'photo_consent' => 1, - 'parent_id' => 1, - 'year_of_registration' => '2025', - 'school_year' => '2025-2026', - 'semester' => 'Fall', - 'is_active' => 1, - 'is_new' => 1, - ], - ]); - - DB::table('student_class')->insert([ - [ - 'student_id' => 1, - 'class_section_id' => 101, - 'semester' => 'Fall', - 'school_year' => '2025-2026', - ], - [ - 'student_id' => 2, - 'class_section_id' => 102, - 'semester' => 'Fall', - 'school_year' => '2025-2026', - ], - [ - 'student_id' => 3, - 'class_section_id' => 103, - 'semester' => 'Fall', - 'school_year' => '2025-2026', - ], - ]); - } - - private function createUser(): User - { - DB::table('users')->insert([ - 'school_id' => 1, - 'firstname' => 'Admin', - 'lastname' => 'User', - 'cellphone' => '5555555555', - 'email' => 'admin@example.com', - 'address_street' => '123 Main', - 'city' => 'City', - 'state' => 'ST', - 'zip' => '12345', - 'accept_school_policy' => 1, - 'is_verified' => 1, - 'status' => 'Active', - 'is_suspended' => 0, - 'failed_attempts' => 0, - 'password' => bcrypt('secret'), - 'semester' => 'Fall', - 'school_year' => '2025-2026', - ]); - - return User::query()->findOrFail(1); - } -} diff --git a/tests/Feature/Api/V1/ClassPreparation/ClassPreparationControllerTest.php b/tests/Feature/Api/V1/ClassPreparation/ClassPreparationControllerTest.php index d4b66594..f07e66e5 100644 --- a/tests/Feature/Api/V1/ClassPreparation/ClassPreparationControllerTest.php +++ b/tests/Feature/Api/V1/ClassPreparation/ClassPreparationControllerTest.php @@ -12,161 +12,167 @@ class ClassPreparationControllerTest extends TestCase { use RefreshDatabase; - public function test_index_returns_prep_payload(): void + public function test_sticker_counts_returns_payload(): void { - $this->seedPrepData(); + $this->seedStickerData(); $user = $this->createUser(); Sanctum::actingAs($user); - $response = $this->getJson('/api/v1/class-prep?school_year=2025-2026&semester=Fall'); + $response = $this->getJson('/api/v1/class-prep/sticker-counts?school_year=2025-2026&semester=Fall'); $response->assertOk(); $response->assertJson([ - 'status' => true, - 'message' => 'Success', + 'ok' => true, 'data' => [ - 'schoolYear' => '2025-2026', - 'semester' => 'Fall', + 'totals' => [ + 'primary' => 6, + 'secondary' => 1, + 'students' => 2, + ], ], ]); - $this->assertNotEmpty($response->json('data.results')); } - public function test_mark_printed_creates_snapshots(): void + public function test_students_by_class_returns_roster(): void { - $this->seedPrepData(); + $this->seedStickerData(); $user = $this->createUser(); Sanctum::actingAs($user); - $response = $this->postJson('/api/v1/class-prep/mark-printed', [ - 'school_year' => '2025-2026', - 'semester' => 'Fall', - 'class_section_ids' => ['101'], - ]); + $response = $this->getJson('/api/v1/class-prep/classes/101/students?school_year=2025-2026'); $response->assertOk(); - $response->assertJson([ - 'status' => true, - ]); - - $this->assertDatabaseHas('class_preparation_log', [ - 'class_section_id' => 101, - 'school_year' => '2025-2026', - ]); + $this->assertCount(1, $response->json('students')); + $this->assertSame('1-A', $response->json('students.0.registration_grade')); } - public function test_mark_printed_rejects_invalid_payload(): void - { - $user = $this->createUser(); - Sanctum::actingAs($user); - - $response = $this->postJson('/api/v1/class-prep/mark-printed', [ - 'class_section_ids' => [], - ]); - - $response->assertStatus(422); - $response->assertJsonStructure(['message', 'errors']); - } - - public function test_save_adjustments_updates_rows(): void - { - $this->seedPrepData(); - - $user = $this->createUser(); - Sanctum::actingAs($user); - - $response = $this->postJson('/api/v1/class-prep/adjustments', [ - 'class_section_id' => '101', - 'school_year' => '2025-2026', - 'adjustments' => [ - 'Small Table' => 2, - ], - ]); - - $response->assertOk(); - $response->assertJson([ - 'status' => true, - ]); - - $this->assertDatabaseHas('class_prep_adjustments', [ - 'class_section_id' => '101', - 'school_year' => '2025-2026', - 'item_name' => 'Small Table', - 'adjustment' => 2, - ]); - } - - public function test_print_logs_prep_items(): void - { - $this->seedPrepData(); - - $user = $this->createUser(); - Sanctum::actingAs($user); - - $response = $this->postJson('/api/v1/class-prep/print/101/2025-2026'); - - $response->assertOk(); - $response->assertJsonPath('status', true); - $response->assertJsonPath('data.print.class_section_id', '101'); - - $this->assertDatabaseHas('class_preparation_log', [ - 'class_section_id' => 101, - 'school_year' => '2025-2026', - ]); - } - - private function seedPrepData(): void + private function seedStickerData(): void { DB::table('configuration')->insert([ ['id' => 1, 'config_key' => 'school_year', 'config_value' => '2025-2026'], ['id' => 2, 'config_key' => 'semester', 'config_value' => 'Fall'], ]); + DB::table('classes')->insert([ + [ + 'id' => 1, + 'class_name' => 'Class 1', + 'schedule' => 'Sun', + 'capacity' => 20, + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ], + [ + 'id' => 2, + 'class_name' => 'Class 2', + 'schedule' => 'Sun', + 'capacity' => 20, + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ], + [ + 'id' => 3, + 'class_name' => 'Class 3', + 'schedule' => 'Sun', + 'capacity' => 20, + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ], + ]); + DB::table('classSection')->insert([ - 'id' => 1, - 'class_id' => 1, - 'class_section_id' => 101, - 'class_section_name' => '1-A', - 'semester' => 'Fall', - 'school_year' => '2025-2026', + [ + 'id' => 1, + 'class_id' => 1, + 'class_section_id' => 101, + 'class_section_name' => '1-A', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ], + [ + 'id' => 2, + 'class_id' => 2, + 'class_section_id' => 102, + 'class_section_name' => '5-B', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ], + [ + 'id' => 3, + 'class_id' => 3, + 'class_section_id' => 103, + 'class_section_name' => 'Youth-1', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ], ]); DB::table('students')->insert([ - 'school_id' => 'S-1', - 'firstname' => 'Student', - 'lastname' => 'One', - 'age' => 8, - 'gender' => 'Male', - 'photo_consent' => 1, - 'parent_id' => 1, - 'year_of_registration' => '2025', - 'school_year' => '2025-2026', - 'semester' => 'Fall', - 'is_active' => 1, + [ + 'school_id' => 'S-1', + 'firstname' => 'Student', + 'lastname' => 'One', + 'age' => 8, + 'gender' => 'Male', + 'photo_consent' => 1, + 'parent_id' => 1, + 'year_of_registration' => '2025', + 'school_year' => '2025-2026', + 'semester' => 'Fall', + 'is_active' => 1, + 'is_new' => 0, + ], + [ + 'school_id' => 'S-2', + 'firstname' => 'Student', + 'lastname' => 'Two', + 'age' => 10, + 'gender' => 'Female', + 'photo_consent' => 1, + 'parent_id' => 1, + 'year_of_registration' => '2025', + 'school_year' => '2025-2026', + 'semester' => 'Fall', + 'is_active' => 1, + 'is_new' => 0, + ], + [ + 'school_id' => 'S-3', + 'firstname' => 'Student', + 'lastname' => 'Three', + 'age' => 12, + 'gender' => 'Female', + 'photo_consent' => 1, + 'parent_id' => 1, + 'year_of_registration' => '2025', + 'school_year' => '2025-2026', + 'semester' => 'Fall', + 'is_active' => 1, + 'is_new' => 1, + ], ]); DB::table('student_class')->insert([ - 'student_id' => 1, - 'class_section_id' => 101, - 'semester' => 'Fall', - 'school_year' => '2025-2026', - ]); - - DB::table('inventory_categories')->insert([ - 'type' => 'classroom', - 'name' => 'Small Table', - ]); - - DB::table('inventory_items')->insert([ - 'type' => 'classroom', - 'category_id' => 1, - 'name' => 'Small Table', - 'quantity' => 10, - 'good_qty' => 10, - 'school_year' => '2025-2026', - 'semester' => 'Fall', + [ + 'student_id' => 1, + 'class_section_id' => 101, + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ], + [ + 'student_id' => 2, + 'class_section_id' => 102, + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ], + [ + 'student_id' => 3, + 'class_section_id' => 103, + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ], ]); } diff --git a/tests/Feature/Api/V1/ClassProgress/ClassProgressControllerTest.php b/tests/Feature/Api/V1/ClassProgress/ClassProgressControllerTest.php new file mode 100644 index 00000000..cea23d1d --- /dev/null +++ b/tests/Feature/Api/V1/ClassProgress/ClassProgressControllerTest.php @@ -0,0 +1,244 @@ +createUser(); + Sanctum::actingAs($user); + + $response = $this->getJson('/api/v1/class-progress/meta'); + + $response->assertOk(); + $response->assertJsonPath('status', true); + $this->assertNotEmpty($response->json('data.subject_sections')); + } + + public function test_index_returns_paginated_reports(): void + { + $user = $this->createUser(); + $this->seedClassSection(); + ClassProgressReport::factory()->count(2)->create([ + 'teacher_id' => $user->id, + 'class_section_id' => 101, + ]); + + Sanctum::actingAs($user); + + $response = $this->getJson('/api/v1/class-progress'); + + $response->assertOk(); + $response->assertJsonPath('status', true); + $this->assertCount(2, $response->json('data.items')); + } + + public function test_store_creates_reports(): void + { + Storage::fake('public'); + $user = $this->createUser(); + $this->seedClassSection(); + $this->assignTeacher($user->id, 101); + Sanctum::actingAs($user); + + $payload = [ + 'class_section_id' => 101, + 'week_start' => '2025-09-07', + 'week_end' => '2025-09-13', + 'covered_islamic' => 'Lesson A', + 'covered_quran' => 'Lesson B', + 'homework_islamic' => 'HW A', + 'homework_quran' => 'HW B', + 'flags' => ['needs_support'], + ]; + + $response = $this->postJson('/api/v1/class-progress', $payload); + + $response->assertStatus(201); + $response->assertJsonPath('status', true); + $this->assertDatabaseCount('class_progress_reports', 2); + } + + public function test_show_returns_weekly_reports(): void + { + $user = $this->createUser(); + $this->seedClassSection(); + $report = ClassProgressReport::factory()->create([ + 'teacher_id' => $user->id, + 'class_section_id' => 101, + 'week_start' => '2025-09-07', + 'week_end' => '2025-09-13', + 'subject' => 'Islamic Studies', + ]); + ClassProgressReport::factory()->create([ + 'teacher_id' => $user->id, + 'class_section_id' => 101, + 'week_start' => '2025-09-07', + 'week_end' => '2025-09-13', + 'subject' => 'Quran/Arabic', + ]); + + Sanctum::actingAs($user); + + $response = $this->getJson('/api/v1/class-progress/' . $report->id); + + $response->assertOk(); + $response->assertJsonPath('status', true); + $this->assertCount(2, $response->json('data.weekly_reports')); + } + + public function test_update_modifies_report(): void + { + $user = $this->createUser(); + $this->seedClassSection(); + $report = ClassProgressReport::factory()->create([ + 'teacher_id' => $user->id, + 'class_section_id' => 101, + ]); + + Sanctum::actingAs($user); + + $response = $this->patchJson('/api/v1/class-progress/' . $report->id, [ + 'status' => 'behind', + ]); + + $response->assertOk(); + $this->assertDatabaseHas('class_progress_reports', [ + 'id' => $report->id, + 'status' => 'behind', + ]); + } + + public function test_destroy_deletes_report(): void + { + $user = $this->createUser(); + $this->seedClassSection(); + $report = ClassProgressReport::factory()->create([ + 'teacher_id' => $user->id, + 'class_section_id' => 101, + ]); + + Sanctum::actingAs($user); + + $response = $this->deleteJson('/api/v1/class-progress/' . $report->id); + + $response->assertOk(); + $this->assertDatabaseMissing('class_progress_reports', [ + 'id' => $report->id, + ]); + } + + public function test_authorization_blocks_other_teachers(): void + { + $owner = $this->createUser('teacher1@example.com'); + $other = $this->createUser('teacher2@example.com'); + $this->seedClassSection(); + $report = ClassProgressReport::factory()->create([ + 'teacher_id' => $owner->id, + 'class_section_id' => 101, + ]); + + Sanctum::actingAs($other); + + $response = $this->getJson('/api/v1/class-progress/' . $report->id); + + $response->assertStatus(403); + } + + public function test_validation_rejects_invalid_payload(): void + { + $user = $this->createUser(); + Sanctum::actingAs($user); + + $response = $this->postJson('/api/v1/class-progress', [ + 'class_section_id' => 'invalid', + ]); + + $response->assertStatus(422); + } + + public function test_attachment_download_returns_file(): void + { + Storage::fake('public'); + Storage::disk('public')->put('class_material/sample.pdf', 'content'); + + $user = $this->createUser(); + $this->seedClassSection(); + $report = ClassProgressReport::factory()->create([ + 'teacher_id' => $user->id, + 'class_section_id' => 101, + ]); + $attachment = ClassProgressAttachment::factory()->create([ + 'report_id' => $report->id, + 'file_path' => 'storage/class_material/sample.pdf', + ]); + + Sanctum::actingAs($user); + + $response = $this->get('/api/v1/class-progress/attachments/' . $attachment->id); + + $response->assertOk(); + } + + private function seedClassSection(): void + { + DB::table('classSection')->insert([ + 'id' => 1, + 'class_id' => 1, + 'class_section_id' => 101, + 'class_section_name' => '1-A', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + } + + private function assignTeacher(int $teacherId, int $classSectionId): void + { + DB::table('teacher_class')->insert([ + 'class_section_id' => $classSectionId, + 'teacher_id' => $teacherId, + 'position' => 'main', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + private function createUser(string $email = 'teacher@example.com'): User + { + DB::table('users')->insert([ + 'school_id' => 1, + 'firstname' => 'Teacher', + 'lastname' => 'User', + 'cellphone' => '5555555555', + 'email' => $email, + 'address_street' => '123 Main', + 'city' => 'City', + 'state' => 'ST', + 'zip' => '12345', + 'accept_school_policy' => 1, + 'is_verified' => 1, + 'status' => 'Active', + 'is_suspended' => 0, + 'failed_attempts' => 0, + 'password' => bcrypt('secret'), + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + + return User::query()->where('email', $email)->firstOrFail(); + } +} diff --git a/tests/Unit/Resources/ClassProgress/ClassProgressReportResourceTest.php b/tests/Unit/Resources/ClassProgress/ClassProgressReportResourceTest.php new file mode 100644 index 00000000..1bc528ea --- /dev/null +++ b/tests/Unit/Resources/ClassProgress/ClassProgressReportResourceTest.php @@ -0,0 +1,25 @@ +create(); + + $resource = (new ClassProgressReportResource($report))->toArray(request()); + + $this->assertArrayHasKey('id', $resource); + $this->assertArrayHasKey('subject', $resource); + $this->assertArrayHasKey('status', $resource); + $this->assertArrayHasKey('attachments', $resource); + } +} diff --git a/tests/Unit/Services/ClassProgress/ClassProgressAttachmentServiceTest.php b/tests/Unit/Services/ClassProgress/ClassProgressAttachmentServiceTest.php new file mode 100644 index 00000000..8e078866 --- /dev/null +++ b/tests/Unit/Services/ClassProgress/ClassProgressAttachmentServiceTest.php @@ -0,0 +1,31 @@ +create(); + $service = new ClassProgressAttachmentService(); + + $files = [UploadedFile::fake()->create('sample.pdf', 12)]; + $stored = $service->storeAttachments($report->id, $files); + + $this->assertCount(1, $stored); + $this->assertDatabaseHas('class_progress_attachments', [ + 'report_id' => $report->id, + ]); + } +} diff --git a/tests/Unit/Services/ClassProgress/ClassProgressMetaServiceTest.php b/tests/Unit/Services/ClassProgress/ClassProgressMetaServiceTest.php new file mode 100644 index 00000000..eff89d2a --- /dev/null +++ b/tests/Unit/Services/ClassProgress/ClassProgressMetaServiceTest.php @@ -0,0 +1,19 @@ +subjectSections(); + + $this->assertArrayHasKey('islamic', $sections); + $this->assertArrayHasKey('quran', $sections); + } +} diff --git a/tests/Unit/Services/ClassProgress/ClassProgressMutationServiceTest.php b/tests/Unit/Services/ClassProgress/ClassProgressMutationServiceTest.php new file mode 100644 index 00000000..19469225 --- /dev/null +++ b/tests/Unit/Services/ClassProgress/ClassProgressMutationServiceTest.php @@ -0,0 +1,121 @@ +createUser(); + $this->seedClassSection(); + $this->assignTeacher($user->id, 101); + + $service = new ClassProgressMutationService( + new ClassProgressRuleService(), + new ClassProgressAttachmentService() + ); + + $payload = [ + 'class_section_id' => 101, + 'week_start' => '2025-09-07', + 'week_end' => '2025-09-13', + 'covered_islamic' => 'Lesson A', + 'covered_quran' => 'Lesson B', + ]; + + $reports = $service->createReports($user, $payload, []); + + $this->assertCount(2, $reports); + $this->assertDatabaseCount('class_progress_reports', 2); + } + + public function test_update_report_changes_status(): void + { + $report = ClassProgressReport::factory()->create([ + 'status' => 'on_track', + ]); + + $service = new ClassProgressMutationService( + new ClassProgressRuleService(), + new ClassProgressAttachmentService() + ); + + $updated = $service->updateReport($report, ['status' => 'behind'], []); + + $this->assertSame('behind', $updated->status); + } + + public function test_delete_report_removes_record(): void + { + $report = ClassProgressReport::factory()->create(); + $service = new ClassProgressMutationService( + new ClassProgressRuleService(), + new ClassProgressAttachmentService() + ); + + $service->deleteReport($report); + + $this->assertDatabaseMissing('class_progress_reports', ['id' => $report->id]); + } + + private function seedClassSection(): void + { + DB::table('classSection')->insert([ + 'id' => 1, + 'class_id' => 1, + 'class_section_id' => 101, + 'class_section_name' => '1-A', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + } + + private function assignTeacher(int $teacherId, int $classSectionId): void + { + DB::table('teacher_class')->insert([ + 'class_section_id' => $classSectionId, + 'teacher_id' => $teacherId, + 'position' => 'main', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + private function createUser(): User + { + DB::table('users')->insert([ + 'school_id' => 1, + 'firstname' => 'Teacher', + 'lastname' => 'User', + 'cellphone' => '5555555555', + 'email' => 'teacher@example.com', + 'address_street' => '123 Main', + 'city' => 'City', + 'state' => 'ST', + 'zip' => '12345', + 'accept_school_policy' => 1, + 'is_verified' => 1, + 'status' => 'Active', + 'is_suspended' => 0, + 'failed_attempts' => 0, + 'password' => bcrypt('secret'), + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + + return User::query()->where('email', 'teacher@example.com')->firstOrFail(); + } +} diff --git a/tests/Unit/Services/ClassProgress/ClassProgressQueryServiceTest.php b/tests/Unit/Services/ClassProgress/ClassProgressQueryServiceTest.php new file mode 100644 index 00000000..ac10c724 --- /dev/null +++ b/tests/Unit/Services/ClassProgress/ClassProgressQueryServiceTest.php @@ -0,0 +1,59 @@ +createUser(); + ClassProgressReport::factory()->create([ + 'teacher_id' => $user->id, + 'class_section_id' => 101, + ]); + ClassProgressReport::factory()->create([ + 'teacher_id' => $user->id, + 'class_section_id' => 202, + ]); + + $service = new ClassProgressQueryService(new ClassProgressRuleService()); + $results = $service->listReports($user, ['class_section_id' => 101]); + + $this->assertSame(1, $results->total()); + } + + private function createUser(): User + { + DB::table('users')->insert([ + 'school_id' => 1, + 'firstname' => 'Teacher', + 'lastname' => 'User', + 'cellphone' => '5555555555', + 'email' => 'teacher@example.com', + 'address_street' => '123 Main', + 'city' => 'City', + 'state' => 'ST', + 'zip' => '12345', + 'accept_school_policy' => 1, + 'is_verified' => 1, + 'status' => 'Active', + 'is_suspended' => 0, + 'failed_attempts' => 0, + 'password' => bcrypt('secret'), + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + + return User::query()->where('email', 'teacher@example.com')->firstOrFail(); + } +} diff --git a/tests/Unit/Services/ClassProgress/ClassProgressRuleServiceTest.php b/tests/Unit/Services/ClassProgress/ClassProgressRuleServiceTest.php new file mode 100644 index 00000000..0303c999 --- /dev/null +++ b/tests/Unit/Services/ClassProgress/ClassProgressRuleServiceTest.php @@ -0,0 +1,27 @@ +buildUnitChapterSummary(['Unit 1'], ['Chapter A']); + + $this->assertSame('Unit 1 / Chapter A', $summary); + } + + public function test_ensure_week_end_defaults_to_six_days(): void + { + $service = new ClassProgressRuleService(); + + $weekEnd = $service->ensureWeekEnd('2025-09-07', null); + + $this->assertSame('2025-09-13', $weekEnd); + } +}