diff --git a/app/Config/Email.php b/app/Config/Email.php index 85a95bc..132f632 100644 --- a/app/Config/Email.php +++ b/app/Config/Email.php @@ -6,16 +6,16 @@ use CodeIgniter\Config\BaseConfig; class Email extends BaseConfig { - public string $protocol = 'smtp'; - public string $SMTPHost = 'smtp.gmail.com'; - public string $SMTPUser = 'alrahma.sunday.school@gmail.com'; - public string $SMTPPass = 'psnp emdq dykw ypul'; // Consider using ENV() - public int $SMTPPort = 465; - public string $SMTPCrypto = 'ssl'; // ✅ Correct for port 465 + public string $protocol; + public string $SMTPHost; + public string $SMTPUser; + public string $SMTPPass; + public int $SMTPPort; + public string $SMTPCrypto; - public bool $SMTPAuth = true; - public int $SMTPTimeout = 5; - public bool $SMTPKeepAlive = true; + public bool $SMTPAuth; + public int $SMTPTimeout; + public bool $SMTPKeepAlive; public string $charset = 'UTF-8'; public string $mailType = 'html'; @@ -23,4 +23,19 @@ class Email extends BaseConfig public string $newline = "\r\n"; public string $CRLF = "\r\n"; -} \ No newline at end of file + + public function __construct() + { + parent::__construct(); + + $this->protocol = (string) env('mail.protocol', env('email.protocol', 'smtp')); + $this->SMTPHost = (string) env('mail.SMTPHost', env('email.SMTPHost', env('SMTP_HOST', 'smtp.gmail.com'))); + $this->SMTPUser = (string) env('mail.SMTPUser', env('email.SMTPUser', env('SMTP_USER', ''))); + $this->SMTPPass = (string) env('mail.SMTPPass', env('email.SMTPPass', env('SMTP_PASS', ''))); + $this->SMTPPort = (int) env('mail.SMTPPort', env('email.SMTPPort', env('SMTP_PORT', 465))); + $this->SMTPCrypto = (string) env('mail.SMTPCrypto', env('email.SMTPCrypto', env('SMTP_ENCRYPTION', 'ssl'))); + $this->SMTPAuth = filter_var(env('mail.SMTPAuth', env('email.SMTPAuth', true)), FILTER_VALIDATE_BOOLEAN); + $this->SMTPTimeout = (int) env('mail.SMTPTimeout', env('email.SMTPTimeout', 5)); + $this->SMTPKeepAlive = filter_var(env('mail.SMTPKeepAlive', env('email.SMTPKeepAlive', true)), FILTER_VALIDATE_BOOLEAN); + } +} diff --git a/app/Config/Filters.php b/app/Config/Filters.php index 3a8f234..f2bfda4 100644 --- a/app/Config/Filters.php +++ b/app/Config/Filters.php @@ -29,7 +29,7 @@ class Filters extends BaseConfig 'auth' => \App\Filters\AuthFilter::class, // Define the alias for your auth filter 'apiAuth' => \App\Filters\ApiAuthFilter::class, // JWT-based API authentication 'cleanupScheduler' => \App\Filters\CleanupScheduler::class, - 'permission' => \App\Filters\PermissionFilter::class, + 'permission' => \App\Filters\PermissionFilter::class, 'timezone' => \App\Filters\TimezoneFilter::class, 'schoolYear' => \App\Filters\RequireSchoolYearFilter::class, 'schoolYearWritable'=> \App\Filters\SchoolYearWritableFilter::class, @@ -45,10 +45,30 @@ class Filters extends BaseConfig */ public array $globals = [ 'before' => [ - 'timezone', + // Heartbeat must not load timezone preferences / settings. + 'timezone' => ['except' => [ + 'session/ping-activity', + 'index.php/session/ping-activity', + 'session/ping', + 'index.php/session/ping', + 'session/check-timeout', + 'index.php/session/check-timeout', + 'session/get-timeout-config', + 'index.php/session/get-timeout-config', + ]], 'sanitizeinput', 'invalidchars', - 'schoolYearWritable', + // Heartbeat is not a school-year write; skip writable-year DB work. + 'schoolYearWritable' => ['except' => [ + 'session/ping-activity', + 'index.php/session/ping-activity', + 'session/ping', + 'index.php/session/ping', + 'session/check-timeout', + 'index.php/session/check-timeout', + 'session/get-timeout-config', + 'index.php/session/get-timeout-config', + ]], 'csrf' => ['except' => [ // Attendance management AJAX saves 'attendance/update', diff --git a/app/Config/Routes.php b/app/Config/Routes.php index 5315538..71f4244 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -308,8 +308,9 @@ $routes->get('ui/style', 'View\UiController::style'); //Timeout page after timeout $routes->get('session/get-timeout-config', 'View\SessionTimeoutController::getTimeoutConfig'); $routes->get('session/check-timeout', 'View\SessionTimeoutController::checkTimeout'); -$routes->post('session/ping-activity', 'View\SessionTimeoutController::pingActivity'); -$routes->post('session/ping', 'View\SessionTimeoutController::pingActivity'); +// Cap heartbeat to ~5 requests/minute per authenticated session (or IP if anonymous). +$routes->post('session/ping-activity', 'View\SessionTimeoutController::pingActivity', ['filter' => 'apiratelimit:5,60']); +$routes->post('session/ping', 'View\SessionTimeoutController::pingActivity', ['filter' => 'apiratelimit:5,60']); /* diff --git a/app/Config/SessionTimeout.php b/app/Config/SessionTimeout.php index 52e4e56..fb5a900 100644 --- a/app/Config/SessionTimeout.php +++ b/app/Config/SessionTimeout.php @@ -6,13 +6,16 @@ class SessionTimeout { // Session timeout in seconds (30 minutes) public const TIMEOUT_DURATION = 1800; - + // Show warning during the last 30 seconds before timeout. public const WARNING_THRESHOLD = 1770; - + // Server-side check interval (in seconds) - public const CHECK_INTERVAL = 5; - - // Client-side check interval (in milliseconds) - public const CLIENT_CHECK_INTERVAL = 5000; + public const CHECK_INTERVAL = 30; + + // Client-side session status check interval (in milliseconds) + public const CLIENT_CHECK_INTERVAL = 30000; + + // Minimum interval between successful activity pings (in milliseconds) + public const CLIENT_PING_INTERVAL = 60000; } diff --git a/app/Controllers/View/EnrollmentAdminController.php b/app/Controllers/View/EnrollmentAdminController.php index acfa78e..471d695 100644 --- a/app/Controllers/View/EnrollmentAdminController.php +++ b/app/Controllers/View/EnrollmentAdminController.php @@ -8,6 +8,7 @@ use CodeIgniter\HTTP\RequestInterface; use CodeIgniter\HTTP\ResponseInterface; use Psr\Log\LoggerInterface; use Throwable; +use App\Support\Enrollment\DeliberationDecision; class EnrollmentAdminController extends BaseController { @@ -503,12 +504,67 @@ class EnrollmentAdminController extends BaseController $row['assignee_name'] = trim((string) ($row['assignee_firstname'] ?? '') . ' ' . (string) ($row['assignee_lastname'] ?? '')); $row['parent_name'] = trim((string) ($row['parent_firstname'] ?? '') . ' ' . (string) ($row['parent_lastname'] ?? '')) ?: ((int) ($row['parent_id'] ?? 0) > 0 ? 'Parent #' . (int) $row['parent_id'] : ''); $row['details'] = json_decode((string) ($row['details_json'] ?? ''), true) ?: []; + if (! isset($row['details']['rule_code'])) { + $ruleCode = $this->ruleCodeForExistingFlag($row); + if ($ruleCode !== null) { + $row['details']['rule_code'] = $ruleCode; + } + } } unset($row); return $rows; } + private function ruleCodeForExistingFlag(array $flag): ?string + { + $type = strtoupper(trim((string) ($flag['flag_type'] ?? ''))); + if (! in_array($type, ['DEFERRED_DELIBERATION', 'RESTRICTED_ADMINISTRATIVE_REVIEW', 'WITHDRAWAL_REVIEW_REQUIRED', 'PENDING_MAKE_UP_EXAM_PROMOTION'], true)) { + return null; + } + + $studentId = (int) ($flag['student_id'] ?? 0); + $sourceSchoolYear = trim((string) ($flag['source_school_year'] ?? '')); + if ($studentId <= 0 || $sourceSchoolYear === '' || ! $this->db->tableExists('student_decisions')) { + return $type === 'WITHDRAWAL_REVIEW_REQUIRED' ? 'WITHDRAWN' : null; + } + + $select = ['decision']; + if ($this->db->fieldExists('deliberation_decision_standard', 'student_decisions')) { + $select[] = 'deliberation_decision_standard'; + } + + $row = $this->db->table('student_decisions') + ->select($select) + ->where('student_id', $studentId) + ->where('school_year', $sourceSchoolYear) + ->orderBy('updated_at', 'DESC') + ->orderBy('id', 'DESC') + ->limit(1) + ->get() + ->getRowArray(); + + if ($row === null) { + return match ($type) { + 'DEFERRED_DELIBERATION' => 'NO_FINAL_DECISION', + 'WITHDRAWAL_REVIEW_REQUIRED' => 'WITHDRAWN', + default => null, + }; + } + + $decision = DeliberationDecision::normalize($row['deliberation_decision_standard'] ?? null) + ?? DeliberationDecision::normalize($row['decision'] ?? null); + + return match ($decision) { + DeliberationDecision::DEFERRED_DECISION => 'DEFERRED_DECISION', + DeliberationDecision::EXPELLED => 'EXPELLED', + DeliberationDecision::WITHDRAWN => 'WITHDRAWN', + DeliberationDecision::MAKE_UP_EXAM => 'MAKE_UP_EXAM', + null => $type === 'DEFERRED_DELIBERATION' ? 'UNRECOGNIZED_DECISION' : null, + default => null, + }; + } + private function enrollmentFollowups(string $schoolYear, array $flags = []): array { if (! $this->db->tableExists('enrollments')) { diff --git a/app/Controllers/View/InvoiceController.php b/app/Controllers/View/InvoiceController.php index 2e52e39..a248587 100644 --- a/app/Controllers/View/InvoiceController.php +++ b/app/Controllers/View/InvoiceController.php @@ -514,9 +514,16 @@ class InvoiceController extends ResourceController bool $recalculateDiscounts = true ) { - $isAjax = $this->request->isAJAX() || str_contains(strtolower($this->request->getHeaderLine('Accept')), 'application/json'); + $request = $this->request ?? service('request'); + $isAjax = $request !== null && ( + $request->isAJAX() + || str_contains(strtolower($request->getHeaderLine('Accept')), 'application/json') + ); + // Programmatic callers (new InvoiceController() without initController) have no response object. + $hasHttpResponse = $this->response !== null; + if ($parentId == null) { - $parentId = (int)$this->request->getPost('parent_id'); + $parentId = (int) ($request?->getPost('parent_id') ?? 0); } $schoolYear = (string) ($schoolYearOverride ?: $this->schoolYear); $semester = (string) ($semesterOverride ?: $this->semester); @@ -528,10 +535,13 @@ class InvoiceController extends ResourceController ->findAll(); if (empty($enrollments)) { - if ($isAjax) { - return $this->response->setJSON(['ok' => false, 'message' => 'No enrollment records found.']); - } - return redirect()->back()->with('error', 'No enrollment records found.'); + return $this->invoiceGenerationResult( + $hasHttpResponse, + $isAjax, + ['ok' => false, 'message' => 'No enrollment records found.'], + 422, + 'No enrollment records found.' + ); } $registeredKids = []; @@ -605,6 +615,24 @@ class InvoiceController extends ResourceController log_message('info', "Updated invoice ID {$invoice['id']} for parent ID {$parentId}."); $updated = true; } else { + $hasNonZeroTuitionOrEvents = abs((float) $tuitionFee) > 0.00001 + || abs((float) $eventchargeTotal) > 0.00001; + $hasApprovedAdjustments = $this->parentHasApprovedInvoiceAdjustments( + (int) $parentId, + $schoolYear, + $semester + ); + + if (! $hasNonZeroTuitionOrEvents && ! $hasApprovedAdjustments) { + return $this->invoiceGenerationResult( + $hasHttpResponse, + $isAjax, + ['ok' => false, 'message' => 'Invoice requires at least one non-zero line.'], + 422, + 'Invoice requires at least one non-zero line.' + ); + } + $issueUtc = (new DateTime('now', new DateTimeZone('UTC')))->format('Y-m-d H:i:s'); // Due date: interpret the date in configured/user local TZ, @@ -642,29 +670,95 @@ class InvoiceController extends ResourceController log_message('info', "Invoice created successfully. Insert ID: {$insertId}"); } catch (\Throwable $e) { log_message('error', 'Invoice issuance failed: ' . $e->getMessage() . ' errors=' . json_encode($this->invoiceModel->errors())); - if ($isAjax) { - return $this->response->setJSON(['ok' => false, 'message' => 'Failed to create invoice.']); - } - return redirect()->back()->with('error', 'Failed to create invoice. Please check input values.'); + $message = str_contains($e->getMessage(), 'non-zero invoice line') + ? 'Invoice requires at least one non-zero line.' + : 'Failed to create invoice.'; + + return $this->invoiceGenerationResult( + $hasHttpResponse, + $isAjax, + ['ok' => false, 'message' => $message], + 422, + $message === 'Invoice requires at least one non-zero line.' + ? $message + : 'Failed to create invoice. Please check input values.' + ); } $updated = false; } - // Success response + $successPayload = [ + 'ok' => true, + 'updated' => $updated, + 'updated_ids' => $updatedIds, + 'insert_id' => isset($insertId) ? (int)$insertId : null, + csrf_token() => csrf_hash(), + 'csrfTokenName' => csrf_token(), + 'csrfHash' => csrf_hash(), + ]; + + return $this->invoiceGenerationResult( + $hasHttpResponse, + $isAjax, + $successPayload, + 200, + null, + $updated ? 'Invoice updated.' : 'Invoice created.' + ); + } + + /** + * Safe invoice response helper for both HTTP and programmatic callers. + * + * @param array $payload + */ + private function invoiceGenerationResult( + bool $hasHttpResponse, + bool $isAjax, + array $payload, + int $statusCode = 200, + ?string $errorFlash = null, + ?string $successFlash = null + ) { + if (! $hasHttpResponse || $this->response === null) { + return $payload; + } + if ($isAjax) { - return $this->response->setJSON([ - 'ok' => true, - 'updated' => $updated, - 'updated_ids' => $updatedIds, - 'insert_id' => isset($insertId) ? (int)$insertId : null, - csrf_token() => csrf_hash(), - 'csrfTokenName' => csrf_token(), - 'csrfHash' => csrf_hash(), - ]); + return $this->response + ->setStatusCode($statusCode) + ->setJSON($payload); + } + + if ($errorFlash !== null) { + return redirect()->back()->with('error', $errorFlash); } return redirect()->to(route_to('InvoiceController::index')) - ->with('success', $updated ? 'Invoice updated.' : 'Invoice created.'); + ->with('success', $successFlash ?? 'Invoice saved.'); + } + + private function parentHasApprovedInvoiceAdjustments(int $parentId, string $schoolYear, string $semester): bool + { + if ($parentId <= 0 || $schoolYear === '' || $semester === '') { + return false; + } + + try { + return $this->additionalChargeModel + ->where('parent_id', $parentId) + ->where('school_year', $schoolYear) + ->where('semester', $semester) + ->where('status', FinancialStatus::ADDITIONAL_CHARGE_APPROVED) + ->where('amount !=', 0) + ->countAllResults() > 0; + } catch (\Throwable $e) { + log_message('warning', 'Unable to check approved invoice adjustments: {message}', [ + 'message' => $e->getMessage(), + ]); + + return false; + } } private function selectActiveInvoiceForParentYear(int $parentId, string $schoolYear): ?array diff --git a/app/Controllers/View/SessionTimeoutController.php b/app/Controllers/View/SessionTimeoutController.php index 1244dc0..b0e6ce1 100644 --- a/app/Controllers/View/SessionTimeoutController.php +++ b/app/Controllers/View/SessionTimeoutController.php @@ -1,15 +1,29 @@ response->setJSON([ @@ -17,77 +31,121 @@ class SessionTimeoutController extends BaseController 'timeout' => SessionTimeout::TIMEOUT_DURATION, 'warning_time' => SessionTimeout::TIMEOUT_DURATION - SessionTimeout::WARNING_THRESHOLD, 'check_interval' => SessionTimeout::CLIENT_CHECK_INTERVAL, + 'ping_interval' => SessionTimeout::CLIENT_PING_INTERVAL, 'logout_url' => site_url('logout'), 'keep_alive_url' => site_url('session/ping-activity'), - 'check_url' => site_url('session/check-timeout') + 'check_url' => site_url('session/check-timeout'), ]); } public function checkTimeout() { - $session = session(); + try { + $session = session(); - // Verify session exists and has last_activity - if (!$session->has('last_activity')) { - return $this->expireSession(); - } + if (! $session->has('last_activity')) { + return $this->expireSession(); + } - $lastActivity = $session->get('last_activity'); - $elapsed = time() - $lastActivity; + $lastActivity = (int) $session->get('last_activity'); + $elapsed = time() - $lastActivity; + + if ($elapsed >= SessionTimeout::TIMEOUT_DURATION) { + return $this->expireSession(); + } + + if ($elapsed >= SessionTimeout::WARNING_THRESHOLD) { + return $this->response->setJSON([ + 'status' => 'warning', + 'time_remaining' => SessionTimeout::TIMEOUT_DURATION - $elapsed, + ]); + } - if ($elapsed >= SessionTimeout::TIMEOUT_DURATION) { - return $this->expireSession(); - } elseif ($elapsed >= SessionTimeout::WARNING_THRESHOLD) { return $this->response->setJSON([ - 'status' => 'warning', - 'time_remaining' => SessionTimeout::TIMEOUT_DURATION - $elapsed + 'status' => 'active', + 'time_remaining' => SessionTimeout::TIMEOUT_DURATION - $elapsed, ]); + } catch (Throwable $e) { + $this->logDbFailureOnce('session/check-timeout', $e); + + return $this->response + ->setStatusCode(503) + ->setJSON([ + 'status' => 'error', + 'message' => 'Session check temporarily unavailable.', + ]); } - - return $this->response->setJSON([ - 'status' => 'active', - 'time_remaining' => SessionTimeout::TIMEOUT_DURATION - $elapsed - ]); } public function pingActivity() { - $session = session(); - - // Only update if session is still valid - if (!$session->has('last_activity') || - (time() - $session->get('last_activity') >= SessionTimeout::TIMEOUT_DURATION)) { - return $this->expireSession(); + try { + $session = session(); + + if (! $session->has('last_activity') + || (time() - (int) $session->get('last_activity') >= SessionTimeout::TIMEOUT_DURATION) + ) { + return $this->expireSession(); + } + + $session->set('last_activity', time()); + + return $this->response->setJSON([ + 'status' => 'active', + 'time_remaining' => SessionTimeout::TIMEOUT_DURATION, + ]); + } catch (Throwable $e) { + $this->logDbFailureOnce('session/ping-activity', $e); + + return $this->response + ->setStatusCode(503) + ->setJSON([ + 'status' => 'error', + 'message' => 'Activity ping temporarily unavailable.', + ]); } - - $session->set('last_activity', time()); - return $this->response->setJSON([ - 'status' => 'active', - 'time_remaining' => SessionTimeout::TIMEOUT_DURATION - ]); } private function expireSession() { $this->destroySession(); + return $this->response->setJSON([ 'status' => 'expired', 'redirect' => site_url('login'), - 'message' => 'Your session has expired due to inactivity.' + 'message' => 'Your session has expired due to inactivity.', ]); } - private function destroySession() + private function destroySession(): void { - $session = session(); - $session->setFlashdata('error', 'Your session has expired due to inactivity.'); - - // Clear session data - $session->remove('last_activity'); - $session->destroy(); - - if (session_status() === PHP_SESSION_ACTIVE) { - session_regenerate_id(true); + try { + $session = session(); + $session->setFlashdata('error', 'Your session has expired due to inactivity.'); + $session->remove('last_activity'); + $session->destroy(); + + if (session_status() === PHP_SESSION_ACTIVE) { + session_regenerate_id(true); + } + } catch (Throwable $e) { + $this->logDbFailureOnce('session/destroy', $e); } } + + private function logDbFailureOnce(string $endpoint, Throwable $e): void + { + static $logged = []; + + $key = $endpoint . '|' . get_class($e); + if (isset($logged[$key])) { + return; + } + + $logged[$key] = true; + log_message('error', 'Session endpoint failure on {endpoint}: {message}', [ + 'endpoint' => $endpoint, + 'message' => $e->getMessage(), + ]); + } } diff --git a/app/Database/Migrations/2026-08-20-010000_CreateSettingsAndUserPreferencesTables.php b/app/Database/Migrations/2026-08-20-010000_CreateSettingsAndUserPreferencesTables.php new file mode 100644 index 0000000..7e0e98c --- /dev/null +++ b/app/Database/Migrations/2026-08-20-010000_CreateSettingsAndUserPreferencesTables.php @@ -0,0 +1,188 @@ +db->tableExists('settings')) { + $this->forge->addField([ + 'id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'auto_increment' => true, + ], + 'name' => [ + 'type' => 'VARCHAR', + 'constraint' => 255, + ], + 'timezone' => [ + 'type' => 'VARCHAR', + 'constraint' => 255, + 'default' => 'America/New_York', + ], + 'updated_by' => [ + 'type' => 'INT', + 'constraint' => 11, + 'null' => true, + ], + 'created_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'updated_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + ]); + $this->forge->addKey('id', true); + $this->forge->createTable('settings', true); + + $this->db->table('settings')->insert([ + 'name' => 'default', + 'timezone' => 'America/New_York', + 'created_at' => date('Y-m-d H:i:s'), + 'updated_at' => date('Y-m-d H:i:s'), + ]); + } elseif (! $this->db->fieldExists('timezone', 'settings')) { + $this->forge->addColumn('settings', [ + 'timezone' => [ + 'type' => 'VARCHAR', + 'constraint' => 255, + 'null' => true, + 'default' => 'America/New_York', + 'after' => 'name', + ], + ]); + } + + if (! $this->db->tableExists('user_preferences')) { + $this->forge->addField([ + 'id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'auto_increment' => true, + ], + 'user_id' => [ + 'type' => 'INT', + 'constraint' => 11, + ], + 'notification_email' => [ + 'type' => 'TINYINT', + 'constraint' => 1, + 'default' => 1, + ], + 'notification_sms' => [ + 'type' => 'TINYINT', + 'constraint' => 1, + 'default' => 1, + ], + 'theme' => [ + 'type' => 'VARCHAR', + 'constraint' => 50, + 'default' => 'light', + ], + 'language' => [ + 'type' => 'VARCHAR', + 'constraint' => 50, + 'default' => 'en', + ], + 'timezone' => [ + 'type' => 'VARCHAR', + 'constraint' => 64, + 'null' => true, + ], + 'style_color' => [ + 'type' => 'VARCHAR', + 'constraint' => 32, + 'null' => true, + ], + 'menu_color' => [ + 'type' => 'VARCHAR', + 'constraint' => 32, + 'null' => true, + ], + 'menu_custom_bg' => [ + 'type' => 'VARCHAR', + 'constraint' => 32, + 'null' => true, + ], + 'menu_custom_text' => [ + 'type' => 'VARCHAR', + 'constraint' => 32, + 'null' => true, + ], + 'menu_custom_mode' => [ + 'type' => 'VARCHAR', + 'constraint' => 16, + 'null' => true, + ], + 'created_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'updated_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + ]); + $this->forge->addKey('id', true); + $this->forge->addKey('user_id'); + $this->forge->createTable('user_preferences', true); + } else { + $optionalColumns = [ + 'timezone' => [ + 'type' => 'VARCHAR', + 'constraint' => 64, + 'null' => true, + ], + 'style_color' => [ + 'type' => 'VARCHAR', + 'constraint' => 32, + 'null' => true, + ], + 'menu_color' => [ + 'type' => 'VARCHAR', + 'constraint' => 32, + 'null' => true, + ], + 'menu_custom_bg' => [ + 'type' => 'VARCHAR', + 'constraint' => 32, + 'null' => true, + ], + 'menu_custom_text' => [ + 'type' => 'VARCHAR', + 'constraint' => 32, + 'null' => true, + ], + 'menu_custom_mode' => [ + 'type' => 'VARCHAR', + 'constraint' => 16, + 'null' => true, + ], + ]; + + foreach ($optionalColumns as $column => $definition) { + if (! $this->db->fieldExists($column, 'user_preferences')) { + $this->forge->addColumn('user_preferences', [$column => $definition]); + } + } + } + } + + public function down() + { + // Do not drop tables that may contain production preference data. + } +} diff --git a/app/Filters/SchoolYearWritableFilter.php b/app/Filters/SchoolYearWritableFilter.php index 66e521f..5932822 100644 --- a/app/Filters/SchoolYearWritableFilter.php +++ b/app/Filters/SchoolYearWritableFilter.php @@ -32,6 +32,11 @@ final class SchoolYearWritableFilter implements FilterInterface 'user/processResetPassword', 'user/save_password', 'set_authorized_user_password', + // Session heartbeat must not resolve/validate school-year context. + 'session/ping-activity', + 'session/ping', + 'session/check-timeout', + 'session/get-timeout-config', ]; public function before(RequestInterface $request, $arguments = null) diff --git a/app/Models/SettingsModel.php b/app/Models/SettingsModel.php index 5074e28..3e012b7 100644 --- a/app/Models/SettingsModel.php +++ b/app/Models/SettingsModel.php @@ -22,7 +22,9 @@ class SettingsModel extends Model public function getSettings() { - return $this->findAll()[0]; // Assuming there's only one settings record + $rows = $this->orderBy('id', 'ASC')->findAll(1); + + return $rows[0] ?? []; } public function updateSettings($data) diff --git a/app/Services/EnrollmentTransitionService.php b/app/Services/EnrollmentTransitionService.php index 874561d..ae0047f 100644 --- a/app/Services/EnrollmentTransitionService.php +++ b/app/Services/EnrollmentTransitionService.php @@ -173,6 +173,7 @@ final class EnrollmentTransitionService if ($decisionRow === null || $decision === null) { $result['blockers'][] = EnrollmentEligibility::MISSING_DECISION_MESSAGE; $result['flags'][] = $this->flag('DEFERRED_DELIBERATION', 'high', [ + 'rule_code' => $decisionRow === null ? 'NO_FINAL_DECISION' : 'UNRECOGNIZED_DECISION', 'reason' => 'Missing or unrecognized final deliberation decision.', ]); return $result; @@ -180,19 +181,19 @@ final class EnrollmentTransitionService if ($decision === DeliberationDecision::EXPELLED) { $result['blockers'][] = EnrollmentEligibility::EXPELLED_MESSAGE; - $result['flags'][] = $this->flag('RESTRICTED_ADMINISTRATIVE_REVIEW', 'high'); + $result['flags'][] = $this->flag('RESTRICTED_ADMINISTRATIVE_REVIEW', 'high', ['rule_code' => 'EXPELLED']); return $result; } if ($decision === DeliberationDecision::WITHDRAWN) { $result['blockers'][] = EnrollmentEligibility::WITHDRAWN_MESSAGE; - $result['flags'][] = $this->flag('WITHDRAWAL_REVIEW_REQUIRED', 'normal'); + $result['flags'][] = $this->flag('WITHDRAWAL_REVIEW_REQUIRED', 'normal', ['rule_code' => 'WITHDRAWN']); return $result; } if ($decision === DeliberationDecision::DEFERRED_DECISION) { $result['blockers'][] = EnrollmentEligibility::DEFERRED_MESSAGE; - $result['flags'][] = $this->flag('DEFERRED_DELIBERATION', 'high'); + $result['flags'][] = $this->flag('DEFERRED_DELIBERATION', 'high', ['rule_code' => 'DEFERRED_DECISION']); return $result; } @@ -705,6 +706,7 @@ final class EnrollmentTransitionService $decisions = $this->latestDecisionsByStudent($sourceSchoolYear); $enrolledIds = $this->activeTargetEnrollmentStudentIds($targetSchoolYear); + $targetReviewCodes = $this->targetEnrollmentReviewCodesByStudent($targetSchoolYear); $bypassCodesByStudent = $this->activeBypassCodesByStudent($targetSchoolYear); $written = 0; @@ -720,20 +722,34 @@ final class EnrollmentTransitionService $alreadyEnrolled = isset($enrolledIds[$studentId]); $flags = []; - if ($decisionRow === null || $decision === null) { + if (! $alreadyEnrolled && isset($targetReviewCodes[$studentId])) { + $review = $targetReviewCodes[$studentId]; + $code = (string) ($review['rule_code'] ?? 'WITHDRAWN'); + $flags[] = $this->flag( + $code === 'DENIED' ? 'RESTRICTED_ADMINISTRATIVE_REVIEW' : 'WITHDRAWAL_REVIEW_REQUIRED', + $code === 'DENIED' ? 'high' : 'normal', + [ + 'rule_code' => $code, + 'enrollment_status' => $review['enrollment_status'] ?? null, + 'is_withdrawn' => $review['is_withdrawn'] ?? null, + ] + ); + } elseif ($decisionRow === null || $decision === null) { if (! $alreadyEnrolled) { $flags[] = $this->flag('DEFERRED_DELIBERATION', 'high', [ + 'rule_code' => $decisionRow === null ? 'NO_FINAL_DECISION' : 'UNRECOGNIZED_DECISION', 'reason' => 'Missing or unrecognized final deliberation decision.', ]); } } elseif ($decision === DeliberationDecision::EXPELLED && ! $alreadyEnrolled) { - $flags[] = $this->flag('RESTRICTED_ADMINISTRATIVE_REVIEW', 'high'); + $flags[] = $this->flag('RESTRICTED_ADMINISTRATIVE_REVIEW', 'high', ['rule_code' => 'EXPELLED']); } elseif ($decision === DeliberationDecision::WITHDRAWN && ! $alreadyEnrolled) { - $flags[] = $this->flag('WITHDRAWAL_REVIEW_REQUIRED', 'normal'); + $flags[] = $this->flag('WITHDRAWAL_REVIEW_REQUIRED', 'normal', ['rule_code' => 'WITHDRAWN']); } elseif ($decision === DeliberationDecision::DEFERRED_DECISION && ! $alreadyEnrolled) { - $flags[] = $this->flag('DEFERRED_DELIBERATION', 'high'); + $flags[] = $this->flag('DEFERRED_DELIBERATION', 'high', ['rule_code' => 'DEFERRED_DECISION']); } elseif ($decision === DeliberationDecision::MAKE_UP_EXAM) { $flags[] = $this->flag('PENDING_MAKE_UP_EXAM_PROMOTION', 'high', [ + 'rule_code' => 'MAKE_UP_EXAM', 'current_class_section_name' => $student['class_section_name'] ?? ($decisionRow['class_section_name'] ?? null), ]); } @@ -990,6 +1006,47 @@ final class EnrollmentTransitionService return $ids; } + /** + * @return array + */ + private function targetEnrollmentReviewCodesByStudent(string $targetSchoolYear): array + { + if (! $this->db->tableExists('enrollments')) { + return []; + } + + $select = ['student_id', 'enrollment_status', 'admission_status']; + if ($this->db->fieldExists('is_withdrawn', 'enrollments')) { + $select[] = 'is_withdrawn'; + } + + $rows = $this->db->table('enrollments') + ->select(implode(', ', $select)) + ->where('school_year', $targetSchoolYear) + ->orderBy('updated_at', 'DESC') + ->orderBy('id', 'DESC') + ->get() + ->getResultArray(); + + $reviewCodes = []; + foreach ($rows as $row) { + $studentId = (int) ($row['student_id'] ?? 0); + if ($studentId <= 0 || isset($reviewCodes[$studentId]) || ! $this->deniedOrWithdrawnEnrollmentBlocksStandardEligibility($row)) { + continue; + } + + $status = strtolower(trim((string) ($row['enrollment_status'] ?? ''))); + $admission = strtolower(trim((string) ($row['admission_status'] ?? ''))); + $reviewCodes[$studentId] = [ + 'rule_code' => $admission === 'denied' || $status === 'denied' ? 'DENIED' : 'WITHDRAWN', + 'enrollment_status' => (string) ($row['enrollment_status'] ?? ''), + 'is_withdrawn' => (int) ($row['is_withdrawn'] ?? 0), + ]; + } + + return $reviewCodes; + } + private function syncSiblingLastNameFlags( array $students, array $enrolledIds, diff --git a/app/Services/TimeService.php b/app/Services/TimeService.php index 15cd02c..71e4559 100644 --- a/app/Services/TimeService.php +++ b/app/Services/TimeService.php @@ -15,6 +15,11 @@ class TimeService // Cached per-request user timezone private ?string $cachedUserTz = null; + /** @var array|null */ + private ?array $cachedSettings = null; + + private bool $settingsLookupAttempted = false; + /** * Prime detection cache from the Request (optional call, lazy by default). */ @@ -76,13 +81,13 @@ class TimeService // 3) Global settings timezone (if available) try { - $settings = (new SettingsModel())->getSettings(); + $settings = $this->cachedApplicationSettings(); $tz = $settings['timezone'] ?? null; if ($tz && in_array($tz, timezone_identifiers_list(), true)) { return $tz; } } catch (\Throwable $e) { - // ignore + // ignore — never fail the request for timezone lookup } // 4) School attendance timezone (if defined) @@ -219,4 +224,25 @@ class TimeService $value = trim($value); return (bool) preg_match('/^\d{4}-\d{2}-\d{2}$/', $value); } + + /** + * @return array + */ + private function cachedApplicationSettings(): array + { + if ($this->settingsLookupAttempted) { + return $this->cachedSettings ?? []; + } + + $this->settingsLookupAttempted = true; + + try { + $settings = (new SettingsModel())->getSettings(); + $this->cachedSettings = is_array($settings) ? $settings : []; + } catch (\Throwable $e) { + $this->cachedSettings = []; + } + + return $this->cachedSettings; + } } diff --git a/app/Views/administrator/enrollment_admin_dashboard.php b/app/Views/administrator/enrollment_admin_dashboard.php index 04f7b1c..80f33c9 100644 --- a/app/Views/administrator/enrollment_admin_dashboard.php +++ b/app/Views/administrator/enrollment_admin_dashboard.php @@ -44,6 +44,10 @@ .enrollment-admin .email-check .email-check-body { flex: 1 1 auto; } + .enrollment-admin .issue-badge { + white-space: normal; + text-align: left; + } endSection() ?> @@ -59,16 +63,61 @@ if (!function_exists('enrollment_admin_flag_label')) { 'LATE_REGISTRATION_EXCEPTION' => 'Late registration', 'FINANCIAL_REVIEW_REQUIRED' => 'Finance review', 'CLASS_CAPACITY_EXCEPTION_REQUIRED' => 'Class capacity', - 'DEFERRED_DELIBERATION' => 'Missing decision', + 'DEFERRED_DELIBERATION' => 'Decision review', 'RESTRICTED_ADMINISTRATIVE_REVIEW' => 'Restricted review', 'WITHDRAWAL_REVIEW_REQUIRED' => 'Withdrawal review', 'COMPLETION_OR_EXIT_PROCESS_REQUIRED' => 'Exit / completion', 'ADULT_STUDENT_ACTION_REQUIRED' => 'Adult student', 'SIBLING_LAST_NAME_MISMATCH' => 'Sibling last names', + 'NO_FINAL_DECISION' => 'No final decision', + 'UNRECOGNIZED_DECISION' => 'Unrecognized decision', + 'DEFERRED_DECISION' => 'Deferred decision', + 'EXPELLED' => 'Expelled', + 'WITHDRAWN' => 'Withdrawn', + 'DENIED' => 'Denied', + 'MAKE_UP_EXAM' => 'Makeup exam', default => $type !== '' ? ucwords(strtolower(str_replace('_', ' ', $type))) : 'Unknown', }; } } +if (!function_exists('enrollment_admin_issue_code')) { + function enrollment_admin_issue_code(array $flag): string + { + $type = strtoupper(trim((string) ($flag['flag_type'] ?? ''))); + $details = is_array($flag['details'] ?? null) ? $flag['details'] : []; + $ruleCode = strtoupper(trim((string) ($details['rule_code'] ?? ''))); + + if ($ruleCode !== '') { + return $ruleCode; + } + + if ($type === 'DEFERRED_DELIBERATION') { + $reason = strtolower((string) ($details['reason'] ?? '')); + if (str_contains($reason, 'unrecognized')) { + return 'UNRECOGNIZED_DECISION'; + } + if (str_contains($reason, 'missing') || str_contains($reason, 'no final')) { + return 'NO_FINAL_DECISION'; + } + } + + return $type; + } +} +if (!function_exists('enrollment_admin_issue_badge_class')) { + function enrollment_admin_issue_badge_class(string $code): string + { + return match (strtoupper($code)) { + 'NO_FINAL_DECISION', 'UNRECOGNIZED_DECISION', 'EXPELLED', 'DENIED', 'RESTRICTED_ADMINISTRATIVE_REVIEW' => 'bg-danger', + 'DEFERRED_DECISION', 'DEFERRED_DELIBERATION', 'FINANCIAL_REVIEW_REQUIRED', 'OUTSTANDING_BALANCE_BLOCKED', 'FINANCE_APPROVAL_REQUIRED' => 'bg-warning text-dark', + 'PENDING_MAKE_UP_EXAM_PROMOTION', 'MAKE_UP_EXAM', 'AGE_EXCEPTION_REQUIRED', 'ADULT_STUDENT_ACTION_REQUIRED' => 'bg-info text-dark', + 'CLASS_REASSIGNMENT_REQUIRED', 'CLASS_CAPACITY_EXCEPTION_REQUIRED' => 'bg-primary', + 'WITHDRAWN', 'WITHDRAWAL_REVIEW_REQUIRED', 'COMPLETION_OR_EXIT_PROCESS_REQUIRED' => 'bg-dark', + 'SIBLING_LAST_NAME_MISMATCH', 'LATE_REGISTRATION_EXCEPTION' => 'bg-secondary', + default => 'bg-secondary', + }; + } +} if (!function_exists('enrollment_admin_placement_label')) { function enrollment_admin_placement_label(string $status): string { @@ -128,16 +177,6 @@ if (!function_exists('enrollment_admin_decision_label')) { }; } } -if (!function_exists('enrollment_admin_priority_label')) { - function enrollment_admin_priority_label(string $priority): string - { - return match (strtolower($priority)) { - 'high' => 'High', - 'low' => 'Low', - default => 'Normal', - }; - } -} if (!function_exists('enrollment_admin_exception_status_label')) { function enrollment_admin_exception_status_label(string $status): string { @@ -308,7 +347,6 @@ $reasonCodes = is_array($exceptionReasonCodes ?? null) ? $exceptionReasonCodes : Student School ID Issue - Priority Details Assigned Created @@ -322,8 +360,8 @@ $reasonCodes = is_array($exceptionReasonCodes ?? null) ? $exceptionReasonCodes : $flagId = (int) ($flag['id'] ?? 0); $flagTypeValue = (string) ($flag['flag_type'] ?? ''); $details = is_array($flag['details'] ?? null) ? $flag['details'] : []; - $priorityValue = (string) ($flag['priority'] ?? 'normal'); - $priorityClass = strtolower($priorityValue) === 'high' ? 'bg-danger' : 'bg-secondary'; + $issueCode = enrollment_admin_issue_code($flag); + $issueClass = enrollment_admin_issue_badge_class($issueCode); ?> @@ -331,8 +369,7 @@ $reasonCodes = is_array($exceptionReasonCodes ?? null) ? $exceptionReasonCodes : - - + $value): ?> diff --git a/docs/production_incident_remediation.md b/docs/production_incident_remediation.md new file mode 100644 index 0000000..2db2e76 --- /dev/null +++ b/docs/production_incident_remediation.md @@ -0,0 +1,104 @@ +# Production Incident Remediation — Ops Follow-ups + +This document covers remediation items that cannot be completed from application code alone. +Deploy the heartbeat / filter / rate-limit code changes first, then work through this list. + +## Deployment wave 1 (application) + +1. Back up production database and current release. +2. Deploy: + - throttled `/session/ping-activity` client (`public/assets/js/session_timeout.js`) + - filter exclusions for timezone + school-year writable on session endpoints + - lightweight `SessionTimeoutController` + - route rate limit `apiratelimit:5,60` on ping endpoints +3. Monitor for 30–60 minutes: + - ping-activity requests/minute + - HTTP 429 / 500 / 503 rates + - MySQL connection errors + +## 3. MySQL `Operation not permitted` + +Do **not** assume a bad password. Check Hostinger / server logs for the incident window +`04:12:03`–`06:21:08` and capture: + +```sql +SHOW VARIABLES LIKE 'max_connections'; +SHOW STATUS LIKE 'Threads_connected'; +SHOW STATUS LIKE 'Max_used_connections'; +SHOW STATUS LIKE 'Aborted_connects'; +``` + +Also inspect: + +- PHP-FPM / LiteSpeed worker limits +- Hostinger resource throttling / inodes / CPU +- firewall / socket restrictions +- temporary MySQL outages + +Document the confirmed root cause here once known: + +- Cause: +- Evidence: +- Fix applied: + +## 5. Missing tables / migrations + +After backup: + +```bash +php spark migrate:status +php spark migrate +``` + +Confirm: + +- `settings` exists +- `user_preferences` exists +- no new `table doesn't exist` errors + +Migration `2026-08-20-010000_CreateSettingsAndUserPreferencesTables` creates both tables if missing. + +## 7. SMTP authentication + +Credentials must live in environment configuration (`mail.SMTP*` / `SMTP_*`), not in +`app/Config/Email.php`. + +Verify production env: + +- SMTP host / port / encryption +- username + app password +- from address / provider restrictions + +If auth recently failed, rotate the Gmail app password and update production env only. +Send one controlled registration or student-removal email from production and confirm success. + +## 10. Monitoring / alerts + +Track at least: + +- HTTP RPS +- `/session/ping-activity` requests/minute +- HTTP 500 and 429 rates +- DB connection failures +- active MySQL connections +- PHP worker usage +- SMTP failures + +Suggested initial alerts: + +- DB connection errors > 5/minute +- 500 responses > 1% of requests +- ping-activity above expected session-based threshold +- MySQL connections > 80% of `max_connections` + +## Verification checklist + +- [ ] No overlapping ping requests from one tab +- [ ] Failed pings use exponential backoff +- [ ] Heartbeat skips timezone / school-year / settings queries +- [ ] Server-side ping rate limiting returns 429 when exceeded +- [ ] MySQL `Operation not permitted` cause identified +- [ ] `settings` + `user_preferences` exist; migrations current +- [ ] SMTP auth succeeds +- [ ] Zero-value invoices return controlled 422 (no null `setJSON`) +- [ ] Monitoring / alerts enabled diff --git a/public/assets/js/session_timeout.js b/public/assets/js/session_timeout.js index b359cd4..70af3d1 100644 --- a/public/assets/js/session_timeout.js +++ b/public/assets/js/session_timeout.js @@ -1,9 +1,10 @@ class SessionTimeoutManager { constructor() { this.config = { - timeout: 1800, // default 30 min - warning_time: 30, // final 30 seconds before timeout - check_interval: 5000, // 5 seconds + timeout: 1800, + warning_time: 30, + check_interval: 30000, + ping_interval: 60000, logout_url: '/logout', keep_alive_url: '/session/ping-activity', check_url: '/session/check-timeout' @@ -11,10 +12,19 @@ class SessionTimeoutManager { this.timers = { checkTimer: null, logoutTimer: null, - warningTimer: null + warningTimer: null, + pingTimer: null }; this.modal = null; this.warningShown = false; + this.pingInProgress = false; + this.sessionExpired = false; + this.loggedOut = false; + this.consecutiveFailures = 0; + this.nextPingAllowedAt = 0; + this.lastSuccessfulPingAt = 0; + this.activityPending = false; + this.backoffMs = [120000, 300000, 600000, 900000]; } async init() { @@ -40,11 +50,11 @@ class SessionTimeoutManager { }, credentials: 'same-origin' }); - + if (!response.ok) { throw new Error(`HTTP ${response.status}`); } - + const data = await response.json(); if (data.success) { this.config = { ...this.config, ...data }; @@ -54,43 +64,150 @@ class SessionTimeoutManager { } } + shouldPausePings() { + return document.hidden || this.sessionExpired || this.loggedOut; + } + + currentBackoffMs() { + if (this.consecutiveFailures <= 0) { + return this.config.ping_interval || 60000; + } + + const index = Math.min(this.consecutiveFailures - 1, this.backoffMs.length - 1); + return this.backoffMs[index]; + } + setupEventListeners() { - // Reset timers on user activity const events = ['mousedown', 'keydown', 'scroll', 'touchstart', 'click', 'input']; - events.forEach(event => { - document.addEventListener(event, () => this.resetActivity(), { passive: true }); + events.forEach((event) => { + document.addEventListener(event, () => this.onUserActivity(), { passive: true }); }); - // Handle visibility change document.addEventListener('visibilitychange', () => { - if (!document.hidden) { + if (document.hidden) { + return; + } + + if (!this.shouldPausePings()) { this.checkSessionStatus(); } }); } + onUserActivity() { + if (this.shouldPausePings()) { + return; + } + + this.activityPending = true; + this.schedulePing(); + } + + schedulePing() { + if (this.shouldPausePings() || this.pingInProgress) { + return; + } + + const now = Date.now(); + const waitMs = Math.max(0, this.nextPingAllowedAt - now); + + clearTimeout(this.timers.pingTimer); + this.timers.pingTimer = setTimeout(() => { + this.resetActivity(); + }, waitMs); + } + async resetActivity() { + if (this.pingInProgress || this.shouldPausePings()) { + return; + } + + const now = Date.now(); + if (now < this.nextPingAllowedAt) { + this.schedulePing(); + return; + } + + if ( + this.consecutiveFailures === 0 + && this.lastSuccessfulPingAt > 0 + && (now - this.lastSuccessfulPingAt) < (this.config.ping_interval || 60000) + && !this.activityPending + ) { + return; + } + + this.pingInProgress = true; + try { - await fetch(this.config.keep_alive_url, { + const response = await fetch(this.config.keep_alive_url, { method: 'POST', headers: { 'X-Requested-With': 'XMLHttpRequest' }, credentials: 'same-origin' }); + + if (response.status === 401 || response.status === 403) { + this.sessionExpired = true; + this.handleSessionExpired({ + redirect: this.config.logout_url, + message: 'Your session has expired. Please log in again.' + }); + return; + } + + if (response.status === 429) { + this.consecutiveFailures += 1; + this.nextPingAllowedAt = Date.now() + this.currentBackoffMs(); + return; + } + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + let data = null; + try { + data = await response.json(); + } catch (parseError) { + data = { status: 'active' }; + } + + if (data && data.status === 'expired') { + this.handleSessionExpired(data); + return; + } + + this.consecutiveFailures = 0; + this.activityPending = false; + this.lastSuccessfulPingAt = Date.now(); + this.nextPingAllowedAt = this.lastSuccessfulPingAt + (this.config.ping_interval || 60000); this.clearWarning(); } catch (error) { - console.warn('Activity reset failed:', error); + this.consecutiveFailures += 1; + this.nextPingAllowedAt = Date.now() + this.currentBackoffMs(); + console.warn('Activity reset failed; backing off:', error); + } finally { + this.pingInProgress = false; } } startPeriodicChecks() { + clearInterval(this.timers.checkTimer); this.timers.checkTimer = setInterval(() => { + if (this.shouldPausePings()) { + return; + } this.checkSessionStatus(); }, this.config.check_interval); } async checkSessionStatus() { + if (this.shouldPausePings()) { + return; + } + try { const response = await fetch(this.config.check_url, { method: 'GET', @@ -100,12 +217,26 @@ class SessionTimeoutManager { credentials: 'same-origin' }); + if (response.status === 404) { + console.log('Session check endpoint not available, skipping'); + return; + } + + if (response.status === 401 || response.status === 403) { + this.sessionExpired = true; + this.handleSessionExpired({ + redirect: this.config.logout_url, + message: 'Your session has expired. Please log in again.' + }); + return; + } + if (!response.ok) { throw new Error(`HTTP ${response.status}`); } const data = await response.json(); - + switch (data.status) { case 'expired': this.handleSessionExpired(data); @@ -123,11 +254,12 @@ class SessionTimeoutManager { } handleSessionExpired(data) { + this.sessionExpired = true; this.clearWarning(); clearInterval(this.timers.checkTimer); - + clearTimeout(this.timers.pingTimer); + if (data.redirect) { - // Show message before redirect alert(data.message || 'Your session has expired. Please log in again.'); window.location.href = data.redirect; } else { @@ -194,7 +326,7 @@ class SessionTimeoutManager { createWarningModal(timeRemaining) { this.hideWarning(); - + this.modal = document.createElement('div'); this.modal.className = 'session-timeout-modal'; this.modal.style.cssText = ` @@ -210,7 +342,7 @@ class SessionTimeoutManager { align-items: center; font-family: Arial, sans-serif; `; - + this.modal.innerHTML = `

Session About to Expire

@@ -221,20 +353,20 @@ class SessionTimeoutManager { Click Continue Session if you want to keep using this session.

- -
`; - + document.body.appendChild(this.modal); document.body.style.overflow = 'hidden'; } @@ -257,11 +389,16 @@ class SessionTimeoutManager { } continueSession() { + this.activityPending = true; + this.nextPingAllowedAt = 0; this.resetActivity(); this.hideWarning(); } logout() { + this.loggedOut = true; + clearInterval(this.timers.checkTimer); + clearTimeout(this.timers.pingTimer); window.location.href = this.config.logout_url; } @@ -269,19 +406,17 @@ class SessionTimeoutManager { clearInterval(this.timers.checkTimer); clearTimeout(this.timers.logoutTimer); clearInterval(this.timers.warningTimer); + clearTimeout(this.timers.pingTimer); this.hideWarning(); } } -// Initialize globally const sessionTimeout = new SessionTimeoutManager(); -// Start when DOM is ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => sessionTimeout.init()); } else { sessionTimeout.init(); } -// Make available globally window.sessionTimeout = sessionTimeout; diff --git a/public/js/session_timeout.js b/public/js/session_timeout.js index 87479a0..70af3d1 100644 --- a/public/js/session_timeout.js +++ b/public/js/session_timeout.js @@ -1,9 +1,10 @@ class SessionTimeoutManager { constructor() { this.config = { - timeout: 1800, // 30 minutes - warning_time: 30, // final 30 seconds before timeout - check_interval: 5000, // 5 seconds + timeout: 1800, + warning_time: 30, + check_interval: 30000, + ping_interval: 60000, logout_url: '/logout', keep_alive_url: '/session/ping-activity', check_url: '/session/check-timeout' @@ -11,100 +12,231 @@ class SessionTimeoutManager { this.timers = { checkTimer: null, logoutTimer: null, - warningTimer: null + warningTimer: null, + pingTimer: null }; this.modal = null; this.warningShown = false; - this.csrfToken = this.getCsrfToken(); - } - - getCsrfToken() { - // Try to get CSRF token from meta tag - const metaTag = document.querySelector('meta[name="csrf-token"]'); - if (metaTag) { - return metaTag.getAttribute('content'); - } - - // Try to get CSRF token from form input (fallback) - const csrfInput = typeof csrf_token !== 'undefined' - ? document.querySelector('input[name="' + csrf_token + '"]') - : null; - if (csrfInput) { - return csrfInput.value; - } - - console.warn('CSRF token not found'); - return ''; + this.pingInProgress = false; + this.sessionExpired = false; + this.loggedOut = false; + this.consecutiveFailures = 0; + this.nextPingAllowedAt = 0; + this.lastSuccessfulPingAt = 0; + this.activityPending = false; + this.backoffMs = [120000, 300000, 600000, 900000]; } async init() { - this.setupEventListeners(); - this.startPeriodicChecks(); - console.log('Session timeout manager initialized with config:', this.config); + try { + await this.fetchConfig(); + this.setupEventListeners(); + this.startPeriodicChecks(); + console.log('Session timeout manager initialized with config:', this.config); + } catch (error) { + console.warn('Session timeout using default config due to error:', error.message); + this.setupEventListeners(); + this.startPeriodicChecks(); + } } - // REMOVE the fetchConfig() method entirely + async fetchConfig() { + try { + const response = await fetch('/session/get-timeout-config', { + method: 'GET', + headers: { + 'Accept': 'application/json', + 'X-Requested-With': 'XMLHttpRequest' + }, + credentials: 'same-origin' + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + const data = await response.json(); + if (data.success) { + this.config = { ...this.config, ...data }; + } + } catch (error) { + console.warn('Failed to load timeout config, using defaults:', error.message); + } + } + + shouldPausePings() { + return document.hidden || this.sessionExpired || this.loggedOut; + } + + currentBackoffMs() { + if (this.consecutiveFailures <= 0) { + return this.config.ping_interval || 60000; + } + + const index = Math.min(this.consecutiveFailures - 1, this.backoffMs.length - 1); + return this.backoffMs[index]; + } setupEventListeners() { - // Reset timers on user activity const events = ['mousedown', 'keydown', 'scroll', 'touchstart', 'click', 'input']; - events.forEach(event => { - document.addEventListener(event, () => this.resetActivity(), { passive: true }); + events.forEach((event) => { + document.addEventListener(event, () => this.onUserActivity(), { passive: true }); }); - // Handle visibility change document.addEventListener('visibilitychange', () => { - if (!document.hidden) { + if (document.hidden) { + return; + } + + if (!this.shouldPausePings()) { this.checkSessionStatus(); } }); } + onUserActivity() { + if (this.shouldPausePings()) { + return; + } + + this.activityPending = true; + this.schedulePing(); + } + + schedulePing() { + if (this.shouldPausePings() || this.pingInProgress) { + return; + } + + const now = Date.now(); + const waitMs = Math.max(0, this.nextPingAllowedAt - now); + + clearTimeout(this.timers.pingTimer); + this.timers.pingTimer = setTimeout(() => { + this.resetActivity(); + }, waitMs); + } + async resetActivity() { + if (this.pingInProgress || this.shouldPausePings()) { + return; + } + + const now = Date.now(); + if (now < this.nextPingAllowedAt) { + this.schedulePing(); + return; + } + + if ( + this.consecutiveFailures === 0 + && this.lastSuccessfulPingAt > 0 + && (now - this.lastSuccessfulPingAt) < (this.config.ping_interval || 60000) + && !this.activityPending + ) { + return; + } + + this.pingInProgress = true; + try { - await fetch(this.config.keep_alive_url, { + const response = await fetch(this.config.keep_alive_url, { method: 'POST', headers: { - 'X-Requested-With': 'XMLHttpRequest', - 'X-CSRF-TOKEN': this.csrfToken + 'X-Requested-With': 'XMLHttpRequest' }, credentials: 'same-origin' }); + + if (response.status === 401 || response.status === 403) { + this.sessionExpired = true; + this.handleSessionExpired({ + redirect: this.config.logout_url, + message: 'Your session has expired. Please log in again.' + }); + return; + } + + if (response.status === 429) { + this.consecutiveFailures += 1; + this.nextPingAllowedAt = Date.now() + this.currentBackoffMs(); + return; + } + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + let data = null; + try { + data = await response.json(); + } catch (parseError) { + data = { status: 'active' }; + } + + if (data && data.status === 'expired') { + this.handleSessionExpired(data); + return; + } + + this.consecutiveFailures = 0; + this.activityPending = false; + this.lastSuccessfulPingAt = Date.now(); + this.nextPingAllowedAt = this.lastSuccessfulPingAt + (this.config.ping_interval || 60000); this.clearWarning(); } catch (error) { - console.warn('Activity reset failed:', error); + this.consecutiveFailures += 1; + this.nextPingAllowedAt = Date.now() + this.currentBackoffMs(); + console.warn('Activity reset failed; backing off:', error); + } finally { + this.pingInProgress = false; } } startPeriodicChecks() { + clearInterval(this.timers.checkTimer); this.timers.checkTimer = setInterval(() => { + if (this.shouldPausePings()) { + return; + } this.checkSessionStatus(); }, this.config.check_interval); } async checkSessionStatus() { + if (this.shouldPausePings()) { + return; + } + try { const response = await fetch(this.config.check_url, { method: 'GET', headers: { - 'X-Requested-With': 'XMLHttpRequest', - 'X-CSRF-TOKEN': this.csrfToken + 'X-Requested-With': 'XMLHttpRequest' }, credentials: 'same-origin' }); if (response.status === 404) { - // If check endpoint doesn't exist, skip checking console.log('Session check endpoint not available, skipping'); return; } + if (response.status === 401 || response.status === 403) { + this.sessionExpired = true; + this.handleSessionExpired({ + redirect: this.config.logout_url, + message: 'Your session has expired. Please log in again.' + }); + return; + } + if (!response.ok) { throw new Error(`HTTP ${response.status}`); } const data = await response.json(); - + switch (data.status) { case 'expired': this.handleSessionExpired(data); @@ -121,11 +253,12 @@ class SessionTimeoutManager { } } - // ... rest of the methods remain the same handleSessionExpired(data) { + this.sessionExpired = true; this.clearWarning(); clearInterval(this.timers.checkTimer); - + clearTimeout(this.timers.pingTimer); + if (data.redirect) { alert(data.message || 'Your session has expired. Please log in again.'); window.location.href = data.redirect; @@ -193,7 +326,7 @@ class SessionTimeoutManager { createWarningModal(timeRemaining) { this.hideWarning(); - + this.modal = document.createElement('div'); this.modal.className = 'session-timeout-modal'; this.modal.style.cssText = ` @@ -209,7 +342,7 @@ class SessionTimeoutManager { align-items: center; font-family: Arial, sans-serif; `; - + this.modal.innerHTML = `

Session About to Expire

@@ -220,20 +353,20 @@ class SessionTimeoutManager { Click Continue Session if you want to keep using this session.

- -
`; - + document.body.appendChild(this.modal); document.body.style.overflow = 'hidden'; } @@ -256,11 +389,16 @@ class SessionTimeoutManager { } continueSession() { + this.activityPending = true; + this.nextPingAllowedAt = 0; this.resetActivity(); this.hideWarning(); } logout() { + this.loggedOut = true; + clearInterval(this.timers.checkTimer); + clearTimeout(this.timers.pingTimer); window.location.href = this.config.logout_url; } @@ -268,19 +406,17 @@ class SessionTimeoutManager { clearInterval(this.timers.checkTimer); clearTimeout(this.timers.logoutTimer); clearInterval(this.timers.warningTimer); + clearTimeout(this.timers.pingTimer); this.hideWarning(); } } -// Initialize globally const sessionTimeout = new SessionTimeoutManager(); -// Start when DOM is ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => sessionTimeout.init()); } else { sessionTimeout.init(); } -// Make available globally window.sessionTimeout = sessionTimeout; diff --git a/tests/app/Controllers/Api/SessionTimeoutControllerTest.php b/tests/app/Controllers/Api/SessionTimeoutControllerTest.php index 436bcbb..d2a1d92 100644 --- a/tests/app/Controllers/Api/SessionTimeoutControllerTest.php +++ b/tests/app/Controllers/Api/SessionTimeoutControllerTest.php @@ -29,7 +29,8 @@ class SessionTimeoutControllerTest extends CIUnitTestCase $this->assertTrue($body['success']); $this->assertSame(1800, $body['timeout']); $this->assertSame(30, $body['warning_time']); - $this->assertSame(5000, $body['check_interval']); + $this->assertSame(30000, $body['check_interval']); + $this->assertSame(60000, $body['ping_interval']); $this->assertStringContainsString('session/check-timeout', $body['check_url']); $this->assertStringContainsString('session/ping-activity', $body['keep_alive_url']); } diff --git a/tests/app/Filters/SchoolYearWritableFilterTest.php b/tests/app/Filters/SchoolYearWritableFilterTest.php index 96cf5e4..09f044a 100644 --- a/tests/app/Filters/SchoolYearWritableFilterTest.php +++ b/tests/app/Filters/SchoolYearWritableFilterTest.php @@ -112,6 +112,15 @@ final class SchoolYearWritableFilterTest extends CIUnitTestCase $this->assertNull((new SchoolYearWritableFilter())->before($request)); } + public function testSessionPingActivityPostIsExempt(): void + { + $this->useSchoolYearContext(['id' => 1, 'name' => '2025-2026', 'status' => 'closed']); + + $request = $this->request('POST', 'https://example.test/session/ping-activity'); + + $this->assertNull((new SchoolYearWritableFilter())->before($request)); + } + public function testReadRequestsAreAllowedForClosedSelectedYear(): void { $this->useSchoolYearContext(['id' => 1, 'name' => '2025-2026', 'status' => 'closed']); diff --git a/tests/app/Services/EnrollmentTransitionServiceTest.php b/tests/app/Services/EnrollmentTransitionServiceTest.php index bfe4c8d..3c7a095 100644 --- a/tests/app/Services/EnrollmentTransitionServiceTest.php +++ b/tests/app/Services/EnrollmentTransitionServiceTest.php @@ -187,6 +187,35 @@ final class EnrollmentTransitionServiceTest extends TestCase ]])); } + public function testTargetEnrollmentReviewCodesIncludeWithdrawnRows(): void + { + $builder = $this->createMock(BaseBuilder::class); + $builder->method('select')->willReturnSelf(); + $builder->method('where')->willReturnSelf(); + $builder->method('orderBy')->willReturnSelf(); + $builder->method('get')->willReturn(new class { + public function getResultArray(): array + { + return [[ + 'student_id' => 42, + 'enrollment_status' => 'payment pending', + 'admission_status' => 'accepted', + 'is_withdrawn' => 1, + ]]; + } + }); + + $db = $this->createMock(BaseConnection::class); + $db->method('tableExists')->with('enrollments')->willReturn(true); + $db->method('fieldExists')->with('is_withdrawn', 'enrollments')->willReturn(true); + $db->method('table')->with('enrollments')->willReturn($builder); + + $service = new EnrollmentTransitionService($db); + $reviewCodes = $this->invoke($service, 'targetEnrollmentReviewCodesByStudent', ['2026-2027']); + + $this->assertSame('WITHDRAWN', $reviewCodes[42]['rule_code']); + } + /** * @param list> $rows */