Files
alrahma_sunday_school/app/Controllers/View/ParentController.php
T
root 849a4579e9
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 47s
Tests / PHPUnit (push) Successful in 1m22s
remove the school reference
2026-08-30 22:04:31 -04:00

2668 lines
109 KiB
PHP

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