db = \Config\Database::connect(); $this->userModel = new UserModel(); $this->studentModel = new StudentModel(); $this->classSectionModel = new ClassSectionModel(); $this->studentClassModel = new StudentClassModel(); $this->configModel = new ConfigurationModel(); $this->enrollmentModel = new EnrollmentModel(); $this->chargesModel = new EventChargesModel(); $this->eventModel = new EventModel(); $this->eventController = new \App\Controllers\View\InvoiceController(); $this->emergencyContactModel = new EmergencyContactModel(); $this->medicalConditionModel = new StudentMedicalConditionModel(); $this->allergyModel = new StudentAllergyModel(); $this->authorizedUsersModel = new AuthorizedUserModel(); $this->policyAcceptanceModel = new ParentPolicyAcceptanceModel(); $this->parentAccountService = new ParentAccountService($this->db, $this->userModel, $this->authorizedUsersModel); $this->parentAttendanceService = new ParentAttendanceService($this->db); $this->parentPaymentService = new ParentPaymentService($this->db); $this->parentEnrollmentService = new ParentEnrollmentService( $this->db, $this->userModel, $this->studentModel, $this->studentClassModel, $this->configModel, $this->medicalConditionModel, $this->allergyModel, $this->policyAcceptanceModel, $this->eventController ); $this->parentEventParticipationService = new ParentEventParticipationService( $this->chargesModel, $this->eventModel, $this->enrollmentModel, $this->eventController ); $this->parentRegistrationService = new ParentRegistrationService( $this->db, $this->userModel, $this->studentModel, $this->enrollmentModel, $this->emergencyContactModel, $this->medicalConditionModel, $this->allergyModel ); $this->ageDateRefernce = $this->configModel->getConfig('date_age_reference'); $this->lastDayOfRegistration = $this->configModel->getConfig('enrollment_deadline'); $this->schoolStartDate = $this->configModel->getConfig('first_day_of_school') ?: $this->configModel->getConfig('fall_semester_start'); $this->withdrawalDeadline = $this->configModel->getConfig('refund_deadline'); $this->schoolYear = $this->configModel->getConfig('school_year'); $this->semester = getSemester(); $this->dateAgeReference = $this->configModel->getConfig('date_age_reference'); $this->maxChilds = (int) $this->configModel->getConfig('max_kids') ?? 0; $this->maxEmergency = (int) $this->configModel->getConfig('max_emergency') ?? 0; helper(['url', 'form']); } private function parentEnrollmentService(): ParentEnrollmentService { if ($this->parentEnrollmentService !== null) { return $this->parentEnrollmentService; } $db = isset($this->db) ? $this->db : \Config\Database::connect(); $this->parentEnrollmentService = new ParentEnrollmentService( $db, isset($this->userModel) ? $this->userModel : new UserModel(), isset($this->studentModel) ? $this->studentModel : new StudentModel(), isset($this->studentClassModel) ? $this->studentClassModel : new StudentClassModel(), isset($this->configModel) ? $this->configModel : new ConfigurationModel(), isset($this->medicalConditionModel) ? $this->medicalConditionModel : new StudentMedicalConditionModel(), isset($this->allergyModel) ? $this->allergyModel : new StudentAllergyModel(), isset($this->policyAcceptanceModel) ? $this->policyAcceptanceModel : new ParentPolicyAcceptanceModel(), isset($this->eventController) ? $this->eventController : null ); return $this->parentEnrollmentService; } private function parentRegistrationService(): ParentRegistrationService { if ($this->parentRegistrationService !== null) { return $this->parentRegistrationService; } $db = isset($this->db) ? $this->db : \Config\Database::connect(); $this->parentRegistrationService = new ParentRegistrationService( $db, isset($this->userModel) ? $this->userModel : new UserModel(), isset($this->studentModel) ? $this->studentModel : new StudentModel(), isset($this->enrollmentModel) ? $this->enrollmentModel : new EnrollmentModel(), isset($this->emergencyContactModel) ? $this->emergencyContactModel : new EmergencyContactModel(), isset($this->medicalConditionModel) ? $this->medicalConditionModel : new StudentMedicalConditionModel(), isset($this->allergyModel) ? $this->allergyModel : new StudentAllergyModel() ); return $this->parentRegistrationService; } public function index() { // Retrieve all parents from the database where role is parent $parents = $this->userModel->where('role', 'parent')->findAll(); // Pass the parents to the view return view('administrator/parent', ['parents' => $parents]); } public function create() { // Show the form to create a new parent return view('administrator/create_parent'); } public function store() { // Handle the form submission to create a new parent $data = [ 'firstname' => $this->request->getPost('firstname'), 'lastname' => $this->request->getPost('lastname'), 'email' => strtolower($this->request->getPost('email')), 'password' => password_hash($this->request->getPost('password'), PASSWORD_DEFAULT), 'role' => 'parent', ]; $this->userModel->insert($data); return redirect()->to('/administrator/parent'); } public function edit($id) { // Retrieve the parent details to edit $parent = $this->userModel->find($id); return view('administrator/edit_parent', ['parent' => $parent]); } public function update($id) { // Handle the form submission to update an existing parent $data = [ 'firstname' => $this->request->getPost('firstname'), 'lastname' => $this->request->getPost('lastname'), 'email' => strtolower($this->request->getPost('email')), ]; if ($this->request->getPost('password')) { $data['password'] = password_hash($this->request->getPost('password'), PASSWORD_DEFAULT); } $this->userModel->update($id, $data); return redirect()->to('/administrator/parent'); } public function destroy($id) { // Delete the parent $this->userModel->delete($id); return redirect()->to('/administrator/parent'); } public function attendance() { try { // Get parent ID from session $parentId = session()->get('user_id'); if (!$parentId) { return redirect()->back()->with('error', 'Parent session not found.'); } $selectedYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? '')); $attendanceResults = $this->parentAttendanceService->attendanceForParent((int) $parentId, $selectedYear); // If no records found, set a flag to show message in the view if (empty($attendanceResults)) { return view('/parent/attendance', [ 'attendance' => null, 'selectedYear' => $selectedYear, 'selectedSemester' => null, //'error' => 'No attendance records found for the selected school year and semester.' ]); } // Return view with attendance results return view('/parent/attendance', [ 'attendance' => $attendanceResults, 'selectedYear' => $selectedYear, 'selectedSemester' => null, 'error' => null ]); } catch (Exception $e) { log_message('error', 'Failed to retrieve attendance data: ' . $e->getMessage()); return view('/parent/attendance', [ 'attendance' => null, 'selectedYear' => $selectedYear ?? null, 'selectedSemester' => null, 'error' => 'Failed to retrieve attendance data. Please try again later.' ]); } } public function viewPayments() { $parentId = session()->get('user_id'); // Log the user ID for debugging log_message('info', 'Logged in user ID: ' . $parentId); if (!$parentId) { log_message('error', 'No user ID found in session.'); return view('/parent/payment', ['invoices' => [], 'error' => 'No user ID found in session.']); } $invoices = $this->parentPaymentService->invoicesForParent((int) $parentId); // Log the query and results for debugging log_message('info', 'invoices: ' . print_r($invoices, true)); if (empty($invoices)) { log_message('info', 'No invoices found for user ID: ' . $parentId); } else { log_message('info', count($invoices) . ' payment(s) found for user ID: ' . $parentId); } // Pass the data to the view return view('/parent/payment', [ 'invoices' => $invoices ]); } public function enrollClasses() { try { if (!$this->schoolYear) { log_message('error', 'Current school year not found in configuration.'); return redirect()->back()->with('error', 'Configuration error: School year missing.'); } $context = $this->resolveSchoolYearContext(); $selectedYear = $context->yearName(); $isEditable = ! $context->isReadonly(); $parentId = session()->get('user_id'); if (!$parentId) { log_message('error', 'User ID not found in session.'); return redirect()->back()->with('error', 'User session error. Please log in again.'); } $result = $this->parentEnrollmentService()->enrollClassesData( (int) $parentId, $selectedYear, $isEditable, $this->withdrawalDeadline, $this->lastDayOfRegistration, $this->schoolStartDate, (int) $this->request->getGet('start'), trim((string) $this->request->getGet('students')) ); if (empty($result['ok'])) { if (($result['code'] ?? '') === 'no_students') { return redirect()->to('/no-kids')->with('error', (string) ($result['message'] ?? 'No students found.')); } return redirect()->back()->with('error', (string) ($result['message'] ?? 'Unable to load enrollment.')); } return view('/parent/enroll_classes', $result['data']); } catch (Exception $e) { log_message('error', 'An error occurred in enrollClasses: ' . $e->getMessage()); return view('errors/html/error_500'); } } public function noKids() { return view('/parent/no_kids_registred'); } public function enrollClassesHandler() { // Retrieve enrollment and withdrawal data from the POST request $enroll = $this->request->getPost('enroll'); // Selected students for enrollment $withdraw = $this->request->getPost('withdraw'); // Selected students for withdrawal $parentId = session()->get('user_id'); // Parent ID from session if (!empty($enroll)) { $parent = $this->userModel->find((int) $parentId); $selectedYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? '')); $hasAcceptedPolicy = $this->hasAcceptedPolicyForYear((int) $parentId, $selectedYear); $acceptedOnSubmit = (string) ($this->request->getPost('accept_school_policy') ?? '') === '1'; if (!$hasAcceptedPolicy && !$acceptedOnSubmit) { return redirect()->back()->withInput()->with('error', 'You must read and accept the school policy before enrolling students.'); } if (!$hasAcceptedPolicy && $acceptedOnSubmit) { $this->recordPolicyAcceptance((int) $parentId, $selectedYear, 'returning_parent_enrollment'); $this->userModel->update((int) $parentId, [ 'accept_school_policy' => 1, 'updated_at' => utc_now(), ]); } } if ($this->lastDayOfRegistration && local_date(utc_now(), 'Y-m-d') > date('Y-m-d', strtotime($this->lastDayOfRegistration))) { if (!empty($enroll)) { return redirect()->back()->with('error', 'Enrollment deadline has passed. New enrollments are not allowed.'); } } // Ensure there are students selected for either enrollment or withdrawal if (empty($enroll) && empty($withdraw)) { return redirect()->back()->with('error', 'No students selected for enrollment or withdrawal.'); } // Handle enrollments $studentData = []; $enrollmentResultMessages = []; $invoiceResultMessage = null; $invoiceErrorMessage = null; if (!empty($enroll)) { $selectedYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? '')); $previousSchoolYear = $this->previousSchoolYearName($selectedYear); if ($previousSchoolYear === null) { return redirect()->back()->withInput()->with('error', 'Enrollment cannot be submitted because the closing school year could not be determined.'); } $parent = $this->userModel->find((int) $parentId); if (! is_array($parent) || ($parent['user_type'] ?? '') !== 'primary') { return redirect()->back()->withInput()->with('error', 'Only primary parents can enroll students.'); } $transitionService = service('enrollmentTransition'); $submittedStudentIds = array_values(array_unique(array_filter(array_map('intval', (array) $enroll), static fn (int $id): bool => $id > 0))); $evaluations = []; $errors = []; $submittedEnrollmentContexts = []; $studentInfoErrors = $this->updateEnrollmentStudentInfo($submittedStudentIds, (int) $parentId, $selectedYear); if ($studentInfoErrors !== []) { return redirect()->back()->withInput()->with('error', implode(' ', $studentInfoErrors)); } $parentContactErrors = $this->updateEnrollmentParentContact((int) $parentId); if ($parentContactErrors !== []) { return redirect()->back()->withInput()->with('error', implode(' ', $parentContactErrors)); } foreach ($submittedStudentIds as $studentId) { try { $evaluation = $transitionService->evaluateForParent((int) $parentId, $studentId, $previousSchoolYear, $selectedYear, 'parent'); } catch (Throwable $e) { log_message('error', 'Parent enrollment eligibility evaluation failed for student {studentId}: {message}', [ 'studentId' => $studentId, 'message' => $e->getMessage(), ]); $errors[] = 'Student ID ' . $studentId . ': enrollment eligibility could not be evaluated. Please contact administration.'; continue; } $studentInfo = $this->studentModel->find($studentId); if (! is_array($studentInfo)) { $errors[] = 'Student ID ' . $studentId . ': student record was not found.'; continue; } $studentName = trim((string) ($studentInfo['firstname'] ?? '') . ' ' . (string) ($studentInfo['lastname'] ?? '')) ?: 'Student ID ' . $studentId; if (empty($evaluation['can_enroll']) && empty($evaluation['parent_enrollment_allowed'])) { $transitionService->logEnrollmentBlock($evaluation, 'parent_enroll_submit', (int) $parentId, (int) $parentId); $messages = array_values(array_filter(array_map('trim', array_map('strval', $evaluation['blockers'] ?? [])))); $codes = array_values(array_filter(array_map('strval', array_merge($evaluation['blocking_rule_codes'] ?? [], $evaluation['review_rule_codes'] ?? [])))); $errors[] = $studentName . ': ' . ($messages !== [] ? implode(' ', $messages) : 'Enrollment is not currently allowed' . ($codes !== [] ? ' (' . implode(', ', $codes) . ')' : '') . '.'); continue; } if (! $this->studentModel->getStudentSchoolIdByStudentId($studentId)) { $errors[] = $studentName . ': Student school ID not found.'; continue; } $evaluations[$studentId] = $evaluation; } if ($errors !== []) { return redirect()->back()->withInput()->with('error', implode(' ', $errors)); } $this->db->transStart(); $enrollmentStatusService = \Config\Services::enrollmentStatus(false); foreach ($enroll as $studentId) { $studentId = (int) $studentId; if (! isset($evaluations[$studentId])) { continue; } $evaluation = $evaluations[$studentId]; // Get student full name (supports both string return or array with firstname/lastname) $studentInfo = $this->studentModel->getFullNameById($studentId); // Save student info into $studentData $studentData[$studentId] = $studentInfo; // raw return from getFullName() // Check if the student already has an enrollment record for the current school year and semester $existingEnrollment = $this->enrollmentModel ->where('student_id', $studentId) ->where('school_year', $selectedYear) ->where('semester', $this->semester) ->get() ->getRowArray(); $studentName = trim((string) ($studentData[$studentId]['firstname'] ?? '') . ' ' . (string) ($studentData[$studentId]['lastname'] ?? '')) ?: 'Student ID ' . $studentId; $isReturningReEnrollment = $this->isReturningReEnrollmentStudent((int)$studentId, $selectedYear); $isNewForSelectedYear = ! $isReturningReEnrollment && service('studentYearStatus')->isNew($studentId, $selectedYear); $submittedEnrollmentContexts[$studentId] = $isNewForSelectedYear ? 'first_enrollment' : 're_enrollment'; $targetEnrollmentStatus = $isNewForSelectedYear ? 'admission under review' : 'payment pending'; $targetAdmissionStatus = $isNewForSelectedYear ? 'pending' : 'accepted'; if ($existingEnrollment) { $update = $this->enrollmentPayloadFromEvaluation($evaluation, [ 'is_withdrawn' => 0, 'withdrawal_date' => null, 'enrollment_status' => $targetEnrollmentStatus, 'admission_status' => $targetAdmissionStatus, 'updated_at' => utc_now(), ]); if ($existingEnrollment['is_withdrawn'] == 1) { // Reactivate the enrollment if the student was previously withdrawn $enrollmentStatusService->upsertStatus(array_merge($update, [ 'id' => (int) $existingEnrollment['id'], 'student_id' => $studentId, 'parent_id' => $parentId, 'school_year' => $selectedYear, 'semester' => $this->semester, ]), (int) $parentId, 'parent_re_enrollment_submitted'); log_message('info', "{$studentData[$studentId]['firstname']} {$studentData[$studentId]['lastname']} (ID $studentId) has been re-enrolled in enrollment ID {$existingEnrollment['id']}."); // Apply promotion-based class placement for the upcoming year $this->applyPromotionAssignment((int)$studentId, $selectedYear, $isReturningReEnrollment); } else { $currentStatus = (string) ($existingEnrollment['enrollment_status'] ?? ''); if ($currentStatus === 'enrolled') { $update['admission_status'] = 'accepted'; } $enrollmentStatusService->upsertStatus(array_merge($update, [ 'id' => (int) $existingEnrollment['id'], 'student_id' => $studentId, 'parent_id' => $parentId, 'school_year' => $selectedYear, 'semester' => $this->semester, ]), (int) $parentId, 'parent_enrollment_submitted'); log_message('info', "{$studentData[$studentId]['firstname']} {$studentData[$studentId]['lastname']} (ID $studentId) is already actively enrolled."); $this->applyPromotionAssignment((int)$studentId, $selectedYear, $isReturningReEnrollment); } $enrollmentId = (int) $existingEnrollment['id']; if (! empty($evaluation['admin_exception']['id'])) { $transitionService->markExceptionUsed((int) $evaluation['admin_exception']['id'], $enrollmentId); } $transitionService->auditEnrollmentDecision( $studentId, $selectedYear, $previousSchoolYear, ! empty($evaluation['admin_exception']) ? 'parent_enrollment_submitted_with_exception' : 'parent_enrollment_submitted', (int) $parentId, $existingEnrollment, $this->enrollmentAuditPayload($update, $evaluation), implode(' ', array_map('strval', $evaluation['rule_codes'] ?? [])) ); $enrollmentResultMessages[] = $studentName . ': ' . $targetEnrollmentStatus; } else { // If no enrollment record exists, insert a new enrollment record $payload = $this->enrollmentPayloadFromEvaluation($evaluation, [ 'student_id' => $studentId, 'parent_id' => $parentId, 'school_year' => $selectedYear, 'semester' => $this->semester, 'enrollment_date' => local_date(utc_now(), 'Y-m-d'), 'is_withdrawn' => 0, 'enrollment_status' => $targetEnrollmentStatus, 'admission_status' => $targetAdmissionStatus, 'created_at' => utc_now() ]); $result = $enrollmentStatusService->upsertStatus($payload, (int) $parentId, 'parent_enrollment_submitted'); if ((int) ($result['id'] ?? 0) <= 0) { $this->db->transRollback(); return redirect()->back()->withInput()->with('error', $studentName . ': Unable to save enrollment.'); } else { log_message('info', "{$studentData[$studentId]['firstname']} {$studentData[$studentId]['lastname']} (ID $studentId) has been newly enrolled."); // Apply promotion-based class placement for the upcoming year $this->applyPromotionAssignment((int)$studentId, $selectedYear, $isReturningReEnrollment); } $enrollmentId = (int) $result['id']; if (! empty($evaluation['admin_exception']['id'])) { $transitionService->markExceptionUsed((int) $evaluation['admin_exception']['id'], $enrollmentId); } $transitionService->auditEnrollmentDecision( $studentId, $selectedYear, $previousSchoolYear, ! empty($evaluation['admin_exception']) ? 'parent_enrollment_submitted_with_exception' : 'parent_enrollment_submitted', (int) $parentId, null, $this->enrollmentAuditPayload($payload, $evaluation), implode(' ', array_map('strval', $evaluation['rule_codes'] ?? [])) ); $enrollmentResultMessages[] = $studentName . ': ' . $targetEnrollmentStatus; } } $this->db->transComplete(); if (! $this->db->transStatus()) { return redirect()->back()->withInput()->with('error', 'A database error occurred while submitting enrollment.'); } } // $studentData now holds info for all students processed // Handle withdrawals $withdrawalResultMessages = []; $withdrawalErrors = []; if (!empty($withdraw)) { foreach ($withdraw as $studentId) { $studentId = (int) $studentId; $enrollment = $this->enrollmentModel ->where('student_id', $studentId) ->where('school_year', $this->schoolYear) ->where('semester', $this->semester) ->where('is_withdrawn', 0) // Only withdraw active enrollments ->get() ->getRowArray(); if ($enrollment !== null) { $withdrawalRequestDate = local_date(utc_now(), 'Y-m-d'); service('withdrawalFinancial')->requestWithdrawal( (int) $enrollment['id'], $withdrawalRequestDate, (int) $parentId ); log_message('info', "Student ID $studentId has been withdrawn from enrollment ID {$enrollment['id']}."); $withdrawalResultMessages[] = 'Student ID ' . $studentId . ': withdraw under review'; } else { log_message('error', "No active enrollment found for student ID $studentId."); $withdrawalErrors[] = 'Student ID ' . $studentId . ': no active enrollment was found.'; } } } if (!empty($enroll) && empty($withdraw)) { $invoiceResult = $this->generateInvoiceForParentEnrollment((int) $parentId); if (! empty($invoiceResult['ok'])) { $invoiceResultMessage = (string) ($invoiceResult['message'] ?? 'Invoice generated.'); } else { $invoiceErrorMessage = (string) ($invoiceResult['message'] ?? 'Enrollment was submitted, but the invoice could not be generated.'); } } $parentData = $this->userModel->getUserInfoById($parentId); $parentData['user_id'] = $parentId; // Redirect to the success page after processing enrollment and withdrawal if (!empty($withdraw)) { // Redirect to withdrawal success page if there are withdrawals //parent/enroll_classes $redirect = redirect()->to('/parent/enroll_classes'); $successParts = []; $errorParts = []; if ($withdrawalResultMessages !== []) { $successParts[] = 'Withdrawal request submitted. ' . implode(' ', $withdrawalResultMessages); } if ($withdrawalErrors !== []) { $errorParts[] = 'Some withdrawal requests failed. ' . implode(' ', $withdrawalErrors); } if ($successParts !== []) { $redirect = $redirect->with('success', implode(' ', $successParts)); } if ($errorParts !== []) { $redirect = $redirect->with('error', implode(' ', $errorParts)); } return $redirect; } else { // Redirect to enrollment success page if there are enrollments $contextValues = array_values(array_unique($submittedEnrollmentContexts ?? [])); $parentData['enrollment_context'] = count($contextValues) === 1 ? $contextValues[0] : 'mixed'; try { Events::trigger('admissionUnderReview', $parentData, $studentData); } catch (Throwable $e) { log_message('error', 'Enrollment notification failed after successful submission: {message}', [ 'message' => $e->getMessage(), ]); } $successMessage = 'Enrollment submitted successfully.'; if ($enrollmentResultMessages !== []) { $successMessage .= ' ' . implode(' ', $enrollmentResultMessages); } if ($invoiceResultMessage !== null) { $successMessage .= ' ' . $invoiceResultMessage; } $redirect = redirect()->to('/parent/enroll_success')->with('success', $successMessage); if ($invoiceErrorMessage !== null) { $redirect = $redirect->with('error', $invoiceErrorMessage); } return $redirect; } } /** * Generate or refresh the parent's school-year invoice after parent-submitted enrollment. * * The invoice engine is intentionally authoritative for billable statuses. For example, * first-time students still under admission review may not produce billable lines yet. * * @return array{ok: bool, message: string} */ private function generateInvoiceForParentEnrollment(int $parentId): array { return $this->parentEnrollmentService()->generateInvoiceForParentEnrollment( $parentId, $this->currentSchoolYearName((string) ($this->schoolYear ?? '')), (string) ($this->semester ?? getSemester()) ); } private function enrollmentPayloadFromEvaluation(array $evaluation, array $base): array { $payload = array_merge($base, [ 'source_school_year' => $evaluation['source_school_year'] ?? null, 'deliberation_decision' => $evaluation['deliberation_decision'] ?? null, 'source_grade_id' => $evaluation['source_grade_id'] ?? null, 'assigned_grade_id' => $evaluation['assigned_grade_id'] ?? null, 'source_class_section_id' => $evaluation['source_class_section_id'] ?? null, 'assigned_class_section_id' => $evaluation['assigned_class_section_id'] ?? null, 'class_section_id' => $evaluation['assigned_class_section_id'] ?? ($base['class_section_id'] ?? null), 'placement_status' => $evaluation['placement_status'] ?? null, 'age_reference_date' => $evaluation['age_reference_date'] ?? null, 'age_on_reference_date' => $evaluation['age_on_reference_date'] ?? null, 'adult_student' => ! empty($evaluation['adult_student']) ? 1 : 0, 'parent_enrollment_allowed' => ! empty($evaluation['parent_enrollment_allowed']) ? 1 : 0, 'student_self_enrollment_allowed' => ! empty($evaluation['student_self_enrollment_allowed']) ? 1 : 0, 'exception_required' => ! empty($evaluation['admin_exception']) || ! empty($evaluation['flags']) ? 1 : 0, 'exception_reason' => $this->exceptionReasonFromEvaluation($evaluation), 'registration_submitted_at' => utc_now(), ]); return $this->filterEnrollmentPayloadByColumns($payload); } private function updateEnrollmentStudentInfo(array $studentIds, int $parentId, string $schoolYear): array { $studentInfo = $this->request->getPost('student_info'); return $this->parentEnrollmentService()->updateEnrollmentStudentInfo( $studentIds, $parentId, is_array($studentInfo) ? $studentInfo : [] ); } /** * @param array $user * @return array */ private function parentContactForEnrollment(array $user): array { $posted = old('parent_contact'); $posted = is_array($posted) ? $posted : []; $phoneDigits = preg_replace('/\D/', '', (string) ($posted['cellphone'] ?? $user['cellphone'] ?? '')) ?? ''; $phoneDisplay = strlen($phoneDigits) === 10 ? substr($phoneDigits, 0, 3) . '-' . substr($phoneDigits, 3, 3) . '-' . substr($phoneDigits, 6) : trim((string) ($posted['cellphone'] ?? $user['cellphone'] ?? '')); return [ 'firstname' => trim((string) ($user['firstname'] ?? '')), 'lastname' => trim((string) ($user['lastname'] ?? '')), 'cellphone' => $phoneDisplay, 'address_street' => trim((string) ($posted['address_street'] ?? $user['address_street'] ?? '')), 'apt' => trim((string) ($posted['apt'] ?? $user['apt'] ?? '')), 'city' => trim((string) ($posted['city'] ?? $user['city'] ?? '')), 'state' => strtoupper(trim((string) ($posted['state'] ?? $user['state'] ?? ''))), 'zip' => trim((string) ($posted['zip'] ?? $user['zip'] ?? '')), ]; } /** * @return list */ private function updateEnrollmentParentContact(int $parentId): array { $fields = $this->request->getPost('parent_contact'); return $this->parentEnrollmentService()->updateEnrollmentParentContact( $parentId, is_array($fields) ? $fields : [] ); } /** * @param array $fields * @return array{errors: list, data: array} */ private function normalizeEnrollmentParentContact(array $fields, string $existingState = ''): array { return $this->parentEnrollmentService()->normalizeEnrollmentParentContact($fields, $existingState); } private function collapseContactWhitespace(string $value): string { return trim(preg_replace('/\s+/', ' ', $value) ?? ''); } /** * @param mixed $selected * @return list */ private function normalizeEnrollmentHealthSelections($selected, string $otherText): array { $values = []; foreach ((array) $selected as $value) { $value = trim((string) $value); if ($value === '') { continue; } $values[] = $value; } $otherText = trim($otherText); if (in_array('Other', $values, true)) { $values = array_values(array_filter($values, static fn(string $value): bool => $value !== 'Other')); if ($otherText !== '') { $values[] = mb_substr($otherText, 0, 100); } } $unique = []; foreach ($values as $value) { $unique[$value] = $value; } return array_values($unique); } private function normalizeEnrollmentStudentName(string $name): string { $name = trim(preg_replace('/\s+/', ' ', $name) ?? ''); return mb_convert_case($name, MB_CASE_TITLE, 'UTF-8'); } private function exceptionReasonFromEvaluation(array $evaluation): ?string { if (! empty($evaluation['admin_exception'])) { return 'Admin exception: ' . (string) ($evaluation['admin_exception']['reason_code'] ?? 'approved'); } $codes = array_values(array_filter(array_map('strval', array_merge( $evaluation['blocking_rule_codes'] ?? [], $evaluation['review_rule_codes'] ?? [], $evaluation['warning_rule_codes'] ?? [] )))); return $codes !== [] ? implode(', ', array_unique($codes)) : null; } private function enrollmentAuditPayload(array $payload, array $evaluation): array { return [ 'enrollment' => $payload, 'eligibility' => [ 'decision' => $evaluation['decision'] ?? null, 'can_enroll' => ! empty($evaluation['can_enroll']), 'rule_codes' => $evaluation['rule_codes'] ?? [], 'blocking_rule_codes' => $evaluation['blocking_rule_codes'] ?? [], 'review_rule_codes' => $evaluation['review_rule_codes'] ?? [], 'warning_rule_codes' => $evaluation['warning_rule_codes'] ?? [], 'admin_exception' => $evaluation['admin_exception'] ?? null, 'financial_summary' => $evaluation['financial_summary'] ?? null, 'last_name_exception_carry_forward' => $evaluation['last_name_exception_carry_forward'] ?? null, ], ]; } private function filterEnrollmentPayloadByColumns(array $payload): array { return $this->filterPayloadByTableColumns($payload, 'enrollments'); } private function filterPayloadByTableColumns(array $payload, string $table): array { foreach (array_keys($payload) as $column) { if (! $this->db->fieldExists($column, $table)) { unset($payload[$column]); } } return $payload; } private function hasAcceptedPolicyForYear(int $parentId, string $schoolYear): bool { return $this->parentEnrollmentService()->hasAcceptedPolicyForYear($parentId, $schoolYear); } private function recordPolicyAcceptance(int $parentId, string $schoolYear, string $source): void { $this->parentEnrollmentService()->recordPolicyAcceptance( $parentId, $schoolYear, $source, $this->request->getIPAddress(), $this->request->getUserAgent()->getAgentString() ); } /** * If a promotion_queue record exists for this student and the given school year, * create/update student_class row accordingly (base section until distribution), * and mark the queue row as applied. */ private function applyPromotionAssignment(int $studentId, string $year, bool $updateEnrollmentPlacement = true): void { try { $this->ensureClassSectionsForYear($year); $promo = new \App\Models\PromotionQueueModel(); $classSectionModel = new \App\Models\ClassSectionModel(); $draftModel = new StudentSectionDistributionDraftModel(); $studentClass = new \App\Models\StudentClassModel(); $previousYear = $this->previousSchoolYearName($year); $previousDecision = $previousYear !== null ? $this->studentDecisionForYear($studentId, $previousYear) : null; if ($this->isFallMakeupExamDecision($previousDecision)) { $this->keepMakeupExamStudentInPreviousGrade($studentId, $year, $previousYear); return; } $draft = $draftModel->where('student_id', $studentId) ->where('school_year', $year) ->where('status', 'pending') ->first(); $row = $promo->where('student_id', $studentId) ->where('school_year_to', $year) ->first(); $targetSectionId = (int)($draft['class_section_id'] ?? 0); if ($targetSectionId <= 0) { $targetSectionId = (int)($row['to_class_section_id'] ?? 0); } $placementStatus = $draft ? 'automatic_distribution_applied' : 'manual_class_assigned'; if (!$row && !$draft && $previousYear !== null) { $evaluation = service('enrollmentTransition')->evaluate($studentId, $previousYear, $year, 'admin'); if (!($evaluation['academic_eligible'] ?? false) || ($evaluation['blockers'] ?? []) !== []) { log_message('warning', 'applyPromotionAssignment: transition fallback blocked for student_id=' . $studentId . ', year=' . $year . ', blockers=' . implode('; ', $evaluation['blockers'] ?? [])); return; } $targetSectionId = (int)($evaluation['assigned_class_section_id'] ?? 0); if ($targetSectionId <= 0 && (int)($evaluation['assigned_grade_id'] ?? 0) > 0) { $base = $this->baseSectionForClassYear((int)$evaluation['assigned_grade_id'], $year); $targetSectionId = (int)($base['class_section_id'] ?? 0); } $placementStatus = match ((string)($evaluation['placement_status'] ?? '')) { 'automatic_distribution_pending' => 'base_section_pending_distribution', 'same_class_assigned', 'temporary_same_grade' => (string)$evaluation['placement_status'], default => 'manual_class_assigned', }; } if (!$row && !$draft && $targetSectionId <= 0) { log_message('warning', 'applyPromotionAssignment: no placement source found for student_id=' . $studentId . ', year=' . $year); return; } if ($targetSectionId <= 0) { // Resolve base section for target class (e.g., '3') $base = $this->baseSectionForClassYear((int)($row['to_class_id'] ?? 0), $year) ?? $classSectionModel->getBaseSectionByClassId((int)($row['to_class_id'] ?? 0)); if (!$base) { log_message('warning', 'applyPromotionAssignment: no draft or base section found for student_id=' . $studentId . ', year=' . $year); return; } $targetSectionId = (int)($base['class_section_id'] ?? 0); } if ($targetSectionId <= 0) return; // Upsert into student_class for the target school year $exists = $studentClass->where('student_id', $studentId) ->where('school_year', $year) ->first(); $payload = [ 'student_id' => $studentId, 'class_section_id' => $targetSectionId, 'school_year' => $year, 'updated_by' => (int)(session()->get('user_id') ?? 0) ?: null, 'updated_at' => utc_now(), ]; if ($exists) { $studentClass->update((int)$exists['id'], $payload); } else { $payload['created_at'] = utc_now(); $studentClass->insert($payload); } if ($updateEnrollmentPlacement) { $this->db->table('enrollments') ->where('student_id', $studentId) ->where('school_year', $year) ->whereIn('enrollment_status', ['admission under review', 'review & decision', 'payment pending', 'enrolled']) ->update([ 'class_section_id' => $targetSectionId, 'assigned_class_section_id' => $targetSectionId, 'placement_status' => $placementStatus, 'updated_at' => utc_now(), ]); } // Mark promotion as applied when promotion_queue is the source. if ($row) { $promo->update((int)$row['id'], [ 'status' => 'applied', 'updated_at' => utc_now(), 'updated_by' => (int)(session()->get('user_id') ?? 0) ?: null, ]); } if ($draft) { $draftModel->update((int)$draft['id'], [ 'status' => 'applied', 'applied_at' => utc_now(), 'updated_at' => utc_now(), ]); } } catch (\Throwable $e) { log_message('error', 'applyPromotionAssignment failed: ' . $e->getMessage()); } } private function isReturningReEnrollmentStudent(int $studentId, string $targetSchoolYear): bool { $previousSchoolYear = $this->previousSchoolYearName($targetSchoolYear); if ($studentId <= 0 || $previousSchoolYear === null) { return false; } try { if ($this->db->tableExists('student_decisions')) { $decision = $this->db->table('student_decisions') ->select('id') ->where('student_id', $studentId) ->where('school_year', $previousSchoolYear) ->limit(1) ->get() ->getRowArray(); if ($decision !== null) { return true; } } if ($this->db->tableExists('student_class')) { $assignment = $this->db->table('student_class') ->select('id') ->where('student_id', $studentId) ->where('school_year', $previousSchoolYear) ->limit(1) ->get() ->getRowArray(); if ($assignment !== null) { return true; } } if ($this->db->tableExists('enrollments')) { $enrollment = $this->db->table('enrollments') ->select('id') ->where('student_id', $studentId) ->where('school_year', $previousSchoolYear) ->limit(1) ->get() ->getRowArray(); if ($enrollment !== null) { return true; } } if ($this->db->tableExists('promotion_queue')) { $promotion = $this->db->table('promotion_queue') ->select('id') ->where('student_id', $studentId) ->where('school_year_from', $previousSchoolYear) ->where('school_year_to', $targetSchoolYear) ->limit(1) ->get() ->getRowArray(); if ($promotion !== null) { return true; } } } catch (\Throwable $e) { log_message('error', 'isReturningReEnrollmentStudent failed: ' . $e->getMessage()); } return false; } private function isFallMakeupExamDecision(?array $decisionRow): bool { return DeliberationDecision::normalize($decisionRow['decision'] ?? null) === DeliberationDecision::MAKE_UP_EXAM; } private function keepMakeupExamStudentInPreviousGrade(int $studentId, string $targetSchoolYear, ?string $previousSchoolYear): void { if ($studentId <= 0 || $targetSchoolYear === '' || $previousSchoolYear === null) { return; } $previousAssignment = $this->db->table('student_class sc') ->select('sc.class_section_id, cs.class_section_name') ->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'left') ->where('sc.student_id', $studentId) ->where('sc.school_year', $previousSchoolYear) ->where('sc.class_section_id IS NOT NULL', null, false) ->orderBy('sc.updated_at', 'DESC') ->orderBy('sc.id', 'DESC') ->limit(1) ->get() ->getRowArray(); if ($previousAssignment === null) { $this->openMakeupExamPlacementFlag($studentId, $targetSchoolYear, null, null); log_message('warning', 'No previous class assignment found for fall make-up exam student_id=' . $studentId . ', year=' . $previousSchoolYear); return; } $previousSectionId = (int) ($previousAssignment['class_section_id'] ?? 0); $previousSectionName = trim((string) ($previousAssignment['class_section_name'] ?? '')); $targetSectionId = $this->equivalentClassSectionIdForYear($previousSectionId, $previousSectionName, $targetSchoolYear); if ($targetSectionId <= 0) { $targetSectionId = $previousSectionId; } if ($targetSectionId <= 0) { $this->openMakeupExamPlacementFlag($studentId, $targetSchoolYear, null, $previousSectionName !== '' ? $previousSectionName : null); return; } $now = utc_now(); $updatedBy = (int) (session()->get('user_id') ?? 0) ?: null; $studentClass = new \App\Models\StudentClassModel(); $existing = $studentClass->where('student_id', $studentId) ->where('school_year', $targetSchoolYear) ->first(); $payload = [ 'student_id' => $studentId, 'class_section_id' => $targetSectionId, 'school_year' => $targetSchoolYear, 'updated_by' => $updatedBy, 'updated_at' => $now, 'description' => 'Held at previous grade pending fall make-up exam result.', ]; if ($existing) { $studentClass->update((int) $existing['id'], $payload); } else { $payload['created_at'] = $now; $studentClass->insert($payload); } $this->db->table('enrollments') ->where('student_id', $studentId) ->where('school_year', $targetSchoolYear) ->whereIn('enrollment_status', ['admission under review', 'review & decision', 'payment pending', 'enrolled']) ->update([ 'class_section_id' => $targetSectionId, 'updated_at' => $now, ]); $targetSectionName = $this->classSectionNameById($targetSectionId) ?? $previousSectionName; $this->openMakeupExamPlacementFlag( $studentId, $targetSchoolYear, $targetSectionId, $targetSectionName !== '' ? $targetSectionName : null ); } private function equivalentClassSectionIdForYear(int $sourceSectionId, string $sourceSectionName, string $targetSchoolYear): int { if ($sourceSectionName !== '' && $this->db->fieldExists('school_year', 'classSection')) { $row = $this->db->table('classSection') ->select('class_section_id') ->where('class_section_name', $sourceSectionName) ->where('school_year', $targetSchoolYear) ->orderBy('id', 'DESC') ->limit(1) ->get() ->getRowArray(); if ($row !== null && (int) ($row['class_section_id'] ?? 0) > 0) { return (int) $row['class_section_id']; } } return $sourceSectionId; } private function classSectionNameById(int $classSectionId): ?string { if ($classSectionId <= 0) { return null; } $row = $this->db->table('classSection') ->select('class_section_name') ->where('class_section_id', $classSectionId) ->orderBy('id', 'DESC') ->limit(1) ->get() ->getRowArray(); $name = trim((string) ($row['class_section_name'] ?? '')); return $name !== '' ? $name : null; } private function baseSectionForClassYear(int $classId, string $schoolYear): ?array { $this->ensureClassSectionsForYear($schoolYear); if ($classId <= 0 || $schoolYear === '' || ! $this->db->tableExists('classSection')) { return null; } $builder = $this->db->table('classSection') ->select('class_section_id, class_section_name, class_id') ->where('class_id', $classId) ->where("class_section_name NOT LIKE '%-%'", null, false) ->orderBy('id', 'ASC') ->limit(1); if ($this->db->fieldExists('school_year', 'classSection')) { $builder->where('school_year', $schoolYear); } $row = $builder->get()->getRowArray(); return $row !== null && (int) ($row['class_section_id'] ?? 0) > 0 ? $row : null; } private function ensureClassSectionsForYear(string $targetSchoolYear): void { $targetSchoolYear = trim($targetSchoolYear); if ($targetSchoolYear === '' || ! $this->db->tableExists('classSection') || ! $this->db->fieldExists('school_year', 'classSection')) { return; } if ($this->db->table('classSection')->where('school_year', $targetSchoolYear)->countAllResults() > 0) { return; } $sourceSchoolYear = $this->previousSchoolYearName($targetSchoolYear); if ($sourceSchoolYear === null) { return; } $sourceRows = $this->db->table('classSection') ->select('class_id, class_section_id, class_section_name') ->where('school_year', $sourceSchoolYear) ->orderBy('id', 'ASC') ->get() ->getResultArray(); $now = utc_now(); foreach ($sourceRows as $row) { $classSectionId = (int)($row['class_section_id'] ?? 0); if ($classSectionId <= 0) { continue; } $exists = $this->db->table('classSection') ->where('school_year', $targetSchoolYear) ->where('class_section_id', $classSectionId) ->countAllResults(); if ($exists > 0) { continue; } $this->db->table('classSection')->insert([ 'class_id' => (int)($row['class_id'] ?? 0), 'class_section_id' => $classSectionId, 'class_section_name' => (string)($row['class_section_name'] ?? ''), 'school_year' => $targetSchoolYear, 'created_at' => $now, 'updated_at' => $now, ]); } } private function openMakeupExamPlacementFlag( int $studentId, string $targetSchoolYear, ?int $classSectionId, ?string $classSectionName ): void { if ($studentId <= 0 || $targetSchoolYear === '' || ! $this->db->tableExists('current_flag')) { return; } $student = $this->db->table('students') ->select('firstname, lastname') ->where('id', $studentId) ->limit(1) ->get() ->getRowArray(); $studentName = trim((string) ($student['firstname'] ?? '') . ' ' . (string) ($student['lastname'] ?? '')); $studentName = $studentName !== '' ? $studentName : 'Student ID ' . $studentId; $heldGradeText = $classSectionName !== null && trim($classSectionName) !== '' ? ' Student is currently held in ' . trim($classSectionName) . '.' : ''; $description = 'Fall make-up exam enrollment: keep student at the closed-year grade until the exam is passed. After passing, place the student in the new grade.' . $heldGradeText; $existing = $this->db->table('current_flag') ->select('id') ->where('student_id', $studentId) ->where('school_year', $targetSchoolYear) ->where('flag', 'grade') ->where('flag_state', 'Open') ->where('open_description', $description) ->limit(1) ->get() ->getRowArray(); if ($existing !== null) { return; } $now = utc_now(); $this->db->table('current_flag')->insert([ 'student_id' => $studentId, 'student_name' => $studentName, 'grade' => $classSectionId !== null && $classSectionId > 0 ? (string) $classSectionId : '', 'flag' => 'grade', 'flag_datetime' => $now, 'flag_state' => 'Open', 'updated_by_open' => (int) (session()->get('user_id') ?? 0) ?: null, 'open_description' => $description, 'semester' => (string) ($this->semester ?? ''), 'school_year' => $targetSchoolYear, 'created_at' => $now, 'updated_at' => $now, ]); } private function studentDecisionForYear(int $studentId, string $schoolYear): ?array { if ($studentId <= 0 || $schoolYear === '' || ! $this->db->tableExists('student_decisions')) { return null; } try { return $this->db->table('student_decisions') ->select('decision, source, notes, class_section_name') ->where('student_id', $studentId) ->where('school_year', $schoolYear) ->orderBy('updated_at', 'DESC') ->orderBy('id', 'DESC') ->limit(1) ->get() ->getRowArray() ?: null; } catch (\Throwable $e) { log_message('error', 'studentDecisionForYear failed: ' . $e->getMessage()); return null; } } private function fallMakeupExamDateForYear(string $schoolYear): ?string { if ($schoolYear === '' || ! $this->db->tableExists('school_years') || ! $this->db->fieldExists('fall_makeup_exam_on', 'school_years')) { return null; } try { $row = $this->db->table('school_years') ->select('fall_makeup_exam_on') ->where('name', $schoolYear) ->limit(1) ->get() ->getRowArray(); $date = trim((string) ($row['fall_makeup_exam_on'] ?? '')); return $date !== '' ? $date : null; } catch (\Throwable $e) { log_message('error', 'fallMakeupExamDateForYear failed: ' . $e->getMessage()); return null; } } private function blockedEnrollmentDecisionMessages(array $studentIds, string $targetSchoolYear): array { $previousSchoolYear = $this->previousSchoolYearName($targetSchoolYear); $messages = []; foreach (array_unique($studentIds) as $studentId) { $studentId = (int) $studentId; if ($studentId <= 0) { continue; } if ($previousSchoolYear === null) { $messages[] = 'Student ID ' . $studentId . ': enrollment cannot be submitted because the closing school year could not be determined.'; continue; } try { $studentName = $this->studentNameForEnrollmentMessage($studentId); $parentId = (int) session()->get('user_id'); $evaluation = service('enrollmentTransition')->evaluateForParent($parentId, $studentId, $previousSchoolYear, $targetSchoolYear, 'parent'); foreach ($evaluation['blockers'] ?? [] as $blocker) { $blocker = trim((string) $blocker); if ($blocker !== '') { $messages[] = $this->messageWithStudentName($studentName, $blocker); } } } catch (\Throwable $e) { log_message('error', 'Enrollment transition evaluation failed: ' . $e->getMessage()); $messages[] = 'Student ID ' . $studentId . ': enrollment eligibility could not be evaluated. Please contact the administration.'; } } return $messages; } private function enrollmentEligibilityMessageForStudent( array $student, ?array $decisionRow, string $targetSchoolYear, ?string $fallMakeupExamOn = null ): array { return EnrollmentEligibility::parentDecisionMessage($student, $decisionRow, $targetSchoolYear, $fallMakeupExamOn); } private function transitionEvaluationForStudent(int $parentId, int $studentId, string $previousSchoolYear, string $selectedYear): ?array { try { return service('enrollmentTransition')->evaluateForParent($parentId, $studentId, $previousSchoolYear, $selectedYear, 'parent'); } catch (\Throwable $e) { log_message('error', 'Enrollment transition evaluation failed for student ' . $studentId . ': ' . $e->getMessage()); return [ 'blockers' => ['Enrollment eligibility could not be evaluated. Please contact the administration.'], 'warnings' => [], 'placement_status' => 'unknown', 'deliberation_decision' => null, 'parent_enrollment_allowed' => false, 'student_self_enrollment_allowed' => false, 'assigned_grade_id' => null, 'assigned_class_section_id' => null, ]; } } private function readonlyEnrollmentEvaluation(?array $decisionRow): array { $decision = DeliberationDecision::normalize($decisionRow['deliberation_decision_standard'] ?? null) ?? DeliberationDecision::normalize($decisionRow['decision'] ?? null); return [ 'blockers' => [], 'warnings' => [], 'placement_status' => 'readonly', 'deliberation_decision' => $decision, 'decision_label' => DeliberationDecision::display($decisionRow['decision'] ?? '') ?: 'Pending', 'parent_enrollment_allowed' => false, 'student_self_enrollment_allowed' => false, 'assigned_grade_id' => null, 'assigned_class_section_id' => null, ]; } private function eligibilityMessageFromTransition(array $student, ?array $evaluation, ?string $fallMakeupExamOn): array { if ($this->hasSettledParentEnrollmentStatus( (string) ($student['enrollment_status'] ?? ''), (string) ($student['admission_status'] ?? '') )) { return [ 'message' => EnrollmentEligibility::alreadyEnrolledMessage((string) ($student['enrollment_status'] ?? '')), 'blocking' => true, 'level' => 'info', 'primary_block_reason' => 'ALREADY_ENROLLED', ]; } if ($evaluation === null) { return $this->enrollmentEligibilityMessageForStudent( $student, $student['previous_year_decision'] ?? null, $this->currentSchoolYearName((string) ($this->schoolYear ?? '')), $fallMakeupExamOn ); } if (! empty($evaluation['can_enroll']) && ! empty($evaluation['admin_exception'])) { return [ 'message' => 'Enrollment has been authorized by administration.', 'blocking' => false, 'level' => 'info', ]; } if (($evaluation['can_enroll'] ?? false) === false && ! empty($evaluation['primary_parent_message'])) { return [ 'message' => (string) $evaluation['primary_parent_message'], 'blocking' => true, 'level' => 'danger', 'primary_block_reason' => $evaluation['primary_block_reason'] ?? null, ]; } $blockers = array_values(array_filter(array_map('trim', array_map('strval', $evaluation['blockers'] ?? [])))); if ($blockers !== []) { $name = $this->studentNameFromRow($student); $blockers = array_map(fn(string $message): string => $this->messageWithStudentName($name, $message), $blockers); return [ 'message' => implode(' ', $blockers), 'blocking' => true, 'level' => 'danger', ]; } $warnings = array_values(array_filter(array_map('trim', array_map('strval', $evaluation['warnings'] ?? [])))); if ($warnings !== []) { return [ 'message' => implode(' ', $warnings), 'blocking' => false, 'level' => 'warning', ]; } $decision = (string) ($evaluation['deliberation_decision'] ?? ''); if ($decision === DeliberationDecision::MAKE_UP_EXAM) { $name = trim((string) ($student['firstname'] ?? '') . ' ' . (string) ($student['lastname'] ?? '')); $dateText = $fallMakeupExamOn !== null ? ' on ' . local_date($fallMakeupExamOn, 'm-d-Y') : ''; return [ 'message' => ($name !== '' ? $name : 'The student') . ' may complete enrollment now and will initially remain in the same grade until the make-up exam' . $dateText . ' is resolved by administration.', 'blocking' => false, 'level' => 'warning', ]; } return ['message' => '', 'blocking' => false, 'level' => 'info']; } private function studentNameFromRow(array $student): string { $name = trim((string) ($student['firstname'] ?? '') . ' ' . (string) ($student['lastname'] ?? '')); return $name !== '' ? $name : 'The student'; } private function studentNameForEnrollmentMessage(int $studentId): string { try { $row = $this->db->table('students') ->select('firstname, lastname') ->where('id', $studentId) ->limit(1) ->get() ->getRowArray(); return is_array($row) ? $this->studentNameFromRow($row) : 'Student ID ' . $studentId; } catch (\Throwable $e) { log_message('error', 'Unable to load student name for enrollment blocker: ' . $e->getMessage()); return 'Student ID ' . $studentId; } } private function messageWithStudentName(string $studentName, string $message): string { $studentName = trim($studentName) !== '' ? trim($studentName) : 'The student'; $message = trim($message); if ($message === '' || str_contains($message, $studentName)) { return $message; } return $studentName . ': ' . $message; } private function expectedPlacementLabel(?array $evaluation): string { if ($evaluation === null || ($evaluation['blockers'] ?? []) !== [] && empty($evaluation['assigned_grade_id'])) { return 'Pending'; } $status = (string) ($evaluation['placement_status'] ?? ''); return match ($status) { 'automatic_distribution_pending' => $this->gradeLabel($this->classNameById((int) ($evaluation['assigned_grade_id'] ?? 0))), 'same_class_assigned', 'temporary_same_grade' => $this->classSectionNameById((int) ($evaluation['assigned_class_section_id'] ?? 0)) ?: 'Same grade', 'manual_class_required' => 'Same grade - administration must assign class', 'temporary_manual_class_required' => 'Same grade initially - administration must assign temporary class', 'exit_required' => 'Completion or exit process required', default => 'Pending', }; } private function requiredActionLabel(?array $evaluation): string { if ($evaluation === null) { return 'Complete re-enrollment before the registration deadline.'; } $state = $this->parentEnrollmentStateFromEvaluation($evaluation, null); return match ($state) { 'Enroll' => 'Complete re-enrollment before the registration deadline.', 'Eligible with follow-up' => 'Complete re-enrollment and follow the listed next step.', 'Already submitted' => 'Already submitted', 'Action needed' => 'Action needed: pay the previous-year balance or contact administration.', 'Under review' => 'Contact the school administration.', default => 'Contact administration', }; } private function parentEnrollmentState(array $student): string { $status = strtolower(trim((string) ($student['enrollment_status'] ?? ''))); if ($this->hasSettledParentEnrollmentStatus($status, (string) ($student['admission_status'] ?? ''))) { return 'Already submitted'; } return $this->parentEnrollmentStateFromEvaluation( is_array($student['transition_evaluation'] ?? null) ? $student['transition_evaluation'] : null, $status ); } private function parentEnrollmentStateFromEvaluation(?array $evaluation, ?string $enrollmentStatus): string { if ($evaluation === null) { return 'Contact administration'; } $decision = (string) ($evaluation['decision'] ?? ''); $codes = array_map('strval', $evaluation['blocking_rule_codes'] ?? []); if ($decision === 'ALREADY_ENROLLED' || $enrollmentStatus === 'already enrolled') { return 'Already submitted'; } if (! empty($evaluation['can_enroll']) || ! empty($evaluation['parent_enrollment_allowed']) || $decision === 'EXCEPTION_ELIGIBLE' || $decision === 'ELIGIBLE') { if ($decision === 'ELIGIBLE_WITH_WARNING' || ($evaluation['warning_rule_codes'] ?? []) !== []) { return 'Eligible with follow-up'; } return 'Enroll'; } if ($decision === 'ELIGIBLE_WITH_WARNING') { return 'Eligible with follow-up'; } if (in_array('OUTSTANDING_BALANCE_BLOCKED', $codes, true) || in_array('FINANCE_APPROVAL_REQUIRED', $codes, true) || in_array('SIBLING_LAST_NAME_MISMATCH', $codes, true) ) { return 'Action needed'; } if ($decision === 'REVIEW_REQUIRED' || in_array((string) ($evaluation['deliberation_decision'] ?? ''), [ DeliberationDecision::EXPELLED, DeliberationDecision::WITHDRAWN, DeliberationDecision::DEFERRED_DECISION, ], true)) { return 'Under review'; } return 'Contact administration'; } private function hasSettledParentEnrollmentStatus(string $status, string $admissionStatus = ''): bool { if (strtolower(trim($admissionStatus)) === 'accepted') { return true; } return in_array(strtolower(trim($status)), [ 'admission under review', 'review & decision', 'payment pending', 'enrolled', 'waitlist', 'withdraw under review', 'refund pending', ], true); } private function familyFinancialSummary(int $parentId, ?string $previousSchoolYear, string $selectedYear, array $students = []): array { $tuitionDue = $this->enrollmentTuitionDue($students); $summary = service('enrollmentTransition')->getEnrollmentFinancialSummary( $parentId, $previousSchoolYear ?? '', $selectedYear, $tuitionDue ); $behavior = (string) ($summary['balance_behavior'] ?? 'submission_blocked_until_payment'); $schoolYearConfig = $this->schoolYearConfig($selectedYear); $summary['policy_message'] = $this->financialPolicyMessage( $behavior, (string) ($schoolYearConfig['financial_policy_message'] ?? '') ); return $summary; } public function enrollmentEligibilityRefresh() { $parentId = (int) session()->get('user_id'); if ($parentId <= 0) { return $this->response->setStatusCode(401)->setJSON(['error' => 'Unauthorized']); } $selectedYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? '')); return $this->response->setJSON( $this->parentEnrollmentService()->eligibilityRefreshData($parentId, $selectedYear) ); } private function enrollmentTuitionDue(array $students): float { $tuitionStudents = []; $existingTuitionStudentCount = 0; foreach ($students as $student) { if ($this->countsTowardCurrentYearTuition($student)) { $existingTuitionStudentCount++; continue; } $evaluation = is_array($student['transition_evaluation'] ?? null) ? $student['transition_evaluation'] : []; if ( ($evaluation['can_enroll'] ?? false) !== true && ($evaluation['parent_enrollment_allowed'] ?? false) !== true ) { continue; } $tuitionStudents[] = [ 'student_id' => (int) ($student['id'] ?? $student['student_id'] ?? 0), 'class_section_id' => (int) ( $evaluation['assigned_class_section_id'] ?? $student['class_section_id'] ?? 0 ), ]; } if ($tuitionStudents === []) { return 0.0; } if ($existingTuitionStudentCount > 0) { $additionalStudentFee = (float) ($this->configModel->getConfig('second_student_fee') ?? 280); return round(count($tuitionStudents) * $additionalStudentFee, 2); } return (new FeeCalculationService())->calculateEnrollmentTuition($tuitionStudents); } private function countsTowardCurrentYearTuition(array $student): bool { $status = strtolower(trim((string) ($student['enrollment_status'] ?? ''))); if (in_array($status, ['withdrawn', 'withdraw under review', 'refund pending', 'denied', 'not enrolled'], true)) { return false; } if (in_array($status, ['admission under review', 'review & decision', 'payment pending', 'enrolled', 'waitlist'], true)) { return true; } return strtolower(trim((string) ($student['admission_status'] ?? ''))) === 'accepted'; } private function enrollmentFeeSchedule(string $selectedYear): array { $schoolYearConfig = $this->schoolYearConfig($selectedYear); return [ 'currency' => '$', 'first_student_fee' => round((float) ($this->configModel->getConfig('first_student_fee') ?? 380), 2), 'second_student_fee' => round((float) ($this->configModel->getConfig('second_student_fee') ?? 280), 2), 'tuition_due_at_registration' => round((float) ($schoolYearConfig['tuition_due_at_registration'] ?? 0), 2), ]; } private function financialPolicyMessage(string $behavior, string $configured): string { $configured = trim($configured); if ($configured !== '') { return $configured; } return match ($behavior) { 'payment_plan_required' => 'Registration may continue under an approved payment plan.', 'submission_allowed_confirmation_blocked' => 'Registration may be submitted, but it will not be confirmed until the balance is settled.', 'submission_blocked_until_payment' => 'The balance must be paid before registration can be submitted.', 'admin_approval_required' => 'Please contact the finance office to arrange an approved exception.', default => 'The previous-year balance must be paid before registration can be submitted.', }; } private function auditParentStudentFieldChanges(array $original, array $updated, int $parentId, string $source): void { if (! $this->db->tableExists('enrollment_transition_audits')) { return; } $studentId = (int) ($original['id'] ?? 0); if ($studentId <= 0) { return; } $fields = ['firstname', 'lastname', 'dob']; $changes = []; foreach ($fields as $field) { $oldValue = trim((string) ($original[$field] ?? '')); $newValue = trim((string) ($updated[$field] ?? '')); if ($oldValue !== $newValue) { $changes[$field] = [ 'old_value' => $oldValue, 'new_value' => $newValue, 'changed' => true, 'changed_by' => $parentId, 'changed_at' => date('Y-m-d H:i:s'), 'source' => $source, ]; } } if ($changes === []) { return; } $this->db->table('enrollment_transition_audits')->insert([ 'student_id' => $studentId, 'school_year' => (string) ($updated['school_year'] ?? $original['school_year'] ?? date('Y')), 'source_school_year' => null, 'action' => 'parent_student_field_edit', 'performed_by' => $parentId, 'original_values_json' => json_encode($original, JSON_UNESCAPED_SLASHES), 'new_values_json' => json_encode(['changes' => $changes, 'updated' => $updated], JSON_UNESCAPED_SLASHES), 'reason' => $source, 'created_at' => date('Y-m-d H:i:s'), ]); } private function parentEditAffectsEligibility(array $original, array $updated): bool { return trim((string) ($original['dob'] ?? '')) !== trim((string) ($updated['dob'] ?? '')) || trim((string) ($original['lastname'] ?? '')) !== trim((string) ($updated['lastname'] ?? '')); } private function recheckEligibilityAfterParentEdit(int $studentId, int $parentId, string $targetSchoolYear): void { $previousSchoolYear = $this->previousSchoolYearName($targetSchoolYear); if ($previousSchoolYear === null) { return; } $evaluation = service('enrollmentTransition')->evaluateForParent( $parentId, $studentId, $previousSchoolYear, $targetSchoolYear, 'parent' ); if (($evaluation['can_enroll'] ?? false) === true || ($evaluation['parent_enrollment_allowed'] ?? false) === true) { return; } $message = (string) ($evaluation['primary_parent_message'] ?? 'Updated student information affects enrollment eligibility.'); service('enrollmentTransition')->logEnrollmentBlock($evaluation, 'parent_student_edit', $parentId, $parentId); session()->setFlashdata('warning', $message); } private function schoolYearConfig(string $schoolYear): array { if ($schoolYear === '' || ! $this->db->tableExists('school_years')) { return []; } return $this->db->table('school_years') ->where('name', $schoolYear) ->limit(1) ->get() ->getRowArray() ?: []; } private function classNameById(int $classId): string { if ($classId <= 0 || ! $this->db->tableExists('classes')) { return 'Assigned grade'; } $row = $this->db->table('classes') ->select('class_name') ->where('id', $classId) ->limit(1) ->get() ->getRowArray(); return trim((string) ($row['class_name'] ?? '')) ?: 'Assigned grade'; } private function gradeLabel(string $className): string { $className = trim($className); if ($className === '') { return 'Assigned grade'; } return preg_match('/^grade\b/i', $className) === 1 ? $className : 'Grade ' . $className; } private function previousSchoolYearName(string $schoolYear): ?string { $schoolYear = trim($schoolYear); if (! preg_match('/^(\d{4})-(\d{4})$/', $schoolYear, $matches)) { return null; } return ((int) $matches[1] - 1) . '-' . ((int) $matches[2] - 1); } public function enrollSuccess() { return view('/parent/enroll_success'); } public function enrollFailure() { echo view('/parent/enroll_failure'); } public function payment() { // Get the logged-in user's ID $userId = session()->get('user_id'); $invoices = $this->parentPaymentService->invoicesForParent((int) $userId, true); // Pass the invoices data to the view return view('/parent/payment', [ 'invoices' => $invoices ]); } public function paymentHandler() { $parentId = session()->get('user_id'); $this->parentPaymentService->markTuitionPaidForParent((int) $parentId); // Clear the total tuition fee from the session session()->remove('total_tuition_fee'); // Set success message in session session()->setFlashdata('success', 'Tuition fees paid successfully'); return redirect()->to('/parent/payment_success'); } public function messageParent() { return view('/parent/parent_message'); } public function successMessage() { // Load the success message view return view('/parent/success_message'); } public function addSecondParent() { return view('/parent/add_second_parent'); } public function createUserFromParentOrAuthorizedUser($userData, $relationToStudent) { return $this->parentAccountService->createRelatedUser( (array) $userData, (string) $relationToStudent, (string) $this->semester, (string) $this->schoolYear ); } /** * Updates or inserts an authorized user record based on user ID and email. * * @param int $userId The ID of the parent/user who is authorizing access. * @param array $data Contains 'email', 'name', and any other required fields. */ protected function updateAuthorizedUsers($userId, $data) { $this->parentAccountService->updateAuthorizedUsers((int) $userId, (array) $data); } public function profile($id) { if (! $this->canAccessUserRecord((int) $id)) { return redirect()->to('/access_denied'); } // Fetch the user's data based on the given ID $user = $this->userModel->find($id); // Check if user exists if (!$user) { return redirect()->to('/')->with('error', 'User not found'); } // Load the profile view with the user's data return view('/profile', ['user' => $user]); } public function updateProfile($id) { if (! $this->canAccessUserRecord((int) $id)) { return redirect()->to('/access_denied'); } $user = $this->userModel->find($id); // Step 1: Check if user exists if (!$user) { return redirect()->to('/')->with('error', 'User not found'); } // Step 2: Define validation rules $validation = \Config\Services::validation(); $validation->setRules([ 'firstname' => [ 'label' => 'First Name', 'rules' => 'required|min_length[3]|max_length[100]|regex_match[/^[A-Za-z\s\-]+$/]', 'errors' => [ 'regex_match' => 'First name must only contain letters, spaces, and dashes.' ] ], 'lastname' => [ 'label' => 'Last Name', 'rules' => 'required|min_length[3]|max_length[100]|regex_match[/^[A-Za-z\s\-]+$/]', 'errors' => [ 'regex_match' => 'Last name must only contain letters, spaces, and dashes.' ] ], 'cellphone' => [ 'label' => 'Cell Phone', 'rules' => 'required|regex_match[/^\d{10}$/]', 'errors' => [ 'regex_match' => 'Cell phone must be exactly 10 digits.' ] ], 'address_street' => [ 'label' => 'Street Address', 'rules' => 'required|min_length[5]|max_length[255]' ], 'city' => [ 'label' => 'City', 'rules' => 'required|min_length[2]|max_length[100]' ], 'state' => [ 'label' => 'State', 'rules' => 'required|min_length[2]|max_length[100]' ], 'zip' => [ 'label' => 'ZIP Code', 'rules' => 'required|regex_match[/^\d{5}$/]', 'errors' => [ 'regex_match' => 'ZIP code must be exactly 5 digits.' ] ] ]); // Step 3: Validate input; reload view with error messages if validation fails if (!$this->validate($validation->getRules())) { return view('/profile', [ 'user' => $user, 'validation' => $this->validator, ]); } // Step 4: Prepare cleaned and validated data for update $updatedData = [ 'firstname' => ucfirst(strtolower($this->request->getPost('firstname'))), 'lastname' => ucfirst(strtolower($this->request->getPost('lastname'))), 'cellphone' => $this->request->getPost('cellphone'), 'address_street' => $this->request->getPost('address_street'), 'city' => ucfirst(strtolower($this->request->getPost('city'))), 'state' => strtoupper($this->request->getPost('state')), 'zip' => $this->request->getPost('zip'), ]; // Step 5: Update user profile in the database $this->userModel->update($id, $updatedData); // Step 6: Redirect with success message session()->setFlashdata('success', 'Profile updated successfully'); return redirect()->to('/profile/' . $id); } public function isEmailUnique(string $email): bool { return $this->parentAccountService->isEmailUnique($email); } //form to save student and emergency contact data public function saveStudentRegistration() { log_message('debug', print_r($this->request->getPost(), true)); $schoolIdService = new SchoolIdService(); $parentId = session()->get('user_id'); $schoolYearContext = $this->resolveSchoolYearContext(); $this->assertSchoolYearWritable($schoolYearContext); $selectedSchoolYear = $schoolYearContext->yearName(); $this->schoolYear = $selectedSchoolYear; if (!$this->lastDayOfRegistration || !strtotime($this->lastDayOfRegistration)) { throw new \Exception('Invalid enrollment deadline date.'); } if (strtolower($this->request->getMethod()) !== 'post') { return redirect()->back()->with('error', 'Invalid request method.'); } try { $data = $this->getRegistrationData($parentId); $post = $this->request->getPost(); $validation = $this->parentRegistrationService()->validateRegistrationSubmission($post, $data); if (empty($validation['ok'])) { return redirect()->back()->withInput()->with('error', (string) ($validation['error'] ?? 'Registration could not be submitted.')); } $this->db->transStart(); $studentAdded = false; $registeredStudentIds = []; $incomingFirstNames = (array) ($post['studentFirstName'] ?? []); $incomingECFirst = (array) ($post['emergency_firstname'] ?? []); // Loop through students foreach ($incomingFirstNames as $idx => $firstName) { if (!empty($firstName)) { // ✅ 7. Extract is_new flag (convert to boolean) $lastYearKey = 'last_year_' . $idx; $lastYearVal = strtolower(trim($post[$lastYearKey] ?? '')); $isNew = match ($lastYearVal) { 'yes' => false, // was with us last year => not new 'no' => true, // was not with us => new student default => null // missing or invalid }; if (!isset($isNew)) { return redirect()->back()->withInput()->with('error', "Please select if student #" . ($idx + 1) . " was with us last year."); } // ✅ 8. Pass $isNew to the student save function $result = $this->validateAndSaveOrUpdateStudent($idx, $parentId, $this->semester, $this->schoolYear, $schoolIdService, $isNew, null); if ($result) { $studentAdded = true; if (is_int($result) && $result > 0) { $registeredStudentIds[] = $result; } } } } $hasEC = !empty($incomingECFirst[0]) || !empty(($this->request->getPost('emergency_lastname')[0] ?? '')); if ($hasEC) { $this->saveEmergencyContact($parentId, $this->semester, $this->schoolYear); } if (!$studentAdded && !$hasEC) { throw new \Exception('Please fill in at least a form before submitting.'); } $this->db->transComplete(); if ($this->db->transStatus() === false) { throw new \Exception('DB transaction failed.'); } if ($registeredStudentIds !== []) { $this->notifyAdminsAboutRegisteredStudents($registeredStudentIds, (int) $parentId); } if ($studentAdded) { return redirect()->to(base_url('/parent/child_register')) ->with('success', 'Registration successful!'); } return redirect()->to(base_url('/parent/child_register'))->with('success', 'Registration successful!'); } catch (\Throwable $e) { log_message('error', $e->getMessage()); $this->db->transRollback(); return redirect()->back()->withInput()->with('error', $e->getMessage()); } } /** * Send admin registration emails after commit so only saved students are reported. * * @param list $studentIds */ private function notifyAdminsAboutRegisteredStudents(array $studentIds, int $parentId): void { $parent = $this->userModel->find($parentId); if (! is_array($parent)) { log_message('warning', 'Unable to send admin student registration email: parent not found for ID ' . $parentId); return; } foreach (array_values(array_unique(array_filter(array_map('intval', $studentIds)))) as $studentId) { $student = $this->studentModel->find($studentId); if (! is_array($student)) { log_message('warning', 'Unable to send admin student registration email: student not found for ID ' . $studentId); continue; } $payload = $student; $payload['parents'] = [ 'user_id' => $parentId, 'firstname' => (string) ($parent['firstname'] ?? ''), 'lastname' => (string) ($parent['lastname'] ?? ''), 'email' => (string) ($parent['email'] ?? ''), ]; $studentFullName = trim((string) ($student['firstname'] ?? '') . ' ' . (string) ($student['lastname'] ?? '')) ?: 'Student ID ' . $studentId; $adminMessage = view('emails/admin_student_registered', ['student' => $payload], ['saveData' => true]); try { $sent = service('emailService')->send( 'registration@alrahmaisgl.org', 'New Student Registered: ' . $studentFullName, $adminMessage, 'notifications' ); if (! $sent) { log_message('error', 'Admin student registration email failed for student ID ' . $studentId); } } catch (Throwable $e) { log_message('error', 'Admin student registration email failed for student ID ' . $studentId . ': ' . $e->getMessage()); } } } // Function to check if the parent has registered kids and redirect accordingly public function registerKidCheck() { $parentId = session()->get('user_id'); if (!$parentId) { return redirect()->back()->with('error', 'Session expired. Please log in again.'); } try { $data = $this->getRegistrationData($parentId); } catch (\RuntimeException $e) { return redirect()->back()->with('error', $e->getMessage()); } catch (\Throwable $e) { log_message('error', "Failed to fetch registration data: " . $e->getMessage()); return redirect()->back()->with('error', 'Failed to retrieve registration info.'); } $selectedSchoolYear = (string) ($data['selectedYear'] ?? ''); $data['lastDayOfRegistration'] = $this->lastDayOfRegistration; $data['registrationAgeDeadline'] = $this->registrationMinimumAgeDeadline($selectedSchoolYear); $data['schoolYearAgeDeadline'] = $this->schoolYearAgeDeadline($selectedSchoolYear); return view('parent/register_student', $data); } private function getRegistrationData(int $parentId): array { $schoolYearContext = $this->resolveSchoolYearContext(); $selectedSchoolYear = $schoolYearContext->yearName(); return $this->parentRegistrationService()->registrationData( $parentId, $selectedSchoolYear, ! $schoolYearContext->isReadonly(), $this->maxChilds, $this->maxEmergency ); } private function validateAndSaveOrUpdateStudent($idx, $parentId, $semester, $schoolYear, $schoolIdService, $isNew = null, $studentId = null) { $result = $this->parentRegistrationService()->saveStudentAtIndex( (int) $idx, $this->request->getPost(), (int) $parentId, (string) $schoolYear, $schoolIdService, $isNew === null ? null : (bool) $isNew, $studentId === null ? null : (int) $studentId, $this->schoolStartDate, $this->ageDateRefernce ?? $this->dateAgeReference ?? null ); if (empty($result['ok'])) { if (! empty($result['error'])) { session()->setFlashdata('error', (string) $result['error']); } return false; } return (int) ($result['student_id'] ?? 0) > 0 ? (int) $result['student_id'] : true; } /** * Validates if a date of birth falls within required age range by a deadline * * @param string $dob Date of birth (YYYY-MM-DD format) * @param string $registrationAgeDeadline Deadline date (YYYY-MM-DD format) * @param int $minAge Minimum required age (default: 5) * @param int $maxAge Maximum allowed age (default: 18) * @return array ['isValid' => bool, 'message' => string] */ function validateDobAge( string $dob, string $registrationAgeDeadline, int $minAge = 5, int $maxAge = 18, ?string $schoolYearAgeDeadline = null ): array { $response = ['isValid' => false, 'message' => '', 'age' => null]; // -- Use a stable timezone for all comparisons (UTC is safest) $tz = new DateTimeZone('UTC'); // 1) Empty? $dob = trim($dob); if ($dob === '') { $response['message'] = 'Date of birth is required'; return $response; } // 2) Strict-parse DOB as a date-only at 00:00:00 (use leading "!" to zero unspecified fields) $birthDate = DateTimeImmutable::createFromFormat('!Y-m-d', $dob, $tz); $errs = DateTimeImmutable::getLastErrors(); if ($birthDate === false || ($errs['warning_count'] ?? 0) > 0 || ($errs['error_count'] ?? 0) > 0) { $response['message'] = 'Invalid date format (Use YYYY-MM-DD)'; return $response; } // 3) Parse deadlines; Dec 31 is only the special minimum-age registration grace date. try { $minimumAgeDeadline = new DateTimeImmutable($registrationAgeDeadline, $tz); } catch (Throwable $e) { $minimumAgeDeadline = new DateTimeImmutable('now', $tz); } try { $ageDeadline = new DateTimeImmutable($schoolYearAgeDeadline ?: $registrationAgeDeadline, $tz); } catch (Throwable $e) { $ageDeadline = $minimumAgeDeadline; } $minimumAgeDeadline = $minimumAgeDeadline->setTime(23, 59, 59); $ageDeadline = $ageDeadline->setTime(23, 59, 59); // 4) The persisted/general age is based on Sep 1 of the school year. $ageAtDeadline = $birthDate->diff($ageDeadline)->y; $ageAtMinimumAgeDeadline = $birthDate->diff($minimumAgeDeadline)->y; $response['age'] = $ageAtDeadline; // 5) Allowed birthdate window (inclusive) // - Earliest birthdate = Sep 1 - maxAge years. // - Latest birthdate = Dec 31 - minAge years, only for minimum-age registration eligibility. $minBirthDate = $ageDeadline->modify('-' . ($maxAge + 1) . ' years')->modify('+1 day')->setTime(0, 0, 0); // earliest allowed $maxBirthDate = $minimumAgeDeadline->modify("-{$minAge} years")->setTime(23, 59, 59); // youngest allowed // 6) Validate $isValid = ($birthDate >= $minBirthDate) && ($birthDate <= $maxBirthDate); $response['isValid'] = $isValid; if (!$isValid) { $response['message'] = sprintf( 'Must be at least %d years old by %s and no older than %d by %s. Current registration age would be: %d', $minAge, $minimumAgeDeadline->format('m-d-Y'), $maxAge, $ageDeadline->format('m-d-Y'), $ageAtMinimumAgeDeadline ); } return $response; } private function saveEmergencyContact($parentId, $semester, $schoolYear, $single = null, $id = null) { $result = $this->parentRegistrationService()->saveEmergencyContact( (int) $parentId, $this->request->getPost(), is_array($single) ? $single : null, $id === null ? null : (int) $id ); if (empty($result['ok'])) { session()->setFlashdata('error', (string) ($result['error'] ?? 'Emergency contact could not be saved.')); return redirect()->back()->withInput(); } } public function editEmergencyContact($id = null) { $schoolYearContext = $this->resolveSchoolYearContext(); $this->assertSchoolYearWritable($schoolYearContext); if ($id === null) { $parentId = session()->get('user_id'); $contacts = $this->emergencyContactModel->where('parent_id', $parentId)->findAll(); return view('/parent/edit_emergency_contact', ['contacts' => $contacts]); } if (strtolower($this->request->getMethod()) === 'post') { $rules = [ 'emergency_first_name' => [ 'label' => 'First Name', 'rules' => 'required|min_length[2]|max_length[50]|regex_match[/^[-A-Za-z ]+$/]', 'errors' => [ 'regex_match' => 'First name may only contain letters, spaces, and dashes.' ] ], 'emergency_last_name' => [ 'label' => 'Last Name', 'rules' => 'required|min_length[2]|max_length[50]|regex_match[/^[-A-Za-z ]+$/]', 'errors' => [ 'regex_match' => 'Last name may only contain letters, spaces, and dashes.' ] ], 'cellphone' => [ 'label' => 'Cell Phone', 'rules' => 'required|regex_match[/^\(?\d{3}\)?[-\s]?\d{3}[-\s]?\d{4}$/]', 'errors' => [ 'regex_match' => 'Please enter a valid phone number (e.g., 123-456-7890 or (123) 456 7890).' ] ], 'relation' => [ 'label' => 'Relation', 'rules' => 'required|min_length[3]|max_length[50]' ], 'email' => [ 'label' => 'Email', 'rules' => 'permit_empty|valid_email|max_length[150]' ] ]; if (!$this->validate($rules)) { return redirect()->back()->withInput()->with('errors', $this->validator->getErrors()); } $parentId = session()->get('user_id'); $semester = $this->semester; $schoolYear = $schoolYearContext->yearName(); $this->saveEmergencyContact($parentId, $semester, $schoolYear, [ 'first_name' => $this->request->getPost('emergency_first_name'), 'last_name' => $this->request->getPost('emergency_last_name'), 'cellphone' => $this->request->getPost('cellphone'), 'email' => $this->request->getPost('email'), 'relation' => $this->request->getPost('relation'), ], $id); return redirect()->to('/parent/child_register') ->with('message', 'Emergency contact updated successfully.'); } } public function editStudent($id) { $schoolYearContext = $this->resolveSchoolYearContext(); $this->assertSchoolYearWritable($schoolYearContext); $this->schoolYear = $schoolYearContext->yearName(); $schoolIdService = new \App\Services\SchoolIdService(); if (strtolower($this->request->getMethod()) !== 'post') { return redirect()->back()->with('error', 'Invalid request.'); } $rules = [ 'firstname' => [ 'label' => 'First Name', 'rules' => 'required|min_length[2]|max_length[255]|regex_match[/^[A-Za-z\s\-]+$/]', 'errors' => ['regex_match' => 'Only letters, spaces, and dashes allowed.'] ], 'lastname' => [ 'label' => 'Last Name', 'rules' => 'required|min_length[2]|max_length[255]|regex_match[/^[A-Za-z\s\-]+$/]', 'errors' => ['regex_match' => 'Only letters, spaces, and dashes allowed.'] ], 'dob' => 'required|valid_date', 'gender' => 'required|in_list[Male,Female]', 'photo_consent' => 'required|in_list[0,1]', 'registration_grade' => 'permit_empty|max_length[50]', ]; // ✅ Validate first, keep scalar post data for proper error messages if (!$this->validate($rules)) { return redirect()->back()->withInput()->with('errors', $this->validator->getErrors()); } $parentId = session()->get('user_id'); // Prepare formData only AFTER validation $formData = [ 'studentFirstName' => [$this->request->getPost('firstname')], 'studentLastName' => [$this->request->getPost('lastname')], 'dob' => [$this->request->getPost('dob')], 'gender' => [$this->request->getPost('gender')], 'registration_grade' => [$this->request->getPost('registration_grade')], 'photo_consent' => [$this->request->getPost('photo_consent')], 'allergies' => [explode(',', $this->request->getPost('allergies') ?? '')], 'medical_conditions' => [explode(',', $this->request->getPost('medical_conditions') ?? '')], ]; // Inject temporarily only for update logic, not before validation $this->request->setGlobal('post', $formData); // Save/update $this->validateAndSaveOrUpdateStudent(0, $parentId, $this->semester, $this->schoolYear, $schoolIdService, null, (int) $id); return redirect()->to('/parent/child_register')->with('success', 'Student updated!'); } public function deleteStudent($id) { $this->assertSchoolYearWritable($this->resolveSchoolYearContext()); if (strtolower($this->request->getMethod()) !== 'post') { return redirect()->back()->with('error', 'Invalid request method.'); } $parentId = session()->get('user_id'); // Confirm student exists and belongs to the parent $student = $this->studentModel ->where('id', $id) ->where('parent_id', $parentId) ->first(); if (!$student) { return redirect()->back()->with('error', 'Student not found or unauthorized.'); } if (! $this->canParentDeleteStudent($student, (int) $parentId)) { return redirect()->back()->with('error', 'This student has enrollment history and cannot be deleted. Please contact administration if a change is needed.'); } // Perform deletion if (!$this->studentModel->delete($id)) { return redirect()->back()->with('error', 'Failed to delete student.'); } // ✅ Check if parent has any students left $remainingStudents = $this->studentModel ->where('parent_id', $parentId) ->countAllResults(); if ($remainingStudents === 0) { // Delete all emergency contacts linked to this parent $this->emergencyContactModel->where('parent_id', $parentId)->delete(); } return redirect()->to('/parent/child_register')->with('success', 'Student deleted successfully.'); } private function canParentDeleteStudent(array $student, int $parentId): bool { return $this->parentRegistrationService()->canParentDeleteStudent($student, $parentId); } public function addStudentForm() { $session = session(); $count = $session->get('registeredStudentsCount') ?? 0; return view('partials/student_form', [ 'registeredStudentsCount' => $count ]); } private function calculateAge($dob) { try { $parts = explode('-', $this->schoolYear); $startYear = isset($parts[0]) ? trim($parts[0]) : date('Y'); $registrationAgeDeadline = "$startYear-09-01"; // Convert both dates into DateTime objects $deadlineObj = \DateTime::createFromFormat('Y-m-d', $registrationAgeDeadline); $dobObj = \DateTime::createFromFormat('Y-m-d', $dob); if (!$deadlineObj || !$dobObj) { throw new \Exception('Invalid date format for DOB or Enrollment_deadline.'); } // For display or logging purposes (if needed) $deadlineForDisplay = $deadlineObj->format('Y-m-d'); $dobForDisplay = $dobObj->format('Y-m-d'); // Compare dates if ($dobObj > $deadlineObj) { log_message('debug', "DOB ($dobForDisplay) is after deadline ($deadlineForDisplay)"); return -1; // Invalid age case } // Calculate age $ageInterval = $dobObj->diff($deadlineObj); return $ageInterval->y; } catch (\Exception $e) { log_message('error', 'Invalid DOB or deadline in calculateAge(): ' . $e->getMessage()); return -1; } } private function calculateAgeAsOfSchoolYearStartYear(?string $dob, string $schoolYear): ?int { $dob = trim((string) $dob); $schoolYear = trim($schoolYear); if ($dob === '' || ! preg_match('/^(\d{4})/', $schoolYear, $matches)) { return null; } try { $timezone = new \DateTimeZone((string) (config('School')->attendance['timezone'] ?? user_timezone())); $birthDate = \DateTimeImmutable::createFromFormat('!Y-m-d', $dob, $timezone); $errors = \DateTimeImmutable::getLastErrors(); $hasParseErrors = is_array($errors) && (($errors['warning_count'] ?? 0) > 0 || ($errors['error_count'] ?? 0) > 0); if ($birthDate === false || $hasParseErrors) { return null; } $schoolYearStartYearCutoff = new \DateTimeImmutable($matches[1] . '-09-01', $timezone); if ($birthDate > $schoolYearStartYearCutoff) { return null; } return $birthDate->diff($schoolYearStartYearCutoff)->y; } catch (\Throwable $e) { log_message('warning', 'Unable to calculate school-year age from DOB: {message}', [ 'message' => $e->getMessage(), ]); return null; } } private function schoolYearAgeDeadline(string $schoolYear): string { if (preg_match('/^(\d{4})/', trim($schoolYear), $matches)) { return $matches[1] . '-09-01'; } if (!empty($this->schoolStartDate) && strtotime((string) $this->schoolStartDate)) { return (new \DateTimeImmutable((string) $this->schoolStartDate))->format('Y-m-d'); } return date('Y') . '-09-01'; } private function registrationMinimumAgeDeadline(string $schoolYear): string { $configured = trim((string) ($this->ageDateRefernce ?? $this->dateAgeReference ?? '')); if ($configured !== '' && strtotime($configured)) { return (new \DateTimeImmutable($configured))->format('Y-m-d'); } if (preg_match('/^(\d{4})-(\d{4})$/', trim($schoolYear), $matches)) { return $matches[2] . '-12-31'; } return date('Y') . '-12-31'; } public function parentEventPage() { $schoolYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? '')); $semester = session()->get('semester'); $parentId = session()->get('user_id'); return view('parent/event_participation', $this->parentEventParticipationService->pageData( (int) $parentId, $schoolYear, (string) $semester )); } public function updateParticipation() { $participations = $this->request->getPost('participation'); // ['student_id:event_id' => 'yes'|'no'] $parentId = session()->get('user_id'); $this->parentEventParticipationService->updateParticipation( is_array($participations) ? $participations : [], (int) $parentId, (string) $this->schoolYear, (string) $this->semester ); return redirect()->back()->with('success', 'Participation updated'); } private function canAccessUserRecord(int $id): bool { $userId = (int) (session()->get('user_id') ?? 0); return $this->parentAccountService->canAccessUserRecord( $id, $userId, array_merge((array) session()->get('roles'), [session()->get('role')]) ); } }