diff --git a/app/Commands/SendRegistrationOpeningEmail.php b/app/Commands/SendRegistrationOpeningEmail.php new file mode 100644 index 0000000..11f91a1 --- /dev/null +++ b/app/Commands/SendRegistrationOpeningEmail.php @@ -0,0 +1,55 @@ +attendance['timezone'] ?? 'America/New_York'); + $timezone = new \DateTimeZone($tzName); + $dateOption = CLI::getOption('date'); + $date = $dateOption + ? new \DateTimeImmutable((string) $dateOption, $timezone) + : new \DateTimeImmutable('today', $timezone); + + $force = CLI::getOption('force') !== null; + $dryRun = CLI::getOption('dry-run') !== null; + $email = CLI::getOption('email'); + $email = is_string($email) && trim($email) !== '' ? trim($email) : null; + + if ($email !== null && ! filter_var($email, FILTER_VALIDATE_EMAIL)) { + CLI::error('Invalid --email value.'); + return; + } + + $service = new RegistrationOpeningEmailService(); + $summary = $service->sendForDate($date, $force, $email, $dryRun); + + foreach ($summary['messages'] as $message) { + CLI::write($message, 'yellow'); + } + + $sentLabel = $dryRun ? 'Would send' : 'Sent'; + + CLI::write(sprintf( + 'Registration opening email complete. Years: %d. Recipients: %d. %s: %d. Failed: %d. Skipped: %d%s.', + $summary['school_years'], + $summary['recipients'], + $sentLabel, + $summary['sent'], + $summary['failed'], + $summary['skipped'], + $dryRun ? ' (dry run)' : '' + ), $summary['failed'] > 0 ? 'red' : 'green'); + } +} diff --git a/app/Config/Commands.php b/app/Config/Commands.php index 3308643..8f4419a 100644 --- a/app/Config/Commands.php +++ b/app/Config/Commands.php @@ -20,6 +20,7 @@ class Commands extends BaseService \App\Commands\SendAbsenteesSummary::class, \App\Commands\SendLatesSummary::class, \App\Commands\SendMonthlyPaymentNotifications::class, + \App\Commands\SendRegistrationOpeningEmail::class, \App\Commands\SendTestPaymentNotification::class, \App\Commands\RecalculateAttendance::class, ]; diff --git a/app/Config/Filters.php b/app/Config/Filters.php index a837405..e16a14f 100644 --- a/app/Config/Filters.php +++ b/app/Config/Filters.php @@ -48,6 +48,7 @@ class Filters extends BaseConfig 'timezone', 'sanitizeinput', 'invalidchars', + 'schoolYearWritable', 'csrf' => ['except' => [ // WhatsApp membership management (legacy allowances retained) 'whatsapp/update-membership', diff --git a/app/Config/Routes.php b/app/Config/Routes.php index ecd668c..cfa5a55 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -1125,7 +1125,7 @@ $routes->match(['get', 'post'], '/configuration/editConfig/(:num)', 'View\Config $routes->post('/configuration/deleteConfig/(:num)', 'View\ConfigurationController::deleteConfig/$1'); // School-year management -$routes->group('administrator/school-years', ['filter' => 'auth:admin'], static function ($routes) { +$routes->group('administrator/school-years', ['filter' => 'auth:admin|administrator|principal|vice_principal'], static function ($routes) { $routes->get('', 'Administrator\SchoolYearController::index'); $routes->post('store', 'Administrator\SchoolYearController::store'); $routes->post('(:num)/update', 'Administrator\SchoolYearController::update/$1'); diff --git a/app/Controllers/BaseController.php b/app/Controllers/BaseController.php index d637a00..6f59147 100644 --- a/app/Controllers/BaseController.php +++ b/app/Controllers/BaseController.php @@ -181,7 +181,9 @@ abstract class BaseController extends Controller try { service('renderer')->setData(service('schoolYearViewData')->forCurrentRequest($this->request)); } catch (SchoolYearConfigurationException $e) { - throw $e; + log_message('warning', 'Unable to inject school-year view data: {message}', [ + 'message' => $e->getMessage(), + ]); } catch (Throwable $e) { log_message('warning', 'Unable to inject school-year view data: {message}', [ 'message' => $e->getMessage(), diff --git a/app/Controllers/View/ParentController.php b/app/Controllers/View/ParentController.php index 71af5f7..5c51dbc 100644 --- a/app/Controllers/View/ParentController.php +++ b/app/Controllers/View/ParentController.php @@ -9,6 +9,7 @@ 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; @@ -54,6 +55,7 @@ class ParentController extends BaseController protected $medicalConditionModel; protected $allergyModel; protected $authorizedUsersModel; + protected ParentPolicyAcceptanceModel $policyAcceptanceModel; protected $schoolStartDate; protected $ageDateRefernce; @@ -75,6 +77,7 @@ class ParentController extends BaseController $this->medicalConditionModel = new StudentMedicalConditionModel(); $this->allergyModel = new StudentAllergyModel(); $this->authorizedUsersModel = new AuthorizedUserModel(); + $this->policyAcceptanceModel = new ParentPolicyAcceptanceModel(); $this->ageDateRefernce = $this->configModel->getConfig('date_age_reference'); $this->lastDayOfRegistration = $this->configModel->getConfig('enrollment_deadline'); @@ -270,7 +273,7 @@ class ParentController extends BaseController // Verify user type is "parent" from the `users` table $userData = $this->db->table('users') - ->select('user_type') + ->select('user_type, accept_school_policy') ->where('id', $parentId) ->get() ->getRowArray(); @@ -290,6 +293,9 @@ class ParentController extends BaseController return redirect()->to('/no-kids')->with('error', 'No students found. Please register your child first.'); } + $previousSchoolYear = $this->previousSchoolYearName($selectedYear); + $fallMakeupExamOn = $this->fallMakeupExamDateForYear($selectedYear); + // Map enrollment statuses $statusMap = [ 'admission under review' => 'admission under review', @@ -356,16 +362,23 @@ class ParentController extends BaseController $student['enrollment_status'], ['admission under review', 'payment pending', 'enrolled', 'withdraw under review', 'denied'] ); + + $student['previous_year_decision'] = $previousSchoolYear !== null + ? $this->studentDecisionForYear((int) $studentId, $previousSchoolYear) + : null; } // Render view return view('/parent/enroll_classes', [ 'students' => $students, 'selectedYear' => $selectedYear, + 'previousSchoolYear' => $previousSchoolYear, + 'fallMakeupExamOn' => $fallMakeupExamOn, 'isEditable' => $isEditable, 'withdrawalDeadline' => $this->withdrawalDeadline, 'lastDayOfRegistration' => $this->lastDayOfRegistration, - 'schoolStartDate' => $this->schoolStartDate + 'schoolStartDate' => $this->schoolStartDate, + 'hasAcceptedSchoolPolicy' => $this->hasAcceptedPolicyForYear((int) $parentId, $selectedYear), ]); } catch (Exception $e) { log_message('error', 'An error occurred in enrollClasses: ' . $e->getMessage()); @@ -389,6 +402,25 @@ class ParentController extends BaseController $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.'); @@ -404,6 +436,12 @@ class ParentController extends BaseController $studentData = []; if (!empty($enroll)) { + $selectedYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? '')); + $blockingDecisionMessages = $this->blockedEnrollmentDecisionMessages(array_map('intval', (array) $enroll), $selectedYear); + if ($blockingDecisionMessages !== []) { + return redirect()->back()->withInput()->with('error', implode(' ', $blockingDecisionMessages)); + } + foreach ($enroll as $studentId) { // Get student full name (supports both string return or array with firstname/lastname) $studentInfo = $this->studentModel->getFullNameById($studentId); @@ -571,6 +609,40 @@ class ParentController extends BaseController } } + private function hasAcceptedPolicyForYear(int $parentId, string $schoolYear): bool + { + if ($parentId <= 0 || $schoolYear === '') { + return false; + } + + try { + return $this->policyAcceptanceModel->hasAccepted($parentId, $schoolYear); + } catch (Throwable $e) { + log_message('error', 'Failed to read parent policy acceptance: {message}', [ + 'message' => $e->getMessage(), + ]); + + return false; + } + } + + private function recordPolicyAcceptance(int $parentId, string $schoolYear, string $source): void + { + if ($parentId <= 0 || $schoolYear === '') { + throw new \RuntimeException('Unable to record school policy acceptance.'); + } + + if (! $this->policyAcceptanceModel->recordAcceptance( + $parentId, + $schoolYear, + $source, + $this->request->getIPAddress(), + $this->request->getUserAgent()->getAgentString() + )) { + throw new \RuntimeException('Unable to record school policy acceptance.'); + } + } + /** * If a promotion_queue record exists for this student and the given school year, * create/update student_class row accordingly (base section until distribution), @@ -705,6 +777,84 @@ class ParentController extends BaseController return false; } + 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); + if ($previousSchoolYear === null) { + return []; + } + + $messages = []; + foreach (array_unique($studentIds) as $studentId) { + $studentId = (int) $studentId; + $decisionRow = $this->studentDecisionForYear($studentId, $previousSchoolYear); + $decision = strtolower(trim((string) ($decisionRow['decision'] ?? ''))); + $source = strtolower(trim((string) ($decisionRow['source'] ?? ''))); + + if ($decision === 'pass' || $decision === 'repeat class') { + continue; + } + + if ($decision === '' && $source !== 'pending') { + continue; + } + + $studentInfo = $this->studentModel->getFullNameById($studentId); + $studentName = is_array($studentInfo) + ? trim((string) ($studentInfo['firstname'] ?? '') . ' ' . (string) ($studentInfo['lastname'] ?? '')) + : trim((string) $studentInfo); + $studentName = $studentName !== '' ? $studentName : 'Student ID ' . $studentId; + + $messages[] = $studentName . ': enrollment cannot be submitted until the prior-year decision is resolved with administration.'; + } + + return $messages; + } + private function previousSchoolYearName(string $schoolYear): ?string { $schoolYear = trim($schoolYear); @@ -1292,15 +1442,17 @@ class ParentController extends BaseController $kids = $this->studentModel->where('parent_id', $parentId)->findAll(); foreach ($kids as &$kid) { + $studentId = (int) ($kid['id'] ?? 0); $kid['allergies'] = $this->allergyModel - ->where('student_id', $kid['id']) + ->where('student_id', $studentId) ->findColumn('allergy') ?? []; $kid['medical_conditions'] = $this->medicalConditionModel - ->where('student_id', $kid['id']) + ->where('student_id', $studentId) ->findColumn('condition_name') ?? []; - $kid['enrollment'] = isset($enrollmentMap[$kid['id']]['id']) && !empty($enrollmentMap[$kid['id']]['id']) ? 1 : 0; + $kid['enrollment'] = isset($enrollmentMap[$studentId]['id']) && !empty($enrollmentMap[$studentId]['id']) ? 1 : 0; + $kid['can_delete'] = $this->canParentDeleteStudent($kid, $parentId); } $emergencies = $this->emergencyContactModel->where('parent_id', $parentId)->findAll(); @@ -1784,6 +1936,10 @@ $existing = $this->studentModel 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.'); @@ -1803,6 +1959,51 @@ $existing = $this->studentModel return redirect()->to('/parent/child_register')->with('success', 'Student deleted successfully.'); } + private function canParentDeleteStudent(array $student, int $parentId): bool + { + $studentId = (int) ($student['id'] ?? 0); + if ($studentId <= 0) { + return false; + } + + if ((string) ($student['is_new'] ?? '1') === '0') { + return false; + } + + if ($this->studentHasEnrollmentHistory($studentId, $parentId)) { + return false; + } + + if ($this->studentHasClassAssignmentHistory($studentId)) { + return false; + } + + return true; + } + + private function studentHasEnrollmentHistory(int $studentId, int $parentId): bool + { + if (! $this->db->tableExists('enrollments')) { + return false; + } + + return $this->db->table('enrollments') + ->where('student_id', $studentId) + ->where('parent_id', $parentId) + ->countAllResults() > 0; + } + + private function studentHasClassAssignmentHistory(int $studentId): bool + { + if (! $this->db->tableExists('student_class')) { + return false; + } + + return $this->db->table('student_class') + ->where('student_id', $studentId) + ->countAllResults() > 0; + } + private function formatName(string $name): string diff --git a/app/Controllers/View/RegisterController.php b/app/Controllers/View/RegisterController.php index 4970690..6c129e1 100644 --- a/app/Controllers/View/RegisterController.php +++ b/app/Controllers/View/RegisterController.php @@ -8,6 +8,7 @@ use App\Models\UserModel; use App\Models\AuthorizedUserModel; use App\Models\RoleModel; use App\Models\ParentModel; +use App\Models\ParentPolicyAcceptanceModel; use App\Models\UserRoleModel; use App\Services\EmailService; use App\Services\SchoolIdService; @@ -23,6 +24,7 @@ class RegisterController extends Controller protected $roleModel; protected $userModel; protected $parentModel; + protected ParentPolicyAcceptanceModel $policyAcceptanceModel; public function __construct() { @@ -40,6 +42,7 @@ class RegisterController extends Controller $this->roleModel = new RoleModel(); $this->userModel = new UserModel(); $this->parentModel = new ParentModel(); + $this->policyAcceptanceModel = new ParentPolicyAcceptanceModel(); $this->semester = $this->configModel->getConfig('semester'); $this->schoolYear = $this->configModel->getConfig('school_year'); @@ -119,7 +122,7 @@ class RegisterController extends Controller 'city' => 'required|alpha_space|min_length[2]|max_length[30]', 'state' => 'required|in_list[CT,ME,MA,NH,NY,RI,VT]', 'zip' => 'required|regex_match[/^\d{5}$/]', - 'accept_school_policy' => 'required', + 'accept_school_policy' => 'required|in_list[1]', 'captcha' => 'required|alpha_numeric|min_length[4]|max_length[10]', ]; @@ -221,6 +224,21 @@ class RegisterController extends Controller 'created_at' => utc_now(), ]); + if ($isParent && (int) $post['accept_school_policy'] === 1) { + $recordedPolicyAcceptance = $this->policyAcceptanceModel->recordAcceptance( + (int) $firstParentId, + (string) $this->schoolYear, + 'new_parent_registration', + $this->request->getIPAddress(), + $this->request->getUserAgent()->getAgentString() + ); + + if (! $recordedPolicyAcceptance) { + $this->db->transRollback(); + return redirect()->back()->withInput()->with('error', 'Unable to record school policy acceptance. Please try again.'); + } + } + /* ───────────── 8. Optional second-parent insert ───────────── */ if ($isParent) { if (empty($rawPost['no_second_parent_info'])) { diff --git a/app/Database/Migrations/2026-07-12-050000_CreateSchoolYears.php b/app/Database/Migrations/2026-07-12-050000_CreateSchoolYears.php index 76ae848..8ee519b 100644 --- a/app/Database/Migrations/2026-07-12-050000_CreateSchoolYears.php +++ b/app/Database/Migrations/2026-07-12-050000_CreateSchoolYears.php @@ -47,6 +47,10 @@ class CreateSchoolYears extends Migration 'type' => 'DATE', 'null' => true, ], + 'fall_makeup_exam_on' => [ + 'type' => 'DATE', + 'null' => true, + ], 'previous_school_year_id' => [ 'type' => 'INT', 'constraint' => 11, @@ -144,6 +148,7 @@ class CreateSchoolYears extends Migration 'description' => ['type' => 'TEXT', 'null' => true], 'registration_starts_on' => ['type' => 'DATE', 'null' => true], 'registration_ends_on' => ['type' => 'DATE', 'null' => true], + 'fall_makeup_exam_on' => ['type' => 'DATE', 'null' => true], 'previous_school_year_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true], 'next_school_year_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true], 'activated_at' => ['type' => 'DATETIME', 'null' => true], diff --git a/app/Database/Migrations/2026-07-30-000100_AddFallMakeupExamToSchoolYears.php b/app/Database/Migrations/2026-07-30-000100_AddFallMakeupExamToSchoolYears.php new file mode 100644 index 0000000..213fead --- /dev/null +++ b/app/Database/Migrations/2026-07-30-000100_AddFallMakeupExamToSchoolYears.php @@ -0,0 +1,32 @@ +db->tableExists('school_years') || $this->db->fieldExists('fall_makeup_exam_on', 'school_years')) { + return; + } + + $this->forge->addColumn('school_years', [ + 'fall_makeup_exam_on' => [ + 'type' => 'DATE', + 'null' => true, + 'after' => 'registration_ends_on', + ], + ]); + } + + public function down(): void + { + if (! $this->db->tableExists('school_years') || ! $this->db->fieldExists('fall_makeup_exam_on', 'school_years')) { + return; + } + + $this->forge->dropColumn('school_years', 'fall_makeup_exam_on'); + } +} diff --git a/app/Database/Migrations/2026-07-30-000200_CreateParentPolicyAcceptances.php b/app/Database/Migrations/2026-07-30-000200_CreateParentPolicyAcceptances.php new file mode 100644 index 0000000..50fcbcc --- /dev/null +++ b/app/Database/Migrations/2026-07-30-000200_CreateParentPolicyAcceptances.php @@ -0,0 +1,176 @@ +db->tableExists('parent_policy_acceptances')) { + return; + } + + $this->forge->addField([ + 'id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'auto_increment' => true, + ], + 'parent_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'null' => false, + ], + 'school_year' => [ + 'type' => 'VARCHAR', + 'constraint' => 9, + 'null' => false, + ], + 'accepted_at' => [ + 'type' => 'DATETIME', + 'null' => false, + ], + 'source' => [ + 'type' => 'VARCHAR', + 'constraint' => 40, + 'null' => false, + 'default' => 'registration', + ], + 'ip_address' => [ + 'type' => 'VARCHAR', + 'constraint' => 45, + 'null' => true, + ], + 'user_agent' => [ + 'type' => 'VARCHAR', + 'constraint' => 255, + 'null' => true, + ], + 'created_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'updated_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + ]); + + $this->forge->addKey('id', true); + $this->forge->addUniqueKey(['parent_id', 'school_year'], 'uq_parent_policy_year'); + $this->forge->addKey('school_year'); + $this->forge->createTable('parent_policy_acceptances'); + + $this->backfillExistingAcceptances(); + } + + public function down(): void + { + $this->forge->dropTable('parent_policy_acceptances', true); + } + + private function backfillExistingAcceptances(): void + { + if (! $this->db->tableExists('users')) { + return; + } + + $schoolYear = $this->configuredSchoolYear(); + if ($schoolYear === null) { + return; + } + + $builder = $this->db->table('users u') + ->select('u.id') + ->where('u.accept_school_policy', 1); + + $hasUserType = $this->db->fieldExists('user_type', 'users'); + $hasRoleTables = $this->db->tableExists('user_roles') && $this->db->tableExists('roles'); + + if ($hasUserType && $hasRoleTables) { + $builder->groupStart() + ->where('u.user_type', 'primary') + ->orWhere( + 'EXISTS (SELECT 1 FROM user_roles ur INNER JOIN roles r ON r.id = ur.role_id WHERE ur.user_id = u.id AND LOWER(r.name) = ' . $this->db->escape('parent') . ')', + null, + false + ) + ->groupEnd(); + } elseif ($hasUserType) { + $builder->where('u.user_type', 'primary'); + } elseif ($hasRoleTables) { + $builder->where( + 'EXISTS (SELECT 1 FROM user_roles ur INNER JOIN roles r ON r.id = ur.role_id WHERE ur.user_id = u.id AND LOWER(r.name) = ' . $this->db->escape('parent') . ')', + null, + false + ); + } else { + return; + } + + $rows = $builder->get() + ->getResultArray(); + + foreach ($rows as $row) { + $parentId = (int) ($row['id'] ?? 0); + if ($parentId <= 0) { + continue; + } + + $existing = $this->db->table('parent_policy_acceptances') + ->where('parent_id', $parentId) + ->where('school_year', $schoolYear) + ->get(1) + ->getRowArray(); + + if ($existing !== null) { + continue; + } + + $this->db->table('parent_policy_acceptances')->insert([ + 'parent_id' => $parentId, + 'school_year' => $schoolYear, + 'accepted_at' => date('Y-m-d H:i:s'), + 'source' => 'legacy_backfill', + 'created_at' => date('Y-m-d H:i:s'), + 'updated_at' => date('Y-m-d H:i:s'), + ]); + } + } + + private function configuredSchoolYear(): ?string + { + if ($this->db->tableExists('school_years')) { + $row = $this->db->table('school_years') + ->select('name') + ->where('status', 'active') + ->orderBy('id', 'DESC') + ->get(1) + ->getRowArray(); + + $name = trim((string) ($row['name'] ?? '')); + if (preg_match('/^\d{4}-\d{4}$/', $name)) { + return $name; + } + } + + if (! $this->db->tableExists('configuration')) { + return null; + } + + $row = $this->db->table('configuration') + ->select('config_value') + ->where('config_key', 'school_year') + ->orderBy('id', 'DESC') + ->get(1) + ->getRowArray(); + + $name = trim((string) ($row['config_value'] ?? '')); + + return preg_match('/^\d{4}-\d{4}$/', $name) ? $name : null; + } +} diff --git a/app/Database/Migrations/2026-07-30-100000_CreateRegistrationOpeningEmailTemplate.php b/app/Database/Migrations/2026-07-30-100000_CreateRegistrationOpeningEmailTemplate.php new file mode 100644 index 0000000..19bdc06 --- /dev/null +++ b/app/Database/Migrations/2026-07-30-100000_CreateRegistrationOpeningEmailTemplate.php @@ -0,0 +1,67 @@ +db->tableExists('email_templates')) { + return; + } + + $fields = $this->db->getFieldNames('email_templates'); + $keyField = in_array('code', $fields, true) ? 'code' : 'template_key'; + $bodyField = in_array('body_html', $fields, true) ? 'body_html' : 'body'; + $nameField = in_array('name', $fields, true) ? 'name' : null; + + $exists = $this->db->table('email_templates') + ->where($keyField, 'registration_opening') + ->countAllResults() > 0; + + if ($exists) { + return; + } + + $data = [ + $keyField => 'registration_opening', + 'subject' => 'Registration is now open for {{school_year}}', + $bodyField => $this->bodyHtml(), + 'is_active' => 1, + ]; + + if ($nameField !== null) { + $data[$nameField] = 'Registration Opening'; + } + + $this->db->table('email_templates')->insert($data); + } + + public function down(): void + { + if (! $this->db->tableExists('email_templates')) { + return; + } + + $fields = $this->db->getFieldNames('email_templates'); + $keyField = in_array('code', $fields, true) ? 'code' : 'template_key'; + + $this->db->table('email_templates') + ->where($keyField, 'registration_opening') + ->delete(); + } + + private function bodyHtml(): string + { + return <<<'HTML' +

Dear {{name}},

+

Registration is now open for the {{school_year}} school year.

+

Please complete your registration through the school portal. The registration deadline is {{registration_deadline}}.

+

Start registration

+

If you already have an account, you can also log in here: {{login_url}}

+

Regards,
{{school_name}}

+HTML; + } +} diff --git a/app/Filters/SchoolYearWritableFilter.php b/app/Filters/SchoolYearWritableFilter.php index 441a7cd..66e521f 100644 --- a/app/Filters/SchoolYearWritableFilter.php +++ b/app/Filters/SchoolYearWritableFilter.php @@ -10,12 +10,50 @@ use CodeIgniter\HTTP\ResponseInterface; final class SchoolYearWritableFilter implements FilterInterface { + private const WRITE_METHODS = ['POST', 'PUT', 'PATCH', 'DELETE']; + private const DESTRUCTIVE_PATH_SEGMENTS = ['delete', 'remove']; + + /** + * Routes here either change the selected context itself or are account/system + * actions that are not scoped to a school year. + * + * @var list + */ + private const EXEMPT_PATHS = [ + 'school-year/select', + 'school-year/reset', + 'administrator/school-years', + 'user/login', + 'api/login', + 'api/register', + 'user/select_role', + 'set-role', + 'processForgotPassword', + 'user/processResetPassword', + 'user/save_password', + 'set_authorized_user_password', + ]; + public function before(RequestInterface $request, $arguments = null) { if (! $request instanceof IncomingRequest) { return null; } + $path = $this->normalizedPath($request); + + if (! $this->isWriteAttempt($request, $path)) { + return null; + } + + if (! session()->get('user_id')) { + return null; + } + + if ($this->isExemptPath($path)) { + return null; + } + try { service('schoolYearWriteGuard')->assertWritable( service('schoolYearContext')->resolve($request) @@ -45,4 +83,42 @@ final class SchoolYearWritableFilter implements FilterInterface { return null; } + + private function isWriteAttempt(IncomingRequest $request, string $path): bool + { + if (in_array(strtoupper($request->getMethod()), self::WRITE_METHODS, true)) { + return true; + } + + $segments = explode('/', $path); + + foreach (self::DESTRUCTIVE_PATH_SEGMENTS as $segment) { + if (in_array($segment, $segments, true)) { + return true; + } + } + + return false; + } + + private function isExemptPath(string $path): bool + { + foreach (self::EXEMPT_PATHS as $exemptPath) { + if ($path === $exemptPath || str_starts_with($path, $exemptPath . '/')) { + return true; + } + } + + return false; + } + + private function normalizedPath(IncomingRequest $request): string + { + $path = trim($request->getUri()->getPath(), '/'); + if (str_starts_with($path, 'index.php/')) { + return substr($path, strlen('index.php/')); + } + + return $path; + } } diff --git a/app/Models/ParentPolicyAcceptanceModel.php b/app/Models/ParentPolicyAcceptanceModel.php new file mode 100644 index 0000000..fbe45ce --- /dev/null +++ b/app/Models/ParentPolicyAcceptanceModel.php @@ -0,0 +1,65 @@ + 'required|integer', + 'school_year' => 'required|regex_match[/^\d{4}-\d{4}$/]|max_length[9]', + 'accepted_at' => 'required|valid_date[Y-m-d H:i:s]', + 'source' => 'required|max_length[40]', + 'ip_address' => 'permit_empty|max_length[45]', + 'user_agent' => 'permit_empty|max_length[255]', + ]; + + public function hasAccepted(int $parentId, string $schoolYear): bool + { + return $this->where('parent_id', $parentId) + ->where('school_year', $schoolYear) + ->first() !== null; + } + + public function recordAcceptance( + int $parentId, + string $schoolYear, + string $source, + ?string $ipAddress = null, + ?string $userAgent = null + ): bool { + $payload = [ + 'parent_id' => $parentId, + 'school_year' => $schoolYear, + 'accepted_at' => utc_now(), + 'source' => $source, + 'ip_address' => $ipAddress, + 'user_agent' => $userAgent !== null ? substr($userAgent, 0, 255) : null, + ]; + + $existing = $this->where('parent_id', $parentId) + ->where('school_year', $schoolYear) + ->first(); + + if ($existing !== null) { + return $this->update((int) $existing[$this->primaryKey], $payload) !== false; + } + + return $this->insert($payload) !== false; + } +} diff --git a/app/Models/SchoolYearModel.php b/app/Models/SchoolYearModel.php index b5819aa..44ab82b 100644 --- a/app/Models/SchoolYearModel.php +++ b/app/Models/SchoolYearModel.php @@ -19,6 +19,7 @@ class SchoolYearModel extends Model 'description', 'registration_starts_on', 'registration_ends_on', + 'fall_makeup_exam_on', 'previous_school_year_id', 'next_school_year_id', 'activated_at', @@ -36,6 +37,7 @@ class SchoolYearModel extends Model 'ends_on' => 'permit_empty|valid_date[Y-m-d]', 'registration_starts_on' => 'permit_empty|valid_date[Y-m-d]', 'registration_ends_on' => 'permit_empty|valid_date[Y-m-d]', + 'fall_makeup_exam_on' => 'permit_empty|valid_date[Y-m-d]', ]; public function active(): ?array diff --git a/app/Services/RegistrationOpeningEmailService.php b/app/Services/RegistrationOpeningEmailService.php new file mode 100644 index 0000000..7c5c819 --- /dev/null +++ b/app/Services/RegistrationOpeningEmailService.php @@ -0,0 +1,310 @@ +db = $db ?? \Config\Database::connect(); + $this->emailService = $emailService ?? new EmailService(); + } + + public function sendForDate(\DateTimeInterface $date, bool $force = false, ?string $testEmail = null, bool $dryRun = false): array + { + $summary = [ + 'school_years' => 0, + 'recipients' => 0, + 'sent' => 0, + 'failed' => 0, + 'skipped' => 0, + 'dry_run' => $dryRun, + 'messages' => [], + ]; + + foreach ($this->registrationYearsForDate($date, $force) as $schoolYear) { + $summary['school_years']++; + + $recipients = $testEmail !== null + ? [[ + 'user_id' => null, + 'family_id' => null, + 'email' => $testEmail, + 'name' => 'Parent', + ]] + : $this->parentEmailRecipients(); + + if ($recipients === []) { + $summary['messages'][] = sprintf('No parent emails found for %s.', $schoolYear['name']); + continue; + } + + [$subject, $body] = $this->composeEmail($schoolYear); + if ($testEmail !== null) { + $subject = '[TEST] ' . $subject; + } + + foreach ($recipients as $recipient) { + if ($testEmail === null && ! $dryRun && $this->alreadySentForRecipient($schoolYear, $recipient['email'])) { + $summary['skipped']++; + continue; + } + + $summary['recipients']++; + $personalizedBody = $this->replaceTokens($body, $schoolYear, $recipient); + $personalizedSubject = $this->replaceTokens($subject, $schoolYear, $recipient); + + $sent = $dryRun || $this->emailService->send($recipient['email'], $personalizedSubject, $personalizedBody, 'general'); + $sent ? $summary['sent']++ : $summary['failed']++; + + if (! $dryRun) { + $this->logAttempt($schoolYear, $recipient, $personalizedSubject, $personalizedBody, $sent); + } + } + } + + return $summary; + } + + public function registrationYearsForDate(\DateTimeInterface $date, bool $force = false): array + { + $builder = $this->db->table('school_years') + ->where('registration_starts_on IS NOT NULL'); + + if (! $force) { + $builder->where('registration_starts_on', $date->format('Y-m-d')); + } + + return $builder->orderBy('registration_starts_on', 'ASC') + ->get() + ->getResultArray(); + } + + public function parentEmailRecipients(): array + { + $recipients = []; + + $hasFamilyGuardians = $this->db->tableExists('family_guardians'); + + if ($this->db->tableExists('roles') && $this->db->tableExists('user_roles')) { + $builder = $this->db->table('users u') + ->select($hasFamilyGuardians ? 'u.id AS user_id, fg.family_id, u.firstname, u.lastname, u.email' : 'u.id AS user_id, NULL AS family_id, u.firstname, u.lastname, u.email') + ->join('user_roles ur', 'ur.user_id = u.id', 'inner') + ->join('roles r', 'r.id = ur.role_id', 'inner') + ->where('LOWER(r.name)', 'parent') + ->where('u.email IS NOT NULL') + ->where('u.email !=', ''); + + if ($hasFamilyGuardians) { + $builder->join('family_guardians fg', 'fg.user_id = u.id', 'left') + ->groupStart() + ->where('fg.id IS NULL') + ->orWhere('fg.receive_emails', 1) + ->groupEnd(); + } + + if ($this->hasField('user_roles', 'deleted_at')) { + $builder->where('ur.deleted_at', null); + } + + foreach ($builder->get()->getResultArray() as $row) { + $this->addRecipient($recipients, $row); + } + } + + if ($hasFamilyGuardians) { + $guardianBuilder = $this->db->table('family_guardians fg') + ->select('u.id AS user_id, fg.family_id, u.firstname, u.lastname, u.email') + ->join('users u', 'u.id = fg.user_id', 'inner') + ->where('fg.receive_emails', 1) + ->where('u.email IS NOT NULL') + ->where('u.email !=', ''); + + foreach ($guardianBuilder->get()->getResultArray() as $row) { + $this->addRecipient($recipients, $row); + } + } + + uasort($recipients, static fn(array $a, array $b): int => strcasecmp($a['email'], $b['email'])); + + return array_values($recipients); + } + + public function alreadySentForSchoolYear(array $schoolYear): bool + { + $schoolYearId = (int) ($schoolYear['id'] ?? 0); + $schoolYearName = (string) ($schoolYear['name'] ?? ''); + + $builder = $this->db->table('communication_logs') + ->where('template_key', self::TEMPLATE_KEY) + ->where('status', 'sent') + ->groupStart(); + + if ($schoolYearId > 0) { + $builder->like('metadata', '"school_year_id":' . $schoolYearId); + } + + if ($schoolYearName !== '') { + if ($schoolYearId > 0) { + $builder->orLike('metadata', '"school_year":"' . $this->escapeJsonLikeValue($schoolYearName) . '"'); + } else { + $builder->like('metadata', '"school_year":"' . $this->escapeJsonLikeValue($schoolYearName) . '"'); + } + } + + $builder->groupEnd(); + + return $builder->countAllResults() > 0; + } + + public function alreadySentForRecipient(array $schoolYear, string $email): bool + { + $schoolYearId = (int) ($schoolYear['id'] ?? 0); + $schoolYearName = (string) ($schoolYear['name'] ?? ''); + + $builder = $this->db->table('communication_logs') + ->where('template_key', self::TEMPLATE_KEY) + ->where('status', 'sent') + ->like('recipients', '"' . strtolower(trim($email)) . '"') + ->groupStart(); + + if ($schoolYearId > 0) { + $builder->like('metadata', '"school_year_id":' . $schoolYearId); + } + + if ($schoolYearName !== '') { + if ($schoolYearId > 0) { + $builder->orLike('metadata', '"school_year":"' . $this->escapeJsonLikeValue($schoolYearName) . '"'); + } else { + $builder->like('metadata', '"school_year":"' . $this->escapeJsonLikeValue($schoolYearName) . '"'); + } + } + + $builder->groupEnd(); + + return $builder->countAllResults() > 0; + } + + private function addRecipient(array &$recipients, array $row): void + { + $email = strtolower(trim((string) ($row['email'] ?? ''))); + if (! filter_var($email, FILTER_VALIDATE_EMAIL)) { + return; + } + + $name = trim((string) ($row['firstname'] ?? '') . ' ' . (string) ($row['lastname'] ?? '')); + $recipients[$email] = [ + 'user_id' => isset($row['user_id']) ? (int) $row['user_id'] : null, + 'family_id' => isset($row['family_id']) ? (int) $row['family_id'] : null, + 'email' => $email, + 'name' => $name !== '' ? $name : 'Parent', + ]; + } + + private function composeEmail(array $schoolYear): array + { + $template = $this->loadTemplate(); + + $subject = $template['subject'] ?? 'Registration is now open for {{school_year}}'; + $bodyHtml = $template['body'] ?? $this->defaultBody(); + $bodyHtml = $this->replaceTokens($bodyHtml, $schoolYear, ['name' => 'Parent', 'email' => '']); + + $body = view('emails/_wrap_layout', [ + 'title' => $this->replaceTokens($subject, $schoolYear, ['name' => 'Parent', 'email' => '']), + 'body_html' => $bodyHtml, + ], ['saveData' => true]); + + return [$subject, $body]; + } + + private function loadTemplate(): ?array + { + if (! $this->db->tableExists('email_templates')) { + return null; + } + + $fields = $this->db->getFieldNames('email_templates'); + $keyField = in_array('code', $fields, true) ? 'code' : 'template_key'; + $bodyField = in_array('body_html', $fields, true) ? 'body_html' : 'body'; + + $row = $this->db->table('email_templates') + ->select('subject, ' . $bodyField . ' AS body') + ->where($keyField, self::TEMPLATE_KEY) + ->where('is_active', 1) + ->get() + ->getRowArray(); + + return $row ?: null; + } + + private function replaceTokens(string $text, array $schoolYear, array $recipient): string + { + $registrationEnds = trim((string) ($schoolYear['registration_ends_on'] ?? '')); + $deadline = $registrationEnds !== '' ? $registrationEnds : 'the posted deadline'; + + return strtr($text, [ + '{{name}}' => (string) ($recipient['name'] ?? 'Parent'), + '{{parent_name}}' => (string) ($recipient['name'] ?? 'Parent'), + '{{school_year}}' => (string) ($schoolYear['name'] ?? ''), + '{{registration_starts_on}}' => (string) ($schoolYear['registration_starts_on'] ?? ''), + '{{registration_ends_on}}' => $registrationEnds, + '{{registration_deadline}}' => $deadline, + '{{registration_url}}' => site_url('user'), + '{{login_url}}' => site_url('login'), + '{{school_name}}' => 'Al Rahma Sunday School', + ]); + } + + private function defaultBody(): string + { + return <<<'HTML' +

Dear {{name}},

+

Registration is now open for the {{school_year}} school year.

+

Please complete your registration through the school portal. The registration deadline is {{registration_deadline}}.

+

Start registration

+

If you already have an account, you can also log in here: {{login_url}}

+

Regards,
{{school_name}}

+HTML; + } + + private function logAttempt(array $schoolYear, array $recipient, string $subject, string $body, bool $sent): void + { + $this->db->table('communication_logs')->insert([ + 'student_id' => 0, + 'family_id' => $recipient['family_id'] ?: null, + 'student_name' => 'All Families', + 'template_key' => self::TEMPLATE_KEY, + 'subject' => $subject, + 'body' => $body, + 'recipients' => json_encode([$recipient['email']]), + 'status' => $sent ? 'sent' : 'failed', + 'error_message' => $sent ? null : 'Email send failed', + 'sent_by' => null, + 'metadata' => json_encode([ + 'campaign' => self::TEMPLATE_KEY, + 'school_year_id' => (int) ($schoolYear['id'] ?? 0), + 'school_year' => (string) ($schoolYear['name'] ?? ''), + 'registration_starts_on' => $schoolYear['registration_starts_on'] ?? null, + 'registration_ends_on' => $schoolYear['registration_ends_on'] ?? null, + 'recipient_user_id' => $recipient['user_id'], + ]), + ]); + } + + private function hasField(string $table, string $field): bool + { + return in_array($field, $this->db->getFieldNames($table), true); + } + + private function escapeJsonLikeValue(string $value): string + { + return trim(json_encode($value), '"'); + } +} diff --git a/app/Services/SchoolYearContextService.php b/app/Services/SchoolYearContextService.php index c7b8af1..632b5e7 100644 --- a/app/Services/SchoolYearContextService.php +++ b/app/Services/SchoolYearContextService.php @@ -129,11 +129,21 @@ final class SchoolYearContextService { $active = $this->schoolYearModel->active(); - if ($active === null) { + if ($active !== null) { + return $this->fromRow($active, false); + } + + $closing = $this->schoolYearModel + ->where('status', SchoolYearStatus::CLOSING) + ->orderBy('closing_started_at', 'DESC') + ->orderBy('id', 'DESC') + ->findAll(2); + + if (count($closing) !== 1) { throw new SchoolYearConfigurationException('Exactly one active school year must be configured.'); } - return $this->fromRow($active, false); + return $this->fromRow($closing[0], false); } private function normalizeInt(mixed $value): ?int diff --git a/app/Services/SchoolYearManagementService.php b/app/Services/SchoolYearManagementService.php index a664128..b34c243 100644 --- a/app/Services/SchoolYearManagementService.php +++ b/app/Services/SchoolYearManagementService.php @@ -26,6 +26,7 @@ final class SchoolYearManagementService public function createDraft(array $payload, ?int $userId = null): int { $payload = $this->metadataPayload($payload); + $payload['previous_school_year_id'] = $this->previousYearIdForDraft((string) $payload['name']); $payload['status'] = SchoolYearStatus::DRAFT; $payload['created_by'] = $userId; $payload['updated_by'] = $userId; @@ -35,6 +36,7 @@ final class SchoolYearManagementService $this->db->transStart(); $id = $this->schoolYearModel->insert($payload, true); if ($id !== false) { + $this->syncConfigurationFromSchoolYear($payload); $this->log((int) $id, null, SchoolYearStatus::DRAFT, 'create', $userId); } $this->db->transComplete(); @@ -62,6 +64,7 @@ final class SchoolYearManagementService $this->db->transStart(); $updated = $this->schoolYearModel->update($id, $payload); if ($updated !== false) { + $this->syncConfigurationFromSchoolYear($payload); $this->log($id, $status, $status, 'metadata_update', $userId); } $this->db->transComplete(); @@ -104,7 +107,7 @@ final class SchoolYearManagementService 'activated_at' => $now, 'updated_by' => $userId, ]); - $this->configurationModel->setConfigValueByKey('school_year', (string) $year['name']); + $this->syncConfigurationFromSchoolYear($year); $this->syncActiveYearSession((string) $year['name']); $this->log($id, $from, SchoolYearStatus::ACTIVE, 'activate', $userId); @@ -255,10 +258,69 @@ final class SchoolYearManagementService 'description' => trim((string) ($payload['description'] ?? '')) ?: null, 'registration_starts_on' => $this->nullableDate($payload['registration_starts_on'] ?? null), 'registration_ends_on' => $this->nullableDate($payload['registration_ends_on'] ?? null), + 'fall_makeup_exam_on' => $this->nullableDate($payload['fall_makeup_exam_on'] ?? null), 'previous_school_year_id' => $this->nullableInt($payload['previous_school_year_id'] ?? null), ]; } + private function syncConfigurationFromSchoolYear(array $schoolYear): void + { + $name = trim((string) ($schoolYear['name'] ?? '')); + if ($name === '') { + throw new RuntimeException('Unable to update school-year configuration: school year name is missing.'); + } + + $ageReferenceDate = $this->ageReferenceDateForSchoolYear($name); + $configValues = [ + 'school_year' => $name, + 'date_age_reference' => $ageReferenceDate, + 'refund_deadline' => $ageReferenceDate, + 'enrollment_deadline' => (string) ($schoolYear['registration_ends_on'] ?? ''), + 'fall_semester_start' => (string) ($schoolYear['starts_on'] ?? ''), + 'last_day_of_school' => (string) ($schoolYear['ends_on'] ?? ''), + ]; + + foreach ($configValues as $key => $value) { + if (! $this->configurationModel->setConfigValueByKey($key, $value)) { + throw new RuntimeException("Unable to update configuration value for {$key}."); + } + } + } + + private function ageReferenceDateForSchoolYear(string $schoolYearName): string + { + if (! preg_match('/^(\d{4})-\d{4}$/', $schoolYearName, $matches)) { + throw new RuntimeException('Unable to update school-year configuration: invalid school year format.'); + } + + return $matches[1] . '-12-31'; + } + + private function previousYearIdForDraft(string $name): ?int + { + if (preg_match('/^(\d{4})-(\d{4})$/', $name, $matches)) { + $previousName = ((int) $matches[1] - 1) . '-' . (int) $matches[1]; + $previousYear = $this->schoolYearModel + ->where('name', $previousName) + ->first(); + + if ($previousYear !== null) { + return (int) ($previousYear['id'] ?? 0) ?: null; + } + } + + $activeYear = $this->schoolYearModel->active(); + if ($activeYear !== null) { + return (int) ($activeYear['id'] ?? 0) ?: null; + } + + $latestYear = $this->schoolYearModel + ->orderBy('name', 'DESC') + ->first(); + + return $latestYear !== null ? ((int) ($latestYear['id'] ?? 0) ?: null) : null; + } + private function nullableDate(mixed $value): ?string { $value = trim((string) $value); diff --git a/app/Services/SchoolYearValidationService.php b/app/Services/SchoolYearValidationService.php index 8a598d6..e8d27b8 100644 --- a/app/Services/SchoolYearValidationService.php +++ b/app/Services/SchoolYearValidationService.php @@ -40,6 +40,8 @@ final class SchoolYearValidationService if ($registrationStarts !== null && $registrationEnds !== null && $registrationStarts > $registrationEnds) { throw new InvalidArgumentException('Registration start date must be on or before the registration end date.'); } + + $this->dateOrNull($payload['fall_makeup_exam_on'] ?? null); } public function isValidYearName(string $value): bool diff --git a/app/Views/parent/enroll_classes.php b/app/Views/parent/enroll_classes.php index 20335e1..ef0bdc1 100644 --- a/app/Views/parent/enroll_classes.php +++ b/app/Views/parent/enroll_classes.php @@ -25,9 +25,88 @@ $deadlineISO = $deadlineObj->format('Y-m-d\TH:i:sP'); // for JS +getFlashdata('error')): ?> +
getFlashdata('error')) ?>
+ + + + '', 'blocking' => false, 'level' => 'info']; + } + + if ($decisionKey === 'make-up exam in fall') { + $dateText = $fallMakeupExamLabel !== null ? ' on ' . $fallMakeupExamLabel : ''; + return [ + 'message' => $name . ' has a make-up exam decision. Enrollment can be completed only after the fall make-up exam' . $dateText . ' and after the result is finalized.', + 'blocking' => true, + 'level' => 'warning', + ]; + } + + if ($decisionKey === 'deferred decision') { + return [ + 'message' => $name . ' has a deferred decision. Enrollment can be completed only after you visit the administration to discuss your child\'s situation.', + 'blocking' => true, + 'level' => 'warning', + ]; + } + + if ($decisionKey === 'repeat class') { + return [ + 'message' => $name . ' has a repeat class decision and is accepted only in ' . $previousClass . '.', + 'blocking' => false, + 'level' => 'info', + ]; + } + + if ($decisionKey === 'expel') { + return [ + 'message' => $name . ' has an expel decision. Enrollment cannot be completed online; please contact the administration.', + 'blocking' => true, + 'level' => 'danger', + ]; + } + + if ($decisionKey === 'withdrawn') { + return [ + 'message' => $name . ' was marked withdrawn in the final decision. Please contact the administration before requesting enrollment.', + 'blocking' => true, + 'level' => 'warning', + ]; + } + + if ($decisionKey === '' || $source === 'pending') { + return [ + 'message' => $name . ' does not have a final promotion decision yet. Enrollment can be completed only after the administration finalizes the decision.', + 'blocking' => true, + 'level' => 'warning', + ]; + } + + return [ + 'message' => $name . ' has a "' . $decision . '" decision. Please contact the administration before requesting enrollment.', + 'blocking' => true, + 'level' => 'warning', + ]; +}; +?> +
+
@@ -46,6 +125,7 @@ $deadlineISO = $deadlineObj->format('Y-m-d\TH:i:sP'); // for JS $student): ?> + @@ -69,6 +149,9 @@ $deadlineISO = $deadlineObj->format('Y-m-d\TH:i:sP'); // for JS > @@ -127,6 +210,15 @@ $deadlineISO = $deadlineObj->format('Y-m-d\TH:i:sP'); // for JS ?> + + + + +
+
+ +
+
@@ -158,6 +250,32 @@ $deadlineISO = $deadlineObj->format('Y-m-d\TH:i:sP'); // for JS

