From 0845ab77e8eff32c55f6fbf0aec3950249abadf8 Mon Sep 17 00:00:00 2001 From: velz Date: Mon, 18 May 2026 12:28:19 +0530 Subject: [PATCH 1/3] FEAT_DOCS&LIVE_ISSEUS&USER_BOCK_UI --- .env.sample | 2 + app/Commands/RateLimitBlocksReconcile.php | 117 +++ app/Config/Acl.php | 6 + app/Config/Routes.php | 11 +- app/Controllers/Docs/DocsController.php | 583 +++++++++++ app/Controllers/EmployeeController.php | 4 +- app/Controllers/FhplApiController.php | 8 +- app/Controllers/RateLimitAdminController.php | 65 ++ .../RestAuthenticationController.php | 75 +- app/Database/rate_limit_blocks.sql | 19 + app/Filters/AuthApiRateLimitFilter.php | 6 +- app/Libraries/JobStatusService.php | 79 +- app/Libraries/RateLimiterService.php | 190 +++- app/Views/admin/rate_limit_blocks.php | 102 ++ app/Views/docs/README.md | 114 +++ app/Views/docs/acl.php | 480 +++++++++ app/Views/docs/api-rate-limiter.php | 520 ++++++++++ app/Views/docs/background-jobs.php | 566 +++++++++++ app/Views/docs/cicd.php | 79 ++ app/Views/docs/deployment.php | 451 +++++++++ app/Views/docs/docs_footer.php | 80 ++ app/Views/docs/docs_header.php | 288 ++++++ app/Views/docs/docs_main_close.php | 109 +++ app/Views/docs/docs_main_open.php | 125 +++ app/Views/docs/docs_sidebar.php | 175 ++++ app/Views/docs/eb-rack-rate-calculation.php | 922 ++++++++++++++++++ app/Views/docs/eb-rack-rate-config.php | 463 +++++++++ app/Views/docs/file-uploads.php | 312 ++++++ app/Views/docs/input-security.php | 315 ++++++ app/Views/docs/installation.php | 175 ++++ app/Views/docs/installation_content.php | 124 +++ app/Views/docs/partials/README.md | 114 +++ app/Views/docs/partials/docs_footer.php | 542 ++++++++++ app/Views/docs/partials/docs_header.php | 288 ++++++ app/Views/docs/partials/docs_main_close.php | 112 +++ app/Views/docs/partials/docs_main_open.php | 142 +++ app/Views/docs/partials/docs_sidebar.php | 175 ++++ app/Views/docs/partials/installation.php | 137 +++ .../docs/partials/installation_content.php | 124 +++ app/Views/docs/s3-cloudfront.php | 524 ++++++++++ app/Views/docs/tpa-recon.php | 308 ++++++ app/Views/docs/visit-offboard.php | 249 +++++ app/Views/docs/visit-onboard.php | 550 +++++++++++ nhance_php_queue_server.service | 15 + tests/unit/RateLimiterServiceSmokeTest.php | 104 ++ 45 files changed, 9890 insertions(+), 59 deletions(-) create mode 100644 app/Commands/RateLimitBlocksReconcile.php create mode 100644 app/Controllers/Docs/DocsController.php create mode 100644 app/Controllers/RateLimitAdminController.php create mode 100644 app/Database/rate_limit_blocks.sql create mode 100644 app/Views/admin/rate_limit_blocks.php create mode 100644 app/Views/docs/README.md create mode 100644 app/Views/docs/acl.php create mode 100644 app/Views/docs/api-rate-limiter.php create mode 100644 app/Views/docs/background-jobs.php create mode 100644 app/Views/docs/cicd.php create mode 100644 app/Views/docs/deployment.php create mode 100644 app/Views/docs/docs_footer.php create mode 100644 app/Views/docs/docs_header.php create mode 100644 app/Views/docs/docs_main_close.php create mode 100644 app/Views/docs/docs_main_open.php create mode 100644 app/Views/docs/docs_sidebar.php create mode 100644 app/Views/docs/eb-rack-rate-calculation.php create mode 100644 app/Views/docs/eb-rack-rate-config.php create mode 100644 app/Views/docs/file-uploads.php create mode 100644 app/Views/docs/input-security.php create mode 100644 app/Views/docs/installation.php create mode 100644 app/Views/docs/installation_content.php create mode 100644 app/Views/docs/partials/README.md create mode 100644 app/Views/docs/partials/docs_footer.php create mode 100644 app/Views/docs/partials/docs_header.php create mode 100644 app/Views/docs/partials/docs_main_close.php create mode 100644 app/Views/docs/partials/docs_main_open.php create mode 100644 app/Views/docs/partials/docs_sidebar.php create mode 100644 app/Views/docs/partials/installation.php create mode 100644 app/Views/docs/partials/installation_content.php create mode 100644 app/Views/docs/s3-cloudfront.php create mode 100644 app/Views/docs/tpa-recon.php create mode 100644 app/Views/docs/visit-offboard.php create mode 100644 app/Views/docs/visit-onboard.php create mode 100644 nhance_php_queue_server.service create mode 100644 tests/unit/RateLimiterServiceSmokeTest.php diff --git a/.env.sample b/.env.sample index b8c1b80f..b288c04c 100755 --- a/.env.sample +++ b/.env.sample @@ -62,6 +62,8 @@ App_Url = NHANCE_LOGO = helpdeskURL = TOKENTIMEOUT = +# Grace seconds after token_time_out epoch before reset-token-timeout cron clears it (default 180 = 3 min) +TOKEN_TIMEOUT_RESET_BUFFER_SECONDS = 180 POST_ENROLLMENT_APP_LINK = GDRIVE_ROOT_FOLDER_ID = RFQ_PARENT_FOLDER_ID = diff --git a/app/Commands/RateLimitBlocksReconcile.php b/app/Commands/RateLimitBlocksReconcile.php new file mode 100644 index 00000000..afb33ce1 --- /dev/null +++ b/app/Commands/RateLimitBlocksReconcile.php @@ -0,0 +1,117 @@ + */ + protected $options = [ + '--dry-run' => 'Show which rows would be purged without deleting cache or DB.', + ]; + + public function run(array $params) + { + $dryRun = CLI::getOption('dry-run') !== null; + + $db = Database::connect(); + + if (! $db->tableExists('rate_limit_blocks')) { + CLI::write('Table rate_limit_blocks does not exist. Nothing to do.', 'yellow'); + + return; + } + + /** @var RateLimiterConfig $rl */ + $rl = config('RateLimiter'); + $limiter = new RateLimiterService(); + + $rows = $db->table('rate_limit_blocks') + ->where('status', 'active') + ->get() + ->getResultArray(); + + $count = 0; + + foreach ($rows as $row) { + $duration = $this->blockSecondsForRow($row, $rl); + if ($duration <= 0) { + continue; + } + + $blockedAt = strtotime((string) $row['blocked_at']); + if ($blockedAt === false) { + CLI::write('Skipping id ' . $row['id'] . ': invalid blocked_at.', 'red'); + continue; + } + + if (time() < $blockedAt + $duration) { + continue; + } + + $id = (int) $row['id']; + $cacheId = (string) $row['cache_identifier']; + $blockType = (string) $row['block_type']; + + CLI::write( + ($dryRun ? '[dry-run] Would reconcile ' : 'Reconciling ') + . "{$blockType} id={$id} level={$row['block_level']} display=" . $row['display_identifier'], + 'cyan' + ); + + if (! $dryRun) { + if ($blockType === 'ip') { + $limiter->purgeIpBlockCaches($cacheId); + } elseif ($blockType === 'user') { + $limiter->purgeUserBlockCaches($cacheId); + } + + $db->table('rate_limit_blocks')->delete(['id' => $id], 1); + } + + $count++; + } + + CLI::write( + $dryRun + ? "Dry run complete. {$count} row(s) would be purged and deleted." + : "Done. Reconciled {$count} expired row(s).", + 'yellow' + ); + } + + /** + * @param array $row + */ + protected function blockSecondsForRow(array $row, RateLimiterConfig $cfg): int + { + $blockCfg = ($row['block_type'] ?? '') === 'ip' ? $cfg->ipBlock : $cfg->userBlock; + $level = (string) ($row['block_level'] ?? ''); + + return match ($level) { + 'soft' => (int) $blockCfg['soft_duration'], + 'medium' => (int) $blockCfg['medium_duration'], + 'hard' => (int) $blockCfg['hard_duration'], + default => 0, + }; + } +} diff --git a/app/Config/Acl.php b/app/Config/Acl.php index 67edbd35..f2937d2b 100644 --- a/app/Config/Acl.php +++ b/app/Config/Acl.php @@ -275,6 +275,12 @@ class Acl 'teams' => [] ], + // ===================== RATE LIMIT ADMIN ===================== + '#^/security/rate-limits#' => [ + 'roles' => [ADMIN_ROLE_ID], + 'teams' => [] + ], + // ===================== INTERNAL TEST ===================== '#^/test#' => [ 'roles' => [ADMIN_ROLE_ID, HEAD_ROLE_ID], diff --git a/app/Config/Routes.php b/app/Config/Routes.php index 081b44cd..e5378f79 100755 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -563,14 +563,13 @@ $routes->cli('cli/send_mail_cli', 'MasterController::testGmailAPIViaCLI'); $routes->cli('cli/sendZeptoMail', 'MasterController::testZeptoSMTP'); $routes->cli('cli/check_bounce_mail_cli', 'MasterController::testCheckBounceMails'); $routes->cli('cli/app_check_list', 'MasterController::appCheckList'); -$routes->cli('cli/reset-token-timeout', 'RestAuthenticationController::resetTokenTimeOut'); -$routes->cli('cli/reset-token-timeout/(:num)', 'RestAuthenticationController::resetTokenTimeOut/$1'); $routes->cli('cli/new_gdrive_token', 'GoogleDriveController::generateNewGoogleDriveAccessToken'); $routes->cli('cli/list-sheet-folder-files', 'GoogleSheetController::listFolderSheetFilesCli'); $routes->cli('cli/list-sheet-folder-files/(:any)', 'GoogleSheetController::listFolderSheetFilesCli/$1'); $routes->cli('cli/check_env', 'MasterController::checkEnv'); //crone job +$routes->cli('cli/reset-token-timeout', 'RestAuthenticationController::resetTokenTimeOut'); $routes->cli('cli/enrollOpendAndClose', 'DashboardController::updatePolicyEnrollmentStatus'); $routes->cli("cli/sendCroneRemainderMail", "DashboardController::sendCroneRemainderMail"); $routes->cli('cli/update-emp-policy-status', 'ClientController::updateEmpAndPolicyStatus'); @@ -1104,6 +1103,12 @@ $routes->group('sales', function($routes) { $routes->get('searchClients', 'SalesController::searchClients'); }); +$routes->group('security/rate-limits', ['filter' => 'authMVC'], function ($routes) { + $routes->get('/', 'RateLimitAdminController::index'); + $routes->post('unblock-ip', 'RateLimitAdminController::unblockIp'); + $routes->post('unblock-user', 'RateLimitAdminController::unblockUser'); +}); + // Expence Module Route Group $routes->group('expense', ["filter" => "authMVC", 'namespace' => 'App\Controllers'], static function($routes) { $routes->get('/', 'ExpenseController::index'); @@ -1114,3 +1119,5 @@ $routes->group('expense', ["filter" => "authMVC", 'namespace' => 'App\Controller }); + $routes->get('docs', 'Docs\DocsController::index'); + $routes->get('docs/(:segment)', 'Docs\DocsController::page/$1'); \ No newline at end of file diff --git a/app/Controllers/Docs/DocsController.php b/app/Controllers/Docs/DocsController.php new file mode 100644 index 00000000..ee117868 --- /dev/null +++ b/app/Controllers/Docs/DocsController.php @@ -0,0 +1,583 @@ +get('docs', 'Docs\DocsController::index'); + * $routes->get('docs/(:segment)', 'Docs\DocsController::page/$1'); + */ +class DocsController extends BaseController +{ + // ─── APP-LEVEL DEFAULTS ─────────────────────────────────────────────────── + // Change these once here; they propagate to every page automatically. + + protected string $appName = 'Nhance PAM'; + protected string $appVersion = 'v1.1.0'; + + // ─── SIDEBAR NAV ────────────────────────────────────────────────────────── + // Add / remove pages here. 'id' must match the key used in page configs below. + + protected array $nav = [ + [ + 'label' => 'Getting Started', + 'items' => [ + ['id' => 'introduction', 'label' => 'Introduction', 'url' => 'docs/introduction'], + ['id' => 'installation', 'label' => 'Installation', 'url' => 'docs/installation'], + ['id' => 'configuration', 'label' => 'Configuration', 'url' => 'docs/configuration'], + ['id' => 'env-setup', 'label' => 'Environment Setup', 'url' => 'docs/env-setup'], + ], + ], + [ + 'label' => 'Architecture', + 'items' => [ + ['id' => 'project-structure', 'label' => 'Project Structure', 'url' => 'docs/project-structure'], + ['id' => 'routing', 'label' => 'Routing', 'url' => 'docs/routing'], + ['id' => 'controllers', 'label' => 'Controllers', 'url' => 'docs/controllers'], + ['id' => 'models', 'label' => 'Models', 'url' => 'docs/models'], + ['id' => 'services', 'label' => 'Services', 'url' => 'docs/services'], + ['id' => 'helpers', 'label' => 'Helpers', 'url' => 'docs/helpers'], + ], + ], + [ + 'label' => 'Features', + 'items' => [ + ['id' => 'authentication', 'label' => 'Authentication', 'url' => 'docs/authentication'], + ['id' => 'acl', 'label' => 'ACL / Access Control','url' => 'docs/acl'], + ['id' => 'input-security', 'label' => 'Input Security Guard','url' => 'docs/input-security'], + ['id' => 'file-uploads', 'label' => 'File Upload Guard', 'url' => 'docs/file-uploads'], + ['id' => 'background-jobs', 'label' => 'Background Jobs', 'url' => 'docs/background-jobs'], + ['id' => 'visit-onboard', 'label' => 'Visit onboard', 'url' => 'docs/visit-onboard'], + ['id' => 'visit-offboard', 'label' => 'Visit offboard', 'url' => 'docs/visit-offboard'], + ['id' => 'notifications', 'label' => 'Email / Notifications', 'url' => 'docs/notifications'], + ['id' => 'api-rate-limiter', 'label' => 'API Rate Limiter', 'url' => 'docs/api-rate-limiter'], + ['id' => 'tpa-recon', 'label' => 'TPA Recon', 'url' => 'docs/tpa-recon'], + ['id' => 'eb-rack-rate-config', 'label' => 'EB rack rate config', 'url' => 'docs/eb-rack-rate-config'], + ['id' => 'eb-rack-rate-calculation', 'label' => 'EB rack rate calculation', 'url' => 'docs/eb-rack-rate-calculation'], + ], + ], + [ + 'label' => 'API Reference', + 'items' => [ + ['id' => 'endpoints', 'label' => 'Endpoints', 'url' => 'docs/endpoints'], + ['id' => 'request-response', 'label' => 'Request / Response', 'url' => 'docs/request-response'], + ['id' => 'error-codes', 'label' => 'Error Codes', 'url' => 'docs/error-codes'], + ], + ], + [ + 'label' => 'DevOps', + 'items' => [ + ['id' => 'deployment', 'label' => 'Deployment', 'url' => 'docs/deployment'], + ['id' => 'cicd', 'label' => 'CI/CD Pipeline', 'url' => 'docs/cicd'], + ['id' => 's3-cloudfront', 'label' => 'S3 & CloudFront', 'url' => 'docs/s3-cloudfront'], + ], + ], + [ + 'label' => 'Reference', + 'items' => [ + ['id' => 'changelog', 'label' => 'Changelog', 'url' => 'docs/changelog'], + ['id' => 'contributing', 'label' => 'Contributing', 'url' => 'docs/contributing'], + ], + ], + ]; + + // ─── PAGE REGISTRY ──────────────────────────────────────────────────────── + // One entry per docs page. + // 'view' — path inside app/Views/ (without .php) + // Other keys are passed straight to the partials — add/remove as needed. + + protected array $pages = [ + + 'introduction' => [ + 'view' => 'docs/introduction', + 'title' => 'Introduction', + 'breadcrumb' => 'Getting Started', + 'last_updated' => 'May 2025', + 'author' => 'Core Team', + 'read_time' => '3 min read', + 'toc' => [ + ['label' => 'What is MyApp?', 'href' => '#what-is-myapp'], + ['label' => 'Tech stack', 'href' => '#tech-stack'], + ['label' => 'Conventions', 'href' => '#conventions'], + ], + 'prev' => null, + 'next' => ['label' => 'Installation', 'url' => 'docs/installation'], + ], + + 'installation' => [ + 'view' => 'docs/installation', + 'title' => 'Installation', + 'breadcrumb' => 'Getting Started', + 'last_updated' => 'May 2025', + 'author' => 'Core Team', + 'read_time' => '5 min read', + 'toc' => [ + ['label' => 'Requirements', 'href' => '#requirements'], + ['label' => 'Steps', 'href' => '#steps'], + ['label' => 'Clone repo', 'href' => '#clone', 'level' => 'h3'], + ['label' => 'Run migrations', 'href' => '#migrate', 'level' => 'h3'], + ['label' => 'Configuration', 'href' => '#configuration'], + ['label' => 'Sample flowchart', 'href' => '#sample-flowchart'], + ], + 'prev' => ['label' => 'Introduction', 'url' => 'docs/introduction'], + 'next' => ['label' => 'Configuration', 'url' => 'docs/configuration'], + ], + + 'configuration' => [ + 'view' => 'docs/configuration', + 'title' => 'Configuration', + 'breadcrumb' => 'Getting Started', + 'last_updated' => 'May 2025', + 'author' => 'Core Team', + 'read_time' => '4 min read', + 'toc' => [ + ['label' => 'Environment file', 'href' => '#env-file'], + ['label' => 'Database', 'href' => '#database'], + ['label' => 'Mail', 'href' => '#mail'], + ['label' => 'File storage', 'href' => '#storage'], + ], + 'prev' => ['label' => 'Installation', 'url' => 'docs/installation'], + 'next' => ['label' => 'Environment Setup','url' => 'docs/env-setup'], + ], + + 'acl' => [ + 'view' => 'docs/acl', + 'title' => 'ACL / Access Control', + 'breadcrumb' => 'Features', + 'last_updated' => 'May 2026', + 'author' => 'Core Team', + 'read_time' => '9 min read', + 'toc' => [ + ['label' => 'Overview', 'href' => '#overview'], + ['label' => 'Where it is wired', 'href' => '#where-it-is-wired'], + ['label' => 'Rule format', 'href' => '#rule-format'], + ['label' => 'Matching behavior', 'href' => '#matching-behavior'], + ['label' => 'Auth context', 'href' => '#auth-context'], + ['label' => 'Allow and deny flow', 'href' => '#allow-and-deny-flow'], + ['label' => 'Developer steps', 'href' => '#developer-steps'], + ['label' => 'Examples', 'href' => '#examples'], + ['label' => 'Do and don’t', 'href' => '#do-and-dont'], + ['label' => 'Common pitfalls', 'href' => '#common-pitfalls'], + ], + 'prev' => null, + 'next' => ['label' => 'Input Security Guard', 'url' => 'docs/input-security'], + ], + + 'input-security' => [ + 'view' => 'docs/input-security', + 'title' => 'Input Security Guard', + 'breadcrumb' => 'Features', + 'last_updated' => 'May 2026', + 'author' => 'Core Team', + 'read_time' => '8 min read', + 'toc' => [ + ['label' => 'Overview', 'href' => '#overview'], + ['label' => 'Where it runs', 'href' => '#where-it-runs'], + ['label' => 'What it checks', 'href' => '#what-it-checks'], + ['label' => 'Canonicalization', 'href' => '#canonicalization'], + ['label' => 'Block behavior', 'href' => '#block-behavior'], + ['label' => 'Filter exceptions', 'href' => '#filter-exceptions'], + ['label' => 'Developer steps', 'href' => '#developer-steps'], + ['label' => 'Common pitfalls', 'href' => '#common-pitfalls'], + ], + 'prev' => ['label' => 'ACL / Access Control', 'url' => 'docs/acl'], + 'next' => ['label' => 'File Upload Guard', 'url' => 'docs/file-uploads'], + ], + + 'file-uploads' => [ + 'view' => 'docs/file-uploads', + 'title' => 'File Upload Guard', + 'breadcrumb' => 'Features', + 'last_updated' => 'May 2026', + 'author' => 'Core Team', + 'read_time' => '8 min read', + 'toc' => [ + ['label' => 'Overview', 'href' => '#overview'], + ['label' => 'When it runs', 'href' => '#when-it-runs'], + ['label' => 'Allowed file types', 'href' => '#allowed-file-types'], + ['label' => 'Blocked extensions', 'href' => '#blocked-extensions'], + ['label' => 'Validation flow', 'href' => '#validation-flow'], + ['label' => 'Magic bytes check', 'href' => '#magic-bytes-check'], + ['label' => 'Route coverage', 'href' => '#route-coverage'], + ['label' => 'Blocked response', 'href' => '#blocked-response'], + ['label' => 'Operational notes', 'href' => '#operational-notes'], + ], + 'prev' => ['label' => 'Input Security Guard', 'url' => 'docs/input-security'], + 'next' => ['label' => 'Background Jobs', 'url' => 'docs/background-jobs'], + ], + + 'background-jobs' => [ + 'view' => 'docs/background-jobs', + 'title' => 'Background Jobs', + 'breadcrumb' => 'Features', + 'last_updated' => 'May 2026', + 'author' => 'Core Team', + 'read_time' => '8 min read', + 'toc' => [ + ['label' => 'Overview', 'href' => '#overview'], + ['label' => 'Queueing jobs', 'href' => '#queueing-jobs'], + ['label' => 'Worker lifecycle', 'href' => '#worker-lifecycle'], + ['label' => 'Handler registry', 'href' => '#handler-registry'], + ['label' => 'Sample handlers', 'href' => '#sample-handlers'], + ['label' => 'Status lifecycle', 'href' => '#status-lifecycle'], + ['label' => 'Failure behavior', 'href' => '#failure-behavior'], + ['label' => 'Running via CLI', 'href' => '#running-via-cli'], + ['label' => 'Live runner script', 'href' => '#live-runner-script'], + ['label' => 'Systemd service', 'href' => '#systemd-service'], + ['label' => 'Permissions setup', 'href' => '#permissions-setup'], + ['label' => 'Checking status', 'href' => '#checking-status'], + ['label' => 'Adding a new handler','href' => '#adding-a-new-handler'], + ], + 'prev' => ['label' => 'File Upload Guard','url' => 'docs/file-uploads'], + 'next' => ['label' => 'Visit onboard', 'url' => 'docs/visit-onboard'], + ], + + 'visit-onboard' => [ + 'view' => 'docs/visit-onboard', + 'title' => 'Visit onboard', + 'breadcrumb' => 'Features', + 'last_updated' => 'May 2026', + 'author' => 'Core Team', + 'read_time' => '8 min read', + 'toc' => [ + ['label' => 'Overview', 'href' => '#overview'], + ['label' => 'Prerequisites', 'href' => '#prerequisites'], + ['label' => 'Entry points', 'href' => '#entry-points'], + ['label' => 'Eligibility rules', 'href' => '#eligibility-rules'], + ['label' => 'Async flow', 'href' => '#async-flow'], + ['label' => 'Family payload', 'href' => '#family-payload'], + ['label' => 'API integration', 'href' => '#api-integration'], + ['label' => 'Response examples', 'href' => '#response-examples'], + ['label' => 'Database updates', 'href' => '#database-updates'], + ['label' => 'Failure behavior', 'href' => '#failure-behavior'], + ['label' => 'Developer steps', 'href' => '#developer-steps'], + ['label' => 'Common pitfalls', 'href' => '#common-pitfalls'], + ], + 'prev' => ['label' => 'Background Jobs', 'url' => 'docs/background-jobs'], + 'next' => ['label' => 'Visit offboard', 'url' => 'docs/visit-offboard'], + ], + + 'visit-offboard' => [ + 'view' => 'docs/visit-offboard', + 'title' => 'Visit offboard', + 'breadcrumb' => 'Features', + 'last_updated' => 'May 2026', + 'author' => 'Core Team', + 'read_time' => '6 min read', + 'toc' => [ + ['label' => 'Overview', 'href' => '#overview'], + ['label' => 'When it is queued', 'href' => '#when-it-is-queued'], + ['label' => 'visitOffBoard()', 'href' => '#visit-off-board-api'], + ['label' => 'updateVisitoffboardStatus()', 'href' => '#update-status'], + ['label' => 'Manual test route', 'href' => '#manual-test-route'], + ['label' => 'Developer steps', 'href' => '#developer-steps'], + ['label' => 'Common pitfalls', 'href' => '#common-pitfalls'], + ], + 'prev' => ['label' => 'Visit onboard', 'url' => 'docs/visit-onboard'], + 'next' => ['label' => 'Deployment', 'url' => 'docs/deployment'], + ], + + 'deployment' => [ + 'view' => 'docs/deployment', + 'title' => 'Deployment', + 'breadcrumb' => 'DevOps', + 'last_updated' => 'May 2026', + 'author' => 'Core Team', + 'read_time' => '8 min read', + 'toc' => [ + ['label' => 'Overview', 'href' => '#overview'], + ['label' => 'Dev cPanel flow', 'href' => '#dev-cpanel-flow', 'tag' => 'DEV'], + ['label' => 'cPanel deploy scripts', 'href' => '#cpanel-deploy-scripts', 'tag' => 'DEV'], + ['label' => 'Branch promotion flow', 'href' => '#branch-promotion-flow', 'tag' => 'UAT/LIVE'], + ['label' => 'Auto merge script', 'href' => '#auto-merge-script', 'tag' => 'UAT/LIVE'], + ['label' => 'Script usage', 'href' => '#script-usage', 'tag' => 'UAT/LIVE'], + ['label' => 'Log output', 'href' => '#log-output', 'tag' => 'UAT/LIVE'], + ['label' => 'Operational notes', 'href' => '#operational-notes', 'tag' => 'UAT/LIVE'], + ['label' => 'Code move scripts', 'href' => '#code-move-scripts', 'tag' => 'UAT/LIVE'], + ['label' => 'Manual fallback', 'href' => '#manual-fallback', 'tag' => 'DEV'], + ], + 'prev' => ['label' => 'Visit offboard', 'url' => 'docs/visit-offboard'], + 'next' => ['label' => 'CI/CD Pipeline', 'url' => 'docs/cicd'], + ], + + 'cicd' => [ + 'view' => 'docs/cicd', + 'title' => 'CI/CD Pipeline', + 'breadcrumb' => 'DevOps', + 'last_updated' => 'May 2026', + 'author' => 'Core Team', + 'read_time' => '4 min read', + 'toc' => [ + ['label' => 'Overview', 'href' => '#overview'], + ['label' => 'Dev pipeline', 'href' => '#dev-pipeline', 'tag' => 'DEV'], + ['label' => 'UAT and live flow', 'href' => '#uat-live-flow', 'tag' => 'UAT/LIVE'], + ['label' => 'Related docs', 'href' => '#related-docs'], + ], + 'prev' => ['label' => 'Deployment', 'url' => 'docs/deployment'], + 'next' => ['label' => 'S3 & CloudFront', 'url' => 'docs/s3-cloudfront'], + ], + + 's3-cloudfront' => [ + 'view' => 'docs/s3-cloudfront', + 'title' => 'S3 & CloudFront', + 'breadcrumb' => 'DevOps', + 'last_updated' => 'May 2026', + 'author' => 'Core Team', + 'read_time' => '7 min read', + 'toc' => [ + ['label' => 'Overview', 'href' => '#overview'], + ['label' => 'Prerequisites', 'href' => '#prerequisites'], + ['label' => 'Selection flow', 'href' => '#selection-flow'], + ['label' => 'Deployment steps', 'href' => '#deployment-steps'], + ['label' => 'Invalidation polling', 'href' => '#invalidation-polling'], + ['label' => 'Operational notes', 'href' => '#operational-notes'], + ['label' => 'Full script', 'href' => '#full-script'], + ], + 'prev' => ['label' => 'CI/CD Pipeline', 'url' => 'docs/cicd'], + 'next' => ['label' => 'Changelog', 'url' => 'docs/changelog'], + ], + + 'api-rate-limiter' => [ + 'view' => 'docs/api-rate-limiter', + 'title' => 'API Rate Limiter', + 'breadcrumb' => 'Features', + 'last_updated' => 'May 2026', + 'author' => 'Core Team', + 'read_time' => '10 min read', + 'toc' => [ + ['label' => 'Overview', 'href' => '#overview'], + ['label' => 'Core service', 'href' => '#core-service'], + ['label' => 'Auth API filter', 'href' => '#auth-api-filter'], + ['label' => 'JWT API filter', 'href' => '#jwt-api-filter'], + ['label' => 'Configuration', 'href' => '#configuration'], + ['label' => 'Change block counts and durations', 'href' => '#change-block-count-duration'], + ['label' => 'Manual unblock samples', 'href' => '#manual-unblock-samples'], + ['label' => 'Cache TTL and auto-release', 'href' => '#cache-ttl-auto-release'], + ['label' => 'DB reconciliation (cron)', 'href' => '#db-reconciliation-cron'], + ['label' => 'HTTP responses', 'href' => '#http-responses'], + ['label' => 'Wiring routes', 'href' => '#wiring-routes'], + ['label' => 'Blocked list URL', 'href' => '#blocked-list-url'], + ['label' => 'Smoke test command', 'href' => '#smoke-test-command'], + ['label' => 'Operational notes', 'href' => '#operational-notes'], + ], + 'prev' => ['label' => 'Email / Notifications', 'url' => 'docs/notifications'], + 'next' => ['label' => 'TPA Recon', 'url' => 'docs/tpa-recon'], + ], + + 'tpa-recon' => [ + 'view' => 'docs/tpa-recon', + 'title' => 'TPA Recon', + 'breadcrumb' => 'Features', + 'last_updated' => 'May 2026', + 'author' => 'Core Team', + 'read_time' => '18 min read', + 'toc' => [ + ['label' => 'What this is', 'href' => '#what-this-is'], + ['label' => 'Glossary', 'href' => '#glossary'], + ['label' => 'Key files and routes', 'href' => '#key-files-routes'], + ['label' => 'End-to-end flow', 'href' => '#end-to-end-flow'], + ['label' => 'Variation report', 'href' => '#variation-report'], + ['label' => 'Classifying rec_type', 'href' => '#classifying-rec-type'], + ['label' => 'Linking ref column', 'href' => '#linking-ref-column'], + ['label' => 'Proceed next step', 'href' => '#proceed-next-step'], + ['label' => 'Not in Nhance → inception', 'href' => '#not-in-nhance-inception'], + ['label' => 'Need to review → DB sync', 'href' => '#need-to-review-sync'], + ['label' => 'Deletion initialization', 'href' => '#deletion-initialization'], + ['label' => 'Background jobs chain', 'href' => '#background-jobs-chain'], + ['label' => 'New developer checklist', 'href' => '#new-developer-checklist'], + ], + 'prev' => ['label' => 'API Rate Limiter', 'url' => 'docs/api-rate-limiter'], + 'next' => ['label' => 'EB rack rate config', 'url' => 'docs/eb-rack-rate-config'], + ], + + 'eb-rack-rate-config' => [ + 'view' => 'docs/eb-rack-rate-config', + 'title' => 'EB rack rate config', + 'breadcrumb' => 'Features', + 'last_updated' => 'May 2026', + 'author' => 'Core Team', + 'read_time' => '20 min read', + 'toc' => [ + ['label' => 'Purpose', 'href' => '#purpose'], + ['label' => 'Premium calculation (all)', 'href' => '#premium-calculation-modes'], + ['label' => 'Applicable family members', 'href' => '#applicable-family-members'], + ['label' => 'Member radios (Yes / No / …)', 'href' => '#applicable-members-radio-meanings'], + ['label' => 'Rack matching vs family', 'href' => '#applicable-members-matching'], + ['label' => 'Rack vs family flowchart', 'href' => '#rack-rate-family-matching-flowchart'], + ['label' => 'How to configure a rack rate', 'href' => '#how-to-configure-rack-rate'], + ['label' => 'Where it appears in the UI', 'href' => '#ui-entry'], + ['label' => 'Load grid data (backend)', 'href' => '#getpolicy-grid-data'], + ['label' => 'Save rack rate (backend)', 'href' => '#create-premium'], + ['label' => 'Frontend flow', 'href' => '#frontend-flow'], + ['label' => 'Grid IDs and storage', 'href' => '#grid-ids-storage'], + ['label' => 'Grid types (1–13)', 'href' => '#grid-types-1-13'], + ['label' => 'Excel paste path', 'href' => '#excel-paste'], + ['label' => 'Related routes', 'href' => '#related-routes'], + ], + 'prev' => ['label' => 'TPA Recon', 'url' => 'docs/tpa-recon'], + 'next' => ['label' => 'EB rack rate calculation', 'url' => 'docs/eb-rack-rate-calculation'], + ], + + 'eb-rack-rate-calculation' => [ + 'view' => 'docs/eb-rack-rate-calculation', + 'title' => 'EB rack rate calculation', + 'breadcrumb' => 'Features', + 'last_updated' => 'May 2026', + 'author' => 'Core Team', + 'read_time' => '18 min read', + 'toc' => [ + ['label' => 'Scope and entry point', 'href' => '#scope'], + ['label' => 'Data loaded before premium', 'href' => '#inputs'], + ['label' => 'Excel row shape (numeric columns)', 'href' => '#excel-columns'], + ['label' => 'Flow: employeesOnboardPreprocess', 'href' => '#flow-preprocess'], + ['label' => 'Flow: calculate_premium_new', 'href' => '#flow-calculate-premium'], + ['label' => 'group_slab_rates_basedon_name', 'href' => '#flow-group-slabs'], + ['label' => 'get_familiy_composition', 'href' => '#flow-family-composition'], + ['label' => 'compare_incoming…_slab', 'href' => '#flow-compare-slab'], + ['label' => 'get_applicable_familiy_members', 'href' => '#flow-applicable-members'], + ['label' => 'Per-member transform and gate', 'href' => '#flow-per-member'], + ['label' => 'premium_calculation_manager', 'href' => '#flow-premium-manager'], + ['label' => 'Dependent addition extras', 'href' => '#dependent-addition'], + ['label' => 'Failure: zero successful families', 'href' => '#failure-modes'], + ['label' => 'Related', 'href' => '#related'], + ], + 'prev' => ['label' => 'EB rack rate config', 'url' => 'docs/eb-rack-rate-config'], + 'next' => ['label' => 'Endpoints', 'url' => 'docs/endpoints'], + ], + + // ── Add more pages here following the same pattern ────────────────── + ]; + + + // ========================================================================= + // PUBLIC ROUTES + // ========================================================================= + + /** + * GET /docs → redirect to first page (introduction) + */ + public function index(): \CodeIgniter\HTTP\RedirectResponse + { + return redirect()->to(base_url('docs/introduction')); + } + + /** + * GET /docs/(:segment) + * + * Looks up the slug in $pages, assembles layout partials, and returns HTML. + * The individual view only contains its own content — no layout boilerplate. + */ + public function page(string $slug): string + { + // 1. Resolve page config + $config = $this->getPageConfig($slug); + + // 2. Render the inner content view (must exist) + $content = $this->renderContentView($config['view'], $config); + + // 3. Assemble full layout and return + return $this->renderDocPage($config, $content); + } + + + // ========================================================================= + // CORE LAYOUT ASSEMBLER + // ========================================================================= + + /** + * Wraps any content string with the full docs layout (header, sidebar, + * main open/close, footer) and returns the complete HTML page. + * + * @param array $config Page config entry from $this->pages + * @param string $content Rendered HTML from the content-only view + */ + protected function renderDocPage(array $config, string $content): string + { + $sharedLayout = [ + 'app_name' => $this->appName, + 'app_version' => $this->appVersion, + 'nav' => $this->nav, // sidebar needs the full nav + ]; + + $header = view('docs/partials/docs_header', array_merge($sharedLayout, [ + 'doc_title' => $config['title'], + ])); + + $sidebar = view('docs/partials/docs_sidebar', array_merge($sharedLayout, [ + 'active_page' => $config['id'], + ])); + + $mainOpen = view('docs/partials/docs_main_open', [ + 'doc_title' => $config['title'], + 'breadcrumb' => $config['breadcrumb'] ?? '', + 'last_updated' => $config['last_updated'] ?? '', + 'author' => $config['author'] ?? '', + 'read_time' => $config['read_time'] ?? '', + 'toc' => $config['toc'] ?? [], + ]); + + $mainClose = view('docs/partials/docs_main_close', [ + 'toc' => $config['toc'] ?? [], + 'prev_label' => $config['prev']['label'] ?? '', + 'prev_url' => $config['prev']['url'] ?? '', + 'next_label' => $config['next']['label'] ?? '', + 'next_url' => $config['next']['url'] ?? '', + ]); + + $footer = view('docs/partials/docs_footer', [ + 'app_name' => $this->appName, + ]); + + return $header . $sidebar . $mainOpen . $content . $mainClose . $footer; + } + + + // ========================================================================= + // HELPERS + // ========================================================================= + + /** + * Resolve a URL slug to its page config array. + * Injects 'id' (the slug) so partials can use it without repeating it. + * + * @throws PageNotFoundException + */ + protected function getPageConfig(string $slug): array + { + if (! array_key_exists($slug, $this->pages)) { + throw new PageNotFoundException("Docs page not found: {$slug}"); + } + + return array_merge($this->pages[$slug], ['id' => $slug]); + } + + /** + * Render the content-only view file. + * Passes the full config as view data so the content view + * can access $title, $toc, etc. if needed. + * + * @throws PageNotFoundException if the view file does not exist on disk + */ + protected function renderContentView(string $viewPath, array $data = []): string + { + $fullPath = APPPATH . 'Views/' . $viewPath . '.php'; + + if (! file_exists($fullPath)) { + throw new PageNotFoundException("Docs view file not found: {$viewPath}.php"); + } + + // Remove 'view' key so it doesn't shadow anything inside the view + unset($data['view']); + + return view($viewPath, $data); + } +} diff --git a/app/Controllers/EmployeeController.php b/app/Controllers/EmployeeController.php index 25072e55..e8d27f3e 100755 --- a/app/Controllers/EmployeeController.php +++ b/app/Controllers/EmployeeController.php @@ -4517,9 +4517,9 @@ class EmployeeController extends AdminController // $generationResult = $this->generateCorrectionUploadFromNeedToReview((int) $file_id, $file); //once correction file uploaded success fully update ref id b/w employeetable and tpaapi data - $generationResult = $this->updateEmployeeDataFromTpa([$file_id]); + $generationResult = $this->updateEmployeeDataFromTpa(['batch_file_id' => (int) $file_id]); - if (!$generationResult['status']) { + if (empty($generationResult['success'])) { return $this->respond( [ 'status' => false, diff --git a/app/Controllers/FhplApiController.php b/app/Controllers/FhplApiController.php index dfa7db1b..1cd91f3a 100644 --- a/app/Controllers/FhplApiController.php +++ b/app/Controllers/FhplApiController.php @@ -549,7 +549,7 @@ class FhplApiController extends BaseController foreach ($allMembers as $m) { if ( - strtolower(trim($policy['name'])) === strtolower(trim($m['EMPLOYEE_NAME'] ?? '')) && + strtolower(trim($policy['name'])) === strtolower(trim($m['BENEFICIARY_NAME'] ?? '')) && ($policy['emp_code'] ?? '') == ($m['EMPLOYEE_ID'] ?? '') && strtolower($policy['relationship']) === strtolower($m['RELATION'] ?? '') ) { @@ -558,7 +558,7 @@ class FhplApiController extends BaseController $sql = "UPDATE employee_polices SET tpa_id = ? WHERE id = ?"; - $this->db->query($sql, [$m['TPA_TPADetailID'], $policy['emp_policy_id']]); + $this->db->query($sql, [$m['MEMBERSHIP_NO'], $policy['emp_policy_id']]); // for e-card send if(strtolower(trim($policy['relationship'])) == 'self'){ @@ -567,7 +567,7 @@ class FhplApiController extends BaseController if ($this->db->affectedRows() > 0) { $updated++; - log_message('error', "FHPL - TPA ID Pull Updated tpa_id={$m['TPA_TPADetailID']} for emp_policy_id={$policy['emp_policy_id']} policy={$policyNo}"); + log_message('error', "FHPL - TPA ID Pull Updated tpa_id={$m['MEMBERSHIP_NO']} for emp_policy_id={$policy['emp_policy_id']} policy={$policyNo}"); } else { log_message('error', "FHPL - TPA ID Pull No update (already set or not matched) for emp_policy_id={$policy['emp_policy_id']} policy={$policyNo}"); } @@ -1056,7 +1056,7 @@ class FhplApiController extends BaseController 'file_id' => $file_id, // ← pass from controller 'emp_code' => trim($row['EMPLOYEE_ID'] ?? ''), - 'name' => trim($row['EMPLOYEE_NAME'] ?? ''), + 'name' => trim($row['BENEFICIARY_NAME'] ?? ''), 'dob' => !empty($row['DATE_OF_BIRTH'] ?? null) ? date('Y-m-d', strtotime(str_replace('/', '-', $row['DATE_OF_BIRTH']))) : null, 'relation' => trim(strtolower($row['RELATION'] ?? '')), diff --git a/app/Controllers/RateLimitAdminController.php b/app/Controllers/RateLimitAdminController.php new file mode 100644 index 00000000..99ad59ca --- /dev/null +++ b/app/Controllers/RateLimitAdminController.php @@ -0,0 +1,65 @@ +limiter = new RateLimiterService(); + } + + public function index(): void + { + $tab = $this->request->getGet('tab'); + if (! in_array($tab, ['ip', 'user'], true)) { + $tab = 'ip'; + } + + $data = [ + 'title' => 'Rate Limit Blocks', + 'activeTab' => $tab, + 'blockedIps' => $this->limiter->listBlockedIps(), + 'blockedUsers' => $this->limiter->listBlockedUsers(), + ]; + + $this->loadLayout('admin/rate_limit_blocks', $data); + } + + public function unblockIp(): \CodeIgniter\HTTP\RedirectResponse + { + $fingerprint = trim((string) $this->request->getPost('cache_identifier')); + $reason = trim((string) $this->request->getPost('reason')); + $actorId = (int) (session()->get('userid') ?? 0); + + if ($fingerprint === '') { + return redirect()->back()->with('error', 'Missing IP block identifier.'); + } + + $this->limiter->unblockIpByAdmin($fingerprint, $reason !== '' ? $reason : null, $actorId ?: null); + + return redirect()->to(base_url('security/rate-limits?tab=ip')) + ->with('success', 'Blocked IP entry unblocked successfully.'); + } + + public function unblockUser(): \CodeIgniter\HTTP\RedirectResponse + { + $identity = trim((string) $this->request->getPost('display_identifier')); + $reason = trim((string) $this->request->getPost('reason')); + $actorId = (int) (session()->get('userid') ?? 0); + + if ($identity === '') { + return redirect()->back()->with('error', 'Missing user identity.'); + } + + $this->limiter->unblockUserByAdmin($identity, $reason !== '' ? $reason : null, $actorId ?: null); + + return redirect()->to(base_url('security/rate-limits?tab=user')) + ->with('success', 'Blocked user entry unblocked successfully.'); + } +} + diff --git a/app/Controllers/RestAuthenticationController.php b/app/Controllers/RestAuthenticationController.php index ae85137b..4eae9ba0 100755 --- a/app/Controllers/RestAuthenticationController.php +++ b/app/Controllers/RestAuthenticationController.php @@ -37,6 +37,9 @@ class RestAuthenticationController extends AdminController { use ResponseTrait; + /** Default grace period after token_time_out epoch before cron clears it (seconds). */ + private const RESET_TOKEN_TIMEOUT_BUFFER_SECONDS_DEFAULT = 180; + protected $myLogger; protected $employeeModel; protected $authHistoryModel; @@ -2377,52 +2380,70 @@ class RestAuthenticationController extends AdminController ], 200); } - public function resetTokenTimeOut($bufferSeconds = null) + /** + * Cron: clear expired token_time_out on active employees (epoch expiry + buffer). + * + * Buffer default: 3 minutes. Override in .env: TOKEN_TIMEOUT_RESET_BUFFER_SECONDS + * + * php public/index.php cli/reset-token-timeout + */ + public function resetTokenTimeOut() { - if (!is_cli()) { + if (! is_cli()) { return $this->respond([ 'status' => false, - 'message' => 'This endpoint is CLI only.' + 'message' => 'This endpoint is CLI only.', ], 403); } - $envBuffer = (int) (getenv('TOKEN_TIMEOUT_RESET_BUFFER_SECONDS') ?: 300); - $buffer = is_numeric($bufferSeconds) ? (int) $bufferSeconds : $envBuffer; + $buffer = (int) (getenv('TOKEN_TIMEOUT_RESET_BUFFER_SECONDS') + ?: self::RESET_TOKEN_TIMEOUT_BUFFER_SECONDS_DEFAULT); if ($buffer < 0) { - $buffer = 0; + $buffer = self::RESET_TOKEN_TIMEOUT_BUFFER_SECONDS_DEFAULT; } - $cutoffEpoch = time() - $buffer; - $db = db_connect(); + $cutoffEpoch = time() - $buffer; + $resetTokenTimeoutCronRan = date('Y-m-d H:i:s'); - $db->table('level_contacts') + $rows = $this->employeeModel + ->select('id') ->where('token_time_out IS NOT NULL', null, false) + ->where('is_active', 1) + ->where('emp_status', 'active') ->where('token_time_out <=', $cutoffEpoch) - ->set(['token_time_out' => null]) - ->update(); - $levelContactsUpdated = $db->affectedRows(); + ->findAll(); - $db->table('employees') - ->where('token_time_out IS NOT NULL', null, false) - ->where('token_time_out <=', $cutoffEpoch) - ->set(['token_time_out' => null]) - ->update(); - $employeesUpdated = $db->affectedRows(); + $employeeIds = array_map(static fn (array $row): int => (int) $row['id'], $rows); + + if ($employeeIds !== []) { + $this->employeeModel + ->whereIn('id', $employeeIds) + ->set(['token_time_out' => null]) + ->update(); + } + + $logPayload = $employeeIds !== [] + ? json_encode([ + 'employee_ids' => $employeeIds, + 'reset_token_timeout_cron_ran_at' => $resetTokenTimeoutCronRan, + ]) + : json_encode(['reset_token_timeout_cron_ran_at' => $resetTokenTimeoutCronRan]); + + $this->myLogger->logme('error', 'reset-token-timeout cron: ' . $logPayload); $result = [ - 'status' => true, - 'message' => 'Token timeout reset completed.', - 'buffer_seconds' => $buffer, - 'cutoff_epoch' => $cutoffEpoch, - 'updated' => [ - 'level_contacts' => $levelContactsUpdated, - 'employees' => $employeesUpdated, - 'total' => $levelContactsUpdated + $employeesUpdated, + 'status' => true, + 'message' => 'Token timeout reset completed.', + 'buffer_seconds' => $buffer, + 'cutoff_epoch' => $cutoffEpoch, + 'reset_token_timeout_cron_ran_at' => $resetTokenTimeoutCronRan, + 'updated' => [ + 'employees' => count($employeeIds), + 'employee_ids' => $employeeIds, ], ]; echo json_encode($result, JSON_UNESCAPED_SLASHES) . PHP_EOL; - return; } } \ No newline at end of file diff --git a/app/Database/rate_limit_blocks.sql b/app/Database/rate_limit_blocks.sql new file mode 100644 index 00000000..d87d8964 --- /dev/null +++ b/app/Database/rate_limit_blocks.sql @@ -0,0 +1,19 @@ +CREATE TABLE IF NOT EXISTS `rate_limit_blocks` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `block_type` ENUM('ip', 'user') NOT NULL, + `cache_identifier` VARCHAR(191) NOT NULL COMMENT 'fingerprint for ip, hashed identity for user', + `display_identifier` VARCHAR(255) NOT NULL COMMENT 'actual IP or email/mobile for admin display', + `block_level` ENUM('soft', 'medium', 'hard') NOT NULL, + `status` ENUM('active', 'unblocked') NOT NULL DEFAULT 'active', + `blocked_at` DATETIME NOT NULL, + `unblocked_at` DATETIME NULL DEFAULT NULL, + `unblocked_by` BIGINT NULL DEFAULT NULL, + `unblock_reason` VARCHAR(255) NULL DEFAULT NULL, + `meta_json` LONGTEXT NULL, + `created_at` DATETIME NOT NULL, + `updated_at` DATETIME NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_rate_limit_block` (`block_type`, `cache_identifier`), + KEY `idx_rate_limit_status` (`status`, `block_type`), + KEY `idx_rate_limit_level` (`block_level`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/app/Filters/AuthApiRateLimitFilter.php b/app/Filters/AuthApiRateLimitFilter.php index 74c2b4fb..210bd0bc 100644 --- a/app/Filters/AuthApiRateLimitFilter.php +++ b/app/Filters/AuthApiRateLimitFilter.php @@ -47,7 +47,7 @@ class AuthApiRateLimitFilter implements FilterInterface // 1. IP-level check $ipResult = $this->limiter->checkIp($fingerprint, 'authApi'); if ($ipResult) { - return $this->jsonResponse($ipResult); + return $this->jsonResponse($request, $ipResult); } // 2. User-level block check (identity may not be present yet on first hit) @@ -55,7 +55,7 @@ class AuthApiRateLimitFilter implements FilterInterface if ($identity) { $userResult = $this->limiter->checkUser($identity); if ($userResult) { - return $this->jsonResponse($userResult); + return $this->jsonResponse($request, $userResult); } } @@ -128,7 +128,7 @@ class AuthApiRateLimitFilter implements FilterInterface /** * Build and return a JSON response for blocked/throttled requests. */ - protected function jsonResponse(array $result): ResponseInterface + protected function jsonResponse(RequestInterface $request, array $result): ResponseInterface { $response = service('response'); $response->setStatusCode($result['status']); diff --git a/app/Libraries/JobStatusService.php b/app/Libraries/JobStatusService.php index 8c6f14a4..650c6a7d 100644 --- a/app/Libraries/JobStatusService.php +++ b/app/Libraries/JobStatusService.php @@ -31,16 +31,7 @@ class JobStatusService return $this->buildResponse(false, $jobName, 'No job record found for given name.'); } - return [ - 'success' => true, - 'job_name' => $jobName, - 'status' => $job['status'] ?? null, - 'response' => $this->normalizeResponse($job['response'] ?? null), - 'job_id' => isset($job['id']) ? (int) $job['id'] : null, - 'uuid' => $job['uuid'] ?? null, - 'run_time' => $this->normalizeRunTime($job['run_time'] ?? null), - 'message' => 'Job status fetched successfully.', - ]; + return $this->formatJobResponse($job, 'Job status fetched successfully.'); } catch (\Throwable $e) { log_message('error', 'JobStatusService failed for job "{job}": {message}', [ 'job' => $jobName, @@ -51,6 +42,74 @@ class JobStatusService } } + public function getJobStatusById(int $jobId): array + { + if ($jobId <= 0) { + return $this->buildResponse(false, '', 'Valid job id is required.'); + } + + try { + $job = $this->jobModel + ->where('id', $jobId) + ->first(); + + if (!$job) { + return $this->buildResponse(false, '', 'No job record found for given id.'); + } + + return $this->formatJobResponse($job, 'Job status fetched successfully.'); + } catch (\Throwable $e) { + log_message('error', 'JobStatusService failed for job id "{id}": {message}', [ + 'id' => $jobId, + 'message' => $e->getMessage(), + ]); + + return $this->buildResponse(false, '', 'Unable to fetch job status right now.'); + } + } + + public function getJobStatusByUuid(string $uuid): array + { + $uuid = trim($uuid); + + if ($uuid === '') { + return $this->buildResponse(false, '', 'Job uuid is required.'); + } + + try { + $job = $this->jobModel + ->where('uuid', $uuid) + ->first(); + + if (!$job) { + return $this->buildResponse(false, '', 'No job record found for given uuid.'); + } + + return $this->formatJobResponse($job, 'Job status fetched successfully.'); + } catch (\Throwable $e) { + log_message('error', 'JobStatusService failed for job uuid "{uuid}": {message}', [ + 'uuid' => $uuid, + 'message' => $e->getMessage(), + ]); + + return $this->buildResponse(false, '', 'Unable to fetch job status right now.'); + } + } + + protected function formatJobResponse(array $job, string $message): array + { + return [ + 'success' => true, + 'job_name' => $job['name'] ?? '', + 'status' => $job['status'] ?? null, + 'response' => $this->normalizeResponse($job['response'] ?? null), + 'job_id' => isset($job['id']) ? (int) $job['id'] : null, + 'uuid' => $job['uuid'] ?? null, + 'run_time' => $this->normalizeRunTime($job['run_time'] ?? null), + 'message' => $message, + ]; + } + protected function normalizeResponse($rawResponse) { // dd($rawResponse); diff --git a/app/Libraries/RateLimiterService.php b/app/Libraries/RateLimiterService.php index 90094907..0a951efe 100644 --- a/app/Libraries/RateLimiterService.php +++ b/app/Libraries/RateLimiterService.php @@ -4,6 +4,7 @@ namespace App\Libraries; use Config\RateLimiter as RateLimiterConfig; use CodeIgniter\Cache\CacheInterface; +use CodeIgniter\Database\BaseConnection; /** * RateLimiterService @@ -21,11 +22,14 @@ class RateLimiterService { protected RateLimiterConfig $config; protected CacheInterface $cache; + protected BaseConnection $db; + protected string $blockTable = 'rate_limit_blocks'; public function __construct() { $this->config = config('RateLimiter'); $this->cache = \Config\Services::cache(); + $this->db = \Config\Database::connect(); } // ========================================================================= @@ -117,18 +121,28 @@ class RateLimiterService // Duration 0 = store for 10 years (permanent until manual unblock) $ttl = $duration > 0 ? $duration : (10 * 365 * 24 * 3600); $this->cache->save($blockKey, $data, $ttl); + $this->upsertBlockRecord('ip', $fingerprint, (string) ($data['ip'] ?? $fingerprint), $level, $data); } /** * Manually unblock an IP. Clears block, violations, and counters. */ public function unblockIp(string $fingerprint): void + { + $this->purgeIpBlockCaches($fingerprint); + $this->markBlockAsUnblocked('ip', $fingerprint); + } + + /** + * Remove IP rate-limit cache entries (block, hits, violations, counter) without touching DB. + */ + public function purgeIpBlockCaches(string $fingerprint): void { $keys = $this->config->cacheKeys; - $this->cache->delete($keys['ip_block'] . $fingerprint); - $this->cache->delete($keys['ip_violations'] . $fingerprint); - $this->cache->delete($keys['ip_count'] . $fingerprint); - $this->cache->delete($keys['ip_block_hits'] . $fingerprint); + $this->cache->delete($keys['ip_block'] . $fingerprint); + $this->cache->delete($keys['ip_violations'] . $fingerprint); + $this->cache->delete($keys['ip_count'] . $fingerprint); + $this->cache->delete($keys['ip_block_hits'] . $fingerprint); } /** @@ -193,7 +207,8 @@ class RateLimiterService public function blockUser(string $identity, string $level = 'soft'): void { $cfg = $this->config->userBlock; - $blockKey = $this->config->cacheKeys['user_block'] . $this->hashIdentity($identity); + $hashed = $this->hashIdentity($identity); + $blockKey = $this->config->cacheKeys['user_block'] . $hashed; $duration = $this->blockDuration($cfg, $level); $ttl = $duration > 0 ? $duration : (10 * 365 * 24 * 3600); @@ -205,6 +220,7 @@ class RateLimiterService ]; $this->cache->save($blockKey, $data, $ttl); + $this->upsertBlockRecord('user', $hashed, $identity, $level, $data); } /** @@ -212,13 +228,78 @@ class RateLimiterService */ public function unblockUser(string $identity): void { - $keys = $this->config->cacheKeys; - $hashed = $this->hashIdentity($identity); + $this->purgeUserBlockCaches($this->hashIdentity($identity)); + $this->markBlockAsUnblocked('user', $this->hashIdentity($identity)); + } - $this->cache->delete($keys['user_block'] . $hashed); - $this->cache->delete($keys['user_violations'] . $hashed); - $this->cache->delete($keys['user_count'] . $hashed); - $this->cache->delete($keys['user_block_hits'] . $hashed); + /** + * Remove user rate-limit cache entries for a hashed identity without touching DB. + */ + public function purgeUserBlockCaches(string $hashedIdentity): void + { + $keys = $this->config->cacheKeys; + $this->cache->delete($keys['user_block'] . $hashedIdentity); + $this->cache->delete($keys['user_violations'] . $hashedIdentity); + $this->cache->delete($keys['user_count'] . $hashedIdentity); + $this->cache->delete($keys['user_block_hits'] . $hashedIdentity); + } + + /** + * List active blocked IP records for admin views. + * + * @return array> + */ + public function listBlockedIps(int $limit = 200): array + { + if (! $this->hasBlockTable()) { + return []; + } + + return $this->db->table($this->blockTable) + ->where('block_type', 'ip') + ->where('status', 'active') + ->orderBy('updated_at', 'DESC') + ->limit($limit) + ->get() + ->getResultArray(); + } + + /** + * List active blocked user records for admin views. + * + * @return array> + */ + public function listBlockedUsers(int $limit = 200): array + { + if (! $this->hasBlockTable()) { + return []; + } + + return $this->db->table($this->blockTable) + ->where('block_type', 'user') + ->where('status', 'active') + ->orderBy('updated_at', 'DESC') + ->limit($limit) + ->get() + ->getResultArray(); + } + + /** + * Admin helper for unblocking via UI. + */ + public function unblockIpByAdmin(string $fingerprint, ?string $reason = null, ?int $actorId = null): void + { + $this->unblockIp($fingerprint); + $this->markBlockAsUnblocked('ip', $fingerprint, $reason, $actorId); + } + + /** + * Admin helper for unblocking via UI. + */ + public function unblockUserByAdmin(string $identity, ?string $reason = null, ?int $actorId = null): void + { + $this->unblockUser($identity); + $this->markBlockAsUnblocked('user', $this->hashIdentity($identity), $reason, $actorId); } /** @@ -380,4 +461,91 @@ class RateLimiterService { return hash('sha256', strtolower(trim($identity))); } + + protected function hasBlockTable(): bool + { + try { + return $this->db->tableExists($this->blockTable); + } catch (\Throwable $e) { + log_message('error', '[RateLimiter] Failed checking block table: ' . $e->getMessage()); + return false; + } + } + + /** + * Keep an admin-readable block index in DB without affecting runtime decisions. + */ + protected function upsertBlockRecord( + string $blockType, + string $cacheIdentifier, + string $displayIdentifier, + string $level, + array $meta = [] + ): void { + if (! $this->hasBlockTable()) { + return; + } + + try { + $builder = $this->db->table($this->blockTable); + $now = date('Y-m-d H:i:s'); + $existing = $builder + ->select('id') + ->where('block_type', $blockType) + ->where('cache_identifier', $cacheIdentifier) + ->get() + ->getRowArray(); + + $payload = [ + 'display_identifier' => $displayIdentifier, + 'block_level' => $level, + 'status' => 'active', + 'blocked_at' => $now, + 'unblocked_at' => null, + 'unblocked_by' => null, + 'unblock_reason' => null, + 'meta_json' => ! empty($meta) ? json_encode($meta) : null, + 'updated_at' => $now, + ]; + + if ($existing) { + $builder->where('id', $existing['id'])->update($payload); + return; + } + + $payload['block_type'] = $blockType; + $payload['cache_identifier'] = $cacheIdentifier; + $payload['created_at'] = $now; + $builder->insert($payload); + } catch (\Throwable $e) { + log_message('error', '[RateLimiter] Failed upserting block record: ' . $e->getMessage()); + } + } + + protected function markBlockAsUnblocked( + string $blockType, + string $cacheIdentifier, + ?string $reason = null, + ?int $actorId = null + ): void { + if (! $this->hasBlockTable()) { + return; + } + + try { + $now = date('Y-m-d H:i:s'); + $this->db->table($this->blockTable) + ->where('block_type', $blockType) + ->where('cache_identifier', $cacheIdentifier) + ->update([ + 'status' => 'unblocked', + 'unblocked_at' => $now, + 'unblocked_by' => $actorId, + 'unblock_reason' => $reason, + 'updated_at' => $now, + ]); + } catch (\Throwable $e) { + log_message('error', '[RateLimiter] Failed marking unblock state: ' . $e->getMessage()); + } + } } diff --git a/app/Views/admin/rate_limit_blocks.php b/app/Views/admin/rate_limit_blocks.php new file mode 100644 index 00000000..c68ed729 --- /dev/null +++ b/app/Views/admin/rate_limit_blocks.php @@ -0,0 +1,102 @@ +
+
+

