getEnrollmentsByParent($parentId, $selectedSchoolYear); $enrollmentMap = []; foreach ($enrollments as $enroll) { $enrollmentMap[$enroll['student_id']] = $enroll; } $user = $this->userModel->find($parentId); if (! $user || ($user['user_type'] ?? '') !== 'primary') { throw new \RuntimeException('Only primary parents are allowed to register children.'); } $kids = $this->studentModel->where('parent_id', $parentId)->findAll(); foreach ($kids as &$kid) { $studentId = (int) ($kid['id'] ?? 0); $kid['allergies'] = $this->allergyModel->where('student_id', $studentId)->findColumn('allergy') ?? []; $kid['medical_conditions'] = $this->medicalConditionModel->where('student_id', $studentId)->findColumn('condition_name') ?? []; $kid['enrollment'] = isset($enrollmentMap[$studentId]['id']) && ! empty($enrollmentMap[$studentId]['id']) ? 1 : 0; } unset($kid); $this->ensureStudentYearStatusRows($kids, $selectedSchoolYear); service('studentYearStatus')->attachToStudents($kids, $selectedSchoolYear); foreach ($kids as &$kid) { $kid['can_delete'] = $this->canParentDeleteStudent($kid, $parentId); } unset($kid); return [ 'existingKids' => $kids, 'emergencies' => $this->emergencyContactModel->where('parent_id', $parentId)->findAll(), 'parent' => $user, 'maxChilds' => $maxChilds, 'maxEmergency' => $maxEmergency, 'enrollments' => $enrollments, 'selectedYear' => $selectedSchoolYear, 'isEditable' => $isEditable, ]; } public function validateRegistrationSubmission(array $post, array $registrationData): array { $existingKids = $registrationData['existingKids'] ?? []; $existingECs = $registrationData['emergencies'] ?? []; $maxChilds = (int) ($registrationData['maxChilds'] ?? 0); $maxEmergency = (int) ($registrationData['maxEmergency'] ?? 0); $incomingFirstNames = (array) ($post['studentFirstName'] ?? []); $incomingLastNames = (array) ($post['studentLastName'] ?? []); $incomingDOBs = (array) ($post['dob'] ?? []); $newStudentCount = count(array_filter($incomingFirstNames)); foreach ($incomingFirstNames as $i => $firstName) { $lastName = trim($incomingLastNames[$i] ?? ''); $dob = trim($incomingDOBs[$i] ?? ''); if (empty($firstName) || empty($lastName) || empty($dob)) { continue; } foreach ($existingKids as $kid) { if ( strtolower($kid['firstname']) === strtolower($firstName) && strtolower($kid['lastname']) === strtolower($lastName) && $kid['dob'] === $dob ) { return ['ok' => false, 'error' => "Duplicate student detected: {$firstName} {$lastName} with DOB {$dob} already exists."]; } } } $seenStudents = []; foreach ($incomingFirstNames as $i => $firstName) { $lastName = trim($incomingLastNames[$i] ?? ''); $dob = trim($incomingDOBs[$i] ?? ''); if (empty($firstName) || empty($lastName) || empty($dob)) { continue; } $key = strtolower($firstName . '|' . $lastName . '|' . $dob); if (isset($seenStudents[$key])) { return ['ok' => false, 'error' => "Duplicate student entry in the form: {$firstName} {$lastName} with DOB {$dob}."]; } $seenStudents[$key] = true; } $incomingECFirst = (array) ($post['emergency_firstname'] ?? []); $incomingECLast = (array) ($post['emergency_lastname'] ?? []); $incomingECPhones = (array) ($post['emergency_phone'] ?? []); $incomingECEmails = (array) ($post['emergency_email'] ?? []); $newECCount = count(array_filter($incomingECFirst)); foreach ($incomingECFirst as $i => $first) { $last = trim($incomingECLast[$i] ?? ''); $phone = preg_replace('/\D/', '', $incomingECPhones[$i] ?? ''); $email = strtolower(trim($incomingECEmails[$i] ?? '')); if (empty($first) || empty($last)) { continue; } foreach ($existingECs as $contact) { $existingPhone = preg_replace('/\D/', '', $contact['cellphone']); $existingEmail = strtolower($contact['email']); if ( strtolower($contact['emergency_contact_name']) === strtolower(trim($first . ' ' . $last)) || ($phone && $phone === $existingPhone) || ($email && $email === $existingEmail) ) { return ['ok' => false, 'error' => "Duplicate emergency contact: {$first} {$last} already exists."]; } } } $seenContacts = []; foreach ($incomingECFirst as $i => $first) { $last = trim($incomingECLast[$i] ?? ''); $phone = preg_replace('/\D/', '', $incomingECPhones[$i] ?? ''); $email = strtolower(trim($incomingECEmails[$i] ?? '')); if (empty($first) || empty($last)) { continue; } $key = strtolower($first . '|' . $last . '|' . $phone . '|' . $email); if (isset($seenContacts[$key])) { return ['ok' => false, 'error' => "Duplicate emergency contact entry in the form: {$first} {$last}."]; } $seenContacts[$key] = true; } $existingKidsCount = count($existingKids); $existingECCount = count($existingECs); if (($existingKidsCount + $newStudentCount) > $maxChilds) { return ['ok' => false, 'error' => "Student limit exceeded. You have $existingKidsCount and tried to add $newStudentCount (limit: $maxChilds)."]; } if (($existingECCount + $newECCount) > $maxEmergency) { return ['ok' => false, 'error' => "Emergency contact limit exceeded. You have $existingECCount and tried to add $newECCount (limit: $maxEmergency)."]; } return ['ok' => true]; } public function saveStudentAtIndex( int $idx, array $post, int $parentId, string $schoolYear, SchoolIdService $schoolIdService, ?bool $isNew = null, ?int $studentId = null, ?string $schoolStartDate = null, ?string $ageDateReference = null ): array { $firstName = $post['studentFirstName'][$idx] ?? null; $lastName = $post['studentLastName'][$idx] ?? null; $dob = $post['dob'][$idx] ?? null; $gender = $post['gender'][$idx] ?? null; $grade = $post['registration_grade'][$idx] ?? null; $conditions = $post['medical_conditions'][$idx] ?? []; $allergies = $post['allergies'][$idx] ?? []; $photoRaw = $post['photo_consent'][$idx] ?? ''; if (! $firstName || ! $lastName || ! $dob || ! $gender || ! $grade) { return ['ok' => false, 'empty' => true]; } $firstName = $this->normalizeStudentName((string) $firstName); $lastName = $this->normalizeStudentName((string) $lastName); $this->validateNames($firstName); $this->validateNames($lastName); $dobObj = new DateTime((string) $dob); $schoolYearAgeDeadline = $this->schoolYearAgeDeadline($schoolYear, $schoolStartDate); $age = $this->calculateAgeAsOfSchoolYearStartYear((string) $dob, $schoolYear); $validation = $this->validateDobAge( (string) $dob, $this->registrationMinimumAgeDeadline($schoolYear, $ageDateReference), 5, 18, $schoolYearAgeDeadline ); if (! $validation['isValid']) { $displayDeadline = (new DateTime($schoolYearAgeDeadline))->format('m-d-Y'); return [ 'ok' => false, 'error' => "Student '{$firstName} {$lastName}' {$validation['message']}. General age is calculated as of {$displayDeadline}.", ]; } $studentData = [ 'firstname' => $firstName, 'lastname' => $lastName, 'age' => $age, 'dob' => $dobObj->format('Y-m-d'), 'gender' => $gender, 'registration_grade' => $grade, 'photo_consent' => strtolower((string) $photoRaw) === 'yes' ? 1 : 0, 'parent_id' => $parentId, 'year_of_registration' => date('Y'), ]; if ($this->db->fieldExists('school_year', 'students')) { $studentData['school_year'] = $schoolYear; } if ($isNew !== null) { $studentData['is_new'] = $isNew ? 1 : 0; } $existingBuilder = $this->studentModel ->where('parent_id', $parentId) ->where('dob', $dobObj->format('Y-m-d')) ->where('firstname', $firstName) ->where('lastname', $lastName); if ($this->db->fieldExists('school_year', 'students')) { $existingBuilder->where('school_year', $schoolYear); } if (! $studentId && $existingBuilder->first()) { return ['ok' => false, 'error' => "Student '{$firstName} {$lastName}' with the same birthdate is already registered for $schoolYear."]; } if ($studentId) { $existing = $this->studentModel->find($studentId); if (! is_array($existing) || (int) ($existing['parent_id'] ?? 0) !== $parentId) { return ['ok' => false, 'error' => 'Student record was not found for this parent account.']; } $this->auditParentStudentFieldChanges($existing, $studentData, $parentId, 'parent_student_edit'); $this->studentModel->update($studentId, $studentData); if ($this->parentEditAffectsEligibility($existing, $studentData)) { $this->recheckEligibilityAfterParentEdit($studentId, $parentId, $schoolYear); } } else { $studentData['registration_date'] = utc_now(); $studentData['tuition_paid'] = 0; $studentData['school_id'] = $schoolIdService->generateStudentSchoolId(); try { $studentId = (int) $this->studentModel->insert($studentData, true); } catch (DatabaseException $e) { if (strpos($e->getMessage(), '1062') !== false) { return ['ok' => false, 'error' => "Student '{$firstName} {$lastName}' with the same birthdate is already registered for $schoolYear."]; } throw $e; } } if ($isNew !== null && $studentId > 0) { $studentYearStatus = service('studentYearStatus'); $statusSaved = $studentYearStatus->upsert($studentId, $schoolYear, $isNew); if (! $statusSaved || ! $studentYearStatus->hasStatus($studentId, $schoolYear)) { throw new \RuntimeException('Student year status could not be saved for student ID ' . $studentId . ' and school year ' . $schoolYear . '.'); } } $this->medicalConditionModel->where('student_id', $studentId)->delete(); foreach ((array) $conditions as $condition) { $condition = trim((string) $condition); if ($condition !== '') { $this->medicalConditionModel->insert(['student_id' => $studentId, 'condition_name' => $condition]); } } $this->allergyModel->where('student_id', $studentId)->delete(); foreach ((array) $allergies as $allergy) { $allergy = trim((string) $allergy); if ($allergy !== '') { $this->allergyModel->insert(['student_id' => $studentId, 'allergy' => $allergy]); } } return ['ok' => true, 'student_id' => $studentId]; } public function saveEmergencyContact(int $parentId, array $post, ?array $single = null, ?int $id = null): array { $phoneFormatter = new PhoneFormatterService(); if ($single !== null) { $firstName = $this->formatName($single['first_name'] ?? ''); $lastName = $this->formatName($single['last_name'] ?? ''); $relation = trim($single['relation'] ?? ''); $phone = $phoneFormatter->formatPhoneNumber($single['cellphone'] ?? ''); $email = strtolower(trim($single['email'] ?? '')); if ($firstName === '' && $lastName === '' && $phone === '(000)-000-0000' && $email === '' && $relation === '') { return ['ok' => true, 'empty' => true]; } $this->validateNames($firstName); $this->validateNames($lastName); if ($email && ! filter_var($email, FILTER_VALIDATE_EMAIL)) { throw new \Exception('Invalid email format for emergency contact.'); } $data = [ 'parent_id' => $parentId, 'emergency_contact_name' => $firstName . ' ' . $lastName, 'cellphone' => $phone, 'email' => $email, 'relation' => $relation, 'updated_at' => utc_now(), ]; $duplicateBuilder = $this->emergencyContactModel ->where('parent_id', $parentId) ->where('emergency_contact_name', $data['emergency_contact_name']) ->where('cellphone', $phone) ->where('email', $email) ->where('relation', $relation); if ($id !== null) { $duplicateBuilder->where('id !=', $id); } if ($duplicateBuilder->first()) { return ['ok' => false, 'error' => $id !== null ? 'Another emergency contact with the same information already exists.' : 'This emergency contact is already registered.']; } if ($id !== null) { $this->emergencyContactModel->update($id, $data); } else { $this->emergencyContactModel->insert($data); } return ['ok' => true]; } $firstNames = (array) ($post['emergency_firstname'] ?? []); $lastNames = (array) ($post['emergency_lastname'] ?? []); $relations = (array) ($post['emergency_relation'] ?? []); $phones = (array) ($post['emergency_phone'] ?? []); $emails = (array) ($post['emergency_email'] ?? []); foreach ($firstNames as $idx => $first) { $firstName = $this->formatName($first ?? ''); $lastName = $this->formatName($lastNames[$idx] ?? ''); $relation = trim($relations[$idx] ?? ''); $phone = $phoneFormatter->formatPhoneNumber($phones[$idx] ?? ''); $email = strtolower(trim($emails[$idx] ?? '')); if ($firstName === '' && $lastName === '' && $phone === '(000)-000-0000' && $email === '' && $relation === '') { continue; } if ($phone === '(000)-000-0000') { throw new \Exception('Invalid phone number.'); } if ($email && ! filter_var($email, FILTER_VALIDATE_EMAIL)) { throw new \Exception('Invalid email format for emergency contact.'); } $fullName = $firstName . ' ' . $lastName; $exists = $this->emergencyContactModel->where([ 'parent_id' => $parentId, 'emergency_contact_name' => $fullName, 'cellphone' => $phone, 'email' => $email, 'relation' => $relation, ])->first(); if (! $exists) { $this->emergencyContactModel->insert([ 'parent_id' => $parentId, 'emergency_contact_name' => $fullName, 'cellphone' => $phone, 'email' => $email, 'relation' => $relation, ]); } } return ['ok' => true]; } public function canParentDeleteStudent(array $student, int $parentId): bool { $studentId = (int) ($student['id'] ?? 0); if ($studentId <= 0) { return false; } $statusYear = trim((string) ($student['school_year'] ?? '')); if ($statusYear === '') { $statusYear = (string) (service('studentYearStatus')->activeSchoolYear() ?? ''); } $isNew = $statusYear !== '' ? service('studentYearStatus')->isNew($studentId, $statusYear) : ((string) ($student['is_new'] ?? '1') === '1'); return $isNew && ! $this->studentHasEnrollmentHistory($studentId, $parentId) && ! $this->studentHasClassAssignmentHistory($studentId); } public function validateDobAge( string $dob, string $registrationAgeDeadline, int $minAge = 5, int $maxAge = 18, ?string $schoolYearAgeDeadline = null ): array { $response = ['isValid' => false, 'message' => '', 'age' => null]; $tz = new DateTimeZone('UTC'); $dob = trim($dob); if ($dob === '') { $response['message'] = 'Date of birth is required'; return $response; } $birthDate = DateTimeImmutable::createFromFormat('!Y-m-d', $dob, $tz); $errs = DateTimeImmutable::getLastErrors(); if ($birthDate === false || (is_array($errs) && (($errs['warning_count'] ?? 0) > 0 || ($errs['error_count'] ?? 0) > 0))) { $response['message'] = 'Invalid date format (Use YYYY-MM-DD)'; return $response; } try { $minimumAgeDeadline = new DateTimeImmutable($registrationAgeDeadline, $tz); } catch (Throwable $e) { $minimumAgeDeadline = new DateTimeImmutable('now', $tz); } try { $ageDeadline = new DateTimeImmutable($schoolYearAgeDeadline ?: $registrationAgeDeadline, $tz); } catch (Throwable $e) { $ageDeadline = $minimumAgeDeadline; } $minimumAgeDeadline = $minimumAgeDeadline->setTime(23, 59, 59); $ageDeadline = $ageDeadline->setTime(23, 59, 59); $ageAtDeadline = $birthDate->diff($ageDeadline)->y; $ageAtMinimumAgeDeadline = $birthDate->diff($minimumAgeDeadline)->y; $response['age'] = $ageAtDeadline; $minBirthDate = $ageDeadline->modify('-' . ($maxAge + 1) . ' years')->modify('+1 day')->setTime(0, 0, 0); $maxBirthDate = $minimumAgeDeadline->modify("-{$minAge} years")->setTime(23, 59, 59); $response['isValid'] = ($birthDate >= $minBirthDate) && ($birthDate <= $maxBirthDate); if (! $response['isValid']) { $response['message'] = sprintf( 'Must be at least %d years old by %s and no older than %d by %s. Current registration age would be: %d', $minAge, $minimumAgeDeadline->format('m-d-Y'), $maxAge, $ageDeadline->format('m-d-Y'), $ageAtMinimumAgeDeadline ); } return $response; } public function schoolYearAgeDeadline(string $schoolYear, ?string $schoolStartDate = null): string { if (preg_match('/^(\d{4})/', trim($schoolYear), $matches)) { return $matches[1] . '-09-01'; } if (! empty($schoolStartDate) && strtotime($schoolStartDate)) { return (new DateTimeImmutable($schoolStartDate))->format('Y-m-d'); } return date('Y') . '-09-01'; } public function registrationMinimumAgeDeadline(string $schoolYear, ?string $ageDateReference = null): string { $configured = trim((string) $ageDateReference); if ($configured !== '' && strtotime($configured)) { return (new DateTimeImmutable($configured))->format('Y-m-d'); } if (preg_match('/^(\d{4})-(\d{4})$/', trim($schoolYear), $matches)) { return $matches[2] . '-12-31'; } return date('Y') . '-12-31'; } public function formatName(string $name): string { $name = trim($name); $name = strtolower($name); $name = ucwords($name, ' '); return implode('-', array_map('ucfirst', explode('-', $name))); } public function validateNames(string $name): void { if (! preg_match('/^[A-Za-z\s\-]{2,30}$/', $name)) { throw new InvalidArgumentException('Invalid name format: Only letters, spaces, or dashes (2-30 chars) allowed.'); } } private function getEnrollmentsByParent(int $parentId, string $schoolYear): array { return $this->enrollmentModel ->where('parent_id', $parentId) ->where('school_year', $schoolYear) ->orderBy('enrollment_date', 'DESC') ->findAll(); } private function ensureStudentYearStatusRows(array $students, string $schoolYear): void { $schoolYear = trim($schoolYear); if ($students === [] || ! preg_match('/^\d{4}-\d{4}$/', $schoolYear)) { return; } $studentYearStatus = service('studentYearStatus'); foreach ($students as $student) { $studentId = (int) ($student['id'] ?? $student['student_id'] ?? 0); if ($studentId <= 0 || $studentYearStatus->hasStatus($studentId, $schoolYear)) { continue; } $isNew = (int) ($student['is_new'] ?? 1) === 1; if (! $studentYearStatus->upsert($studentId, $schoolYear, $isNew)) { log_message('error', 'Unable to repair student_year_status for student_id={studentId}, school_year={schoolYear}', [ 'studentId' => $studentId, 'schoolYear' => $schoolYear, ]); } } } private function calculateAgeAsOfSchoolYearStartYear(?string $dob, string $schoolYear): ?int { $dob = trim((string) $dob); $schoolYear = trim($schoolYear); if ($dob === '' || ! preg_match('/^(\d{4})/', $schoolYear, $matches)) { return null; } try { $timezone = new DateTimeZone((string) (config('School')->attendance['timezone'] ?? user_timezone())); $birthDate = DateTimeImmutable::createFromFormat('!Y-m-d', $dob, $timezone); $errors = DateTimeImmutable::getLastErrors(); $hasParseErrors = is_array($errors) && (($errors['warning_count'] ?? 0) > 0 || ($errors['error_count'] ?? 0) > 0); if ($birthDate === false || $hasParseErrors) { return null; } $schoolYearStartYearCutoff = new DateTimeImmutable($matches[1] . '-09-01', $timezone); if ($birthDate > $schoolYearStartYearCutoff) { return null; } return $birthDate->diff($schoolYearStartYearCutoff)->y; } catch (Throwable $e) { log_message('warning', 'Unable to calculate school-year age from DOB: {message}', [ 'message' => $e->getMessage(), ]); return null; } } private function normalizeStudentName(string $name): string { $name = trim(preg_replace('/\s+/', ' ', $name) ?? ''); return mb_convert_case($name, MB_CASE_TITLE, 'UTF-8'); } private function auditParentStudentFieldChanges(array $original, array $updated, int $parentId, string $source): void { if (! $this->db->tableExists('enrollment_transition_audits')) { return; } $studentId = (int) ($original['id'] ?? 0); if ($studentId <= 0) { return; } $changes = []; foreach (['firstname', 'lastname', 'dob'] as $field) { $oldValue = trim((string) ($original[$field] ?? '')); $newValue = trim((string) ($updated[$field] ?? '')); if ($oldValue !== $newValue) { $changes[$field] = [ 'old_value' => $oldValue, 'new_value' => $newValue, 'changed' => true, 'changed_by' => $parentId, 'changed_at' => date('Y-m-d H:i:s'), 'source' => $source, ]; } } if ($changes === []) { return; } $this->db->table('enrollment_transition_audits')->insert([ 'student_id' => $studentId, 'school_year' => (string) ($updated['school_year'] ?? $original['school_year'] ?? date('Y')), 'source_school_year' => null, 'action' => 'parent_student_field_edit', 'performed_by' => $parentId, 'original_values_json' => json_encode($original, JSON_UNESCAPED_SLASHES), 'new_values_json' => json_encode(['changes' => $changes, 'updated' => $updated], JSON_UNESCAPED_SLASHES), 'reason' => $source, 'created_at' => date('Y-m-d H:i:s'), ]); } private function parentEditAffectsEligibility(array $original, array $updated): bool { return trim((string) ($original['dob'] ?? '')) !== trim((string) ($updated['dob'] ?? '')) || trim((string) ($original['lastname'] ?? '')) !== trim((string) ($updated['lastname'] ?? '')); } private function recheckEligibilityAfterParentEdit(int $studentId, int $parentId, string $targetSchoolYear): void { $previousSchoolYear = $this->previousSchoolYearName($targetSchoolYear); if ($previousSchoolYear === null) { return; } $evaluation = service('enrollmentTransition')->evaluateForParent( $parentId, $studentId, $previousSchoolYear, $targetSchoolYear, 'parent' ); if (($evaluation['can_enroll'] ?? false) === true || ($evaluation['parent_enrollment_allowed'] ?? false) === true) { return; } $message = (string) ($evaluation['primary_parent_message'] ?? 'Updated student information affects enrollment eligibility.'); service('enrollmentTransition')->logEnrollmentBlock($evaluation, 'parent_student_edit', $parentId, $parentId); session()->setFlashdata('warning', $message); } private function previousSchoolYearName(string $schoolYear): ?string { if (! preg_match('/^(\d{4})-(\d{4})$/', trim($schoolYear), $matches)) { return null; } return ((int) $matches[1] - 1) . '-' . ((int) $matches[2] - 1); } private function studentHasEnrollmentHistory(int $studentId, int $parentId): bool { if (! $this->db->tableExists('enrollments')) { return false; } return $this->db->table('enrollments') ->where('student_id', $studentId) ->where('parent_id', $parentId) ->countAllResults() > 0; } private function studentHasClassAssignmentHistory(int $studentId): bool { if (! $this->db->tableExists('student_class')) { return false; } return $this->db->table('student_class') ->where('student_id', $studentId) ->countAllResults() > 0; } }