From 140be9922dd3f9dfd18e402f505038443d432121 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 30 Aug 2026 20:40:35 -0400 Subject: [PATCH] fix registration issue and split parent controller into services --- app/Controllers/View/ParentController.php | 1538 +++-------------- app/Models/StudentModel.php | 12 - app/Services/Parents/ParentAccountService.php | 186 ++ .../Parents/ParentAttendanceService.php | 27 + .../Parents/ParentEnrollmentService.php | 1097 ++++++++++++ .../ParentEventParticipationService.php | 98 ++ app/Services/Parents/ParentPaymentService.php | 38 + .../Parents/ParentRegistrationService.php | 724 ++++++++ tests/app/Models/StudentModelTest.php | 44 + 9 files changed, 2458 insertions(+), 1306 deletions(-) create mode 100644 app/Services/Parents/ParentAccountService.php create mode 100644 app/Services/Parents/ParentAttendanceService.php create mode 100644 app/Services/Parents/ParentEnrollmentService.php create mode 100644 app/Services/Parents/ParentEventParticipationService.php create mode 100644 app/Services/Parents/ParentPaymentService.php create mode 100644 app/Services/Parents/ParentRegistrationService.php diff --git a/app/Controllers/View/ParentController.php b/app/Controllers/View/ParentController.php index 2155048..b37399e 100644 --- a/app/Controllers/View/ParentController.php +++ b/app/Controllers/View/ParentController.php @@ -21,6 +21,12 @@ use CodeIgniter\Events\Events; use App\Services\FeeCalculationService; use App\Services\SchoolIdService; use App\Services\PhoneFormatterService; +use App\Services\Parents\ParentAccountService; +use App\Services\Parents\ParentAttendanceService; +use App\Services\Parents\ParentEnrollmentService; +use App\Services\Parents\ParentEventParticipationService; +use App\Services\Parents\ParentPaymentService; +use App\Services\Parents\ParentRegistrationService; use App\Support\Enrollment\DeliberationDecision; use App\Support\Enrollment\EnrollmentEligibility; use InvalidArgumentException; @@ -60,6 +66,12 @@ class ParentController extends BaseController protected ParentPolicyAcceptanceModel $policyAcceptanceModel; protected $schoolStartDate; protected $ageDateRefernce; + protected ParentAccountService $parentAccountService; + protected ParentAttendanceService $parentAttendanceService; + protected ?ParentEnrollmentService $parentEnrollmentService = null; + protected ParentEventParticipationService $parentEventParticipationService; + protected ParentPaymentService $parentPaymentService; + protected ?ParentRegistrationService $parentRegistrationService = null; @@ -80,6 +92,35 @@ class ParentController extends BaseController $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'); @@ -95,6 +136,48 @@ class ParentController extends BaseController 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 @@ -164,16 +247,7 @@ class ParentController extends BaseController $selectedYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? '')); - // Build query to retrieve attendance - $builder = $this->db->table('attendance_data'); - $builder->select('students.firstname, students.lastname, attendance_data.date, attendance_data.status, attendance_data.reason'); - $builder->join('students', 'students.id = attendance_data.student_id'); - $builder->where('attendance_data.school_year', $selectedYear); - // No semester filter so both semesters show for the selected year - $builder->where('students.parent_id', $parentId); - - $query = $builder->get(); - $attendanceResults = $query->getResultArray(); + $attendanceResults = $this->parentAttendanceService->attendanceForParent((int) $parentId, $selectedYear); // If no records found, set a flag to show message in the view if (empty($attendanceResults)) { @@ -215,18 +289,9 @@ class ParentController extends BaseController return view('/parent/payment', ['invoices' => [], 'error' => 'No user ID found in session.']); } - // Fetch invoices for the logged-in user based on parent IDs - $builder = $this->db->table('invoices'); - $builder->select('*'); - $builder->groupStart() - ->where('parent_id', $parentId) - //->orWhere('secondparent_user_id', $userId) - ->groupEnd(); - $query = $builder->get(); - $invoices = $query->getResultArray(); + $invoices = $this->parentPaymentService->invoicesForParent((int) $parentId); // Log the query and results for debugging - log_message('info', 'Query executed: ' . $builder->getCompiledSelect()); log_message('info', 'invoices: ' . print_r($invoices, true)); if (empty($invoices)) { @@ -244,7 +309,6 @@ class ParentController extends BaseController public function enrollClasses() { try { - // Get deadlines and school year from config if (!$this->schoolYear) { log_message('error', 'Current school year not found in configuration.'); return redirect()->back()->with('error', 'Configuration error: School year missing.'); @@ -253,8 +317,6 @@ class ParentController extends BaseController $context = $this->resolveSchoolYearContext(); $selectedYear = $context->yearName(); $isEditable = ! $context->isReadonly(); - - // Get parent ID from session $parentId = session()->get('user_id'); if (!$parentId) { @@ -262,174 +324,26 @@ class ParentController extends BaseController return redirect()->back()->with('error', 'User session error. Please log in again.'); } - // Verify user type is "parent" from the `users` table - $userData = $this->db->table('users') - ->select('user_type, accept_school_policy, firstname, lastname, cellphone, address_street, apt, city, state, zip') - ->where('id', $parentId) - ->get() - ->getRowArray(); + $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 (!$userData || $userData['user_type'] !== 'primary') { - return redirect()->back()->with('error', 'Invalid user type. Only primary parents can enroll.'); - } - - // Fetch students with parent_id = current logged in user - $students = $this->db->table('students') - ->where('parent_id', $parentId) - ->get() - ->getResultArray(); - - if (empty($students)) { - log_message('error', 'No students found for Parent ID: ' . $parentId); - return redirect()->to('/no-kids')->with('error', 'No students found. Please register your child first.'); - } - - $previousSchoolYear = $this->previousSchoolYearName($selectedYear); - $fallMakeupExamOn = $this->fallMakeupExamDateForYear($selectedYear); - - if ($previousSchoolYear !== null) { - service('enrollmentTransition')->syncParentFinancialReviewFlags( - (int) $parentId, - $previousSchoolYear, - $selectedYear, - array_values(array_map(static fn (array $student): int => (int) ($student['id'] ?? 0), $students)) - ); - } - - // Map enrollment statuses - $statusMap = [ - 'admission under review' => 'admission under review', - 'review & decision' => 'review & decision', - 'payment pending' => 'payment pending', - 'enrolled' => 'enrolled', - 'withdraw under review' => 'withdraw under review', - 'refund pending' => 'refund pending', - 'withdrawn' => 'withdrawn', - 'denied' => 'denied', - 'waitlist' => 'waitlist' // ✅ Add this line - ]; - - - // Attach enrollment and class section data to each student - foreach ($students as &$student) { - $studentId = $student['id']; - $student['age'] = $this->calculateAgeAsOfSchoolYearStartYear($student['dob'] ?? null, $selectedYear); - $student['allergies'] = $this->allergyModel - ->where('student_id', (int) $studentId) - ->findColumn('allergy') ?? []; - $student['medical_conditions'] = $this->medicalConditionModel - ->where('student_id', (int) $studentId) - ->findColumn('condition_name') ?? []; - - // Get class section info (can be multiple sections like Grade + Arabic) - $classSections = $this->studentClassModel->getClassSectionsByStudentId($studentId, $selectedYear, true); - $student['class_section'] = !empty($classSections) - ? implode(', ', $classSections) - : 'Class not Assigned'; - - // Get enrollment status AND admission status - $enrollment = $this->db->table('enrollments') - ->select('enrollment_status, admission_status') - ->where('student_id', $studentId) - ->where('school_year', $selectedYear) - ->get() - ->getRowArray(); - - // ✅ Apply admission status override logic - if ($enrollment && isset($enrollment['admission_status'])) { - $student['admission_status'] = $enrollment['admission_status']; - - if ($enrollment['admission_status'] === 'denied') { - // Force enrollment_status to denied if admission is denied - $student['enrollment_status'] = 'denied'; - } else { - // Use enrollment_status from DB if admission is accepted/pending - $student['enrollment_status'] = $enrollment && isset($statusMap[$enrollment['enrollment_status']]) - ? $statusMap[$enrollment['enrollment_status']] - : 'not enrolled'; - } - } else { - // No enrollment record found - $student['admission_status'] = null; - $student['enrollment_status'] = 'not enrolled'; + if (empty($result['ok'])) { + if (($result['code'] ?? '') === 'no_students') { + return redirect()->to('/no-kids')->with('error', (string) ($result['message'] ?? 'No students found.')); } - // No enrollment record fabrication from class assignment. - - // ✅ Updated disable logic to include denied status - $student['disable_enroll'] = in_array( - $student['enrollment_status'], - ['admission under review', 'review & decision', 'payment pending', 'enrolled', 'withdraw under review', 'denied'] - ); - - $decisionYear = $isEditable ? $previousSchoolYear : $selectedYear; - $student['previous_year_decision'] = $decisionYear !== null - ? $this->studentDecisionForYear((int) $studentId, $decisionYear) - : null; - - if ($isEditable) { - $student['transition_evaluation'] = $previousSchoolYear !== null - ? $this->transitionEvaluationForStudent((int) $parentId, (int) $studentId, $previousSchoolYear, $selectedYear) - : null; - $student['enrollment_eligibility_message'] = $this->eligibilityMessageFromTransition( - $student, - $student['transition_evaluation'], - $fallMakeupExamOn - ); - $student['expected_placement_label'] = $this->expectedPlacementLabel($student['transition_evaluation']); - $student['required_action_label'] = $this->requiredActionLabel($student['transition_evaluation']); - $student['parent_enrollment_state'] = $this->parentEnrollmentState($student); - } else { - $student['transition_evaluation'] = $this->readonlyEnrollmentEvaluation($student['previous_year_decision']); - $student['enrollment_eligibility_message'] = ['message' => '', 'blocking' => false, 'level' => 'info']; - $student['expected_placement_label'] = (string) ($student['class_section'] ?? 'Class not Assigned'); - $student['required_action_label'] = 'Read-only closed school year.'; - $student['parent_enrollment_state'] = $this->parentEnrollmentState($student); - } - } - unset($student); - - $this->ensureStudentYearStatusRows($students, $selectedYear); - service('studentYearStatus')->attachToStudents($students, $selectedYear); - foreach ($students as &$student) { - $studentId = (int) ($student['id'] ?? 0); - if ($studentId > 0 && $this->isReturningReEnrollmentStudent($studentId, $selectedYear)) { - $student['is_new'] = 0; - } - } - unset($student); - - $enrollmentStartStep = (int) $this->request->getGet('start'); - if ($enrollmentStartStep < 1 || $enrollmentStartStep > 5) { - $enrollmentStartStep = 0; + return redirect()->back()->with('error', (string) ($result['message'] ?? 'Unable to load enrollment.')); } - $preselectedStudentIds = []; - $studentsParam = trim((string) $this->request->getGet('students')); - if ($studentsParam !== '') { - $preselectedStudentIds = array_values(array_unique(array_filter( - array_map('intval', explode(',', $studentsParam)), - static fn (int $id): bool => $id > 0 - ))); - } - - // Render view - return view('/parent/enroll_classes', [ - 'students' => $students, - 'selectedYear' => $selectedYear, - 'previousSchoolYear' => $previousSchoolYear, - 'fallMakeupExamOn' => $fallMakeupExamOn, - 'isEditable' => $isEditable, - 'withdrawalDeadline' => $this->withdrawalDeadline, - 'lastDayOfRegistration' => $this->lastDayOfRegistration, - 'schoolStartDate' => $this->schoolStartDate, - 'hasAcceptedSchoolPolicy' => $this->hasAcceptedPolicyForYear((int) $parentId, $selectedYear), - 'familyFinancialSummary' => $this->familyFinancialSummary((int) $parentId, $previousSchoolYear, $selectedYear, $students), - 'enrollmentFeeSchedule' => $this->enrollmentFeeSchedule($selectedYear), - 'parentContact' => $this->parentContactForEnrollment($userData), - 'enrollmentStartStep' => $enrollmentStartStep, - 'preselectedStudentIds' => $preselectedStudentIds, - ]); + 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'); @@ -784,51 +698,11 @@ class ParentController extends BaseController */ private function generateInvoiceForParentEnrollment(int $parentId): array { - if ($parentId <= 0) { - return ['ok' => false, 'message' => 'Enrollment was submitted, but the parent invoice could not be generated.']; - } - - $schoolYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? '')); - $semester = (string) ($this->semester ?? getSemester()); - - try { - $result = $this->eventController->generateInvoice((string) $parentId, $schoolYear, $semester); - } catch (Throwable $e) { - log_message('error', 'Invoice generation failed after parent enrollment: {message}', [ - 'message' => $e->getMessage(), - 'parentId' => $parentId, - 'schoolYear' => $schoolYear, - 'semester' => $semester, - ]); - - return ['ok' => false, 'message' => 'Enrollment was submitted, but the invoice could not be generated. Please contact the school administration.']; - } - - if (is_array($result) && ! empty($result['ok'])) { - return [ - 'ok' => true, - 'message' => ! empty($result['updated']) ? 'Invoice updated.' : 'Invoice generated.', - ]; - } - - $message = is_array($result) ? (string) ($result['message'] ?? '') : ''; - if ($message === 'Invoice requires at least one non-zero line.') { - log_message('info', 'No invoice generated after parent enrollment because no billable invoice lines exist yet for parent {parentId}, year {schoolYear}.', [ - 'parentId' => $parentId, - 'schoolYear' => $schoolYear, - ]); - - return ['ok' => true, 'message' => 'No invoice was generated yet because there are no billable enrollment charges.']; - } - - log_message('error', 'Invoice generation returned an unsuccessful result after parent enrollment: {result}', [ - 'result' => json_encode($result), - 'parentId' => $parentId, - 'schoolYear' => $schoolYear, - 'semester' => $semester, - ]); - - return ['ok' => false, 'message' => 'Enrollment was submitted, but the invoice could not be generated. Please contact the school administration.']; + return $this->parentEnrollmentService()->generateInvoiceForParentEnrollment( + $parentId, + $this->currentSchoolYearName((string) ($this->schoolYear ?? '')), + (string) ($this->semester ?? getSemester()) + ); } private function enrollmentPayloadFromEvaluation(array $evaluation, array $base): array @@ -858,93 +732,11 @@ class ParentController extends BaseController private function updateEnrollmentStudentInfo(array $studentIds, int $parentId, string $schoolYear): array { $studentInfo = $this->request->getPost('student_info'); - if (! is_array($studentInfo)) { - return []; - } - - $submittedIds = array_fill_keys(array_map('intval', $studentIds), true); - $errors = []; - $updates = []; - - foreach ($studentInfo as $studentId => $fields) { - $studentId = (int) $studentId; - if ($studentId <= 0 || ! isset($submittedIds[$studentId]) || ! is_array($fields)) { - continue; - } - - $existing = $this->studentModel - ->where('id', $studentId) - ->where('parent_id', $parentId) - ->first(); - - if (! is_array($existing)) { - $errors[] = 'Student ID ' . $studentId . ': student record was not found.'; - continue; - } - - $studentLabel = trim((string) ($existing['firstname'] ?? '') . ' ' . (string) ($existing['lastname'] ?? '')) - ?: 'Student ID ' . $studentId; - $photoConsent = (string) ($fields['photo_consent'] ?? ''); - - if ($photoConsent !== '0' && $photoConsent !== '1') { - $errors[] = $studentLabel . ': photo consent is required.'; - continue; - } - - $medicalConditions = $this->normalizeEnrollmentHealthSelections( - $fields['medical_conditions'] ?? [], - (string) ($fields['medical_condition_other'] ?? '') - ); - $allergies = $this->normalizeEnrollmentHealthSelections( - $fields['allergies'] ?? [], - (string) ($fields['allergy_other'] ?? '') - ); - - if ($medicalConditions === []) { - $errors[] = $studentLabel . ': medical conditions are required.'; - continue; - } - - if ($allergies === []) { - $errors[] = $studentLabel . ': allergies are required.'; - continue; - } - - $updates[$studentId] = [ - 'photo_consent' => (int) $photoConsent, - 'medical_conditions' => $medicalConditions, - 'allergies' => $allergies, - ]; - } - - if ($errors !== []) { - return $errors; - } - - foreach ($updates as $studentId => $payload) { - if (! $this->studentModel->update($studentId, ['photo_consent' => $payload['photo_consent']])) { - $errors[] = 'Student ID ' . $studentId . ': student information could not be updated.'; - continue; - } - - $this->medicalConditionModel->where('student_id', $studentId)->delete(); - foreach ($payload['medical_conditions'] as $condition) { - $this->medicalConditionModel->insert([ - 'student_id' => $studentId, - 'condition_name' => $condition, - ]); - } - - $this->allergyModel->where('student_id', $studentId)->delete(); - foreach ($payload['allergies'] as $allergy) { - $this->allergyModel->insert([ - 'student_id' => $studentId, - 'allergy' => $allergy, - ]); - } - } - - return $errors; + return $this->parentEnrollmentService()->updateEnrollmentStudentInfo( + $studentIds, + $parentId, + is_array($studentInfo) ? $studentInfo : [] + ); } /** @@ -978,21 +770,10 @@ class ParentController extends BaseController private function updateEnrollmentParentContact(int $parentId): array { $fields = $this->request->getPost('parent_contact'); - $currentState = ''; - $user = $this->userModel->find($parentId); - if (is_array($user)) { - $currentState = strtoupper(trim((string) ($user['state'] ?? ''))); - } - $normalized = $this->normalizeEnrollmentParentContact(is_array($fields) ? $fields : [], $currentState); - if ($normalized['errors'] !== []) { - return $normalized['errors']; - } - - if (! $this->userModel->update($parentId, $normalized['data'])) { - return ['Parent contact information could not be updated.']; - } - - return []; + return $this->parentEnrollmentService()->updateEnrollmentParentContact( + $parentId, + is_array($fields) ? $fields : [] + ); } /** @@ -1001,61 +782,7 @@ class ParentController extends BaseController */ private function normalizeEnrollmentParentContact(array $fields, string $existingState = ''): array { - $phoneDigits = preg_replace('/\D/', '', (string) ($fields['cellphone'] ?? '')) ?? ''; - $street = $this->collapseContactWhitespace((string) ($fields['address_street'] ?? '')); - $apt = $this->collapseContactWhitespace((string) ($fields['apt'] ?? '')); - $city = $this->collapseContactWhitespace((string) ($fields['city'] ?? '')); - $state = strtoupper(trim((string) ($fields['state'] ?? ''))); - $zip = preg_replace('/\D/', '', (string) ($fields['zip'] ?? '')) ?? ''; - $errors = []; - $allowedStates = ['CT', 'ME', 'MA', 'NH', 'NY', 'RI', 'VT']; - $existingState = strtoupper(trim($existingState)); - if (preg_match('/^[A-Z]{2}$/', $existingState) === 1) { - $allowedStates[] = $existingState; - } - - if (strlen($phoneDigits) !== 10) { - $errors[] = 'A valid 10-digit home/cell phone number is required.'; - } - - if ($street === '' || strlen($street) < 3 || strlen($street) > 50 || ! preg_match('/^[A-Za-z0-9.\s\-]+$/', $street)) { - $errors[] = 'Home street address must be 3–50 characters and may contain only letters, numbers, spaces, periods, and hyphens.'; - } - - if ($apt !== '' && (strlen($apt) > 15 || ! preg_match('/^[A-Za-z0-9.\s\-]+$/', $apt))) { - $errors[] = 'Apartment or unit must be 15 characters or fewer and may contain only letters, numbers, spaces, periods, and hyphens.'; - } - - if (strlen($city) < 2 || strlen($city) > 30 || ! preg_match('/^[A-Za-z\s.\'\-]+$/', $city)) { - $errors[] = 'City must be 2–30 characters and may contain only letters, spaces, periods, apostrophes, and hyphens.'; - } - - if (! preg_match('/^[A-Z]{2}$/', $state) || ! in_array($state, $allowedStates, true)) { - $errors[] = 'State is required.'; - } - - if (! preg_match('/^\d{5}$/', $zip)) { - $errors[] = 'A valid 5-digit ZIP code is required.'; - } - - if ($errors !== []) { - return ['errors' => $errors, 'data' => []]; - } - - $formattedPhone = (new PhoneFormatterService())->formatPhoneNumber($phoneDigits); - - return [ - 'errors' => [], - 'data' => [ - 'cellphone' => $formattedPhone ?: $phoneDigits, - 'address_street' => ucwords(strtolower($street)), - 'apt' => strtoupper($apt), - 'city' => ucwords(strtolower($city), " -'"), - 'state' => $state, - 'zip' => $zip, - 'updated_at' => utc_now(), - ], - ]; + return $this->parentEnrollmentService()->normalizeEnrollmentParentContact($fields, $existingState); } private function collapseContactWhitespace(string $value): string @@ -1151,36 +878,18 @@ class ParentController extends BaseController private function hasAcceptedPolicyForYear(int $parentId, string $schoolYear): bool { - if ($parentId <= 0 || $schoolYear === '') { - return false; - } - - try { - return $this->policyAcceptanceModel->hasAccepted($parentId, $schoolYear); - } catch (Throwable $e) { - log_message('error', 'Failed to read parent policy acceptance: {message}', [ - 'message' => $e->getMessage(), - ]); - - return false; - } + return $this->parentEnrollmentService()->hasAcceptedPolicyForYear($parentId, $schoolYear); } private function recordPolicyAcceptance(int $parentId, string $schoolYear, string $source): void { - if ($parentId <= 0 || $schoolYear === '') { - throw new \RuntimeException('Unable to record school policy acceptance.'); - } - - if (! $this->policyAcceptanceModel->recordAcceptance( + $this->parentEnrollmentService()->recordPolicyAcceptance( $parentId, $schoolYear, $source, $this->request->getIPAddress(), $this->request->getUserAgent()->getAgentString() - )) { - throw new \RuntimeException('Unable to record school policy acceptance.'); - } + ); } /** @@ -2004,91 +1713,9 @@ class ParentController extends BaseController } $selectedYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? '')); - $previousSchoolYear = $this->previousSchoolYearName($selectedYear); - if ($previousSchoolYear === null) { - return $this->response->setJSON(['students' => []]); - } - - $students = $this->db->table('students') - ->select('id, firstname, lastname, dob') - ->where('parent_id', $parentId) - ->get() - ->getResultArray(); - - $transitionService = service('enrollmentTransition'); - $payload = []; - foreach ($students as $student) { - $studentId = (int) ($student['id'] ?? 0); - if ($studentId <= 0) { - continue; - } - - $existingEnrollment = $this->db->table('enrollments') - ->select('enrollment_status, admission_status') - ->where('student_id', $studentId) - ->where('school_year', $selectedYear) - ->orderBy('id', 'DESC') - ->get() - ->getRowArray(); - $existingStatus = is_array($existingEnrollment) ? (string) ($existingEnrollment['enrollment_status'] ?? '') : ''; - $existingAdmissionStatus = is_array($existingEnrollment) ? (string) ($existingEnrollment['admission_status'] ?? '') : ''; - - if ($this->hasSettledParentEnrollmentStatus($existingStatus, $existingAdmissionStatus)) { - $payload[] = [ - 'student_id' => $studentId, - 'student_name' => trim((string) ($student['firstname'] ?? '') . ' ' . (string) ($student['lastname'] ?? '')) ?: 'Student ID ' . $studentId, - 'can_enroll' => false, - 'primary_block_reason' => 'ALREADY_ENROLLED', - 'primary_parent_message' => EnrollmentEligibility::alreadyEnrolledMessage($existingStatus), - 'block_title' => EnrollmentEligibility::alreadyEnrolledTitle($existingStatus), - 'decision' => 'ALREADY_ENROLLED', - 'blocking_rule_codes' => ['ALREADY_ENROLLED'], - ]; - continue; - } - - $evaluation = $transitionService->evaluateForParent( - $parentId, - $studentId, - $previousSchoolYear, - $selectedYear - ); - $payload[] = [ - 'student_id' => $studentId, - 'student_name' => trim((string) ($student['firstname'] ?? '') . ' ' . (string) ($student['lastname'] ?? '')) ?: 'Student ID ' . $studentId, - 'can_enroll' => (bool) ($evaluation['can_enroll'] ?? false), - 'parent_enrollment_allowed' => (bool) ($evaluation['parent_enrollment_allowed'] ?? $evaluation['can_enroll'] ?? false), - 'first_enrollment' => (bool) ($evaluation['first_enrollment'] ?? false), - 'primary_block_reason' => $evaluation['primary_block_reason'] ?? null, - 'primary_parent_message' => $evaluation['primary_parent_message'] ?? null, - 'blocking_rule_codes' => array_values(array_map('strval', $evaluation['blocking_rule_codes'] ?? [])), - 'decision' => $evaluation['decision'] ?? null, - ]; - } - - $financialSummary = $transitionService->getEnrollmentFinancialSummary( - $parentId, - (string) $previousSchoolYear, - $selectedYear + return $this->response->setJSON( + $this->parentEnrollmentService()->eligibilityRefreshData($parentId, $selectedYear) ); - - if ($previousSchoolYear !== null) { - $transitionService->syncParentFinancialReviewFlags( - $parentId, - $previousSchoolYear, - $selectedYear, - array_values(array_map(static fn (array $student): int => (int) ($student['id'] ?? 0), $students)) - ); - } - - return $this->response->setJSON([ - 'students' => $payload, - 'financial_summary' => [ - 'carry_forward_balance' => (float) ($financialSummary['carry_forward_balance'] ?? 0), - 'current_year_balance' => (float) ($financialSummary['current_year_balance'] ?? 0), - 'total_enrollment_due' => (float) ($financialSummary['total_enrollment_due'] ?? 0), - ], - ]); } private function enrollmentTuitionDue(array $students): float @@ -2315,13 +1942,7 @@ class ParentController extends BaseController // Get the logged-in user's ID $userId = session()->get('user_id'); - // Fetch invoices records where the user is either the first or second parent - $invoices = $this->db->table('invoices') - ->select('invoices.*, registeredKids') // Assuming 'registeredKids' is a field - ->where('parent_id', $userId) - //->orWhere('secondparent_user_id', $userId) - ->get() - ->getResultArray(); + $invoices = $this->parentPaymentService->invoicesForParent((int) $userId, true); // Pass the invoices data to the view return view('/parent/payment', [ @@ -2333,10 +1954,7 @@ class ParentController extends BaseController { $parentId = session()->get('user_id'); - // Update all students for this parent to mark tuition as paid - $this->db->table('students') - ->where('parent_id', $parentId) - ->update(['tuition_paid' => 1]); + $this->parentPaymentService->markTuitionPaidForParent((int) $parentId); // Clear the total tuition fee from the session session()->remove('total_tuition_fee'); @@ -2365,112 +1983,12 @@ class ParentController extends BaseController public function createUserFromParentOrAuthorizedUser($userData, $relationToStudent) { - $schoolIdService = new SchoolIdService(); - - // Step 1: Generate a secure token for email verification - $token = bin2hex(random_bytes(48)); - $tokenHash = hash('sha256', $token); - - // Step 2: Determine user type based on relationship - $userType = in_array(strtolower($relationToStudent), ['wife', 'husband']) ? 'Secondary' : 'Tertiary'; - - // Step 3: Validate incoming user data - $validation = \Config\Services::validation(); - $validation->setRules([ - 'firstname' => [ - 'label' => 'First Name', - 'rules' => 'required|min_length[2]|max_length[100]|regex_match[/^[A-Za-z\s\-]+$/]', - 'errors' => [ - 'regex_match' => 'First name may only contain letters, spaces, and dashes.' - ] - ], - 'lastname' => [ - 'label' => 'Last Name', - 'rules' => 'required|min_length[2]|max_length[100]|regex_match[/^[A-Za-z\s\-]+$/]', - 'errors' => [ - 'regex_match' => 'Last name may only contain letters, spaces, and dashes.' - ] - ], - 'email' => [ - 'label' => 'Email Address', - 'rules' => 'required|valid_email|max_length[150]|is_unique[users.email]', - 'errors' => [ - 'is_unique' => 'This email is already registered.' - ] - ], - 'cellphone' => [ - 'label' => 'Cell Phone', - 'rules' => 'required|regex_match[/^\d{10}$/]', - 'errors' => [ - 'regex_match' => 'Phone number must be exactly 10 digits.' - ] - ], - 'gender' => 'required|in_list[Male,Female]', - 'city' => 'required|max_length[100]', - 'state' => 'required|max_length[100]', - 'zip' => 'required|regex_match[/^\d{5}$/]', - ]); - - if (!$validation->run($userData)) { - log_message('error', 'User creation failed due to invalid data: ' . json_encode($validation->getErrors())); - return false; - } - - // Step 4: Prepare sanitized user data - $userEntry = [ - 'firstname' => ucfirst(strtolower($userData['firstname'])), - 'lastname' => ucfirst(strtolower($userData['lastname'])), - 'gender' => $userData['gender'], - 'cellphone' => $userData['cellphone'], - 'email' => strtolower($userData['email']), - 'address_street' => $userData['address_street'] ?? '', - 'apt' => $userData['apt'] ?? null, - 'city' => ucfirst(strtolower($userData['city'])), - 'state' => strtoupper($userData['state']), - 'zip' => $userData['zip'], - 'accept_school_policy' => $userData['accept_school_policy'] ?? 0, - 'token' => $tokenHash, - 'is_verified' => 0, - 'status' => 'Inactive', - 'user_type' => $userType, - 'semester' => $this->semester, - 'school_year' => $this->schoolYear, - 'school_id' => $schoolIdService->generateUserSchoolId(), - ]; - - try { - // Step 5: Insert user into database - if (!$this->userModel->insert($userEntry)) { - log_message('error', 'Failed to insert user: ' . print_r($this->userModel->errors(), true)); - return false; - } - - $userId = $this->userModel->getInsertID(); - - // Step 6: Send activation email - $this->sendActivationEmail($userData['email'], $token); - - log_message('info', "User with ID $userId created successfully and activation email sent."); - return $userId; - } catch (Exception $e) { - log_message('error', 'Exception during user creation: ' . $e->getMessage()); - return false; - } - } - - private function sendActivationEmail($email, $token) - { - $emailController = new \App\Controllers\View\EmailController(); - - $subject = 'Activate Your Account'; - $activationLink = site_url('/user/confirm/' . $token); - $message = "Please click the following link to confirm your email and set your password: $activationLink"; - - if ($emailController->sendEmail($email, $subject, $message)) { - log_message('info', 'Activation email sent successfully to ' . $email); - } else { - log_message('error', 'Failed to send activation email to ' . $email); - } + return $this->parentAccountService->createRelatedUser( + (array) $userData, + (string) $relationToStudent, + (string) $this->semester, + (string) $this->schoolYear + ); } /** @@ -2481,52 +1999,7 @@ class ParentController extends BaseController */ protected function updateAuthorizedUsers($userId, $data) { - $validation = \Config\Services::validation(); - - // Step 1: Define validation rules for authorized user fields - $validation->setRules([ - 'email' => [ - 'label' => 'Email', - 'rules' => 'required|valid_email|max_length[150]', - 'errors' => [ - 'required' => 'Email is required.', - 'valid_email' => 'Please provide a valid email address.', - 'max_length' => 'Email must be less than 150 characters.' - ] - ], - 'name' => [ - 'label' => 'Name', - 'rules' => 'required|min_length[3]|max_length[100]|regex_match[/^[A-Za-z\s\-]+$/]', - 'errors' => [ - 'required' => 'Name is required.', - 'regex_match' => 'Name can only contain letters, spaces, and dashes.', - 'min_length' => 'Name must be at least 3 characters long.', - 'max_length' => 'Name must be less than 100 characters.' - ] - ] - ]); - - // Step 2: Run validation - if (!$validation->run($data)) { - log_message('error', 'Invalid authorized user data: ' . json_encode($validation->getErrors())); - return; // Skip update/insert if validation fails - } - - // Step 3: Check if an authorized user with same email/user ID exists - $existingAuthorizedUser = $this->authorizedUsersModel - ->where('user_id', $userId) - ->where('email', $data['email']) - ->first(); - - if ($existingAuthorizedUser) { - // Step 4: Update existing authorized user - $this->authorizedUsersModel->update($existingAuthorizedUser['id'], $data); - } else { - // Step 5: Insert new authorized user with pending status - $data['user_id'] = $userId; - $data['status'] = 'Pending'; // default status for new records - $this->authorizedUsersModel->insert($data); - } + $this->parentAccountService->updateAuthorizedUsers((int) $userId, (array) $data); } public function profile($id) @@ -2634,23 +2107,7 @@ class ParentController extends BaseController public function isEmailUnique(string $email): bool { - $tablesAndColumns = [ - 'users' => 'email', - 'emergency_contacts' => 'email', - ]; - - foreach ($tablesAndColumns as $table => $column) { - // Skip duplicate table lookups with different columns - $builder = $this->db->table($table); - $builder->where($column, $email); - $exists = $builder->countAllResults(); - - if ($exists > 0) { - return false; // Email already exists - } - } - - return true; // Email is unique + return $this->parentAccountService->isEmailUnique($email); } @@ -2675,124 +2132,26 @@ class ParentController extends BaseController } try { - // ✅ 1. Get existing data $data = $this->getRegistrationData($parentId); - $existingKids = $data['existingKids']; - $existingECs = $data['emergencies']; - $maxChilds = $data['maxChilds']; - $maxEmergency = $data['maxEmergency']; - - $existingKidsCount = count($existingKids); - $existingECCount = count($existingECs); - - $incomingFirstNames = $this->request->getPost('studentFirstName') ?? []; - $incomingLastNames = $this->request->getPost('studentLastName') ?? []; - $incomingDOBs = $this->request->getPost('dob') ?? []; - - $newStudentCount = count(array_filter($incomingFirstNames)); - - // ✅ 2. Duplicate student check against existing records - foreach ($incomingFirstNames as $i => $firstName) { - $lastName = trim($incomingLastNames[$i] ?? ''); - $dob = trim($incomingDOBs[$i] ?? ''); - - if (empty($firstName) || empty($lastName) || empty($dob)) continue; - - foreach ($existingKids as $kid) { - if ( - strtolower($kid['firstname']) === strtolower($firstName) && - strtolower($kid['lastname']) === strtolower($lastName) && - $kid['dob'] === $dob - ) { - return redirect()->back()->withInput()->with('error', "Duplicate student detected: {$firstName} {$lastName} with DOB {$dob} already exists."); - } - } + $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.')); } - // ✅ 3. Duplicate student check within incoming data - $seenStudents = []; - foreach ($incomingFirstNames as $i => $firstName) { - $lastName = trim($incomingLastNames[$i] ?? ''); - $dob = trim($incomingDOBs[$i] ?? ''); - - if (empty($firstName) || empty($lastName) || empty($dob)) continue; - - $key = strtolower($firstName . '|' . $lastName . '|' . $dob); - if (isset($seenStudents[$key])) { - return redirect()->back()->withInput()->with('error', "Duplicate student entry in the form: {$firstName} {$lastName} with DOB {$dob}."); - } - $seenStudents[$key] = true; - } - - // ✅ 4. Emergency contact validation - $incomingECFirst = $this->request->getPost('emergency_firstname') ?? []; - $incomingECLast = $this->request->getPost('emergency_lastname') ?? []; - $incomingECPhones = $this->request->getPost('emergency_phone') ?? []; - $incomingECEmails = $this->request->getPost('emergency_email') ?? []; - - $newECCount = count(array_filter($incomingECFirst)); - - // Against existing - foreach ($incomingECFirst as $i => $first) { - $last = trim($incomingECLast[$i] ?? ''); - $phone = preg_replace('/\D/', '', $incomingECPhones[$i] ?? ''); - $email = strtolower(trim($incomingECEmails[$i] ?? '')); - - if (empty($first) || empty($last)) continue; - - foreach ($existingECs as $contact) { - $existingPhone = preg_replace('/\D/', '', $contact['cellphone']); - $existingEmail = strtolower($contact['email']); - - if ( - strtolower($contact['emergency_contact_name']) === strtolower(trim($first . ' ' . $last)) || - ($phone && $phone === $existingPhone) || - ($email && $email === $existingEmail) - ) { - return redirect()->back()->withInput()->with('error', "Duplicate emergency contact: {$first} {$last} already exists."); - } - } - } - - // Within incoming data - $seenContacts = []; - foreach ($incomingECFirst as $i => $first) { - $last = trim($incomingECLast[$i] ?? ''); - $phone = preg_replace('/\D/', '', $incomingECPhones[$i] ?? ''); - $email = strtolower(trim($incomingECEmails[$i] ?? '')); - - if (empty($first) || empty($last)) continue; - - $key = strtolower($first . '|' . $last . '|' . $phone . '|' . $email); - if (isset($seenContacts[$key])) { - return redirect()->back()->withInput()->with('error', "Duplicate emergency contact entry in the form: {$first} {$last}."); - } - $seenContacts[$key] = true; - } - - // ✅ 5. Check limits - if (($existingKidsCount + $newStudentCount) > $maxChilds) { - return redirect()->back()->withInput()->with('error', "Student limit exceeded. You have $existingKidsCount and tried to add $newStudentCount (limit: $maxChilds)."); - } - - if (($existingECCount + $newECCount) > $maxEmergency) { - return redirect()->back()->withInput()->with('error', "Emergency contact limit exceeded. You have $existingECCount and tried to add $newECCount (limit: $maxEmergency)."); - } - - // ✅ 6. Start transaction and save $this->db->transStart(); $studentAdded = false; - - // Gather all POST data for last year flags - $rawPost = $this->request->getPost(); + $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($rawPost[$lastYearKey] ?? '')); + $lastYearVal = strtolower(trim($post[$lastYearKey] ?? '')); $isNew = match ($lastYearVal) { 'yes' => false, // was with us last year => not new @@ -2809,6 +2168,9 @@ class ParentController extends BaseController if ($result) { $studentAdded = true; + if (is_int($result) && $result > 0) { + $registeredStudentIds[] = $result; + } } } } @@ -2829,9 +2191,13 @@ class ParentController extends BaseController throw new \Exception('DB transaction failed.'); } + if ($registeredStudentIds !== []) { + $this->notifyAdminsAboutRegisteredStudents($registeredStudentIds, (int) $parentId); + } + if ($studentAdded) { - return redirect()->to(base_url('/parent/enroll_classes?' . http_build_query(['start' => 1]))) - ->with('success', 'Registration successful! Continue enrollment by selecting all students you want to enroll.'); + return redirect()->to(base_url('/parent/child_register')) + ->with('success', 'Registration successful!'); } return redirect()->to(base_url('/parent/child_register'))->with('success', 'Registration successful!'); @@ -2842,6 +2208,54 @@ class ParentController extends BaseController } } + /** + * 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() @@ -2867,260 +2281,42 @@ class ParentController extends BaseController return view('parent/register_student', $data); } - private function getEnrollmentsByParent($parentId, $schoolYear) - { - return $this->enrollmentModel - ->where('parent_id', $parentId) - ->where('school_year', $schoolYear) - ->orderBy('enrollment_date', 'DESC') - ->findAll(); - } - private function getRegistrationData(int $parentId): array { $schoolYearContext = $this->resolveSchoolYearContext(); $selectedSchoolYear = $schoolYearContext->yearName(); - $enrollments = $this->getEnrollmentsByParent($parentId, $selectedSchoolYear); - - $enrollmentMap = []; - if (!empty($enrollments)) { - foreach ($enrollments as $enroll) { - $enrollmentMap[$enroll['student_id']] = $enroll; - } - } - - $user = $this->userModel->find($parentId); - if (!$user || $user['user_type'] !== 'primary') { - throw new \RuntimeException('Only primary parents are allowed to register children.'); - } - - $kids = $this->studentModel->where('parent_id', $parentId)->findAll(); - foreach ($kids as &$kid) { - $studentId = (int) ($kid['id'] ?? 0); - $kid['allergies'] = $this->allergyModel - ->where('student_id', $studentId) - ->findColumn('allergy') ?? []; - - $kid['medical_conditions'] = $this->medicalConditionModel - ->where('student_id', $studentId) - ->findColumn('condition_name') ?? []; - - $kid['enrollment'] = isset($enrollmentMap[$studentId]['id']) && !empty($enrollmentMap[$studentId]['id']) ? 1 : 0; - } - unset($kid); - - $this->ensureStudentYearStatusRows($kids, $selectedSchoolYear); - service('studentYearStatus')->attachToStudents($kids, $selectedSchoolYear); - - foreach ($kids as &$kid) { - $kid['can_delete'] = $this->canParentDeleteStudent($kid, $parentId); - } - unset($kid); - - $emergencies = $this->emergencyContactModel->where('parent_id', $parentId)->findAll(); - - return [ - 'existingKids' => $kids, - 'emergencies' => $emergencies, - 'parent' => $user, - 'maxChilds' => $this->maxChilds, - 'maxEmergency' => $this->maxEmergency, - 'enrollments' => $enrollments, - 'selectedYear' => $selectedSchoolYear, - 'isEditable' => ! $schoolYearContext->isReadonly(), - ]; - } - - /** - * @param list> $students - */ - private function ensureStudentYearStatusRows(array $students, string $schoolYear): void - { - $schoolYear = trim($schoolYear); - if ($students === [] || ! preg_match('/^\d{4}-\d{4}$/', $schoolYear)) { - return; - } - - $studentYearStatus = service('studentYearStatus'); - foreach ($students as $student) { - $studentId = (int) ($student['id'] ?? $student['student_id'] ?? 0); - if ($studentId <= 0 || $studentYearStatus->hasStatus($studentId, $schoolYear)) { - continue; - } - - $isNew = (int) ($student['is_new'] ?? 1) === 1; - if (! $studentYearStatus->upsert($studentId, $schoolYear, $isNew)) { - log_message('error', 'Unable to repair student_year_status for student_id={studentId}, school_year={schoolYear}', [ - 'studentId' => $studentId, - 'schoolYear' => $schoolYear, - ]); - } - } + return $this->parentRegistrationService()->registrationData( + $parentId, + $selectedSchoolYear, + ! $schoolYearContext->isReadonly(), + $this->maxChilds, + $this->maxEmergency + ); } private function validateAndSaveOrUpdateStudent($idx, $parentId, $semester, $schoolYear, $schoolIdService, $isNew = null, $studentId = null) { - $firstName = $this->request->getPost('studentFirstName')[$idx] ?? null; - $lastName = $this->request->getPost('studentLastName')[$idx] ?? null; - $dob = $this->request->getPost('dob')[$idx] ?? null; - $gender = $this->request->getPost('gender')[$idx] ?? null; - $grade = $this->request->getPost('registration_grade')[$idx] ?? null; - $conditions = $this->request->getPost('medical_conditions')[$idx] ?? []; - $allergies = $this->request->getPost('allergies')[$idx] ?? []; - $photoRaw = $this->request->getPost('photo_consent')[$idx] ?? ''; - - if (!$firstName || !$lastName || !$dob || !$gender || !$grade) { - return false; - } - - // Normalize names: trim, collapse internal spaces, ucfirst each word - $normalize = function (string $s): string { - $s = trim($s); - $s = preg_replace('/\s+/', ' ', $s); // collapse whitespace - // Keep your existing formatName if you prefer; this is a safe default: - return mb_convert_case($s, MB_CASE_TITLE, 'UTF-8'); // "ahmed ali" -> "Ahmed Ali" - }; - - $firstName = $normalize($firstName); - $lastName = $normalize($lastName); - - // Still do your stricter name validation if desired - $this->validateNames($firstName); - $this->validateNames($lastName); - - $dobObj = new \DateTime($dob); - $schoolYearAgeDeadline = $this->schoolYearAgeDeadline($schoolYear); - $age = $this->calculateAgeAsOfSchoolYearStartYear($dob, $schoolYear); - - $validation = $this->validateDobAge( - $dob, - $this->registrationMinimumAgeDeadline($schoolYear), - 5, - 18, - $schoolYearAgeDeadline + $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 (!$validation['isValid']) { - $displayDeadline = (new DateTime($schoolYearAgeDeadline))->format('m-d-Y'); - session()->setFlashdata( - 'error', - "Student '{$firstName} {$lastName}' {$validation['message']}. General age is calculated as of {$displayDeadline}." - ); + + if (empty($result['ok'])) { + if (! empty($result['error'])) { + session()->setFlashdata('error', (string) $result['error']); + } + return false; } - $photoConsent = strtolower($photoRaw) === 'yes' ? 1 : 0; - - $studentData = [ - 'firstname' => $firstName, - 'lastname' => $lastName, - 'age' => $age, - 'dob' => $dobObj->format('Y-m-d'), - 'gender' => $gender, - 'registration_grade' => $grade, - 'photo_consent' => $photoConsent, - 'parent_id' => (int) $parentId, - 'year_of_registration' => date('Y'), - ]; - if ($this->db->fieldExists('school_year', 'students')) { - $studentData['school_year'] = $schoolYear; - } - - if (!is_null($isNew)) { - $studentData['is_new'] = $isNew ? 1 : 0; - } - - // ---------- DUPLICATE CHECK (case/space-insensitive) ---------- - // Using LOWER(TRIM(...)) bound safely; CI4 will bind the value. - $fnKey = mb_strtolower(trim(preg_replace('/\s+/', ' ', $firstName)), 'UTF-8'); - $lnKey = mb_strtolower(trim(preg_replace('/\s+/', ' ', $lastName)), 'UTF-8'); - $dobStr = $dobObj->format('Y-m-d'); - - $existingBuilder = $this->studentModel - ->where('parent_id', (int) $parentId) - ->where('dob', $dobStr) - ->where('firstname', $firstName) // already normalized above - ->where('lastname', $lastName); // already normalized above - - if ($this->db->fieldExists('school_year', 'students')) { - $existingBuilder->where('school_year', $schoolYear); - } - - $existing = $existingBuilder->first(); - - if (!$studentId && $existing) { - session()->setFlashdata( - 'error', - "Student '{$firstName} {$lastName}' with the same birthdate is already registered for $schoolYear." - ); - return false; - } - - - // ---------- UPDATE OR INSERT ---------- - if ($studentId) { - $existing = $this->studentModel->find((int) $studentId); - if (! is_array($existing) || (int) ($existing['parent_id'] ?? 0) !== (int) $parentId) { - session()->setFlashdata('error', 'Student record was not found for this parent account.'); - - return false; - } - - $this->auditParentStudentFieldChanges($existing, $studentData, (int) $parentId, 'parent_student_edit'); - $this->studentModel->update($studentId, $studentData); - - if ($this->parentEditAffectsEligibility($existing, $studentData)) { - $this->recheckEligibilityAfterParentEdit((int) $studentId, (int) $parentId, (string) $schoolYear); - } - } else { - $studentData['registration_date'] = utc_now(); - $studentData['tuition_paid'] = 0; - $studentData['school_id'] = $schoolIdService->generateStudentSchoolId(); - - try { - $studentId = $this->studentModel->insert($studentData, true); - } catch (\CodeIgniter\Database\Exceptions\DatabaseException $e) { - // If DB unique index (below) triggers 1062 duplicate-key, show a friendly message - if (strpos($e->getMessage(), '1062') !== false) { - session()->setFlashdata('error', "Student '{$firstName} {$lastName}' with the same birthdate is already registered for $schoolYear."); - return false; - } - throw $e; // other DB errors bubble up - } - } - - if (! is_null($isNew) && (int) $studentId > 0) { - $studentYearStatus = service('studentYearStatus'); - $statusSaved = $studentYearStatus->upsert((int) $studentId, (string) $schoolYear, (bool) $isNew); - if (! $statusSaved || ! $studentYearStatus->hasStatus((int) $studentId, (string) $schoolYear)) { - throw new \RuntimeException('Student year status could not be saved for student ID ' . (int) $studentId . ' and school year ' . (string) $schoolYear . '.'); - } - } - - // ---------- SAVE MEDICAL CONDITIONS ---------- - $this->medicalConditionModel->where('student_id', $studentId)->delete(); - foreach ((array) $conditions as $c) { - $c = trim($c); - if ($c !== '') { - $this->medicalConditionModel->insert([ - 'student_id' => $studentId, - 'condition_name' => $c, - ]); - } - } - - // ---------- SAVE ALLERGIES ---------- - $this->allergyModel->where('student_id', $studentId)->delete(); - foreach ((array) $allergies as $a) { - $a = trim($a); - if ($a !== '') { - $this->allergyModel->insert([ - 'student_id' => $studentId, - 'allergy' => $a, - ]); - } - } - - return (int) $studentId > 0 ? (int) $studentId : true; + return (int) ($result['student_id'] ?? 0) > 0 ? (int) $result['student_id'] : true; } @@ -3208,125 +2404,18 @@ class ParentController extends BaseController } - private function validateNames($name) - { - if (!preg_match('/^[A-Za-z\s\-]{2,30}$/', $name)) { - throw new InvalidArgumentException("Invalid name format: Only letters, spaces, or dashes (2–30 chars) allowed."); - } - } - private function saveEmergencyContact($parentId, $semester, $schoolYear, $single = null, $id = null) { - $phoneFormatter = new PhoneFormatterService(); // Load the formatter service + $result = $this->parentRegistrationService()->saveEmergencyContact( + (int) $parentId, + $this->request->getPost(), + is_array($single) ? $single : null, + $id === null ? null : (int) $id + ); - // ✅ SINGLE CONTACT MODE - if ($single !== null) { - $firstName = $this->formatName($single['first_name'] ?? ''); - $lastName = $this->formatName($single['last_name'] ?? ''); - $relation = trim($single['relation'] ?? ''); - $phone = $phoneFormatter->formatPhoneNumber($single['cellphone'] ?? ''); - $email = strtolower(trim($single['email'] ?? '')); - - if ($firstName === '' && $lastName === '' && $phone === '(000)-000-0000' && $email === '' && $relation === '') { - return; - } - - $this->validateNames($firstName); - $this->validateNames($lastName); - - if ($email && !filter_var($email, FILTER_VALIDATE_EMAIL)) { - throw new \Exception('Invalid email format for emergency contact.'); - } - - $fullName = $firstName . ' ' . $lastName; - - $data = [ - 'parent_id' => $parentId, - 'emergency_contact_name' => $fullName, - 'cellphone' => $phone, - 'email' => $email, - 'relation' => $relation, - 'updated_at' => utc_now(), - ]; - - if ($id !== null) { - $duplicate = $this->emergencyContactModel - ->where('parent_id', $parentId) - ->where('emergency_contact_name', $fullName) - ->where('cellphone', $phone) - ->where('email', $email) - ->where('relation', $relation) - ->where('id !=', $id) - ->first(); - - if ($duplicate) { - session()->setFlashdata('error', 'Another emergency contact with the same information already exists.'); - return redirect()->back()->withInput(); - } - - $this->emergencyContactModel->update($id, $data); - } else { - $exists = $this->emergencyContactModel->where([ - 'parent_id' => $parentId, - 'emergency_contact_name' => $fullName, - 'cellphone' => $phone, - 'email' => $email, - 'relation' => $relation, - ])->first(); - - if ($exists) { - session()->setFlashdata('error', 'This emergency contact is already registered.'); - return redirect()->back()->withInput(); - } - - $this->emergencyContactModel->insert($data); - } - } - - // ✅ BULK MODE - $firstNames = $this->request->getPost('emergency_firstname') ?? []; - $lastNames = $this->request->getPost('emergency_lastname') ?? []; - $relations = $this->request->getPost('emergency_relation') ?? []; - $phones = $this->request->getPost('emergency_phone') ?? []; - $emails = $this->request->getPost('emergency_email') ?? []; - - foreach ($firstNames as $idx => $first) { - $firstName = $this->formatName($first ?? ''); - $lastName = $this->formatName($lastNames[$idx] ?? ''); - $relation = trim($relations[$idx] ?? ''); - $phone = $phoneFormatter->formatPhoneNumber($phones[$idx] ?? ''); - $email = strtolower(trim($emails[$idx] ?? '')); - - if ($firstName === '' && $lastName === '' && $phone === '(000)-000-0000' && $email === '' && $relation === '') { - continue; - } - - if ($phone === '(000)-000-0000') { - throw new \Exception('Invalid phone number.'); - } - if ($email && !filter_var($email, FILTER_VALIDATE_EMAIL)) { - throw new \Exception('Invalid email format for emergency contact.'); - } - - $fullName = $firstName . ' ' . $lastName; - - $exists = $this->emergencyContactModel->where([ - 'parent_id' => $parentId, - 'emergency_contact_name' => $fullName, - 'cellphone' => $phone, - 'email' => $email, - 'relation' => $relation, - ])->first(); - - if (!$exists) { - $this->emergencyContactModel->insert([ - 'parent_id' => $parentId, - 'emergency_contact_name' => $fullName, - 'cellphone' => $phone, - 'email' => $email, - 'relation' => $relation, - ]); - } + if (empty($result['ok'])) { + session()->setFlashdata('error', (string) ($result['error'] ?? 'Emergency contact could not be saved.')); + return redirect()->back()->withInput(); } } @@ -3497,70 +2586,9 @@ class ParentController extends BaseController private function canParentDeleteStudent(array $student, int $parentId): bool { - $studentId = (int) ($student['id'] ?? 0); - if ($studentId <= 0) { - return false; - } - - $statusYear = trim((string) ($student['school_year'] ?? '')); - if ($statusYear === '') { - $statusYear = (string) (service('studentYearStatus')->activeSchoolYear() ?? ''); - } - $isNew = $statusYear !== '' - ? service('studentYearStatus')->isNew($studentId, $statusYear) - : ((string) ($student['is_new'] ?? '1') === '1'); - - if (! $isNew) { - return false; - } - - if ($this->studentHasEnrollmentHistory($studentId, $parentId)) { - return false; - } - - if ($this->studentHasClassAssignmentHistory($studentId)) { - return false; - } - - return true; + return $this->parentRegistrationService()->canParentDeleteStudent($student, $parentId); } - private function studentHasEnrollmentHistory(int $studentId, int $parentId): bool - { - if (! $this->db->tableExists('enrollments')) { - return false; - } - - return $this->db->table('enrollments') - ->where('student_id', $studentId) - ->where('parent_id', $parentId) - ->countAllResults() > 0; - } - - private function studentHasClassAssignmentHistory(int $studentId): bool - { - if (! $this->db->tableExists('student_class')) { - return false; - } - - return $this->db->table('student_class') - ->where('student_id', $studentId) - ->countAllResults() > 0; - } - - - - private function formatName(string $name): string - { - $name = trim($name); - $name = strtolower($name); - $name = ucwords($name, ' '); - $name = implode('-', array_map('ucfirst', explode('-', $name))); - - return $name; - } - - public function addStudentForm() { $session = session(); @@ -3675,51 +2703,11 @@ class ParentController extends BaseController $semester = session()->get('semester'); $parentId = session()->get('user_id'); - // Get active events - $activeEvents = $this->eventModel->getActiveEvents($schoolYear, $semester); - $activeEventCount = is_array($activeEvents) ? count($activeEvents) : 0; - - // Get charges (participation info) - $chargesList = $this->chargesModel->getChargesWithEventInfo($parentId, $schoolYear, $semester); - - // Build a map: "studentId:eventId" => [ 'participation' => ..., 'date' => ... ] - $charges = []; - $externalParticipantsByEvent = []; - foreach ($chargesList as $charge) { - $studentId = $charge['student_id'] ?? null; - $eventId = (int) ($charge['event_id'] ?? 0); - - if (!empty($studentId)) { - $key = $studentId . ':' . $eventId; - $charges[$key] = [ - 'participation' => $charge['participation'], - 'date' => $charge['updated_at'] ?? $charge['created_at'], // Use updated_at if available - ]; - continue; - } - - $externalName = trim((string) ($charge['external_firstname'] ?? '') . ' ' . (string) ($charge['external_lastname'] ?? '')); - if ($eventId > 0 && $externalName !== '') { - $externalParticipantsByEvent[$eventId][] = [ - 'name' => $externalName, - 'note' => (string) ($charge['external_note'] ?? ''), - 'participation' => (string) ($charge['participation'] ?? ''), - 'event_paid' => !empty($charge['event_paid']), - 'charged' => (float) ($charge['charged'] ?? ($charge['event_amount'] ?? 0)), - ]; - } - } - - // Get enrolled students - $students = $this->enrollmentModel->getEnrolledStudents($parentId, $schoolYear); - - return view('parent/event_participation', [ - 'activeEvents' => $activeEvents, - 'charges' => $charges, - 'externalParticipantsByEvent' => $externalParticipantsByEvent, - 'yourStudents' => $students, - 'activeEventCount' => $activeEventCount, - ]); + return view('parent/event_participation', $this->parentEventParticipationService->pageData( + (int) $parentId, + $schoolYear, + (string) $semester + )); } public function updateParticipation() @@ -3727,42 +2715,12 @@ class ParentController extends BaseController $participations = $this->request->getPost('participation'); // ['student_id:event_id' => 'yes'|'no'] $parentId = session()->get('user_id'); - foreach ($participations as $key => $value) { - [$studentId, $eventId] = explode(':', $key); - - $existing = $this->chargesModel->where([ - 'parent_id' => $parentId, - 'student_id' => $studentId, - 'event_id' => $eventId, - ])->first(); - - if ($value === 'no') { - if ($existing) { - $this->chargesModel->delete($existing['id']); - } - continue; // skip to next entry - } - - // value is 'yes' - if ($existing) { - $this->chargesModel->update($existing['id'], ['participation' => $value]); - } else { - $event = $this->eventModel->getEvent($eventId, $this->schoolYear); - - $this->chargesModel->insert([ - 'parent_id' => $parentId, - 'student_id' => $studentId, - 'event_id' => $eventId, - 'participation' => $value, - 'charged' => $event['amount'], - 'school_year' => $this->schoolYear, - 'semester' => $this->semester, - 'updated_by' => $parentId - ]); - } - } - //recalculate the invoice - $this->eventController->generateInvoice($parentId); + $this->parentEventParticipationService->updateParticipation( + is_array($participations) ? $participations : [], + (int) $parentId, + (string) $this->schoolYear, + (string) $this->semester + ); return redirect()->back()->with('success', 'Participation updated'); } @@ -3770,18 +2728,10 @@ class ParentController extends BaseController private function canAccessUserRecord(int $id): bool { $userId = (int) (session()->get('user_id') ?? 0); - if ($userId <= 0 || $id <= 0) { - return false; - } - if ($userId === $id) { - return true; - } - - $roles = array_map( - static fn ($role): string => strtolower(trim((string) $role)), - array_filter(array_merge((array) session()->get('roles'), [session()->get('role')])) + return $this->parentAccountService->canAccessUserRecord( + $id, + $userId, + array_merge((array) session()->get('roles'), [session()->get('role')]) ); - - return (bool) array_intersect($roles, ['administrator', 'administrative staff', 'principal', 'admin']); } } diff --git a/app/Models/StudentModel.php b/app/Models/StudentModel.php index 903fefc..eb101a4 100644 --- a/app/Models/StudentModel.php +++ b/app/Models/StudentModel.php @@ -492,18 +492,6 @@ class StudentModel extends Model ( student_class.student_id IS NOT NULL OR enrollments.student_id IS NOT NULL - OR ( - NOT EXISTS ( - SELECT 1 - FROM student_class sc_history - WHERE sc_history.student_id = students.id - ) - AND NOT EXISTS ( - SELECT 1 - FROM enrollments e_history - WHERE e_history.student_id = students.id - ) - ) ) "; } diff --git a/app/Services/Parents/ParentAccountService.php b/app/Services/Parents/ParentAccountService.php new file mode 100644 index 0000000..c5fc3d8 --- /dev/null +++ b/app/Services/Parents/ParentAccountService.php @@ -0,0 +1,186 @@ + strtolower(trim((string) $role)), + array_filter($sessionRoles) + ); + + return (bool) array_intersect($roles, ['administrator', 'administrative staff', 'principal', 'admin']); + } + + public function isEmailUnique(string $email): bool + { + foreach (['users' => 'email', 'emergency_contacts' => 'email'] as $table => $column) { + if ($this->db->table($table)->where($column, $email)->countAllResults() > 0) { + return false; + } + } + + return true; + } + + public function createRelatedUser(array $userData, string $relationToStudent, string $semester, string $schoolYear): int|false + { + $schoolIdService = new SchoolIdService(); + $token = bin2hex(random_bytes(48)); + $tokenHash = hash('sha256', $token); + $userType = in_array(strtolower($relationToStudent), ['wife', 'husband'], true) ? 'Secondary' : 'Tertiary'; + + $validation = \Config\Services::validation(); + $validation->setRules([ + 'firstname' => [ + 'label' => 'First Name', + 'rules' => 'required|min_length[2]|max_length[100]|regex_match[/^[A-Za-z\s\-]+$/]', + 'errors' => ['regex_match' => 'First name may only contain letters, spaces, and dashes.'], + ], + 'lastname' => [ + 'label' => 'Last Name', + 'rules' => 'required|min_length[2]|max_length[100]|regex_match[/^[A-Za-z\s\-]+$/]', + 'errors' => ['regex_match' => 'Last name may only contain letters, spaces, and dashes.'], + ], + 'email' => [ + 'label' => 'Email Address', + 'rules' => 'required|valid_email|max_length[150]|is_unique[users.email]', + 'errors' => ['is_unique' => 'This email is already registered.'], + ], + 'cellphone' => [ + 'label' => 'Cell Phone', + 'rules' => 'required|regex_match[/^\d{10}$/]', + 'errors' => ['regex_match' => 'Phone number must be exactly 10 digits.'], + ], + 'gender' => 'required|in_list[Male,Female]', + 'city' => 'required|max_length[100]', + 'state' => 'required|max_length[100]', + 'zip' => 'required|regex_match[/^\d{5}$/]', + ]); + + if (! $validation->run($userData)) { + log_message('error', 'User creation failed due to invalid data: ' . json_encode($validation->getErrors())); + return false; + } + + $userEntry = [ + 'firstname' => ucfirst(strtolower($userData['firstname'])), + 'lastname' => ucfirst(strtolower($userData['lastname'])), + 'gender' => $userData['gender'], + 'cellphone' => $userData['cellphone'], + 'email' => strtolower($userData['email']), + 'address_street' => $userData['address_street'] ?? '', + 'apt' => $userData['apt'] ?? null, + 'city' => ucfirst(strtolower($userData['city'])), + 'state' => strtoupper($userData['state']), + 'zip' => $userData['zip'], + 'accept_school_policy' => $userData['accept_school_policy'] ?? 0, + 'token' => $tokenHash, + 'is_verified' => 0, + 'status' => 'Inactive', + 'user_type' => $userType, + 'semester' => $semester, + 'school_year' => $schoolYear, + 'school_id' => $schoolIdService->generateUserSchoolId(), + ]; + + try { + if (! $this->userModel->insert($userEntry)) { + log_message('error', 'Failed to insert user: ' . print_r($this->userModel->errors(), true)); + return false; + } + + $userId = (int) $this->userModel->getInsertID(); + $this->sendActivationEmail((string) $userData['email'], $token); + log_message('info', "User with ID $userId created successfully and activation email sent."); + + return $userId; + } catch (Exception $e) { + log_message('error', 'Exception during user creation: ' . $e->getMessage()); + return false; + } + } + + public function updateAuthorizedUsers(int $userId, array $data): void + { + $validation = \Config\Services::validation(); + $validation->setRules([ + 'email' => [ + 'label' => 'Email', + 'rules' => 'required|valid_email|max_length[150]', + 'errors' => [ + 'required' => 'Email is required.', + 'valid_email' => 'Please provide a valid email address.', + 'max_length' => 'Email must be less than 150 characters.', + ], + ], + 'name' => [ + 'label' => 'Name', + 'rules' => 'required|min_length[3]|max_length[100]|regex_match[/^[A-Za-z\s\-]+$/]', + 'errors' => [ + 'required' => 'Name is required.', + 'regex_match' => 'Name can only contain letters, spaces, and dashes.', + 'min_length' => 'Name must be at least 3 characters long.', + 'max_length' => 'Name must be less than 100 characters.', + ], + ], + ]); + + if (! $validation->run($data)) { + log_message('error', 'Invalid authorized user data: ' . json_encode($validation->getErrors())); + return; + } + + $existingAuthorizedUser = $this->authorizedUsersModel + ->where('user_id', $userId) + ->where('email', $data['email']) + ->first(); + + if ($existingAuthorizedUser) { + $this->authorizedUsersModel->update($existingAuthorizedUser['id'], $data); + return; + } + + $data['user_id'] = $userId; + $data['status'] = 'Pending'; + $this->authorizedUsersModel->insert($data); + } + + private function sendActivationEmail(string $email, string $token): void + { + $emailController = new EmailController(); + $subject = 'Activate Your Account'; + $activationLink = site_url('/user/confirm/' . $token); + $message = "Please click the following link to confirm your email and set your password: $activationLink"; + + if ($emailController->sendEmail($email, $subject, $message)) { + log_message('info', 'Activation email sent successfully to ' . $email); + } else { + log_message('error', 'Failed to send activation email to ' . $email); + } + } +} diff --git a/app/Services/Parents/ParentAttendanceService.php b/app/Services/Parents/ParentAttendanceService.php new file mode 100644 index 0000000..f46829c --- /dev/null +++ b/app/Services/Parents/ParentAttendanceService.php @@ -0,0 +1,27 @@ +db->table('attendance_data') + ->select('students.firstname, students.lastname, attendance_data.date, attendance_data.status, attendance_data.reason') + ->join('students', 'students.id = attendance_data.student_id') + ->where('attendance_data.school_year', $schoolYear) + ->where('students.parent_id', $parentId) + ->get() + ->getResultArray(); + } +} diff --git a/app/Services/Parents/ParentEnrollmentService.php b/app/Services/Parents/ParentEnrollmentService.php new file mode 100644 index 0000000..2a793a1 --- /dev/null +++ b/app/Services/Parents/ParentEnrollmentService.php @@ -0,0 +1,1097 @@ +db->table('users') + ->select('user_type, accept_school_policy, firstname, lastname, cellphone, address_street, apt, city, state, zip') + ->where('id', $parentId) + ->get() + ->getRowArray(); + + if (! $userData || ($userData['user_type'] ?? '') !== 'primary') { + return [ + 'ok' => false, + 'code' => 'invalid_user_type', + 'message' => 'Invalid user type. Only primary parents can enroll.', + ]; + } + + $students = $this->db->table('students') + ->where('parent_id', $parentId) + ->get() + ->getResultArray(); + + if ($students === []) { + log_message('error', 'No students found for Parent ID: ' . $parentId); + return [ + 'ok' => false, + 'code' => 'no_students', + 'message' => 'No students found. Please register your child first.', + ]; + } + + $previousSchoolYear = $this->previousSchoolYearName($selectedYear); + $fallMakeupExamOn = $this->fallMakeupExamDateForYear($selectedYear); + + if ($previousSchoolYear !== null) { + service('enrollmentTransition')->syncParentFinancialReviewFlags( + $parentId, + $previousSchoolYear, + $selectedYear, + array_values(array_map(static fn (array $student): int => (int) ($student['id'] ?? 0), $students)) + ); + } + + $this->attachEnrollmentDataToStudents($students, $parentId, $selectedYear, $previousSchoolYear, $fallMakeupExamOn, $isEditable); + $this->ensureStudentYearStatusRows($students, $selectedYear); + service('studentYearStatus')->attachToStudents($students, $selectedYear); + + foreach ($students as &$student) { + $studentId = (int) ($student['id'] ?? 0); + if ($studentId > 0 && $this->isReturningReEnrollmentStudent($studentId, $selectedYear)) { + $student['is_new'] = 0; + } + } + unset($student); + + if ($enrollmentStartStep < 1 || $enrollmentStartStep > 5) { + $enrollmentStartStep = 0; + } + + $preselectedStudentIds = []; + if (trim($studentsParam) !== '') { + $preselectedStudentIds = array_values(array_unique(array_filter( + array_map('intval', explode(',', $studentsParam)), + static fn (int $id): bool => $id > 0 + ))); + } + + return [ + 'ok' => true, + 'data' => [ + 'students' => $students, + 'selectedYear' => $selectedYear, + 'previousSchoolYear' => $previousSchoolYear, + 'fallMakeupExamOn' => $fallMakeupExamOn, + 'isEditable' => $isEditable, + 'withdrawalDeadline' => $withdrawalDeadline, + 'lastDayOfRegistration' => $lastDayOfRegistration, + 'schoolStartDate' => $schoolStartDate, + 'hasAcceptedSchoolPolicy' => $this->hasAcceptedPolicyForYear($parentId, $selectedYear), + 'familyFinancialSummary' => $this->familyFinancialSummary($parentId, $previousSchoolYear, $selectedYear, $students), + 'enrollmentFeeSchedule' => $this->enrollmentFeeSchedule($selectedYear), + 'parentContact' => $this->parentContactForEnrollment($userData), + 'enrollmentStartStep' => $enrollmentStartStep, + 'preselectedStudentIds' => $preselectedStudentIds, + ], + ]; + } + + public function eligibilityRefreshData(int $parentId, string $selectedYear): array + { + $previousSchoolYear = $this->previousSchoolYearName($selectedYear); + if ($previousSchoolYear === null) { + return ['students' => []]; + } + + $students = $this->db->table('students') + ->select('id, firstname, lastname, dob') + ->where('parent_id', $parentId) + ->get() + ->getResultArray(); + + $transitionService = service('enrollmentTransition'); + $payload = []; + foreach ($students as $student) { + $studentId = (int) ($student['id'] ?? 0); + if ($studentId <= 0) { + continue; + } + + $existingEnrollment = $this->db->table('enrollments') + ->select('enrollment_status, admission_status') + ->where('student_id', $studentId) + ->where('school_year', $selectedYear) + ->orderBy('id', 'DESC') + ->get() + ->getRowArray(); + $existingStatus = is_array($existingEnrollment) ? (string) ($existingEnrollment['enrollment_status'] ?? '') : ''; + $existingAdmissionStatus = is_array($existingEnrollment) ? (string) ($existingEnrollment['admission_status'] ?? '') : ''; + + if ($this->hasSettledParentEnrollmentStatus($existingStatus, $existingAdmissionStatus)) { + $payload[] = [ + 'student_id' => $studentId, + 'student_name' => $this->studentNameFromRow($student, 'Student ID ' . $studentId), + 'can_enroll' => false, + 'primary_block_reason' => 'ALREADY_ENROLLED', + 'primary_parent_message' => EnrollmentEligibility::alreadyEnrolledMessage($existingStatus), + 'block_title' => EnrollmentEligibility::alreadyEnrolledTitle($existingStatus), + 'decision' => 'ALREADY_ENROLLED', + 'blocking_rule_codes' => ['ALREADY_ENROLLED'], + ]; + continue; + } + + $evaluation = $transitionService->evaluateForParent($parentId, $studentId, $previousSchoolYear, $selectedYear); + $payload[] = [ + 'student_id' => $studentId, + 'student_name' => $this->studentNameFromRow($student, 'Student ID ' . $studentId), + 'can_enroll' => (bool) ($evaluation['can_enroll'] ?? false), + 'parent_enrollment_allowed' => (bool) ($evaluation['parent_enrollment_allowed'] ?? $evaluation['can_enroll'] ?? false), + 'first_enrollment' => (bool) ($evaluation['first_enrollment'] ?? false), + 'primary_block_reason' => $evaluation['primary_block_reason'] ?? null, + 'primary_parent_message' => $evaluation['primary_parent_message'] ?? null, + 'blocking_rule_codes' => array_values(array_map('strval', $evaluation['blocking_rule_codes'] ?? [])), + 'decision' => $evaluation['decision'] ?? null, + ]; + } + + $financialSummary = $transitionService->getEnrollmentFinancialSummary($parentId, $previousSchoolYear, $selectedYear); + $transitionService->syncParentFinancialReviewFlags( + $parentId, + $previousSchoolYear, + $selectedYear, + array_values(array_map(static fn (array $student): int => (int) ($student['id'] ?? 0), $students)) + ); + + return [ + 'students' => $payload, + 'financial_summary' => [ + 'carry_forward_balance' => (float) ($financialSummary['carry_forward_balance'] ?? 0), + 'current_year_balance' => (float) ($financialSummary['current_year_balance'] ?? 0), + 'total_enrollment_due' => (float) ($financialSummary['total_enrollment_due'] ?? 0), + ], + ]; + } + + public function hasAcceptedPolicyForYear(int $parentId, string $schoolYear): bool + { + if ($parentId <= 0 || $schoolYear === '') { + return false; + } + + try { + return $this->policyAcceptanceModel->hasAccepted($parentId, $schoolYear); + } catch (Throwable $e) { + log_message('error', 'Failed to read parent policy acceptance: {message}', [ + 'message' => $e->getMessage(), + ]); + + return false; + } + } + + public function recordPolicyAcceptance(int $parentId, string $schoolYear, string $source, string $ipAddress, string $userAgent): void + { + if ($parentId <= 0 || $schoolYear === '') { + throw new \RuntimeException('Unable to record school policy acceptance.'); + } + + if (! $this->policyAcceptanceModel->recordAcceptance($parentId, $schoolYear, $source, $ipAddress, $userAgent)) { + throw new \RuntimeException('Unable to record school policy acceptance.'); + } + } + + public function updateEnrollmentStudentInfo(array $studentIds, int $parentId, array $studentInfo): array + { + if ($studentInfo === []) { + return []; + } + + $submittedIds = array_fill_keys(array_map('intval', $studentIds), true); + $errors = []; + $updates = []; + + foreach ($studentInfo as $studentId => $fields) { + $studentId = (int) $studentId; + if ($studentId <= 0 || ! isset($submittedIds[$studentId]) || ! is_array($fields)) { + continue; + } + + $existing = $this->studentModel + ->where('id', $studentId) + ->where('parent_id', $parentId) + ->first(); + + if (! is_array($existing)) { + $errors[] = 'Student ID ' . $studentId . ': student record was not found.'; + continue; + } + + $studentLabel = trim((string) ($existing['firstname'] ?? '') . ' ' . (string) ($existing['lastname'] ?? '')) + ?: 'Student ID ' . $studentId; + $photoConsent = (string) ($fields['photo_consent'] ?? ''); + + if ($photoConsent !== '0' && $photoConsent !== '1') { + $errors[] = $studentLabel . ': photo consent is required.'; + continue; + } + + $medicalConditions = $this->normalizeEnrollmentHealthSelections( + $fields['medical_conditions'] ?? [], + (string) ($fields['medical_condition_other'] ?? '') + ); + $allergies = $this->normalizeEnrollmentHealthSelections( + $fields['allergies'] ?? [], + (string) ($fields['allergy_other'] ?? '') + ); + + if ($medicalConditions === []) { + $errors[] = $studentLabel . ': medical conditions are required.'; + continue; + } + + if ($allergies === []) { + $errors[] = $studentLabel . ': allergies are required.'; + continue; + } + + $updates[$studentId] = [ + 'photo_consent' => (int) $photoConsent, + 'medical_conditions' => $medicalConditions, + 'allergies' => $allergies, + ]; + } + + if ($errors !== []) { + return $errors; + } + + foreach ($updates as $studentId => $payload) { + if (! $this->studentModel->update($studentId, ['photo_consent' => $payload['photo_consent']])) { + $errors[] = 'Student ID ' . $studentId . ': student information could not be updated.'; + continue; + } + + $this->medicalConditionModel->where('student_id', $studentId)->delete(); + foreach ($payload['medical_conditions'] as $condition) { + $this->medicalConditionModel->insert([ + 'student_id' => $studentId, + 'condition_name' => $condition, + ]); + } + + $this->allergyModel->where('student_id', $studentId)->delete(); + foreach ($payload['allergies'] as $allergy) { + $this->allergyModel->insert([ + 'student_id' => $studentId, + 'allergy' => $allergy, + ]); + } + } + + return $errors; + } + + public function updateEnrollmentParentContact(int $parentId, array $fields): array + { + $currentState = ''; + $user = $this->userModel->find($parentId); + if (is_array($user)) { + $currentState = strtoupper(trim((string) ($user['state'] ?? ''))); + } + + $normalized = $this->normalizeEnrollmentParentContact($fields, $currentState); + if ($normalized['errors'] !== []) { + return $normalized['errors']; + } + + if (! $this->userModel->update($parentId, $normalized['data'])) { + return ['Parent contact information could not be updated.']; + } + + return []; + } + + public function normalizeEnrollmentParentContact(array $fields, string $existingState = ''): array + { + $phoneDigits = preg_replace('/\D/', '', (string) ($fields['cellphone'] ?? '')) ?? ''; + $street = $this->collapseContactWhitespace((string) ($fields['address_street'] ?? '')); + $apt = $this->collapseContactWhitespace((string) ($fields['apt'] ?? '')); + $city = $this->collapseContactWhitespace((string) ($fields['city'] ?? '')); + $state = strtoupper(trim((string) ($fields['state'] ?? ''))); + $zip = preg_replace('/\D/', '', (string) ($fields['zip'] ?? '')) ?? ''; + $errors = []; + $allowedStates = ['CT', 'ME', 'MA', 'NH', 'NY', 'RI', 'VT']; + $existingState = strtoupper(trim($existingState)); + if (preg_match('/^[A-Z]{2}$/', $existingState) === 1) { + $allowedStates[] = $existingState; + } + + if (strlen($phoneDigits) !== 10) { + $errors[] = 'A valid 10-digit home/cell phone number is required.'; + } + + if ($street === '' || strlen($street) < 3 || strlen($street) > 50 || ! preg_match('/^[A-Za-z0-9.\s\-]+$/', $street)) { + $errors[] = 'Home street address must be 3-50 characters and may contain only letters, numbers, spaces, periods, and hyphens.'; + } + + if ($apt !== '' && (strlen($apt) > 15 || ! preg_match('/^[A-Za-z0-9.\s\-]+$/', $apt))) { + $errors[] = 'Apartment or unit must be 15 characters or fewer and may contain only letters, numbers, spaces, periods, and hyphens.'; + } + + if (strlen($city) < 2 || strlen($city) > 30 || ! preg_match('/^[A-Za-z\s.\'\-]+$/', $city)) { + $errors[] = 'City must be 2-30 characters and may contain only letters, spaces, periods, apostrophes, and hyphens.'; + } + + if (! preg_match('/^[A-Z]{2}$/', $state) || ! in_array($state, $allowedStates, true)) { + $errors[] = 'State is required.'; + } + + if (! preg_match('/^\d{5}$/', $zip)) { + $errors[] = 'A valid 5-digit ZIP code is required.'; + } + + if ($errors !== []) { + return ['errors' => $errors, 'data' => []]; + } + + $formattedPhone = (new PhoneFormatterService())->formatPhoneNumber($phoneDigits); + + return [ + 'errors' => [], + 'data' => [ + 'cellphone' => $formattedPhone ?: $phoneDigits, + 'address_street' => ucwords(strtolower($street)), + 'apt' => strtoupper($apt), + 'city' => ucwords(strtolower($city), " -'"), + 'state' => $state, + 'zip' => $zip, + 'updated_at' => utc_now(), + ], + ]; + } + + public function generateInvoiceForParentEnrollment(int $parentId, string $schoolYear, string $semester): array + { + if ($parentId <= 0) { + return ['ok' => false, 'message' => 'Enrollment was submitted, but the parent invoice could not be generated.']; + } + + try { + $invoiceController = $this->invoiceController ??= new InvoiceController(); + $result = $invoiceController->generateInvoice((string) $parentId, $schoolYear, $semester); + } catch (Throwable $e) { + log_message('error', 'Invoice generation failed after parent enrollment: {message}', [ + 'message' => $e->getMessage(), + 'parentId' => $parentId, + 'schoolYear' => $schoolYear, + 'semester' => $semester, + ]); + + return ['ok' => false, 'message' => 'Enrollment was submitted, but the invoice could not be generated. Please contact the school administration.']; + } + + if (is_array($result) && ! empty($result['ok'])) { + return [ + 'ok' => true, + 'message' => ! empty($result['updated']) ? 'Invoice updated.' : 'Invoice generated.', + ]; + } + + $message = is_array($result) ? (string) ($result['message'] ?? '') : ''; + if ($message === 'Invoice requires at least one non-zero line.') { + log_message('info', 'No invoice generated after parent enrollment because no billable invoice lines exist yet for parent {parentId}, year {schoolYear}.', [ + 'parentId' => $parentId, + 'schoolYear' => $schoolYear, + ]); + + return ['ok' => true, 'message' => 'No invoice was generated yet because there are no billable enrollment charges.']; + } + + log_message('error', 'Invoice generation returned an unsuccessful result after parent enrollment: {result}', [ + 'result' => json_encode($result), + 'parentId' => $parentId, + 'schoolYear' => $schoolYear, + 'semester' => $semester, + ]); + + return ['ok' => false, 'message' => 'Enrollment was submitted, but the invoice could not be generated. Please contact the school administration.']; + } + + private function attachEnrollmentDataToStudents( + array &$students, + int $parentId, + string $selectedYear, + ?string $previousSchoolYear, + ?string $fallMakeupExamOn, + bool $isEditable + ): void { + $statusMap = [ + 'admission under review' => 'admission under review', + 'review & decision' => 'review & decision', + 'payment pending' => 'payment pending', + 'enrolled' => 'enrolled', + 'withdraw under review' => 'withdraw under review', + 'refund pending' => 'refund pending', + 'withdrawn' => 'withdrawn', + 'denied' => 'denied', + 'waitlist' => 'waitlist', + ]; + + foreach ($students as &$student) { + $studentId = (int) ($student['id'] ?? 0); + $student['age'] = $this->calculateAgeAsOfSchoolYearStartYear($student['dob'] ?? null, $selectedYear); + $student['allergies'] = $this->allergyModel->where('student_id', $studentId)->findColumn('allergy') ?? []; + $student['medical_conditions'] = $this->medicalConditionModel->where('student_id', $studentId)->findColumn('condition_name') ?? []; + + $classSections = $this->studentClassModel->getClassSectionsByStudentId($studentId, $selectedYear, true); + $student['class_section'] = $classSections !== [] ? implode(', ', $classSections) : 'Class not Assigned'; + + $enrollment = $this->db->table('enrollments') + ->select('enrollment_status, admission_status') + ->where('student_id', $studentId) + ->where('school_year', $selectedYear) + ->get() + ->getRowArray(); + + if ($enrollment && isset($enrollment['admission_status'])) { + $student['admission_status'] = $enrollment['admission_status']; + $student['enrollment_status'] = $enrollment['admission_status'] === 'denied' + ? 'denied' + : ($statusMap[$enrollment['enrollment_status']] ?? 'not enrolled'); + } else { + $student['admission_status'] = null; + $student['enrollment_status'] = 'not enrolled'; + } + + $student['disable_enroll'] = in_array( + $student['enrollment_status'], + ['admission under review', 'review & decision', 'payment pending', 'enrolled', 'withdraw under review', 'denied'], + true + ); + + $decisionYear = $isEditable ? $previousSchoolYear : $selectedYear; + $student['previous_year_decision'] = $decisionYear !== null + ? $this->studentDecisionForYear($studentId, $decisionYear) + : null; + + if ($isEditable) { + $student['transition_evaluation'] = $previousSchoolYear !== null + ? $this->transitionEvaluationForStudent($parentId, $studentId, $previousSchoolYear, $selectedYear) + : null; + $student['enrollment_eligibility_message'] = $this->eligibilityMessageFromTransition( + $student, + $student['transition_evaluation'], + $fallMakeupExamOn, + $selectedYear + ); + $student['expected_placement_label'] = $this->expectedPlacementLabel($student['transition_evaluation']); + $student['required_action_label'] = $this->requiredActionLabel($student['transition_evaluation']); + $student['parent_enrollment_state'] = $this->parentEnrollmentState($student); + } else { + $student['transition_evaluation'] = $this->readonlyEnrollmentEvaluation($student['previous_year_decision']); + $student['enrollment_eligibility_message'] = ['message' => '', 'blocking' => false, 'level' => 'info']; + $student['expected_placement_label'] = (string) ($student['class_section'] ?? 'Class not Assigned'); + $student['required_action_label'] = 'Read-only closed school year.'; + $student['parent_enrollment_state'] = $this->parentEnrollmentState($student); + } + } + unset($student); + } + + 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'] ?? '')), + ]; + } + + private function normalizeEnrollmentHealthSelections($selected, string $otherText): array + { + $values = []; + foreach ((array) $selected as $value) { + $value = trim((string) $value); + if ($value !== '') { + $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); + } + } + + return array_values(array_combine($values, $values) ?: []); + } + + private function collapseContactWhitespace(string $value): string + { + return trim(preg_replace('/\s+/', ' ', $value) ?? ''); + } + + 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 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 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, string $selectedYear): 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 EnrollmentEligibility::parentDecisionMessage( + $student, + $student['previous_year_decision'] ?? null, + $selectedYear, + $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']; + } + + if ((string) ($evaluation['deliberation_decision'] ?? '') === DeliberationDecision::MAKE_UP_EXAM) { + $name = $this->studentNameFromRow($student); + $dateText = $fallMakeupExamOn !== null ? ' on ' . local_date($fallMakeupExamOn, 'm-d-Y') : ''; + + return [ + 'message' => $name . ' 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 expectedPlacementLabel(?array $evaluation): string + { + if ($evaluation === null || (($evaluation['blockers'] ?? []) !== [] && empty($evaluation['assigned_grade_id']))) { + return 'Pending'; + } + + return match ((string) ($evaluation['placement_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.'; + } + + return match ($this->parentEnrollmentStateFromEvaluation($evaluation, null)) { + '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') { + return $decision === 'ELIGIBLE_WITH_WARNING' || ($evaluation['warning_rule_codes'] ?? []) !== [] + ? 'Eligible with follow-up' + : '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 + { + $summary = service('enrollmentTransition')->getEnrollmentFinancialSummary( + $parentId, + $previousSchoolYear ?? '', + $selectedYear, + $this->enrollmentTuitionDue($students) + ); + $summary['policy_message'] = $this->financialPolicyMessage( + (string) ($summary['balance_behavior'] ?? 'submission_blocked_until_payment'), + (string) ($this->schoolYearConfig($selectedYear)['financial_policy_message'] ?? '') + ); + + return $summary; + } + + 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) { + return round(count($tuitionStudents) * (float) ($this->configModel->getConfig('second_student_fee') ?? 280), 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 ensureStudentYearStatusRows(array $students, string $schoolYear): void + { + $schoolYear = trim($schoolYear); + if ($students === [] || ! preg_match('/^\d{4}-\d{4}$/', $schoolYear)) { + return; + } + + $studentYearStatus = service('studentYearStatus'); + foreach ($students as $student) { + $studentId = (int) ($student['id'] ?? $student['student_id'] ?? 0); + if ($studentId <= 0 || $studentYearStatus->hasStatus($studentId, $schoolYear)) { + continue; + } + + $isNew = (int) ($student['is_new'] ?? 1) === 1; + if (! $studentYearStatus->upsert($studentId, $schoolYear, $isNew)) { + log_message('error', 'Unable to repair student_year_status for student_id={studentId}, school_year={schoolYear}', [ + 'studentId' => $studentId, + 'schoolYear' => $schoolYear, + ]); + } + } + } + + private function isReturningReEnrollmentStudent(int $studentId, string $targetSchoolYear): bool + { + $previousSchoolYear = $this->previousSchoolYearName($targetSchoolYear); + if ($studentId <= 0 || $previousSchoolYear === null) { + return false; + } + + try { + foreach ([ + ['student_decisions', ['student_id' => $studentId, 'school_year' => $previousSchoolYear]], + ['student_class', ['student_id' => $studentId, 'school_year' => $previousSchoolYear]], + ['enrollments', ['student_id' => $studentId, 'school_year' => $previousSchoolYear]], + ['promotion_queue', ['student_id' => $studentId, 'school_year_from' => $previousSchoolYear, 'school_year_to' => $targetSchoolYear]], + ] as [$table, $where]) { + if (! $this->db->tableExists($table)) { + continue; + } + + $builder = $this->db->table($table)->select('id'); + foreach ($where as $column => $value) { + $builder->where($column, $value); + } + + if ($builder->limit(1)->get()->getRowArray() !== null) { + return true; + } + } + } catch (Throwable $e) { + log_message('error', 'isReturningReEnrollmentStudent failed: ' . $e->getMessage()); + } + + return false; + } + + 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 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 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 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 previousSchoolYearName(string $schoolYear): ?string + { + if (! preg_match('/^(\d{4})-(\d{4})$/', trim($schoolYear), $matches)) { + return null; + } + + return ((int) $matches[1] - 1) . '-' . ((int) $matches[2] - 1); + } + + private function studentNameFromRow(array $student, string $fallback = 'The student'): string + { + $name = trim((string) ($student['firstname'] ?? '') . ' ' . (string) ($student['lastname'] ?? '')); + + return $name !== '' ? $name : $fallback; + } + + 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; + } +} diff --git a/app/Services/Parents/ParentEventParticipationService.php b/app/Services/Parents/ParentEventParticipationService.php new file mode 100644 index 0000000..5e042bc --- /dev/null +++ b/app/Services/Parents/ParentEventParticipationService.php @@ -0,0 +1,98 @@ +eventModel->getActiveEvents($schoolYear, $semester); + $chargesList = $this->chargesModel->getChargesWithEventInfo($parentId, $schoolYear, $semester); + + $charges = []; + $externalParticipantsByEvent = []; + foreach ($chargesList as $charge) { + $studentId = $charge['student_id'] ?? null; + $eventId = (int) ($charge['event_id'] ?? 0); + + if (! empty($studentId)) { + $charges[$studentId . ':' . $eventId] = [ + 'participation' => $charge['participation'], + 'date' => $charge['updated_at'] ?? $charge['created_at'], + ]; + continue; + } + + $externalName = trim((string) ($charge['external_firstname'] ?? '') . ' ' . (string) ($charge['external_lastname'] ?? '')); + if ($eventId > 0 && $externalName !== '') { + $externalParticipantsByEvent[$eventId][] = [ + 'name' => $externalName, + 'note' => (string) ($charge['external_note'] ?? ''), + 'participation' => (string) ($charge['participation'] ?? ''), + 'event_paid' => ! empty($charge['event_paid']), + 'charged' => (float) ($charge['charged'] ?? ($charge['event_amount'] ?? 0)), + ]; + } + } + + return [ + 'activeEvents' => $activeEvents, + 'charges' => $charges, + 'externalParticipantsByEvent' => $externalParticipantsByEvent, + 'yourStudents' => $this->enrollmentModel->getEnrolledStudents($parentId, $schoolYear), + 'activeEventCount' => is_array($activeEvents) ? count($activeEvents) : 0, + ]; + } + + public function updateParticipation(array $participations, int $parentId, string $schoolYear, string $semester): void + { + foreach ($participations as $key => $value) { + [$studentId, $eventId] = explode(':', (string) $key); + + $existing = $this->chargesModel->where([ + 'parent_id' => $parentId, + 'student_id' => $studentId, + 'event_id' => $eventId, + ])->first(); + + if ($value === 'no') { + if ($existing) { + $this->chargesModel->delete($existing['id']); + } + continue; + } + + if ($existing) { + $this->chargesModel->update($existing['id'], ['participation' => $value]); + continue; + } + + $event = $this->eventModel->getEvent($eventId, $schoolYear); + $this->chargesModel->insert([ + 'parent_id' => $parentId, + 'student_id' => $studentId, + 'event_id' => $eventId, + 'participation' => $value, + 'charged' => $event['amount'], + 'school_year' => $schoolYear, + 'semester' => $semester, + 'updated_by' => $parentId, + ]); + } + + $this->invoiceController->generateInvoice($parentId); + } +} diff --git a/app/Services/Parents/ParentPaymentService.php b/app/Services/Parents/ParentPaymentService.php new file mode 100644 index 0000000..5953de2 --- /dev/null +++ b/app/Services/Parents/ParentPaymentService.php @@ -0,0 +1,38 @@ +db->table('invoices') + ->select($select) + ->where('parent_id', $parentId) + ->get() + ->getResultArray(); + } + + public function markTuitionPaidForParent(int $parentId): void + { + if ($parentId <= 0) { + return; + } + + $this->db->table('students') + ->where('parent_id', $parentId) + ->update(['tuition_paid' => 1]); + } +} diff --git a/app/Services/Parents/ParentRegistrationService.php b/app/Services/Parents/ParentRegistrationService.php new file mode 100644 index 0000000..8336454 --- /dev/null +++ b/app/Services/Parents/ParentRegistrationService.php @@ -0,0 +1,724 @@ +getEnrollmentsByParent($parentId, $selectedSchoolYear); + $enrollmentMap = []; + foreach ($enrollments as $enroll) { + $enrollmentMap[$enroll['student_id']] = $enroll; + } + + $user = $this->userModel->find($parentId); + if (! $user || ($user['user_type'] ?? '') !== 'primary') { + throw new \RuntimeException('Only primary parents are allowed to register children.'); + } + + $kids = $this->studentModel->where('parent_id', $parentId)->findAll(); + foreach ($kids as &$kid) { + $studentId = (int) ($kid['id'] ?? 0); + $kid['allergies'] = $this->allergyModel->where('student_id', $studentId)->findColumn('allergy') ?? []; + $kid['medical_conditions'] = $this->medicalConditionModel->where('student_id', $studentId)->findColumn('condition_name') ?? []; + $kid['enrollment'] = isset($enrollmentMap[$studentId]['id']) && ! empty($enrollmentMap[$studentId]['id']) ? 1 : 0; + } + unset($kid); + + $this->ensureStudentYearStatusRows($kids, $selectedSchoolYear); + service('studentYearStatus')->attachToStudents($kids, $selectedSchoolYear); + + foreach ($kids as &$kid) { + $kid['can_delete'] = $this->canParentDeleteStudent($kid, $parentId); + } + unset($kid); + + return [ + 'existingKids' => $kids, + 'emergencies' => $this->emergencyContactModel->where('parent_id', $parentId)->findAll(), + 'parent' => $user, + 'maxChilds' => $maxChilds, + 'maxEmergency' => $maxEmergency, + 'enrollments' => $enrollments, + 'selectedYear' => $selectedSchoolYear, + 'isEditable' => $isEditable, + ]; + } + + public function validateRegistrationSubmission(array $post, array $registrationData): array + { + $existingKids = $registrationData['existingKids'] ?? []; + $existingECs = $registrationData['emergencies'] ?? []; + $maxChilds = (int) ($registrationData['maxChilds'] ?? 0); + $maxEmergency = (int) ($registrationData['maxEmergency'] ?? 0); + + $incomingFirstNames = (array) ($post['studentFirstName'] ?? []); + $incomingLastNames = (array) ($post['studentLastName'] ?? []); + $incomingDOBs = (array) ($post['dob'] ?? []); + $newStudentCount = count(array_filter($incomingFirstNames)); + + foreach ($incomingFirstNames as $i => $firstName) { + $lastName = trim($incomingLastNames[$i] ?? ''); + $dob = trim($incomingDOBs[$i] ?? ''); + if (empty($firstName) || empty($lastName) || empty($dob)) { + continue; + } + + foreach ($existingKids as $kid) { + if ( + strtolower($kid['firstname']) === strtolower($firstName) + && strtolower($kid['lastname']) === strtolower($lastName) + && $kid['dob'] === $dob + ) { + return ['ok' => false, 'error' => "Duplicate student detected: {$firstName} {$lastName} with DOB {$dob} already exists."]; + } + } + } + + $seenStudents = []; + foreach ($incomingFirstNames as $i => $firstName) { + $lastName = trim($incomingLastNames[$i] ?? ''); + $dob = trim($incomingDOBs[$i] ?? ''); + if (empty($firstName) || empty($lastName) || empty($dob)) { + continue; + } + + $key = strtolower($firstName . '|' . $lastName . '|' . $dob); + if (isset($seenStudents[$key])) { + return ['ok' => false, 'error' => "Duplicate student entry in the form: {$firstName} {$lastName} with DOB {$dob}."]; + } + $seenStudents[$key] = true; + } + + $incomingECFirst = (array) ($post['emergency_firstname'] ?? []); + $incomingECLast = (array) ($post['emergency_lastname'] ?? []); + $incomingECPhones = (array) ($post['emergency_phone'] ?? []); + $incomingECEmails = (array) ($post['emergency_email'] ?? []); + $newECCount = count(array_filter($incomingECFirst)); + + foreach ($incomingECFirst as $i => $first) { + $last = trim($incomingECLast[$i] ?? ''); + $phone = preg_replace('/\D/', '', $incomingECPhones[$i] ?? ''); + $email = strtolower(trim($incomingECEmails[$i] ?? '')); + if (empty($first) || empty($last)) { + continue; + } + + foreach ($existingECs as $contact) { + $existingPhone = preg_replace('/\D/', '', $contact['cellphone']); + $existingEmail = strtolower($contact['email']); + if ( + strtolower($contact['emergency_contact_name']) === strtolower(trim($first . ' ' . $last)) + || ($phone && $phone === $existingPhone) + || ($email && $email === $existingEmail) + ) { + return ['ok' => false, 'error' => "Duplicate emergency contact: {$first} {$last} already exists."]; + } + } + } + + $seenContacts = []; + foreach ($incomingECFirst as $i => $first) { + $last = trim($incomingECLast[$i] ?? ''); + $phone = preg_replace('/\D/', '', $incomingECPhones[$i] ?? ''); + $email = strtolower(trim($incomingECEmails[$i] ?? '')); + if (empty($first) || empty($last)) { + continue; + } + + $key = strtolower($first . '|' . $last . '|' . $phone . '|' . $email); + if (isset($seenContacts[$key])) { + return ['ok' => false, 'error' => "Duplicate emergency contact entry in the form: {$first} {$last}."]; + } + $seenContacts[$key] = true; + } + + $existingKidsCount = count($existingKids); + $existingECCount = count($existingECs); + if (($existingKidsCount + $newStudentCount) > $maxChilds) { + return ['ok' => false, 'error' => "Student limit exceeded. You have $existingKidsCount and tried to add $newStudentCount (limit: $maxChilds)."]; + } + + if (($existingECCount + $newECCount) > $maxEmergency) { + return ['ok' => false, 'error' => "Emergency contact limit exceeded. You have $existingECCount and tried to add $newECCount (limit: $maxEmergency)."]; + } + + return ['ok' => true]; + } + + public function saveStudentAtIndex( + int $idx, + array $post, + int $parentId, + string $schoolYear, + SchoolIdService $schoolIdService, + ?bool $isNew = null, + ?int $studentId = null, + ?string $schoolStartDate = null, + ?string $ageDateReference = null + ): array { + $firstName = $post['studentFirstName'][$idx] ?? null; + $lastName = $post['studentLastName'][$idx] ?? null; + $dob = $post['dob'][$idx] ?? null; + $gender = $post['gender'][$idx] ?? null; + $grade = $post['registration_grade'][$idx] ?? null; + $conditions = $post['medical_conditions'][$idx] ?? []; + $allergies = $post['allergies'][$idx] ?? []; + $photoRaw = $post['photo_consent'][$idx] ?? ''; + + if (! $firstName || ! $lastName || ! $dob || ! $gender || ! $grade) { + return ['ok' => false, 'empty' => true]; + } + + $firstName = $this->normalizeStudentName((string) $firstName); + $lastName = $this->normalizeStudentName((string) $lastName); + $this->validateNames($firstName); + $this->validateNames($lastName); + + $dobObj = new DateTime((string) $dob); + $schoolYearAgeDeadline = $this->schoolYearAgeDeadline($schoolYear, $schoolStartDate); + $age = $this->calculateAgeAsOfSchoolYearStartYear((string) $dob, $schoolYear); + $validation = $this->validateDobAge( + (string) $dob, + $this->registrationMinimumAgeDeadline($schoolYear, $ageDateReference), + 5, + 18, + $schoolYearAgeDeadline + ); + + if (! $validation['isValid']) { + $displayDeadline = (new DateTime($schoolYearAgeDeadline))->format('m-d-Y'); + return [ + 'ok' => false, + 'error' => "Student '{$firstName} {$lastName}' {$validation['message']}. General age is calculated as of {$displayDeadline}.", + ]; + } + + $studentData = [ + 'firstname' => $firstName, + 'lastname' => $lastName, + 'age' => $age, + 'dob' => $dobObj->format('Y-m-d'), + 'gender' => $gender, + 'registration_grade' => $grade, + 'photo_consent' => strtolower((string) $photoRaw) === 'yes' ? 1 : 0, + 'parent_id' => $parentId, + 'year_of_registration' => date('Y'), + ]; + + if ($this->db->fieldExists('school_year', 'students')) { + $studentData['school_year'] = $schoolYear; + } + + if ($isNew !== null) { + $studentData['is_new'] = $isNew ? 1 : 0; + } + + $existingBuilder = $this->studentModel + ->where('parent_id', $parentId) + ->where('dob', $dobObj->format('Y-m-d')) + ->where('firstname', $firstName) + ->where('lastname', $lastName); + + if ($this->db->fieldExists('school_year', 'students')) { + $existingBuilder->where('school_year', $schoolYear); + } + + if (! $studentId && $existingBuilder->first()) { + return ['ok' => false, 'error' => "Student '{$firstName} {$lastName}' with the same birthdate is already registered for $schoolYear."]; + } + + if ($studentId) { + $existing = $this->studentModel->find($studentId); + if (! is_array($existing) || (int) ($existing['parent_id'] ?? 0) !== $parentId) { + return ['ok' => false, 'error' => 'Student record was not found for this parent account.']; + } + + $this->auditParentStudentFieldChanges($existing, $studentData, $parentId, 'parent_student_edit'); + $this->studentModel->update($studentId, $studentData); + + if ($this->parentEditAffectsEligibility($existing, $studentData)) { + $this->recheckEligibilityAfterParentEdit($studentId, $parentId, $schoolYear); + } + } else { + $studentData['registration_date'] = utc_now(); + $studentData['tuition_paid'] = 0; + $studentData['school_id'] = $schoolIdService->generateStudentSchoolId(); + + try { + $studentId = (int) $this->studentModel->insert($studentData, true); + } catch (DatabaseException $e) { + if (strpos($e->getMessage(), '1062') !== false) { + return ['ok' => false, 'error' => "Student '{$firstName} {$lastName}' with the same birthdate is already registered for $schoolYear."]; + } + throw $e; + } + } + + if ($isNew !== null && $studentId > 0) { + $studentYearStatus = service('studentYearStatus'); + $statusSaved = $studentYearStatus->upsert($studentId, $schoolYear, $isNew); + if (! $statusSaved || ! $studentYearStatus->hasStatus($studentId, $schoolYear)) { + throw new \RuntimeException('Student year status could not be saved for student ID ' . $studentId . ' and school year ' . $schoolYear . '.'); + } + } + + $this->medicalConditionModel->where('student_id', $studentId)->delete(); + foreach ((array) $conditions as $condition) { + $condition = trim((string) $condition); + if ($condition !== '') { + $this->medicalConditionModel->insert(['student_id' => $studentId, 'condition_name' => $condition]); + } + } + + $this->allergyModel->where('student_id', $studentId)->delete(); + foreach ((array) $allergies as $allergy) { + $allergy = trim((string) $allergy); + if ($allergy !== '') { + $this->allergyModel->insert(['student_id' => $studentId, 'allergy' => $allergy]); + } + } + + return ['ok' => true, 'student_id' => $studentId]; + } + + public function saveEmergencyContact(int $parentId, array $post, ?array $single = null, ?int $id = null): array + { + $phoneFormatter = new PhoneFormatterService(); + + if ($single !== null) { + $firstName = $this->formatName($single['first_name'] ?? ''); + $lastName = $this->formatName($single['last_name'] ?? ''); + $relation = trim($single['relation'] ?? ''); + $phone = $phoneFormatter->formatPhoneNumber($single['cellphone'] ?? ''); + $email = strtolower(trim($single['email'] ?? '')); + + if ($firstName === '' && $lastName === '' && $phone === '(000)-000-0000' && $email === '' && $relation === '') { + return ['ok' => true, 'empty' => true]; + } + + $this->validateNames($firstName); + $this->validateNames($lastName); + + if ($email && ! filter_var($email, FILTER_VALIDATE_EMAIL)) { + throw new \Exception('Invalid email format for emergency contact.'); + } + + $data = [ + 'parent_id' => $parentId, + 'emergency_contact_name' => $firstName . ' ' . $lastName, + 'cellphone' => $phone, + 'email' => $email, + 'relation' => $relation, + 'updated_at' => utc_now(), + ]; + + $duplicateBuilder = $this->emergencyContactModel + ->where('parent_id', $parentId) + ->where('emergency_contact_name', $data['emergency_contact_name']) + ->where('cellphone', $phone) + ->where('email', $email) + ->where('relation', $relation); + + if ($id !== null) { + $duplicateBuilder->where('id !=', $id); + } + + if ($duplicateBuilder->first()) { + return ['ok' => false, 'error' => $id !== null + ? 'Another emergency contact with the same information already exists.' + : 'This emergency contact is already registered.']; + } + + if ($id !== null) { + $this->emergencyContactModel->update($id, $data); + } else { + $this->emergencyContactModel->insert($data); + } + + return ['ok' => true]; + } + + $firstNames = (array) ($post['emergency_firstname'] ?? []); + $lastNames = (array) ($post['emergency_lastname'] ?? []); + $relations = (array) ($post['emergency_relation'] ?? []); + $phones = (array) ($post['emergency_phone'] ?? []); + $emails = (array) ($post['emergency_email'] ?? []); + + foreach ($firstNames as $idx => $first) { + $firstName = $this->formatName($first ?? ''); + $lastName = $this->formatName($lastNames[$idx] ?? ''); + $relation = trim($relations[$idx] ?? ''); + $phone = $phoneFormatter->formatPhoneNumber($phones[$idx] ?? ''); + $email = strtolower(trim($emails[$idx] ?? '')); + + if ($firstName === '' && $lastName === '' && $phone === '(000)-000-0000' && $email === '' && $relation === '') { + continue; + } + + if ($phone === '(000)-000-0000') { + throw new \Exception('Invalid phone number.'); + } + if ($email && ! filter_var($email, FILTER_VALIDATE_EMAIL)) { + throw new \Exception('Invalid email format for emergency contact.'); + } + + $fullName = $firstName . ' ' . $lastName; + $exists = $this->emergencyContactModel->where([ + 'parent_id' => $parentId, + 'emergency_contact_name' => $fullName, + 'cellphone' => $phone, + 'email' => $email, + 'relation' => $relation, + ])->first(); + + if (! $exists) { + $this->emergencyContactModel->insert([ + 'parent_id' => $parentId, + 'emergency_contact_name' => $fullName, + 'cellphone' => $phone, + 'email' => $email, + 'relation' => $relation, + ]); + } + } + + return ['ok' => true]; + } + + public function canParentDeleteStudent(array $student, int $parentId): bool + { + $studentId = (int) ($student['id'] ?? 0); + if ($studentId <= 0) { + return false; + } + + $statusYear = trim((string) ($student['school_year'] ?? '')); + if ($statusYear === '') { + $statusYear = (string) (service('studentYearStatus')->activeSchoolYear() ?? ''); + } + + $isNew = $statusYear !== '' + ? service('studentYearStatus')->isNew($studentId, $statusYear) + : ((string) ($student['is_new'] ?? '1') === '1'); + + return $isNew + && ! $this->studentHasEnrollmentHistory($studentId, $parentId) + && ! $this->studentHasClassAssignmentHistory($studentId); + } + + public function validateDobAge( + string $dob, + string $registrationAgeDeadline, + int $minAge = 5, + int $maxAge = 18, + ?string $schoolYearAgeDeadline = null + ): array { + $response = ['isValid' => false, 'message' => '', 'age' => null]; + $tz = new DateTimeZone('UTC'); + $dob = trim($dob); + if ($dob === '') { + $response['message'] = 'Date of birth is required'; + return $response; + } + + $birthDate = DateTimeImmutable::createFromFormat('!Y-m-d', $dob, $tz); + $errs = DateTimeImmutable::getLastErrors(); + if ($birthDate === false || (is_array($errs) && (($errs['warning_count'] ?? 0) > 0 || ($errs['error_count'] ?? 0) > 0))) { + $response['message'] = 'Invalid date format (Use YYYY-MM-DD)'; + return $response; + } + + 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); + $ageAtDeadline = $birthDate->diff($ageDeadline)->y; + $ageAtMinimumAgeDeadline = $birthDate->diff($minimumAgeDeadline)->y; + $response['age'] = $ageAtDeadline; + + $minBirthDate = $ageDeadline->modify('-' . ($maxAge + 1) . ' years')->modify('+1 day')->setTime(0, 0, 0); + $maxBirthDate = $minimumAgeDeadline->modify("-{$minAge} years")->setTime(23, 59, 59); + $response['isValid'] = ($birthDate >= $minBirthDate) && ($birthDate <= $maxBirthDate); + + if (! $response['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; + } + + public function schoolYearAgeDeadline(string $schoolYear, ?string $schoolStartDate = null): string + { + if (preg_match('/^(\d{4})/', trim($schoolYear), $matches)) { + return $matches[1] . '-09-01'; + } + + if (! empty($schoolStartDate) && strtotime($schoolStartDate)) { + return (new DateTimeImmutable($schoolStartDate))->format('Y-m-d'); + } + + return date('Y') . '-09-01'; + } + + public function registrationMinimumAgeDeadline(string $schoolYear, ?string $ageDateReference = null): string + { + $configured = trim((string) $ageDateReference); + 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 formatName(string $name): string + { + $name = trim($name); + $name = strtolower($name); + $name = ucwords($name, ' '); + + return implode('-', array_map('ucfirst', explode('-', $name))); + } + + public function validateNames(string $name): void + { + if (! preg_match('/^[A-Za-z\s\-]{2,30}$/', $name)) { + throw new InvalidArgumentException('Invalid name format: Only letters, spaces, or dashes (2-30 chars) allowed.'); + } + } + + private function getEnrollmentsByParent(int $parentId, string $schoolYear): array + { + return $this->enrollmentModel + ->where('parent_id', $parentId) + ->where('school_year', $schoolYear) + ->orderBy('enrollment_date', 'DESC') + ->findAll(); + } + + private function ensureStudentYearStatusRows(array $students, string $schoolYear): void + { + $schoolYear = trim($schoolYear); + if ($students === [] || ! preg_match('/^\d{4}-\d{4}$/', $schoolYear)) { + return; + } + + $studentYearStatus = service('studentYearStatus'); + foreach ($students as $student) { + $studentId = (int) ($student['id'] ?? $student['student_id'] ?? 0); + if ($studentId <= 0 || $studentYearStatus->hasStatus($studentId, $schoolYear)) { + continue; + } + + $isNew = (int) ($student['is_new'] ?? 1) === 1; + if (! $studentYearStatus->upsert($studentId, $schoolYear, $isNew)) { + log_message('error', 'Unable to repair student_year_status for student_id={studentId}, school_year={schoolYear}', [ + 'studentId' => $studentId, + 'schoolYear' => $schoolYear, + ]); + } + } + } + + 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 normalizeStudentName(string $name): string + { + $name = trim(preg_replace('/\s+/', ' ', $name) ?? ''); + + return mb_convert_case($name, MB_CASE_TITLE, 'UTF-8'); + } + + 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; + } + + $changes = []; + foreach (['firstname', 'lastname', 'dob'] 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 previousSchoolYearName(string $schoolYear): ?string + { + if (! preg_match('/^(\d{4})-(\d{4})$/', trim($schoolYear), $matches)) { + return null; + } + + return ((int) $matches[1] - 1) . '-' . ((int) $matches[2] - 1); + } + + private function studentHasEnrollmentHistory(int $studentId, int $parentId): bool + { + if (! $this->db->tableExists('enrollments')) { + return false; + } + + return $this->db->table('enrollments') + ->where('student_id', $studentId) + ->where('parent_id', $parentId) + ->countAllResults() > 0; + } + + private function studentHasClassAssignmentHistory(int $studentId): bool + { + if (! $this->db->tableExists('student_class')) { + return false; + } + + return $this->db->table('student_class') + ->where('student_id', $studentId) + ->countAllResults() > 0; + } +} diff --git a/tests/app/Models/StudentModelTest.php b/tests/app/Models/StudentModelTest.php index 494371f..53a14dd 100644 --- a/tests/app/Models/StudentModelTest.php +++ b/tests/app/Models/StudentModelTest.php @@ -54,6 +54,50 @@ class StudentModelTest extends ModelCrudTestCase $this->assertNotContains($returningStudentId, $ids); } + public function testEnrollmentWithdrawalRosterExcludesRegisteredOnlyStudents(): void + { + $db = Database::connect('tests'); + $schoolYear = $this->validSchoolYear(); + $parentId = $this->insertParent($db, 'parent-roster-filter@example.test'); + $registeredOnlyId = $this->insertStudent($db, $parentId, 'Registered', 'Only'); + $enrolledId = $this->insertStudent($db, $parentId, 'Roster', 'Student'); + + $db->table('student_year_status')->insert([ + 'student_id' => $registeredOnlyId, + 'school_year' => $schoolYear, + 'is_new' => 1, + 'created_at' => date('Y-m-d H:i:s'), + 'updated_at' => date('Y-m-d H:i:s'), + ]); + $db->table('student_year_status')->insert([ + 'student_id' => $enrolledId, + 'school_year' => $schoolYear, + 'is_new' => 1, + 'created_at' => date('Y-m-d H:i:s'), + 'updated_at' => date('Y-m-d H:i:s'), + ]); + $db->table('enrollments')->insert([ + 'student_id' => $enrolledId, + 'class_section_id' => null, + 'parent_id' => $parentId, + 'enrollment_date' => date('Y-m-d'), + 'enrollment_status' => 'admission under review', + 'withdrawal_date' => null, + 'is_withdrawn' => 0, + 'admission_status' => 'pending', + 'semester' => 'Fall', + 'school_year' => $schoolYear, + 'created_at' => date('Y-m-d H:i:s'), + 'updated_at' => date('Y-m-d H:i:s'), + ]); + + $rows = (new StudentModel())->getStudentsWithClassAndEnrollment($schoolYear); + $ids = array_map(static fn (array $row): int => (int) $row['id'], $rows); + + $this->assertNotContains($registeredOnlyId, $ids); + $this->assertContains($enrolledId, $ids); + } + private function insertParent($db, string $email): int { $db->table('users')->insert([