diff --git a/app/Http/Controllers/Api/Grading/GradingController.php b/app/Http/Controllers/Api/Grading/GradingController.php index 9d575a5f..cd3e19aa 100644 --- a/app/Http/Controllers/Api/Grading/GradingController.php +++ b/app/Http/Controllers/Api/Grading/GradingController.php @@ -4,6 +4,10 @@ namespace App\Http\Controllers\Api\Grading; use App\Http\Controllers\Api\Core\BaseApiController; use App\Http\Requests\Grading\BelowSixtyEmailRequest; +use App\Http\Requests\Grading\BelowSixtyDecisionDetailsRequest; +use App\Http\Requests\Grading\BelowSixtyDecisionListRequest; +use App\Http\Requests\Grading\BelowSixtyDecisionSaveRequest; +use App\Http\Requests\Grading\BelowSixtyDecisionSendRequest; use App\Http\Requests\Grading\BelowSixtyMeetingRequest; use App\Http\Requests\Grading\BelowSixtyMeetingSaveRequest; use App\Http\Requests\Grading\BelowSixtySendRequest; @@ -312,6 +316,73 @@ class GradingController extends BaseApiController return response()->json(['ok' => true] + $data); } + public function belowSixtyDecisions(BelowSixtyDecisionListRequest $request): JsonResponse + { + $payload = $request->validated(); + $rows = $this->belowSixtyService->listDecisionRows((string) $payload['school_year']); + + return response()->json([ + 'ok' => true, + 'rows' => $rows, + 'school_year' => $payload['school_year'], + 'semester' => 'year', + ]); + } + + public function saveBelowSixtyDecision(BelowSixtyDecisionSaveRequest $request): JsonResponse + { + $userId = $this->authenticatedUserIdOrUnauthorized(); + if ($userId instanceof JsonResponse) { + return $userId; + } + + $payload = $request->validated(); + $result = $this->belowSixtyService->saveDecision( + (int) $payload['student_id'], + (string) $payload['school_year'], + (string) ($payload['decision'] ?? ''), + (string) ($payload['notes'] ?? ''), + $userId + ); + + return response()->json(['ok' => true, 'decision' => $result]); + } + + public function belowSixtyDecisionDetails(BelowSixtyDecisionDetailsRequest $request): JsonResponse + { + $payload = $request->validated(); + $data = $this->belowSixtyService->studentDecisionDetails( + (int) $payload['student_id'], + (string) $payload['school_year'] + ); + + return response()->json(['ok' => true] + $data); + } + + public function belowSixtyDecisionEmail(BelowSixtyDecisionDetailsRequest $request): JsonResponse + { + $payload = $request->validated(); + $data = $this->belowSixtyService->decisionEmailContext( + (int) $payload['student_id'], + (string) $payload['school_year'] + ); + + return response()->json(['ok' => true] + $data); + } + + public function sendBelowSixtyDecisionEmail(BelowSixtyDecisionSendRequest $request): JsonResponse + { + $payload = $request->validated(); + $this->belowSixtyService->sendDecisionEmail( + (int) $payload['student_id'], + (string) $payload['school_year'], + isset($payload['subject']) ? (string) $payload['subject'] : null, + isset($payload['html']) ? (string) $payload['html'] : null + ); + + return response()->json(['ok' => true]); + } + public function saveBelowSixtyMeeting(BelowSixtyMeetingSaveRequest $request): JsonResponse { $userId = $this->authenticatedUserIdOrUnauthorized(); diff --git a/app/Http/Requests/Grading/BelowSixtyDecisionDetailsRequest.php b/app/Http/Requests/Grading/BelowSixtyDecisionDetailsRequest.php new file mode 100644 index 00000000..e06947d0 --- /dev/null +++ b/app/Http/Requests/Grading/BelowSixtyDecisionDetailsRequest.php @@ -0,0 +1,21 @@ + ['required', 'integer', 'min:1'], + 'school_year' => ['required', 'string', 'max:50'], + ]; + } +} diff --git a/app/Http/Requests/Grading/BelowSixtyDecisionListRequest.php b/app/Http/Requests/Grading/BelowSixtyDecisionListRequest.php new file mode 100644 index 00000000..f74f4d9a --- /dev/null +++ b/app/Http/Requests/Grading/BelowSixtyDecisionListRequest.php @@ -0,0 +1,20 @@ + ['required', 'string', 'max:50'], + ]; + } +} diff --git a/app/Http/Requests/Grading/BelowSixtyDecisionSaveRequest.php b/app/Http/Requests/Grading/BelowSixtyDecisionSaveRequest.php new file mode 100644 index 00000000..44eed508 --- /dev/null +++ b/app/Http/Requests/Grading/BelowSixtyDecisionSaveRequest.php @@ -0,0 +1,37 @@ + ['required', 'integer', 'min:1'], + 'school_year' => ['required', 'string', 'max:50'], + 'semester' => ['nullable', 'string', 'max:20'], + 'decision' => [ + 'nullable', + 'string', + 'max:100', + Rule::in([ + 'Pass', + 'Repeat Class', + 'Make-up exam in fall', + 'Deferred decision', + 'Expel', + 'Withdrawn', + ]), + ], + 'notes' => ['nullable', 'string'], + ]; + } +} diff --git a/app/Http/Requests/Grading/BelowSixtyDecisionSendRequest.php b/app/Http/Requests/Grading/BelowSixtyDecisionSendRequest.php new file mode 100644 index 00000000..b1c76743 --- /dev/null +++ b/app/Http/Requests/Grading/BelowSixtyDecisionSendRequest.php @@ -0,0 +1,23 @@ + ['required', 'integer', 'min:1'], + 'school_year' => ['required', 'string', 'max:50'], + 'subject' => ['nullable', 'string', 'max:255'], + 'html' => ['nullable', 'string'], + ]; + } +} diff --git a/app/Http/Resources/Exams/ExamDraftResource.php b/app/Http/Resources/Exams/ExamDraftResource.php index 49caa433..93dc84aa 100644 --- a/app/Http/Resources/Exams/ExamDraftResource.php +++ b/app/Http/Resources/Exams/ExamDraftResource.php @@ -10,19 +10,19 @@ class ExamDraftResource extends JsonResource { return [ 'id' => (int) ($this['id'] ?? 0), - 'teacher_id' => (int) ($this['teacher_id'] ?? 0), + 'teacher_id' => (int) (($this['teacher_id'] ?? $this['author_id'] ?? 0) ?: 0), 'class_section_id' => (int) ($this['class_section_id'] ?? 0), 'class_section_name' => $this['class_section_name'] ?? null, 'semester' => $this['semester'] ?? null, 'school_year' => $this['school_year'] ?? null, 'exam_type' => $this['exam_type'] ?? null, 'draft_title' => $this['draft_title'] ?? null, - 'description' => $this['description'] ?? null, - 'teacher_file' => $this['teacher_file'] ?? null, - 'teacher_filename' => $this['teacher_filename'] ?? null, + 'description' => $this['description'] ?? ($this['author_comment'] ?? null), + 'teacher_file' => $this['teacher_file'] ?? ($this['author_file'] ?? null), + 'teacher_filename' => $this['teacher_filename'] ?? ($this['author_filename'] ?? null), 'status' => $this['status'] ?? null, - 'admin_id' => $this['admin_id'] ?? null, - 'admin_comments' => $this['admin_comments'] ?? null, + 'admin_id' => $this['admin_id'] ?? ($this['reviewer_id'] ?? null), + 'admin_comments' => $this['admin_comments'] ?? ($this['reviewer_comments'] ?? null), 'reviewed_at' => $this['reviewed_at'] ?? null, 'final_file' => $this['final_file'] ?? null, 'final_filename' => $this['final_filename'] ?? null, diff --git a/app/Models/BelowSixtyDecision.php b/app/Models/BelowSixtyDecision.php new file mode 100644 index 00000000..13688e19 --- /dev/null +++ b/app/Models/BelowSixtyDecision.php @@ -0,0 +1,26 @@ + 'integer', + 'decided_by' => 'integer', + ]; +} diff --git a/app/Models/StudentDecision.php b/app/Models/StudentDecision.php new file mode 100644 index 00000000..bbba5824 --- /dev/null +++ b/app/Models/StudentDecision.php @@ -0,0 +1,29 @@ + 'integer', + 'generated_by' => 'integer', + 'year_score' => 'decimal:2', + ]; +} diff --git a/app/Services/Exams/ExamDraftAdminService.php b/app/Services/Exams/ExamDraftAdminService.php index 79d63c07..9f22c66a 100644 --- a/app/Services/Exams/ExamDraftAdminService.php +++ b/app/Services/Exams/ExamDraftAdminService.php @@ -17,19 +17,45 @@ class ExamDraftAdminService public function list(): array { $context = $this->configService->context(); + $teacherUserColumn = (string) ($context['teacher_user_column'] ?? ''); + $adminUserColumn = (string) ($context['admin_user_column'] ?? ''); - $drafts = DB::table('exam_drafts as ed') + $draftsQuery = DB::table('exam_drafts as ed') ->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'ed.class_section_id') - ->leftJoin('users as u', 'u.id', '=', 'ed.teacher_id') - ->leftJoin('users as a', 'a.id', '=', 'ed.admin_id') ->select( 'ed.*', 'cs.class_section_name', - 'u.firstname as teacher_first', - 'u.lastname as teacher_last', - 'a.firstname as admin_first', - 'a.lastname as admin_last' - ) + ); + + if ($teacherUserColumn !== '') { + $draftsQuery + ->leftJoin('users as u', "u.id", '=', "ed.{$teacherUserColumn}") + ->addSelect( + 'u.firstname as teacher_first', + 'u.lastname as teacher_last' + ); + } else { + $draftsQuery->addSelect( + DB::raw('NULL as teacher_first'), + DB::raw('NULL as teacher_last') + ); + } + + if ($adminUserColumn !== '') { + $draftsQuery + ->leftJoin('users as a', 'a.id', '=', "ed.{$adminUserColumn}") + ->addSelect( + 'a.firstname as admin_first', + 'a.lastname as admin_last' + ); + } else { + $draftsQuery->addSelect( + DB::raw('NULL as admin_first'), + DB::raw('NULL as admin_last') + ); + } + + $drafts = $draftsQuery ->orderByDesc('ed.created_at') ->get() ->map(fn ($r) => (array) $r) @@ -88,6 +114,7 @@ class ExamDraftAdminService public function uploadLegacy(int $adminId, array $payload, \Illuminate\Http\UploadedFile $file): array { $context = $this->configService->context(); + $adminUserColumn = (string) ($context['admin_user_column'] ?? ''); $classSectionId = (int) ($payload['class_section_id'] ?? 0); $schoolYear = trim((string) ($payload['school_year'] ?? $context['school_year'] ?? '')); $semester = trim((string) ($payload['semester'] ?? $context['semester'] ?? '')); @@ -124,20 +151,35 @@ class ExamDraftAdminService : (pathinfo($stored['original'], PATHINFO_FILENAME) . '.pdf'); $payload = [ - 'teacher_id' => $adminId, 'class_section_id' => $classSectionId, 'semester' => ucfirst(strtolower($semester)), 'school_year' => $schoolYear, 'exam_type' => $examType !== '' ? $examType : null, 'draft_title' => $examType !== '' ? $examType : 'Legacy Exam', 'final_file' => $pdfName, - 'final_filename' => $finalFilename, 'status' => 'finalized', - 'admin_id' => $adminId, - 'reviewed_at' => now(), 'version' => 1, ]; + if ($context['has_teacher_id'] ?? false) { + $payload['teacher_id'] = $adminId; + } + if ($context['has_author_id'] ?? false) { + $payload['author_id'] = $adminId; + } + + if ($context['has_final_filename'] ?? false) { + $payload['final_filename'] = $finalFilename; + } + if ($context['has_admin_id'] ?? false) { + $payload['admin_id'] = $adminId; + } elseif ($adminUserColumn === 'reviewer_id') { + $payload['reviewer_id'] = $adminId; + } + if ($context['has_reviewed_at'] ?? false) { + $payload['reviewed_at'] = now(); + } + if ($context['has_legacy'] ?? false) { $payload['is_legacy'] = 1; } @@ -171,13 +213,26 @@ class ExamDraftAdminService $status = 'reviewed'; } + $context = $this->configService->context(); + $update = [ 'status' => $status, - 'admin_comments' => $comments !== '' ? $comments : null, - 'admin_id' => $adminId, - 'reviewed_at' => now(), ]; + if ($context['has_admin_comments'] ?? false) { + $update['admin_comments'] = $comments !== '' ? $comments : null; + } elseif ($context['has_reviewer_comments'] ?? false) { + $update['reviewer_comments'] = $comments !== '' ? $comments : null; + } + if ($context['has_admin_id'] ?? false) { + $update['admin_id'] = $adminId; + } elseif (($context['admin_user_column'] ?? null) === 'reviewer_id') { + $update['reviewer_id'] = $adminId; + } + if ($context['has_reviewed_at'] ?? false) { + $update['reviewed_at'] = now(); + } + if ($file) { $stored = $this->fileService->storeUploadedFile( $file, @@ -189,7 +244,9 @@ class ExamDraftAdminService return ['ok' => false, 'message' => 'Unable to store the final draft.']; } $update['final_file'] = $stored['stored']; - $update['final_filename'] = $stored['original']; + if ($context['has_final_filename'] ?? false) { + $update['final_filename'] = $stored['original']; + } $pdfName = $this->fileService->ensurePdfExists( $stored['stored'], $stored['extension'], @@ -198,27 +255,29 @@ class ExamDraftAdminService if ($pdfName !== null && ($this->configService->context()['has_final_pdf'] ?? false)) { $update['final_pdf_file'] = $pdfName; } - } elseif ($status === 'finalized' && !empty($draft->teacher_file)) { + } elseif ($status === 'finalized' && !empty($draft->teacher_file ?: $draft->author_file)) { $copied = $this->fileService->copyDraftToFinal( - (string) $draft->teacher_file, + (string) ($draft->teacher_file ?? $draft->author_file), ExamDraftConfigService::TEACHER_UPLOAD_DIR, ExamDraftConfigService::FINAL_UPLOAD_DIR ); if ($copied !== null) { $update['final_file'] = $copied; - $update['final_filename'] = $draft->teacher_filename ?? $draft->teacher_file; + if ($context['has_final_filename'] ?? false) { + $update['final_filename'] = $draft->teacher_filename ?? $draft->author_filename ?? $draft->teacher_file ?? $draft->author_file; + } $pdfName = $this->fileService->ensurePdfExists( $copied, pathinfo($copied, PATHINFO_EXTENSION), ExamDraftConfigService::FINAL_UPLOAD_DIR ); - if ($pdfName !== null && ($this->configService->context()['has_final_pdf'] ?? false)) { + if ($pdfName !== null && ($context['has_final_pdf'] ?? false)) { $update['final_pdf_file'] = $pdfName; } } } - if ($status === 'finalized' && ($this->configService->context()['has_legacy'] ?? false)) { + if ($status === 'finalized' && ($context['has_legacy'] ?? false)) { $update['is_legacy'] = 1; } diff --git a/app/Services/Exams/ExamDraftConfigService.php b/app/Services/Exams/ExamDraftConfigService.php index d4269311..b7d0f68e 100644 --- a/app/Services/Exams/ExamDraftConfigService.php +++ b/app/Services/Exams/ExamDraftConfigService.php @@ -26,10 +26,49 @@ class ExamDraftConfigService public function context(): array { + $teacherUserColumn = $this->hasColumn('exam_drafts', 'teacher_id') + ? 'teacher_id' + : ($this->hasColumn('exam_drafts', 'author_id') ? 'author_id' : null); + + $teacherFileColumn = $this->hasColumn('exam_drafts', 'teacher_file') + ? 'teacher_file' + : ($this->hasColumn('exam_drafts', 'author_file') ? 'author_file' : null); + + $teacherFilenameColumn = $this->hasColumn('exam_drafts', 'teacher_filename') + ? 'teacher_filename' + : ($this->hasColumn('exam_drafts', 'author_filename') ? 'author_filename' : null); + + $teacherCommentColumn = $this->hasColumn('exam_drafts', 'description') + ? 'description' + : ($this->hasColumn('exam_drafts', 'author_comment') ? 'author_comment' : null); + + $adminUserColumn = $this->hasColumn('exam_drafts', 'admin_id') + ? 'admin_id' + : ($this->hasColumn('exam_drafts', 'reviewer_id') ? 'reviewer_id' : null); + + $adminCommentsColumn = $this->hasColumn('exam_drafts', 'admin_comments') + ? 'admin_comments' + : ($this->hasColumn('exam_drafts', 'reviewer_comments') ? 'reviewer_comments' : null); + return [ 'school_year' => (string) (Configuration::getConfig('school_year') ?? ''), 'semester' => (string) (Configuration::getConfig('semester') ?? ''), + 'teacher_user_column' => $teacherUserColumn, + 'teacher_file_column' => $teacherFileColumn, + 'teacher_filename_column' => $teacherFilenameColumn, + 'teacher_comment_column' => $teacherCommentColumn, + 'admin_user_column' => $adminUserColumn, + 'admin_comments_column' => $adminCommentsColumn, + 'has_teacher_id' => $this->hasColumn('exam_drafts', 'teacher_id'), + 'has_admin_id' => $this->hasColumn('exam_drafts', 'admin_id'), + 'has_author_id' => $this->hasColumn('exam_drafts', 'author_id'), + 'has_reviewer_id' => $this->hasColumn('exam_drafts', 'reviewer_id'), + 'has_admin_comments' => $this->hasColumn('exam_drafts', 'admin_comments'), + 'has_reviewer_comments' => $this->hasColumn('exam_drafts', 'reviewer_comments'), + 'has_reviewed_at' => $this->hasColumn('exam_drafts', 'reviewed_at'), 'has_final_pdf' => $this->hasColumn('exam_drafts', 'final_pdf_file'), + 'has_final_filename' => $this->hasColumn('exam_drafts', 'final_filename'), + 'has_previous_draft_id' => $this->hasColumn('exam_drafts', 'previous_draft_id'), 'has_legacy' => $this->hasColumn('exam_drafts', 'is_legacy'), ]; } diff --git a/app/Services/Exams/ExamDraftTeacherService.php b/app/Services/Exams/ExamDraftTeacherService.php index 9ba079a1..fa28bdf7 100644 --- a/app/Services/Exams/ExamDraftTeacherService.php +++ b/app/Services/Exams/ExamDraftTeacherService.php @@ -18,15 +18,19 @@ class ExamDraftTeacherService { $context = $this->configService->context(); $schoolYear = (string) ($context['school_year'] ?? ''); + $teacherUserColumn = (string) ($context['teacher_user_column'] ?? ''); $assignments = TeacherClass::getClassAssignmentsByUserId($teacherId, $schoolYear); $classSectionIds = array_map(static fn ($a) => (int) $a['class_section_id'], $assignments); - $drafts = ExamDraft::query() - ->where('teacher_id', $teacherId) - ->orderByDesc('created_at') - ->get() - ->toArray(); + $drafts = []; + if ($teacherUserColumn !== '') { + $drafts = ExamDraft::query() + ->where($teacherUserColumn, $teacherId) + ->orderByDesc('created_at') + ->get() + ->toArray(); + } $legacyExams = []; if (($context['has_legacy'] ?? false) && !empty($classSectionIds)) { @@ -94,11 +98,14 @@ class ExamDraftTeacherService } $existingDraft = ExamDraft::query() - ->where('teacher_id', $teacherId) ->where('class_section_id', $classSectionId) ->where('semester', $semester) ->where('school_year', $schoolYear) ->orderByDesc('version') + ->when( + (string) ($context['teacher_user_column'] ?? '') !== '', + fn ($query) => $query->where((string) $context['teacher_user_column'], $teacherId) + ) ->first(); $nextVersion = 1; @@ -110,20 +117,45 @@ class ExamDraftTeacherService $title = $examType !== '' ? $examType : 'Exam Draft'; - $draft = ExamDraft::query()->create([ - 'teacher_id' => $teacherId, + $createPayload = [ 'class_section_id' => $classSectionId, 'semester' => $semester, 'school_year' => $schoolYear, 'exam_type' => $examType !== '' ? $examType : null, 'draft_title' => $title, - 'description' => $description !== '' ? $description : null, - 'teacher_file' => $teacherFile, - 'teacher_filename' => $teacherFilename, 'status' => 'submitted', 'version' => $nextVersion, - 'previous_draft_id' => $previousId, - ]); + ]; + + if (($context['teacher_user_column'] ?? null) === 'teacher_id') { + $createPayload['teacher_id'] = $teacherId; + } + if (($context['teacher_user_column'] ?? null) === 'author_id') { + $createPayload['author_id'] = $teacherId; + } + if (($context['teacher_comment_column'] ?? null) === 'description') { + $createPayload['description'] = $description !== '' ? $description : null; + } + if (($context['teacher_comment_column'] ?? null) === 'author_comment') { + $createPayload['author_comment'] = $description !== '' ? $description : null; + } + if (($context['teacher_file_column'] ?? null) === 'teacher_file') { + $createPayload['teacher_file'] = $teacherFile; + } + if (($context['teacher_file_column'] ?? null) === 'author_file') { + $createPayload['author_file'] = $teacherFile; + } + if (($context['teacher_filename_column'] ?? null) === 'teacher_filename') { + $createPayload['teacher_filename'] = $teacherFilename; + } + if (($context['teacher_filename_column'] ?? null) === 'author_filename') { + $createPayload['author_filename'] = $teacherFilename; + } + if ($context['has_previous_draft_id'] ?? false) { + $createPayload['previous_draft_id'] = $previousId; + } + + $draft = ExamDraft::query()->create($createPayload); return [ 'ok' => true, diff --git a/app/Services/Grading/GradingBelowSixtyService.php b/app/Services/Grading/GradingBelowSixtyService.php index e544e501..d774e371 100644 --- a/app/Services/Grading/GradingBelowSixtyService.php +++ b/app/Services/Grading/GradingBelowSixtyService.php @@ -2,10 +2,12 @@ namespace App\Services\Grading; +use App\Models\BelowSixtyDecision; use App\Models\CurrentFlag; use App\Models\ParentMeetingSchedule; use App\Models\ScoreComment; use App\Models\Student; +use App\Models\StudentDecision; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Event; use RuntimeException; @@ -258,6 +260,330 @@ class GradingBelowSixtyService ]); } + public function listDecisionRows(string $schoolYear): array + { + $scoreRows = DB::table('semester_scores as ss') + ->select([ + 's.id as student_id', + 's.school_id', + 's.firstname', + 's.lastname', + 's.age', + 'ss.class_section_id', + 'cs.class_section_name', + DB::raw('LOWER(TRIM(ss.semester)) as sem_key'), + 'ss.semester_score', + ]) + ->join('students as s', 's.id', '=', 'ss.student_id') + ->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'ss.class_section_id') + ->where('s.is_active', 1) + ->where('ss.school_year', $schoolYear) + ->whereRaw('LOWER(TRIM(ss.semester)) IN (?, ?)', ['fall', 'spring']) + ->whereNotNull('ss.semester_score') + ->orderBy('cs.class_section_name', 'ASC') + ->orderBy('s.lastname', 'ASC') + ->orderBy('s.firstname', 'ASC') + ->orderByDesc('ss.updated_at') + ->orderByDesc('ss.id') + ->get(); + + if ($scoreRows->isEmpty()) { + return []; + } + + $studentMap = []; + foreach ($scoreRows as $row) { + $studentId = (int) ($row->student_id ?? 0); + if ($studentId <= 0) { + continue; + } + + if (!isset($studentMap[$studentId])) { + $studentMap[$studentId] = [ + 'student_id' => $studentId, + 'school_id' => $row->school_id, + 'firstname' => $row->firstname, + 'lastname' => $row->lastname, + 'age' => $row->age, + 'class_section_id' => (int) ($row->class_section_id ?? 0), + 'class_section_name' => $row->class_section_name, + 'fall_score' => null, + 'spring_score' => null, + ]; + } + + $semesterKey = strtolower(trim((string) ($row->sem_key ?? ''))); + $score = is_numeric($row->semester_score ?? null) ? (float) $row->semester_score : null; + if ($score === null) { + continue; + } + + if ($semesterKey === 'fall' && $studentMap[$studentId]['fall_score'] === null) { + $studentMap[$studentId]['fall_score'] = $score; + } + + if ($semesterKey === 'spring' && $studentMap[$studentId]['spring_score'] === null) { + $studentMap[$studentId]['spring_score'] = $score; + } + } + + $studentIds = array_keys($studentMap); + if (empty($studentIds)) { + return []; + } + + $belowDecisionMap = BelowSixtyDecision::query() + ->where('school_year', $schoolYear) + ->where('semester', 'year') + ->whereIn('student_id', $studentIds) + ->get() + ->keyBy(static fn (BelowSixtyDecision $row) => (int) $row->student_id); + + $studentDecisionMap = StudentDecision::query() + ->where('school_year', $schoolYear) + ->whereIn('student_id', $studentIds) + ->get() + ->keyBy(static fn (StudentDecision $row) => (int) $row->student_id); + + $certificateRows = DB::table('certificate_records') + ->select('student_id', 'certificate_number', 'issued_at') + ->where('school_year', $schoolYear) + ->whereIn('student_id', $studentIds) + ->orderByDesc('issued_at') + ->get(); + + $certificateMap = []; + foreach ($certificateRows as $row) { + $studentId = (int) ($row->student_id ?? 0); + if ($studentId > 0 && !isset($certificateMap[$studentId])) { + $certificateMap[$studentId] = (string) ($row->certificate_number ?? ''); + } + } + + $rows = []; + foreach ($studentMap as $studentId => $info) { + $fall = $info['fall_score']; + $spring = $info['spring_score']; + + if ($fall === null || $spring === null) { + continue; + } + + $yearScore = round(($fall + $spring) / 2, 2); + if ($yearScore >= 60) { + continue; + } + + $belowDecision = $belowDecisionMap->get($studentId); + $studentDecision = $studentDecisionMap->get($studentId); + + if ($studentDecision !== null && is_numeric($studentDecision->year_score)) { + $yearScore = round((float) $studentDecision->year_score, 2); + } + + $rows[] = [ + 'student_id' => $studentId, + 'school_id' => $info['school_id'], + 'firstname' => $info['firstname'], + 'lastname' => $info['lastname'], + 'age' => $info['age'], + 'class_section_id' => $info['class_section_id'], + 'class_section_name' => $info['class_section_name'], + 'fall_score' => $fall, + 'spring_score' => $spring, + 'year_score' => $yearScore, + 'decision' => (string) ($belowDecision?->decision ?? ''), + 'decision_notes' => (string) ($belowDecision?->notes ?? ''), + 'consolidated_decision' => $studentDecision?->decision, + 'certificate_number' => $certificateMap[$studentId] ?? '', + ]; + } + + return $rows; + } + + public function saveDecision(int $studentId, string $schoolYear, string $decision, string $notes, ?int $userId): array + { + if ($studentId <= 0 || trim($schoolYear) === '') { + throw new RuntimeException('Missing student or school year.'); + } + + $decision = trim($decision); + $notes = trim($notes); + + $belowDecision = BelowSixtyDecision::query()->firstOrNew([ + 'student_id' => $studentId, + 'semester' => 'year', + 'school_year' => $schoolYear, + ]); + $belowDecision->decision = $decision !== '' ? $decision : null; + $belowDecision->notes = $notes !== '' ? $notes : null; + $belowDecision->decided_by = $userId; + $belowDecision->save(); + + $scoreRows = DB::table('semester_scores as ss') + ->select([ + DB::raw('LOWER(TRIM(ss.semester)) as sem_key'), + 'ss.semester_score', + 'cs.class_section_name', + ]) + ->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'ss.class_section_id') + ->where('ss.student_id', $studentId) + ->where('ss.school_year', $schoolYear) + ->whereRaw('LOWER(TRIM(ss.semester)) IN (?, ?)', ['fall', 'spring']) + ->whereNotNull('ss.semester_score') + ->orderByDesc('ss.updated_at') + ->orderByDesc('ss.id') + ->get(); + + $fallScore = null; + $springScore = null; + $classSectionName = null; + foreach ($scoreRows as $row) { + $semesterKey = strtolower(trim((string) ($row->sem_key ?? ''))); + $score = is_numeric($row->semester_score ?? null) ? (float) $row->semester_score : null; + if ($classSectionName === null && trim((string) ($row->class_section_name ?? '')) !== '') { + $classSectionName = (string) $row->class_section_name; + } + if ($score === null) { + continue; + } + if ($semesterKey === 'fall' && $fallScore === null) { + $fallScore = $score; + } + if ($semesterKey === 'spring' && $springScore === null) { + $springScore = $score; + } + } + + if ($classSectionName === null) { + $enrollment = DB::table('student_class as sc') + ->select('cs.class_section_name') + ->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'sc.class_section_id') + ->where('sc.student_id', $studentId) + ->where('sc.school_year', $schoolYear) + ->orderByDesc('sc.id') + ->first(); + $classSectionName = $enrollment?->class_section_name; + } + + $yearScore = null; + if ($fallScore !== null && $springScore !== null) { + $yearScore = round(($fallScore + $springScore) / 2, 2); + } elseif ($fallScore !== null) { + $yearScore = round($fallScore, 2); + } elseif ($springScore !== null) { + $yearScore = round($springScore, 2); + } + + $studentDecision = StudentDecision::query()->firstOrNew([ + 'student_id' => $studentId, + 'school_year' => $schoolYear, + ]); + $studentDecision->class_section_name = $classSectionName; + $studentDecision->year_score = $yearScore; + $studentDecision->decision = $decision !== '' ? $decision : null; + $studentDecision->source = $decision !== '' ? 'manual' : 'pending'; + $studentDecision->notes = $notes !== '' ? $notes : null; + $studentDecision->generated_by = $userId; + $studentDecision->save(); + + return [ + 'student_id' => $studentId, + 'school_year' => $schoolYear, + 'decision' => $decision, + 'notes' => $notes, + 'year_score' => $yearScore, + 'class_section_name' => $classSectionName, + ]; + } + + public function studentDecisionDetails(int $studentId, string $schoolYear): array + { + if ($studentId <= 0 || trim($schoolYear) === '') { + throw new RuntimeException('Missing student or school year.'); + } + + return [ + 'semesters' => $this->fetchAllSemestersForStudent($studentId, $schoolYear), + ]; + } + + public function decisionEmailContext(int $studentId, string $schoolYear): array + { + $context = $this->getBelowSixtyDecisionEmailContext($studentId, $schoolYear); + if (empty($context['student'])) { + throw new RuntimeException('Student not found.'); + } + + $decision = trim((string) ($context['decision_row']['decision'] ?? '')); + if ($decision === '') { + throw new RuntimeException('No saved decision found for this student. Save a decision first.'); + } + + $subject = 'Whole Year Academic Decision — ' + . (string) ($context['student_name'] ?? 'Student') + . ' (' . $schoolYear . ')'; + + return [ + 'student_id' => $studentId, + 'student_name' => (string) ($context['student_name'] ?? 'Student'), + 'class_section_name' => (string) ($context['class_section_name'] ?? ''), + 'school_year' => $schoolYear, + 'semester' => 'year', + 'decision' => $decision, + 'subject' => $subject, + 'html' => $this->buildDecisionEmailHtml($context), + 'year_score' => $context['year_score'] ?? null, + ]; + } + + public function sendDecisionEmail(int $studentId, string $schoolYear, ?string $subject, ?string $html): void + { + $context = $this->getBelowSixtyDecisionEmailContext($studentId, $schoolYear); + if (empty($context['student'])) { + throw new RuntimeException('Student not found.'); + } + + $decision = trim((string) ($context['decision_row']['decision'] ?? '')); + if ($decision === '') { + throw new RuntimeException('No saved decision found for this student.'); + } + + $resolvedSubject = trim((string) $subject); + if ($resolvedSubject === '') { + $resolvedSubject = 'Whole Year Academic Decision — ' + . (string) ($context['student_name'] ?? 'Student') + . ' (' . $schoolYear . ')'; + } + + $payload = [ + 'student_id' => $studentId, + 'student_name' => (string) ($context['student_name'] ?? 'Student'), + 'class_section_name' => (string) ($context['class_section_name'] ?? ''), + 'semester' => 'year', + 'school_year' => $schoolYear, + 'decision' => $decision, + 'notes' => (string) ($context['decision_row']['notes'] ?? ''), + 'subject' => $resolvedSubject, + 'scores' => [ + 'fall_score' => $context['fall_score'] ?? null, + 'spring_score' => $context['spring_score'] ?? null, + 'year_score' => $context['year_score'] ?? null, + ], + 'all_semesters' => $context['all_semesters'] ?? [], + ]; + + $resolvedHtml = trim((string) $html); + if ($resolvedHtml !== '') { + $payload['html'] = $resolvedHtml; + } else { + $payload['html'] = $this->buildDecisionEmailHtml($context); + } + + $this->sendEmail($payload); + } + private function fetchBelowSixtyEmailRow(int $studentId, string $schoolYear, string $semester): array { $semesterKey = strtolower(trim($semester)); @@ -303,6 +629,71 @@ class GradingBelowSixtyService return $row; } + private function fetchAllSemestersForStudent(int $studentId, string $schoolYear): array + { + $rows = DB::table('semester_scores as ss') + ->select([ + 'ss.semester', + 'cs.class_section_name', + 'ss.homework_avg', + 'ss.project_avg', + 'ss.participation_score', + DB::raw('COALESCE(ss.test_avg, ss.quiz_avg) as test_avg'), + 'ss.ptap_score', + 'ss.attendance_score', + 'ss.midterm_exam_score', + 'ss.final_exam_score', + 'ss.semester_score', + ]) + ->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'ss.class_section_id') + ->where('ss.student_id', $studentId) + ->where('ss.school_year', $schoolYear) + ->orderBy('ss.semester') + ->get(); + + $semesters = []; + foreach ($rows as $row) { + $semester = ucfirst(strtolower(trim((string) ($row->semester ?? '')))); + $semesters[$semester] = [ + 'semester' => $semester, + 'class_section_name' => $row->class_section_name, + 'homework_avg' => $row->homework_avg, + 'project_avg' => $row->project_avg, + 'participation_score' => $row->participation_score, + 'test_avg' => $row->test_avg, + 'ptap_score' => $row->ptap_score, + 'attendance_score' => $row->attendance_score, + 'midterm_exam_score' => $row->midterm_exam_score, + 'final_exam_score' => $row->final_exam_score, + 'semester_score' => $row->semester_score, + 'comments' => [], + ]; + } + + if (!empty($semesters)) { + $commentRows = DB::table('score_comments') + ->select('semester', 'score_type', 'comment', 'created_at') + ->where('student_id', $studentId) + ->where('school_year', $schoolYear) + ->whereNotNull('comment') + ->where('comment', '!=', '') + ->orderBy('semester') + ->orderBy('score_type') + ->orderByDesc('created_at') + ->get(); + + foreach ($commentRows as $row) { + $semester = ucfirst(strtolower(trim((string) ($row->semester ?? '')))); + $type = strtolower(trim((string) ($row->score_type ?? 'general'))); + if (isset($semesters[$semester]) && !isset($semesters[$semester]['comments'][$type])) { + $semesters[$semester]['comments'][$type] = (string) ($row->comment ?? ''); + } + } + } + + return array_values($semesters); + } + private function fetchBelowSixtyParentName(int $studentId): string { $parentName = 'Parent/Guardian'; @@ -328,6 +719,274 @@ class GradingBelowSixtyService return $parentName; } + private function getBelowSixtyDecisionEmailContext(int $studentId, string $schoolYear): array + { + $student = DB::table('students as s') + ->select(['s.id', 's.firstname', 's.lastname', 's.is_active']) + ->where('s.id', $studentId) + ->first(); + + if ($student === null) { + return [ + 'student' => null, + 'decision_row' => null, + 'student_name' => '', + 'class_section_name' => '', + 'fall_score' => null, + 'spring_score' => null, + 'year_score' => null, + 'all_semesters' => [], + ]; + } + + $studentName = trim((string) ($student->firstname ?? '') . ' ' . (string) ($student->lastname ?? '')); + if ($studentName === '') { + $studentName = 'Student'; + } + + $decisionRow = BelowSixtyDecision::query() + ->where('student_id', $studentId) + ->where('school_year', $schoolYear) + ->where('semester', 'year') + ->first(); + + $scoreRows = DB::table('semester_scores as ss') + ->select([ + DB::raw('LOWER(TRIM(ss.semester)) as sem_key'), + 'ss.semester', + 'ss.semester_score', + 'ss.class_section_id', + 'cs.class_section_name', + ]) + ->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'ss.class_section_id') + ->where('ss.student_id', $studentId) + ->where('ss.school_year', $schoolYear) + ->whereRaw('LOWER(TRIM(ss.semester)) IN (?, ?)', ['fall', 'spring']) + ->whereNotNull('ss.semester_score') + ->orderByDesc('ss.updated_at') + ->orderByDesc('ss.id') + ->get(); + + $fallScore = null; + $springScore = null; + $classSectionName = ''; + foreach ($scoreRows as $row) { + $semesterKey = strtolower(trim((string) ($row->sem_key ?? ''))); + $score = is_numeric($row->semester_score ?? null) ? (float) $row->semester_score : null; + if ($classSectionName === '' && trim((string) ($row->class_section_name ?? '')) !== '') { + $classSectionName = (string) $row->class_section_name; + } + if ($score === null) { + continue; + } + if ($semesterKey === 'fall' && $fallScore === null) { + $fallScore = $score; + } + if ($semesterKey === 'spring' && $springScore === null) { + $springScore = $score; + } + } + + if ($classSectionName === '') { + $enrollment = DB::table('student_class as sc') + ->select('cs.class_section_name') + ->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'sc.class_section_id') + ->where('sc.student_id', $studentId) + ->where('sc.school_year', $schoolYear) + ->orderByDesc('sc.id') + ->first(); + $classSectionName = (string) ($enrollment?->class_section_name ?? ''); + } + + $yearScore = null; + if ($fallScore !== null && $springScore !== null) { + $yearScore = round(($fallScore + $springScore) / 2, 2); + } elseif ($fallScore !== null) { + $yearScore = round($fallScore, 2); + } elseif ($springScore !== null) { + $yearScore = round($springScore, 2); + } + + return [ + 'student' => (array) $student, + 'decision_row' => $decisionRow?->toArray(), + 'student_name' => $studentName, + 'class_section_name' => $classSectionName, + 'school_year' => $schoolYear, + 'fall_score' => $fallScore, + 'spring_score' => $springScore, + 'year_score' => $yearScore, + 'all_semesters' => $this->fetchAllSemestersForStudent($studentId, $schoolYear), + ]; + } + + private function buildDecisionEmailHtml(array $context): string + { + $studentName = (string) ($context['student_name'] ?? 'Student'); + $schoolYear = (string) ($context['school_year'] ?? ''); + $classSectionName = (string) ($context['class_section_name'] ?? ''); + $decision = trim((string) ($context['decision_row']['decision'] ?? '')); + $notes = trim((string) ($context['decision_row']['notes'] ?? '')); + $fallScore = $context['fall_score'] ?? null; + $springScore = $context['spring_score'] ?? null; + $yearScore = $context['year_score'] ?? null; + + $fallText = $fallScore !== null ? number_format((float) $fallScore, 2) : 'N/A'; + $springText = $springScore !== null ? number_format((float) $springScore, 2) : 'N/A'; + $yearText = $yearScore !== null ? number_format((float) $yearScore, 2) : 'N/A'; + + $html = ' +

Dear Parent/Guardian,

+

+ This message is regarding ' . e($studentName) . ' + for the ' . e($schoolYear) . ' school year. +

+

+ Class: ' . e($classSectionName !== '' ? $classSectionName : 'N/A') . '
+ Fall Score: ' . e($fallText) . '
+ Spring Score: ' . e($springText) . '
+ Whole-Year Score: ' . e($yearText) . '
+ Decision: ' . e($decision) . ' +

'; + + if ($notes !== '') { + $html .= ' +

+ Decision Notes:
+ ' . nl2br(e($notes)) . ' +

'; + } + + $html .= $this->buildDecisionEmailDetailsHtml($context['all_semesters'] ?? []); + + $html .= ' +

Please contact the school administration if you have any questions.

+

Regards,
Al Rahma Sunday School

'; + + return $html; + } + + private function buildDecisionEmailDetailsHtml(array $semesters): string + { + if (empty($semesters)) { + return ' +

Detailed Scores and Comments

+

No detailed score data found.

'; + } + + $scoreLabels = [ + 'homework_avg' => 'Homework Avg', + 'project_avg' => 'Project Avg', + 'participation_score' => 'Participation', + 'test_avg' => 'Test Avg', + 'ptap_score' => 'PTAP Score', + 'attendance_score' => 'Attendance', + 'midterm_exam_score' => 'Midterm Score', + 'final_exam_score' => 'Final Exam', + 'semester_score' => 'Semester Score', + ]; + + $commentTypeLabels = [ + 'general' => 'General', + 'attendance' => 'Attendance', + 'attendance_comment' => 'Attendance', + 'midterm' => 'Midterm', + 'final' => 'Final Exam', + 'ptap' => 'PTAP', + ]; + + $html = ' +
+

Detailed Scores and Comments

'; + + foreach ($semesters as $semester) { + $semesterName = trim((string) ($semester['semester'] ?? '')); + if ($semesterName === '') { + $semesterName = 'Semester'; + } + + $classSectionName = trim((string) ($semester['class_section_name'] ?? '')); + $html .= ' +

+ ' . e($semesterName) . ' Semester'; + if ($classSectionName !== '') { + $html .= ' — ' . e($classSectionName); + } + $html .= ' +

+ + + + + + + + '; + + $hasScoreRow = false; + foreach ($scoreLabels as $key => $label) { + if (!array_key_exists($key, $semester)) { + continue; + } + $value = $semester[$key]; + if ($value === null || $value === '') { + continue; + } + $scoreText = is_numeric($value) ? number_format((float) $value, 2) : (string) $value; + $fontWeight = $key === 'semester_score' ? 'font-weight:bold;' : ''; + $html .= ' + + + + '; + $hasScoreRow = true; + } + + if (!$hasScoreRow) { + $html .= ' + + + '; + } + + $html .= ' + +
ItemScore
' . e($label) . '' . e($scoreText) . '
No scores recorded.
'; + + $comments = is_array($semester['comments'] ?? null) ? $semester['comments'] : []; + if (!empty($comments)) { + $deduped = []; + $seen = []; + foreach ($comments as $type => $text) { + $text = trim((string) $text); + if ($text === '') { + continue; + } + $label = $commentTypeLabels[$type] ?? (string) $type; + $hash = $label . '|' . $text; + if (isset($seen[$hash])) { + continue; + } + $seen[$hash] = true; + $deduped[] = ['label' => $label, 'text' => $text]; + } + + if (!empty($deduped)) { + $html .= '

Comments

'; + foreach ($deduped as $comment) { + $html .= ' +

+ ' . e($comment['label']) . ':
+ ' . nl2br(e($comment['text'])) . ' +

'; + } + } + } + } + + return $html; + } + private function buildBelowSixtySubject(string $studentName, string $semester, string $schoolYear): string { $subject = 'Student Performance Alert'; diff --git a/config/database.php b/config/database.php index 53dcae02..6db50c84 100644 --- a/config/database.php +++ b/config/database.php @@ -2,6 +2,13 @@ use Illuminate\Support\Str; +$mysqlSslCaOption = null; +if (extension_loaded('pdo_mysql')) { + $mysqlSslCaOption = defined('Pdo\\Mysql::ATTR_SSL_CA') + ? constant('Pdo\\Mysql::ATTR_SSL_CA') + : (defined('PDO::MYSQL_ATTR_SSL_CA') ? constant('PDO::MYSQL_ATTR_SSL_CA') : null); +} + return [ /* @@ -58,8 +65,8 @@ return [ 'prefix_indexes' => true, 'strict' => true, 'engine' => null, - 'options' => extension_loaded('pdo_mysql') ? array_filter([ - PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), + 'options' => $mysqlSslCaOption !== null ? array_filter([ + $mysqlSslCaOption => env('MYSQL_ATTR_SSL_CA'), ]) : [], ], @@ -78,8 +85,8 @@ return [ 'prefix_indexes' => true, 'strict' => true, 'engine' => null, - 'options' => extension_loaded('pdo_mysql') ? array_filter([ - PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), + 'options' => $mysqlSslCaOption !== null ? array_filter([ + $mysqlSslCaOption => env('MYSQL_ATTR_SSL_CA'), ]) : [], ], diff --git a/database/migrations/0001_01_01_000000_create_users_table.php b/database/migrations/0001_01_01_000000_create_users_table.php index 05fb5d9e..eaafe5d6 100644 --- a/database/migrations/0001_01_01_000000_create_users_table.php +++ b/database/migrations/0001_01_01_000000_create_users_table.php @@ -11,30 +11,9 @@ return new class extends Migration */ public function up(): void { - Schema::create('users', function (Blueprint $table) { - $table->id(); - $table->string('name'); - $table->string('email')->unique(); - $table->timestamp('email_verified_at')->nullable(); - $table->string('password'); - $table->rememberToken(); - $table->timestamps(); - }); - - Schema::create('password_reset_tokens', function (Blueprint $table) { - $table->string('email')->primary(); - $table->string('token'); - $table->timestamp('created_at')->nullable(); - }); - - Schema::create('sessions', function (Blueprint $table) { - $table->string('id')->primary(); - $table->foreignId('user_id')->nullable()->index(); - $table->string('ip_address', 45)->nullable(); - $table->text('user_agent')->nullable(); - $table->longText('payload'); - $table->integer('last_activity')->index(); - }); + // This project has authoritative legacy-schema migrations for users, + // password reset tables, and sessions. Keep the starter migration inert + // so migrate:fresh follows the provided school_api schema instead. } /** @@ -42,8 +21,6 @@ return new class extends Migration */ public function down(): void { - Schema::dropIfExists('users'); - Schema::dropIfExists('password_reset_tokens'); - Schema::dropIfExists('sessions'); + // No-op: the dump-aligned migrations own these tables. } }; diff --git a/database/migrations/0001_01_01_000001_create_cache_table.php b/database/migrations/0001_01_01_000001_create_cache_table.php index b9c106be..ac17262f 100644 --- a/database/migrations/0001_01_01_000001_create_cache_table.php +++ b/database/migrations/0001_01_01_000001_create_cache_table.php @@ -11,17 +11,8 @@ return new class extends Migration */ public function up(): void { - Schema::create('cache', function (Blueprint $table) { - $table->string('key')->primary(); - $table->mediumText('value'); - $table->integer('expiration'); - }); - - Schema::create('cache_locks', function (Blueprint $table) { - $table->string('key')->primary(); - $table->string('owner'); - $table->integer('expiration'); - }); + // The dump-aligned cache migration runs later with the exact schema and + // indexes from school_api.sql. Leave this starter migration inert. } /** @@ -29,7 +20,6 @@ return new class extends Migration */ public function down(): void { - Schema::dropIfExists('cache'); - Schema::dropIfExists('cache_locks'); + // No-op: the dump-aligned migration owns these tables. } }; diff --git a/database/migrations/0001_01_01_000002_create_jobs_table.php b/database/migrations/0001_01_01_000002_create_jobs_table.php index 425e7058..3b3bd318 100644 --- a/database/migrations/0001_01_01_000002_create_jobs_table.php +++ b/database/migrations/0001_01_01_000002_create_jobs_table.php @@ -11,38 +11,8 @@ return new class extends Migration */ public function up(): void { - Schema::create('jobs', function (Blueprint $table) { - $table->id(); - $table->string('queue')->index(); - $table->longText('payload'); - $table->unsignedTinyInteger('attempts'); - $table->unsignedInteger('reserved_at')->nullable(); - $table->unsignedInteger('available_at'); - $table->unsignedInteger('created_at'); - }); - - Schema::create('job_batches', function (Blueprint $table) { - $table->string('id')->primary(); - $table->string('name'); - $table->integer('total_jobs'); - $table->integer('pending_jobs'); - $table->integer('failed_jobs'); - $table->longText('failed_job_ids'); - $table->mediumText('options')->nullable(); - $table->integer('cancelled_at')->nullable(); - $table->integer('created_at'); - $table->integer('finished_at')->nullable(); - }); - - Schema::create('failed_jobs', function (Blueprint $table) { - $table->id(); - $table->string('uuid')->unique(); - $table->text('connection'); - $table->text('queue'); - $table->longText('payload'); - $table->longText('exception'); - $table->timestamp('failed_at')->useCurrent(); - }); + // school_api.sql does not define Laravel queue tables. Keep this starter + // migration inert so fresh migrations stay aligned with the provided dump. } /** @@ -50,8 +20,6 @@ return new class extends Migration */ public function down(): void { - Schema::dropIfExists('jobs'); - Schema::dropIfExists('job_batches'); - Schema::dropIfExists('failed_jobs'); + // No-op: this migration does not create any tables. } }; diff --git a/database/migrations/2026_06_04_120000_create_certificate_records_table.php b/database/migrations/2026_06_04_120000_create_certificate_records_table.php new file mode 100644 index 00000000..a5593ded --- /dev/null +++ b/database/migrations/2026_06_04_120000_create_certificate_records_table.php @@ -0,0 +1,51 @@ +group(function () { Route::put('placement/batch/{batchId}', [GradingApiController::class, 'updatePlacementBatch']); Route::get('below-sixty', [GradingApiController::class, 'belowSixty']); + Route::get('below-sixty/decisions', [GradingApiController::class, 'belowSixtyDecisions']); + Route::post('below-sixty/decisions', [GradingApiController::class, 'saveBelowSixtyDecision']); + Route::get('below-sixty/decisions/details', [GradingApiController::class, 'belowSixtyDecisionDetails']); + Route::get('below-sixty/decisions/email', [GradingApiController::class, 'belowSixtyDecisionEmail']); + Route::post('below-sixty/decisions/email', [GradingApiController::class, 'sendBelowSixtyDecisionEmail']); Route::get('below-sixty/email', [GradingApiController::class, 'belowSixtyEmail']); Route::post('below-sixty/email', [GradingApiController::class, 'sendBelowSixtyEmail']); Route::post('below-sixty/status', [GradingApiController::class, 'updateBelowSixtyStatus']);