No students found for the selected school year. Please register your kids first.

+ + -
-
-
Other
-
-
-
-
-
-
Queued
-
-
-
-
-
-
Assigned
-
-
-
-
-
-
Applied
-
-
-
Pending
@@ -250,14 +226,8 @@
-
Missing
-
-
-
-
-
-
Missing Queue
-
+
Other
+
diff --git a/app/Views/school_years/index.php b/app/Views/school_years/index.php index 87cde0c..1d1793b 100644 --- a/app/Views/school_years/index.php +++ b/app/Views/school_years/index.php @@ -13,6 +13,15 @@ $money = static fn ($value): string => '$' . number_format((float) $value, 2); $activeId = (int) ($activeYear['id'] ?? 0); $nextDraftId = (int) ($nextDraftYear['id'] ?? 0); + $defaultNewPreviousYear = $activeYear ?: ($schoolYears[0] ?? null); + $defaultNewPreviousYearName = (string) ($defaultNewPreviousYear['name'] ?? 'None'); + $previousYearNameByNewYearName = []; + foreach (($schoolYears ?? []) as $existingYear) { + $existingName = (string) ($existingYear['name'] ?? ''); + if (preg_match('/^(\d{4})-(\d{4})$/', $existingName, $matches)) { + $previousYearNameByNewYearName[$matches[2] . '-' . ((int) $matches[2] + 1)] = $existingName; + } + } $closingPreviewUrl = $activeId > 0 ? site_url('administrator/school-years/' . $activeId . '/closing/preview' . ($nextDraftId > 0 ? '?' . http_build_query(['target_school_year_id' => $nextDraftId]) : '')) : ''; @@ -85,13 +94,15 @@
- - + +
@@ -109,6 +120,10 @@
+
+ + +
@@ -149,6 +164,7 @@
+
Fall makeup:
@@ -338,6 +354,10 @@
+
+ + +
@@ -415,6 +435,21 @@ document.addEventListener('DOMContentLoaded', function () { order: [[0, 'desc']] }); } + + const newYearInput = document.getElementById('new_school_year_name'); + const previousYearDisplay = document.getElementById('new_previous_school_year_display'); + const previousYearByNewYear = ; + + if (newYearInput && previousYearDisplay) { + const defaultPreviousYear = previousYearDisplay.dataset.defaultPreviousYear || 'None'; + const updatePreviousYear = function () { + const newYearName = newYearInput.value.trim(); + previousYearDisplay.value = previousYearByNewYear[newYearName] || defaultPreviousYear; + }; + + newYearInput.addEventListener('input', updatePreviousYear); + updatePreviousYear(); + } }); endSection() ?> diff --git a/app/Views/user/register.php b/app/Views/user/register.php index 13ea30b..eee45af 100644 --- a/app/Views/user/register.php +++ b/app/Views/user/register.php @@ -257,6 +257,9 @@ (Read school policies here) + +
+
diff --git a/cronJobs.txt b/cronJobs.txt index 3722807..998003f 100644 --- a/cronJobs.txt +++ b/cronJobs.txt @@ -13,6 +13,9 @@ CI_ENVIRONMENT=production # June 1 @ 00:10 America/New_York 10 0 1 6 * cd /opt/lampp/htdocs/alrahma_school_sunday && /usr/bin/php spark config:update -t update_date_age_reference --tz=America/New_York >> /var/log/ci4_config_update.log 2>&1 +# Daily @ 8:00 AM - send registration opening email only when today matches school_years.registration_starts_on +0 8 * * * cd /opt/lampp/htdocs/alrahma_school_sunday && /usr/bin/php spark registration:send-opening-email --tz=America/New_York >> /var/log/ci4_registration_opening_email.log 2>&1 + # America/New_York # 1st February @ 00:05 — Spring 5 0 1 2 * cd /opt/lampp/htdocs/alrahma_school_sunday && /usr/bin/php spark config:update set_semester_spring --tz=America/New_York >> /var/log/ci4_config_update.log 2>&1 diff --git a/tests/app/Config/SchoolYearRouteIntegrityTest.php b/tests/app/Config/SchoolYearRouteIntegrityTest.php new file mode 100644 index 0000000..0b6d9da --- /dev/null +++ b/tests/app/Config/SchoolYearRouteIntegrityTest.php @@ -0,0 +1,18 @@ +assertMatchesRegularExpression( + "/\\\$routes->group\\('administrator\\/school-years', \\['filter' => 'auth:[^']*\\bprincipal\\b[^']*'\\]/", + $routes + ); + } +} diff --git a/tests/app/Filters/SchoolYearWritableFilterTest.php b/tests/app/Filters/SchoolYearWritableFilterTest.php new file mode 100644 index 0000000..3c112bf --- /dev/null +++ b/tests/app/Filters/SchoolYearWritableFilterTest.php @@ -0,0 +1,108 @@ +row; + } +} + +final class SchoolYearWritableFilterTest extends CIUnitTestCase +{ + protected function setUp(): void + { + parent::setUp(); + + Services::resetSingle('session'); + Services::resetSingle('schoolYearContext'); + session()->set('user_id', 123); + } + + public function testPostAgainstClosedSelectedYearIsBlocked(): void + { + $this->useSchoolYearContext(['id' => 1, 'name' => '2025-2026', 'status' => 'closed']); + + $request = $this->request('POST', 'https://example.test/administrator/grades/save'); + $request->setHeader('Accept', 'application/json'); + + $result = (new SchoolYearWritableFilter())->before($request); + + $this->assertInstanceOf(Response::class, $result); + $this->assertSame(409, $result->getStatusCode()); + $this->assertStringContainsString('Read-only school year', $result->getBody()); + } + + public function testPostAgainstActiveSelectedYearIsAllowed(): void + { + $this->useSchoolYearContext(['id' => 2, 'name' => '2026-2027', 'status' => 'active']); + + $request = $this->request('POST', 'https://example.test/administrator/grades/save'); + + $this->assertNull((new SchoolYearWritableFilter())->before($request)); + } + + public function testSchoolYearSelectionPostIsExempt(): void + { + $this->useSchoolYearContext(['id' => 1, 'name' => '2025-2026', 'status' => 'closed']); + + $request = $this->request('POST', 'https://example.test/school-year/select'); + + $this->assertNull((new SchoolYearWritableFilter())->before($request)); + } + + public function testReadRequestsAreAllowedForClosedSelectedYear(): void + { + $this->useSchoolYearContext(['id' => 1, 'name' => '2025-2026', 'status' => 'closed']); + + $request = $this->request('GET', 'https://example.test/administrator/grades'); + + $this->assertNull((new SchoolYearWritableFilter())->before($request)); + } + + public function testLegacyGetDeleteRouteAgainstClosedSelectedYearIsBlocked(): void + { + $this->useSchoolYearContext(['id' => 1, 'name' => '2025-2026', 'status' => 'closed']); + + $request = $this->request('GET', 'https://example.test/administrator/teacher/delete/44'); + $request->setHeader('Accept', 'application/json'); + + $result = (new SchoolYearWritableFilter())->before($request); + + $this->assertInstanceOf(Response::class, $result); + $this->assertSame(409, $result->getStatusCode()); + } + + private function useSchoolYearContext(array $row): void + { + Services::injectMock( + 'schoolYearContext', + new SchoolYearContextService(new SchoolYearWritableFilterFakeModel($row)) + ); + } + + private function request(string $method, string $uri): IncomingRequest + { + $request = new IncomingRequest(config(App::class), new URI($uri), 'php://input', new UserAgent()); + $request->setMethod($method); + + return $request; + } +} diff --git a/tests/app/Services/SchoolYearContextServiceTest.php b/tests/app/Services/SchoolYearContextServiceTest.php index 4d0a269..e9bd8c5 100644 --- a/tests/app/Services/SchoolYearContextServiceTest.php +++ b/tests/app/Services/SchoolYearContextServiceTest.php @@ -29,6 +29,8 @@ final class SchoolYearContextRequest extends MockIncomingRequest final class SchoolYearContextFakeModel extends SchoolYearModel { + private array $filters = []; + public function __construct(private readonly array $rows) { } @@ -48,6 +50,35 @@ final class SchoolYearContextFakeModel extends SchoolYearModel return null; } + + public function where($key, $value = null, ?bool $escape = null) + { + $this->filters[] = [$key, $value]; + + return $this; + } + + public function orderBy($orderBy, string $direction = '', ?bool $escape = null) + { + return $this; + } + + public function findAll(?int $limit = null, int $offset = 0) + { + $rows = array_values(array_filter($this->rows, function (array $row): bool { + foreach ($this->filters as [$key, $value]) { + if (($row[$key] ?? null) !== $value) { + return false; + } + } + + return true; + })); + + $this->filters = []; + + return $limit !== null ? array_slice($rows, $offset, $limit) : array_slice($rows, $offset); + } } final class SchoolYearContextServiceTest extends CIUnitTestCase @@ -116,4 +147,20 @@ final class SchoolYearContextServiceTest extends CIUnitTestCase $this->assertNull(session()->get('semester')); $this->assertNull(session()->get('active_school_year')); } + + public function testSingleClosingYearIsUsedAsReadonlyContextWhenNoActiveYearExists(): void + { + $service = new SchoolYearContextService(new SchoolYearContextFakeModel([ + 1 => ['id' => 1, 'name' => '2025-2026', 'status' => 'closing'], + 2 => ['id' => 2, 'name' => '2026-2027', 'status' => 'draft'], + ])); + + $context = $service->resolve(new SchoolYearContextRequest()); + + $this->assertSame(1, $context->id()); + $this->assertSame('2025-2026', $context->yearName()); + $this->assertSame('closing', $context->status()); + $this->assertTrue($context->isReadonly()); + $this->assertFalse($context->isExplicitSelection()); + } }