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}}.
+ +If you already have an account, you can also log in here: {{login_url}}
+Regards,
{{school_name}}
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}}.
+ +If you already have an account, you can also log in here: {{login_url}}
+Regards,
{{school_name}}