Rate Limit Blocks

+ URL-only admin utility +
+ + getFlashdata('success')): ?> +
getFlashdata('success')) ?>
+ + getFlashdata('error')): ?> +
getFlashdata('error')) ?>
+ + + + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
IPBlock LevelCache Identifier (Fingerprint)Blocked AtAction
No active blocked IP records.
+
+ + + +
+
+
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
User IdentityBlock LevelIdentity Hash KeyBlocked AtAction
No active blocked user records.
+
+ + + +
+
+
+
+ +
diff --git a/app/Views/docs/README.md b/app/Views/docs/README.md new file mode 100644 index 00000000..8305067e --- /dev/null +++ b/app/Views/docs/README.md @@ -0,0 +1,114 @@ +# CI4 Dev Docs + +Docs pages live in `app/Views/docs/`. +Shared layout partials live in `app/Views/docs/partials/`. + +## File list + +| File | Purpose | +|------------------------|----------------------------------------------------------------| +| `docs_header.php` | ``, CSS tokens, top bar, opens `
` | +| `docs_sidebar.php` | Left nav sidebar — edit the `$nav` array to add/remove pages | +| `docs_main_open.php` | Opens `
`, renders breadcrumb, h1, meta row | +| `docs_main_close.php` | Closes `
`, prev/next nav, right TOC, closes layout div | +| `docs_footer.php` | Global footer bar, hljs init, closes `` | +| `installation.php` | **Sample content-only page** — copy this as the template for every new page | + +--- + +## How to use + +With `DocsController`, each docs page should contain **content only**. +Do not render `docs_header`, `docs_sidebar`, `docs_main_open`, `docs_main_close`, +or `docs_footer` inside individual page views, because the controller already +wraps the page with the full layout. + +Each docs page should look like this: + +```php + + +

...

+

Section One

+

...

+``` + +--- + +## Adding a new page to the sidebar + +Open `app/Controllers/Docs/DocsController.php` and update: + +1. The `$nav` array to add a sidebar link. +2. The `$pages` array to map the slug to its view and metadata. + +Example: + +```php +['id' => 'my-new-page', 'label' => 'My New Page', 'url' => 'docs/my-new-page'], + +'my-new-page' => [ + 'view' => 'docs/my-new-page', + 'title' => 'My New Page', + 'breadcrumb' => 'Getting Started', + 'last_updated' => 'May 2026', + 'author' => 'Core Team', + 'read_time' => '3 min read', + 'toc' => [ + ['label' => 'Section One', 'href' => '#section-one'], + ], + 'prev' => null, + 'next' => null, +], +``` + +Then create `app/Views/docs/my-new-page.php` by copying `installation.php` +and updating only the page content. + +--- + +## Available content components + +All CSS is in `docs_header.php`. These classes are ready to use in any page: + +| Class / Element | What it renders | +|---------------------|----------------------------------------| +| `

` `

