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