This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Commands;
|
||||
|
||||
use App\Services\RegistrationOpeningEmailService;
|
||||
use CodeIgniter\CLI\BaseCommand;
|
||||
use CodeIgniter\CLI\CLI;
|
||||
|
||||
class SendRegistrationOpeningEmail extends BaseCommand
|
||||
{
|
||||
protected $group = 'Registration';
|
||||
protected $name = 'registration:send-opening-email';
|
||||
protected $description = 'Send the parent registration opening email on the configured registration start date.';
|
||||
protected $usage = 'php spark registration:send-opening-email [--force] [--date=YYYY-MM-DD] [--email=parent@example.com] [--dry-run] [--tz=America/New_York]';
|
||||
|
||||
public function run(array $params)
|
||||
{
|
||||
$tzName = (string) (CLI::getOption('tz') ?? config('School')->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');
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -48,6 +48,7 @@ class Filters extends BaseConfig
|
||||
'timezone',
|
||||
'sanitizeinput',
|
||||
'invalidchars',
|
||||
'schoolYearWritable',
|
||||
'csrf' => ['except' => [
|
||||
// WhatsApp membership management (legacy allowances retained)
|
||||
'whatsapp/update-membership',
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'])) {
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Database\Migrations;
|
||||
|
||||
use CodeIgniter\Database\Migration;
|
||||
|
||||
class AddFallMakeupExamToSchoolYears extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (! $this->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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
<?php
|
||||
|
||||
namespace App\Database\Migrations;
|
||||
|
||||
use CodeIgniter\Database\Migration;
|
||||
|
||||
class CreateParentPolicyAcceptances extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if ($this->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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace App\Database\Migrations;
|
||||
|
||||
use CodeIgniter\Database\Migration;
|
||||
|
||||
class CreateRegistrationOpeningEmailTemplate extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (! $this->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'
|
||||
<p>Dear {{name}},</p>
|
||||
<p>Registration is now open for the {{school_year}} school year.</p>
|
||||
<p>Please complete your registration through the school portal. The registration deadline is {{registration_deadline}}.</p>
|
||||
<p><a href="{{registration_url}}">Start registration</a></p>
|
||||
<p>If you already have an account, you can also log in here: <a href="{{login_url}}">{{login_url}}</a></p>
|
||||
<p>Regards,<br>{{school_name}}</p>
|
||||
HTML;
|
||||
}
|
||||
}
|
||||
@@ -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<string>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use CodeIgniter\Model;
|
||||
|
||||
class ParentPolicyAcceptanceModel extends Model
|
||||
{
|
||||
protected $table = 'parent_policy_acceptances';
|
||||
protected $primaryKey = 'id';
|
||||
protected $returnType = 'array';
|
||||
protected $useTimestamps = true;
|
||||
|
||||
protected $allowedFields = [
|
||||
'parent_id',
|
||||
'school_year',
|
||||
'accepted_at',
|
||||
'source',
|
||||
'ip_address',
|
||||
'user_agent',
|
||||
];
|
||||
|
||||
protected $validationRules = [
|
||||
'parent_id' => '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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use CodeIgniter\Database\BaseConnection;
|
||||
|
||||
class RegistrationOpeningEmailService
|
||||
{
|
||||
public const TEMPLATE_KEY = 'registration_opening';
|
||||
|
||||
private BaseConnection $db;
|
||||
private EmailService $emailService;
|
||||
|
||||
public function __construct(?BaseConnection $db = null, ?EmailService $emailService = null)
|
||||
{
|
||||
$this->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'
|
||||
<p>Dear {{name}},</p>
|
||||
<p>Registration is now open for the {{school_year}} school year.</p>
|
||||
<p>Please complete your registration through the school portal. The registration deadline is {{registration_deadline}}.</p>
|
||||
<p><a href="{{registration_url}}">Start registration</a></p>
|
||||
<p>If you already have an account, you can also log in here: <a href="{{login_url}}">{{login_url}}</a></p>
|
||||
<p>Regards,<br>{{school_name}}</p>
|
||||
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), '"');
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -25,9 +25,88 @@ $deadlineISO = $deadlineObj->format('Y-m-d\TH:i:sP'); // for JS
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<?php if (session()->getFlashdata('error')): ?>
|
||||
<div class="alert alert-danger"><?= esc(session()->getFlashdata('error')) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php $hasAcceptedSchoolPolicy = (bool) ($hasAcceptedSchoolPolicy ?? false); ?>
|
||||
<?php
|
||||
$fallMakeupExamLabel = !empty($fallMakeupExamOn) ? local_date($fallMakeupExamOn, 'm-d-Y') : null;
|
||||
$decisionMessageForStudent = static function (array $student) use ($fallMakeupExamLabel): array {
|
||||
$decisionRow = is_array($student['previous_year_decision'] ?? null) ? $student['previous_year_decision'] : [];
|
||||
$decision = trim((string) ($decisionRow['decision'] ?? ''));
|
||||
$source = strtolower(trim((string) ($decisionRow['source'] ?? '')));
|
||||
$decisionKey = strtolower($decision);
|
||||
$name = trim((string) ($student['firstname'] ?? 'Your child') . ' ' . (string) ($student['lastname'] ?? ''));
|
||||
$name = $name !== '' ? $name : 'Your child';
|
||||
$previousClass = trim((string) ($decisionRow['class_section_name'] ?? ''));
|
||||
$previousClass = $previousClass !== '' ? $previousClass : 'the same class';
|
||||
|
||||
if ($decisionKey === 'pass' || ($decisionKey === '' && $source !== 'pending')) {
|
||||
return ['message' => '', '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',
|
||||
];
|
||||
};
|
||||
?>
|
||||
|
||||
<?php if (!empty($students)): ?>
|
||||
<form action="<?= base_url('/parent/enroll_classes_handler') ?>" method="post">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" id="accept_school_policy_input" name="accept_school_policy" value="<?= $hasAcceptedSchoolPolicy ? '1' : '0' ?>">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-bordered align-middle">
|
||||
<thead>
|
||||
@@ -46,6 +125,7 @@ $deadlineISO = $deadlineObj->format('Y-m-d\TH:i:sP'); // for JS
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($students as $index => $student): ?>
|
||||
<?php $decisionMessage = $decisionMessageForStudent($student); ?>
|
||||
<tr>
|
||||
<td><?= $index + 1 ?></td>
|
||||
<td><?= esc($student['school_id'] ?? 'N/A') ?></td>
|
||||
@@ -69,6 +149,9 @@ $deadlineISO = $deadlineObj->format('Y-m-d\TH:i:sP'); // for JS
|
||||
<input type="checkbox"
|
||||
name="enroll[]"
|
||||
value="<?= esc($student['id']) ?>"
|
||||
data-decision-message="<?= esc($decisionMessage['message']) ?>"
|
||||
data-decision-blocking="<?= $decisionMessage['blocking'] ? '1' : '0' ?>"
|
||||
data-decision-message-target="enrollment-decision-message-<?= esc($student['id']) ?>"
|
||||
<?= $disableEnrollUI ? 'disabled' : '' ?>>
|
||||
<?php elseif (in_array($student['enrollment_status'], ['enrolled', 'admission under review', 'payment pending', 'withdraw under review'])): ?>
|
||||
<input type="checkbox" checked disabled>
|
||||
@@ -127,6 +210,15 @@ $deadlineISO = $deadlineObj->format('Y-m-d\TH:i:sP'); // for JS
|
||||
?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php if ($decisionMessage['message'] !== ''): ?>
|
||||
<tr id="enrollment-decision-message-<?= esc($student['id']) ?>" class="enrollment-decision-message-row d-none">
|
||||
<td colspan="10">
|
||||
<div class="alert alert-<?= esc($decisionMessage['level']) ?> mb-0">
|
||||
<?= esc($decisionMessage['message']) ?>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -158,6 +250,32 @@ $deadlineISO = $deadlineObj->format('Y-m-d\TH:i:sP'); // for JS
|
||||
<?php else: ?>
|
||||
<p>No students found for the selected school year. Please register your kids first.</p>
|
||||
<?php endif; ?>
|
||||
<!-- School Policy Modal -->
|
||||
<div class="modal fade" id="schoolPolicyModal" tabindex="-1" aria-labelledby="schoolPolicyModalLabel" aria-hidden="true" data-bs-backdrop="static">
|
||||
<div class="modal-dialog modal-xl modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header bg-success text-white">
|
||||
<h5 class="modal-title" id="schoolPolicyModalLabel">School Policies</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<iframe src="<?= base_url('policy/school_policy') ?>" width="100%" height="520" frameborder="0" title="School Policies"></iframe>
|
||||
<div class="form-check mt-3">
|
||||
<input class="form-check-input" type="checkbox" value="1" id="schoolPolicyAcceptedCheckbox" <?= $hasAcceptedSchoolPolicy ? 'checked disabled' : '' ?>>
|
||||
<label class="form-check-label" for="schoolPolicyAcceptedCheckbox">
|
||||
I have read and accept all school policies.
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-success" id="acceptSchoolPolicyButton" <?= $hasAcceptedSchoolPolicy ? '' : 'disabled' ?>>
|
||||
Accept Policy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Enrollment Deadline Modal -->
|
||||
<div class="modal fade" id="deadlineModal" tabindex="-1" aria-labelledby="deadlineModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
@@ -188,13 +306,56 @@ $deadlineISO = $deadlineObj->format('Y-m-d\TH:i:sP'); // for JS
|
||||
// Values from PHP
|
||||
const deadlinePassed = <?= $deadlinePassed ? 'true' : 'false' ?>;
|
||||
const enrollmentDeadline = new Date("<?= esc($deadlineISO) ?>");
|
||||
let hasAcceptedSchoolPolicy = <?= $hasAcceptedSchoolPolicy ? 'true' : 'false' ?>;
|
||||
|
||||
// Bootstrap Modal
|
||||
const modalEl = document.getElementById('deadlineModal');
|
||||
const deadlineModal = modalEl ? new bootstrap.Modal(modalEl) : null;
|
||||
const policyModalEl = document.getElementById('schoolPolicyModal');
|
||||
const policyModal = policyModalEl ? new bootstrap.Modal(policyModalEl) : null;
|
||||
const policyAcceptedInput = document.getElementById('accept_school_policy_input');
|
||||
const policyAcceptedCheckbox = document.getElementById('schoolPolicyAcceptedCheckbox');
|
||||
const acceptPolicyButton = document.getElementById('acceptSchoolPolicyButton');
|
||||
const showDeadlineModal = () => {
|
||||
if (deadlineModal) deadlineModal.show();
|
||||
};
|
||||
const showPolicyModal = () => {
|
||||
if (policyModal) policyModal.show();
|
||||
};
|
||||
const setDecisionMessageVisible = (checkbox, visible) => {
|
||||
const targetId = checkbox.dataset.decisionMessageTarget || '';
|
||||
const target = targetId ? document.getElementById(targetId) : null;
|
||||
if (target) {
|
||||
target.classList.toggle('d-none', !visible);
|
||||
}
|
||||
};
|
||||
const hideAllDecisionMessages = () => {
|
||||
document.querySelectorAll('.enrollment-decision-message-row').forEach(row => {
|
||||
row.classList.add('d-none');
|
||||
});
|
||||
};
|
||||
|
||||
if (policyAcceptedCheckbox && acceptPolicyButton) {
|
||||
policyAcceptedCheckbox.addEventListener('change', function() {
|
||||
acceptPolicyButton.disabled = !this.checked;
|
||||
});
|
||||
}
|
||||
|
||||
if (acceptPolicyButton) {
|
||||
acceptPolicyButton.addEventListener('click', function() {
|
||||
if (policyAcceptedCheckbox && !policyAcceptedCheckbox.checked) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasAcceptedSchoolPolicy = true;
|
||||
if (policyAcceptedInput) {
|
||||
policyAcceptedInput.value = '1';
|
||||
}
|
||||
if (policyModal) {
|
||||
policyModal.hide();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// The enrollment form
|
||||
const form = document.querySelector("form[action*='enroll_classes_handler']");
|
||||
@@ -202,11 +363,35 @@ $deadlineISO = $deadlineObj->format('Y-m-d\TH:i:sP'); // for JS
|
||||
// 1) Block clicking "enroll" checkboxes after deadline
|
||||
document.querySelectorAll("input[name='enroll[]']").forEach(cb => {
|
||||
cb.addEventListener("click", function(e) {
|
||||
hideAllDecisionMessages();
|
||||
|
||||
if (deadlinePassed) {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
this.checked = false;
|
||||
showDeadlineModal();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasAcceptedSchoolPolicy) {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
this.checked = false;
|
||||
showPolicyModal();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.checked && this.dataset.decisionMessage) {
|
||||
setDecisionMessageVisible(this, true);
|
||||
|
||||
if (this.dataset.decisionBlocking === '1') {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
this.checked = false;
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
setDecisionMessageVisible(this, false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -223,6 +408,12 @@ $deadlineISO = $deadlineObj->format('Y-m-d\TH:i:sP'); // for JS
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasAcceptedSchoolPolicy && anyBoxesChecked) {
|
||||
e.preventDefault();
|
||||
showPolicyModal();
|
||||
return;
|
||||
}
|
||||
|
||||
if (deadlinePassed && anyBoxesChecked) {
|
||||
e.preventDefault();
|
||||
showDeadlineModal();
|
||||
|
||||
@@ -110,7 +110,7 @@ $disableEmergencyBtn = count($emergencies) >= $maxEmergency;
|
||||
?>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<?php if ($isEditable && $kid['enrollment'] == 0): ?>
|
||||
<?php if ($isEditable && !empty($kid['can_delete'])): ?>
|
||||
<form action="<?= base_url('/parent/delete_student/' . $kid['id']) ?>"
|
||||
method="post"
|
||||
style="display:inline">
|
||||
@@ -126,7 +126,7 @@ $disableEmergencyBtn = count($emergencies) >= $maxEmergency;
|
||||
</button>
|
||||
</form>
|
||||
<?php else: ?>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" disabled title="<?= $isEditable ? 'Student is already enrolled' : 'This school year is read-only' ?>">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" disabled title="<?= $isEditable ? 'Student has enrollment history and cannot be deleted' : 'This school year is read-only' ?>">
|
||||
<i class="fas fa-ban"></i> Delete
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
|
||||
@@ -218,30 +218,6 @@
|
||||
<div class="fw-semibold"><?= esc((string) ($promotionSummary['pass'] ?? 0)) ?></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-2 col-md-3 col-6">
|
||||
<div class="border rounded p-2 h-100">
|
||||
<div class="text-muted small">Other</div>
|
||||
<div class="fw-semibold"><?= esc((string) ($promotionSummary['other_decision'] ?? 0)) ?></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-2 col-md-3 col-6">
|
||||
<div class="border rounded p-2 h-100">
|
||||
<div class="text-muted small">Queued</div>
|
||||
<div class="fw-semibold"><?= esc((string) ($promotionSummary['queued'] ?? 0)) ?></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-2 col-md-3 col-6">
|
||||
<div class="border rounded p-2 h-100">
|
||||
<div class="text-muted small">Assigned</div>
|
||||
<div class="fw-semibold"><?= esc((string) ($promotionSummary['assigned'] ?? 0)) ?></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-2 col-md-3 col-6">
|
||||
<div class="border rounded p-2 h-100">
|
||||
<div class="text-muted small">Applied</div>
|
||||
<div class="fw-semibold"><?= esc((string) ($promotionSummary['applied'] ?? 0)) ?></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-2 col-md-3 col-6">
|
||||
<div class="border rounded p-2 h-100">
|
||||
<div class="text-muted small">Pending</div>
|
||||
@@ -250,14 +226,8 @@
|
||||
</div>
|
||||
<div class="col-lg-2 col-md-3 col-6">
|
||||
<div class="border rounded p-2 h-100">
|
||||
<div class="text-muted small">Missing</div>
|
||||
<div class="fw-semibold text-danger"><?= esc((string) ($promotionSummary['missing_decision'] ?? 0)) ?></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-2 col-md-3 col-6">
|
||||
<div class="border rounded p-2 h-100">
|
||||
<div class="text-muted small">Missing Queue</div>
|
||||
<div class="fw-semibold text-danger"><?= esc((string) ($promotionSummary['missing_queue'] ?? 0)) ?></div>
|
||||
<div class="text-muted small">Other</div>
|
||||
<div class="fw-semibold"><?= esc((string) ($promotionSummary['other_decision'] ?? 0)) ?></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 @@
|
||||
<input class="form-control" id="new_school_year_name" name="name" placeholder="2026-2027" required pattern="\d{4}-\d{4}">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label" for="new_previous_school_year_id">Previous Year</label>
|
||||
<select class="form-select" id="new_previous_school_year_id" name="previous_school_year_id">
|
||||
<option value="">None</option>
|
||||
<?php foreach (($schoolYears ?? []) as $existingYear): ?>
|
||||
<option value="<?= (int) ($existingYear['id'] ?? 0) ?>"><?= esc($existingYear['name'] ?? '') ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<label class="form-label" for="new_previous_school_year_display">Previous Year</label>
|
||||
<input
|
||||
class="form-control"
|
||||
id="new_previous_school_year_display"
|
||||
type="text"
|
||||
value="<?= esc($defaultNewPreviousYearName, 'attr') ?>"
|
||||
data-default-previous-year="<?= esc($defaultNewPreviousYearName, 'attr') ?>"
|
||||
readonly
|
||||
>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label" for="new_school_year_starts_on">Starts On</label>
|
||||
@@ -109,6 +120,10 @@
|
||||
<label class="form-label" for="new_registration_ends_on">Registration Ends</label>
|
||||
<input class="form-control" id="new_registration_ends_on" name="registration_ends_on" type="date">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label" for="new_fall_makeup_exam_on">Fall Makeup Exam</label>
|
||||
<input class="form-control" id="new_fall_makeup_exam_on" name="fall_makeup_exam_on" type="date">
|
||||
</div>
|
||||
<div class="col-md-2 d-flex gap-2">
|
||||
<button class="btn btn-primary" type="submit">Save Draft</button>
|
||||
<button class="btn btn-secondary" type="button" data-bs-toggle="collapse" data-bs-target="#schoolYearCreateForm">Cancel</button>
|
||||
@@ -149,6 +164,7 @@
|
||||
<td>
|
||||
<div><?= $formatDate($year['starts_on'] ?? null) ?></div>
|
||||
<div class="text-muted small"><?= $formatDate($year['ends_on'] ?? null) ?></div>
|
||||
<div class="text-muted small">Fall makeup: <?= $formatDate($year['fall_makeup_exam_on'] ?? null) ?></div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge <?= esc($statusClasses[$status] ?? 'bg-secondary') ?>">
|
||||
@@ -338,6 +354,10 @@
|
||||
<label class="form-label" for="registration_ends_on_<?= $id ?>">Registration Ends</label>
|
||||
<input class="form-control" id="registration_ends_on_<?= $id ?>" name="registration_ends_on" type="date" value="<?= esc($year['registration_ends_on'] ?? '') ?>">
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label" for="fall_makeup_exam_on_<?= $id ?>">Fall Makeup Exam</label>
|
||||
<input class="form-control" id="fall_makeup_exam_on_<?= $id ?>" name="fall_makeup_exam_on" type="date" value="<?= esc($year['fall_makeup_exam_on'] ?? '') ?>">
|
||||
</div>
|
||||
</div>
|
||||
<label class="form-label" for="description_<?= $id ?>">Description</label>
|
||||
<textarea class="form-control" id="description_<?= $id ?>" name="description" rows="3"><?= esc($year['description'] ?? '') ?></textarea>
|
||||
@@ -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 = <?= json_encode($previousYearNameByNewYearName, JSON_UNESCAPED_SLASHES) ?>;
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<?= $this->endSection() ?>
|
||||
|
||||
@@ -257,6 +257,9 @@
|
||||
(Read school policies here)
|
||||
</button>
|
||||
</label>
|
||||
<?php if (isset($errors['accept_school_policy'])): ?>
|
||||
<div class="text-danger small"><?= esc($errors['accept_school_policy']) ?></div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<div class="text-center border p-2 bg-light item">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\App\Config;
|
||||
|
||||
use CodeIgniter\Test\CIUnitTestCase;
|
||||
|
||||
final class SchoolYearRouteIntegrityTest extends CIUnitTestCase
|
||||
{
|
||||
public function testSchoolYearManagementRoutesAllowPrincipalRole(): void
|
||||
{
|
||||
$routes = file_get_contents(ROOTPATH . 'app/Config/Routes.php') ?: '';
|
||||
|
||||
$this->assertMatchesRegularExpression(
|
||||
"/\\\$routes->group\\('administrator\\/school-years', \\['filter' => 'auth:[^']*\\bprincipal\\b[^']*'\\]/",
|
||||
$routes
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\App\Filters;
|
||||
|
||||
use App\Filters\SchoolYearWritableFilter;
|
||||
use App\Models\SchoolYearModel;
|
||||
use App\Services\SchoolYearContextService;
|
||||
use CodeIgniter\HTTP\IncomingRequest;
|
||||
use CodeIgniter\HTTP\Response;
|
||||
use CodeIgniter\HTTP\URI;
|
||||
use CodeIgniter\HTTP\UserAgent;
|
||||
use CodeIgniter\Test\CIUnitTestCase;
|
||||
use Config\App;
|
||||
use Config\Services;
|
||||
|
||||
final class SchoolYearWritableFilterFakeModel extends SchoolYearModel
|
||||
{
|
||||
public function __construct(private readonly array $row)
|
||||
{
|
||||
}
|
||||
|
||||
public function active(): ?array
|
||||
{
|
||||
return $this->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;
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user