fix deployment db issue and widthrawal administration page
This commit is contained in:
+25
-10
@@ -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";
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
+23
-3
@@ -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',
|
||||
|
||||
@@ -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']);
|
||||
|
||||
|
||||
/*
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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')) {
|
||||
|
||||
@@ -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<string, mixed> $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
|
||||
|
||||
@@ -1,15 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
use App\Controllers\BaseController;
|
||||
use Config\SessionTimeout;
|
||||
use CodeIgniter\Controller;
|
||||
use CodeIgniter\API\ResponseTrait;
|
||||
|
||||
class SessionTimeoutController extends BaseController
|
||||
use CodeIgniter\API\ResponseTrait;
|
||||
use CodeIgniter\Controller;
|
||||
use CodeIgniter\HTTP\RequestInterface;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
use Config\SessionTimeout;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Lightweight session heartbeat endpoints.
|
||||
* Intentionally does not extend BaseController to avoid school-year / API priming.
|
||||
*/
|
||||
class SessionTimeoutController extends Controller
|
||||
{
|
||||
use ResponseTrait;
|
||||
|
||||
public function initController(RequestInterface $request, ResponseInterface $response, LoggerInterface $logger)
|
||||
{
|
||||
parent::initController($request, $response, $logger);
|
||||
session();
|
||||
}
|
||||
|
||||
public function getTimeoutConfig()
|
||||
{
|
||||
return $this->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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
<?php
|
||||
|
||||
namespace App\Database\Migrations;
|
||||
|
||||
use CodeIgniter\Database\Migration;
|
||||
|
||||
/**
|
||||
* Ensures production has the settings and user_preferences tables that
|
||||
* TimeService / PreferencesModel expect. These tables historically came from
|
||||
* SQL dumps rather than migrations, so some hosts never received them.
|
||||
*/
|
||||
class CreateSettingsAndUserPreferencesTables extends Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
if (! $this->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.
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<int, array{rule_code: string, enrollment_status: string, is_withdrawn: int}>
|
||||
*/
|
||||
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,
|
||||
|
||||
@@ -15,6 +15,11 @@ class TimeService
|
||||
// Cached per-request user timezone
|
||||
private ?string $cachedUserTz = null;
|
||||
|
||||
/** @var array<string, mixed>|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<string, mixed>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
</style>
|
||||
<?= $this->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 :
|
||||
<th>Student</th>
|
||||
<th>School ID</th>
|
||||
<th>Issue</th>
|
||||
<th>Priority</th>
|
||||
<th>Details</th>
|
||||
<th>Assigned</th>
|
||||
<th>Created</th>
|
||||
@@ -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);
|
||||
?>
|
||||
<tr>
|
||||
<td>
|
||||
@@ -331,8 +369,7 @@ $reasonCodes = is_array($exceptionReasonCodes ?? null) ? $exceptionReasonCodes :
|
||||
<?= student_enrollment_status_button($flag, $schoolYear ?? null) ?>
|
||||
</td>
|
||||
<td><?= esc($flag['school_id'] ?? '') ?></td>
|
||||
<td><span class="badge bg-secondary"><?= esc(enrollment_admin_flag_label($flagTypeValue)) ?></span></td>
|
||||
<td><span class="badge <?= esc($priorityClass) ?>"><?= esc(enrollment_admin_priority_label($priorityValue)) ?></span></td>
|
||||
<td><span class="badge issue-badge <?= esc($issueClass) ?>"><?= esc(enrollment_admin_flag_label($issueCode)) ?></span></td>
|
||||
<td>
|
||||
<?php if ($details !== []): ?>
|
||||
<?php foreach ($details as $key => $value): ?>
|
||||
|
||||
@@ -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
|
||||
@@ -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 = `
|
||||
<div style="background: white; padding: 30px; border-radius: 10px; text-align: center; max-width: 400px; box-shadow: 0 5px 25px rgba(0,0,0,0.3);">
|
||||
<h3 style="color: #d35400; margin-bottom: 20px; font-size: 1.5em;">Session About to Expire</h3>
|
||||
@@ -221,20 +353,20 @@ class SessionTimeoutManager {
|
||||
Click Continue Session if you want to keep using this session.
|
||||
</p>
|
||||
<div style="margin-top: 25px;">
|
||||
<button onclick="sessionTimeout.continueSession()"
|
||||
style="background: #27ae60; color: white; border: none; padding: 12px 24px;
|
||||
<button onclick="sessionTimeout.continueSession()"
|
||||
style="background: #27ae60; color: white; border: none; padding: 12px 24px;
|
||||
border-radius: 5px; cursor: pointer; margin-right: 15px; font-size: 1em;">
|
||||
Continue Session
|
||||
</button>
|
||||
<button onclick="sessionTimeout.logout()"
|
||||
style="background: #e74c3c; color: white; border: none; padding: 12px 24px;
|
||||
<button onclick="sessionTimeout.logout()"
|
||||
style="background: #e74c3c; color: white; border: none; padding: 12px 24px;
|
||||
border-radius: 5px; cursor: pointer; font-size: 1em;">
|
||||
Logout Now
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
|
||||
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;
|
||||
|
||||
+189
-53
@@ -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 = `
|
||||
<div style="background: white; padding: 30px; border-radius: 10px; text-align: center; max-width: 400px; box-shadow: 0 5px 25px rgba(0,0,0,0.3);">
|
||||
<h3 style="color: #d35400; margin-bottom: 20px; font-size: 1.5em;">Session About to Expire</h3>
|
||||
@@ -220,20 +353,20 @@ class SessionTimeoutManager {
|
||||
Click Continue Session if you want to keep using this session.
|
||||
</p>
|
||||
<div style="margin-top: 25px;">
|
||||
<button onclick="sessionTimeout.continueSession()"
|
||||
style="background: #27ae60; color: white; border: none; padding: 12px 24px;
|
||||
<button onclick="sessionTimeout.continueSession()"
|
||||
style="background: #27ae60; color: white; border: none; padding: 12px 24px;
|
||||
border-radius: 5px; cursor: pointer; margin-right: 15px; font-size: 1em;">
|
||||
Continue Session
|
||||
</button>
|
||||
<button onclick="sessionTimeout.logout()"
|
||||
style="background: #e74c3c; color: white; border: none; padding: 12px 24px;
|
||||
<button onclick="sessionTimeout.logout()"
|
||||
style="background: #e74c3c; color: white; border: none; padding: 12px 24px;
|
||||
border-radius: 5px; cursor: pointer; font-size: 1em;">
|
||||
Logout Now
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
|
||||
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;
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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<array<string, mixed>> $rows
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user