` | Section headings (id required for TOC scroll) | +| `.callout.info` | Blue info box | +| `.callout.warning` | Amber warning box | +| `.callout.danger` | Red danger box | +| `.callout.success` | Green success box | +| `
    ` | Numbered step list with connector lines | +| `` | Styled data / param / API tables | +| `.badge.get/post/put/delete` | HTTP method badges | +| `.badge.req` / `.badge.opt` | Required / Optional param badges | +| `.param-name` | Monospace blue param name in tables | +| `.code-header` + `
    ` | Dark code block with filename header |
    +
    +---
    +
    +## Route setup (CI4)
    +
    +Add a catch-all route in `app/Config/Routes.php`:
    +
    +```php
    +$routes->get('docs',          'Docs\DocsController::index');
    +$routes->get('docs/(:segment)', 'Docs\DocsController::page/$1');
    +```
    +
    +Then in `DocsController`, let the controller render the content view and wrap it:
    +
    +```php
    +public function page(string $slug): string
    +{
    +    $config = $this->getPageConfig($slug);
    +    $content = $this->renderContentView($config['view'], $config);
    +
    +    return $this->renderDocPage($config, $content);
    +}
    +```
    diff --git a/app/Views/docs/acl.php b/app/Views/docs/acl.php
    new file mode 100644
    index 00000000..616883d5
    --- /dev/null
    +++ b/app/Views/docs/acl.php
    @@ -0,0 +1,480 @@
    +
    +
    +

    + Route-level access control is handled by Config\Acl plus the + global AclFilter. Developers should treat this as the single + source of truth for web ACL decisions: a normalized request path is matched + against ordered regex rules, then the user is allowed by public flag, role, or + team membership. Everything else is denied. +

    + +
    + i +
    + Core files + ACL rules live in app/Config/Acl.php. Enforcement lives in + app/Filters/AclFilter.php. Global activation is configured in + app/Config/Filters.php. +
    +
    + +

    Overview

    + +
    +
    +flowchart TD + A[Incoming web request] --> B[ACL filter runs] + B --> C[Bypass CLI] + C --> D[Normalize request path] + D --> E[Load ordered ACL rules] + E --> F[Find first matching regex] + F --> G{Public route} + G -->|Yes| H[Allow] + G -->|No| I{Logged in} + I -->|No| J[401 JSON or logout redirect] + I -->|Yes| K[Read role and team context] + K --> L{Role allowed} + L -->|Yes| H + L -->|No| M{Team allowed} + M -->|Yes| H + M -->|No| N[403 deny] +
    +
    + +

    Where it is wired

    + +

    + AclFilter is registered as an alias and applied in the global + before filter stack, with a route exception list. +

    + +
    'AclFilter' => AclFilter::class,
    +
    +'before' => [
    +    'AclFilter' => ['except' => [
    +        'login',
    +        'logout',
    +        'auth/*',
    +        'oauth2callback',
    +        'claim-form-download',
    +        'claims-feedback-form',
    +        'autobookstackLogin',
    +        'employeeRest/*',
    +        'processjob',
    +        'getCommission',
    +        'downloadEmployeeEcardZip',
    +        'downloadClaimFile/*',
    +        'api/v1/*'
    +    ]],
    +]
    + +

    + That means ACL is primarily enforcing browser/MVC routes. Several public, + webhook, CLI, and API-style paths are intentionally excluded from the global + filter and managed elsewhere. +

    + +

    Rule format

    + +

    + Each ACL rule in Config\Acl::$rules uses a regex pattern as the + key and a rule definition array as the value. +

    + +
    + + + + + + + + + + + + + + + + + +
    KeyMeaning
    publicIf truthy, the route is allowed without session, role, or team checks.
    rolesList of allowed role IDs, typically using constants like ADMIN_ROLE_ID.
    teamsList of allowed team IDs, typically using constants like CLAIMS_TEAM_ID.
    + +
    '#^/client#' => [
    +    'roles' => [HEAD_ROLE_ID, ADMIN_ROLE_ID, MANAGER_ROLE_ID, ACCOUNT_MANAGER_ROLE_ID],
    +    'teams' => []
    +],
    +
    +'#^/claims-feedback-form#' => ['public' => true],
    + +

    + Regex patterns are matched against normalized paths such as + /dashboard/view, /client/list, or + /ticket/view/123. +

    + +

    Matching behavior

    + +

    + Matching is ordered and strict: +

    + +
      +
    1. + The filter normalizes the path +

      It removes the base application path and strips /index.php if present.

      +
    2. +
    3. + Rules are evaluated top to bottom +

      The filter loops through $rules and stops on the first regex match.

      +
    4. +
    5. + First match wins +

      Later rules are ignored once an earlier pattern matches.

      +
    6. +
    7. + No match means deny +

      If nothing matches, the request is blocked immediately.

      +
    8. +
    + +
    + ! +
    + Order is critical + Place more specific patterns before broad prefixes. A broad rule like + #^/client# will swallow more specific client routes if it appears + earlier and already matches what you need. +
    +
    + +

    + The config also ends with a zero-trust fallback: +

    + +
    '#^/#' => [
    +    'roles' => [ADMIN_ROLE_ID, HEAD_ROLE_ID],
    +    'teams' => []
    +],
    + +

    + That default rule makes unmatched routes deny by default unless explicitly + opened earlier. +

    + +

    + In practice, pattern matching works like this: +

    + + + + + + + + + + + + + + + + + + + + + + +
    PatternMatchesDoes not match
    #^/client#/client, /client/list, /client/create/api/client
    #^/client/special-report#/client/special-report, /client/special-report/view/client/list
    #^/download-#/download-e-card/123, /download-kyc-docs/abc/client/download
    + +
    + i +
    + How to think about it + The filter does not look at controller names or route groups. It only checks + the normalized request path string against the regex keys in + Config\Acl::$rules. +
    +
    + +

    Auth context

    + +

    + AclFilter relies on session helper functions for the current user + context: +

    + + + + + + + + + + + + + + + + + + + +
    HelperExpected result
    check_session()Returns true when the session contains isLoggedIn === true.
    check_role()Returns the current user's role ID from get_session_userdata()->role.
    user_team()Returns an array of current team IDs from the session key user_team.
    + +
    $userRole  = check_role();
    +$userTeams = user_team();
    + +

    + For developers, this means ACL correctness depends on login/session setup + putting the right role and team data into session. +

    + +

    Allow and deny flow

    + +

    + The allow sequence is: +

    + +
      +
    1. + Public route check +

      If the matched rule has public, access is allowed immediately.

      +
    2. +
    3. + Authentication check +

      If the route is not public and the session is missing, the filter returns either a JSON 401 or a web logout/redirect flow.

      +
    4. +
    5. + Role-first authorization +

      If the user's role ID is in roles, access is allowed.

      +
    6. +
    7. + Team fallback authorization +

      If no role matched but any current team ID is in teams, access is allowed.

      +
    8. +
    9. + Deny otherwise +

      The filter logs the block and returns a 403 response.

      +
    10. +
    + + + + + + + + + + + + + + + +
    Request typeDeny behavior
    AJAX / API / /employeeRestJSON error response with status 401 or 403.
    Normal web request403 page rendered through errors/403, or logout redirect when session is missing.
    + +

    Developer steps

    + +

    + When adding or changing a route, use this exact checklist: +

    + +
      +
    1. + Decide whether the route should be public or protected +

      If it should be accessible without login, add a public ACL rule or confirm that it is intentionally excluded from the global filter.

      +
    2. +
    3. + Choose the correct path pattern +

      Write the regex against the normalized route path, not the full server URL and not a filesystem path.

      +
    4. +
    5. + Add the ACL rule in the right order +

      Insert the new rule in app/Config/Acl.php before any broader pattern that would match first.

      +
    6. +
    7. + Prefer role rules first, team rules second +

      If a route belongs to a business function, define the required role IDs and then optionally add team IDs for fallback access.

      +
    8. +
    9. + Check whether the route is excluded in Filters.php +

      If it is listed in the ACL exception list, your new ACL rule will never run until the exception is removed.

      +
    10. +
    11. + Test both success and failure paths +

      Verify access with an allowed user, a disallowed user, and an unauthenticated request.

      +
    12. +
    + +

    + For a brand-new route, the safest developer workflow is: +

    + +
      +
    1. + Create or confirm the route path first +

      Know the actual URL path that the browser will hit, for example /reports/monthly.

      +
    2. +
    3. + Write the narrowest ACL regex that covers exactly that area +

      If only one route needs different access, do not start with a broad prefix rule.

      +
    4. +
    5. + Place the new rule above any broader parent rule +

      A specific child path must appear before its parent path if they need different access.

      +
    6. +
    7. + Choose whether access is role-based, team-based, or public +

      Prefer explicit roles. Use teams as fallback or business-group access where appropriate.

      +
    8. +
    9. + Check the global ACL exception list +

      If the route is bypassed in Filters.php, adding a rule in Acl.php alone will not protect it.

      +
    10. +
    11. + Test the final path, not just the config +

      Open the actual route in browser or hit it through the expected frontend flow with different user profiles.

      +
    12. +
    + +

    Examples

    + +

    + Example 1: add a protected MVC section for a new module: +

    + +
    '#^/reports#' => [
    +    'roles' => [HEAD_ROLE_ID, ADMIN_ROLE_ID, MANAGER_ROLE_ID],
    +    'teams' => [FINANCE_TEAM_ID]
    +],
    + +

    + Example 2: add a public callback route: +

    + +
    '#^/external-callback#' => ['public' => true],
    + +

    + Example 3: protect a narrow route before a broad one: +

    + +
    '#^/client/special-report#' => [
    +    'roles' => [ADMIN_ROLE_ID],
    +    'teams' => []
    +],
    +
    +'#^/client#' => [
    +    'roles' => [HEAD_ROLE_ID, ADMIN_ROLE_ID, MANAGER_ROLE_ID, ACCOUNT_MANAGER_ROLE_ID],
    +    'teams' => []
    +],
    + +

    + Example 4: add a new route safely without breaking an existing broad rule: +

    + +
    // New route to add: /master/export-audit
    +
    +'#^/master/export-audit#' => [
    +    'roles' => [ADMIN_ROLE_ID, HEAD_ROLE_ID],
    +    'teams' => []
    +],
    +
    +'#^/master#' => [
    +    'roles' => [HEAD_ROLE_ID, ADMIN_ROLE_ID, MANAGER_ROLE_ID, ACCOUNT_MANAGER_ROLE_ID],
    +    'teams' => []
    +],
    + +

    + The specific /master/export-audit rule must stay above the broader + /master rule, otherwise the broad rule will match first and the + special restriction will never apply. +

    + +

    Do and don’t

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    DoDon’t
    Write rules against normalized URL paths like /client/list.Do not write ACL rules against controller class names or filesystem paths.
    Put specific patterns before broad patterns.Do not place #^/client# above a more specific child route that needs different access.
    Check Filters.php exceptions before assuming ACL applies.Do not assume a new ACL rule is active if the route is globally excluded.
    Use public only for routes that truly must bypass auth.Do not mark internal routes public just to “make it work”.
    Test with allowed, denied, and logged-out users.Do not test only as admin and assume the ACL is correct.
    Keep the fallback deny model intact.Do not weaken the final catch-all rule unless you fully understand the impact.
    + +

    Common pitfalls

    + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    PitfallWhy it happens
    ACL rule added but never usedThe route is still listed in the ACL filter except list.
    Specific rule appears correct but never matchesA broader earlier regex already matched first.
    Team access does not workuser_team() must return an array of team IDs in session.
    Unexpected 403 on web routesNo matching rule, wrong ordering, wrong regex, or missing role/team data in session.
    Unexpected JSON 403/401The request is AJAX or under an API-style prefix, so the filter returns JSON instead of a web page.
    + +
    + + +
    + Practical rule for developers + Whenever you add a new route, treat ACL as part of the feature definition. + Add or verify the route rule, confirm it is not bypassed by filter + exceptions, and test it with the real role/team combinations expected in + production. +
    +
    diff --git a/app/Views/docs/api-rate-limiter.php b/app/Views/docs/api-rate-limiter.php new file mode 100644 index 00000000..2ce16adb --- /dev/null +++ b/app/Views/docs/api-rate-limiter.php @@ -0,0 +1,520 @@ + + +

    + The API rate limiter combines a shared RateLimiterService with two route filters: + one for unauthenticated auth-style endpoints (email / mobile in the request body or query), and + one for JWT-protected APIs. Both enforce IP-level throttling and progressive blocks, and tie + additional counters to a resolved user identity when one is available. +

    + +

    Overview

    + +

    + A client fingerprint is built with generateFingerprint(exclude_ua: true), so the + IP-level bucket is keyed primarily by client IP (not the full user-agent string). The service + stores counters and block metadata in the application cache (Config\Services::cache()). +

    + +
    +
    +flowchart TD + A[Incoming request] --> B{Which filter} + B -->|Auth API routes| C[AuthApiRateLimitFilter] + B -->|JWT API routes| D[JwtApiRateLimitFilter] + C --> E[RateLimiterService] + D --> E + E --> F{IP allowed} + F -->|No| G[JSON error response] + F -->|Yes| H{User checks} + H -->|Blocked / throttled| G + H -->|Pass| I[Controller runs] + I --> J{Response status} + J -->|4xx except 429/403/451| K[Record IP + user failures] + J -->|Other| L[No extra recording] +
    +
    + +

    Core service

    + +

    + App\Libraries\RateLimiterService reads limits from app/Config/RateLimiter.php. + It separates IP behaviour (shared $config->ipBlock) from + user behaviour (shared $config->userBlock for block durations and + escalation), while per-route-type windows and limits use either $jwtApi or + $authApi depending on the string passed from the filter (jwtApi vs + authApi). +

    + +

    IP level

    +
      +
    • checkIp($fingerprint, $routeType) — if the IP is already blocked, returns a block payload and may escalate soft → medium → hard when additional requests hit while blocked.
    • +
    • Otherwise increments a sliding-window request counter; exceeding the limit increments an IP + violation counter. Enough violations apply a soft IP block; a single-window overrun without + reaching the block threshold returns a throttle message (HTTP 429) without yet blocking.
    • +
    • recordIpFailure($fingerprint) — increments the same violation path (used from + filter after() on failed controller responses).
    • +
    + +

    User level

    +
      +
    • Identities are normalized for storage keys with hash('sha256', strtolower(trim($identity))).
    • +
    • checkUser($identity) — returns a block response if that identity is already blocked.
    • +
    • checkUserThrottle($identity, 'jwtApi') — used on JWT routes: block check first, then + a per-user request counter in a time window (same violation → soft-block pattern as IP).
    • +
    • recordUserFailure($identity, $routeType) — increments user violations when the + controller returns a failure; thresholds use the route-type config (authApi or + jwtApi).
    • +
    • Progressive blocks (soft, medium, hard) can escalate when the client keeps hitting endpoints + while already blocked; durations come from userBlock / ipBlock (zero + duration is treated as long-lived until manual unblock).
    • +
    + +

    + Manual operations exposed on the service include blockIp, unblockIp, + blockUser, and unblockUser, which clear the relevant cache keys for + counters, violations, and block records. +

    + +

    Auth API filter

    + +

    + App\Filters\AuthApiRateLimitFilter targets routes that do not rely on + JWT (for example mobile verification or OTP flows). Identity is resolved from POST fields first, + then GET: email (lower-cased) or mobile_number (trimmed). +

    + +
      +
    • before: checkIp($fingerprint, 'authApi'), then if identity exists, + checkUser($identity) only (no per-user request throttle before the controller).
    • +
    • after: On HTTP status ≥ 400, excluding 429, 403, and 451, records + recordIpFailure and recordUserFailure($identity, 'authApi') when identity + was stashed or can still be resolved — so failed logins or bad OTP attempts feed the violation + counters.
    • +
    • Throttle/block JSON responses run the global Cors filter’s after() + handler so CORS headers stay consistent on early exits.
    • +
    + +

    JWT API filter

    + +

    + The class JwtApiRateLimitFilter lives in app/Filters/JwtApiFilter.php. + It resolves identity from getEmailFromJWT() or getMobileFromJWT() when those + helpers exist; invalid JWTs are caught and the request falls back to IP-only limiting. +

    + +
      +
    • before: checkIp($fingerprint, 'jwtApi'), then + checkUserThrottle($identity, 'jwtApi') when identity is known.
    • +
    • after: On controller failures (4xx except 429, 403, 451), records IP failure and + recordUserFailure($identity, 'jwtApi') for the JWT identity when available.
    • +
    + +

    Configuration

    + +

    + Defaults in app/Config/RateLimiter.php (adjust per environment as needed): +

    + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    KeyMeaning (current defaults)
    jwtApi60 requests per 60 seconds per user identity; 3 violations before a soft user block.
    authApi10 requests per 180 seconds at the IP bucket for auth routes; 3 user violations (from failed + responses) before a soft user block.
    ipBlock120 requests per 60 seconds per fingerprint; 5 violations before soft IP block; medium/hard + durations and triggers for escalation while blocked.
    userBlock / ipBlock durationsSoft can be stored as long TTL when duration is 0; medium 2 hours; hard 24 hours (see config).
    statusCodesThrottle and soft blocks use 429; medium 403; hard 451.
    + +

    How to change block count and duration

    + +

    + All tuning happens in app/Config/RateLimiter.php. No filter code changes are needed + for normal policy updates. Edit values, deploy, and clear cache if your backend keeps old keys. +

    + +

    What controls what

    +
      +
    • jwtApi.limit and jwtApi.window: per-user request throttle for JWT APIs.
    • +
    • authApi.limit and authApi.window: auth-route request throttle window used by + IP checks in the auth filter flow.
    • +
    • jwtApi.violation_soft / authApi.violation_soft: number of recorded violations + before applying a soft user block.
    • +
    • ipBlock.limit and ipBlock.window: global per-fingerprint request throttle.
    • +
    • ipBlock.violation_soft: over-limit events before soft IP block.
    • +
    • userBlock.soft_duration, medium_duration, hard_duration: + user-block durations in seconds.
    • +
    • ipBlock.soft_duration, medium_duration, hard_duration: + IP-block durations in seconds.
    • +
    • userBlock.medium_trigger / hard_trigger and equivalent in + ipBlock: attempts while already blocked that escalate level.
    • +
    + +

    Duration conversion quick reference

    +
    300   = 5 minutes
    +900   = 15 minutes
    +1800  = 30 minutes
    +3600  = 1 hour
    +7200  = 2 hours
    +86400 = 24 hours
    +0     = permanent-style block (manual unblock expected)
    + +

    Sample 1: Strict production policy

    +
    public array $jwtApi = [
    +    'limit'          => 45,
    +    'window'         => 60,
    +    'violation_soft' => 2,
    +];
    +
    +public array $authApi = [
    +    'limit'          => 8,
    +    'window'         => 180,
    +    'violation_soft' => 2,
    +];
    +
    +public array $userBlock = [
    +    'soft_duration'   => 1800,   // 30 min
    +    'medium_duration' => 7200,   // 2 hours
    +    'hard_duration'   => 86400,  // 24 hours
    +    'medium_trigger'  => 1,
    +    'hard_trigger'    => 1,
    +];
    +
    +public array $ipBlock = [
    +    'limit'           => 100,
    +    'window'          => 60,
    +    'violation_soft'  => 4,
    +    'soft_duration'   => 1800,
    +    'medium_duration' => 7200,
    +    'hard_duration'   => 86400,
    +    'medium_trigger'  => 1,
    +    'hard_trigger'    => 1,
    +];
    + +

    Sample 2: Balanced default-like policy

    +
    public array $jwtApi = [
    +    'limit'          => 60,
    +    'window'         => 60,
    +    'violation_soft' => 3,
    +];
    +
    +public array $authApi = [
    +    'limit'          => 10,
    +    'window'         => 180,
    +    'violation_soft' => 3,
    +];
    +
    +public array $userBlock = [
    +    'soft_duration'   => 900,    // 15 min
    +    'medium_duration' => 3600,   // 1 hour
    +    'hard_duration'   => 86400,  // 24 hours
    +    'medium_trigger'  => 2,
    +    'hard_trigger'    => 2,
    +];
    + +

    Sample 3: Dev / QA friendly policy

    +
    public array $jwtApi = [
    +    'limit'          => 200,
    +    'window'         => 60,
    +    'violation_soft' => 20,
    +];
    +
    +public array $authApi = [
    +    'limit'          => 40,
    +    'window'         => 180,
    +    'violation_soft' => 10,
    +];
    +
    +public array $userBlock = [
    +    'soft_duration'   => 60,     // 1 min
    +    'medium_duration' => 300,    // 5 min
    +    'hard_duration'   => 900,    // 15 min
    +    'medium_trigger'  => 5,
    +    'hard_trigger'    => 5,
    +];
    +
    +public array $ipBlock = [
    +    'limit'           => 300,
    +    'window'          => 60,
    +    'violation_soft'  => 30,
    +    'soft_duration'   => 60,
    +    'medium_duration' => 300,
    +    'hard_duration'   => 900,
    +    'medium_trigger'  => 5,
    +    'hard_trigger'    => 5,
    +];
    + +

    Change workflow (safe rollout)

    +
      +
    1. Copy current values from RateLimiter.php to your release notes for rollback.
    2. +
    3. Change one policy group at a time (for example JWT first, then auth).
    4. +
    5. Deploy and clear cache keys if required by your cache backend strategy.
    6. +
    7. Monitor 429/403/451 counts and support tickets for 24-48 hours.
    8. +
    9. Adjust violation_soft and durations gradually, not in large jumps.
    10. +
    + +
    + ! +
    + Important: + In this implementation, a duration of 0 is treated as long-lived and practically + permanent until manual unblock via unblockIp() or unblockUser(). +
    +
    + +

    Manual unblock samples

    + +

    + Use unblockUser() and unblockIp() when support confirms a genuine user was + blocked by policy. Keep unblock actions auditable (who unblocked, why, and when). +

    + +

    Sample: controller/admin action

    +
    <?php
    +
    +namespace App\Controllers\Admin;
    +
    +use App\Controllers\BaseController;
    +use App\Libraries\RateLimiterService;
    +
    +class SecurityController extends BaseController
    +{
    +    public function unblockRateLimitedUser(): \CodeIgniter\HTTP\ResponseInterface
    +    {
    +        $identity = trim((string) $this->request->getPost('identity'));
    +        if ($identity === '') {
    +            return $this->response->setStatusCode(422)->setJSON([
    +                'success' => false,
    +                'message' => 'identity is required',
    +            ]);
    +        }
    +
    +        $limiter = new RateLimiterService();
    +        $limiter->unblockUser($identity);
    +
    +        return $this->response->setJSON([
    +            'success'  => true,
    +            'identity' => $identity,
    +            'message'  => 'User rate-limit state cleared',
    +        ]);
    +    }
    +}
    + +

    Sample: CLI / one-off script logic

    +
    $limiter = new \App\Libraries\RateLimiterService();
    +$identity = 'user@example.com';
    +$fingerprint = 'known-fingerprint-key';
    +
    +$limiter->unblockUser($identity);
    +$limiter->unblockIp($fingerprint);
    + +

    + If your support team only has an email/mobile, unblock user first. IP unblock should be done more + carefully because multiple users may share an IP (office NAT, VPN, mobile carrier). +

    + +

    Unblock SOP (short checklist)

    +
      +
    1. Verify requester: confirm account identity (email/mobile/user ID) from ticket context.
    2. +
    3. Check scope: determine whether block is user-level, IP-level, or both.
    4. +
    5. Apply least-risk fix: run unblockUser() first; use unblockIp() only if still blocked and justified.
    6. +
    7. Audit it: record ticket ID, operator, timestamp, action taken, and reason.
    8. +
    9. Watch rebound: monitor logs/metrics for quick re-block; escalate if abuse pattern continues.
    10. +
    + +

    Cache TTL and auto-release

    + +

    + Runtime enforcement of a block is whether the block payload exists in the application cache + (RateLimiterService::blockIp() / blockUser() call + $this->cache->save(..., $ttl)). When $ttl is a positive number of seconds + (medium and hard levels in app/Config/RateLimiter.php), the entry expires after that + period. The next cache->get() no longer returns block data, so the client is no longer + blocked for API checks (throttle and violation keys use their own TTLs). +

    + +

    + With the default file cache handler (app/Config/Cache.php), + CodeIgniter’s FileHandler treats an item as expired when + now > stored_time + ttl; on read it removes the file and returns empty, so behaviour + matches a timed release without a separate unlock job. +

    + +

    + When a level’s configured duration is 0 (for example soft blocks in the stock config), + the service stores a very long TTL (effectively manual unblock). Those rows are not “timed” blocks + in the operational sense. +

    + +

    DB reconciliation (cron)

    + +

    + Active blocks are also upserted into the rate_limit_blocks table for admin visibility + (/security/rate-limits). Cache entries for medium/hard can disappear on TTL while the + database row stays status = active until something cleans it up. A scheduled job keeps + the index aligned with real enforcement and clears any leftover cache keys. +

    + +

    + Spark command (implementation: app/Commands/RateLimitBlocksReconcile.php): +

    + +
    php spark rate-limit:reconcile-blocks --dry-run
    +php spark rate-limit:reconcile-blocks
    + +

    + Behaviour summary: +

    + +
      +
    • Selects rows where status = 'active'.
    • +
    • Computes expiry as blocked_at + duration(block_level) using the same duration fields + as RateLimiter (userBlock vs ipBlock depending on + block_type). Rows whose duration is <= 0 are skipped so permanent-style + soft blocks are not removed by the job.
    • +
    • If the row is past that time: calls RateLimiterService::purgeIpBlockCaches() or + purgeUserBlockCaches() (same cache keys as manual unblock, without updating the DB + row first), then deletes the row from rate_limit_blocks.
    • +
    • --dry-run prints what would be reconciled without changing cache or the database.
    • +
    + +

    + Example cron (every 10 minutes on Linux): +

    + +
    */10 * * * * cd /path/to/nhance && php spark rate-limit:reconcile-blocks >> /path/to/logs/rate-limit-reconcile.log 2>&1
    + +

    + On Windows, use Task Scheduler with the same command, set “Start in” to the project directory, and a + 10-minute trigger. Deleting reconciled rows removes them from the admin list; if you need a full audit + trail instead, consider changing the job to mark unblocked rather than delete (not the + current implementation). +

    + +
    + ! +
    + Config changes: expiry for reconciliation uses current + RateLimiter values, not the numbers that were in effect when the block was created. + If you shorten durations in config, old rows can become eligible sooner than the original policy. +
    +
    + +

    HTTP responses

    + +

    + When the service returns a structured result, filters respond with JSON of the form: +

    + +
    {
    +  "success": false,
    +  "error": {
    +    "code": "RATE_LIMIT_THROTTLE",
    +    "message": "Too many requests. Please slow down.",
    +    "type": "ip"
    +  }
    +}
    + +

    + For progressive blocks, code uses RATE_LIMIT_ plus the level + (SOFT, MEDIUM, HARD), and type is + ip or user. Messages for blocks are defined in + RateLimiterService::blockedResponse(). +

    + +

    Wiring routes

    + +

    + Aliases in app/Config/Filters.php: +

    + +
    'AuthApiRateLimitFilter' => AuthApiRateLimitFilter::class,
    +'JwtApiRateLimitFilter'  => JwtApiRateLimitFilter::class,
    + +

    + Attach them per route (or route group) with the filter option, for example: +

    + +
    $routes->post('api/auth/verify-otp', 'AuthController::verifyOtp', ['filter' => 'AuthApiRateLimitFilter']);
    +$routes->get('api/profile', 'ProfileController::index', ['filter' => 'JwtApiRateLimitFilter']);
    + +

    + Ensure JWT helpers used by JwtApiRateLimitFilter match your authentication stack; the + filter comments note replacing helper names if your project uses different entry points. +

    + +

    Blocked list URL

    + +

    + Admin can view active blocked IP and blocked user entries at: +

    + +
    /security/rate-limits
    + +

    + This page has two tabs (IP and User), shows block level, and provides unblock action per row. + It is protected by ACL and restricted to ADMIN_ROLE_ID only. +

    + +

    + Direct actions on the same feature: +

    + +
    POST /security/rate-limits/unblock-ip
    +POST /security/rate-limits/unblock-user
    + +

    Smoke test command

    + +

    + The project includes a targeted smoke test for this service at + tests/unit/RateLimiterServiceSmokeTest.php. Run it with: +

    + +
    php vendor/bin/phpunit --filter RateLimiterServiceSmokeTest
    + +

    + Expected result on success: OK (3 tests, 77 assertions) (assertion count can change as + tests evolve). +

    + +

    Operational notes

    + +
      +
    • Cache backend choice (Redis, file, etc.) affects how limits behave across multiple PHP workers; + use a shared store in production so limits are cluster-wide.
    • +
    • Timed medium/hard blocks clear from cache automatically when TTL elapses; see + Cache TTL and auto-release. Permanent-style blocks + (0 second duration, stored as a long TTL) still require + unblockIp / unblockUser or admin unblock.
    • +
    • Run DB reconciliation (cron) on a schedule if you want + rate_limit_blocks rows removed after timed blocks end, and stray cache files cleared.
    • +
    • Filters skip recording failures on 429, 403, and 451 so rate-limit and block responses are not + double-counted as application failures.
    • +
    diff --git a/app/Views/docs/background-jobs.php b/app/Views/docs/background-jobs.php new file mode 100644 index 00000000..ed89576a --- /dev/null +++ b/app/Views/docs/background-jobs.php @@ -0,0 +1,566 @@ + + +

    + Nhance uses a database-backed job queue for long-running or deferred work. A + producer adds a row into the jobs table through + Jobs::addJob(), and the CLI worker in + JobWorker picks up queued rows, executes the mapped handler, and + writes the final status and response back to the same record. +

    + +
    + i +
    + Current implementation + This page describes the queue exactly as it exists today, including the + handler registry in JobWorker::$event_class_mapping and the + helper methods already used by controllers like EmployeeController + and LeadsController. +
    +
    + +

    Overview

    + +

    + The queue flow is simple: enqueue, store, process, update. It is used for + tasks such as file validation, employee processing, claim import work, API + sync jobs, bulk mail, and batch e-card generation. +

    + +
    +
    +flowchart TD + A[Controller or service] --> B[Jobs::addJob] + B --> C[(jobs table)] + C --> D[php spark cli/processjobs] + D --> E[JobWorker::processJobs] + E --> F[JobWorker::processJob] + F --> G[event_class_mapping lookup] + G --> H[Handler method or function] + H --> I[Update status, run_time, response] +
    +
    + +

    Queueing jobs

    + +

    + New jobs are inserted through Jobs::addJob(array $payload). The + method validates the input, generates a UUID, JSON-encodes the inner payload, + and stores the row with status queued unless a custom status is + provided. +

    + + + + + + + + + + + + + + + + + + + + + + +
    KeyRequiredDescription
    job_namerequiredName used to look up the handler in JobWorker::$event_class_mapping.
    payloadrequiredArray that will be JSON-encoded into the jobs.payload column.
    statusoptionalDefaults to queued.
    + +
    $job = Jobs::addJob([
    +    'job_name' => 'memberDataListExcelFileFormatValidation',
    +    'payload'  => [
    +        'lead_id' => $lead_id,
    +        'age_validation' => true,
    +    ],
    +]);
    +
    + +

    + The method returns an array with id, uuid, and + job_name. Real code paths already use this pattern, for example + after lead placement data is saved and then queued for validation. +

    + +

    Worker lifecycle

    + +

    + JobWorker is the execution engine. It defines four statuses: + queued, running, done, and + failed. +

    + +
      +
    1. + processJobs() fetches all queued rows +

      Jobs are selected from jobs ordered by created_dt ASC.

      +
    2. +
    3. + Each queued row is forwarded to processJob() +

      The worker can process the next queued row, or a specific id plus uuid pair.

      +
    4. +
    5. + Status changes to running +

      Once picked, the worker updates the row before invoking the handler.

      +
    6. +
    7. + The handler receives the decoded payload +

      The worker resolves the handler from the event map and passes the job payload into it.

      +
    8. +
    9. + The row is finalized +

      After execution, the worker stores the final status, runtime, and response JSON back into the same job row.

      +
    10. +
    + +
    + ! +
    + Selection and locking + The worker query uses LIMIT 1 FOR UPDATE when fetching a single + job. In practice, treat the queue as database-backed and process it from CLI + workers, not from normal web requests. +
    +
    + +

    Handler registry

    + +

    + Every executable job must be registered in + JobWorker::$event_class_mapping. Each entry defines a handler + category and a target class or function. +

    + + + + + + + + + + + + + + + + + + + + + + +
    TypeMeaningExample from code
    CCController class handlerApp\Controllers\Jobs\SubJob, EmployeeServiceController
    HCHelper-style class handlerApp\Helpers\HttpRequestHelper, App\Helpers\MailHelper
    HFStandalone function handlerfancy_date_time_format
    + +

    + Resolution order inside the worker is: +

    + +
      +
    1. + Look up the job name in the mapping +

      If the name is missing, the worker throws an exception and marks the job failed.

      +
    2. +
    3. + Instantiate the mapped class for CC or HC +

      The mapped class must exist.

      +
    4. +
    5. + Choose the callable +

      If a method matching the job name exists, it is used first; otherwise the worker falls back to handle().

      +
    6. +
    7. + For HF, call the mapped function directly +

      The handler value itself is treated as the callable.

      +
    8. +
    + +

    Sample handlers

    + +

    + The app/Controllers/Jobs/ directory currently contains two simple + examples that show the expected pattern for small job classes: +

    + + + + + + + + + + + + + + + + + +
    FileMethodBehavior
    app/Controllers/Jobs/AddJob.phphandle($payload)Returns $payload['a'] + $payload['b'].
    app/Controllers/Jobs/SubJob.phphandle($payload)Logs through mylogger and returns $payload['a'] - $payload['b'].
    + +
    namespace App\Controllers\Jobs;
    +
    +use App\Controllers\PublicController;
    +
    +class ExampleJob extends PublicController
    +{
    +    public function handle($payload)
    +    {
    +        return [
    +            'ok' => true,
    +            'received' => $payload,
    +        ];
    +    }
    +}
    +
    + +

    Status lifecycle

    + +

    + The queue storage model is app/Models/JobModel.php, which maps to + the jobs table and allows these main fields: +

    + + + + + + + + + + + + + + +
    ColumnPurpose
    idPrimary key returned after enqueue.
    nameLogical job name used in the worker registry.
    payloadJSON-encoded input payload.
    responseJSON-encoded handler output or failure details.
    statusqueued, running, done, or failed.
    run_timeMeasured execution time for the job.
    uuidGenerated at enqueue time and used when fetching a specific row.
    + +
    +
    +stateDiagram-v2 + [*] --> queued + queued --> running + running --> done + running --> failed +
    +
    + +

    Failure behavior

    + +

    + Both worker-level failures and handler-level failures are caught and written + back into jobs.response as structured JSON. The stored data + includes the error message, file, line, trace, and whether the failure + happened in the task or the worker wrapper. +

    + +
    + x +
    + Special file failure branch + When a failed job payload contains only a numeric file_id, the + worker also updates the related files row to + status = failed and writes a generic system error reason. This + is important for file-processing jobs that surface state back to the UI. +
    +
    + +

    Running via CLI

    + +

    + The worker is exposed through CLI routes in app/Config/Routes.php. +

    + + + + + + + + + + + + + + + + + +
    RouteTargetPurpose
    cli/processjobJobWorker::processJobProcess one queued job.
    cli/processjobsJobWorker::processJobsLoop through all queued jobs in created order.
    + +
    php spark cli/processjob
    +php spark cli/processjobs
    + +

    + There is also a web route named processjob, but operationally this + queue should be treated as a CLI worker flow. +

    + +

    Live runner script

    + +

    + For live environments where the worker should keep polling continuously, a + simple shell loop can call the queue worker across all required applications. + Use the root-level phpqueue.sh script and replace the placeholder + app paths with your deployment-specific values. +

    + +
    #!/bin/bash
    +
    +PHP_BIN="${PHP_BIN:-/usr/bin/php}"
    +
    +# Dummy example paths for documentation.
    +# Replace each one with the real public/index.php path in that environment.
    +APP_INDEXES=(
    +    "/var/www/example-suite/zenith-app/public/index.php"
    +    "/var/www/example-suite/enrolment-app/public/index.php"
    +    "/var/www/example-suite/partner-api/public/index.php"
    +)
    +
    +while true; do
    +    echo "Running job at $(date)"
    +
    +    for index_file in "${APP_INDEXES[@]}"; do
    +        "${PHP_BIN}" "${index_file}" cli/processjob
    +    done
    +
    +    sleep 1
    +done
    + + + + + + + + + + + + + + + + + + + +
    Dummy pathDescription
    /var/www/example-suite/zenith-app/public/index.phpExample public entry file for the first application queue target.
    /var/www/example-suite/enrolment-app/public/index.phpExample public entry file for the second application queue target.
    /var/www/example-suite/partner-api/public/index.phpExample public entry file for the third application queue target.
    + +
    + ! +
    + Production note + This script is an infinite loop, so it should be started under a process + manager such as systemd, supervisord, or another + service wrapper rather than being launched manually in a shell session. +
    +
    + +

    Systemd service

    + +

    + For Ubuntu-style live deployments, keep a systemd unit file such as + nhance_php_queue_server.service. The repository now includes a + sanitized template with placeholder values. +

    + +
    [Unit]
    +Description=Nhance PHP Queue Server
    +After=network.target
    +
    +[Service]
    +Type=simple
    +User=www-data
    +Group=www-data
    +WorkingDirectory=/var/www/example-suite/nhance-app
    +ExecStart=/bin/bash /var/www/example-suite/nhance-app/phpqueue.sh
    +Restart=always
    +RestartSec=2
    +
    +[Install]
    +WantedBy=multi-user.target
    + + + + + + + + + + + + + + + +
    Dummy valueDescription
    /var/www/example-suite/nhance-appExample project root where phpqueue.sh is kept.
    www-dataExample service account; replace it with the real Linux user and group used by PHP on that server.
    + +

    + After replacing the placeholders, deploy and enable it with standard systemd + commands: +

    + +
    sudo mv nhance_php_queue_server.service /etc/systemd/system/
    +sudo systemctl daemon-reload
    +sudo systemctl enable nhance_php_queue_server.service
    +sudo systemctl start nhance_php_queue_server.service
    +sudo systemctl restart nhance_php_queue_server.service
    + +

    Permissions setup

    + +

    + The queue runner script should live in the project root as + phpqueue.sh, and its ownership plus execute permission should be + set for the service user and group used by PHP in that environment. +

    + +
    sudo chown <service-user>:<service-group> phpqueue.sh
    +sudo chmod 750 phpqueue.sh
    + +
    + i +
    + Why this matters + In live environments, the two crucial steps are: + using a managed system service for the queue loop, and ensuring the + root-level phpqueue.sh file has the correct owner, group, and + execute permission for that service account. +
    +
    + +

    Checking status

    + +

    + app/Libraries/JobStatusService.php now supports lookup by job + name, by job id, and by job uuid. All methods normalize the + decoded response and runtime before returning them. +

    + + + + + + + + + + + + + + + + + + + +
    MethodUse when
    getJobStatusByName(string $jobName)You want the latest job row for a logical job name.
    getJobStatusById(int $jobId)You know the numeric queue row id and want that exact job record.
    getJobStatusByUuid(string $uuid)You want to track one exact job instance using the UUID returned at enqueue time.
    + +
    $service = new \App\Libraries\JobStatusService();
    +$status = $service->getJobStatusByName('bulkGenerateEcardAndStoreinS3');
    +
    + +
    $job = Jobs::addJob([
    +    'job_name' => 'exampleJob',
    +    'payload' => [
    +        'file_id' => 123,
    +    ],
    +]);
    +
    +$service = new \App\Libraries\JobStatusService();
    +$statusById = $service->getJobStatusById((int) $job['id']);
    +$statusByUuid = $service->getJobStatusByUuid($job['uuid']);
    +
    + +

    + The service returns success, job_name, + status, response, job_id, + uuid, run_time, and a message. +

    + +
    + i +
    + Which lookup should you use? + Use job name when you only care about the latest run for a given + handler. Use job id or uuid when the enqueue response + is available and you need to track one specific job instance across retries + or parallel runs. +
    +
    + +

    Adding a new handler

    + +
      +
    1. + Create the handler class or function +

      For small custom jobs, app/Controllers/Jobs/ is already used as a simple home for dedicated job handlers.

      +
    2. +
    3. + Register the job in JobWorker::$event_class_mapping +

      Pick the correct type: CC, HC, or HF.

      +
    4. +
    5. + Expose a callable method +

      The worker first looks for a method matching the job name, then falls back to handle().

      +
    6. +
    7. + Queue the job through Jobs::addJob() +

      Pass a stable job name and only the payload fields the handler actually needs.

      +
    8. +
    9. + Run the worker and inspect the job row +

      Validate the final status, runtime, and response before integrating the job into larger workflows.

      +
    10. +
    + +
    // 1. Register in JobWorker::$event_class_mapping
    +'exampleJob' => [
    +    'type' => 'CC',
    +    'handler' => 'App\Controllers\Jobs\ExampleJob',
    +],
    +
    +// 2. Queue it
    +Jobs::addJob([
    +    'job_name' => 'exampleJob',
    +    'payload' => [
    +        'file_id' => 123,
    +        'source' => 'manual-test',
    +    ],
    +]);
    +
    + +
    + + +
    + Practical rule + Keep payloads explicit and handler names stable. The job name is the contract + between producers and the worker registry, so renaming it has queue-wide + impact. +
    +
    diff --git a/app/Views/docs/cicd.php b/app/Views/docs/cicd.php new file mode 100644 index 00000000..5f8257b6 --- /dev/null +++ b/app/Views/docs/cicd.php @@ -0,0 +1,79 @@ + + +

    + The current CI/CD setup is split by environment. Dev uses webhook-driven + deployment and supporting asset publish scripts, while UAT and live use a + controlled branch-promotion plus server-side deployment flow. +

    + +

    Overview

    + +
    +
    +flowchart TD + A[Code pushed] --> B{Target environment} + B --> C[DEV flow] + B --> D[UAT/LIVE flow] + C --> E[Bitbucket webhook to cPanel] + E --> F[deploy.php] + F --> G[deploy.sh] + G --> H[Optional asset publish to S3 + CloudFront] + D --> I[auto_merge.py] + I --> J[Environment deployment scripts] +
    +
    + +

    Dev pipeline

    + +

    + In dev, deployment is handled by Bitbucket webhook integration with cPanel. + A custom PHP receiver triggers the deployment shell script, and the shell + script also sends a Cliq notification after the deployment result is known. +

    + +

    + For frontend or static asset publishing, the team also uses a separate + S3 + CloudFront batch workflow documented on the S3 & CloudFront + page. +

    + +

    UAT and live flow

    + +

    + For UAT and live, branch movement is not push-triggered. The standard order is + dev -> test -> uat -> live through auto_merge.py, + followed by the relevant environment deployment shell scripts on the server. +

    + +
    + ! +
    + Manual prerequisites still apply + DB changes, environment variable updates, and other environment-specific + release tasks must still be handled manually before the final deployment + scripts are executed. +
    +
    + + + + + + + + + + + + + + + + + +
    PageWhat it covers
    DeploymentcPanel dev deployment, branch promotion, merge helper, and UAT/live code move steps.
    S3 & CloudFrontDev batch script for S3 upload and CloudFront invalidation.
    diff --git a/app/Views/docs/deployment.php b/app/Views/docs/deployment.php new file mode 100644 index 00000000..e7ca2967 --- /dev/null +++ b/app/Views/docs/deployment.php @@ -0,0 +1,451 @@ + + +

    + Deployment currently has two documented paths. For the dev environment, code + is deployed in cPanel using a Bitbucket webhook plus deploy scripts. For UAT + and live preparation, the release flow first promotes code across the + long-lived Git branches in order: devtest → + uatlive using a server-side Python merge helper. +

    + +
    + i +
    + Server-side merge helper + The merge script is maintained on the deployment servers inside a + repo_merge folder under the server user's home directory. The + examples below use dummy paths only, so update them with the real values in + that environment. +
    +
    + +

    Overview

    + +

    + The deployment preparation flow has three main parts: +

    + +
      +
    1. + Dev environment auto deployment through cPanel +

      A Bitbucket push triggers a webhook, which is received by a custom PHP script and forwarded to a deployment shell script. The same shell script also sends a Cliq channel notification after deployment.

      +
    2. +
    3. + Promote branches using the merge helper +

      This ensures test, uat, and live receive the expected upstream code in order.

      +
    4. +
    5. + Run the environment deployment scripts +

      Once branch promotion completes successfully, call the UAT or live deployment shell scripts from the deployment server.

      +
    6. +
    + +

    Dev cPanel flow

    + +

    + In the dev environment, deployment is handled through cPanel rather than the + branch-promotion helper. The current flow is: +

    + +
    +
    +flowchart LR + A[Bitbucket push] --> B[Bitbucket webhook] + B --> C[deploy.php] + C --> D[deploy.sh] + D --> E[Application deployed] + D --> F[Cliq channel notification] +
    +
    + +

    + The webhook request is received by a custom PHP entry point, which triggers a + deployment shell script. That same shell script also posts deployment status + into Cliq using the Cliq channel message API. +

    + +
    + i +
    + Dev-only deployment path + This cPanel webhook flow is specifically used for the dev environment. It is + separate from the branch promotion process documented below for UAT and live + preparation. +
    +
    + +

    cPanel deploy scripts

    + +

    + The cPanel deployment scripts are maintained under the cPanel web root in the + following locations: +

    + + + + + + + + + + + + + + + +
    PathPurpose
    /public_html/cicd/bashscripts/deploy.phpReceives the Bitbucket webhook request and triggers the deployment shell script.
    /public_html/cicd/bashscripts/deploy.shExecutes the deployment steps and sends the final Cliq notification from the same script.
    + +

    + Operationally, the dev environment follows this chain: +

    + +
      +
    1. + Push code to Bitbucket +

      The push event becomes the trigger for the deployment automation.

      +
    2. +
    3. + Bitbucket sends the webhook request +

      The webhook hits the custom PHP receiver hosted in cPanel.

      +
    4. +
    5. + deploy.php validates and forwards the action +

      This script acts as the receiver and handoff point into the shell deployment layer.

      +
    6. +
    7. + deploy.sh performs deployment +

      The shell script runs the actual deployment commands for the dev environment.

      +
    8. +
    9. + Cliq notification is sent +

      The same shell script sends a channel update through the Cliq message API after deployment completes.

      +
    10. +
    + +

    Branch promotion flow

    + +

    + The merge script performs a sequential promotion chain: +

    + +
    +
    +flowchart LR + A[dev] --> B[test] + B --> C[uat] + C --> D[live] +
    +
    + +

    + The script first refreshes dev, then merges: +

    + + + + + + + + + + + +
    StepAction
    1git checkout dev and git pull origin dev
    2Merge dev into test
    3Merge test into uat
    4Merge uat into live
    + +
    + ! +
    + Important deployment gate + This branch promotion step should happen before live release execution. If + the script fails at any merge or push step, stop and resolve that issue + before continuing. +
    +
    + +

    Auto merge script

    + +

    + The deployment servers keep a Python helper called + auto_merge.py. Below is the current script for documentation and + future reference. +

    + +
    import subprocess
    +import sys
    +import datetime
    +import os
    +
    +# Function to execute shell commands in a specific directory
    +def run_command(command, log_file, repo_path):
    +    try:
    +        result = subprocess.run(command, shell=True, check=True, capture_output=True, text=True, cwd=repo_path)
    +        log_message = f"[SUCCESS] {command}\n{result.stdout.strip()}"
    +    except subprocess.CalledProcessError as e:
    +        log_message = f"[ERROR] {command}\n{e.stderr.strip() if e.stderr else e.stdout.strip()}"
    +        print(log_message, file=sys.stderr)
    +        log_file.write(log_message + "\n")
    +        sys.exit(1)
    +
    +    print(log_message)
    +    log_file.write(log_message + "\n")
    +
    +# Main function to automate merging
    +def auto_merge(repo_path, commit_message):
    +    if not os.path.isdir(repo_path):
    +        print(f"[ERROR] Invalid repository path: {repo_path}")
    +        sys.exit(1)
    +
    +    script_dir = os.getcwd()
    +    timestamp = datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
    +    log_filename = os.path.join(script_dir, f"merge_log_{timestamp}.txt")
    +
    +    with open(log_filename, "w") as log_file:
    +        log_file.write(f"=== Auto Merge Script Started at {timestamp} ===\n")
    +
    +        log_file.write("\n--- Pulling latest changes from dev ---\n")
    +        run_command("git checkout dev", log_file, repo_path)
    +        run_command("git pull origin dev", log_file, repo_path)
    +
    +        branches = [("dev", "test"), ("test", "uat"), ("uat", "live")]
    +
    +        for source, target in branches:
    +            merge_msg = f"MERGE_{target.upper()}_{commit_message}"
    +
    +            log_file.write(f"\n--- Merging {source} -> {target} ---\n")
    +
    +            run_command(f"git checkout {target}", log_file, repo_path)
    +            run_command(f"git pull origin {target}", log_file, repo_path)
    +            run_command(f"git merge --no-ff {source} -m \"{merge_msg}\"", log_file, repo_path)
    +            run_command(f"git push origin {target}", log_file, repo_path)
    +
    +        log_file.write("\n=== Auto Merge Completed Successfully ===\n")
    +
    +if __name__ == "__main__":
    +    if len(sys.argv) != 3:
    +        print("Usage: python auto_merge.py <REPO_PATH> '<COMMIT_MESSAGE_SUFFIX>'")
    +        print("Usage Example: python auto_merge.py './nhance' '<COMMIT_MESSAGE_SUFFIX>'")
    +        print("Usage Example: python auto_merge.py './nhance-enrollment' '<COMMIT_MESSAGE_SUFFIX>'")
    +        sys.exit(1)
    +
    +    repo_path = sys.argv[1].strip()
    +    commit_suffix = sys.argv[2].strip().upper()
    +
    +    auto_merge(repo_path, commit_suffix)
    +
    + +

    Script usage

    + +

    + The script is typically run from the server-side repo_merge + directory. Use a dummy home path like the example below and replace it with + the real deployment user home directory. +

    + + + + + + + + + + + + + + + + + + + +
    Dummy pathDescription
    /home/deploy-user/repo_mergeExample folder where auto_merge.py is stored on UAT or live.
    /home/deploy-user/nhanceExample repository checkout path passed as the first script argument.
    RELEASE_2026_05_12Example commit message suffix appended into merge commit messages.
    + +
    cd /home/deploy-user/repo_merge
    +python3 auto_merge.py "/home/deploy-user/nhance" "RELEASE_2026_05_12"
    + +

    + Usage format: +

    + +
    python3 auto_merge.py "<REPO_PATH>" "<COMMIT_MESSAGE_SUFFIX>"
    + +

    + The commit message suffix is converted to uppercase by the script. For each + merge step, the script generates messages like: +

    + +
    MERGE_TEST_RELEASE_2026_05_12
    +MERGE_UAT_RELEASE_2026_05_12
    +MERGE_LIVE_RELEASE_2026_05_12
    + +

    Log output

    + +

    + Every run creates a timestamped log file in the directory where the script is + executed. The log captures both successful commands and the exact step that + failed. +

    + + + + + + + + + + + + + + + + + + + +
    BehaviorDetails
    Log filenamemerge_log_YYYY-MM-DD_HH-MM-SS.txt
    Success loggingWrites [SUCCESS] plus command output.
    Error loggingWrites [ERROR], prints to stderr, and exits immediately.
    + +

    Operational notes

    + +
      +
    1. + Run the script from the deployment helper folder +

      This keeps the generated merge log files in one predictable location.

      +
    2. +
    3. + Make sure the repo path is correct before starting +

      The script exits immediately if the provided repository directory does not exist.

      +
    4. +
    5. + Do not continue deployment on merge failure +

      If checkout, pull, merge, or push fails for any branch, stop and resolve the issue first.

      +
    6. +
    7. + Use a meaningful commit suffix +

      Choose a release identifier that makes merge history easy to trace later.

      +
    8. +
    + +
    + + +
    + Recommended practice + Treat this script as the first gate in the deployment flow for UAT and live + releases. Once branch promotion completes successfully, continue with the + environment-specific deployment steps. +
    +
    + +

    Code move scripts

    + +
    + ! +
    + Strict warning before running these steps + Before calling the UAT or live deployment shell scripts, any required + database changes and environment variable changes must be done manually. + Do not assume these scripts handle DB updates, migrations, secrets, or + environment-specific configuration automatically. +
    +
    + +

    + After the previous Python merge script completes successfully, the next step + is to run the environment-specific deployment shell scripts from the server. + These scripts are available only on the deployment servers and are not stored + in this repository. +

    + + + + + + + + + + + + + + + + + + + + + + + +
    ScriptEnvironment / Purpose
    crm_deployment.shLive CRM code move script.
    enrolment_depoloyment.shLive enrolment code move script.
    uat_crm_deployment.shUAT CRM code move script.
    uat_enrolment_depoloyment.shUAT enrolment code move script.
    + +

    + The execution rule is simple: +

    + +
      +
    1. + Run auto_merge.py first +

      This completes the required branch promotion chain before any server-side code move starts.

      +
    2. +
    3. + Choose the scripts based on target environment +

      For UAT, call the uat_* scripts. For live, call the non-UAT deployment scripts.

      +
    4. +
    5. + Execute the relevant application scripts +

      Run the CRM and enrolment deployment scripts that match the environment being released.

      +
    6. +
    + +
    # UAT example
    +python3 auto_merge.py "/home/deploy-user/nhance" "RELEASE_2026_05_12"
    +./uat_crm_deployment.sh
    +./uat_enrolment_depoloyment.sh
    +
    +# LIVE example
    +python3 auto_merge.py "/home/deploy-user/nhance" "RELEASE_2026_05_12"
    +./crm_deployment.sh
    +./enrolment_depoloyment.sh
    + +
    + ! +
    + Server-only scripts + These deployment scripts exist only on the deployment servers. Keep this + documentation as the operational reference, but do not expect to find the + actual shell files inside this repository. +
    +
    + +

    Manual fallback

    + +

    + If the dev auto-deploy flow fails, manual deployment is still available through + cPanel Git Version Control. That manual apply option should be used as the + fallback path when the webhook receiver or deploy script does not complete + successfully. +

    + +
    + ! +
    + Fallback path + Keep cPanel Git Version Control enabled for the repository so the team can + manually apply the latest revision whenever the automatic cPanel deployment + path fails. +
    +
    diff --git a/app/Views/docs/docs_footer.php b/app/Views/docs/docs_footer.php new file mode 100644 index 00000000..1caca673 --- /dev/null +++ b/app/Views/docs/docs_footer.php @@ -0,0 +1,80 @@ + + + + + + +
    + © . Internal developer docs. + + Built with CodeIgniter 4  ·  + Changelog  ·  + Contributing + +
    + + + diff --git a/app/Views/docs/docs_header.php b/app/Views/docs/docs_header.php new file mode 100644 index 00000000..2b8a8e6e --- /dev/null +++ b/app/Views/docs/docs_header.php @@ -0,0 +1,288 @@ + 'Installation']) ?> + * + * Variables: + * $doc_title (string) — page title shown in and breadcrumb + * $app_name (string) — defaults to 'Nhance PAM' + * $app_version (string) — defaults to 'v1.1.0' + */ + +$doc_title = $doc_title ?? 'Documentation'; +$app_name = $app_name ?? 'Nhance PAM'; +$app_version = $app_version ?? 'v1.1.0'; +$base = base_url(); +?> +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title><?= esc($doc_title) ?> — <?= esc($app_name) ?> Doc, <?= esc($app_version) ?> + + + + + + + + + + + + + + +
    + + +
    + +
    +
    + + +
    diff --git a/app/Views/docs/docs_main_close.php b/app/Views/docs/docs_main_close.php new file mode 100644 index 00000000..b4a33778 --- /dev/null +++ b/app/Views/docs/docs_main_close.php @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + +
    + + diff --git a/app/Views/docs/docs_main_open.php b/app/Views/docs/docs_main_open.php new file mode 100644 index 00000000..e964f522 --- /dev/null +++ b/app/Views/docs/docs_main_open.php @@ -0,0 +1,125 @@ + + + + + +
    + + + +
    + Docs + + + + + + +
    + + + +

    + + +
    + 📅 Last updated: + ✍️ + +
    + + diff --git a/app/Views/docs/docs_sidebar.php b/app/Views/docs/docs_sidebar.php new file mode 100644 index 00000000..99f60501 --- /dev/null +++ b/app/Views/docs/docs_sidebar.php @@ -0,0 +1,175 @@ + 'installation']) ?> + * + * Variables: + * $active_page (string) — slug matching the 'id' on each nav item below + * $nav (array) — optional nav injected by DocsController + * + * To add a page: add an entry to the $nav array below. + * To add a section: add a new group with a 'label' key. + */ + +$active_page = $active_page ?? ''; + +$nav = $nav ?? [ + [ + 'label' => 'Getting Started', + 'items' => [ + ['id' => 'introduction', 'label' => 'Introduction', 'url' => 'docs/introduction'], + ['id' => 'installation', 'label' => 'Installation', 'url' => 'docs/installation'], + ['id' => 'configuration', 'label' => 'Configuration', 'url' => 'docs/configuration'], + ['id' => 'env-setup', 'label' => 'Environment Setup', 'url' => 'docs/env-setup'], + ], + ], + [ + 'label' => 'Architecture', + 'items' => [ + ['id' => 'project-structure', 'label' => 'Project Structure', 'url' => 'docs/project-structure'], + ['id' => 'routing', 'label' => 'Routing', 'url' => 'docs/routing'], + ['id' => 'controllers', 'label' => 'Controllers', 'url' => 'docs/controllers'], + ['id' => 'models', 'label' => 'Models', 'url' => 'docs/models'], + ['id' => 'services', 'label' => 'Services', 'url' => 'docs/services'], + ['id' => 'helpers', 'label' => 'Helpers', 'url' => 'docs/helpers'], + ], + ], + [ + 'label' => 'Features', + 'items' => [ + ['id' => 'authentication', 'label' => 'Authentication', 'url' => 'docs/authentication'], + ['id' => 'acl', 'label' => 'ACL / Access Control', 'url' => 'docs/acl'], + ['id' => 'input-security', 'label' => 'Input Security Guard', 'url' => 'docs/input-security'], + ['id' => 'file-uploads', 'label' => 'File Upload Guard', 'url' => 'docs/file-uploads'], + ['id' => 'background-jobs', 'label' => 'Background Jobs', 'url' => 'docs/background-jobs'], + ['id' => 'visit-onboard', 'label' => 'Visit onboard', 'url' => 'docs/visit-onboard'], + ['id' => 'visit-offboard', 'label' => 'Visit offboard', 'url' => 'docs/visit-offboard'], + ['id' => 'notifications', 'label' => 'Email / Notifications', 'url' => 'docs/notifications'], + ], + ], + [ + 'label' => 'API Reference', + 'items' => [ + ['id' => 'endpoints', 'label' => 'Endpoints', 'url' => 'docs/endpoints'], + ['id' => 'request-response','label' => 'Request / Response', 'url' => 'docs/request-response'], + ['id' => 'error-codes', 'label' => 'Error Codes', 'url' => 'docs/error-codes'], + ], + ], + [ + 'label' => 'DevOps', + 'items' => [ + ['id' => 'deployment', 'label' => 'Deployment', 'url' => 'docs/deployment'], + ['id' => 'cicd', 'label' => 'CI/CD Pipeline', 'url' => 'docs/cicd'], + ['id' => 's3-cloudfront', 'label' => 'S3 & CloudFront', 'url' => 'docs/s3-cloudfront'], + ], + ], + [ + 'label' => 'Reference', + 'items' => [ + ['id' => 'changelog', 'label' => 'Changelog', 'url' => 'docs/changelog'], + ['id' => 'contributing', 'label' => 'Contributing', 'url' => 'docs/contributing'], + ], + ], +]; +?> + + + + + diff --git a/app/Views/docs/eb-rack-rate-calculation.php b/app/Views/docs/eb-rack-rate-calculation.php new file mode 100644 index 00000000..b6b55318 --- /dev/null +++ b/app/Views/docs/eb-rack-rate-calculation.php @@ -0,0 +1,922 @@ + + +

    Scope and entry point

    + +

    + This page documents how configured rack rates (policy premium slabs and grid metadata loaded from the DB) + are matched to each family and how premium, pro-rata, and GST are written onto each member row + during Excel-driven employee onboarding. +

    + +
    + i +
    + In scope here: the branch of + EmployeeServiceController::employeesOnboardPreprocess() that runs when $params['file_id'] is set + (physical workbook under WRITEPATH/uploads/excel/), and Excel actions + inception, missed_inception, addition, and dependent_addition only. + The enrollment-to-inception branch (client_policy_id without a file) is out of scope. + UI configuration of racks remains on + EB rack rate config. +
    +
    + +

    + Primary symbols: + app/Controllers/EmployeeServiceController.phpemployeesOnboardPreprocess(); + app/Helpers/excel_util_helper.phpcalculate_premium_new() and its callees; + app/Helpers/excel_util_helper.phppremium_calculation_manager() for grid-type-specific slab row matching. +

    + +

    Data loaded before premium

    + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    SourceWhat it isUsed for
    clientPolicyModel->getPolicyDetails()Policy terms (start/end dates, insurer id, GST default, flags such as is_addon).Pro-rata denominators, optional +1 day on coverage for additions, post-match premium_type rules.
    policiesModel->getPolicySlabRatesForEmpOnboard()Array shaped as ['slab_rates' => [...rows...], 'grid_master' => ..., 'additional_slab_info' => ...] from PolicyPremium1Model / PolicyPremium2Model plus joined grid_master per row.Every premium grid row (rack_rate_name, SI, age, grade, unit, premium_type, additional_relationship JSON, etc.).
    clientBranchModel->getExisitingUnits()List of valid unit names for the branch.Default unit when Excel unit column is empty; unit matching inside slab loops.
    Excel sheetRows parsed to a numeric-indexed array per member (see below).Family composition, SI, DOB, dates, band, unit.
    + +

    Excel row shape (numeric columns)

    + +

    + After rangeToArray, each family member row is a 0-based array. The premium path relies heavily on these indices + inside helpers (e.g. transform_excel_data_to_db, get_applicable_familiy_members). +

    + + + + + + + + + + + + + + + + +
    IndexTypical meaning
    1Employee code (family key).
    2Name.
    3DOB (age and age-band grids).
    5Relationship (Self, Spouse, …) — slugified for composition and applicability.
    6Basic cover SI.
    7Date of coverage.
    9Basic pay (GPA grid 1 basic-pay path).
    10Band / grade.
    18Unit name.
    + +

    + The first row in each grouped family array is treated as the self anchor inside calculate_premium_new: + SI ([6]), band ([10]), and a few other fields are copied from $family_data[0] onto every member before transform. +

    + +

    + A family may match more than one named rack. The code walks racks in the order returned by + group_slab_rates_basedon_name; each applicable rack overwrites $family_data[i]['temp'] for the same indexes, + so the last matching rack in iteration order wins for grid_name, grid_master, and + premium_type on that row before per-member pricing runs. +

    + +

    Flow: employeesOnboardPreprocess (Excel path)

    + +

    + High-level orchestration: validate file, resolve column set from $file['action'], load policy + slabs + units, + group rows by employee code, optionally merge DB family for dependent addition, call calculate_premium_new per family, + then employeesOnboardProcess to persist. File status becomes success only if at least one family inserted; + otherwise a generic rack-configuration failure is recorded. +

    + +
    +
    +flowchart TD + A["employeesOnboardPreprocess file_id"] --> B{"file row exists"} + B -->|no| Z1["return file not found"] + B -->|yes| C{"physical xlsx exists"} + C -->|no| Z2["file status failed"] + C -->|yes| D["columns_to_check from file action"] + D --> E["getPolicyDetails"] + E --> F["load spreadsheet rangeToArray"] + F --> G["getPolicySlabRatesForEmpOnboard"] + G --> H["getExisitingUnits"] + H --> I["data_group_by_family from excel"] + I --> J{"dependent_addition"} + J -->|yes| K["merge DB family transform_db_data_to_excel"] + K --> L["self_rata_premium on dependents"] + J -->|no| M["for each family"] + L --> M + M --> N["calculate_premium_new"] + N --> O["employeesOnboardProcess"] + O --> P{"insert count gt 0"} + P -->|yes| Q["file status success"] + P -->|no| R["file failed rack slab hint"] +
    +
    + +

    Flow: calculate_premium_new

    + +

    + Two conceptual phases: (1) rack selection — for each named rack, decide if the family matches the configured + relationship pattern and stamp temp metadata on applicable Excel rows; (2) per-member pricing — + normalize row, optionally call premium_calculation_manager, collect results. Dependent addition runs an extra + normalisation pass validatet_family_floter_rata_premium. +

    + +
    +
    +flowchart TD + + START([Start calculate_premium_new]) + + START --> INIT[Initialize Variables] + + INIT --> GROUP_SLAB[Group slab details based on rack rate name] + GROUP_SLAB --> FAMILY_COMP[Construct incoming family composition & counts] + + FAMILY_COMP --> LOOP_SLABS_START{{Loop Each Grouped Slab}} + + %% ========================================================= + %% SLAB APPLICABILITY SECTION + %% ========================================================= + + LOOP_SLABS_START --> COMPARE_SLAB[Compare incoming family composition with configured slab] + + COMPARE_SLAB --> SLAB_APPLICABLE{Is Slab Applicable?} + + SLAB_APPLICABLE -->|No| MARK_NOT_APPLICABLE[Mark slab as NOT applicable] + MARK_NOT_APPLICABLE --> NEXT_SLAB + + SLAB_APPLICABLE -->|Yes| MARK_APPLICABLE[Mark slab as applicable] + + MARK_APPLICABLE --> STORE_MEMBERS[Store applicable members] + + STORE_MEMBERS --> GET_APPLICABLE_MEMBERS[Get applicable family members from overall family] + + GET_APPLICABLE_MEMBERS --> INIT_SELF_FLAG[Initialize acting self flag = false] + + INIT_SELF_FLAG --> LOOP_APPLICABLE_MEMBERS{{Loop Applicable Members}} + + LOOP_APPLICABLE_MEMBERS --> CHECK_SELF{Is acting self already assigned?} + + CHECK_SELF -->|No| CHECK_RELATIONSHIP{Relationship = Self ?} + + CHECK_RELATIONSHIP -->|Yes| SET_ACTING_SELF_TRUE[Set acting self = true] + CHECK_RELATIONSHIP -->|No| SET_FIRST_AS_SELF[Set first applicable member as acting self] + + SET_ACTING_SELF_TRUE --> UPDATE_SELF_FLAG + SET_FIRST_AS_SELF --> UPDATE_SELF_FLAG + + UPDATE_SELF_FLAG[Set self flag assigned = true] + --> UPDATE_MEMBER_TEMP + + CHECK_SELF -->|Yes| UPDATE_MEMBER_TEMP + + UPDATE_MEMBER_TEMP[Update member temp data: + - max age + - max count + - grid name + - grid master + - acting self + - premium type] + + UPDATE_MEMBER_TEMP --> MORE_APPLICABLE_MEMBERS{More applicable members?} + + MORE_APPLICABLE_MEMBERS -->|Yes| LOOP_APPLICABLE_MEMBERS + MORE_APPLICABLE_MEMBERS -->|No| NEXT_SLAB + + NEXT_SLAB --> MORE_SLABS{More slabs available?} + + MORE_SLABS -->|Yes| LOOP_SLABS_START + + %% ========================================================= + %% FAMILY MEMBER PROCESSING SECTION + %% ========================================================= + + MORE_SLABS -->|No| LOOP_FAMILY_START{{Loop Each Family Member}} + + LOOP_FAMILY_START --> SET_GRID_INFO[Set grid info into fileArr] + + SET_GRID_INFO --> COPY_SELF_VALUES[Copy self values: + - SI + - Grade + - Unit + to current member] + + COPY_SELF_VALUES --> CHECK_UNIT{Is Unit Empty?} + + CHECK_UNIT -->|Yes| SET_DEFAULT_UNIT[Assign default existing unit] + CHECK_UNIT -->|No| TRANSFORM_MEMBER + + SET_DEFAULT_UNIT --> TRANSFORM_MEMBER + + TRANSFORM_MEMBER[Transform Excel row to DB structure] + + TRANSFORM_MEMBER --> CHECK_ACTION{Action NOT in D/C/SI ?} + + CHECK_ACTION -->|Yes| GENERATE_REL_CODE[Generate relationship code & emp type] + CHECK_ACTION -->|No| BUILD_CONDITIONS + + GENERATE_REL_CODE --> BUILD_CONDITIONS + + %% ========================================================= + %% CONDITIONS SECTION + %% ========================================================= + + BUILD_CONDITIONS[Build Premium Calculation Conditions] + + BUILD_CONDITIONS --> PRIMARY_GRID_CONDITION[ + Build Primary Grid Type Condition + ] + + PRIMARY_GRID_CONDITION --> FINAL_CONDITION{ + Allow Premium Calculation? + } + + FINAL_CONDITION -->|No| NEXT_MEMBER + + FINAL_CONDITION -->|Yes| PREMIUM_MANAGER[Call Premium Calculation Manager] + + PREMIUM_MANAGER --> STORE_RESULT[Append transformed member to result] + + STORE_RESULT --> NEXT_MEMBER + + NEXT_MEMBER --> MORE_MEMBERS{More family members?} + + MORE_MEMBERS -->|Yes| LOOP_FAMILY_START + + %% ========================================================= + %% FINAL VALIDATION SECTION + %% ========================================================= + + MORE_MEMBERS -->|No| CHECK_DEPENDENT_ADDITION{ + File Action = dependent_addition? + } + + CHECK_DEPENDENT_ADDITION -->|Yes| VALIDATE_FLOATER[ + Validate Family Floater Rata Premium + ] + + CHECK_DEPENDENT_ADDITION -->|No| RETURN_RESULT + + VALIDATE_FLOATER --> RETURN_RESULT + + RETURN_RESULT([Return Result]) +
    +
    + +

    group_slab_rates_basedon_name

    + +

    + Flattens the list returned from the model into a map keyed by rack_rate_name. Each bucket keeps + ['slab_rates' => [...], 'grid_master' => ...] where grid_master comes from the row’s policy grid record + (ui_type becomes the numeric grid id used later in premium_calculation_manager). +

    + +
    +
    +flowchart LR + S["slab_rates rows"] --> L["iterate in DB order"] + L --> K{"rack_rate_name changed"} + K -->|yes| B["open new bucket"] + K -->|no| U["same bucket"] + B --> P["append row to slab_rates array"] + U --> P + P --> G["store grid_master from row"] +
    +
    + +
    + i +
    + When maintaining this helper, inspect the implementation for duplicate pushes on the first row of a new rack name; + downstream code tolerates duplicate slab rows but it can confuse debugging of premium matches. +
    +
    + +

    get_familiy_composition

    + +

    + Builds a compact associative array of counts / presence flags from the incoming workbook family only + (already grouped to one employee). Keys align with the JSON used in rack configuration (additional_relationship), except + either-parents-pil and elders_count which are stripped before comparison. +

    + +
    +
    +flowchart TD + A["single pass over family_data rows"] --> B["slugify column 5 per row"] + B --> C["bump self spouse childrens parents parents-in-law counters"] + C --> D["return family_composition map"] +
    +
    + +

    compare_incoming_family_slab_with_configured_slab

    + +

    + The first row of each rack’s slab_rates carries additional_relationship (JSON). After removing + either-parents-pil and elders_count, each remaining key is evaluated in sequence: +

    + +
      +
    • If the configured value is 'NA', that dimension is ignored (does not participate in match or applicable-member list).
    • +
    • Otherwise the incoming composition must contain the same key. If incoming[key] == configured or configured is 'any', the key contributes applicable relationship tokens (via an internal map: self, spouse, son/daughter, parents, in-laws).
    • +
    • On the first failed key, the rack is rejected (is_applicable false, applicable members cleared) and the loop stops.
    • +
    • If the decoded JSON is empty, the rack is not applicable.
    • +
    + +
    +
    +flowchart TD + J["decode additional_relationship JSON"] --> U["strip either-parents-pil and elders_count"] + U --> E{"non-NA rules exist"} + E -->|no| F["rack not applicable"] + E -->|yes| K["sequential AND each non-NA key"] + K -->|incoming matches value or any| Y["rack applicable plus relationship tokens"] + K -->|missing key or mismatch| X["rack not applicable"] +
    +
    + +

    get_applicable_familiy_members and acting_self

    + +

    + Given the list of relationship strings that matched the rack (e.g. self, spouse, son), this helper returns: +

    + +
      +
    • index: Excel row indexes whose slugified relationship is in that list.
    • +
    • max_age: list of ages (years from DOB column [3] to “today” in calculate_days_bw_dates).
    • +
    • max_count: count of those indexes (used by grids 10 and 11).
    • +
    + +

    + calculate_premium_new then walks index in order and sets acting_self: the first applicable member + receives acting_self = true; subsequent applicable members get false. Combined with relationship checks later in + premium_calculation_manager, this distinguishes who carries floater-style premium when premium_type == 1. +

    + +
    +
    +flowchart TD + A["applicable_members from compare"] --> B["scan family_data by index"] + B --> C{"relationship slug in list"} + C -->|yes| D["record index and age from DOB"] + C -->|no| B + D --> B + B -->|done| E["max_count equals number of indexes"] + E --> F["walk indexes in order first gets acting_self true remainder false"] +
    +
    + +

    Per-member transform and premium gate

    + +

    + For each raw Excel row, transform_excel_data_to_db builds the associative structure expected by persistence and by + premium_calculation_manager, including temp.grid_type (rack name), temp.grid_id (grid master + ui_type), temp.action (single-letter code I, A, DA, MI, …), and nested policy_details. +

    + +

    + Premium is only calculated when any of the following holds (Excel onboarding path simplifies to the first two in practice): +

    + +
      +
    • isEmployeeSourceEnrollment: fileArr['id'] == null (not used in the scoped Excel path).
    • +
    • isEmployeeSourceExcelFile: temp.source == 'excel' — normal onboarding uploads.
    • +
    • primaryGridTypeCondition: dependent addition + primary grid + premium_type single + relationship self + basic SI already set (additional-grid path; omitted from diagrams above for brevity).
    • +
    + +
    +
    +flowchart TD + T["transform_excel_data_to_db"] --> G{"need relationship_code"} + G -->|not deletion correction SI| R["generate_relationship_code"] + G -->|skip| C2["build conditions"] + R --> C2 + C2 --> P{"excel upload or enrollment or DA primary single self path"} + P -->|yes| M["premium_calculation_manager"] + P -->|no| X["member not added to priced result"] +
    +
    + +

    premium_calculation_manager (grid types 1–13)

    + +

    + Resolves $emp_data['temp']['grid_name'] to the rack bucket, takes that rack’s slab_rates rows, and branches on + temp.grid_id (string "1""13"). Common outcomes for a match: +

    + +
      +
    • Set policy_details.basic_cover_si, date_coverage, policy_end_date, days.
    • +
    • Set annual premium, then rata_premimum via calculate_pro_rata_premimum(premium, employee_days, policy_days).
    • +
    • Set gst from policy GST percent (default 18).
    • +
    + +

    + Special cases worth reading in source: GPA grid 1 also supports si_or_bp == 2 auto SI from basic pay when no slab row matches; + grids 10 and 11 consume temp.max_age / temp.max_count from the rack-selection phase; + grids 12 and 13 match slugified relationship (child merges son/daughter). +

    + +
    +
    + +flowchart TD + + START([Start premium_calculation_manager]) + + START --> INIT[Initialize Logger, Slug Service, Grid Type, Slab Index] + + INIT --> CHECK_GRID{Grid Name Available?} + + CHECK_GRID -->|No| RETURN_FALSE[Return False] + CHECK_GRID -->|Yes| GET_SLAB_RATES[Get Current Slab Rates] + + %% ===================================================== + %% DRAFT / AUDIT HISTORY SECTION + %% ===================================================== + + GET_SLAB_RATES --> CHECK_EMP_DRAFT{ + Employee Source = Enrollment + AND Emp Status = Draft + AND Policy Status = Draft ? + } + + CHECK_EMP_DRAFT -->|Yes| FETCH_EMP_AUDIT[Fetch Original Employee Audit Data] + CHECK_EMP_DRAFT -->|No| CHECK_POLICY_DRAFT + + FETCH_EMP_AUDIT --> CHECK_POLICY_DRAFT + + CHECK_POLICY_DRAFT{ + Fetch Original Policy Audit Data? + } + + CHECK_POLICY_DRAFT -->|Yes| FETCH_POLICY_AUDIT[Fetch Original Policy Audit Records] + CHECK_POLICY_DRAFT -->|No| CHECK_ADDITION_ACTION + + FETCH_POLICY_AUDIT --> CHECK_ADDITION_ACTION + + %% ===================================================== + %% ADDITION ACTION SECTION + %% ===================================================== + + CHECK_ADDITION_ACTION{ + Action = DA or A ? + } + + CHECK_ADDITION_ACTION -->|No| INIT_CALCULATION + CHECK_ADDITION_ACTION -->|Yes| FETCH_INSURER + + FETCH_INSURER[Fetch Insurer Master] + + FETCH_INSURER --> CHECK_ADD_DAY{ + addition_add_day enabled? + } + + CHECK_ADD_DAY -->|Yes| ADD_ONE_DAY[Add +1 day to coverage date] + CHECK_ADD_DAY -->|No| INIT_CALCULATION + + ADD_ONE_DAY --> INIT_CALCULATION + + %% ===================================================== + %% MAIN CALCULATION SECTION + %% ===================================================== + + INIT_CALCULATION[Initialize: + - GST + - is_match_found = false] + + INIT_CALCULATION --> SWITCH_GRID{{Switch Grid Type}} + + %% ===================================================== + %% GRID TYPE 1 + %% ===================================================== + + SWITCH_GRID --> GRID1[Grid Type 1: + GPA - SI * Multiplier] + + GRID1 --> GRID1_LOOP{{Loop Slab Rates}} + + GRID1_LOOP --> GRID1_MATCH{ + SI + Unit Match? + OR + Grade + SI + Unit Match? + } + + GRID1_MATCH -->|Yes| COMMON_ASSIGNMENT_1 + GRID1_MATCH -->|No| GRID1_NEXT + + GRID1_NEXT --> MORE_GRID1{More Slabs?} + + MORE_GRID1 -->|Yes| GRID1_LOOP + + MORE_GRID1 -->|No| CHECK_AUTO_CALC + + CHECK_AUTO_CALC{ + SI/BP Type = Basic Pay? + } + + CHECK_AUTO_CALC -->|No| END_GRID1 + + CHECK_AUTO_CALC -->|Yes| AUTO_CALC_SI + + AUTO_CALC_SI[Auto Calculate: + - SI using Basic Pay + - Premium using Multiplier] + + AUTO_CALC_SI --> COMMON_ASSIGNMENT_1 + + COMMON_ASSIGNMENT_1[ + Assign: + - SI + - Coverage Dates + - Days + - Premium + - Rata Premium + - GST + Set Match Found = true + ] + + COMMON_ASSIGNMENT_1 --> END_GRID1 + + END_GRID1 --> POST_SWITCH + + %% ===================================================== + %% GRID TYPE 2 + %% ===================================================== + + SWITCH_GRID --> GRID2[Grid Type 2: + GPA Flat Rate] + + GRID2 --> SIMPLE_SI_MATCH_2 + + SIMPLE_SI_MATCH_2{ + SI + Unit Match? + } + + SIMPLE_SI_MATCH_2 -->|Yes| COMMON_ASSIGNMENT_2 + SIMPLE_SI_MATCH_2 -->|No| POST_SWITCH + + COMMON_ASSIGNMENT_2[ + Assign Premium Details + Set Match Found = true + ] + + COMMON_ASSIGNMENT_2 --> POST_SWITCH + + %% ===================================================== + %% GRID TYPE 3 + %% ===================================================== + + SWITCH_GRID --> GRID3[Grid Type 3: + GMC - SI] + + GRID3 --> SIMPLE_SI_MATCH_3 + + SIMPLE_SI_MATCH_3{ + SI + Unit Match? + } + + SIMPLE_SI_MATCH_3 -->|Yes| COMMON_ASSIGNMENT_3 + SIMPLE_SI_MATCH_3 -->|No| POST_SWITCH + + COMMON_ASSIGNMENT_3[ + Assign Premium Details + Set Match Found = true + ] + + COMMON_ASSIGNMENT_3 --> POST_SWITCH + + %% ===================================================== + %% GRID TYPE 4 - 7 + %% ===================================================== + + SWITCH_GRID --> GRID4TO7[Grid Types 4-7: + Age Based Calculation] + + GRID4TO7 --> CALCULATE_AGE[Calculate Employee Age] + + CALCULATE_AGE --> AGE_MATCH_LOOP{{Loop Slab Rates}} + + AGE_MATCH_LOOP --> AGE_MATCH{ + SI + Unit + Age Band Match? + } + + AGE_MATCH -->|Yes| AGE_ASSIGNMENT + AGE_MATCH -->|No| AGE_NEXT + + AGE_NEXT --> MORE_AGE_SLABS{More Slabs?} + + MORE_AGE_SLABS -->|Yes| AGE_MATCH_LOOP + MORE_AGE_SLABS -->|No| POST_SWITCH + + AGE_ASSIGNMENT[ + Assign: + - Premium + - Age Band + - GST + - Rata Premium + Set Match Found = true + ] + + AGE_ASSIGNMENT --> POST_SWITCH + + %% ===================================================== + %% GRID TYPE 8 + %% ===================================================== + + SWITCH_GRID --> GRID8[Grid Type 8: + Grade/Band Based SI] + + GRID8 --> GRID8_MATCH{ + Grade + SI + Unit Match? + } + + GRID8_MATCH -->|Yes| COMMON_ASSIGNMENT_8 + GRID8_MATCH -->|No| POST_SWITCH + + COMMON_ASSIGNMENT_8[ + Assign Premium Details + Set Match Found = true + ] + + COMMON_ASSIGNMENT_8 --> POST_SWITCH + + %% ===================================================== + %% GRID TYPE 9 + %% ===================================================== + + SWITCH_GRID --> GRID9[Grid Type 9: + Flat Rate For All] + + GRID9 --> GRID9_MATCH{ + SI + Unit Match? + } + + GRID9_MATCH -->|Yes| COMMON_ASSIGNMENT_9 + GRID9_MATCH -->|No| POST_SWITCH + + COMMON_ASSIGNMENT_9[ + Assign Premium Details + Set Match Found = true + ] + + COMMON_ASSIGNMENT_9 --> POST_SWITCH + + %% ===================================================== + %% GRID TYPE 10 + %% ===================================================== + + SWITCH_GRID --> GRID10[Grid Type 10: + Max Dependent Age] + + GRID10 --> GET_MAX_AGE[Get Maximum Family Age] + + GET_MAX_AGE --> GRID10_MATCH{ + SI + Unit + Max Age Match? + } + + GRID10_MATCH -->|Yes| GRID10_ASSIGN + GRID10_MATCH -->|No| POST_SWITCH + + GRID10_ASSIGN[ + Assign: + - Premium + - Age Band + - GST + ] + + GRID10_ASSIGN --> POST_SWITCH + + %% ===================================================== + %% GRID TYPE 11 + %% ===================================================== + + SWITCH_GRID --> GRID11[Grid Type 11: + Max Family Count] + + GRID11 --> GET_MAX_COUNT[Get Family Member Count] + + GET_MAX_COUNT --> CALCULATE_FAMILY_SI[Calculate Family SI] + + CALCULATE_FAMILY_SI --> LIMIT_MAX_SI[Apply Max SI Limit] + + LIMIT_MAX_SI --> GET_PREMIUM_BY_SI[Fetch Premium using Family SI] + + GET_PREMIUM_BY_SI --> GRID11_ASSIGN + + GRID11_ASSIGN[ + Assign Family Premium Details + ] + + GRID11_ASSIGN --> POST_SWITCH + + %% ===================================================== + %% GRID TYPE 12 + %% ===================================================== + + SWITCH_GRID --> GRID12[Grid Type 12: + Relationship Based] + + GRID12 --> NORMALIZE_RELATIONSHIP[Normalize Relationship] + + NORMALIZE_RELATIONSHIP --> GRID12_MATCH{ + SI + Unit + Relationship Match? + } + + GRID12_MATCH -->|Yes| GRID12_ASSIGN + GRID12_MATCH -->|No| POST_SWITCH + + GRID12_ASSIGN[ + Assign Premium Details + ] + + GRID12_ASSIGN --> POST_SWITCH + + %% ===================================================== + %% GRID TYPE 13 + %% ===================================================== + + SWITCH_GRID --> GRID13[Grid Type 13: + Relationship + Age] + + GRID13 --> CALCULATE_REL_AGE[Calculate Age & Normalize Relationship] + + CALCULATE_REL_AGE --> GRID13_MATCH{ + SI + Unit + Relationship + Age Match? + } + + GRID13_MATCH -->|Yes| GRID13_ASSIGN + GRID13_MATCH -->|No| POST_SWITCH + + GRID13_ASSIGN[ + Assign Premium + Age Band + ] + + GRID13_ASSIGN --> POST_SWITCH + + %% ===================================================== + %% DEFAULT + %% ===================================================== + + SWITCH_GRID --> GRID_DEFAULT[Unknown Grid Type] + + GRID_DEFAULT --> LOG_GRID_ERROR[Log Grid Type Error] + + LOG_GRID_ERROR --> POST_SWITCH + + %% ===================================================== + %% POST PROCESSING + %% ===================================================== + + POST_SWITCH --> CHECK_FLOATER{ + Match Found + AND Relationship != Self + AND Premium Type = 3 + AND Addon != 3 ? + } + + CHECK_FLOATER -->|Yes| RESET_DEPENDENT_SI[Set Dependent SI = 0] + CHECK_FLOATER -->|No| CHECK_MATCH_FOUND + + RESET_DEPENDENT_SI --> CHECK_MATCH_FOUND + + %% ===================================================== + %% NO MATCH SECTION + %% ===================================================== + + CHECK_MATCH_FOUND{ + Match Found? + } + + CHECK_MATCH_FOUND -->|Yes| CHECK_DEPENDENT_DA + + CHECK_MATCH_FOUND -->|No| HANDLE_NO_MATCH + + HANDLE_NO_MATCH{ + Premium Type = 1 ? + } + + HANDLE_NO_MATCH -->|Yes| RESET_SELF_ONLY + + HANDLE_NO_MATCH -->|No| LOG_SLAB_NOT_FOUND + + RESET_SELF_ONLY[ + Reset: + - SI + - Premium + - GST + - Rata Premium + ] + + RESET_SELF_ONLY --> LOG_ERROR + + LOG_SLAB_NOT_FOUND[ + Log: + Slab Rate Not Found + ] + + LOG_SLAB_NOT_FOUND --> LOG_ERROR + + LOG_ERROR[Write Error Log] + + LOG_ERROR --> CHECK_DEPENDENT_DA + + %% ===================================================== + %% DEPENDENT ADDITION LOGIC + %% ===================================================== + + CHECK_DEPENDENT_DA{ + Dependent + AND Acting Self Empty + AND Premium Type = 1 + AND Action = DA ? + } + + CHECK_DEPENDENT_DA -->|Yes| HANDLE_DA_PREMIUM + + CHECK_DEPENDENT_DA -->|No| CHECK_OTHER_ACTIONS + + HANDLE_DA_PREMIUM[ + Set: + - SI = 0 + - Premium = 0 + + Recalculate Rata Premium Difference + ] + + HANDLE_DA_PREMIUM --> RETURN_RESULT + + CHECK_OTHER_ACTIONS{ + Action = I/A/MI ? + } + + CHECK_OTHER_ACTIONS -->|Yes| RESET_DEPENDENT_VALUES + CHECK_OTHER_ACTIONS -->|No| RETURN_RESULT + + RESET_DEPENDENT_VALUES[ + Reset: + - SI + - Premium + - GST + - Days + ] + + RESET_DEPENDENT_VALUES --> RETURN_RESULT + + RETURN_RESULT([Return Employee Data]) + + +
    +
    + +

    Dependent addition extras

    + +

    + Before calculate_premium_new, the controller loads active family members from the DB, maps them into the same Excel column layout, + merges them with new dependents, and re-groups so Self stays first. It copies the self member’s + temp.rata_premimum onto each non-self row as self_rata_premium for downstream floater math. +

    + +

    + After pricing, validatet_family_floter_rata_premium adjusts dependents when premium_type == 1 so that only the + intended Excel dependents retain non-zero rata (see implementation for the two-pass rules and the data_from == excel filter). +

    + +

    Failure: zero successful families

    + +

    + If every family iteration yields no successful insert from employeesOnboardProcess, the file is marked failed with a message + pointing operators at slab / rack configuration. That usually means no family produced priced rows that the persistence layer accepted, + which often traces back to rack mismatch, missing slab rows for SI/unit/age, or premium_calculation_manager returning + unmatched state for all members. +

    + + + +
      +
    • EB rack rate config — UI fields, premium_type semantics, grid catalogue.
    • +
    • app/Controllers/EmployeeServiceController.phpemployeesOnboardPreprocess, employeesOnboardProcess.
    • +
    • app/Helpers/excel_util_helper.phpcalculate_premium_new, premium_calculation_manager, composition/compare helpers.
    • +
    • app/Models/PolicesModel.phpgetPolicySlabRatesForEmpOnboard().
    • +
    diff --git a/app/Views/docs/eb-rack-rate-config.php b/app/Views/docs/eb-rack-rate-config.php new file mode 100644 index 00000000..cf86ad9a --- /dev/null +++ b/app/Views/docs/eb-rack-rate-config.php @@ -0,0 +1,463 @@ + + +

    Purpose

    + +

    + EB rack rate config is how Nhance authors premium rack rates for a client policy: + rate tables (SI, age, grade, relationship, etc.) plus two policy-wide behaviours that apply to + every grid type (1–13): Premium calculation and + Applicable family members. GMC policies may also define multiple + named rack rates (Primary + additional tabs). Technical save/load paths reference + ClientController, policy_grid.php, and policy_grid_excel.php. + For the Excel employee onboarding pipeline that consumes this configuration and runs + calculate_premium_new, see + EB rack rate calculation. +

    + +

    Premium calculation (all grid types)

    + +

    + The modal exposes Premium calculation as three radios: Individual, + Family floater, and Family floater cum Individual. This choice is stored + with the rack (e.g. premium_type on premium rows where used) and interpreted when + premiums are calculated in downstream flows (enrollment, endorsements, etc.) — not re-derived from + the grid layout alone. +

    + + + + + + + + + + + + + + + + + + + +
    ModeMeaning (at calculation time)
    Individual + Sum insured is covered per individual family member, and premium is calculated + (and applied) per member according to the rack rules and member attributes. +
    Family floater + Sum insured applies to the whole family as one floater cover. Premium is calculated for + the family unit, but the amount is stored / represented against the self member only + (single premium bucket for the floater). +
    Family floater cum Individual + Combines both behaviours where the product rules require floater cover together with + individually rated members (exact split depends on policy / insurer rules in the calculation engine). +
    + +
    + i +
    + For developers: the rack modal captures the mode and the rate table; always + trace how premium_type (and policy terms) are read in the premium calculation path + you are debugging — not only in createClientPolicyPremium. +
    +
    + +

    Applicable family members (all grid types)

    + +

    + The section “Choose applicable family members” is driven by what the policy + terms allow for that client policy (who can exist on the cover). The user then selects which of + those relationships are in scope for this specific rack rate (Self / Spouse / Children / + Parents / Parents in law) using the radio options below. +

    + +

    Radio option meanings (per relationship row)

    + +

    + Each relationship (Self, Spouse, Children, Parents, Parents in law) uses the same vocabulary of choices. + These define eligibility rules for this rack rate, not the member list itself. +

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    ChoiceMeaning for this rack rate
    YesThat relationship must be present in the incoming family for this rack rate to apply.
    NoThat relationship must not be present — if it is, this rack rate does not match.
    MaybeIf that relationship is available on the family, it is included in this rack rate’s match; if not present, the rack can still match without it.
    NAEven if members of that relationship exist on the policy, they are not applicable to this rack rate (this rack never prices or targets them).
    AnyAny number of members with that same relationship is allowed for a match (no fixed count).
    1, 2, 3, 4, … (numeric)An exact count of members with that relationship must be present for this rack rate to match (e.g. exactly two parents).
    + +

    How rack rates are picked at premium time

    + +

    + At calculation time (enrollment / endorsement / etc.), the engine compares the incoming family’s + members and relationships (who is on the cover and in what roles) against each configured rack + rate’s applicable members (the rules saved in additional_relationship for that tab’s rack). + Racks whose rules match the family pattern are candidates for premium. +

    + +

    + More than one rack rate can match the same family (e.g. a base employee rack and a separate + parents top-up). In those cases multiple rack rates may apply and premium is calculated + accordingly (combined according to product rules in the calculation path — not in the modal UI alone). +

    + +

    + The diagram below is conceptual: the exact class or function name lives in your premium / + onboarding pipeline, but the decision order is what new developers should internalize. +

    + +
    +
    +flowchart TD + Start([Start premium run]) --> Family["Incoming family snapshot:
    members, relationship, counts"] + Family --> Load["Load active rack config for policy
    policy_premium_1 / 2, is_active = 1
    each rack_rate_name + rules + grid rows"] + Load --> Compare["For each rack rate:
    compare family vs applicable members
    Yes / No / Maybe / NA / Any / count"] + Compare --> Matched["Build matched rack list
    0, 1, or many racks"] + Matched --> Calc["Premium engine:
    use premium_type + rate table per matched rack"] + Calc --> Multi{"Several racks matched?"} + Multi -->|Yes| Combine["Combine premium
    per product rules"] + Multi -->|No| Single["Single-rack premium"] + Combine --> Done([Allocate to members / floater]) + Single --> Done +
    +
    + +
      +
    • Why multiple tabs exist: A single policy may define several rack rates (GMC + Primary + “Add Rack Rate” tabs). Each tab encodes a different applicable-member pattern and/or rate table.
    • +
    • Persisted as: On save, the selections are stored in additional_relationship + JSON on premium rows (keys such as self, spouse, childrens, + parents, parents-in-law). Grid ids 1 and 2 force a + simplified relation map in the controller (self-only path).
    • +
    • Reload in UI: For GMC, getpolicyGridData returns jsonArray keyed by + rack_rate_name so the modal can restore checkbox state per tab.
    • +
    + +

    How to configure a rack rate (checklist)

    + +
      +
    1. Policy terms first. Ensure client_policy.policy_terms reflects allowed members, + family floater flags, SI ladders, etc. The rack UI inherits what is allowed.
    2. +
    3. Open the Rack Rate modal (.btnPolicyModel) for the target client_policy_id.
    4. +
    5. Set Premium calculation — pick Individual, Family floater, or Family floater cum Individual + per product rules (see Premium calculation).
    6. +
    7. Set applicable family members for this rack rate tab — narrow from policy-allowed + members to who this table applies to (see Applicable family members).
    8. +
    9. Choose Policy premium type — maps to policy_grid_id 1–13 from + policy_grid_master (see Grid types).
    10. +
    11. Fill the grid — manual rows, “+” rows, and/or Copy from excel using headers + from policy_grid_excel.php.
    12. +
    13. Save — POST to client/premimum/create; confirm no validation errors (Parsley, + SI vs policy terms, duplicate family composition across tabs where enforced).
    14. +
    15. Repeat for additional GMC tabs if the product uses more than one named rack rate.
    16. +
    + +

    Where it appears in the UI

    + +
      +
    • View files: app/Views/policy_grid.php (modal shell, tabs, forms) and + app/Views/policy_grid_excel.php (Excel header maps, paste helpers, copyHeaders / generateTable).
    • +
    • Open modal: A control with class .btnPolicyModel passes data-id (client policy id) and policy type context; the script loads grid definitions and any saved premiums (see AJAX below).
    • +
    • GMC vs GPA in the modal: For GPA (policy_type_string == 'GPA' or policy type ids 6 / 7), “Add Rack Rate” and the primary tab chrome are hidden and the submit button label is “Submit”. For GMC, multiple rack-rate tabs are supported (appendNewTab, rename).
    • +
    • Forms: Each tab uses a form whose id starts with GridForm_ (primary GridForm_, additional tabs GridForm_{tabId}). Hidden fields carry client_id, client_policy_id, rack_rate_name (e.g. Primary), and the selected policy_grid_id from the “Policy Premium Type” dropdown.
    • +
    + +

    Load grid data — ClientController::getpolicyGridData

    + +

    HTTP: GET util/policy-premium with query client_policy_id (see app/Config/Routes.php under the /util group). The front end calls base_url("util/policy-premium") with that query parameter.

    + +

    What it does:

    +
      +
    1. Loads client_policy joined to policy_type and reads policy_terms JSON (family floater flags, family_floaters, etc.).
    2. +
    3. Resolves terms_si_amount_array via getPolicyTerms($client_policy_id) and branch units via getBranchUnitsByBranchId.
    4. +
    5. Counts active employees on the policy for UI gating (hideShowSubmitButton).
    6. +
    7. Derives a search token from policy type name / id: GMC (regex on type name, or type id 72) vs GPA (regex or type ids 6 / 7).
    8. +
    9. Fetches grid templates from policy_grid_master via PolicyGridModel::like('policy_type', $search_term) — these rows drive which “Policy Premium Type” options exist and what columns each grid id expects.
    10. +
    11. GPA: Loads saved rows from policy_premium_1 for this client + client_policy where is_active = 1.
    12. +
    13. GMC: Loads saved rows from policy_premium_2 for the same scope. Also builds jsonArray: grouped by rack_rate_name, each value is json_decode(additional_relationship) (family-composition flags used when re-rendering checkboxes).
    14. +
    15. GMC family floater filter: If family_floater == 0, when existing data exists it keeps premiums whose policy_grid_id is in 3–9; if family_floater == 1, it prefers grid ids 10–11 when data exists. Empty data passes through unchanged.
    16. +
    + +

    Response shape (success): data (grid master rows), premiumData (JSON string of premium rows for the UI), jsonArray, branch_units, family_floater, self, count, terms_si_amount_array, client_policy_id. GPA and GMC branches return the same keys; non-GPA/GMC types still return grid templates but premium payload may be empty.

    + +

    Save rack rate — ClientController::createClientPolicyPremium

    + +

    HTTP: POST client/premimum/create (spelling premimum matches routes). Body is multipart form data from the modal (FormData in JS).

    + +

    What it does:

    +
      +
    1. Sanitizes POST via sanitizeInputArrayAdvanced; requires a valid client_policy_id and loads client_policy for client_id / branch.
    2. +
    3. Builds additional_relationship JSON from checkboxes: self, spouse, childrens, parents, parents-in-law. For grid ids 1 or 2 it forces a fixed relation map (self only).
    4. +
    5. Deactivate old rows (soft replace): For policy_grid_id 1 or 2, sets is_active = 0 on all policy_premium_1 rows for that client policy. Otherwise sets is_active = 0 on policy_premium_2 rows matching the same rack_rate_name (so additional GMC tabs do not wipe other rack rates).
    6. +
    7. Insert new active rows: One insert per SI/premium row (arrays in POST). Units default from branch if a slot is empty or undefined.
    8. +
    + +

    Branching by policy_grid_id (high level):

    +
      +
    • 1 (GPA-style primary): Uses si_or_bp: 1 = sum insured + premium rows (gpa_sum_si[], gpa_sum_premium[], …); 2 = basic pay ladder; 3 = band/grade + SI + premium. Writes to policy_premium_1.
    • +
    • 2 or 9: Simple SI + premium columns (gpa_si29[], gpa_premium29[]). Grid 2 uses policy_premium_1; grid 9 uses policy_premium_2.
    • +
    • 3–8, 10–13: Prefix {id}_ on POST keys (e.g. 3_premium[], 3_si[], age from/to, grade, relationship, max_si). All go to policy_premium_2.
    • +
    + +

    Success response: status: true, rack_rate_json — a small JSON map used by the UI to prevent duplicate family-floater combinations across tabs (rarc_rate_json_array in policy_grid.php).

    + +

    Frontend flow (policy_grid.php)

    + +
      +
    1. Open: Click .btnPolicyModel → GET util/policy-premium?client_policy_id=….
    2. +
    3. Populate dropdown: appendGridData(res.data, …) fills “Policy Premium Type” from grid master rows.
    4. +
    5. Build inputs: Changing the dropdown calls addGridHTML, which injects large HTML templates for grid ids 1–13 (SI/basic/grade layouts, GMC age bands, etc.). Existing premiumData pre-fills values when editing.
    6. +
    7. Family composition: createCheckboxes uses res.self / policy terms and, for GMC, saved additional_relationship from jsonArray.
    8. +
    9. Submit: Delegated submit handler on form[id^="GridForm_"] — Parsley validation, duplicate SI checks against terms_si_amount_array (checkPolicyTermsSI), unit checks, then POST client/premimum/create with FormData. On success, appends res.rack_rate_json for duplicate-tab prevention; for grid 1 or 2 the modal may auto-close.
    10. +
    11. Excel: “Copy from excel” toggles a textarea; policy_grid_excel.php defines per-grid header order and parsing to fill the grid.
    12. +
    + +

    Grid IDs and database tables

    + + + + + + + + + + + + + + + + + + + + + + + +
    ArtifactRole
    policy_grid_masterCatalog of available premium grid layouts filtered by policy type (GPA vs GMC).
    policy_premium_1Stores GPA primary grid (id 1), GPA-style grid 2, and other rows where the controller routes to PolicyPremium1Model.
    policy_premium_2Stores most GMC grids (3+), grid 9, and additional rack rates distinguished by rack_rate_name.
    client_policy.policy_termsJSON: drives family floater behaviour in getpolicyGridData and which checkbox defaults appear.
    + +

    Grid types (1–13) — policy_grid_master

    + +

    + Every grid below uses the same two layers documented above: + Premium calculation and + Applicable family members. + The only difference between ids 1–13 is the shape of the rate table (which columns + appear and how POST fields are named). Downstream premium logic must combine premium_type, + additional_relationship, and these rows. +

    + +

    UI pattern: grid 4 vs grid 5 (age vs age + SI per row)

    + +

    + These two GMC layouts are easy to confuse; the modal layout differs as follows (reviewed UI): +

    + +
      +
    • Grid 4 — Employees Age band: One Sum insured field applies to the whole table + block; each row is only From age, To age, and Premium. You are + building age bands under a single SI.
    • +
    • Grid 5 — Employees Age + SI: Each row includes Sum insured, + From age, To age, and Premium. SI can change per row together + with the age band.
    • +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Grid IDLineMaster labelRate table (what differs)Persisted in
    1GPASum Insured (SI) * Multiplier + GPA primary: sub-mode si_or_bp1 SI × multiplier rows, 2 basic pay ladder with multipliers, + 3 band/grade + SI + premium. Multiple unit/SI/premium lines. Controller forces additional_relationship to self-only for grids 1–2. + Same premium calculation radios apply when shown. + policy_premium_1
    2GPAFlat Rate for all SI + Simple ladder: gpa_unit29[], gpa_si29[], gpa_premium29[] per row — no age/grade columns. + policy_premium_1
    3GMCSI + Unit + SI + premium per row (no age/relationship in the standard template). Prefix 3_ on POST keys. + policy_premium_2
    4GMCEmployees Age band + One SI for the table; rows = age band + premium only (see Grid 4 vs 5). + Backend still stores si, age_from, age_to, premium per insert; UI collects one SI context then many age rows. + policy_premium_2
    5GMCEmployees Age + SI + Each row: SI + from age + to age + premium (see Grid 4 vs 5). Prefix 5_. + policy_premium_2
    6GMCEmployees + Dependent Age band + Age-band table where dependents are in product scope (policy_grid_master dependent flags). Same POST pattern as other GMC age grids with prefix 6_. + policy_premium_2
    7GMCEmployees + Dependent Age + SI + Dependent-aware age bands and SI on each row (prefix 7_) for combined pricing dimensions. + policy_premium_2
    8GMCSI as per Grade or Band + Adds grade/band per row with SI and premium (8_grade[], etc.). For corporate grade–based insurer tables. + policy_premium_2
    9GMCFlat Rate for all + Same row shape as grid 2 (gpa_si29[] / gpa_premium29[]) but saved to policy_premium_2 for GMC. + policy_premium_2
    10GMCMaximum age of Dependents + Floater-oriented table (age + SI + premium); getpolicyGridData prefers ids 10–11 when family_floater = 1 and premium data exists. Prefix 10_. + policy_premium_2
    11GMCMaximum count per Family + Similar family/floater use case as 10; includes max SI column (11_max_si[]) for family-count / cap rules. Prefix 11_. + policy_premium_2
    12GMCEmployees + relationship + Each row carries a relationship value plus SI/premium (and unit) so rates differ by member type. Prefix 12_. + policy_premium_2
    13GMCEmployees age + relationship age + Full row: relationship + age from/to + SI + premium. Prefix 13_. + policy_premium_2
    + +
    + i +
    + POST naming for GMC grids 3–13: fields use the {gridId}_ prefix, e.g. + 5_age_from[], 5_age_to[], 5_si[], 5_premium[], 5_unit[]. + See policy_grid_excel.phpexcel_headers for paste column order per id. +
    +
    + +

    Excel paste path

    + +

    + policy_grid_excel.php defines excel_headers keyed by grid id (and nested keys for GPA grid 1 variants). + Users paste tab-separated data; helpers build a preview table and map columns into the same field names the manual grid uses. + Use this when bulk-entering many SI/premium lines instead of row-by-row “+” buttons. +

    + + + +
      +
    • POST client/premimum/editClientController::editClientPolicyPremium (edit path; not detailed on this page).
    • +
    • GET util/delete-additional-rack-rate/(:any) — referenced elsewhere for removing extra GMC rack-rate tabs / data.
    • +
    • Premium calculation at enrollment: Employee flows (e.g. onboarding) read slab / rack configuration through existing policy services — this page documents where rack rows are authored, not every consumer.
    • +
    + +
    + ! +
    + Production caution: Saving grid 1 or 2 deactivates all rows in + policy_premium_1 for the policy before insert. Other grids deactivate only rows sharing the same + rack_rate_name in policy_premium_2. Test on a copy of client policy data first. +
    +
    diff --git a/app/Views/docs/file-uploads.php b/app/Views/docs/file-uploads.php new file mode 100644 index 00000000..30eb7b69 --- /dev/null +++ b/app/Views/docs/file-uploads.php @@ -0,0 +1,312 @@ + + +

    + File uploads are protected by GlobalPostFileUploadGuard, a + request filter that validates uploaded files before controller code runs. Its + job is to reject dangerous uploads early using extension checks, MIME checks, + magic-byte inspection, filename validation, and size limits. +

    + +
    + i +
    + Where this logic lives + The implementation is in app/Filters/GlobalPostFileUploadGuard.php. + The filter is aliased in app/Config/Filters.php and is also + applied globally in the before filter chain. +
    +
    + +

    Overview

    + +
    +
    +flowchart TD + A[POST request with files] --> B[Run upload guard filter] + B --> C[Walk uploaded inputs] + C --> D[Validate one file] + D --> E[Check filename and extension] + E --> F[Check file size] + F --> G[Detect real MIME] + G --> H[Resolve expected MIME] + H --> I[Check magic bytes] + I --> J[Verify strict MIME match] + J --> K[Allow request] + E --> X[Block upload] + F --> X + G --> X + H --> X + I --> X + J --> X +
    +
    + +

    When it runs

    + +

    + The filter returns immediately unless all of the following are true: +

    + +
      +
    1. + The request method is POST +

      Non-POST requests are ignored by the guard.

      +
    2. +
    3. + The request actually contains uploaded files +

      If $request->getFiles() is empty, the filter exits without doing anything.

      +
    4. +
    5. + Each uploaded file is valid enough to inspect +

      Oversized uploads that fail at the PHP upload layer are still blocked and logged with a specific reason.

      +
    6. +
    + +

    + The filter also recurses through nested file input arrays, so it protects both + single-file and multi-file form structures. +

    + +

    Allowed file types

    + +

    + The allowlist is defined through $allowedMimeMap. The filter + resolves an expected MIME from the client extension and rejects any extension + that does not map to an approved MIME. +

    + + + + + + + + + + + + + + + + + + + + + +
    MIME typeAllowed extensions
    image/jpegjpg, jpeg
    image/pngpng
    image/gifgif
    image/webpwebp
    application/pdfpdf
    application/msworddoc
    application/vnd.openxmlformats-officedocument.wordprocessingml.documentdocx
    application/vnd.oasis.opendocument.textodt
    text/rtf / application/rtfrtf
    application/vnd.ms-excelxls
    application/vnd.openxmlformats-officedocument.spreadsheetml.sheetxlsx
    application/vnd.oasis.opendocument.spreadsheetods
    text/csv / application/csvcsv
    text/plaintxt
    + +
    + ! +
    + Notable exclusions + SVG is explicitly removed. Archive formats such as zip, + rar, and 7z are blocked. The filter also separates + xlsx from old Excel MIME handling and keeps txt and + csv distinct. +
    +
    + +

    Blocked extensions

    + +

    + The guard maintains a large denylist in $blockedExtensions to + stop common executable, script, archive, config, and sensitive file types. +

    + + + + + + + + + + + + + + +
    CategoryExamples
    PHP / server codephp, phtml, phar, jsp, asp, aspx
    Scriptsjs, ts, jsx, tsx, sh, bash, ps1, bat, cmd
    Binariesexe, dll, msi, apk, deb, rpm, bin
    Archiveszip, rar, 7z, tar, gz, iso
    Config / secretsenv, ini, htaccess, htpasswd, key, pem, p12
    Database / logssql, db, sqlite, log, bak
    Markup / risky texthtml, htm, xhtml, xml, svg
    + +

    + Multiple extensions are handled defensively. If a filename like + invoice.php.pdf or report.jpg.js contains any blocked + extension in its middle segments, the file is rejected. +

    + +

    Validation flow

    + +

    + Each uploaded file passes through this validation order: +

    + +
      +
    1. + Upload validity check +

      If PHP reports an invalid upload and the error is a server/form size issue, the guard blocks immediately.

      +
    2. +
    3. + Filename safety check +

      Rejects null bytes, path separators, and filenames longer than 255 characters.

      +
    4. +
    5. + Multiple-extension detection +

      Rejects files that hide blocked extensions inside multi-part names.

      +
    6. +
    7. + Forbidden extension check +

      Rejects uploads whose client extension is directly on the blocked list.

      +
    8. +
    9. + File size limit +

      The hard application limit is 25 MB.

      +
    10. +
    11. + Real MIME detection +

      Uses PHP finfo(FILEINFO_MIME_TYPE) on the temporary uploaded file.

      +
    12. +
    13. + Expected MIME resolution +

      Maps the client extension to one expected MIME from the allowlist.

      +
    14. +
    15. + Magic byte validation +

      Checks the actual file header against known signatures for supported formats.

      +
    16. +
    17. + Strict MIME match +

      The detected MIME must match the expected MIME exactly; generic fallback MIME values are not accepted.

      +
    18. +
    + +
    if ($request->getMethod() !== 'post') {
    +    return;
    +}
    +
    +$files = $request->getFiles();
    +if (empty($files)) {
    +    return;
    +}
    + +

    Magic bytes check

    + +

    + The guard performs deep header checks using $magicBytes for + several formats: +

    + + + + + + + + + + + + + + +
    TypeSignature rule
    JPEGFF D8 FF
    PNG89 50 4E 47 0D 0A 1A 0A
    GIFGIF87a or GIF89a
    PDF%PDF-
    DOC / XLS (legacy)D0 CF 11 E0
    DOCX / XLSX / ODT / ODSPK 03 04
    WebPSpecial-case check for RIFF....WEBP
    + +

    + There is also an scanForEmbeddedCode() method in the filter, but + its invocation is currently commented out. The active protection path today is + the filename, extension, MIME, and magic-byte validation sequence. +

    + +

    Route coverage

    + +

    + This guard is registered in two relevant places: +

    + + + + + + + + + + + + + + + +
    LocationEffect
    app/Config/Filters.php global before filtersApplies the guard to incoming requests globally before controller execution.
    app/Config/Routes.php employeeRest groupAlso explicitly includes GlobalPostFileUploadGuard alongside rate-limit, app-signature, and JWT auth filters.
    + +
    $routes->group("employeeRest", ["filter" => ['GlobalPostFileUploadGuard', 'ratelimit', 'appSignature', 'authJWT']], function ($routes) {
    +    // upload-related endpoints live here
    +});
    + +

    Blocked response

    + +

    + When the filter rejects a file, it logs a critical event and immediately sends + a JSON error response with HTTP 403. +

    + + + + + + + + + + +
    Response fieldMeaning
    statuserror
    messageSecurity-policy rejection message including the reason.
    debugDetailed rejection reason only when ENVIRONMENT === 'development'.
    + +
    {
    +  "status": "error",
    +  "message": "File upload rejected: Security policy violation. Reason: MIME-extension mismatch.",
    +  "debug": "MIME-extension mismatch"
    +}
    + +

    + The log entry includes the block reason, client IP, URI, input field, original + filename, MIME, extension, and size. +

    + +

    Operational notes

    + +
      +
    1. + Controller code never sees blocked files +

      The filter sends the response directly and exits, so later controller logic does not run for rejected uploads.

      +
    2. +
    3. + Client extension alone is never trusted +

      The extension is only used to resolve the expected MIME; the real file MIME and header still have to match.

      +
    4. +
    5. + 25 MB is the app-level limit +

      Server-side PHP upload limits can still reject larger files earlier, and the filter explicitly handles that error path.

      +
    6. +
    7. + False positives are possible if MIME support differs by environment +

      Because the check is strict, any environment mismatch in MIME detection can cause a block until the allowlist is updated deliberately.

      +
    8. +
    + +
    + + +
    + Practical takeaway + This is a defensive upload gate, not just a UI validator. If a new file type + must be accepted, update the allowlist, magic-byte rules, and operational + expectations together rather than changing only the frontend. +
    +
    diff --git a/app/Views/docs/input-security.php b/app/Views/docs/input-security.php new file mode 100644 index 00000000..bfa27dbb --- /dev/null +++ b/app/Views/docs/input-security.php @@ -0,0 +1,315 @@ + + +

    + SecurityInputFilter is the request-level input guard for + high-confidence XSS detection. It inspects GET and POST values before + controller logic runs, canonicalizes user input to reduce encoding bypasses, + and blocks the request when a known dangerous pattern is detected. +

    + +
    + i +
    + Good name in Features + This docs page is listed as Input Security Guard because the + filter is not only about sanitizing forms. It is a request gate that checks + user-controlled input before normal application logic continues. +
    +
    + +

    Overview

    + +
    +
    +flowchart TD + A[Incoming request] --> B[Read GET and POST input] + B --> C{Any input present} + C -->|No| D[Allow request] + C -->|Yes| E[Canonicalize each value] + E --> F[Trim input] + F --> G[Check XSS patterns] + G --> H{Dangerous pattern found} + H -->|No| D + H -->|Yes| I[Log metadata and hash] + I --> J[Return 403 JSON] +
    +
    + +

    Where it runs

    + +

    + The filter is globally registered in app/Config/Filters.php in + the before chain, which means it runs for normal incoming web + requests unless the route is explicitly excluded. +

    + +
    'SecurityInputFilter' => SecurityInputFilter::class,
    +
    +'before' => [
    +    'SecurityInputFilter' => [
    +        'except' => [
    +            '/client/notification/create',
    +            '/ticket/crud_mail_template/*',
    +            'test_mail',
    +            'leads/sendMail',
    +            'ticket/reply'
    +        ]
    +    ],
    +]
    + +

    + The filter only reads: +

    + +
      +
    • $request->getGet()
    • +
    • $request->getPost()
    • +
    + +

    + It does not inspect uploaded file contents. File uploads are handled by the + separate GlobalPostFileUploadGuard filter. +

    + +

    What it checks

    + +

    + The filter uses a focused list of high-confidence XSS patterns to reduce false + positives while still blocking obvious injection attempts. +

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    CategoryExamples from the filter
    Script tags<script, </script>
    JavaScript execution schemesjavascript:, vbscript:, data:text/html
    Inline event handlersonclick=, onerror=, onload=
    Dangerous HTML tags<iframe, <object, <embed, <applet, <img
    SVG / MathML vectors<svg, <math
    Meta refresh payloads<meta http-equiv="refresh"
    Injected src/href handlersHTML tags using src=javascript: or href=data:
    + +
    + ! +
    + Detection, not rich sanitization + This filter is a blocker for clearly malicious input. It is not a full HTML + sanitizer for rich text fields. If a feature needs controlled HTML input, + design that path explicitly and make sure the route is handled appropriately. +
    +
    + +

    Canonicalization

    + +

    + Before pattern matching, the filter canonicalizes each value: +

    + +
      +
    1. + URL decode +

      Helps catch encoded payloads that would otherwise bypass naive matching.

      +
    2. +
    3. + HTML entity decode +

      Turns entity-encoded payloads into their real characters before detection.

      +
    4. +
    5. + Strip invisible control characters +

      Removes null bytes and other control characters from the evaluation string.

      +
    6. +
    7. + Trim the final value +

      Reduces noise before regex evaluation.

      +
    8. +
    + +
    private function canonicalize(string $value): string
    +{
    +    $value = urldecode($value);
    +    $value = html_entity_decode($value, ENT_QUOTES | ENT_HTML5, 'UTF-8');
    +
    +    return preg_replace('/[\x00-\x1F\x7F]/u', '', $value);
    +}
    + +

    + Array inputs are converted to JSON first, then canonicalized as a string. +

    + +

    Block behavior

    + +

    + When a pattern matches, the filter logs security metadata and immediately + returns a JSON 403 response: +

    + + + + + + + + + + + + + + +
    Logged fieldPurpose
    ipSource IP address
    methodRequest method
    uriCurrent request URL
    fieldInput field name
    attackStatic marker XSS_PATTERN
    lengthCanonicalized payload length
    hashSHA-256 hash of the canonicalized payload
    + +

    + The raw input value is not logged directly. The filter logs intent metadata and + a hash instead. +

    + +
    {
    +  "status": 403,
    +  "error": "Forbidden",
    +  "message": "Malicious input detected"
    +}
    + +

    Filter exceptions

    + +

    + Some routes are explicitly excluded from the global security-input filter: +

    + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Excluded routeWhy developers should care
    /client/notification/createGlobal input blocking does not run here.
    /ticket/crud_mail_template/*Template-editing paths often need richer content and should be handled deliberately.
    test_mailBypassed globally.
    leads/sendMailBypassed globally.
    ticket/replyBypassed globally.
    + +
    + ! +
    + Important developer rule + If you add a route to the exception list, you are taking responsibility for + validating and safely handling that input somewhere else in the request flow. +
    +
    + +

    Developer steps

    + +

    + When building a new form, endpoint, or feature that accepts user input, follow + this checklist: +

    + +
      +
    1. + Assume GET and POST are inspected automatically +

      If your route is not in the exception list, the filter already evaluates GET and POST fields before controller code runs.

      +
    2. +
    3. + Do not rely on this filter as your only validation +

      Business validation, field-level validation, and output escaping are still required.

      +
    4. +
    5. + Be careful with HTML-capable inputs +

      If a feature legitimately accepts formatted HTML, do not silently fight the filter. Design a safe path for that route and document why it needs special handling.

      +
    6. +
    7. + Only add filter exceptions deliberately +

      If you exclude a route in Filters.php, add compensating server-side sanitization or allowlist logic in the receiving code.

      +
    8. +
    9. + Test encoded attack strings too +

      Because the filter canonicalizes input, test URL-encoded and HTML-entity-encoded payloads in addition to plain strings.

      +
    10. +
    11. + Watch the logs when troubleshooting blocks +

      The filter logs a structured critical event named SECURITY_BLOCKED_REQUEST with a payload hash and field name.

      +
    12. +
    + +

    Common pitfalls

    + + + + + + + + + + + + + + + + + + + + + + + +
    PitfallWhy it happens
    A rich text feature keeps returning 403The submitted markup matches one of the high-confidence XSS patterns, and the route is still under the global filter.
    Input looks harmless in raw form but still gets blockedThe canonicalization step decoded the payload into a dangerous form before matching.
    A developer adds an exception without extra protectionThe route bypasses the global blocker and now depends entirely on downstream validation.
    Files are assumed to be covered hereUploaded file contents are handled by the separate file-upload guard, not this filter.
    + +
    + + +
    + Practical takeaway + Treat SecurityInputFilter as the first request-level XSS tripwire. + Keep it on by default, make exceptions rarely, and document every exception + with the safer validation path that replaces it. +
    +
    diff --git a/app/Views/docs/installation.php b/app/Views/docs/installation.php new file mode 100644 index 00000000..6014dda5 --- /dev/null +++ b/app/Views/docs/installation.php @@ -0,0 +1,175 @@ + + +

    + This guide walks you through setting up the project on a local development machine. + For production deployment, see the Deployment page. +

    + +
    + ℹ️ +
    + Before you begin + Make sure PHP 8.1+, Composer 2.x, and MySQL 5.7+ are installed on your machine. +
    +
    + + +

    Requirements

    + + + + + + + + + + + +
    DependencyVersionNotes
    PHP8.1+Required by CI 4.4+
    MySQL5.7 / 8.0Primary database
    Composer2.xDependency management
    Node.js18+ (optional)Only needed for asset pipeline
    + + +

    Steps

    + +
      +
    1. + Clone the repository +
      + terminal + bash +
      +
      git clone https://github.com/your-org/myapp.git
      +cd myapp
      +
    2. + +
    3. + Install PHP dependencies +
      composer install
      +
    4. + +
    5. + Copy the environment file +
      cp env .env
      +

      Edit .env with your local database credentials and base URL.

      +
    6. + +
    7. + Run migrations and seeders +
      php spark migrate
      +php spark db:seed MainSeeder
      +
    8. + +
    9. + Start the dev server +
      php spark serve
      +

      App will be available at http://localhost:8080.

      +
    10. +
    + +
    + ⚠️ +
    + Use 127.0.0.1, not localhost + MySQL on some setups resolves localhost to a socket path instead of TCP. + Use 127.0.0.1 in .env to avoid connection errors. +
    +
    + + +

    Configuration

    + +

    Key variables to configure in .env:

    + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    VariableDefaultDescription
    + CI_ENVIRONMENT + required + productionSet to development locally to enable error display.
    + database.default.hostname + required + MySQL hostname. Use 127.0.0.1.
    + app.baseURL + required + Full URL with trailing slash. e.g. http://localhost:8080/
    + JWT_SECRET + optional + Only needed if JWT API auth is enabled.
    + +
    + 🚫 +
    + Never commit .env + The file is in .gitignore. Use your CI/CD secrets manager for production values. +
    +
    + + +

    Sample flowchart

    + +

    + Use Mermaid blocks when a setup, request path, or job pipeline is easier to explain + visually. Any docs page can now render a diagram by adding a + <div class="mermaid"> block like the example below. +

    + +
    + i +
    + Reusable in other docs pages + Copy this section structure into any docs view and replace the diagram text with + your own flow. +
    +
    + +
    +
    +flowchart TD + A[Clone repository] --> B[Install Composer dependencies] + B --> C[Copy env to .env] + C --> D[Update database and app settings] + D --> E[Run migrations and seeders] + E --> F[Start local server] + F --> G[Open docs or app in browser] +
    +
    + +
    <div class="mermaid">
    +flowchart TD
    +    A[Start] --> B[Process]
    +    B --> C[Done]
    +</div>
    diff --git a/app/Views/docs/installation_content.php b/app/Views/docs/installation_content.php new file mode 100644 index 00000000..bf0b6e9a --- /dev/null +++ b/app/Views/docs/installation_content.php @@ -0,0 +1,124 @@ + + +

    + This guide walks you through setting up the project on a local development machine. + For production deployment, see the Deployment page. +

    + +
    + ℹ️ +
    + Before you begin + Make sure PHP 8.1+, Composer 2.x, and MySQL 5.7+ are installed on your machine. +
    +
    + + +

    Requirements

    + + + + + + + + + + + +
    DependencyVersionNotes
    PHP8.1+Required by CI 4.4+
    MySQL5.7 / 8.0Primary database
    Composer2.xDependency management
    Node.js18+ (optional)Only for asset pipeline
    + + +

    Steps

    + +
      +
    1. + Clone the repository +
      + terminal + bash +
      +
      git clone https://github.com/your-org/myapp.git
      +cd myapp
      +
    2. +
    3. + Install PHP dependencies +
      composer install
      +
    4. +
    5. + Copy the environment file +
      cp env .env
      +

      Edit .env with your local database credentials and base URL.

      +
    6. +
    7. + Run migrations and seeders +
      php spark migrate
      +php spark db:seed MainSeeder
      +
    8. +
    9. + Start the dev server +
      php spark serve
      +

      App will be available at http://localhost:8080.

      +
    10. +
    + +
    + ⚠️ +
    + Use 127.0.0.1, not localhost + MySQL on some setups resolves localhost to a socket path instead of TCP. + Use 127.0.0.1 in .env to avoid connection errors. +
    +
    + + +

    Configuration

    + +

    Key variables to configure in .env:

    + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    VariableDefaultDescription
    CI_ENVIRONMENT requiredproductionSet to development locally for detailed error display.
    database.default.hostname requiredMySQL hostname. Use 127.0.0.1.
    app.baseURL requiredFull URL with trailing slash. e.g. http://localhost:8080/
    JWT_SECRET optionalOnly needed if JWT API auth is enabled.
    + +
    + 🚫 +
    + Never commit .env + The file is in .gitignore. Use your CI/CD secrets manager for production values. +
    +
    diff --git a/app/Views/docs/partials/README.md b/app/Views/docs/partials/README.md new file mode 100644 index 00000000..8305067e --- /dev/null +++ b/app/Views/docs/partials/README.md @@ -0,0 +1,114 @@ +# CI4 Dev Docs + +Docs pages live in `app/Views/docs/`. +Shared layout partials live in `app/Views/docs/partials/`. + +## File list + +| File | Purpose | +|------------------------|----------------------------------------------------------------| +| `docs_header.php` | ``, CSS tokens, top bar, opens `
    ` | +| `docs_sidebar.php` | Left nav sidebar — edit the `$nav` array to add/remove pages | +| `docs_main_open.php` | Opens `
    `, renders breadcrumb, h1, meta row | +| `docs_main_close.php` | Closes `
    `, prev/next nav, right TOC, closes layout div | +| `docs_footer.php` | Global footer bar, hljs init, closes `` | +| `installation.php` | **Sample content-only page** — copy this as the template for every new page | + +--- + +## How to use + +With `DocsController`, each docs page should contain **content only**. +Do not render `docs_header`, `docs_sidebar`, `docs_main_open`, `docs_main_close`, +or `docs_footer` inside individual page views, because the controller already +wraps the page with the full layout. + +Each docs page should look like this: + +```php + + +

    ...

    +

    Section One

    +

    ...

    +``` + +--- + +## Adding a new page to the sidebar + +Open `app/Controllers/Docs/DocsController.php` and update: + +1. The `$nav` array to add a sidebar link. +2. The `$pages` array to map the slug to its view and metadata. + +Example: + +```php +['id' => 'my-new-page', 'label' => 'My New Page', 'url' => 'docs/my-new-page'], + +'my-new-page' => [ + 'view' => 'docs/my-new-page', + 'title' => 'My New Page', + 'breadcrumb' => 'Getting Started', + 'last_updated' => 'May 2026', + 'author' => 'Core Team', + 'read_time' => '3 min read', + 'toc' => [ + ['label' => 'Section One', 'href' => '#section-one'], + ], + 'prev' => null, + 'next' => null, +], +``` + +Then create `app/Views/docs/my-new-page.php` by copying `installation.php` +and updating only the page content. + +--- + +## Available content components + +All CSS is in `docs_header.php`. These classes are ready to use in any page: + +| Class / Element | What it renders | +|---------------------|----------------------------------------| +| `

    ` `

    ` | Section headings (id required for TOC scroll) | +| `.callout.info` | Blue info box | +| `.callout.warning` | Amber warning box | +| `.callout.danger` | Red danger box | +| `.callout.success` | Green success box | +| `
      ` | Numbered step list with connector lines | +| `` | Styled data / param / API tables | +| `.badge.get/post/put/delete` | HTTP method badges | +| `.badge.req` / `.badge.opt` | Required / Optional param badges | +| `.param-name` | Monospace blue param name in tables | +| `.code-header` + `
      ` | Dark code block with filename header |
      +
      +---
      +
      +## Route setup (CI4)
      +
      +Add a catch-all route in `app/Config/Routes.php`:
      +
      +```php
      +$routes->get('docs',          'Docs\DocsController::index');
      +$routes->get('docs/(:segment)', 'Docs\DocsController::page/$1');
      +```
      +
      +Then in `DocsController`, let the controller render the content view and wrap it:
      +
      +```php
      +public function page(string $slug): string
      +{
      +    $config = $this->getPageConfig($slug);
      +    $content = $this->renderContentView($config['view'], $config);
      +
      +    return $this->renderDocPage($config, $content);
      +}
      +```
      diff --git a/app/Views/docs/partials/docs_footer.php b/app/Views/docs/partials/docs_footer.php
      new file mode 100644
      index 00000000..5a1a96af
      --- /dev/null
      +++ b/app/Views/docs/partials/docs_footer.php
      @@ -0,0 +1,542 @@
      +
      +
      +
      +
      +
      +
      +
      +
      +
      +
      +
      + © . Internal developer docs. + + Built with CodeIgniter 4  ·  + Changelog  ·  + Contributing + +
      + + + diff --git a/app/Views/docs/partials/docs_header.php b/app/Views/docs/partials/docs_header.php new file mode 100644 index 00000000..2b8a8e6e --- /dev/null +++ b/app/Views/docs/partials/docs_header.php @@ -0,0 +1,288 @@ + 'Installation']) ?> + * + * Variables: + * $doc_title (string) — page title shown in and breadcrumb + * $app_name (string) — defaults to 'Nhance PAM' + * $app_version (string) — defaults to 'v1.1.0' + */ + +$doc_title = $doc_title ?? 'Documentation'; +$app_name = $app_name ?? 'Nhance PAM'; +$app_version = $app_version ?? 'v1.1.0'; +$base = base_url(); +?> +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title><?= esc($doc_title) ?> — <?= esc($app_name) ?> Doc, <?= esc($app_version) ?> + + + + + + + + + + + + + + +
      + + +
      + +
      +
      + + +
      diff --git a/app/Views/docs/partials/docs_main_close.php b/app/Views/docs/partials/docs_main_close.php new file mode 100644 index 00000000..afc4fb93 --- /dev/null +++ b/app/Views/docs/partials/docs_main_close.php @@ -0,0 +1,112 @@ + + + + + + + + + + + + + + +
      + + diff --git a/app/Views/docs/partials/docs_main_open.php b/app/Views/docs/partials/docs_main_open.php new file mode 100644 index 00000000..8e53fffe --- /dev/null +++ b/app/Views/docs/partials/docs_main_open.php @@ -0,0 +1,142 @@ + + + + + +
      + + + +
      + Docs + + + + + + +
      + + + +

      + + +
      + 📅 Last updated: + ✍️ + +
      + + diff --git a/app/Views/docs/partials/docs_sidebar.php b/app/Views/docs/partials/docs_sidebar.php new file mode 100644 index 00000000..99f60501 --- /dev/null +++ b/app/Views/docs/partials/docs_sidebar.php @@ -0,0 +1,175 @@ + 'installation']) ?> + * + * Variables: + * $active_page (string) — slug matching the 'id' on each nav item below + * $nav (array) — optional nav injected by DocsController + * + * To add a page: add an entry to the $nav array below. + * To add a section: add a new group with a 'label' key. + */ + +$active_page = $active_page ?? ''; + +$nav = $nav ?? [ + [ + 'label' => 'Getting Started', + 'items' => [ + ['id' => 'introduction', 'label' => 'Introduction', 'url' => 'docs/introduction'], + ['id' => 'installation', 'label' => 'Installation', 'url' => 'docs/installation'], + ['id' => 'configuration', 'label' => 'Configuration', 'url' => 'docs/configuration'], + ['id' => 'env-setup', 'label' => 'Environment Setup', 'url' => 'docs/env-setup'], + ], + ], + [ + 'label' => 'Architecture', + 'items' => [ + ['id' => 'project-structure', 'label' => 'Project Structure', 'url' => 'docs/project-structure'], + ['id' => 'routing', 'label' => 'Routing', 'url' => 'docs/routing'], + ['id' => 'controllers', 'label' => 'Controllers', 'url' => 'docs/controllers'], + ['id' => 'models', 'label' => 'Models', 'url' => 'docs/models'], + ['id' => 'services', 'label' => 'Services', 'url' => 'docs/services'], + ['id' => 'helpers', 'label' => 'Helpers', 'url' => 'docs/helpers'], + ], + ], + [ + 'label' => 'Features', + 'items' => [ + ['id' => 'authentication', 'label' => 'Authentication', 'url' => 'docs/authentication'], + ['id' => 'acl', 'label' => 'ACL / Access Control', 'url' => 'docs/acl'], + ['id' => 'input-security', 'label' => 'Input Security Guard', 'url' => 'docs/input-security'], + ['id' => 'file-uploads', 'label' => 'File Upload Guard', 'url' => 'docs/file-uploads'], + ['id' => 'background-jobs', 'label' => 'Background Jobs', 'url' => 'docs/background-jobs'], + ['id' => 'visit-onboard', 'label' => 'Visit onboard', 'url' => 'docs/visit-onboard'], + ['id' => 'visit-offboard', 'label' => 'Visit offboard', 'url' => 'docs/visit-offboard'], + ['id' => 'notifications', 'label' => 'Email / Notifications', 'url' => 'docs/notifications'], + ], + ], + [ + 'label' => 'API Reference', + 'items' => [ + ['id' => 'endpoints', 'label' => 'Endpoints', 'url' => 'docs/endpoints'], + ['id' => 'request-response','label' => 'Request / Response', 'url' => 'docs/request-response'], + ['id' => 'error-codes', 'label' => 'Error Codes', 'url' => 'docs/error-codes'], + ], + ], + [ + 'label' => 'DevOps', + 'items' => [ + ['id' => 'deployment', 'label' => 'Deployment', 'url' => 'docs/deployment'], + ['id' => 'cicd', 'label' => 'CI/CD Pipeline', 'url' => 'docs/cicd'], + ['id' => 's3-cloudfront', 'label' => 'S3 & CloudFront', 'url' => 'docs/s3-cloudfront'], + ], + ], + [ + 'label' => 'Reference', + 'items' => [ + ['id' => 'changelog', 'label' => 'Changelog', 'url' => 'docs/changelog'], + ['id' => 'contributing', 'label' => 'Contributing', 'url' => 'docs/contributing'], + ], + ], +]; +?> + + + + + diff --git a/app/Views/docs/partials/installation.php b/app/Views/docs/partials/installation.php new file mode 100644 index 00000000..38f43807 --- /dev/null +++ b/app/Views/docs/partials/installation.php @@ -0,0 +1,137 @@ + + +

      + This guide walks you through setting up the project on a local development machine. + For production deployment, see the Deployment page. +

      + +
      + ℹ️ +
      + Before you begin + Make sure PHP 8.1+, Composer 2.x, and MySQL 5.7+ are installed on your machine. +
      +
      + + +

      Requirements

      + +
      + + + + + + + + + +
      DependencyVersionNotes
      PHP8.1+Required by CI 4.4+
      MySQL5.7 / 8.0Primary database
      Composer2.xDependency management
      Node.js18+ (optional)Only needed for asset pipeline
      + + +

      Steps

      + +
        +
      1. + Clone the repository +
        + terminal + bash +
        +
        git clone https://github.com/your-org/myapp.git
        +cd myapp
        +
      2. + +
      3. + Install PHP dependencies +
        composer install
        +
      4. + +
      5. + Copy the environment file +
        cp env .env
        +

        Edit .env with your local database credentials and base URL.

        +
      6. + +
      7. + Run migrations and seeders +
        php spark migrate
        +php spark db:seed MainSeeder
        +
      8. + +
      9. + Start the dev server +
        php spark serve
        +

        App will be available at http://localhost:8080.

        +
      10. +
      + +
      + ⚠️ +
      + Use 127.0.0.1, not localhost + MySQL on some setups resolves localhost to a socket path instead of TCP. + Use 127.0.0.1 in .env to avoid connection errors. +
      +
      + + +

      Configuration

      + +

      Key variables to configure in .env:

      + + + + + + + + + + + + + + + + + + + + + + + + + + + +
      VariableDefaultDescription
      + CI_ENVIRONMENT + required + productionSet to development locally to enable error display.
      + database.default.hostname + required + MySQL hostname. Use 127.0.0.1.
      + app.baseURL + required + Full URL with trailing slash. e.g. http://localhost:8080/
      + JWT_SECRET + optional + Only needed if JWT API auth is enabled.
      + +
      + 🚫 +
      + Never commit .env + The file is in .gitignore. Use your CI/CD secrets manager for production values. +
      +
      diff --git a/app/Views/docs/partials/installation_content.php b/app/Views/docs/partials/installation_content.php new file mode 100644 index 00000000..bf0b6e9a --- /dev/null +++ b/app/Views/docs/partials/installation_content.php @@ -0,0 +1,124 @@ + + +

      + This guide walks you through setting up the project on a local development machine. + For production deployment, see the Deployment page. +

      + +
      + ℹ️ +
      + Before you begin + Make sure PHP 8.1+, Composer 2.x, and MySQL 5.7+ are installed on your machine. +
      +
      + + +

      Requirements

      + + + + + + + + + + + +
      DependencyVersionNotes
      PHP8.1+Required by CI 4.4+
      MySQL5.7 / 8.0Primary database
      Composer2.xDependency management
      Node.js18+ (optional)Only for asset pipeline
      + + +

      Steps

      + +
        +
      1. + Clone the repository +
        + terminal + bash +
        +
        git clone https://github.com/your-org/myapp.git
        +cd myapp
        +
      2. +
      3. + Install PHP dependencies +
        composer install
        +
      4. +
      5. + Copy the environment file +
        cp env .env
        +

        Edit .env with your local database credentials and base URL.

        +
      6. +
      7. + Run migrations and seeders +
        php spark migrate
        +php spark db:seed MainSeeder
        +
      8. +
      9. + Start the dev server +
        php spark serve
        +

        App will be available at http://localhost:8080.

        +
      10. +
      + +
      + ⚠️ +
      + Use 127.0.0.1, not localhost + MySQL on some setups resolves localhost to a socket path instead of TCP. + Use 127.0.0.1 in .env to avoid connection errors. +
      +
      + + +

      Configuration

      + +

      Key variables to configure in .env:

      + + + + + + + + + + + + + + + + + + + + + + + + + + + +
      VariableDefaultDescription
      CI_ENVIRONMENT requiredproductionSet to development locally for detailed error display.
      database.default.hostname requiredMySQL hostname. Use 127.0.0.1.
      app.baseURL requiredFull URL with trailing slash. e.g. http://localhost:8080/
      JWT_SECRET optionalOnly needed if JWT API auth is enabled.
      + +
      + 🚫 +
      + Never commit .env + The file is in .gitignore. Use your CI/CD secrets manager for production values. +
      +
      diff --git a/app/Views/docs/s3-cloudfront.php b/app/Views/docs/s3-cloudfront.php new file mode 100644 index 00000000..bb34d073 --- /dev/null +++ b/app/Views/docs/s3-cloudfront.php @@ -0,0 +1,524 @@ + + +

      + This page documents the current dev-side asset deployment script that uploads + files to an S3 bucket and then invalidates a selected CloudFront distribution. + The script is interactive and is intended for operator-driven publishing rather + than unattended release automation. +

      + +
      + i +
      + Dev-focused workflow + The process below is documented from the Windows batch script currently used + for S3 upload plus CloudFront invalidation in the dev workflow. +
      +
      + +

      Overview

      + +
      +
      +flowchart TD + A[Start batch script] --> B[List S3 buckets] + B --> C[Select bucket] + C --> D[List CloudFront distributions] + D --> E[Select distribution] + E --> F[Confirm selection] + F --> G[Delete existing S3 files] + G --> H[Sync new files] + H --> I[Create CloudFront invalidation] + I --> J[Wait 45 seconds] + J --> K[Poll invalidation status] + K --> L[Finish when Completed] +
      +
      + +

      Prerequisites

      + +

      + Before running the script, the local machine must already be able to execute + AWS CLI commands successfully. +

      + + + + + + + + + + + + + + + + + + + + + + + +
      RequirementPurpose
      aws s3 lsLists available buckets for the operator to choose from.
      aws cloudfront list-distributionsLists distributions so the operator can choose the target invalidation.
      AWS credentials already configuredThe script assumes AWS CLI authentication is already working.
      Correct working directoryaws s3 sync . ... uploads from the current folder, so the script must be run from the directory containing the files to publish.
      + +

      Selection flow

      + +

      + The script begins with two interactive selections: +

      + +
        +
      1. + Select the target S3 bucket +

        It runs aws s3 ls, numbers the available buckets, and stores the selected bucket as S3_BUCKET.

        +
      2. +
      3. + Select the target CloudFront distribution +

        It runs aws cloudfront list-distributions and shows the distribution ID, domain name, and comment for each available entry.

        +
      4. +
      5. + Confirm before deploy +

        The operator can proceed, re-choose the bucket/distribution pair, or exit before any destructive operation starts.

        +
      6. +
      + +

      + The current UI-side mapping used for bucket to CloudFront pairing is: +

      + + + + + + + + + + + + + + + + + + + + + + + +
      S3 bucketCloudFront distribution ID
      uat-benefits-app-bucketEUBZ8CDSV9KZZ
      uat-hr-app-bucketE9TNPRI9ITM1M
      benefits-app-bucketE1MKRK4U5MZ3BD
      live-hr-app-bucketE3TE01DPKHTD8B
      + +

      + These pairs are aligned with the bucket-to-distribution mapping currently used + in app/Views/fedeploy.php. +

      + +
      + ! +
      + Interactive by design + This script is not written as a fixed one-click pipeline. It deliberately + pauses for operator selection and confirmation before deployment begins. +
      +
      + +

      Deployment steps

      + +

      + Once confirmed, the script executes five sequential steps: +

      + + + + + + + + + + + + + + + + + + + + + + + + + + + +
      StepWhat it does
      1/5Deletes the existing contents of the selected S3 bucket with aws s3 rm --recursive.
      2/5Syncs the current directory into the bucket with aws s3 sync, excluding *.bat and *.bat.*.
      3/5Creates a CloudFront invalidation for /* and captures the invalidation ID.
      4/5Waits 45 seconds before the first status check.
      5/5Polls invalidation status until it becomes Completed or retries are exhausted.
      + +
      aws s3 rm "%S3_BUCKET%" --recursive
      +aws s3 sync . "%S3_BUCKET%" --exclude "*.bat" --exclude "*.bat.*"
      +aws cloudfront create-invalidation --distribution-id %DIST_ID% --paths "/*"
      + +
      + ! +
      + Bucket cleanup is destructive + The script removes existing files from the selected S3 bucket before syncing + the new content. Confirm the selected bucket carefully before proceeding. +
      +
      + +

      Invalidation polling

      + +

      + The script includes explicit status handling for CloudFront invalidation: +

      + + + + + + + + + + + + + + + + + + + + + + +
      SettingValuePurpose
      WAIT_SECONDS45Initial wait before the first invalidation status check.
      MAX_STATUS_RETRIES20Upper bound for repeated status polling attempts.
      Retry delay15 secondsPause between repeated invalidation status checks.
      + +

      + If AWS returns an error or the status is empty, the script retries until the + maximum retry count is reached. If the returned status becomes + Completed, the deployment is treated as successful. +

      + +
      Completed
      +InProgress
      +
      + +

      Operational notes

      + +
        +
      1. + Run from the correct publish directory +

        The script syncs the current directory, so it should be started only from the folder whose contents should go to S3.

        +
      2. +
      3. + Verify the chosen bucket and distribution before confirming +

        The confirmation step exists to prevent publishing to the wrong S3 bucket or invalidating the wrong distribution.

        +
      4. +
      5. + Keep batch files out of the published output +

        The script explicitly excludes batch files during sync so deployment helpers are not uploaded into the target bucket.

        +
      6. +
      7. + Watch for invalidation completion +

        The script does not finish immediately after creating the invalidation; it waits and polls until the status is complete or retries are exhausted.

        +
      8. +
      + +
      + + +
      + Practical use + Use this workflow when dev static assets or frontend build output must be + refreshed in S3 and then propagated through CloudFront without waiting for + normal cache expiry. +
      +
      + +

      Full script

      + +

      + Full reference copy of the current Windows batch script: +

      + +
      @echo off
      +setlocal EnableDelayedExpansion
      +
      +REM ==============================
      +REM CONFIGURATION
      +REM ==============================
      +set WAIT_SECONDS=45
      +set MAX_STATUS_RETRIES=20
      +
      +REM ==============================
      +REM SELECT S3 BUCKET
      +REM ==============================
      +:SELECT_BUCKET
      +cls
      +echo =========================================
      +echo    AVAILABLE S3 BUCKETS
      +echo =========================================
      +echo.
      +
      +set count=0
      +for /f "tokens=3 delims= " %%a in ('aws s3 ls') do (
      +    set /a count+=1
      +    set bucket[!count!]=%%a
      +    echo !count!. %%a
      +)
      +
      +if %count%==0 (
      +    echo [ERROR] No S3 buckets found.
      +    pause
      +    exit /b 1
      +)
      +
      +echo.
      +set /p bucketChoice=Select bucket number:
      +
      +if not defined bucket[%bucketChoice%] (
      +    echo [ERROR] Invalid selection.
      +    ping -n 3 127.0.0.1 >nul
      +    goto SELECT_BUCKET
      +)
      +
      +set S3_BUCKET=s3://!bucket[%bucketChoice%]!
      +
      +REM ==============================
      +REM SELECT CLOUDFRONT DISTRIBUTION
      +REM ==============================
      +:SELECT_CF
      +cls
      +echo =========================================
      +echo    AVAILABLE CLOUDFRONT DISTRIBUTIONS
      +echo =========================================
      +echo.
      +
      +set cfcount=0
      +for /f "tokens=1,2,3 delims=  " %%a in ('aws cloudfront list-distributions --query "DistributionList.Items[*].[Id,DomainName,Comment]" --output text') do (
      +    set /a cfcount+=1
      +    set cfid[!cfcount!]=%%a
      +    echo !cfcount!. ID: %%a
      +    echo     Domain: %%b
      +    echo     Desc  : %%c
      +    echo.
      +)
      +
      +if %cfcount%==0 (
      +    echo [ERROR] No CloudFront distributions found.
      +    pause
      +    exit /b 1
      +)
      +
      +set /p cfChoice=Select CloudFront number:
      +
      +if not defined cfid[%cfChoice%] (
      +    echo [ERROR] Invalid selection.
      +    ping -n 3 127.0.0.1 >nul
      +    goto SELECT_CF
      +)
      +
      +set DIST_ID=!cfid[%cfChoice%]!
      +
      +REM ==============================
      +REM CONFIRM SELECTION
      +REM ==============================
      +:CONFIRM
      +cls
      +echo =========================================
      +echo    CONFIRM YOUR SELECTION
      +echo =========================================
      +echo.
      +echo   S3 Bucket  : %S3_BUCKET%
      +echo   CloudFront : %DIST_ID%
      +echo.
      +echo   1. Proceed
      +echo   2. Re-choose
      +echo   3. Exit
      +echo.
      +
      +set /p confirmChoice=Enter choice:
      +
      +if "%confirmChoice%"=="1" goto DEPLOY
      +if "%confirmChoice%"=="2" goto SELECT_BUCKET
      +if "%confirmChoice%"=="3" exit /b 0
      +
      +goto CONFIRM
      +
      +
      +REM ==============================
      +REM DEPLOYMENT FLOW
      +REM ==============================
      +:DEPLOY
      +cls
      +echo =========================================
      +echo    DEPLOYMENT STARTED
      +echo =========================================
      +echo.
      +
      +REM --------------------------------------------------
      +REM STEP 1: REMOVE EXISTING S3 FILES
      +REM --------------------------------------------------
      +echo [1/5] Removing existing files from %S3_BUCKET%...
      +aws s3 rm "%S3_BUCKET%" --recursive
      +if %ERRORLEVEL% NEQ 0 (
      +    echo [ERROR] S3 DELETE FAILED. Aborting.
      +    exit /b 1
      +)
      +echo [OK] S3 cleanup completed.
      +echo.
      +
      +REM --------------------------------------------------
      +REM STEP 2: SYNC NEW FILES
      +REM FIX B1+B2: Quoted S3_BUCKET, exclude .bat files from sync
      +REM --------------------------------------------------
      +echo [2/5] Uploading new files to %S3_BUCKET%...
      +aws s3 sync . "%S3_BUCKET%" --exclude "*.bat" --exclude "*.bat.*"
      +if %ERRORLEVEL% NEQ 0 (
      +    echo [ERROR] S3 SYNC FAILED. Aborting.
      +    exit /b 1
      +)
      +echo [OK] S3 sync completed.
      +echo.
      +
      +REM --------------------------------------------------
      +REM STEP 3: CREATE CLOUDFRONT INVALIDATION
      +REM FIX B3: Pre-clear variable before reading temp file
      +REM FIX B4: Use for /f to read temp file -- strips \r automatically
      +REM --------------------------------------------------
      +echo [3/5] Creating CloudFront invalidation...
      +
      +set INVALIDATION_ID=
      +aws cloudfront create-invalidation --distribution-id %DIST_ID% --paths "/*" --query "Invalidation.Id" --output text > "%TEMP%\cf_inv_id.txt" 2>"%TEMP%\cf_inv_err.txt"
      +
      +if %ERRORLEVEL% NEQ 0 (
      +    echo [ERROR] CloudFront invalidation creation failed.
      +    echo --- AWS Error Output ---
      +    type "%TEMP%\cf_inv_err.txt"
      +    del "%TEMP%\cf_inv_id.txt"  >nul 2>&1
      +    del "%TEMP%\cf_inv_err.txt" >nul 2>&1
      +    exit /b 1
      +)
      +
      +REM for /f auto-strips \r\n giving a clean value -- fixes the critical \r bug
      +for /f "usebackq delims=" %%i in ("%TEMP%\cf_inv_id.txt") do set INVALIDATION_ID=%%i
      +del "%TEMP%\cf_inv_id.txt"  >nul 2>&1
      +del "%TEMP%\cf_inv_err.txt" >nul 2>&1
      +
      +if "%INVALIDATION_ID%"=="" (
      +    echo [ERROR] Invalidation ID was empty after creation. Aborting.
      +    exit /b 1
      +)
      +
      +echo [OK] Invalidation created.
      +echo       Invalidation ID : %INVALIDATION_ID%
      +echo       Distribution ID : %DIST_ID%
      +echo.
      +
      +REM --------------------------------------------------
      +REM STEP 4: WAIT 45 SECONDS
      +REM --------------------------------------------------
      +set /a PING_COUNT=%WAIT_SECONDS%+1
      +echo [4/5] Waiting %WAIT_SECONDS% seconds before polling...
      +ping -n %PING_COUNT% 127.0.0.1 >nul
      +echo [OK] Wait complete.
      +echo.
      +
      +REM --------------------------------------------------
      +REM STEP 5: POLL INVALIDATION STATUS
      +REM FIX B5: for /f strips \r so STATUS matches "Completed" correctly
      +REM FIX B6: Retry counter prevents infinite loop on AWS errors
      +REM --------------------------------------------------
      +set retryCount=0
      +
      +:CHECK_STATUS
      +cls
      +echo =========================================
      +echo    Checking CloudFront Invalidation Status
      +echo =========================================
      +echo.
      +echo   Invalidation ID : %INVALIDATION_ID%
      +echo   Distribution ID : %DIST_ID%
      +echo.
      +
      +set STATUS=
      +aws cloudfront get-invalidation --distribution-id %DIST_ID% --id %INVALIDATION_ID% --query "Invalidation.Status" --output text > "%TEMP%\cf_status.txt" 2>"%TEMP%\cf_status_err.txt"
      +
      +if %ERRORLEVEL% NEQ 0 (
      +    echo [WARN] AWS call failed. Error output:
      +    type "%TEMP%\cf_status_err.txt"
      +    del "%TEMP%\cf_status.txt"     >nul 2>&1
      +    del "%TEMP%\cf_status_err.txt" >nul 2>&1
      +    set /a retryCount+=1
      +    if !retryCount! GEQ %MAX_STATUS_RETRIES% (
      +        echo [ERROR] Max retries ^(%MAX_STATUS_RETRIES%^) reached. Deployment status unknown.
      +        exit /b 1
      +    )
      +    echo Retrying in 15 seconds... [Attempt !retryCount!/%MAX_STATUS_RETRIES%]
      +    ping -n 16 127.0.0.1 >nul
      +    goto CHECK_STATUS
      +)
      +
      +for /f "usebackq delims=" %%s in ("%TEMP%\cf_status.txt") do set STATUS=%%s
      +del "%TEMP%\cf_status.txt"     >nul 2>&1
      +del "%TEMP%\cf_status_err.txt" >nul 2>&1
      +
      +if "%STATUS%"=="" (
      +    set /a retryCount+=1
      +    echo [WARN] Empty status received.
      +    if !retryCount! GEQ %MAX_STATUS_RETRIES% (
      +        echo [ERROR] Max retries ^(%MAX_STATUS_RETRIES%^) reached. Aborting.
      +        exit /b 1
      +    )
      +    echo Retrying in 15 seconds... [Attempt !retryCount!/%MAX_STATUS_RETRIES%]
      +    ping -n 16 127.0.0.1 >nul
      +    goto CHECK_STATUS
      +)
      +
      +echo   Current Status  : %STATUS%
      +echo.
      +
      +if /I "%STATUS%"=="Completed" (
      +    echo =========================================
      +    echo    INVALIDATION COMPLETED SUCCESSFULLY
      +    echo =========================================
      +    goto END_SCRIPT
      +)
      +
      +set /a retryCount+=1
      +if !retryCount! GEQ %MAX_STATUS_RETRIES% (
      +    echo [ERROR] Max retries ^(%MAX_STATUS_RETRIES%^) reached. Last status: %STATUS%
      +    exit /b 1
      +)
      +
      +echo [INFO] Still "%STATUS%". Checking again in 15 seconds... [Attempt !retryCount!/%MAX_STATUS_RETRIES%]
      +ping -n 16 127.0.0.1 >nul
      +goto CHECK_STATUS
      +
      +REM ==============================
      +REM DONE
      +REM ==============================
      +:END_SCRIPT
      +echo.
      +echo =========================================
      +echo    DEPLOYMENT FINISHED SUCCESSFULLY
      +echo =========================================
      +echo.
      +pause
      +exit /b 0
      diff --git a/app/Views/docs/tpa-recon.php b/app/Views/docs/tpa-recon.php new file mode 100644 index 00000000..f5273124 --- /dev/null +++ b/app/Views/docs/tpa-recon.php @@ -0,0 +1,308 @@ + + +

      What this is

      + +

      + TPA Recon (reconciliation) compares Nhance enrolment data with a + TPA API dump stored in tpa_api_data for a given batch file. HR can see + who exists only in Nhance, only in the TPA file, or in both with field differences, then take actions: + inception upload for missing members, direct DB updates for mismatches, and a deletion pipeline when + the TPA marks rows for deletion. +

      + +

      + The flow is intentionally split across EmployeeController (report, proceed, heavy logic), + EmployeePolicyModel::getTPADataVariationReport (SQL slices), and + EmployeeServiceController (Excel pipelines and queued follow-up jobs). Read this page top + to bottom once; use the checklist at the end when you touch production data. +

      + +

      Glossary

      + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
      TermMeaning here
      batch_files.id (often called file_id in code)The batch row for the TPA import / variation context. tpa_api_data.file_id points at the same id.
      tpa_api_dataOne row per TPA member line for that file: emp_code, name, dob, gender, relation, optional ref (FK to employee_polices.id), rec_type, action_flag_status (e.g. D for deletion intent), is_active.
      rec_typeSnapshot classification on tpa_api_data: matched, need_to_review, or not_in_nhance. Used to speed up repeat UI loads after the first full compute.
      refWhen set, links a TPA row to employee_polices.id after strict matching in reconTpaApiDataWithEmployeepolicies.
      Not in TPANhance has an active policy member for the client/policy, but no TPA row with that emp_code in the dump.
      Not in NhanceTPA has an emp_code not present in the Nhance “master” list for that client/policy (see model method with $all = true).
      Need to review / mismatchSame emp_code and same relation line can be paired, but name, DOB, or gender differ; or relation-level pairing failed and TPA rows for that code need manual attention.
      + +

      Key files and routes

      + +
        +
      • Controller: app/Controllers/EmployeeController.php +
          +
        • getTPADataVariationReport($file_id, $type)
        • +
        • proceedTPADataVariationNextStep($file_id)
        • +
        • generateEmployeeUploadFromNotInNhance(...) (protected)
        • +
        • initializeDeletionProcessForTpaApiData($file_id) — expects ['file_id' => batch_file_id]
        • +
        • Helpers: reconcileDbWithTpa, reconTpaApiDataWithEmployeepolicies, updateEmployeeDataFromTpa, exportVariationReportExcel
        • +
        +
      • +
      • Model slice: app/Models/EmployeePolicyModel.phpgetTPADataVariationReport($client_id, $client_policy_id, $file_id, $emp_codes = [], $all = false)
      • +
      • Excel / jobs: app/Controllers/EmployeeServiceController.php (inception, correction, disembark; queues jobs listed below)
      • +
      • Job routing: app/Controllers/JobWorker.php maps job names to EmployeeController handlers
      • +
      • Routes (employee group in app/Config/Routes.php): +
          +
        • GET employee/getTPADataVariationReport/(:num) — default second segment resolves to download-style behaviour
        • +
        • GET employee/getTPADataVariationReportView/(:num) — same action with view type (JSON for UI)
        • +
        • GET employee/proceedTPADataVariationNextStep/(:num) — query string ?tab=... (see Proceed section)
        • +
        +
      • +
      • UI: app/Views/batch_list.php — download / view variation report links call the routes above
      • +
      + +

      End-to-end flow

      + +
      +
      +flowchart TB + A[TPA rows in tpa_api_data] --> B[getTPADataVariationReport] + B --> C{rec_type snapshot exists?} + C -->|no| D[Compute and persist rec_type] + C -->|yes| E[Load from rec_type] + D --> F[reconTpaApiDataWithEmployeepolicies] + F --> G[Report UI / Excel] + E --> G + B --> H[not_in_tpa from SQL] + G --> I[proceedTPADataVariationNextStep] + I -->|not_in_nhance| J[Inception Excel + files] + I -->|need_to_review| K[updateEmployeeDataFromTpa] + J --> L[Excel pipelines] + K --> L + L --> M[Queued jobs: sync + recon + deletion init] +
      +
      + +

      + “Proceed” runs immediate actions; ref sync and deletion initialization also run as queued jobs + after inception or correction Excel workflows complete in EmployeeServiceController. +

      + +

      Variation report — getTPADataVariationReport

      + +

      Inputs: $file_id (batch file id), $type:

      +
        +
      • view — JSON API response with structured data (or empty array if nothing).
      • +
      • download — streams Excel via exportVariationReportExcel (three sheets).
      • +
      + +

      + The controller loads batch_files for client_id, client_policy_id, then decides + compute vs cached mode: +

      +
        +
      • Compute if there is no existing rec_type snapshot on active tpa_api_data for this file (non-empty rec_type on any row).
      • +
      • Cached if a snapshot already exists — avoids rewriting rec_type on every page load.
      • +
      + +

      + Compute path — high level: +

      +
        +
      1. Load all active tpa_api_data for file_id; index by emp_code.
      2. +
      3. Initialize every TPA row id to rec_type = matched as a default.
      4. +
      5. Load Nhance rows from EmployeePolicyModel::getTPADataVariationReport($client_id, $client_policy_id, $file_id) (default branch: members with tpa_id null — the “not yet linked / review” slice).
      6. +
      7. For each Nhance row, run reconcileDbWithTpa (see next section). Update rec_type on the matched TPA id(s) accordingly.
      8. +
      9. Load master emp_code list with getTPADataVariationReport(..., [], true). Any TPA row whose code is not in that list → not_in_nhance.
      10. +
      11. updateBatch all rec_type values in a transaction.
      12. +
      13. Call reconTpaApiDataWithEmployeepolicies(['file_id' => $file_id]) to populate ref where possible.
      14. +
      + +

      + Cached path: Reads not_in_nhance and need_to_review rows from tpa_api_data by rec_type, + then rebuilds mismatch_data for the UI without re-running the full reconciliation loop. +

      + +

      + not_in_tpa (always “live”): Built from TPA emp_codes for the file and + getTPADataVariationReport(..., $tpa_emp_codes) — Nhance members whose emp_code is not in the TPA set. + This list is not driven from the rec_type snapshot by design. +

      + +

      + The response also includes counts and button flags for the “Not in Nhance” proceed action (inception vs + deletion tallies based on empty ref and action_flag_status === 'D'). +

      + +

      Classifying rec_typereconcileDbWithTpa

      + +

      + Given one Nhance row ($db) and all TPA rows for the same emp_code ($tpaRows), the controller walks TPA rows in order: +

      +
        +
      • Relation gate: strtolower($db['relationship']) === strtolower($tpa['relation']). If it does not match, that TPA row is skipped.
      • +
      • First passing row wins. Compare name, dob, gender (case-insensitive for gender).
      • +
      • Return status = matched with tpa_record and not_matching as the list of differing field names (may be empty = perfect match).
      • +
      • If no TPA row passes the relation gate → status = no_match.
      • +
      + +

      + The report layer uses that result to set rec_type on TPA ids: perfect match → matched; + matched with diffs → need_to_review; no relation-level match → mark candidate TPA rows for that + emp_code as need_to_review. +

      + +

      Linking refreconTpaApiDataWithEmployeepolicies

      + +

      + Parameter shape: ['file_id' => $batchFileId] (same id as the TPA batch). +

      +
        +
      • Loads active tpa_api_data for the file. Rows that already have a non-empty ref are skipped.
      • +
      • Builds Nhance candidates: active employee_polices joined to employees for the same client_id / client_policy_id, keyed by emp_code.
      • +
      • For each TPA row still needing ref, finds a candidate where all match exactly after normalization: + name, relationship vs relation, dob, gender.
      • +
      • Batch-updates tpa_api_data.ref to the chosen employee_policies.id inside a transaction.
      • +
      + +

      + This is stricter than reconcileDbWithTpa (which only compares three fields after relation match) + because it must pick a single policy row to link. +

      + +

      Proceed next step — proceedTPADataVariationNextStep

      + +

      Route: GET employee/proceedTPADataVariationNextStep/{file_id}?tab=...

      + + + + + + + + + + + + + + + + + + + +
      tabBehaviour
      not_in_nhanceCalls generateEmployeeUploadFromNotInNhance. On success, an inception-style file exists in files and format validation has run. Follow-up ref/deletion jobs are not invoked inline here (see background jobs).
      need_to_reviewCalls updateEmployeeDataFromTpa(['batch_file_id' => (int) $file_id]). This applies TPA-sourced values onto employees for reconciled mismatches (no correction Excel in this path). The handler checks $generationResult['success'] (not status).
      other / missingStill logs “proceed” and returns a generic success message.
      + +
      + i +
      + Fix applied: The need_to_review branch previously passed the wrong parameter shape + to updateEmployeeDataFromTpa and read a non-existent status key. It now passes + batch_file_id and honours success, matching how queued jobs and QA utilities call the same method. +
      +
      + +

      Not in Nhance → inception file — generateEmployeeUploadFromNotInNhance

      + +

      Purpose: Turn TPA-only members into an Employee Upload with Events Excel so the normal onboarding pipeline can create them in Nhance.

      + +
        +
      1. Validate client_id, client_policy_id, client_branch_id on the batch file.
      2. +
      3. Master codes = getTPADataVariationReport(..., [], true)emp_code list.
      4. +
      5. Select active tpa_api_data for this file_id whose emp_code is not in the master list.
      6. +
      7. Build headers from EmployeeServiceController::getInceptionExcelColumns(); map each TPA row (relation synonyms, change_event = addition, dates as d-M-Y, etc.).
      8. +
      9. Save XLSX under WRITEPATH/uploads/excel/, insert files row (action = addition, status = inprogress).
      10. +
      11. Run excelFileFormatValidation with ['file_id' => newFileId, 'batch_file_id' => batchFileId].
      12. +
      + +

      Need to review → DB sync — updateEmployeeDataFromTpa

      + +

      + Expects ['batch_file_id' => int]. Loads the same Nhance slice as the report, loads TPA rows per + emp_code, reuses reconcileDbWithTpa. When status is matched and not_matching is non-empty, + writes allowed fields on employees (name, dob, gender, relationship from TPA relation, corporate email when present). + Returns success, message, and counts in data. +

      + +

      Deletion initialization — initializeDeletionProcessForTpaApiData

      + +

      + Parameter: ['file_id' => $batchFileId] (same batch / TPA file id). +

      + +

      Business logic (as implemented):

      +
        +
      1. Resolve the batch file; require client, policy, and branch.
      2. +
      3. From tpa_api_data, select distinct non-empty ref values where file_id matches, rows are active, and action_flag_status = 'D' (deletion intent from TPA).
      4. +
      5. Those ref values are employee_polices.id values already linked to TPA.
      6. +
      7. Load active Nhance members in the same client/policy scope whose employee_polices.id is in that set (whereIn on policy id). These are the rows that will appear on the generated deletion sheet.
      8. +
      9. Build a deletion-format .xls, create a files row with action = deletion, run EmployeeServiceController::employeeDisembark to create endorsements from the sheet.
      10. +
      11. Build a separate import-format workbook from export helpers, create a batch_files row, run EmpDataServiceController::importDeletionValidation for the batch import path.
      12. +
      + +

      + A commented rec_type = matched filter exists in the query; it is intentionally not applied — do not assume + rec_type gates deletion eligibility unless you change the code deliberately. +

      + +

      Background jobs chain

      + +

      + After a successful inception-style onboarding from grouped family data + (employeesOnboardProcess path in EmployeeServiceController) when batch_file_id is present in params, + three jobs are enqueued in order: +

      +
        +
      1. updateEmployeeDataFromTpa — payload includes file_id (newly created processing file where applicable) and batch_file_id
      2. +
      3. reconTpaApiDataWithEmployeepolicies — payload ['file_id' => batch_file_id]
      4. +
      5. initializeDeletionProcessForTpaApiData — payload ['file_id' => batch_file_id]
      6. +
      + +

      + A similar trio is queued after employeesCorrectionProcess when batch_file_id is passed. This is how + ref linking and deletion initialization catch up after Excel-driven workflows finish, even when the + “Proceed” controller path does not call them inline. +

      + +

      New developer checklist

      + +
        +
      1. Identify the batch file id you are debugging; confirm matching rows exist in tpa_api_data with the same file_id.
      2. +
      3. Open Variation report in view mode first — inspect not_in_tpa, not_in_nhance, mismatch_data separately.
      4. +
      5. If rec_type looks stale, remember the controller only recomputes when no snapshot exists unless you clear or adjust rec_type in DB (there is no public “job” route in production docs).
      6. +
      7. When changing matching rules, update both reconcileDbWithTpa and reconTpaApiDataWithEmployeepolicies if they must stay aligned, or document intentional differences.
      8. +
      9. Before testing deletion flows on real clients, trace action_flag_status and ref on TPA rows — deletion candidates are rows explicitly flagged D with a populated ref.
      10. +
      11. Watch myLogger entries prefixed with TPA / TPA RECON for operational breadcrumbs.
      12. +
      + +

      + For QA-only utilities (guarded in production), see internal notes such as + public/dev_logs/2026-04-03.md for updateEmployeeDataFromTpa and related routes. +

      diff --git a/app/Views/docs/visit-offboard.php b/app/Views/docs/visit-offboard.php new file mode 100644 index 00000000..148978fc --- /dev/null +++ b/app/Views/docs/visit-offboard.php @@ -0,0 +1,249 @@ + + +

      + Visit offboard removes members from the Visit wellness side after they are + deleted or exited in Nhance. A background job calls the Visit delete-policy + API; on HTTP 200 and a JSON body where message is + success, Nhance appends _DEL to + employee_polices.wellness_onboard for the affected rows. +

      + +
      + i +
      + Core files + API and DB update logic live in + app/Controllers/EmployeeController.php + (visitOffBoard(), updateVisitoffboardStatus()). + The insurer deletion flow importDeletionUpdateEndorsementID() + enqueues the job from + app/Controllers/EmpDataServiceController.php. The worker maps + the job name in app/Controllers/JobWorker.php. +
      +
      + +

      Overview

      + +
      +
      +flowchart TD + A[Deletion batch completes for insurer file] --> B[Jobs::addJob visitOffBoard] + B --> C[JobWorker runs EmployeeController::visitOffBoard] + C --> D[POST delete-policy-with-dependents] + D --> E{HTTP 200 and JSON message success} + E -->|Yes| F[updateVisitoffboardStatus] + F --> G[CONCAT wellness_onboard with _DEL] + E -->|No| H[Log failure, no DB marker] +
      +
      + +

      When it is queued

      + +

      + After importDeletionUpdateEndorsementID() applies policy and + endorsement updates from the deletion Excel, when + $file['insurer_or_tpa'] == 'insurer', the controller enqueues + visitOffBoard alongside cash-deposit and BDS jobs. The payload + carries the list of employee_polices.id values processed in that + batch, the client policy number, and a fixed source string. +

      + +
      $r = Jobs::addJob(['job_name' => 'visitOffBoard', 'payload' => [
      +    'memberIds'    => $employee_policy_table_primaryKey ?? [],
      +    'policyNumber' => $policy_name['policy_no'] ?? null,
      +    'source'       => 'NHANCE',
      +]]);
      + + + + + + + + + + + + + + + + + + + +
      Payload keyMeaning
      memberIdsArray of employee_polices.id primary keys collected from the deletion Excel flow (emp_policy_primarykey per row).
      policyNumberPolicy number from getPolicyNameUsingClientPolicyId() for the batch client policy.
      sourceAlways NHANCE in the enqueue; visitOffBoard() also forces source to NHANCE before the HTTP call.
      + +
      'visitOffBoard' => [
      +    'type'    => 'CC',
      +    'handler' => 'App\Controllers\EmployeeController',
      +],
      + +

      visitOffBoard()

      + +

      + The handler builds the Visit URL from environment configuration, appends the + path delete-policy-with-dependents, and POSTs JSON with + memberIds, policyNumber, and source. + Authorization uses a JWT prefix (this differs from the Visit + onboard upload path, which uses Basic auth in + sendFamiliesToWellnessApi()). +

      + +
      $apiUrl   = env('WELLNESS_ONBOARD_ENDPOINT_URL') . 'delete-policy-with-dependents';
      +$apiToken = env('WELLNESS_ONBOARD_AUTHORIZATION');
      +
      +$response = $client->request('POST', '', [
      +    'headers' => [
      +        'Authorization' => 'JWT ' . $apiToken,
      +        'Content-Type'  => 'application/json',
      +        'Accept'        => 'application/json',
      +    ],
      +    'json' => [
      +        'memberIds'    => $params['memberIds'] ?? [],
      +        'policyNumber' => $params['policyNumber'] ?? '',
      +        'source'       => $params['source'] ?? '',
      +    ],
      +    'http_errors' => false,
      +]);
      + + + + + + + + + + + + + + + + + + + +
      OutcomeBehavior
      HTTP status 200Decodes JSON, calls updateVisitoffboardStatus() with memberIds and api_result, returns that array shape to the worker.
      Other HTTP statusLogs failure; returns a structured error array; DB is not updated here.
      Network or client exceptionLogs exception; returns error array with error message.
      + +

      updateVisitoffboardStatus()

      + +

      + This method is only invoked from visitOffBoard() after a + 200 response. It validates the payload, requires + $data['api_result']['message'] === 'success', then updates all + listed employee policy rows in one statement. +

      + +
        +
      1. + Validate structure +

        Empty or non-array input logs and returns false.

        +
      2. +
      3. + Require API result +

        Missing api_result logs and returns false.

        +
      4. +
      5. + Require success message +

        If message is not exactly success, logs and returns false (no DB update).

        +
      6. +
      7. + Optional warning for missingMemberIds +

        If the API returns missingMemberIds, it is logged but processing continues.

        +
      8. +
      9. + Append offboard marker +

        Runs whereIn('id', $memberIds) and sets wellness_onboard to CONCAT(wellness_onboard, '_DEL') so existing reference ids stay traceable.

        +
      10. +
      + +
      $this->employeePolicyModel
      +    ->whereIn('id', $memberIds)
      +    ->set('wellness_onboard', "CONCAT(wellness_onboard, '_DEL')", false)
      +    ->update();
      + +
      + ! +
      + HTTP 200 alone is not enough + The DB update runs only when the decoded body has + message === 'success'. A 200 with a failed business payload will + not append _DEL. +
      +
      + +

      Manual test route

      + +

      + visitOffBoardCheck() is a thin admin helper that builds a hardcoded + sample $params array and prints visitOffBoard($params). + It is not part of the production deletion pipeline; use it only for targeted + debugging in non-production environments. +

      + +
      $routes->get('/visitOffBoardCheck', 'EmployeeController::visitOffBoardCheck');
      + +

      + ACL restricts this path to admin role in app/Config/Acl.php + (#^/visitOffBoardCheck#). +

      + +

      Developer steps

      + +
        +
      1. + Keep payload keys aligned with the Visit API contract +

        The job must supply memberIds, policyNumber, and source in the shape the delete endpoint expects.

        +
      2. +
      3. + Confirm environment variables +

        WELLNESS_ONBOARD_ENDPOINT_URL must include the correct base (trailing slash behavior matters when concatenating delete-policy-with-dependents). WELLNESS_ONBOARD_AUTHORIZATION must be the token value expected after JWT .

        +
      4. +
      5. + Do not call updateVisitoffboardStatus() directly for partial failures +

        It trusts api_result['message']; wire new callers through visitOffBoard() or replicate its guards.

        +
      6. +
      7. + Queue dependency +

        As with other jobs, the worker must be running; otherwise the offboard job stays queued after deletion processing.

        +
      8. +
      + +

      Common pitfalls

      + + + + + + + + + + + + + + + + + + + + + + + +
      PitfallWhy it happens
      wellness_onboard never gets _DELHTTP not 200, or JSON message is not success, or memberIds empty / wrong type.
      Visit receives wrong identifiersmemberIds must match what the delete API expects (same identifiers family as onboard where applicable).
      Auth works for onboard but not offboardOffboard uses JWT header construction; onboard upload uses Basic in a different helper.
      Job never enqueuesThe enqueue block runs only when insurer_or_tpa == 'insurer' on that deletion file path.
      diff --git a/app/Views/docs/visit-onboard.php b/app/Views/docs/visit-onboard.php new file mode 100644 index 00000000..70757d39 --- /dev/null +++ b/app/Views/docs/visit-onboard.php @@ -0,0 +1,550 @@ + + +

      + Visit onboard is the Visit wellness onboarding flow used for eligible members + under a selected client policy. The browser first checks how many members are + still pending, then starts a background job that pages through + employee_polices rows, groups each family by + emp_code, sends one payload per family to the external Visit + API, and stores the returned referenceId back into + employee_polices.wellness_onboard. +

      + +
      + i +
      + Core files + The status check, queue trigger, payload builder, API call, and DB update + logic all live in app/Controllers/EmployeeController.php. The + queue worker entry is registered in app/Controllers/JobWorker.php. +
      +
      + +

      Overview

      + +
      +
      +flowchart TD + A[User selects policy] --> B[checkWellnessOnboardStatus] + B --> C{Pending members > 0} + C -->|No| D[Hide onboard action] + C -->|Yes| E[Show Visit onboard action] + E --> F[initiateWellnessOnboard] + F --> G[Jobs::addJob] + G --> H[JobWorker executes initiateWellnessOnboardJob] + H --> I[Fetch one page of employee policy rows] + I --> J[Group rows by emp_code] + J --> K[Build family payload] + K --> L[POST to Visit API] + L --> M[Store referenceId in wellness_onboard] + M --> N{Page was full} + N -->|Yes| O[Queue next page job] + N -->|No| P[Batch complete] +
      +
      + +

      Prerequisites

      + +
      + ! +
      + A valid Visit plan ID must be mapped to the client policy in the policy edit page before using Visit onboard. + Without a proper wellness plan mapping, the current eligibility query will + not pick that policy for onboarding and the flow will not start as expected. +
      +
      + +

      + Before testing or using this feature, confirm that the selected client policy + has a valid wellness_plan_id configured in the policy edit screen. + This mapping is one of the core prerequisites checked by the current query. +

      + +

      Entry points

      + +

      + The current implementation is split across two browser-facing routes and one + queued job handler: +

      + + + + + + + + + + + + + + + + + + + +
      Entry pointCurrent responsibility
      GET checkWellnessOnboardStatus/{client_policy_id}Counts eligible member rows and returns the number in response.data.
      GET initiateWellnessOnboard/{client_policy_id}Queues the background job and immediately returns Process started.
      initiateWellnessOnboardJob($arr)Processes one page, sends family payloads to the Visit API, persists results, and queues the next page if needed.
      + +
      $routes->get("checkWellnessOnboardStatus/(:any)", "EmployeeController::checkWellnessOnboardStatus/$1");
      +$routes->get("initiateWellnessOnboard/(:any)", "EmployeeController::initiateWellnessOnboard/$1");
      +
      +'initiateWellnessOnboardJob' => [
      +    'type' => 'CC',
      +    'handler' => 'App\Controllers\EmployeeController',
      +],
      + +

      + On the admin page, app/Views/employee_upload.php calls the count + endpoint when a policy is selected, shows the CTA only when the count is + greater than zero, and asks for user confirmation before calling the initiate + endpoint. +

      + +
      + ! +
      + Current trigger style + The existing implementation starts work from a browser GET route and then + hands off the heavy work to the queue. If you change the route method or + path, update Routes.php, the frontend AJAX calls, and any ACL + expectations together. +
      +
      + +

      Eligibility rules

      + +

      + Both checkWellnessOnboardStatus() and + initiateWellnessOnboardJob() use nearly the same base query. A + member is considered eligible only when all of these conditions are true: +

      + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
      ConditionMeaning in current code
      employee_polices.client_policy_id = {selected id}The job is always scoped to one chosen client policy.
      employee_polices.is_active = 1Only active employee policy rows are considered.
      employee_polices.status = 'active'Inactive policy-members are excluded.
      employee_polices.wellness_onboard = '0'Already onboarded rows are skipped because this column later stores the Visit referenceId.
      employees.emp_status = 'active' and employees.is_active = 1Only active employees/dependants are sent.
      cp.wellness_plan_id is presentThe selected policy must have a wellness plan configured.
      cp.wellness_vendor_id is null, empty, or 0This is how the current code filters policies for this flow today.
      cp.policy_status = 1 and cp.is_active = 1The client policy itself must be active.
      + +

      + The status endpoint returns a count of matching member rows, not a count of + grouped families. That is why the button text says employees, while the job + later sends one API payload per family group. +

      + +

      Async flow

      + +
        +
      1. + Check the pending count +

        checkWellnessOnboardStatus($client_policy_id) runs the eligibility query and returns count($data).

        +
      2. +
      3. + Queue the first job +

        initiateWellnessOnboard($client_policy_id) inserts a job with the name initiateWellnessOnboardJob and payload ['client_policy_id' => ...].

        +
      4. +
      5. + Worker resolves the handler +

        JobWorker::$event_class_mapping maps that job name back to EmployeeController.

        +
      6. +
      7. + Process one page of rows +

        The job defaults to page = 1, per_page = 50, and calculates the SQL offset from those values.

        +
      8. +
      9. + Group the current page by family +

        Rows are grouped by emp_code before building API payloads.

        +
      10. +
      11. + Send to Visit and persist the response +

        The job posts each family payload, then stores the returned referenceId into all member rows for that family.

        +
      12. +
      13. + Queue the next page only when needed +

        If the current query returns exactly per_page rows, the job assumes more data may exist and queues page + 1.

        +
      14. +
      + +
      Jobs::addJob([
      +    'job_name' => 'initiateWellnessOnboardJob',
      +    'payload'  => [
      +        'client_policy_id' => $client_policy_id,
      +        'page'             => $page + 1,
      +        'per_page'         => $perPage,
      +    ]
      +]);
      + +

      + The legacy method initiateWellnessOnboardJobOLD() still exists in + the controller, but the active queue mapping points to the current + initiateWellnessOnboardJob() implementation. +

      + +

      Family payload

      + +

      + The job builds one outbound payload per emp_code. The first row in + the family is used as the policy-level reference, and every family member + becomes one entry in memberDetails. +

      + + + + + + + + + + + + + + + + + + +
      Outbound fieldSource in current code
      policyDetails.policyNumbercp.policy_no
      policyDetails.employeeIdemp_code
      policyDetails.policyNameHardcoded GMC
      policyDetails.policyStartDatecp.policy_start_date
      policyDetails.policyEndDatecp.policy_end_date
      policyDetails.plancp.wellness_plan_id
      policyDetails.sourceHardcoded NHANCE
      policyDetails.employerclients.short_name
      memberDetails[].memberIdemployee_polices.id
      memberDetails[].relationshipNameMapped by mapRelationship()
      memberDetails[].genderM => Male, otherwise Female
      + +
      [
      +    'policyDetails' => [
      +        'policyNumber'    => $primary['policy_no'],
      +        'employeeId'      => $empCode,
      +        'policyName'      => 'GMC',
      +        'policyStartDate' => $primary['cp_policy_start_date'],
      +        'policyEndDate'   => $primary['policy_end_date'],
      +        'plan'            => $primary['wellness_plan_id'],
      +        'source'          => 'NHANCE',
      +        'employer'        => $primary['short_name'],
      +        'employeeCode'    => $empCode,
      +    ],
      +    'memberDetails' => [
      +        [
      +            'memberId'         => $row['id'],
      +            'name'             => $row['name'],
      +            'phone'            => $row['mobile'],
      +            'email'            => $row['email_corporate'],
      +            'relationshipName' => $this->mapRelationship($row['relationship'], $row['gender']),
      +            'gender'           => $row['gender'] == 'M' ? 'Male' : 'Female',
      +            'dob'              => $row['dob'],
      +        ],
      +    ],
      +]
      + +
      + ! +
      + Important mapping rule + mapRelationship() throws an exception for + spouse when gender is missing. If spouse data is incomplete, the + job can fail before the API call is made. +
      +
      + +

      API integration

      + +

      + sendFamiliesToWellnessApi() uses the CI4 cURL service and reads + its runtime configuration from environment values: +

      + + + + + + + + + + + + + + + +
      Env keyUsage
      WELLNESS_ONBOARD_ENDPOINT_URLTarget URL for the Visit onboarding POST request.
      WELLNESS_ONBOARD_AUTHORIZATIONBasic auth token value appended to the Authorization header.
      + +

      + Each family is posted as JSON with http_errors = false and a + 30 second timeout. The helper stores the raw API result back onto + the in-memory family array under apiResponse so the next step can + decide whether to persist anything. +

      + +
      $response = $client->post($endpointUrl, [
      +    'headers' => [
      +        'Content-Type'  => 'application/json',
      +        'Authorization' => 'Basic ' . getenv('WELLNESS_ONBOARD_AUTHORIZATION'),
      +    ],
      +    'body'        => json_encode($family),
      +    'http_errors' => false,
      +    'timeout'     => 30,
      +]);
      + +

      + If the HTTP client throws, the exception is captured into + apiResponse['error'] with a synthetic statusCode of + 0. +

      + +

      Response examples

      + +
      + i +
      + Sample data only + The JSON below uses placeholder IDs, names, phones, and emails so no live + user data appears in documentation. Production responses follow the same + shape with real values. +
      +
      + +

      + Visit success response (HTTP 2xx with a body the job can + decode). updateWellnessOnboardResponseToDB() reads + referenceId from the decoded JSON and writes it to + employee_polices.wellness_onboard for each + policyDetails[].memberId (employee policy row id). +

      + +
      {
      +  "message": "success",
      +  "body": "The policy details are posted successfully",
      +  "policyDetails": [
      +    {
      +      "memberId": "100001",
      +      "name": "Primary Member Example",
      +      "phone": "9000000001",
      +      "email": "primary.member@example.com",
      +      "relationshipName": "husband",
      +      "gender": "Male",
      +      "dob": "1962-11-25"
      +    },
      +    {
      +      "memberId": "100002",
      +      "name": "Dependent Member Example",
      +      "phone": "9000000001",
      +      "email": "dependent.member@example.com",
      +      "relationshipName": "son",
      +      "gender": "Male",
      +      "dob": "1996-02-13"
      +    }
      +  ],
      +  "referenceId": "00000000000000000000-NHANCE-1700000000123"
      +}
      + +

      + Visit failure responses (same endpoint; when validation or + business rules fail, the body typically looks like one of these). In these + cases the current persistence step skips updating rows because there is no + referenceId. +

      + +
      {
      +  "message": "failed",
      +  "errorMessage": "Invalid name"
      +}
      + +
      {
      +  "message": "failed",
      +  "errorMessage": "Invalid mobileno"
      +}
      + +
      {
      +  "message": "failed",
      +  "errorMessage": "invalid [\"null\",\"string\"]: 100010"
      +}
      + +

      + The last shape is a schema-style rejection: the bracketed part describes the + expected type(s), and the trailing id is the member identifier the API could + not accept (shown here as a placeholder id, not production data). +

      + +

      Database updates

      + +

      + Successful persistence is done by + updateWellnessOnboardResponseToDB(). The method expects the Visit + API response to contain a referenceId. That value becomes the new + wellness_onboard value for every member in the family. +

      + + + + + + + + + + + +
      BeforeAfter successful onboard
      employee_polices.wellness_onboard = '0'employee_polices.wellness_onboard = {referenceId}
      + +

      + The code collects all row updates for the page and writes them in one + updateBatch(..., 'id') call, using each + memberDetails[].memberId as the primary key. +

      + +
      $allUpdates[] = [
      +    'id'               => $memberPk,
      +    'wellness_onboard' => $referenceId,
      +];
      +
      +$this->employeePolicyModel->updateBatch($allUpdates, 'id');
      + +

      Failure behavior

      + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
      ConditionCurrent behavior
      Missing client_policy_id in job payloadLogs an error and returns true so the worker can continue.
      No rows found for a pageLogs batch completion and stops queue recursion.
      Row missing emp_codeSkips that row and logs a warning.
      HTTP status 400, 500, or missing response dataLogs the API error and does not update wellness_onboard.
      Missing referenceId in API responseLogs the issue and skips DB persistence for that family.
      Queue context return valueThe job returns true instead of using $this->respond().
      + +
      + i +
      + Current logging style + The implementation uses multiple log_message('error', ...) + calls for progress tracing, not only for failures. Keep that in mind while + reading logs during QA or production support. +
      +
      + +

      Developer steps

      + +
        +
      1. + Keep the browser trigger and route definitions in sync +

        If you rename the route or change the method, update both Routes.php and the AJAX calls in employee_upload.php.

        +
      2. +
      3. + Do not move the heavy loop back into the web request +

        initiateWellnessOnboard() should remain a thin queue trigger. The batching work belongs in the job handler.

        +
      4. +
      5. + Preserve family grouping assumptions +

        emp_code is the family key. If source data changes, make sure all related members still group together correctly.

        +
      6. +
      7. + Validate required member data before rollout +

        Fields such as emp_code, relationship, gender, dob, mobile, and email_corporate affect payload quality.

        +
      8. +
      9. + Keep the queue mapping intact +

        If you rename initiateWellnessOnboardJob, update the corresponding entry in JobWorker::$event_class_mapping.

        +
      10. +
      11. + Verify runtime configuration before testing +

        The queue worker must be running, and both wellness environment variables must be present before QA can validate the end-to-end flow.

        +
      12. +
      + +

      Common pitfalls

      + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
      PitfallWhy it happens
      The Visit onboard button never appearsThe status endpoint returned 0 because the selected policy failed one of the eligibility filters.
      User sees Process started but no records changeThe initial request only queues the job; no worker means no real processing.
      Only some members get updatedFamilies with API errors, missing referenceId, or missing emp_code are skipped during persistence.
      Spouse records fail unexpectedlymapRelationship() requires gender to translate spouse into husband or wife.
      Count shown in UI does not equal number of API callsThe UI count is member-row based, but outbound requests are family-group based.
      Pagination changes create odd onboarding batchesThe job paginates raw rows first and groups families afterward, so careless query changes can alter how families are chunked.
      diff --git a/nhance_php_queue_server.service b/nhance_php_queue_server.service new file mode 100644 index 00000000..92ee37fb --- /dev/null +++ b/nhance_php_queue_server.service @@ -0,0 +1,15 @@ +[Unit] +Description=Nhance PHP Queue Server +After=network.target + +[Service] +Type=simple +User=www-data +Group=www-data +WorkingDirectory=/var/www/example-suite/nhance-app +ExecStart=/bin/bash /var/www/example-suite/nhance-app/phpqueue.sh +Restart=always +RestartSec=2 + +[Install] +WantedBy=multi-user.target diff --git a/tests/unit/RateLimiterServiceSmokeTest.php b/tests/unit/RateLimiterServiceSmokeTest.php new file mode 100644 index 00000000..e1ea9944 --- /dev/null +++ b/tests/unit/RateLimiterServiceSmokeTest.php @@ -0,0 +1,104 @@ +service = new RateLimiterService(); + } + + public function testIpBlockEscalationAndUnblockFlow(): void + { + $cfg = config('RateLimiter'); + $fingerprint = 'smoke-ip-' . uniqid('', true); + + // Initially no IP block. + $this->assertNull($this->service->getIpBlock($fingerprint)); + + // Record enough failures to trigger soft block. + $result = null; + for ($i = 0; $i < $cfg->ipBlock['violation_soft']; $i++) { + $result = $this->service->recordIpFailure($fingerprint); + } + + $this->assertIsArray($result); + $this->assertSame('soft', $result['level']); + + // Hitting again while blocked should eventually escalate to hard. + $this->service->checkIp($fingerprint, 'jwtApi'); // return soft, escalate to medium + $this->service->checkIp($fingerprint, 'jwtApi'); // return medium, escalate to hard + $blocked = $this->service->checkIp($fingerprint, 'jwtApi'); // return hard + + $this->assertIsArray($blocked); + $this->assertSame('hard', $blocked['level']); + + $this->service->unblockIp($fingerprint); + $this->assertNull($this->service->getIpBlock($fingerprint)); + } + + public function testUserFailureFlowAndManualUnblock(): void + { + $cfg = config('RateLimiter'); + $identity = 'smoke-user-' . uniqid('', true) . '@example.com'; + + $this->assertNull($this->service->getUserBlock($identity)); + + $result = null; + for ($i = 0; $i < $cfg->authApi['violation_soft']; $i++) { + $result = $this->service->recordUserFailure($identity, 'authApi'); + } + + $this->assertIsArray($result); + $this->assertSame('soft', $result['level']); + + // Re-hits while blocked escalate from soft -> medium -> hard. + $this->service->checkUser($identity); // return soft, escalate to medium + $this->service->checkUser($identity); // return medium, escalate to hard + $blocked = $this->service->checkUser($identity); // return hard + + $this->assertIsArray($blocked); + $this->assertSame('hard', $blocked['level']); + + $this->service->unblockUser($identity); + $this->assertNull($this->service->getUserBlock($identity)); + } + + public function testJwtUserThrottleToSoftBlock(): void + { + $cfg = config('RateLimiter'); + $identity = 'smoke-throttle-' . uniqid('', true) . '@example.com'; + + // First "limit" requests should pass. + for ($i = 0; $i < $cfg->jwtApi['limit']; $i++) { + $this->assertNull($this->service->checkUserThrottle($identity, 'jwtApi')); + } + + // Over-limit attempts should return throttle first, then soft block after enough violations. + $throttle = $this->service->checkUserThrottle($identity, 'jwtApi'); + $this->assertIsArray($throttle); + $this->assertSame('throttle', $throttle['level']); + + $blocked = null; + for ($i = 0; $i < $cfg->jwtApi['violation_soft']; $i++) { + $blocked = $this->service->checkUserThrottle($identity, 'jwtApi'); + } + + $this->assertIsArray($blocked); + $this->assertContains($blocked['level'], ['soft', 'medium', 'hard']); + + $this->service->unblockUser($identity); + $this->assertNull($this->service->getUserBlock($identity)); + } +} + From 7c843e4ac84993353a0a486fc9497247179f3b9e Mon Sep 17 00:00:00 2001 From: Gowtham M Date: Mon, 18 May 2026 15:17:06 +0530 Subject: [PATCH 2/3] File merge issue --- app/Helpers/merge_pdf_helper.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/Helpers/merge_pdf_helper.php b/app/Helpers/merge_pdf_helper.php index a192e33e..1751661f 100644 --- a/app/Helpers/merge_pdf_helper.php +++ b/app/Helpers/merge_pdf_helper.php @@ -72,6 +72,14 @@ if (! function_exists('merge_ticket_pdfs')) { return $result; } + if (count($rows) ==1){ + //set file_type for for that one file. + $claimFiles->where('id', $rows[0]['id'])->set(['file_type' => 4])->update(); + $result['status'] = true; + $result['message'] = 'Only one PDF/image file to merge'; + return $result; + } + $uploadDir = rtrim(WRITEPATH, '/\\') . DIRECTORY_SEPARATOR . 'uploads' . DIRECTORY_SEPARATOR . 'claim_files' . DIRECTORY_SEPARATOR; From a2865f1096523c92598952df9276a0d15e4eafad Mon Sep 17 00:00:00 2001 From: Gowtham M Date: Mon, 18 May 2026 16:40:05 +0530 Subject: [PATCH 3/3] GWM : Manual merge option --- app/Config/Routes.php | 1 + app/Controllers/TicketController.php | 73 ++++++++++++++- app/Helpers/merge_pdf_helper.php | 135 +++++++++++++++++++++++++-- app/Views/claim_files_upload.php | 104 ++++++++++++++++++++- 4 files changed, 301 insertions(+), 12 deletions(-) diff --git a/app/Config/Routes.php b/app/Config/Routes.php index 74dddbef..55fa525f 100755 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -818,6 +818,7 @@ $routes->group("/ticket", ["filter" => "authMVC"], function ($routes) { $routes->get("getMoreInfo","TicketController::getMoreInfo"); $routes->post('upload_url',"TicketController::upload_url"); $routes->post('getUrlDataByTicketId',"TicketController::getUrlDataByTicketId"); + $routes->post('manualMergeClaimFiles', 'TicketController::manualMergeClaimFiles'); $routes->post('uploadClaimFilesToTPA', 'TicketController::uploadClaimFilesToTPA'); $routes->get('remove_url',"TicketController::remove_url"); $routes->get('fetchVehiclePolicy/(:any)','TicketController::fetchVehiclePolicy/$1'); diff --git a/app/Controllers/TicketController.php b/app/Controllers/TicketController.php index bcfb183a..6addab9a 100644 --- a/app/Controllers/TicketController.php +++ b/app/Controllers/TicketController.php @@ -3721,9 +3721,78 @@ class TicketController extends BaseController ->where('is_active', 1) ->findAll(); + helper('merge_pdf'); + $mergeUi = merge_ticket_manual_merge_status((int) $ticket_id); + return $this->response->setJSON([ - 'status' => true, - 'data' => $urlData + 'status' => true, + 'data' => $urlData, + 'merge_ui' => $mergeUi, + ]); + } + + /** + * Manually merge claim PDF/image files for a ticket (Claim Files tab). + */ + public function manualMergeClaimFiles() + { + $ticket_id = (int) $this->request->getPost('ticket_id'); + + if ($ticket_id <= 0) { + return $this->response->setStatusCode(400)->setJSON([ + 'status' => false, + 'message' => 'Ticket ID is required', + ]); + } + + helper('merge_pdf'); + + $mergeUi = merge_ticket_manual_merge_status($ticket_id); + if (! $mergeUi['show_manual_merge'] && $mergeUi['mergeable_count'] < 1) { + return $this->response->setStatusCode(400)->setJSON([ + 'status' => false, + 'message' => 'No PDF or image files available on disk to merge.', + ]); + } + + $ticket = $this->ticketMasterModel + ->select('id, ticket_type_id') + ->where('id', $ticket_id) + ->where('is_active', 1) + ->first(); + + if (! $ticket) { + return $this->response->setStatusCode(404)->setJSON([ + 'status' => false, + 'message' => 'Claim not found.', + ]); + } + + try { + $result = merge_ticket_pdfs($ticket_id, [ + 'ticket_type' => (int) ($ticket['ticket_type_id'] ?? 1), + 'created_by' => function_exists('get_session_userid') ? get_session_userid() : null, + ]); + } catch (\Throwable $e) { + log_message('error', 'TicketController::manualMergeClaimFiles | ticket_id=' . $ticket_id . ' | ' . $e->getMessage()); + return $this->response->setStatusCode(500)->setJSON([ + 'status' => false, + 'message' => 'Merge failed: ' . $e->getMessage(), + ]); + } + + if (! ($result['status'] ?? false)) { + return $this->response->setJSON([ + 'status' => false, + 'message' => $result['message'] ?? 'Merge failed. Check logs for details.', + 'data' => $result, + ]); + } + + return $this->response->setJSON([ + 'status' => true, + 'message' => $result['message'] ?? 'Documents merged successfully.', + 'data' => $result, ]); } diff --git a/app/Helpers/merge_pdf_helper.php b/app/Helpers/merge_pdf_helper.php index 1751661f..c222289c 100644 --- a/app/Helpers/merge_pdf_helper.php +++ b/app/Helpers/merge_pdf_helper.php @@ -86,13 +86,8 @@ if (! function_exists('merge_ticket_pdfs')) { $sourceFiles = []; foreach ($rows as $row) { - $name = ! empty($row['url']) ? $row['url'] : ($row['file_name'] ?? ''); - if (empty($name)) { - continue; - } - // url column may sometimes hold a full URL; we only care about the file basename on disk. - $full = $uploadDir . basename($name); - if (is_file($full) && is_readable($full)) { + $full = merge_ticket_pdf_resolve_disk_path($row, $uploadDir); + if ($full !== null) { $mime = merge_ticket_pdf_resolve_mime($full, (string) ($row['mime_type'] ?? '')); if (! in_array($mime, $opts['include_mime_types'], true)) { log_message('error', "merge_ticket_pdfs | unsupported source mime {$mime} | claim_file_id={$row['id']} | path={$full}"); @@ -105,7 +100,8 @@ if (! function_exists('merge_ticket_pdfs')) { 'id' => $row['id'] ?? null, ]; } else { - log_message('error', "merge_ticket_pdfs | missing file on disk | claim_file_id={$row['id']} | path={$full}"); + $name = ! empty($row['url']) ? $row['url'] : ($row['file_name'] ?? ''); + log_message('error', "merge_ticket_pdfs | missing file on disk | claim_file_id={$row['id']} | path=" . $uploadDir . basename((string) $name)); } } @@ -340,10 +336,50 @@ if (! function_exists('merge_ticket_pdf_resolve_mime')) { return 'image/png'; } + if (in_array($detectedMime, ['application/octet-stream', 'binary/octet-stream'], true)) { + if ($ext === 'pdf') { + return 'application/pdf'; + } + if (in_array($ext, ['jpg', 'jpeg'], true)) { + return 'image/jpeg'; + } + if ($ext === 'png') { + return 'image/png'; + } + } + return $detectedMime ?: ($storedMime ?: 'application/octet-stream'); } } +if (! function_exists('merge_ticket_pdf_resolve_disk_path')) { + /** + * Resolve on-disk path for a claim_files row (url and/or file_name). + */ + function merge_ticket_pdf_resolve_disk_path(array $row, string $uploadDir): ?string + { + $candidates = []; + if (! empty($row['url'])) { + $candidates[] = basename((string) $row['url']); + } + if (! empty($row['file_name'])) { + $candidates[] = basename((string) $row['file_name']); + } + + foreach (array_unique($candidates) as $name) { + if ($name === '' || preg_match('#^https?://#i', $name)) { + continue; + } + $full = $uploadDir . $name; + if (is_file($full) && is_readable($full)) { + return $full; + } + } + + return null; + } +} + if (! function_exists('merge_ticket_pdf_add_image_page')) { /** * Add an uploaded image as a single PDF page, preserving portrait/landscape @@ -397,3 +433,86 @@ if (! function_exists('merge_ticket_pdf_add_image_page')) { return true; } } + +if (! function_exists('merge_ticket_manual_merge_status')) { + /** + * UI/status helper: whether manual merge should be offered on Claim Files tab. + * + * @return array{ + * has_merged_file: bool, + * mergeable_count: int, + * show_manual_merge: bool, + * button_label: string + * } + */ + function merge_ticket_manual_merge_status(int $ticket_master_id, array $opts = []): array + { + $opts += [ + 'include_file_types' => [1, 2], + 'include_mime_types' => ['application/pdf', 'image/jpeg', 'image/png'], + ]; + + $status = [ + 'has_merged_file' => false, + 'mergeable_count' => 0, + 'show_manual_merge' => false, + 'button_label' => 'Merge documents', + ]; + + if ($ticket_master_id <= 0) { + return $status; + } + + $claimFiles = new ClaimFilesModel(); + $rows = $claimFiles + ->where('ticket_id', $ticket_master_id) + ->where('is_active', 1) + ->whereIn('file_type', array_merge($opts['include_file_types'], [MERGED_CLAIM_FILE_TYPE])) + ->orderBy('id', 'ASC') + ->findAll(); + + $uploadDir = rtrim(WRITEPATH, '/\\') . DIRECTORY_SEPARATOR + . 'uploads' . DIRECTORY_SEPARATOR + . 'claim_files' . DIRECTORY_SEPARATOR; + + $mergeableCount = 0; + $sourceRowCount = 0; + foreach ($rows as $row) { + if ((int) ($row['file_type'] ?? 0) === MERGED_CLAIM_FILE_TYPE) { + $status['has_merged_file'] = true; + continue; + } + + if (! in_array((int) ($row['file_type'] ?? 0), $opts['include_file_types'], true)) { + continue; + } + + $sourceRowCount++; + + $full = merge_ticket_pdf_resolve_disk_path($row, $uploadDir); + if ($full === null) { + continue; + } + + $mime = merge_ticket_pdf_resolve_mime($full, (string) ($row['mime_type'] ?? '')); + if (in_array($mime, $opts['include_mime_types'], true)) { + $mergeableCount++; + } + } + + $status['mergeable_count'] = $mergeableCount; + + // Show manual merge when there is no merged file but user has uploaded docs in the list. + if (! $status['has_merged_file']) { + if ($mergeableCount >= 1 || $sourceRowCount >= 2) { + $status['show_manual_merge'] = true; + $status['button_label'] = 'Merge documents'; + } + } elseif ($mergeableCount >= 2) { + $status['show_manual_merge'] = true; + $status['button_label'] = 'Re-merge documents'; + } + + return $status; + } +} diff --git a/app/Views/claim_files_upload.php b/app/Views/claim_files_upload.php index 6ea03e37..c444773d 100644 --- a/app/Views/claim_files_upload.php +++ b/app/Views/claim_files_upload.php @@ -103,6 +103,11 @@

      File List

    +