MERGE_TEST_CLAIM_PUSH_BUTTON

This commit is contained in:
Ubuntu 2026-05-18 17:03:40 +05:30
commit 40fe8d8d03
48 changed files with 10199 additions and 74 deletions

View File

@ -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 =

View File

@ -0,0 +1,117 @@
<?php
namespace App\Commands;
use App\Libraries\RateLimiterService;
use CodeIgniter\CLI\BaseCommand;
use CodeIgniter\CLI\CLI;
use Config\Database;
use Config\RateLimiter as RateLimiterConfig;
/**
* Align rate_limit_blocks with timed cache TTL: purge stale cache keys and delete DB rows.
*
* Schedule (example every 10 minutes):
* *\/10 * * * * cd /path/to/project && php spark rate-limit:reconcile-blocks
*/
class RateLimitBlocksReconcile extends BaseCommand
{
protected $group = 'Rate limit';
protected $name = 'rate-limit:reconcile-blocks';
protected $description = 'For active rows whose block TTL has passed: purge cache keys, then delete the DB row.';
protected $usage = 'rate-limit:reconcile-blocks [--dry-run]';
/** @var array<string, string> */
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<string, mixed> $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,
};
}
}

View File

@ -276,6 +276,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],

View File

@ -564,14 +564,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');
@ -819,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');
@ -1114,6 +1114,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');
@ -1123,7 +1129,6 @@ $routes->group('expense', ["filter" => "authMVC", 'namespace' => 'App\Controller
$routes->get('client-policies', 'ExpenseController::clientPolicies');
});
$routes->get('docs', 'Docs\DocsController::index');
$routes->get('docs/(:segment)', 'Docs\DocsController::page/$1');

View File

@ -0,0 +1,583 @@
<?php
namespace App\Controllers\Docs;
use App\Controllers\BaseController;
use CodeIgniter\Exceptions\PageNotFoundException;
/**
* DocsController
* app/Controllers/Docs/DocsController.php
*
* Handles all developer documentation pages.
* Each page only needs to define its own content layout partials are assembled here.
*
* Route setup (app/Config/Routes.php):
* $routes->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 dont', '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 (113)', '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);
}
}

View File

@ -4518,9 +4518,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,

View File

@ -556,7 +556,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'] ?? '')
) {
@ -565,7 +565,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'){
@ -574,7 +574,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}");
}
@ -1063,7 +1063,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'] ?? '')),

View File

@ -0,0 +1,65 @@
<?php
namespace App\Controllers;
use App\Libraries\RateLimiterService;
class RateLimitAdminController extends BaseController
{
protected RateLimiterService $limiter;
public function __construct()
{
$this->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.');
}
}

View File

@ -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;
}

View File

@ -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,
]);
}

View File

@ -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;

View File

@ -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']);

View File

@ -72,19 +72,22 @@ 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;
$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}");
@ -97,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));
}
}
@ -332,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
@ -389,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;
}
}

View File

@ -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);

View File

@ -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<int, array<string, mixed>>
*/
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<int, array<string, mixed>>
*/
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());
}
}
}

View File

@ -0,0 +1,102 @@
<div class="container-fluid py-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Rate Limit Blocks</h4>
<small class="text-muted">URL-only admin utility</small>
</div>
<?php if (session()->getFlashdata('success')): ?>
<div class="alert alert-success"><?= esc(session()->getFlashdata('success')) ?></div>
<?php endif; ?>
<?php if (session()->getFlashdata('error')): ?>
<div class="alert alert-danger"><?= esc(session()->getFlashdata('error')) ?></div>
<?php endif; ?>
<ul class="nav nav-tabs mb-3">
<li class="nav-item">
<a class="nav-link <?= $activeTab === 'ip' ? 'active' : '' ?>" href="<?= base_url('security/rate-limits?tab=ip') ?>">
Blocked IP List (<?= count($blockedIps) ?>)
</a>
</li>
<li class="nav-item">
<a class="nav-link <?= $activeTab === 'user' ? 'active' : '' ?>" href="<?= base_url('security/rate-limits?tab=user') ?>">
Blocked User List (<?= count($blockedUsers) ?>)
</a>
</li>
</ul>
<?php if ($activeTab === 'ip'): ?>
<div class="card">
<div class="card-body table-responsive">
<table class="table table-striped table-bordered align-middle mb-0">
<thead>
<tr>
<th>IP</th>
<th>Block Level</th>
<th>Cache Identifier (Fingerprint)</th>
<th>Blocked At</th>
<th>Action</th>
</tr>
</thead>
<tbody>
<?php if (empty($blockedIps)): ?>
<tr><td colspan="5" class="text-center text-muted">No active blocked IP records.</td></tr>
<?php else: ?>
<?php foreach ($blockedIps as $row): ?>
<tr>
<td><?= esc($row['display_identifier'] ?? '-') ?></td>
<td><span class="badge bg-danger"><?= esc(strtoupper((string) ($row['block_level'] ?? '-'))) ?></span></td>
<td><small><?= esc($row['cache_identifier'] ?? '-') ?></small></td>
<td><?= esc((string) ($row['blocked_at'] ?? '-')) ?></td>
<td>
<form method="post" action="<?= base_url('security/rate-limits/unblock-ip') ?>" class="d-flex gap-2">
<input type="hidden" name="cache_identifier" value="<?= esc($row['cache_identifier'] ?? '') ?>">
<input type="text" name="reason" class="form-control form-control-sm" placeholder="Reason (optional)">
<button type="submit" class="btn btn-sm btn-success" onclick="return confirm('Unblock this IP entry?')">Unblock</button>
</form>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<?php else: ?>
<div class="card">
<div class="card-body table-responsive">
<table class="table table-striped table-bordered align-middle mb-0">
<thead>
<tr>
<th>User Identity</th>
<th>Block Level</th>
<th>Identity Hash Key</th>
<th>Blocked At</th>
<th>Action</th>
</tr>
</thead>
<tbody>
<?php if (empty($blockedUsers)): ?>
<tr><td colspan="5" class="text-center text-muted">No active blocked user records.</td></tr>
<?php else: ?>
<?php foreach ($blockedUsers as $row): ?>
<tr>
<td><?= esc($row['display_identifier'] ?? '-') ?></td>
<td><span class="badge bg-danger"><?= esc(strtoupper((string) ($row['block_level'] ?? '-'))) ?></span></td>
<td><small><?= esc($row['cache_identifier'] ?? '-') ?></small></td>
<td><?= esc((string) ($row['blocked_at'] ?? '-')) ?></td>
<td>
<form method="post" action="<?= base_url('security/rate-limits/unblock-user') ?>" class="d-flex gap-2">
<input type="hidden" name="display_identifier" value="<?= esc($row['display_identifier'] ?? '') ?>">
<input type="text" name="reason" class="form-control form-control-sm" placeholder="Reason (optional)">
<button type="submit" class="btn btn-sm btn-success" onclick="return confirm('Unblock this user entry?')">Unblock</button>
</form>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<?php endif; ?>
</div>

View File

@ -103,6 +103,11 @@
<h4 class="mb-0" style="position: relative;">File List</h4>
</div>
<div class="col-md-6 text-md-right mt-2 mt-md-0">
<button type="button" class="btn btn-warning waves-effect waves-light mr-2 d-none"
id="btn_manual_merge_claim_files"
title="Combine uploaded PDFs and images into one file">
Merge documents
</button>
<button type="button" class="btn btn-primary waves-effect waves-light"
id="btn_upload_claim_files_to_tpa">
Upload files to TPA
@ -170,9 +175,18 @@
<script>
$(document).ready(function(){
let ticket_id = $('#ticket_master_id').val();
let ticket_id = getClaimFileListTicketId();
$('#ticket_id_url').val(ticket_id);
let urlData = getUrlDataByTicketId(ticket_id);
if (ticket_id) {
getUrlDataByTicketId(ticket_id);
}
});
$(document).on('shown.bs.tab', 'a[href="#uploads-tab"], #uploads_tab', function () {
var ticket_id = getClaimFileListTicketId();
if (ticket_id) {
getUrlDataByTicketId(ticket_id);
}
});
function getClaimFileListTicketId() {
@ -183,6 +197,88 @@
return id;
}
function resolveManualMergeUi(serverMergeUi, fileListData) {
if (serverMergeUi && serverMergeUi.show_manual_merge) {
return serverMergeUi;
}
if (!fileListData || !fileListData.length) {
return serverMergeUi || { show_manual_merge: false };
}
var hasMerged = fileListData.some(function (item) {
return item.file_type == 4 || item.file_type === '4';
});
var sourceCount = fileListData.filter(function (item) {
return item.file_type == 1 || item.file_type === '1'
|| item.file_type == 2 || item.file_type === '2';
}).length;
if (!hasMerged && sourceCount >= 1) {
return {
show_manual_merge: true,
button_label: 'Merge documents',
mergeable_count: sourceCount
};
}
return serverMergeUi || { show_manual_merge: false };
}
function updateManualMergeButton(mergeUi, fileListData) {
var $btn = $('#btn_manual_merge_claim_files');
if (!$btn.length) {
return;
}
var resolved = resolveManualMergeUi(mergeUi, fileListData);
if (resolved && resolved.show_manual_merge) {
$btn.removeClass('d-none').css('display', 'inline-block');
$btn.text(resolved.button_label || 'Merge documents');
$btn.prop('disabled', false);
} else {
$btn.addClass('d-none').css('display', '');
}
}
$(document).on('click', '#btn_manual_merge_claim_files', function () {
var ticket_id = getClaimFileListTicketId();
if (!ticket_id) {
toastr.warning('Ticket ID is missing. Please reload the page.', 'Validation');
return;
}
var $btn = $(this);
$.ajax({
url: "<?= base_url('ticket/manualMergeClaimFiles') ?>",
type: "POST",
data: { ticket_id: ticket_id },
dataType: "json",
beforeSend: function () {
$btn.prop('disabled', true);
$('.loader').fadeIn();
$('.loader-mask').fadeIn();
},
success: function (res) {
if (res && res.status === true) {
toastr.success(res.message || 'Documents merged successfully', 'Success');
getUrlDataByTicketId(ticket_id);
} else {
toastr.error((res && res.message) ? res.message : 'Failed to merge documents', 'Error');
}
},
error: function (xhr) {
var msg = 'Failed to merge documents';
try {
var r = JSON.parse(xhr.responseText);
if (r.message) {
msg = r.message;
}
} catch (e) {}
toastr.error(msg, 'Error');
},
complete: function () {
$btn.prop('disabled', false);
$('.loader').fadeOut();
$('.loader-mask').delay(350).fadeOut('slow');
}
});
});
$(document).on('click', '#btn_upload_claim_files_to_tpa', function () {
var ticket_id = getClaimFileListTicketId();
if (!ticket_id) {
@ -395,10 +491,13 @@
console.log('Form submitted response:', response);
if (response.status === true) {
window._claimFilesMergeUi = response.merge_ui || null;
create_url_list(response.data);
updateManualMergeButton(response.merge_ui, response.data);
addHTMLInput();
return ;
} else {
updateManualMergeButton(null, []);
addHTMLInput();
console.warn("No Data");
}
@ -555,6 +654,7 @@
} else {
$('#table_bd').html('<tr><td colspan="5">No Data Found</td></tr>');
}
updateManualMergeButton(window._claimFilesMergeUi || null, data || []);
}
$(document).on('click', '.delete-url', function (e) {

114
app/Views/docs/README.md Normal file
View File

@ -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` | `<head>`, CSS tokens, top bar, opens `<div class="docs-layout">` |
| `docs_sidebar.php` | Left nav sidebar — edit the `$nav` array to add/remove pages |
| `docs_main_open.php` | Opens `<main>`, renders breadcrumb, h1, meta row |
| `docs_main_close.php` | Closes `</main>`, prev/next nav, right TOC, closes layout div |
| `docs_footer.php` | Global footer bar, hljs init, closes `</body></html>` |
| `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
<?php
/**
* Content only.
* The controller injects the layout and page metadata.
*/
?>
<p>...</p>
<h2 id="section-one">Section One</h2>
<p>...</p>
```
---
## 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 |
|---------------------|----------------------------------------|
| `<h2 id="...">` `<h3 id="...">` | 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 |
| `<ol class="steps">` | Numbered step list with connector lines |
| `<table>` | 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` + `<pre>` | 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);
}
```

480
app/Views/docs/acl.php Normal file
View File

@ -0,0 +1,480 @@
<?php
/**
* ACL / Access Control - content only
* app/Views/docs/acl.php
*
* Based on app/Config/Acl.php and app/Filters/AclFilter.php
*/
?>
<p>
Route-level access control is handled by <code>Config\Acl</code> plus the
global <code>AclFilter</code>. 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.
</p>
<div class="callout info">
<span>i</span>
<div>
<strong>Core files</strong>
ACL rules live in <code>app/Config/Acl.php</code>. Enforcement lives in
<code>app/Filters/AclFilter.php</code>. Global activation is configured in
<code>app/Config/Filters.php</code>.
</div>
</div>
<h2 id="overview">Overview</h2>
<div class="mermaid-wrapper">
<div class="mermaid">
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]
</div>
</div>
<h2 id="where-it-is-wired">Where it is wired</h2>
<p>
<code>AclFilter</code> is registered as an alias and applied in the global
<code>before</code> filter stack, with a route exception list.
</p>
<pre><code class="language-php">'AclFilter' => AclFilter::class,
'before' => [
'AclFilter' => ['except' => [
'login',
'logout',
'auth/*',
'oauth2callback',
'claim-form-download',
'claims-feedback-form',
'autobookstackLogin',
'employeeRest/*',
'processjob',
'getCommission',
'downloadEmployeeEcardZip',
'downloadClaimFile/*',
'api/v1/*'
]],
]</code></pre>
<p>
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.
</p>
<h2 id="rule-format">Rule format</h2>
<p>
Each ACL rule in <code>Config\Acl::$rules</code> uses a regex pattern as the
key and a rule definition array as the value.
</p>
<table>
<thead>
<tr><th>Key</th><th>Meaning</th></tr>
</thead>
<tbody>
<tr>
<td><code>public</code></td>
<td>If truthy, the route is allowed without session, role, or team checks.</td>
</tr>
<tr>
<td><code>roles</code></td>
<td>List of allowed role IDs, typically using constants like <code>ADMIN_ROLE_ID</code>.</td>
</tr>
<tr>
<td><code>teams</code></td>
<td>List of allowed team IDs, typically using constants like <code>CLAIMS_TEAM_ID</code>.</td>
</tr>
</tbody>
</table>
<pre><code class="language-php">'#^/client#' => [
'roles' => [HEAD_ROLE_ID, ADMIN_ROLE_ID, MANAGER_ROLE_ID, ACCOUNT_MANAGER_ROLE_ID],
'teams' => []
],
'#^/claims-feedback-form#' => ['public' => true],</code></pre>
<p>
Regex patterns are matched against normalized paths such as
<code>/dashboard/view</code>, <code>/client/list</code>, or
<code>/ticket/view/123</code>.
</p>
<h2 id="matching-behavior">Matching behavior</h2>
<p>
Matching is ordered and strict:
</p>
<ol class="steps">
<li>
<strong>The filter normalizes the path</strong>
<p>It removes the base application path and strips <code>/index.php</code> if present.</p>
</li>
<li>
<strong>Rules are evaluated top to bottom</strong>
<p>The filter loops through <code>$rules</code> and stops on the first regex match.</p>
</li>
<li>
<strong>First match wins</strong>
<p>Later rules are ignored once an earlier pattern matches.</p>
</li>
<li>
<strong>No match means deny</strong>
<p>If nothing matches, the request is blocked immediately.</p>
</li>
</ol>
<div class="callout warning">
<span>!</span>
<div>
<strong>Order is critical</strong>
Place more specific patterns before broad prefixes. A broad rule like
<code>#^/client#</code> will swallow more specific client routes if it appears
earlier and already matches what you need.
</div>
</div>
<p>
The config also ends with a zero-trust fallback:
</p>
<pre><code class="language-php">'#^/#' => [
'roles' => [ADMIN_ROLE_ID, HEAD_ROLE_ID],
'teams' => []
],</code></pre>
<p>
That default rule makes unmatched routes deny by default unless explicitly
opened earlier.
</p>
<p>
In practice, pattern matching works like this:
</p>
<table>
<thead>
<tr><th>Pattern</th><th>Matches</th><th>Does not match</th></tr>
</thead>
<tbody>
<tr>
<td><code>#^/client#</code></td>
<td><code>/client</code>, <code>/client/list</code>, <code>/client/create</code></td>
<td><code>/api/client</code></td>
</tr>
<tr>
<td><code>#^/client/special-report#</code></td>
<td><code>/client/special-report</code>, <code>/client/special-report/view</code></td>
<td><code>/client/list</code></td>
</tr>
<tr>
<td><code>#^/download-#</code></td>
<td><code>/download-e-card/123</code>, <code>/download-kyc-docs/abc</code></td>
<td><code>/client/download</code></td>
</tr>
</tbody>
</table>
<div class="callout info">
<span>i</span>
<div>
<strong>How to think about it</strong>
The filter does not look at controller names or route groups. It only checks
the normalized request path string against the regex keys in
<code>Config\Acl::$rules</code>.
</div>
</div>
<h2 id="auth-context">Auth context</h2>
<p>
<code>AclFilter</code> relies on session helper functions for the current user
context:
</p>
<table>
<thead>
<tr><th>Helper</th><th>Expected result</th></tr>
</thead>
<tbody>
<tr>
<td><code>check_session()</code></td>
<td>Returns <code>true</code> when the session contains <code>isLoggedIn === true</code>.</td>
</tr>
<tr>
<td><code>check_role()</code></td>
<td>Returns the current user's role ID from <code>get_session_userdata()-&gt;role</code>.</td>
</tr>
<tr>
<td><code>user_team()</code></td>
<td>Returns an array of current team IDs from the session key <code>user_team</code>.</td>
</tr>
</tbody>
</table>
<pre><code class="language-php">$userRole = check_role();
$userTeams = user_team();</code></pre>
<p>
For developers, this means ACL correctness depends on login/session setup
putting the right role and team data into session.
</p>
<h2 id="allow-and-deny-flow">Allow and deny flow</h2>
<p>
The allow sequence is:
</p>
<ol class="steps">
<li>
<strong>Public route check</strong>
<p>If the matched rule has <code>public</code>, access is allowed immediately.</p>
</li>
<li>
<strong>Authentication check</strong>
<p>If the route is not public and the session is missing, the filter returns either a JSON 401 or a web logout/redirect flow.</p>
</li>
<li>
<strong>Role-first authorization</strong>
<p>If the user's role ID is in <code>roles</code>, access is allowed.</p>
</li>
<li>
<strong>Team fallback authorization</strong>
<p>If no role matched but any current team ID is in <code>teams</code>, access is allowed.</p>
</li>
<li>
<strong>Deny otherwise</strong>
<p>The filter logs the block and returns a 403 response.</p>
</li>
</ol>
<table>
<thead>
<tr><th>Request type</th><th>Deny behavior</th></tr>
</thead>
<tbody>
<tr>
<td>AJAX / API / <code>/employeeRest</code></td>
<td>JSON error response with status <code>401</code> or <code>403</code>.</td>
</tr>
<tr>
<td>Normal web request</td>
<td>403 page rendered through <code>errors/403</code>, or logout redirect when session is missing.</td>
</tr>
</tbody>
</table>
<h2 id="developer-steps">Developer steps</h2>
<p>
When adding or changing a route, use this exact checklist:
</p>
<ol class="steps">
<li>
<strong>Decide whether the route should be public or protected</strong>
<p>If it should be accessible without login, add a <code>public</code> ACL rule or confirm that it is intentionally excluded from the global filter.</p>
</li>
<li>
<strong>Choose the correct path pattern</strong>
<p>Write the regex against the normalized route path, not the full server URL and not a filesystem path.</p>
</li>
<li>
<strong>Add the ACL rule in the right order</strong>
<p>Insert the new rule in <code>app/Config/Acl.php</code> before any broader pattern that would match first.</p>
</li>
<li>
<strong>Prefer role rules first, team rules second</strong>
<p>If a route belongs to a business function, define the required role IDs and then optionally add team IDs for fallback access.</p>
</li>
<li>
<strong>Check whether the route is excluded in <code>Filters.php</code></strong>
<p>If it is listed in the ACL exception list, your new ACL rule will never run until the exception is removed.</p>
</li>
<li>
<strong>Test both success and failure paths</strong>
<p>Verify access with an allowed user, a disallowed user, and an unauthenticated request.</p>
</li>
</ol>
<p>
For a brand-new route, the safest developer workflow is:
</p>
<ol class="steps">
<li>
<strong>Create or confirm the route path first</strong>
<p>Know the actual URL path that the browser will hit, for example <code>/reports/monthly</code>.</p>
</li>
<li>
<strong>Write the narrowest ACL regex that covers exactly that area</strong>
<p>If only one route needs different access, do not start with a broad prefix rule.</p>
</li>
<li>
<strong>Place the new rule above any broader parent rule</strong>
<p>A specific child path must appear before its parent path if they need different access.</p>
</li>
<li>
<strong>Choose whether access is role-based, team-based, or public</strong>
<p>Prefer explicit <code>roles</code>. Use <code>teams</code> as fallback or business-group access where appropriate.</p>
</li>
<li>
<strong>Check the global ACL exception list</strong>
<p>If the route is bypassed in <code>Filters.php</code>, adding a rule in <code>Acl.php</code> alone will not protect it.</p>
</li>
<li>
<strong>Test the final path, not just the config</strong>
<p>Open the actual route in browser or hit it through the expected frontend flow with different user profiles.</p>
</li>
</ol>
<h2 id="examples">Examples</h2>
<p>
Example 1: add a protected MVC section for a new module:
</p>
<pre><code class="language-php">'#^/reports#' => [
'roles' => [HEAD_ROLE_ID, ADMIN_ROLE_ID, MANAGER_ROLE_ID],
'teams' => [FINANCE_TEAM_ID]
],</code></pre>
<p>
Example 2: add a public callback route:
</p>
<pre><code class="language-php">'#^/external-callback#' => ['public' => true],</code></pre>
<p>
Example 3: protect a narrow route before a broad one:
</p>
<pre><code class="language-php">'#^/client/special-report#' => [
'roles' => [ADMIN_ROLE_ID],
'teams' => []
],
'#^/client#' => [
'roles' => [HEAD_ROLE_ID, ADMIN_ROLE_ID, MANAGER_ROLE_ID, ACCOUNT_MANAGER_ROLE_ID],
'teams' => []
],</code></pre>
<p>
Example 4: add a new route safely without breaking an existing broad rule:
</p>
<pre><code class="language-php">// 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' => []
],</code></pre>
<p>
The specific <code>/master/export-audit</code> rule must stay above the broader
<code>/master</code> rule, otherwise the broad rule will match first and the
special restriction will never apply.
</p>
<h2 id="do-and-dont">Do and dont</h2>
<table>
<thead>
<tr><th>Do</th><th>Dont</th></tr>
</thead>
<tbody>
<tr>
<td>Write rules against normalized URL paths like <code>/client/list</code>.</td>
<td>Do not write ACL rules against controller class names or filesystem paths.</td>
</tr>
<tr>
<td>Put specific patterns before broad patterns.</td>
<td>Do not place <code>#^/client#</code> above a more specific child route that needs different access.</td>
</tr>
<tr>
<td>Check <code>Filters.php</code> exceptions before assuming ACL applies.</td>
<td>Do not assume a new ACL rule is active if the route is globally excluded.</td>
</tr>
<tr>
<td>Use <code>public</code> only for routes that truly must bypass auth.</td>
<td>Do not mark internal routes public just to “make it work”.</td>
</tr>
<tr>
<td>Test with allowed, denied, and logged-out users.</td>
<td>Do not test only as admin and assume the ACL is correct.</td>
</tr>
<tr>
<td>Keep the fallback deny model intact.</td>
<td>Do not weaken the final catch-all rule unless you fully understand the impact.</td>
</tr>
</tbody>
</table>
<h2 id="common-pitfalls">Common pitfalls</h2>
<table>
<thead>
<tr><th>Pitfall</th><th>Why it happens</th></tr>
</thead>
<tbody>
<tr>
<td>ACL rule added but never used</td>
<td>The route is still listed in the ACL filter <code>except</code> list.</td>
</tr>
<tr>
<td>Specific rule appears correct but never matches</td>
<td>A broader earlier regex already matched first.</td>
</tr>
<tr>
<td>Team access does not work</td>
<td><code>user_team()</code> must return an array of team IDs in session.</td>
</tr>
<tr>
<td>Unexpected 403 on web routes</td>
<td>No matching rule, wrong ordering, wrong regex, or missing role/team data in session.</td>
</tr>
<tr>
<td>Unexpected JSON 403/401</td>
<td>The request is AJAX or under an API-style prefix, so the filter returns JSON instead of a web page.</td>
</tr>
</tbody>
</table>
<div class="callout success">
<span>+</span>
<div>
<strong>Practical rule for developers</strong>
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.
</div>
</div>

View File

@ -0,0 +1,520 @@
<?php
/**
* API Rate Limiter content only
* app/Views/docs/api-rate-limiter.php
*
* Based on app/Libraries/RateLimiterService.php,
* app/Filters/AuthApiRateLimitFilter.php, app/Filters/JwtApiFilter.php (class JwtApiRateLimitFilter).
*/
?>
<p>
The API rate limiter combines a shared <code>RateLimiterService</code> 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.
</p>
<h2 id="overview">Overview</h2>
<p>
A client fingerprint is built with <code>generateFingerprint(exclude_ua: true)</code>, 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 (<code>Config\Services::cache()</code>).
</p>
<div class="mermaid-wrapper">
<div class="mermaid">
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]
</div>
</div>
<h2 id="core-service">Core service</h2>
<p>
<code>App\Libraries\RateLimiterService</code> reads limits from <code>app/Config/RateLimiter.php</code>.
It separates <strong>IP behaviour</strong> (shared <code>$config->ipBlock</code>) from
<strong>user behaviour</strong> (shared <code>$config->userBlock</code> for block durations and
escalation), while per-route-type windows and limits use either <code>$jwtApi</code> or
<code>$authApi</code> depending on the string passed from the filter (<code>jwtApi</code> vs
<code>authApi</code>).
</p>
<h3>IP level</h3>
<ul>
<li><code>checkIp($fingerprint, $routeType)</code> if the IP is already blocked, returns a block payload and may escalate soft medium hard when additional requests hit while blocked.</li>
<li>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.</li>
<li><code>recordIpFailure($fingerprint)</code> increments the same violation path (used from
filter <code>after()</code> on failed controller responses).</li>
</ul>
<h3>User level</h3>
<ul>
<li>Identities are normalized for storage keys with <code>hash('sha256', strtolower(trim($identity)))</code>.</li>
<li><code>checkUser($identity)</code> returns a block response if that identity is already blocked.</li>
<li><code>checkUserThrottle($identity, 'jwtApi')</code> used on JWT routes: block check first, then
a per-user request counter in a time window (same violation soft-block pattern as IP).</li>
<li><code>recordUserFailure($identity, $routeType)</code> increments user violations when the
controller returns a failure; thresholds use the route-type config (<code>authApi</code> or
<code>jwtApi</code>).</li>
<li>Progressive blocks (soft, medium, hard) can escalate when the client keeps hitting endpoints
while already blocked; durations come from <code>userBlock</code> / <code>ipBlock</code> (zero
duration is treated as long-lived until manual unblock).</li>
</ul>
<p>
Manual operations exposed on the service include <code>blockIp</code>, <code>unblockIp</code>,
<code>blockUser</code>, and <code>unblockUser</code>, which clear the relevant cache keys for
counters, violations, and block records.
</p>
<h2 id="auth-api-filter">Auth API filter</h2>
<p>
<code>App\Filters\AuthApiRateLimitFilter</code> targets routes that do <strong>not</strong> rely on
JWT (for example mobile verification or OTP flows). Identity is resolved from POST fields first,
then GET: <code>email</code> (lower-cased) or <code>mobile_number</code> (trimmed).
</p>
<ul>
<li><strong>before:</strong> <code>checkIp($fingerprint, 'authApi')</code>, then if identity exists,
<code>checkUser($identity)</code> only (no per-user request throttle before the controller).</li>
<li><strong>after:</strong> On HTTP status 400, excluding 429, 403, and 451, records
<code>recordIpFailure</code> and <code>recordUserFailure($identity, 'authApi')</code> when identity
was stashed or can still be resolved so failed logins or bad OTP attempts feed the violation
counters.</li>
<li>Throttle/block JSON responses run the global <code>Cors</code> filters <code>after()</code>
handler so CORS headers stay consistent on early exits.</li>
</ul>
<h2 id="jwt-api-filter">JWT API filter</h2>
<p>
The class <code>JwtApiRateLimitFilter</code> lives in <code>app/Filters/JwtApiFilter.php</code>.
It resolves identity from <code>getEmailFromJWT()</code> or <code>getMobileFromJWT()</code> when those
helpers exist; invalid JWTs are caught and the request falls back to IP-only limiting.
</p>
<ul>
<li><strong>before:</strong> <code>checkIp($fingerprint, 'jwtApi')</code>, then
<code>checkUserThrottle($identity, 'jwtApi')</code> when identity is known.</li>
<li><strong>after:</strong> On controller failures (4xx except 429, 403, 451), records IP failure and
<code>recordUserFailure($identity, 'jwtApi')</code> for the JWT identity when available.</li>
</ul>
<h2 id="configuration">Configuration</h2>
<p>
Defaults in <code>app/Config/RateLimiter.php</code> (adjust per environment as needed):
</p>
<table>
<thead>
<tr><th>Key</th><th>Meaning (current defaults)</th></tr>
</thead>
<tbody>
<tr>
<td><code>jwtApi</code></td>
<td>60 requests per 60 seconds per user identity; 3 violations before a soft user block.</td>
</tr>
<tr>
<td><code>authApi</code></td>
<td>10 requests per 180 seconds at the IP bucket for auth routes; 3 user violations (from failed
responses) before a soft user block.</td>
</tr>
<tr>
<td><code>ipBlock</code></td>
<td>120 requests per 60 seconds per fingerprint; 5 violations before soft IP block; medium/hard
durations and triggers for escalation while blocked.</td>
</tr>
<tr>
<td><code>userBlock</code> / <code>ipBlock</code> durations</td>
<td>Soft can be stored as long TTL when duration is 0; medium 2 hours; hard 24 hours (see config).</td>
</tr>
<tr>
<td><code>statusCodes</code></td>
<td>Throttle and soft blocks use 429; medium 403; hard 451.</td>
</tr>
</tbody>
</table>
<h3 id="change-block-count-duration">How to change block count and duration</h3>
<p>
All tuning happens in <code>app/Config/RateLimiter.php</code>. No filter code changes are needed
for normal policy updates. Edit values, deploy, and clear cache if your backend keeps old keys.
</p>
<h4>What controls what</h4>
<ul>
<li><code>jwtApi.limit</code> and <code>jwtApi.window</code>: per-user request throttle for JWT APIs.</li>
<li><code>authApi.limit</code> and <code>authApi.window</code>: auth-route request throttle window used by
IP checks in the auth filter flow.</li>
<li><code>jwtApi.violation_soft</code> / <code>authApi.violation_soft</code>: number of recorded violations
before applying a soft user block.</li>
<li><code>ipBlock.limit</code> and <code>ipBlock.window</code>: global per-fingerprint request throttle.</li>
<li><code>ipBlock.violation_soft</code>: over-limit events before soft IP block.</li>
<li><code>userBlock.soft_duration</code>, <code>medium_duration</code>, <code>hard_duration</code>:
user-block durations in seconds.</li>
<li><code>ipBlock.soft_duration</code>, <code>medium_duration</code>, <code>hard_duration</code>:
IP-block durations in seconds.</li>
<li><code>userBlock.medium_trigger</code> / <code>hard_trigger</code> and equivalent in
<code>ipBlock</code>: attempts while already blocked that escalate level.</li>
</ul>
<h4>Duration conversion quick reference</h4>
<pre><code class="language-text">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)</code></pre>
<h4>Sample 1: Strict production policy</h4>
<pre><code class="language-php">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,
];</code></pre>
<h4>Sample 2: Balanced default-like policy</h4>
<pre><code class="language-php">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,
];</code></pre>
<h4>Sample 3: Dev / QA friendly policy</h4>
<pre><code class="language-php">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,
];</code></pre>
<h4>Change workflow (safe rollout)</h4>
<ol>
<li>Copy current values from <code>RateLimiter.php</code> to your release notes for rollback.</li>
<li>Change one policy group at a time (for example JWT first, then auth).</li>
<li>Deploy and clear cache keys if required by your cache backend strategy.</li>
<li>Monitor 429/403/451 counts and support tickets for 24-48 hours.</li>
<li>Adjust <code>violation_soft</code> and durations gradually, not in large jumps.</li>
</ol>
<div class="callout warning">
<span>!</span>
<div>
<strong>Important:</strong>
In this implementation, a duration of <code>0</code> is treated as long-lived and practically
permanent until manual unblock via <code>unblockIp()</code> or <code>unblockUser()</code>.
</div>
</div>
<h3 id="manual-unblock-samples">Manual unblock samples</h3>
<p>
Use <code>unblockUser()</code> and <code>unblockIp()</code> when support confirms a genuine user was
blocked by policy. Keep unblock actions auditable (who unblocked, why, and when).
</p>
<h4>Sample: controller/admin action</h4>
<pre><code class="language-php">&lt;?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',
]);
}
}</code></pre>
<h4>Sample: CLI / one-off script logic</h4>
<pre><code class="language-php">$limiter = new \App\Libraries\RateLimiterService();
$identity = 'user@example.com';
$fingerprint = 'known-fingerprint-key';
$limiter->unblockUser($identity);
$limiter->unblockIp($fingerprint);</code></pre>
<p>
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).
</p>
<h4>Unblock SOP (short checklist)</h4>
<ol>
<li><strong>Verify requester:</strong> confirm account identity (email/mobile/user ID) from ticket context.</li>
<li><strong>Check scope:</strong> determine whether block is user-level, IP-level, or both.</li>
<li><strong>Apply least-risk fix:</strong> run <code>unblockUser()</code> first; use <code>unblockIp()</code> only if still blocked and justified.</li>
<li><strong>Audit it:</strong> record ticket ID, operator, timestamp, action taken, and reason.</li>
<li><strong>Watch rebound:</strong> monitor logs/metrics for quick re-block; escalate if abuse pattern continues.</li>
</ol>
<h2 id="cache-ttl-auto-release">Cache TTL and auto-release</h2>
<p>
Runtime enforcement of a block is whether the block payload exists in the application cache
(<code>RateLimiterService::blockIp()</code> / <code>blockUser()</code> call
<code>$this->cache->save(..., $ttl)</code>). When <code>$ttl</code> is a positive number of seconds
(medium and hard levels in <code>app/Config/RateLimiter.php</code>), the entry expires after that
period. The next <code>cache->get()</code> no longer returns block data, so the client is no longer
blocked for API checks (throttle and violation keys use their own TTLs).
</p>
<p>
With the default <strong>file</strong> cache handler (<code>app/Config/Cache.php</code>),
CodeIgniters <code>FileHandler</code> treats an item as expired when
<code>now > stored_time + ttl</code>; on read it removes the file and returns empty, so behaviour
matches a timed release without a separate unlock job.
</p>
<p>
When a levels configured duration is <code>0</code> (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.
</p>
<h2 id="db-reconciliation-cron">DB reconciliation (cron)</h2>
<p>
Active blocks are also upserted into the <code>rate_limit_blocks</code> table for admin visibility
(<code>/security/rate-limits</code>). Cache entries for medium/hard can disappear on TTL while the
database row stays <code>status = active</code> until something cleans it up. A scheduled job keeps
the index aligned with real enforcement and clears any leftover cache keys.
</p>
<p>
Spark command (implementation: <code>app/Commands/RateLimitBlocksReconcile.php</code>):
</p>
<pre><code class="language-bash">php spark rate-limit:reconcile-blocks --dry-run
php spark rate-limit:reconcile-blocks</code></pre>
<p>
Behaviour summary:
</p>
<ul>
<li>Selects rows where <code>status = 'active'</code>.</li>
<li>Computes expiry as <code>blocked_at + duration(block_level)</code> using the same duration fields
as <code>RateLimiter</code> (<code>userBlock</code> vs <code>ipBlock</code> depending on
<code>block_type</code>). Rows whose duration is <code>&lt;= 0</code> are skipped so permanent-style
soft blocks are not removed by the job.</li>
<li>If the row is past that time: calls <code>RateLimiterService::purgeIpBlockCaches()</code> or
<code>purgeUserBlockCaches()</code> (same cache keys as manual unblock, without updating the DB
row first), then <strong>deletes</strong> the row from <code>rate_limit_blocks</code>.</li>
<li><code>--dry-run</code> prints what would be reconciled without changing cache or the database.</li>
</ul>
<p>
Example cron (every 10 minutes on Linux):
</p>
<pre><code class="language-bash">*/10 * * * * cd /path/to/nhance &amp;&amp; php spark rate-limit:reconcile-blocks &gt;&gt; /path/to/logs/rate-limit-reconcile.log 2&gt;&amp;1</code></pre>
<p>
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 <code>unblocked</code> rather than delete (not the
current implementation).
</p>
<div class="callout warning">
<span>!</span>
<div>
<strong>Config changes:</strong> expiry for reconciliation uses <strong>current</strong>
<code>RateLimiter</code> 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.
</div>
</div>
<h2 id="http-responses">HTTP responses</h2>
<p>
When the service returns a structured result, filters respond with JSON of the form:
</p>
<pre><code class="language-json">{
"success": false,
"error": {
"code": "RATE_LIMIT_THROTTLE",
"message": "Too many requests. Please slow down.",
"type": "ip"
}
}</code></pre>
<p>
For progressive blocks, <code>code</code> uses <code>RATE_LIMIT_</code> plus the level
(<code>SOFT</code>, <code>MEDIUM</code>, <code>HARD</code>), and <code>type</code> is
<code>ip</code> or <code>user</code>. Messages for blocks are defined in
<code>RateLimiterService::blockedResponse()</code>.
</p>
<h2 id="wiring-routes">Wiring routes</h2>
<p>
Aliases in <code>app/Config/Filters.php</code>:
</p>
<pre><code class="language-php">'AuthApiRateLimitFilter' => AuthApiRateLimitFilter::class,
'JwtApiRateLimitFilter' => JwtApiRateLimitFilter::class,</code></pre>
<p>
Attach them per route (or route group) with the <code>filter</code> option, for example:
</p>
<pre><code class="language-php">$routes->post('api/auth/verify-otp', 'AuthController::verifyOtp', ['filter' => 'AuthApiRateLimitFilter']);
$routes->get('api/profile', 'ProfileController::index', ['filter' => 'JwtApiRateLimitFilter']);</code></pre>
<p>
Ensure JWT helpers used by <code>JwtApiRateLimitFilter</code> match your authentication stack; the
filter comments note replacing helper names if your project uses different entry points.
</p>
<h2 id="blocked-list-url">Blocked list URL</h2>
<p>
Admin can view active blocked IP and blocked user entries at:
</p>
<pre><code class="language-text">/security/rate-limits</code></pre>
<p>
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 <code>ADMIN_ROLE_ID</code> only.
</p>
<p>
Direct actions on the same feature:
</p>
<pre><code class="language-text">POST /security/rate-limits/unblock-ip
POST /security/rate-limits/unblock-user</code></pre>
<h2 id="smoke-test-command">Smoke test command</h2>
<p>
The project includes a targeted smoke test for this service at
<code>tests/unit/RateLimiterServiceSmokeTest.php</code>. Run it with:
</p>
<pre><code class="language-bash">php vendor/bin/phpunit --filter RateLimiterServiceSmokeTest</code></pre>
<p>
Expected result on success: <code>OK (3 tests, 77 assertions)</code> (assertion count can change as
tests evolve).
</p>
<h2 id="operational-notes">Operational notes</h2>
<ul>
<li>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.</li>
<li>Timed medium/hard blocks clear from cache automatically when TTL elapses; see
<a href="#cache-ttl-auto-release">Cache TTL and auto-release</a>. Permanent-style blocks
(<code>0</code> second duration, stored as a long TTL) still require
<code>unblockIp</code> / <code>unblockUser</code> or admin unblock.</li>
<li>Run <a href="#db-reconciliation-cron">DB reconciliation (cron)</a> on a schedule if you want
<code>rate_limit_blocks</code> rows removed after timed blocks end, and stray cache files cleared.</li>
<li>Filters skip recording failures on 429, 403, and 451 so rate-limit and block responses are not
double-counted as application failures.</li>
</ul>

View File

@ -0,0 +1,566 @@
<?php
/**
* Background Jobs - content only
* app/Views/docs/background-jobs.php
*
* This page documents the current queue implementation built around:
* - app/Controllers/Jobs.php
* - app/Controllers/JobWorker.php
* - app/Controllers/Jobs/*.php sample handlers
* - app/Models/JobModel.php
*/
?>
<p>
Nhance uses a database-backed job queue for long-running or deferred work. A
producer adds a row into the <code>jobs</code> table through
<code>Jobs::addJob()</code>, and the CLI worker in
<code>JobWorker</code> picks up queued rows, executes the mapped handler, and
writes the final status and response back to the same record.
</p>
<div class="callout info">
<span>i</span>
<div>
<strong>Current implementation</strong>
This page describes the queue exactly as it exists today, including the
handler registry in <code>JobWorker::$event_class_mapping</code> and the
helper methods already used by controllers like <code>EmployeeController</code>
and <code>LeadsController</code>.
</div>
</div>
<h2 id="overview">Overview</h2>
<p>
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.
</p>
<div class="mermaid-wrapper">
<div class="mermaid">
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]
</div>
</div>
<h2 id="queueing-jobs">Queueing jobs</h2>
<p>
New jobs are inserted through <code>Jobs::addJob(array $payload)</code>. The
method validates the input, generates a UUID, JSON-encodes the inner payload,
and stores the row with status <code>queued</code> unless a custom status is
provided.
</p>
<table>
<thead>
<tr><th>Key</th><th>Required</th><th>Description</th></tr>
</thead>
<tbody>
<tr>
<td><span class="param-name">job_name</span></td>
<td><span class="badge req">required</span></td>
<td>Name used to look up the handler in <code>JobWorker::$event_class_mapping</code>.</td>
</tr>
<tr>
<td><span class="param-name">payload</span></td>
<td><span class="badge req">required</span></td>
<td>Array that will be JSON-encoded into the <code>jobs.payload</code> column.</td>
</tr>
<tr>
<td><span class="param-name">status</span></td>
<td><span class="badge opt">optional</span></td>
<td>Defaults to <code>queued</code>.</td>
</tr>
</tbody>
</table>
<pre><code class="language-php">$job = Jobs::addJob([
'job_name' => 'memberDataListExcelFileFormatValidation',
'payload' => [
'lead_id' => $lead_id,
'age_validation' => true,
],
]);
</code></pre>
<p>
The method returns an array with <code>id</code>, <code>uuid</code>, and
<code>job_name</code>. Real code paths already use this pattern, for example
after lead placement data is saved and then queued for validation.
</p>
<h2 id="worker-lifecycle">Worker lifecycle</h2>
<p>
<code>JobWorker</code> is the execution engine. It defines four statuses:
<code>queued</code>, <code>running</code>, <code>done</code>, and
<code>failed</code>.
</p>
<ol class="steps">
<li>
<strong><code>processJobs()</code> fetches all queued rows</strong>
<p>Jobs are selected from <code>jobs</code> ordered by <code>created_dt ASC</code>.</p>
</li>
<li>
<strong>Each queued row is forwarded to <code>processJob()</code></strong>
<p>The worker can process the next queued row, or a specific <code>id</code> plus <code>uuid</code> pair.</p>
</li>
<li>
<strong>Status changes to <code>running</code></strong>
<p>Once picked, the worker updates the row before invoking the handler.</p>
</li>
<li>
<strong>The handler receives the decoded payload</strong>
<p>The worker resolves the handler from the event map and passes the job payload into it.</p>
</li>
<li>
<strong>The row is finalized</strong>
<p>After execution, the worker stores the final status, runtime, and response JSON back into the same job row.</p>
</li>
</ol>
<div class="callout warning">
<span>!</span>
<div>
<strong>Selection and locking</strong>
The worker query uses <code>LIMIT 1 FOR UPDATE</code> when fetching a single
job. In practice, treat the queue as database-backed and process it from CLI
workers, not from normal web requests.
</div>
</div>
<h2 id="handler-registry">Handler registry</h2>
<p>
Every executable job must be registered in
<code>JobWorker::$event_class_mapping</code>. Each entry defines a handler
category and a target class or function.
</p>
<table>
<thead>
<tr><th>Type</th><th>Meaning</th><th>Example from code</th></tr>
</thead>
<tbody>
<tr>
<td><code>CC</code></td>
<td>Controller class handler</td>
<td><code>App\Controllers\Jobs\SubJob</code>, <code>EmployeeServiceController</code></td>
</tr>
<tr>
<td><code>HC</code></td>
<td>Helper-style class handler</td>
<td><code>App\Helpers\HttpRequestHelper</code>, <code>App\Helpers\MailHelper</code></td>
</tr>
<tr>
<td><code>HF</code></td>
<td>Standalone function handler</td>
<td><code>fancy_date_time_format</code></td>
</tr>
</tbody>
</table>
<p>
Resolution order inside the worker is:
</p>
<ol class="steps">
<li>
<strong>Look up the job name in the mapping</strong>
<p>If the name is missing, the worker throws an exception and marks the job failed.</p>
</li>
<li>
<strong>Instantiate the mapped class for <code>CC</code> or <code>HC</code></strong>
<p>The mapped class must exist.</p>
</li>
<li>
<strong>Choose the callable</strong>
<p>If a method matching the job name exists, it is used first; otherwise the worker falls back to <code>handle()</code>.</p>
</li>
<li>
<strong>For <code>HF</code>, call the mapped function directly</strong>
<p>The handler value itself is treated as the callable.</p>
</li>
</ol>
<h2 id="sample-handlers">Sample handlers</h2>
<p>
The <code>app/Controllers/Jobs/</code> directory currently contains two simple
examples that show the expected pattern for small job classes:
</p>
<table>
<thead>
<tr><th>File</th><th>Method</th><th>Behavior</th></tr>
</thead>
<tbody>
<tr>
<td><code>app/Controllers/Jobs/AddJob.php</code></td>
<td><code>handle($payload)</code></td>
<td>Returns <code>$payload['a'] + $payload['b']</code>.</td>
</tr>
<tr>
<td><code>app/Controllers/Jobs/SubJob.php</code></td>
<td><code>handle($payload)</code></td>
<td>Logs through <code>mylogger</code> and returns <code>$payload['a'] - $payload['b']</code>.</td>
</tr>
</tbody>
</table>
<pre><code class="language-php">namespace App\Controllers\Jobs;
use App\Controllers\PublicController;
class ExampleJob extends PublicController
{
public function handle($payload)
{
return [
'ok' => true,
'received' => $payload,
];
}
}
</code></pre>
<h2 id="status-lifecycle">Status lifecycle</h2>
<p>
The queue storage model is <code>app/Models/JobModel.php</code>, which maps to
the <code>jobs</code> table and allows these main fields:
</p>
<table>
<thead>
<tr><th>Column</th><th>Purpose</th></tr>
</thead>
<tbody>
<tr><td><code>id</code></td><td>Primary key returned after enqueue.</td></tr>
<tr><td><code>name</code></td><td>Logical job name used in the worker registry.</td></tr>
<tr><td><code>payload</code></td><td>JSON-encoded input payload.</td></tr>
<tr><td><code>response</code></td><td>JSON-encoded handler output or failure details.</td></tr>
<tr><td><code>status</code></td><td><code>queued</code>, <code>running</code>, <code>done</code>, or <code>failed</code>.</td></tr>
<tr><td><code>run_time</code></td><td>Measured execution time for the job.</td></tr>
<tr><td><code>uuid</code></td><td>Generated at enqueue time and used when fetching a specific row.</td></tr>
</tbody>
</table>
<div class="mermaid-wrapper">
<div class="mermaid">
stateDiagram-v2
[*] --> queued
queued --> running
running --> done
running --> failed
</div>
</div>
<h2 id="failure-behavior">Failure behavior</h2>
<p>
Both worker-level failures and handler-level failures are caught and written
back into <code>jobs.response</code> 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.
</p>
<div class="callout danger">
<span>x</span>
<div>
<strong>Special file failure branch</strong>
When a failed job payload contains only a numeric <code>file_id</code>, the
worker also updates the related <code>files</code> row to
<code>status = failed</code> and writes a generic system error reason. This
is important for file-processing jobs that surface state back to the UI.
</div>
</div>
<h2 id="running-via-cli">Running via CLI</h2>
<p>
The worker is exposed through CLI routes in <code>app/Config/Routes.php</code>.
</p>
<table>
<thead>
<tr><th>Route</th><th>Target</th><th>Purpose</th></tr>
</thead>
<tbody>
<tr>
<td><code>cli/processjob</code></td>
<td><code>JobWorker::processJob</code></td>
<td>Process one queued job.</td>
</tr>
<tr>
<td><code>cli/processjobs</code></td>
<td><code>JobWorker::processJobs</code></td>
<td>Loop through all queued jobs in created order.</td>
</tr>
</tbody>
</table>
<pre><code class="language-bash">php spark cli/processjob
php spark cli/processjobs</code></pre>
<p>
There is also a web route named <code>processjob</code>, but operationally this
queue should be treated as a CLI worker flow.
</p>
<h2 id="live-runner-script">Live runner script</h2>
<p>
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 <code>phpqueue.sh</code> script and replace the placeholder
app paths with your deployment-specific values.
</p>
<pre><code class="language-bash">#!/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</code></pre>
<table>
<thead>
<tr><th>Dummy path</th><th>Description</th></tr>
</thead>
<tbody>
<tr>
<td><code>/var/www/example-suite/zenith-app/public/index.php</code></td>
<td>Example public entry file for the first application queue target.</td>
</tr>
<tr>
<td><code>/var/www/example-suite/enrolment-app/public/index.php</code></td>
<td>Example public entry file for the second application queue target.</td>
</tr>
<tr>
<td><code>/var/www/example-suite/partner-api/public/index.php</code></td>
<td>Example public entry file for the third application queue target.</td>
</tr>
</tbody>
</table>
<div class="callout warning">
<span>!</span>
<div>
<strong>Production note</strong>
This script is an infinite loop, so it should be started under a process
manager such as <code>systemd</code>, <code>supervisord</code>, or another
service wrapper rather than being launched manually in a shell session.
</div>
</div>
<h2 id="systemd-service">Systemd service</h2>
<p>
For Ubuntu-style live deployments, keep a systemd unit file such as
<code>nhance_php_queue_server.service</code>. The repository now includes a
sanitized template with placeholder values.
</p>
<pre><code class="language-ini">[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</code></pre>
<table>
<thead>
<tr><th>Dummy value</th><th>Description</th></tr>
</thead>
<tbody>
<tr>
<td><code>/var/www/example-suite/nhance-app</code></td>
<td>Example project root where <code>phpqueue.sh</code> is kept.</td>
</tr>
<tr>
<td><code>www-data</code></td>
<td>Example service account; replace it with the real Linux user and group used by PHP on that server.</td>
</tr>
</tbody>
</table>
<p>
After replacing the placeholders, deploy and enable it with standard systemd
commands:
</p>
<pre><code class="language-bash">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</code></pre>
<h2 id="permissions-setup">Permissions setup</h2>
<p>
The queue runner script should live in the project root as
<code>phpqueue.sh</code>, and its ownership plus execute permission should be
set for the service user and group used by PHP in that environment.
</p>
<pre><code class="language-bash">sudo chown &lt;service-user&gt;:&lt;service-group&gt; phpqueue.sh
sudo chmod 750 phpqueue.sh</code></pre>
<div class="callout info">
<span>i</span>
<div>
<strong>Why this matters</strong>
In live environments, the two crucial steps are:
using a managed system service for the queue loop, and ensuring the
root-level <code>phpqueue.sh</code> file has the correct owner, group, and
execute permission for that service account.
</div>
</div>
<h2 id="checking-status">Checking status</h2>
<p>
<code>app/Libraries/JobStatusService.php</code> now supports lookup by job
name, by job <code>id</code>, and by job <code>uuid</code>. All methods normalize the
decoded response and runtime before returning them.
</p>
<table>
<thead>
<tr><th>Method</th><th>Use when</th></tr>
</thead>
<tbody>
<tr>
<td><code>getJobStatusByName(string $jobName)</code></td>
<td>You want the latest job row for a logical job name.</td>
</tr>
<tr>
<td><code>getJobStatusById(int $jobId)</code></td>
<td>You know the numeric queue row id and want that exact job record.</td>
</tr>
<tr>
<td><code>getJobStatusByUuid(string $uuid)</code></td>
<td>You want to track one exact job instance using the UUID returned at enqueue time.</td>
</tr>
</tbody>
</table>
<pre><code class="language-php">$service = new \App\Libraries\JobStatusService();
$status = $service->getJobStatusByName('bulkGenerateEcardAndStoreinS3');
</code></pre>
<pre><code class="language-php">$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']);
</code></pre>
<p>
The service returns <code>success</code>, <code>job_name</code>,
<code>status</code>, <code>response</code>, <code>job_id</code>,
<code>uuid</code>, <code>run_time</code>, and a message.
</p>
<div class="callout info">
<span>i</span>
<div>
<strong>Which lookup should you use?</strong>
Use <code>job name</code> when you only care about the latest run for a given
handler. Use <code>job id</code> or <code>uuid</code> when the enqueue response
is available and you need to track one specific job instance across retries
or parallel runs.
</div>
</div>
<h2 id="adding-a-new-handler">Adding a new handler</h2>
<ol class="steps">
<li>
<strong>Create the handler class or function</strong>
<p>For small custom jobs, <code>app/Controllers/Jobs/</code> is already used as a simple home for dedicated job handlers.</p>
</li>
<li>
<strong>Register the job in <code>JobWorker::$event_class_mapping</code></strong>
<p>Pick the correct type: <code>CC</code>, <code>HC</code>, or <code>HF</code>.</p>
</li>
<li>
<strong>Expose a callable method</strong>
<p>The worker first looks for a method matching the job name, then falls back to <code>handle()</code>.</p>
</li>
<li>
<strong>Queue the job through <code>Jobs::addJob()</code></strong>
<p>Pass a stable job name and only the payload fields the handler actually needs.</p>
</li>
<li>
<strong>Run the worker and inspect the job row</strong>
<p>Validate the final status, runtime, and response before integrating the job into larger workflows.</p>
</li>
</ol>
<pre><code class="language-php">// 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',
],
]);
</code></pre>
<div class="callout success">
<span>+</span>
<div>
<strong>Practical rule</strong>
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.
</div>
</div>

79
app/Views/docs/cicd.php Normal file
View File

@ -0,0 +1,79 @@
<?php
/**
* CI/CD Pipeline - content only
* app/Views/docs/cicd.php
*/
?>
<p>
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.
</p>
<h2 id="overview">Overview</h2>
<div class="mermaid-wrapper">
<div class="mermaid">
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]
</div>
</div>
<h2 id="dev-pipeline">Dev pipeline</h2>
<p>
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.
</p>
<p>
For frontend or static asset publishing, the team also uses a separate
S3 + CloudFront batch workflow documented on the <code>S3 &amp; CloudFront</code>
page.
</p>
<h2 id="uat-live-flow">UAT and live flow</h2>
<p>
For UAT and live, branch movement is not push-triggered. The standard order is
<code>dev -> test -> uat -> live</code> through <code>auto_merge.py</code>,
followed by the relevant environment deployment shell scripts on the server.
</p>
<div class="callout warning">
<span>!</span>
<div>
<strong>Manual prerequisites still apply</strong>
DB changes, environment variable updates, and other environment-specific
release tasks must still be handled manually before the final deployment
scripts are executed.
</div>
</div>
<h2 id="related-docs">Related docs</h2>
<table>
<thead>
<tr><th>Page</th><th>What it covers</th></tr>
</thead>
<tbody>
<tr>
<td><a href="<?= base_url('docs/deployment') ?>">Deployment</a></td>
<td>cPanel dev deployment, branch promotion, merge helper, and UAT/live code move steps.</td>
</tr>
<tr>
<td><a href="<?= base_url('docs/s3-cloudfront') ?>">S3 &amp; CloudFront</a></td>
<td>Dev batch script for S3 upload and CloudFront invalidation.</td>
</tr>
</tbody>
</table>

View File

@ -0,0 +1,451 @@
<?php
/**
* Deployment - content only
* app/Views/docs/deployment.php
*
* DevOps deployment notes for branch promotion and release preparation.
*/
?>
<p>
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: <code>dev</code> <code>test</code>
<code>uat</code> <code>live</code> using a server-side Python merge helper.
</p>
<div class="callout info">
<span>i</span>
<div>
<strong>Server-side merge helper</strong>
The merge script is maintained on the deployment servers inside a
<code>repo_merge</code> 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.
</div>
</div>
<h2 id="overview">Overview</h2>
<p>
The deployment preparation flow has three main parts:
</p>
<ol class="steps">
<li>
<strong>Dev environment auto deployment through cPanel</strong>
<p>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.</p>
</li>
<li>
<strong>Promote branches using the merge helper</strong>
<p>This ensures <code>test</code>, <code>uat</code>, and <code>live</code> receive the expected upstream code in order.</p>
</li>
<li>
<strong>Run the environment deployment scripts</strong>
<p>Once branch promotion completes successfully, call the UAT or live deployment shell scripts from the deployment server.</p>
</li>
</ol>
<h2 id="dev-cpanel-flow">Dev cPanel flow</h2>
<p>
In the dev environment, deployment is handled through cPanel rather than the
branch-promotion helper. The current flow is:
</p>
<div class="mermaid-wrapper">
<div class="mermaid">
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]
</div>
</div>
<p>
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.
</p>
<div class="callout info">
<span>i</span>
<div>
<strong>Dev-only deployment path</strong>
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.
</div>
</div>
<h2 id="cpanel-deploy-scripts">cPanel deploy scripts</h2>
<p>
The cPanel deployment scripts are maintained under the cPanel web root in the
following locations:
</p>
<table>
<thead>
<tr><th>Path</th><th>Purpose</th></tr>
</thead>
<tbody>
<tr>
<td><code>/public_html/cicd/bashscripts/deploy.php</code></td>
<td>Receives the Bitbucket webhook request and triggers the deployment shell script.</td>
</tr>
<tr>
<td><code>/public_html/cicd/bashscripts/deploy.sh</code></td>
<td>Executes the deployment steps and sends the final Cliq notification from the same script.</td>
</tr>
</tbody>
</table>
<p>
Operationally, the dev environment follows this chain:
</p>
<ol class="steps">
<li>
<strong>Push code to Bitbucket</strong>
<p>The push event becomes the trigger for the deployment automation.</p>
</li>
<li>
<strong>Bitbucket sends the webhook request</strong>
<p>The webhook hits the custom PHP receiver hosted in cPanel.</p>
</li>
<li>
<strong><code>deploy.php</code> validates and forwards the action</strong>
<p>This script acts as the receiver and handoff point into the shell deployment layer.</p>
</li>
<li>
<strong><code>deploy.sh</code> performs deployment</strong>
<p>The shell script runs the actual deployment commands for the dev environment.</p>
</li>
<li>
<strong>Cliq notification is sent</strong>
<p>The same shell script sends a channel update through the Cliq message API after deployment completes.</p>
</li>
</ol>
<h2 id="branch-promotion-flow">Branch promotion flow</h2>
<p>
The merge script performs a sequential promotion chain:
</p>
<div class="mermaid-wrapper">
<div class="mermaid">
flowchart LR
A[dev] --> B[test]
B --> C[uat]
C --> D[live]
</div>
</div>
<p>
The script first refreshes <code>dev</code>, then merges:
</p>
<table>
<thead>
<tr><th>Step</th><th>Action</th></tr>
</thead>
<tbody>
<tr><td>1</td><td><code>git checkout dev</code> and <code>git pull origin dev</code></td></tr>
<tr><td>2</td><td>Merge <code>dev</code> into <code>test</code></td></tr>
<tr><td>3</td><td>Merge <code>test</code> into <code>uat</code></td></tr>
<tr><td>4</td><td>Merge <code>uat</code> into <code>live</code></td></tr>
</tbody>
</table>
<div class="callout warning">
<span>!</span>
<div>
<strong>Important deployment gate</strong>
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.
</div>
</div>
<h2 id="auto-merge-script">Auto merge script</h2>
<p>
The deployment servers keep a Python helper called
<code>auto_merge.py</code>. Below is the current script for documentation and
future reference.
</p>
<pre><code class="language-python">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 &lt;REPO_PATH&gt; '&lt;COMMIT_MESSAGE_SUFFIX&gt;'")
print("Usage Example: python auto_merge.py './nhance' '&lt;COMMIT_MESSAGE_SUFFIX&gt;'")
print("Usage Example: python auto_merge.py './nhance-enrollment' '&lt;COMMIT_MESSAGE_SUFFIX&gt;'")
sys.exit(1)
repo_path = sys.argv[1].strip()
commit_suffix = sys.argv[2].strip().upper()
auto_merge(repo_path, commit_suffix)
</code></pre>
<h2 id="script-usage">Script usage</h2>
<p>
The script is typically run from the server-side <code>repo_merge</code>
directory. Use a dummy home path like the example below and replace it with
the real deployment user home directory.
</p>
<table>
<thead>
<tr><th>Dummy path</th><th>Description</th></tr>
</thead>
<tbody>
<tr>
<td><code>/home/deploy-user/repo_merge</code></td>
<td>Example folder where <code>auto_merge.py</code> is stored on UAT or live.</td>
</tr>
<tr>
<td><code>/home/deploy-user/nhance</code></td>
<td>Example repository checkout path passed as the first script argument.</td>
</tr>
<tr>
<td><code>RELEASE_2026_05_12</code></td>
<td>Example commit message suffix appended into merge commit messages.</td>
</tr>
</tbody>
</table>
<pre><code class="language-bash">cd /home/deploy-user/repo_merge
python3 auto_merge.py "/home/deploy-user/nhance" "RELEASE_2026_05_12"</code></pre>
<p>
Usage format:
</p>
<pre><code class="language-bash">python3 auto_merge.py "&lt;REPO_PATH&gt;" "&lt;COMMIT_MESSAGE_SUFFIX&gt;"</code></pre>
<p>
The commit message suffix is converted to uppercase by the script. For each
merge step, the script generates messages like:
</p>
<pre><code class="language-text">MERGE_TEST_RELEASE_2026_05_12
MERGE_UAT_RELEASE_2026_05_12
MERGE_LIVE_RELEASE_2026_05_12</code></pre>
<h2 id="log-output">Log output</h2>
<p>
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.
</p>
<table>
<thead>
<tr><th>Behavior</th><th>Details</th></tr>
</thead>
<tbody>
<tr>
<td>Log filename</td>
<td><code>merge_log_YYYY-MM-DD_HH-MM-SS.txt</code></td>
</tr>
<tr>
<td>Success logging</td>
<td>Writes <code>[SUCCESS]</code> plus command output.</td>
</tr>
<tr>
<td>Error logging</td>
<td>Writes <code>[ERROR]</code>, prints to stderr, and exits immediately.</td>
</tr>
</tbody>
</table>
<h2 id="operational-notes">Operational notes</h2>
<ol class="steps">
<li>
<strong>Run the script from the deployment helper folder</strong>
<p>This keeps the generated merge log files in one predictable location.</p>
</li>
<li>
<strong>Make sure the repo path is correct before starting</strong>
<p>The script exits immediately if the provided repository directory does not exist.</p>
</li>
<li>
<strong>Do not continue deployment on merge failure</strong>
<p>If checkout, pull, merge, or push fails for any branch, stop and resolve the issue first.</p>
</li>
<li>
<strong>Use a meaningful commit suffix</strong>
<p>Choose a release identifier that makes merge history easy to trace later.</p>
</li>
</ol>
<div class="callout success">
<span>+</span>
<div>
<strong>Recommended practice</strong>
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.
</div>
</div>
<h2 id="code-move-scripts">Code move scripts</h2>
<div class="callout danger">
<span>!</span>
<div>
<strong>Strict warning before running these steps</strong>
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.
</div>
</div>
<p>
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.
</p>
<table>
<thead>
<tr><th>Script</th><th>Environment / Purpose</th></tr>
</thead>
<tbody>
<tr>
<td><code>crm_deployment.sh</code></td>
<td>Live CRM code move script.</td>
</tr>
<tr>
<td><code>enrolment_depoloyment.sh</code></td>
<td>Live enrolment code move script.</td>
</tr>
<tr>
<td><code>uat_crm_deployment.sh</code></td>
<td>UAT CRM code move script.</td>
</tr>
<tr>
<td><code>uat_enrolment_depoloyment.sh</code></td>
<td>UAT enrolment code move script.</td>
</tr>
</tbody>
</table>
<p>
The execution rule is simple:
</p>
<ol class="steps">
<li>
<strong>Run <code>auto_merge.py</code> first</strong>
<p>This completes the required branch promotion chain before any server-side code move starts.</p>
</li>
<li>
<strong>Choose the scripts based on target environment</strong>
<p>For UAT, call the <code>uat_*</code> scripts. For live, call the non-UAT deployment scripts.</p>
</li>
<li>
<strong>Execute the relevant application scripts</strong>
<p>Run the CRM and enrolment deployment scripts that match the environment being released.</p>
</li>
</ol>
<pre><code class="language-bash"># 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</code></pre>
<div class="callout warning">
<span>!</span>
<div>
<strong>Server-only scripts</strong>
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.
</div>
</div>
<h2 id="manual-fallback">Manual fallback</h2>
<p>
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.
</p>
<div class="callout warning">
<span>!</span>
<div>
<strong>Fallback path</strong>
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.
</div>
</div>

View File

@ -0,0 +1,80 @@
<?php
/**
* Docs Footer Partial
* app/Views/docs/partials/docs_footer.php
*
* Closes the body/html tags and initialises highlight.js.
* Always the last partial on every docs page.
*
* Variables:
* $app_name (string) defaults to 'Nhance PAM'
*/
$app_name = $app_name ?? 'Nhance PAM';
$year = date('Y');
?>
<script>
document.addEventListener('DOMContentLoaded', function () {
// Syntax highlighting
if (window.hljs) hljs.highlightAll();
// Active TOC link on scroll
const tocLinks = document.querySelectorAll('.docs-toc a');
const headings = document.querySelectorAll('main h2, main h3');
if (tocLinks.length && headings.length) {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
tocLinks.forEach(l => l.classList.remove('is-active-toc'));
const match = document.querySelector(
`.docs-toc a[href="#${entry.target.id}"]`
);
if (match) match.classList.add('is-active-toc');
}
});
},
{ rootMargin: '0px 0px -70% 0px' }
);
headings.forEach(h => { if (h.id) observer.observe(h); });
}
});
</script>
<style>
.docs-toc a.is-active-toc { color: var(--accent); font-weight: 500; }
/* ─── GLOBAL FOOTER BAR ──────────────────── */
.docs-global-footer {
border-top : 1px solid var(--border);
padding : 16px 24px;
font-size : 12px;
color : var(--muted);
display : flex;
justify-content: space-between;
align-items: center;
margin-top : auto;
}
.docs-global-footer a {
color : var(--muted);
text-decoration: none;
}
.docs-global-footer a:hover { color: var(--accent); }
</style>
<footer class="docs-global-footer">
<span>© <?= $year ?> <?= esc($app_name) ?>. Internal developer docs.</span>
<span>
Built with CodeIgniter 4 &nbsp;·&nbsp;
<a href="<?= base_url('docs/changelog') ?>">Changelog</a> &nbsp;·&nbsp;
<a href="<?= base_url('docs/contributing') ?>">Contributing</a>
</span>
</footer>
</body>
</html>

View File

@ -0,0 +1,288 @@
<?php
/**
* Docs Header Partial
* app/Views/docs/partials/docs_header.php
*
* Usage in any docs view:
* <?= view('docs/partials/docs_header', ['doc_title' => 'Installation']) ?>
*
* Variables:
* $doc_title (string) page title shown in <title> 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) ?></title>
<!-- Highlight.js (code syntax) -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
<!-- Google Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500;600&display=swap" rel="stylesheet" />
<style>
/* ─── RESET ──────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
/* ─── TOKENS ─────────────────────────────── */
:root {
--sidebar-w : 260px;
--topbar-h : 56px;
--bg : #ffffff;
--bg2 : #f7f8fa;
--border : #e2e5ea;
--text : #1a1d23;
--muted : #6b7280;
--accent : #2563eb;
--accent-bg : #eff4ff;
--code-bg : #f3f4f6;
--font : 'IBM Plex Sans', system-ui, sans-serif;
--mono : 'IBM Plex Mono', 'Fira Code', monospace;
}
/* ─── BASE ───────────────────────────────── */
body {
font-family : var(--font);
font-size : 15px;
line-height : 1.7;
color : var(--text);
background : var(--bg);
display : flex;
flex-direction: column;
min-height : 100vh;
}
/* ─── TOP BAR ────────────────────────────── */
.docs-topbar {
position : fixed;
top: 0; left: 0; right: 0;
z-index : 200;
height : var(--topbar-h);
background : var(--bg);
border-bottom : 1px solid var(--border);
display : flex;
align-items : center;
justify-content: space-between;
padding : 0 24px;
}
.docs-topbar__logo {
display : flex;
align-items : center;
gap : 10px;
text-decoration: none;
color : var(--text);
font-weight : 600;
font-size : 15px;
letter-spacing : -0.01em;
}
.docs-topbar__badge {
background : var(--accent);
color : #fff;
font-size : 10px;
font-weight : 600;
padding : 2px 8px;
border-radius : 4px;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.docs-topbar__right {
display : flex;
align-items: center;
gap : 16px;
}
.docs-topbar__version {
font-family : var(--mono);
font-size : 12px;
color : var(--muted);
background : var(--bg2);
border : 1px solid var(--border);
border-radius: 20px;
padding : 2px 10px;
}
.docs-topbar__search {
display : flex;
align-items : center;
gap : 8px;
border : 1px solid var(--border);
border-radius: 8px;
padding : 5px 12px;
font-size : 13px;
color : var(--muted);
background : var(--bg2);
cursor : text;
min-width : 200px;
}
.docs-topbar__search kbd {
font-family : var(--mono);
font-size : 11px;
background : var(--bg);
border : 1px solid var(--border);
border-radius: 4px;
padding : 1px 5px;
margin-left : auto;
}
/* ─── LAYOUT WRAPPER ─────────────────────── */
.docs-layout {
display : flex;
margin-top: var(--topbar-h);
min-height: calc(100vh - var(--topbar-h));
}
/* ─── TYPOGRAPHY (shared across pages) ───── */
h1 { font-size: 28px; font-weight: 600; letter-spacing: -0.02em; line-height: 1.3; margin-bottom: 8px; }
h2 { font-size: 20px; font-weight: 600; margin: 40px 0 12px; letter-spacing: -0.015em; }
h3 { font-size: 16px; font-weight: 600; margin: 28px 0 8px; }
p { margin-bottom: 16px; color: #2d3340; }
a { color: var(--accent); }
/* ─── CODE ───────────────────────────────── */
pre {
background : #1e1e2e;
border-radius: 8px;
padding : 18px 20px;
overflow-x : auto;
margin : 16px 0;
}
pre code {
font-family: var(--mono);
font-size : 13px;
line-height: 1.65;
color : #cdd6f4;
background : none;
}
code {
font-family : var(--mono);
font-size : 13px;
background : var(--code-bg);
color : #c7254e;
padding : 2px 5px;
border-radius: 4px;
}
.code-header {
background : #13131e;
border-radius: 8px 8px 0 0;
padding : 8px 16px;
display : flex;
align-items : center;
justify-content: space-between;
margin-bottom: -8px;
}
.code-filename { font-family: var(--mono); font-size: 12px; color: #a6adc8; }
.code-lang { font-size: 11px; color: #585b70; text-transform: uppercase; letter-spacing: 0.06em; }
/* ─── CALLOUTS ───────────────────────────── */
.callout {
border-radius: 8px;
padding : 14px 16px;
margin : 20px 0;
font-size : 14px;
display : flex;
gap : 10px;
align-items : flex-start;
border-left : 3px solid;
}
.callout strong { display: block; margin-bottom: 2px; font-weight: 600; }
.callout.info { background: #eff4ff; border-color: #2563eb; color: #1e40af; }
.callout.warning { background: #fffbeb; border-color: #f59e0b; color: #b45309; }
.callout.danger { background: #fef2f2; border-color: #f87171; color: #b91c1c; }
.callout.success { background: #f0fdf4; border-color: #4ade80; color: #15803d; }
/* ─── TABLES ─────────────────────────────── */
table { width: 100%; border-collapse: collapse; margin: 20px 0; font-size: 13.5px; }
th {
background : var(--bg2);
font-weight : 600;
font-size : 12px;
text-transform: uppercase;
letter-spacing: 0.05em;
color : var(--muted);
padding : 10px 14px;
border : 1px solid var(--border);
text-align : left;
}
td { padding: 10px 14px; border: 1px solid var(--border); vertical-align: top; }
tr:hover td { background: var(--bg2); }
/* ─── BADGES ─────────────────────────────── */
.badge {
display : inline-block;
font-size : 11px;
font-weight : 500;
padding : 2px 8px;
border-radius: 4px;
font-family : var(--mono);
}
.badge.get { background: #dbeafe; color: #1d4ed8; }
.badge.post { background: #dcfce7; color: #15803d; }
.badge.put { background: #fef9c3; color: #92400e; }
.badge.delete { background: #fee2e2; color: #991b1b; }
.badge.req { background: #fee2e2; color: #991b1b; font-size: 10px; text-transform: uppercase; letter-spacing: 0.05em; }
.badge.opt { background: var(--bg2); color: var(--muted); font-size: 10px; text-transform: uppercase; letter-spacing: 0.05em; }
/* ─── PARAM NAME ─────────────────────────── */
.param-name { font-family: var(--mono); font-size: 12.5px; color: var(--accent); }
/* ─── STEP LIST ──────────────────────────── */
.steps { list-style: none; counter-reset: steps; margin: 20px 0; }
.steps li { counter-increment: steps; position: relative; padding: 0 0 28px 44px; }
.steps li::before {
content : counter(steps);
position : absolute; left: 0; top: 2px;
width: 28px; height: 28px; border-radius: 50%;
background : var(--accent-bg); color: var(--accent);
font-size : 12px; font-weight: 600;
display : flex; align-items: center; justify-content: center;
}
.steps li::after {
content : '';
position: absolute; left: 13px; top: 32px; bottom: 0;
width : 1px; background: var(--border);
}
.steps li:last-child::after { display: none; }
.steps li strong { display: block; margin-bottom: 4px; }
</style>
</head>
<body>
<!-- ═══════════════════════════════════════════
TOP BAR
════════════════════════════════════════════ -->
<header class="docs-topbar">
<a href="<?= $base ?>docs" class="docs-topbar__logo">
<span class="docs-topbar__badge">CI4</span>
<?= esc($app_name) ?> Doc, <?= esc($app_version) ?>
</a>
<div class="docs-topbar__right">
<div class="docs-topbar__search">
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
Search docs
<kbd>⌘K</kbd>
</div>
</div>
</header>
<!-- ═══════════════════════════════════════════
LAYOUT WRAPPER (sidebar + main go inside)
════════════════════════════════════════════ -->
<div class="docs-layout">

View File

@ -0,0 +1,109 @@
<?php
/**
* Docs Main Close Partial
* app/Views/docs/partials/docs_main_close.php
*
* Closes the main content area, renders prev/next nav,
* and renders the right-side TOC. Always pair with docs_main_open.php.
*
* Variables:
* $prev_label (string) label for previous page link
* $prev_url (string) URL segment e.g. 'docs/introduction'
* $next_label (string) label for next page link
* $next_url (string) URL segment
* $toc (array) same array passed to docs_main_open.php
* ['label', 'href', 'level'(optional h3)]
*/
$prev_label = $prev_label ?? '';
$prev_url = $prev_url ?? '';
$next_label = $next_label ?? '';
$next_url = $next_url ?? '';
$toc = $toc ?? [];
?>
<!-- ── PAGE CONTENT ENDS ABOVE THIS LINE ── -->
<!-- Prev / Next navigation -->
<div class="docs-page-nav">
<div>
<?php if ($prev_label && $prev_url): ?>
<a href="<?= base_url($prev_url) ?>" class="docs-page-nav__link">
<span class="docs-page-nav__dir"> Previous</span>
<span class="docs-page-nav__title"><?= esc($prev_label) ?></span>
</a>
<?php endif; ?>
</div>
<div>
<?php if ($next_label && $next_url): ?>
<a href="<?= base_url($next_url) ?>" class="docs-page-nav__link docs-page-nav__link--right">
<span class="docs-page-nav__dir">Next </span>
<span class="docs-page-nav__title"><?= esc($next_label) ?></span>
</a>
<?php endif; ?>
</div>
</div>
</main><!-- /.docs-main -->
<!-- ═══════════════════════════════════════════
RIGHT TOC
════════════════════════════════════════════ -->
<?php if (!empty($toc)): ?>
<nav class="docs-toc" aria-label="On this page">
<div class="docs-toc__title">On this page</div>
<?php foreach ($toc as $item): ?>
<a
href="<?= esc($item['href']) ?>"
class="<?= (!empty($item['level']) && $item['level'] === 'h3') ? 'is-sub' : '' ?>"
>
<?= esc($item['label']) ?>
</a>
<?php endforeach; ?>
</nav>
<?php endif; ?>
</div><!-- /.docs-layout -->
<style>
/* ─── PREV / NEXT NAV ────────────────────── */
.docs-page-nav {
display : flex;
justify-content: space-between;
gap : 16px;
margin-top : 56px;
padding-top : 24px;
border-top : 1px solid var(--border);
}
.docs-page-nav__link {
display : flex;
flex-direction : column;
gap : 4px;
text-decoration: none;
color : var(--text);
padding : 12px 16px;
border : 1px solid var(--border);
border-radius : 8px;
transition : border-color 0.15s, background 0.15s;
min-width : 160px;
}
.docs-page-nav__link:hover {
border-color: var(--accent);
background : var(--accent-bg);
}
.docs-page-nav__link--right { text-align: right; }
.docs-page-nav__dir {
font-size : 12px;
color : var(--muted);
}
.docs-page-nav__title {
font-size : 14px;
font-weight: 500;
color : var(--accent);
}
</style>

View File

@ -0,0 +1,125 @@
<?php
/**
* Docs Main Open Partial
* app/Views/docs/partials/docs_main_open.php
*
* Opens the main content + right TOC wrapper.
* Always pair with docs_main_close.php.
*
* Variables:
* $doc_title (string) page heading (h1)
* $breadcrumb (string) section label, e.g. 'Getting Started'
* $last_updated (string) e.g. 'May 2025'
* $author (string) e.g. 'Core Team'
* $read_time (string) e.g. '5 min read'
* $toc (array) array of ['label', 'href', 'level'(optional)]
* level: 'h3' indents it as a sub-item
*/
$doc_title = $doc_title ?? 'Page Title';
$breadcrumb = $breadcrumb ?? '';
$last_updated = $last_updated ?? '';
$author = $author ?? '';
$read_time = $read_time ?? '';
$toc = $toc ?? [];
?>
<style>
/* ─── MAIN CONTENT ───────────────────────── */
.docs-main {
flex : 1;
max-width: 760px;
padding : 48px 56px 80px;
min-width: 0;
}
.docs-main__breadcrumb {
font-size : 12px;
color : var(--muted);
margin-bottom: 12px;
display : flex;
align-items: center;
gap : 6px;
}
.docs-main__breadcrumb a {
color : var(--muted);
text-decoration: none;
}
.docs-main__breadcrumb a:hover { color: var(--accent); }
.docs-main__meta {
font-size : 13px;
color : var(--muted);
margin-bottom: 32px;
border-bottom: 1px solid var(--border);
padding-bottom: 24px;
display : flex;
flex-wrap : wrap;
gap : 16px;
}
/* ─── RIGHT TOC ──────────────────────────── */
.docs-toc {
width : 200px;
min-width: 200px;
padding : 56px 20px 0;
position : sticky;
top : calc(var(--topbar-h) + 32px);
height : fit-content;
align-self: flex-start;
flex-shrink: 0;
}
.docs-toc__title {
font-size : 11px;
font-weight : 600;
text-transform: uppercase;
letter-spacing: 0.07em;
color : var(--muted);
margin-bottom : 10px;
}
.docs-toc a {
display : block;
font-size : 12.5px;
color : var(--muted);
text-decoration: none;
padding : 3px 0;
transition : color 0.1s;
}
.docs-toc a:hover { color: var(--accent); }
.docs-toc a.is-sub { padding-left: 10px; font-size: 12px; }
</style>
<!-- ═══════════════════════════════════════════
CONTENT AREA + RIGHT TOC
════════════════════════════════════════════ -->
<main class="docs-main">
<!-- Breadcrumb -->
<?php if ($breadcrumb): ?>
<div class="docs-main__breadcrumb">
<a href="<?= base_url('docs') ?>">Docs</a>
<span></span>
<span><?= esc($breadcrumb) ?></span>
<?php if ($doc_title !== $breadcrumb): ?>
<span></span>
<span><?= esc($doc_title) ?></span>
<?php endif; ?>
</div>
<?php endif; ?>
<!-- Page title -->
<h1><?= esc($doc_title) ?></h1>
<!-- Meta row -->
<div class="docs-main__meta">
<?php if ($last_updated): ?><span>📅 Last updated: <?= esc($last_updated) ?></span><?php endif; ?>
<?php if ($author): ?><span>✍️ <?= esc($author) ?></span><?php endif; ?>
<?php if ($read_time): ?><span>⏱ <?= esc($read_time) ?></span><?php endif; ?>
</div>
<!-- ── PAGE CONTENT GOES BELOW THIS LINE ── -->

View File

@ -0,0 +1,175 @@
<?php
/**
* Docs Sidebar Partial
* app/Views/docs/partials/docs_sidebar.php
*
* Usage:
* <?= view('docs/partials/docs_sidebar', ['active_page' => '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'],
],
],
];
?>
<!-- ═══════════════════════════════════════════
SIDEBAR
════════════════════════════════════════════ -->
<aside class="docs-sidebar">
<?php foreach ($nav as $section): ?>
<span class="docs-sidebar__label">
<?= esc($section['label']) ?>
</span>
<?php foreach ($section['items'] as $item):
$is_active = ($item['id'] === $active_page);
?>
<a
href="<?= base_url($item['url']) ?>"
class="docs-sidebar__link <?= $is_active ? 'is-active' : '' ?>"
>
<span class="docs-sidebar__dot"></span>
<?= esc($item['label']) ?>
</a>
<?php endforeach; ?>
<?php endforeach; ?>
</aside>
<style>
/* ─── SIDEBAR ────────────────────────────── */
.docs-sidebar {
width : var(--sidebar-w);
min-width : var(--sidebar-w);
border-right: 1px solid var(--border);
padding : 24px 0 48px;
position : sticky;
top : var(--topbar-h);
height : calc(100vh - var(--topbar-h));
overflow-y : auto;
background : var(--bg);
flex-shrink: 0;
}
/* Thin scrollbar */
.docs-sidebar::-webkit-scrollbar { width: 4px; }
.docs-sidebar::-webkit-scrollbar-track { background: transparent; }
.docs-sidebar::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
.docs-sidebar__label {
display : block;
font-size : 11px;
font-weight : 600;
letter-spacing: 0.07em;
text-transform: uppercase;
color : var(--muted);
padding : 0 20px 6px;
margin-top : 20px;
}
/* Remove top margin from first label */
.docs-sidebar__label:first-child { margin-top: 0; }
.docs-sidebar__link {
display : flex;
align-items : center;
gap : 8px;
padding : 6px 20px;
font-size : 13.5px;
color : var(--text);
text-decoration: none;
border-left : 2px solid transparent;
transition : background 0.1s, color 0.1s;
}
.docs-sidebar__link:hover {
background: var(--bg2);
color : var(--accent);
}
.docs-sidebar__link.is-active {
background : var(--accent-bg);
color : var(--accent);
font-weight : 500;
border-left-color: var(--accent);
}
.docs-sidebar__dot {
width : 5px;
height : 5px;
border-radius: 50%;
background : var(--border);
flex-shrink : 0;
}
.docs-sidebar__link.is-active .docs-sidebar__dot {
background: var(--accent);
}
</style>

View File

@ -0,0 +1,922 @@
<?php
/**
* EB rack rate calculation content only
* app/Views/docs/eb-rack-rate-calculation.php
*/
?>
<h2 id="scope">Scope and entry point</h2>
<p>
This page documents how <strong>configured rack rates</strong> (policy premium slabs and grid metadata loaded from the DB)
are <strong>matched to each family</strong> and how <strong>premium, pro-rata, and GST</strong> are written onto each member row
during <strong>Excel-driven employee onboarding</strong>.
</p>
<div class="callout info">
<span>i</span>
<div>
<strong>In scope here:</strong> the branch of
<code>EmployeeServiceController::employeesOnboardPreprocess()</code> that runs when <code>$params['file_id']</code> is set
(physical workbook under <code>WRITEPATH/uploads/excel/</code>), and Excel actions
<strong>inception</strong>, <strong>missed_inception</strong>, <strong>addition</strong>, and <strong>dependent_addition</strong> only.
The enrollment-to-inception branch (<code>client_policy_id</code> without a file) is out of scope.
UI configuration of racks remains on
<a href="<?= base_url('docs/eb-rack-rate-config') ?>">EB rack rate config</a>.
</div>
</div>
<p>
Primary symbols:
<code>app/Controllers/EmployeeServiceController.php</code> <code>employeesOnboardPreprocess()</code>;
<code>app/Helpers/excel_util_helper.php</code> <code>calculate_premium_new()</code> and its callees;
<code>app/Helpers/excel_util_helper.php</code> <code>premium_calculation_manager()</code> for grid-type-specific slab row matching.
</p>
<h2 id="inputs">Data loaded before premium</h2>
<table>
<thead>
<tr><th>Source</th><th>What it is</th><th>Used for</th></tr>
</thead>
<tbody>
<tr>
<td><code>clientPolicyModel->getPolicyDetails()</code></td>
<td>Policy terms (start/end dates, insurer id, GST default, flags such as <code>is_addon</code>).</td>
<td>Pro-rata denominators, optional +1 day on coverage for additions, post-match premium_type rules.</td>
</tr>
<tr>
<td><code>policiesModel->getPolicySlabRatesForEmpOnboard()</code></td>
<td>Array shaped as <code>['slab_rates' => [...rows...], 'grid_master' => ..., 'additional_slab_info' => ...]</code> from <code>PolicyPremium1Model</code> / <code>PolicyPremium2Model</code> plus joined <code>grid_master</code> per row.</td>
<td>Every premium grid row (<code>rack_rate_name</code>, SI, age, grade, unit, <code>premium_type</code>, <code>additional_relationship</code> JSON, etc.).</td>
</tr>
<tr>
<td><code>clientBranchModel->getExisitingUnits()</code></td>
<td>List of valid unit names for the branch.</td>
<td>Default unit when Excel unit column is empty; unit matching inside slab loops.</td>
</tr>
<tr>
<td>Excel sheet</td>
<td>Rows parsed to a numeric-indexed array per member (see below).</td>
<td>Family composition, SI, DOB, dates, band, unit.</td>
</tr>
</tbody>
</table>
<h2 id="excel-columns">Excel row shape (numeric columns)</h2>
<p>
After <code>rangeToArray</code>, each family member row is a 0-based array. The premium path relies heavily on these indices
inside helpers (e.g. <code>transform_excel_data_to_db</code>, <code>get_applicable_familiy_members</code>).
</p>
<table>
<thead>
<tr><th>Index</th><th>Typical meaning</th></tr>
</thead>
<tbody>
<tr><td><code>1</code></td><td>Employee code (family key).</td></tr>
<tr><td><code>2</code></td><td>Name.</td></tr>
<tr><td><code>3</code></td><td>DOB (age and age-band grids).</td></tr>
<tr><td><code>5</code></td><td>Relationship (Self, Spouse, ) slugified for composition and applicability.</td></tr>
<tr><td><code>6</code></td><td>Basic cover SI.</td></tr>
<tr><td><code>7</code></td><td>Date of coverage.</td></tr>
<tr><td><code>9</code></td><td>Basic pay (GPA grid 1 basic-pay path).</td></tr>
<tr><td><code>10</code></td><td>Band / grade.</td></tr>
<tr><td><code>18</code></td><td>Unit name.</td></tr>
</tbody>
</table>
<p>
The <strong>first row in each grouped family array is treated as the self anchor</strong> inside <code>calculate_premium_new</code>:
SI (<code>[6]</code>), band (<code>[10]</code>), and a few other fields are copied from <code>$family_data[0]</code> onto every member before transform.
</p>
<p>
A family may match <strong>more than one</strong> named rack. The code walks racks in the order returned by
<code>group_slab_rates_basedon_name</code>; each applicable rack overwrites <code>$family_data[i]['temp']</code> for the same indexes,
so the <strong>last matching rack in iteration order</strong> wins for <code>grid_name</code>, <code>grid_master</code>, and
<code>premium_type</code> on that row before per-member pricing runs.
</p>
<h2 id="flow-preprocess">Flow: <code>employeesOnboardPreprocess</code> (Excel path)</h2>
<p>
High-level orchestration: validate file, resolve column set from <code>$file['action']</code>, load policy + slabs + units,
group rows by employee code, optionally merge DB family for dependent addition, call <code>calculate_premium_new</code> per family,
then <code>employeesOnboardProcess</code> to persist. File status becomes <code>success</code> only if at least one family inserted;
otherwise a generic rack-configuration failure is recorded.
</p>
<div class="mermaid-wrapper" id="flowchart-employees-onboard-preprocess">
<div class="mermaid">
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"]
</div>
</div>
<h2 id="flow-calculate-premium">Flow: <code>calculate_premium_new</code></h2>
<p>
Two conceptual phases: (1) <strong>rack selection</strong> for each named rack, decide if the family matches the configured
relationship pattern and stamp <code>temp</code> metadata on applicable Excel rows; (2) <strong>per-member pricing</strong>
normalize row, optionally call <code>premium_calculation_manager</code>, collect results. Dependent addition runs an extra
normalisation pass <code>validatet_family_floter_rata_premium</code>.
</p>
<div class="mermaid-wrapper" id="flowchart-calculate-premium-new">
<div class="mermaid">
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])
</div>
</div>
<h2 id="flow-group-slabs"><code>group_slab_rates_basedon_name</code></h2>
<p>
Flattens the list returned from the model into a map keyed by <code>rack_rate_name</code>. Each bucket keeps
<code>['slab_rates' => [...], 'grid_master' => ...]</code> where <code>grid_master</code> comes from the rows policy grid record
(<code>ui_type</code> becomes the numeric grid id used later in <code>premium_calculation_manager</code>).
</p>
<div class="mermaid-wrapper" id="flowchart-group-slab-rates">
<div class="mermaid">
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"]
</div>
</div>
<div class="callout info">
<span>i</span>
<div>
When maintaining this helper, inspect the implementation for <strong>duplicate pushes</strong> on the first row of a new rack name;
downstream code tolerates duplicate slab rows but it can confuse debugging of premium matches.
</div>
</div>
<h2 id="flow-family-composition"><code>get_familiy_composition</code></h2>
<p>
Builds a compact associative array of <strong>counts / presence flags</strong> from the <strong>incoming workbook family only</strong>
(already grouped to one employee). Keys align with the JSON used in rack configuration (<code>additional_relationship</code>), except
<code>either-parents-pil</code> and <code>elders_count</code> which are stripped before comparison.
</p>
<div class="mermaid-wrapper" id="flowchart-family-composition">
<div class="mermaid">
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"]
</div>
</div>
<h2 id="flow-compare-slab"><code>compare_incoming_family_slab_with_configured_slab</code></h2>
<p>
The first row of each racks <code>slab_rates</code> carries <code>additional_relationship</code> (JSON). After removing
<code>either-parents-pil</code> and <code>elders_count</code>, each remaining key is evaluated in sequence:
</p>
<ul>
<li>If the configured value is <code>'NA'</code>, that dimension is ignored (does not participate in match or applicable-member list).</li>
<li>Otherwise the incoming composition <strong>must</strong> contain the same key. If <code>incoming[key] == configured</code> <em>or</em> configured is <code>'any'</code>, the key contributes applicable relationship tokens (via an internal map: self, spouse, son/daughter, parents, in-laws).</li>
<li>On the first failed key, the rack is rejected (<code>is_applicable</code> false, applicable members cleared) and the loop stops.</li>
<li>If the decoded JSON is empty, the rack is not applicable.</li>
</ul>
<div class="mermaid-wrapper" id="flowchart-compare-slab">
<div class="mermaid">
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"]
</div>
</div>
<h2 id="flow-applicable-members"><code>get_applicable_familiy_members</code> and <code>acting_self</code></h2>
<p>
Given the list of relationship strings that matched the rack (e.g. <code>self</code>, <code>spouse</code>, <code>son</code>), this helper returns:
</p>
<ul>
<li><code>index</code>: Excel row indexes whose slugified relationship is in that list.</li>
<li><code>max_age</code>: list of ages (years from DOB column <code>[3]</code> to “today” in <code>calculate_days_bw_dates</code>).</li>
<li><code>max_count</code>: count of those indexes (used by grids 10 and 11).</li>
</ul>
<p>
<code>calculate_premium_new</code> then walks <code>index</code> in order and sets <code>acting_self</code>: the <strong>first</strong> applicable member
receives <code>acting_self = true</code>; subsequent applicable members get <code>false</code>. Combined with relationship checks later in
<code>premium_calculation_manager</code>, this distinguishes who carries floater-style premium when <code>premium_type == 1</code>.
</p>
<div class="mermaid-wrapper" id="flowchart-applicable-members">
<div class="mermaid">
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"]
</div>
</div>
<h2 id="flow-per-member">Per-member transform and premium gate</h2>
<p>
For each raw Excel row, <code>transform_excel_data_to_db</code> builds the associative structure expected by persistence and by
<code>premium_calculation_manager</code>, including <code>temp.grid_type</code> (rack name), <code>temp.grid_id</code> (grid master
<code>ui_type</code>), <code>temp.action</code> (single-letter code I, A, DA, MI, ), and nested <code>policy_details</code>.
</p>
<p>
Premium is only calculated when any of the following holds (Excel onboarding path simplifies to the first two in practice):
</p>
<ul>
<li><code>isEmployeeSourceEnrollment</code>: <code>fileArr['id'] == null</code> (not used in the scoped Excel path).</li>
<li><code>isEmployeeSourceExcelFile</code>: <code>temp.source == 'excel'</code> normal onboarding uploads.</li>
<li><code>primaryGridTypeCondition</code>: dependent addition + primary grid + premium_type single + relationship self + basic SI already set (additional-grid path; omitted from diagrams above for brevity).</li>
</ul>
<div class="mermaid-wrapper" id="flowchart-per-member-gate">
<div class="mermaid">
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"]
</div>
</div>
<h2 id="flow-premium-manager"><code>premium_calculation_manager</code> (grid types 113)</h2>
<p>
Resolves <code>$emp_data['temp']['grid_name']</code> to the rack bucket, takes that racks <code>slab_rates</code> rows, and branches on
<code>temp.grid_id</code> (string <code>"1"</code> <code>"13"</code>). Common outcomes for a match:
</p>
<ul>
<li>Set <code>policy_details.basic_cover_si</code>, <code>date_coverage</code>, <code>policy_end_date</code>, <code>days</code>.</li>
<li>Set annual <code>premium</code>, then <code>rata_premimum</code> via <code>calculate_pro_rata_premimum(premium, employee_days, policy_days)</code>.</li>
<li>Set <code>gst</code> from policy GST percent (default 18).</li>
</ul>
<p>
Special cases worth reading in source: GPA grid <strong>1</strong> also supports <code>si_or_bp == 2</code> auto SI from basic pay when no slab row matches;
grids <strong>10</strong> and <strong>11</strong> consume <code>temp.max_age</code> / <code>temp.max_count</code> from the rack-selection phase;
grids <strong>12</strong> and <strong>13</strong> match slugified relationship (child merges son/daughter).
</p>
<div class="mermaid-wrapper" id="flowchart-premium-calculation-manager">
<div class="mermaid">
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])
</div>
</div>
<h2 id="dependent-addition">Dependent addition extras</h2>
<p>
Before <code>calculate_premium_new</code>, 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 <strong>Self stays first</strong>. It copies the self members
<code>temp.rata_premimum</code> onto each non-self row as <code>self_rata_premium</code> for downstream floater math.
</p>
<p>
After pricing, <code>validatet_family_floter_rata_premium</code> adjusts dependents when <code>premium_type == 1</code> so that only the
intended Excel dependents retain non-zero rata (see implementation for the two-pass rules and the <code>data_from == excel</code> filter).
</p>
<h2 id="failure-modes">Failure: zero successful families</h2>
<p>
If every family iteration yields no successful insert from <code>employeesOnboardProcess</code>, 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 <code>premium_calculation_manager</code> returning
unmatched state for all members.
</p>
<h2 id="related">Related</h2>
<ul>
<li><a href="<?= base_url('docs/eb-rack-rate-config') ?>">EB rack rate config</a> UI fields, <code>premium_type</code> semantics, grid catalogue.</li>
<li><code>app/Controllers/EmployeeServiceController.php</code> <code>employeesOnboardPreprocess</code>, <code>employeesOnboardProcess</code>.</li>
<li><code>app/Helpers/excel_util_helper.php</code> <code>calculate_premium_new</code>, <code>premium_calculation_manager</code>, composition/compare helpers.</li>
<li><code>app/Models/PolicesModel.php</code> <code>getPolicySlabRatesForEmpOnboard()</code>.</li>
</ul>

View File

@ -0,0 +1,463 @@
<?php
/**
* EB rack rate config content only
* app/Views/docs/eb-rack-rate-config.php
*/
?>
<h2 id="purpose">Purpose</h2>
<p>
<strong>EB rack rate config</strong> is how Nhance authors <strong>premium rack rates</strong> for a client policy:
rate tables (SI, age, grade, relationship, etc.) plus two <strong>policy-wide behaviours</strong> that apply to
<strong>every</strong> grid type (113): <a href="#premium-calculation-modes">Premium calculation</a> and
<a href="#applicable-family-members">Applicable family members</a>. GMC policies may also define multiple
named rack rates (Primary + additional tabs). Technical save/load paths reference
<code>ClientController</code>, <code>policy_grid.php</code>, and <code>policy_grid_excel.php</code>.
For the <strong>Excel employee onboarding</strong> pipeline that consumes this configuration and runs
<code>calculate_premium_new</code>, see
<a href="<?= base_url('docs/eb-rack-rate-calculation') ?>">EB rack rate calculation</a>.
</p>
<h2 id="premium-calculation-modes">Premium calculation (all grid types)</h2>
<p>
The modal exposes <strong>Premium calculation</strong> as three radios: <strong>Individual</strong>,
<strong>Family floater</strong>, and <strong>Family floater cum Individual</strong>. This choice is stored
with the rack (e.g. <code>premium_type</code> on premium rows where used) and interpreted <strong>when
premiums are calculated in downstream flows</strong> (enrollment, endorsements, etc.) not re-derived from
the grid layout alone.
</p>
<table>
<thead>
<tr><th>Mode</th><th>Meaning (at calculation time)</th></tr>
</thead>
<tbody>
<tr>
<td><strong>Individual</strong></td>
<td>
Sum insured is covered <strong>per individual</strong> family member, and <strong>premium is calculated
(and applied) per member</strong> according to the rack rules and member attributes.
</td>
</tr>
<tr>
<td><strong>Family floater</strong></td>
<td>
Sum insured applies to the <strong>whole family</strong> as one floater cover. Premium is calculated for
the family unit, but the amount is <strong>stored / represented against the self member only</strong>
(single premium bucket for the floater).
</td>
</tr>
<tr>
<td><strong>Family floater cum Individual</strong></td>
<td>
<strong>Combines both</strong> behaviours where the product rules require floater cover together with
individually rated members (exact split depends on policy / insurer rules in the calculation engine).
</td>
</tr>
</tbody>
</table>
<div class="callout info">
<span>i</span>
<div>
For developers: the rack modal captures the <strong>mode</strong> and the <strong>rate table</strong>; always
trace how <code>premium_type</code> (and policy terms) are read in the <strong>premium calculation</strong> path
you are debugging not only in <code>createClientPolicyPremium</code>.
</div>
</div>
<h2 id="applicable-family-members">Applicable family members (all grid types)</h2>
<p>
The section <strong>“Choose applicable family members”</strong> is driven by what the <strong>policy
terms</strong> allow for that client policy (who can exist on the cover). The user then selects which of
those relationships are <strong>in scope for this specific rack rate</strong> (Self / Spouse / Children /
Parents / Parents in law) using the radio options below.
</p>
<h3 id="applicable-members-radio-meanings">Radio option meanings (per relationship row)</h3>
<p>
Each relationship (Self, Spouse, Children, Parents, Parents in law) uses the same vocabulary of choices.
These define <strong>eligibility rules for this rack rate</strong>, not the member list itself.
</p>
<table>
<thead>
<tr><th>Choice</th><th>Meaning for this rack rate</th></tr>
</thead>
<tbody>
<tr>
<td><strong>Yes</strong></td>
<td>That relationship <strong>must</strong> be present in the incoming family for this rack rate to apply.</td>
</tr>
<tr>
<td><strong>No</strong></td>
<td>That relationship <strong>must not</strong> be present if it is, this rack rate does not match.</td>
</tr>
<tr>
<td><strong>Maybe</strong></td>
<td>If that relationship is <strong>available</strong> on the family, it is <strong>included in</strong> this rack rates match; if not present, the rack can still match without it.</td>
</tr>
<tr>
<td><strong>NA</strong></td>
<td>Even if members of that relationship exist on the policy, they are <strong>not applicable</strong> to this rack rate (this rack never prices or targets them).</td>
</tr>
<tr>
<td><strong>Any</strong></td>
<td><strong>Any number</strong> of members with that same relationship is allowed for a match (no fixed count).</td>
</tr>
<tr>
<td><strong>1, 2, 3, 4, </strong> (numeric)</td>
<td>An <strong>exact count</strong> of members with that relationship must be present for this rack rate to match (e.g. exactly two parents).</td>
</tr>
</tbody>
</table>
<h3 id="applicable-members-matching">How rack rates are picked at premium time</h3>
<p>
At calculation time (enrollment / endorsement / etc.), the engine compares the <strong>incoming familys
members and relationships</strong> (who is on the cover and in what roles) against <strong>each configured rack
rates applicable members</strong> (the rules saved in <code>additional_relationship</code> for that tabs rack).
Racks whose rules <strong>match</strong> the family pattern are candidates for premium.
</p>
<p>
<strong>More than one rack rate can match</strong> the same family (e.g. a base employee rack and a separate
parents top-up). In those cases <strong>multiple rack rates may apply</strong> and <strong>premium is calculated
accordingly</strong> (combined according to product rules in the calculation path not in the modal UI alone).
</p>
<p>
The diagram below is <strong>conceptual</strong>: the exact class or function name lives in your premium /
onboarding pipeline, but the decision order is what new developers should internalize.
</p>
<div class="mermaid-wrapper" id="rack-rate-family-matching-flowchart">
<div class="mermaid">
flowchart TD
Start([Start premium run]) --> Family["Incoming family snapshot:<br/>members, relationship, counts"]
Family --> Load["Load active rack config for policy<br/>policy_premium_1 / 2, is_active = 1<br/>each rack_rate_name + rules + grid rows"]
Load --> Compare["For each rack rate:<br/>compare family vs applicable members<br/>Yes / No / Maybe / NA / Any / count"]
Compare --> Matched["Build matched rack list<br/>0, 1, or many racks"]
Matched --> Calc["Premium engine:<br/>use premium_type + rate table per matched rack"]
Calc --> Multi{"Several racks matched?"}
Multi -->|Yes| Combine["Combine premium<br/>per product rules"]
Multi -->|No| Single["Single-rack premium"]
Combine --> Done([Allocate to members / floater])
Single --> Done
</div>
</div>
<ul>
<li><strong>Why multiple tabs exist:</strong> A single policy may define <strong>several rack rates</strong> (GMC
Primary + “Add Rack Rate” tabs). Each tab encodes a different applicable-member pattern and/or rate table.</li>
<li><strong>Persisted as:</strong> On save, the selections are stored in <code>additional_relationship</code>
JSON on premium rows (keys such as <code>self</code>, <code>spouse</code>, <code>childrens</code>,
<code>parents</code>, <code>parents-in-law</code>). Grid ids <strong>1</strong> and <strong>2</strong> force a
simplified relation map in the controller (self-only path).</li>
<li><strong>Reload in UI:</strong> For GMC, <code>getpolicyGridData</code> returns <code>jsonArray</code> keyed by
<code>rack_rate_name</code> so the modal can restore checkbox state per tab.</li>
</ul>
<h2 id="how-to-configure-rack-rate">How to configure a rack rate (checklist)</h2>
<ol>
<li><strong>Policy terms first.</strong> Ensure <code>client_policy.policy_terms</code> reflects allowed members,
family floater flags, SI ladders, etc. The rack UI inherits what is allowed.</li>
<li><strong>Open the Rack Rate modal</strong> (<code>.btnPolicyModel</code>) for the target <code>client_policy_id</code>.</li>
<li><strong>Set Premium calculation</strong> pick Individual, Family floater, or Family floater cum Individual
per product rules (see <a href="#premium-calculation-modes">Premium calculation</a>).</li>
<li><strong>Set applicable family members</strong> for <em>this</em> rack rate tab narrow from policy-allowed
members to who this table applies to (see <a href="#applicable-family-members">Applicable family members</a>).</li>
<li><strong>Choose Policy premium type</strong> maps to <code>policy_grid_id</code> 113 from
<code>policy_grid_master</code> (see <a href="#grid-types-1-13">Grid types</a>).</li>
<li><strong>Fill the grid</strong> manual rows, + rows, and/or <strong>Copy from excel</strong> using headers
from <code>policy_grid_excel.php</code>.</li>
<li><strong>Save</strong> POST to <code>client/premimum/create</code>; confirm no validation errors (Parsley,
SI vs policy terms, duplicate family composition across tabs where enforced).</li>
<li><strong>Repeat for additional GMC tabs</strong> if the product uses more than one named rack rate.</li>
</ol>
<h2 id="ui-entry">Where it appears in the UI</h2>
<ul>
<li><strong>View files:</strong> <code>app/Views/policy_grid.php</code> (modal shell, tabs, forms) and
<code>app/Views/policy_grid_excel.php</code> (Excel header maps, paste helpers, <code>copyHeaders</code> / <code>generateTable</code>).</li>
<li><strong>Open modal:</strong> A control with class <code>.btnPolicyModel</code> passes <code>data-id</code> (client policy id) and policy type context; the script loads grid definitions and any saved premiums (see AJAX below).</li>
<li><strong>GMC vs GPA in the modal:</strong> For GPA (<code>policy_type_string == 'GPA'</code> 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 (<code>appendNewTab</code>, rename).</li>
<li><strong>Forms:</strong> Each tab uses a form whose id starts with <code>GridForm_</code> (primary <code>GridForm_</code>, additional tabs <code>GridForm_{tabId}</code>). Hidden fields carry <code>client_id</code>, <code>client_policy_id</code>, <code>rack_rate_name</code> (e.g. Primary), and the selected <code>policy_grid_id</code> from the “Policy Premium Type” dropdown.</li>
</ul>
<h2 id="getpolicy-grid-data">Load grid data <code>ClientController::getpolicyGridData</code></h2>
<p><strong>HTTP:</strong> <code>GET</code> <code>util/policy-premium</code> with query <code>client_policy_id</code> (see <code>app/Config/Routes.php</code> under the <code>/util</code> group). The front end calls <code>base_url("util/policy-premium")</code> with that query parameter.</p>
<p><strong>What it does:</strong></p>
<ol>
<li>Loads <code>client_policy</code> joined to <code>policy_type</code> and reads <code>policy_terms</code> JSON (family floater flags, <code>family_floaters</code>, etc.).</li>
<li>Resolves <code>terms_si_amount_array</code> via <code>getPolicyTerms($client_policy_id)</code> and branch units via <code>getBranchUnitsByBranchId</code>.</li>
<li>Counts active employees on the policy for UI gating (<code>hideShowSubmitButton</code>).</li>
<li>Derives a search token from policy type name / id: <strong>GMC</strong> (regex on type name, or type id 72) vs <strong>GPA</strong> (regex or type ids 6 / 7).</li>
<li>Fetches grid <strong>templates</strong> from <code>policy_grid_master</code> via <code>PolicyGridModel::like('policy_type', $search_term)</code> these rows drive which “Policy Premium Type” options exist and what columns each grid id expects.</li>
<li><strong>GPA:</strong> Loads saved rows from <code>policy_premium_1</code> for this client + client_policy where <code>is_active = 1</code>.</li>
<li><strong>GMC:</strong> Loads saved rows from <code>policy_premium_2</code> for the same scope. Also builds <code>jsonArray</code>: grouped by <code>rack_rate_name</code>, each value is <code>json_decode(additional_relationship)</code> (family-composition flags used when re-rendering checkboxes).</li>
<li><strong>GMC family floater filter:</strong> If <code>family_floater == 0</code>, when existing data exists it keeps premiums whose <code>policy_grid_id</code> is in 39; if <code>family_floater == 1</code>, it prefers grid ids 1011 when data exists. Empty data passes through unchanged.</li>
</ol>
<p><strong>Response shape (success):</strong> <code>data</code> (grid master rows), <code>premiumData</code> (JSON string of premium rows for the UI), <code>jsonArray</code>, <code>branch_units</code>, <code>family_floater</code>, <code>self</code>, <code>count</code>, <code>terms_si_amount_array</code>, <code>client_policy_id</code>. GPA and GMC branches return the same keys; non-GPA/GMC types still return grid templates but premium payload may be empty.</p>
<h2 id="create-premium">Save rack rate <code>ClientController::createClientPolicyPremium</code></h2>
<p><strong>HTTP:</strong> <code>POST</code> <code>client/premimum/create</code> (spelling <code>premimum</code> matches routes). Body is multipart form data from the modal (<code>FormData</code> in JS).</p>
<p><strong>What it does:</strong></p>
<ol>
<li>Sanitizes POST via <code>sanitizeInputArrayAdvanced</code>; requires a valid <code>client_policy_id</code> and loads <code>client_policy</code> for <code>client_id</code> / branch.</li>
<li>Builds <code>additional_relationship</code> JSON from checkboxes: <code>self</code>, <code>spouse</code>, <code>childrens</code>, <code>parents</code>, <code>parents-in-law</code>. For grid ids <strong>1</strong> or <strong>2</strong> it forces a fixed relation map (self only).</li>
<li><strong>Deactivate old rows (soft replace):</strong> For <code>policy_grid_id</code> 1 or 2, sets <code>is_active = 0</code> on <strong>all</strong> <code>policy_premium_1</code> rows for that client policy. Otherwise sets <code>is_active = 0</code> on <code>policy_premium_2</code> rows matching the same <code>rack_rate_name</code> (so additional GMC tabs do not wipe other rack rates).</li>
<li><strong>Insert new active rows:</strong> One insert per SI/premium row (arrays in POST). Units default from branch if a slot is empty or <code>undefined</code>.</li>
</ol>
<p><strong>Branching by <code>policy_grid_id</code> (high level):</strong></p>
<ul>
<li><strong>1 (GPA-style primary):</strong> Uses <code>si_or_bp</code>: <code>1</code> = sum insured + premium rows (<code>gpa_sum_si[]</code>, <code>gpa_sum_premium[]</code>, ); <code>2</code> = basic pay ladder; <code>3</code> = band/grade + SI + premium. Writes to <code>policy_premium_1</code>.</li>
<li><strong>2 or 9:</strong> Simple SI + premium columns (<code>gpa_si29[]</code>, <code>gpa_premium29[]</code>). Grid <strong>2</strong> uses <code>policy_premium_1</code>; grid <strong>9</strong> uses <code>policy_premium_2</code>.</li>
<li><strong>38, 1013:</strong> Prefix <code>{id}_</code> on POST keys (e.g. <code>3_premium[]</code>, <code>3_si[]</code>, age from/to, grade, relationship, <code>max_si</code>). All go to <code>policy_premium_2</code>.</li>
</ul>
<p><strong>Success response:</strong> <code>status: true</code>, <code>rack_rate_json</code> a small JSON map used by the UI to prevent duplicate family-floater combinations across tabs (<code>rarc_rate_json_array</code> in <code>policy_grid.php</code>).</p>
<h2 id="frontend-flow">Frontend flow (<code>policy_grid.php</code>)</h2>
<ol>
<li><strong>Open:</strong> Click <code>.btnPolicyModel</code> GET <code>util/policy-premium?client_policy_id=</code>.</li>
<li><strong>Populate dropdown:</strong> <code>appendGridData(res.data, )</code> fills “Policy Premium Type” from grid master rows.</li>
<li><strong>Build inputs:</strong> Changing the dropdown calls <code>addGridHTML</code>, which injects large HTML templates for grid ids 113 (SI/basic/grade layouts, GMC age bands, etc.). Existing <code>premiumData</code> pre-fills values when editing.</li>
<li><strong>Family composition:</strong> <code>createCheckboxes</code> uses <code>res.self</code> / policy terms and, for GMC, saved <code>additional_relationship</code> from <code>jsonArray</code>.</li>
<li><strong>Submit:</strong> Delegated submit handler on <code>form[id^="GridForm_"]</code> Parsley validation, duplicate SI checks against <code>terms_si_amount_array</code> (<code>checkPolicyTermsSI</code>), unit checks, then <code>POST client/premimum/create</code> with <code>FormData</code>. On success, appends <code>res.rack_rate_json</code> for duplicate-tab prevention; for grid 1 or 2 the modal may auto-close.</li>
<li><strong>Excel:</strong> “Copy from excel” toggles a textarea; <code>policy_grid_excel.php</code> defines per-grid header order and parsing to fill the grid.</li>
</ol>
<h2 id="grid-ids-storage">Grid IDs and database tables</h2>
<table>
<thead>
<tr><th>Artifact</th><th>Role</th></tr>
</thead>
<tbody>
<tr>
<td><code>policy_grid_master</code></td>
<td>Catalog of available premium grid layouts filtered by policy type (GPA vs GMC).</td>
</tr>
<tr>
<td><code>policy_premium_1</code></td>
<td>Stores GPA primary grid (id 1), GPA-style grid 2, and other rows where the controller routes to <code>PolicyPremium1Model</code>.</td>
</tr>
<tr>
<td><code>policy_premium_2</code></td>
<td>Stores most GMC grids (3+), grid 9, and additional rack rates distinguished by <code>rack_rate_name</code>.</td>
</tr>
<tr>
<td><code>client_policy.policy_terms</code></td>
<td>JSON: drives family floater behaviour in <code>getpolicyGridData</code> and which checkbox defaults appear.</td>
</tr>
</tbody>
</table>
<h2 id="grid-types-1-13">Grid types (113) <code>policy_grid_master</code></h2>
<p>
Every grid below uses the same two layers documented above:
<a href="#premium-calculation-modes">Premium calculation</a> and
<a href="#applicable-family-members">Applicable family members</a>.
The <strong>only</strong> difference between ids 113 is the <strong>shape of the rate table</strong> (which columns
appear and how POST fields are named). Downstream premium logic must combine <code>premium_type</code>,
<code>additional_relationship</code>, and these rows.
</p>
<h3 id="grid-4-vs-5-ui">UI pattern: grid 4 vs grid 5 (age vs age + SI per row)</h3>
<p>
These two GMC layouts are easy to confuse; the modal layout differs as follows (reviewed UI):
</p>
<ul>
<li><strong>Grid 4 Employees Age band:</strong> One <strong>Sum insured</strong> field applies to the whole table
block; each row is only <strong>From age</strong>, <strong>To age</strong>, and <strong>Premium</strong>. You are
building age bands under a single SI.</li>
<li><strong>Grid 5 Employees Age + SI:</strong> Each row includes <strong>Sum insured</strong>,
<strong>From age</strong>, <strong>To age</strong>, and <strong>Premium</strong>. SI can change per row together
with the age band.</li>
</ul>
<table>
<thead>
<tr>
<th>Grid ID</th>
<th>Line</th>
<th>Master label</th>
<th>Rate table (what differs)</th>
<th>Persisted in</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>1</code></td>
<td>GPA</td>
<td>Sum Insured (SI) * Multiplier</td>
<td>
GPA primary: sub-mode <code>si_or_bp</code> <strong>1</strong> SI × multiplier rows, <strong>2</strong> basic pay ladder with multipliers,
<strong>3</strong> band/grade + SI + premium. Multiple unit/SI/premium lines. Controller forces <code>additional_relationship</code> to self-only for grids 12.
Same <a href="#premium-calculation-modes">premium calculation</a> radios apply when shown.
</td>
<td><code>policy_premium_1</code></td>
</tr>
<tr>
<td><code>2</code></td>
<td>GPA</td>
<td>Flat Rate for all SI</td>
<td>
Simple ladder: <code>gpa_unit29[]</code>, <code>gpa_si29[]</code>, <code>gpa_premium29[]</code> per row no age/grade columns.
</td>
<td><code>policy_premium_1</code></td>
</tr>
<tr>
<td><code>3</code></td>
<td>GMC</td>
<td>SI</td>
<td>
Unit + SI + premium per row (no age/relationship in the standard template). Prefix <code>3_</code> on POST keys.
</td>
<td><code>policy_premium_2</code></td>
</tr>
<tr>
<td><code>4</code></td>
<td>GMC</td>
<td>Employees Age band</td>
<td>
<strong>One SI</strong> for the table; rows = age band + premium only (see <a href="#grid-4-vs-5-ui">Grid 4 vs 5</a>).
Backend still stores <code>si</code>, <code>age_from</code>, <code>age_to</code>, <code>premium</code> per insert; UI collects one SI context then many age rows.
</td>
<td><code>policy_premium_2</code></td>
</tr>
<tr>
<td><code>5</code></td>
<td>GMC</td>
<td>Employees Age + SI</td>
<td>
<strong>Each row</strong>: SI + from age + to age + premium (see <a href="#grid-4-vs-5-ui">Grid 4 vs 5</a>). Prefix <code>5_</code>.
</td>
<td><code>policy_premium_2</code></td>
</tr>
<tr>
<td><code>6</code></td>
<td>GMC</td>
<td>Employees + Dependent Age band</td>
<td>
Age-band table where dependents are in product scope (<code>policy_grid_master</code> dependent flags). Same POST pattern as other GMC age grids with prefix <code>6_</code>.
</td>
<td><code>policy_premium_2</code></td>
</tr>
<tr>
<td><code>7</code></td>
<td>GMC</td>
<td>Employees + Dependent Age + SI</td>
<td>
Dependent-aware age bands <strong>and</strong> SI on each row (prefix <code>7_</code>) for combined pricing dimensions.
</td>
<td><code>policy_premium_2</code></td>
</tr>
<tr>
<td><code>8</code></td>
<td>GMC</td>
<td>SI as per Grade or Band</td>
<td>
Adds <strong>grade/band</strong> per row with SI and premium (<code>8_grade[]</code>, etc.). For corporate gradebased insurer tables.
</td>
<td><code>policy_premium_2</code></td>
</tr>
<tr>
<td><code>9</code></td>
<td>GMC</td>
<td>Flat Rate for all</td>
<td>
Same row shape as grid <strong>2</strong> (<code>gpa_si29[]</code> / <code>gpa_premium29[]</code>) but saved to <code>policy_premium_2</code> for GMC.
</td>
<td><code>policy_premium_2</code></td>
</tr>
<tr>
<td><code>10</code></td>
<td>GMC</td>
<td>Maximum age of Dependents</td>
<td>
Floater-oriented table (age + SI + premium); <code>getpolicyGridData</code> prefers ids <strong>1011</strong> when <code>family_floater = 1</code> and premium data exists. Prefix <code>10_</code>.
</td>
<td><code>policy_premium_2</code></td>
</tr>
<tr>
<td><code>11</code></td>
<td>GMC</td>
<td>Maximum count per Family</td>
<td>
Similar family/floater use case as 10; includes <strong>max SI</strong> column (<code>11_max_si[]</code>) for family-count / cap rules. Prefix <code>11_</code>.
</td>
<td><code>policy_premium_2</code></td>
</tr>
<tr>
<td><code>12</code></td>
<td>GMC</td>
<td>Employees + relationship</td>
<td>
Each row carries a <strong>relationship</strong> value plus SI/premium (and unit) so rates differ by member type. Prefix <code>12_</code>.
</td>
<td><code>policy_premium_2</code></td>
</tr>
<tr>
<td><code>13</code></td>
<td>GMC</td>
<td>Employees age + relationship age</td>
<td>
Full row: relationship + age from/to + SI + premium. Prefix <code>13_</code>.
</td>
<td><code>policy_premium_2</code></td>
</tr>
</tbody>
</table>
<div class="callout info">
<span>i</span>
<div>
<strong>POST naming for GMC grids 313:</strong> fields use the <code>{gridId}_</code> prefix, e.g.
<code>5_age_from[]</code>, <code>5_age_to[]</code>, <code>5_si[]</code>, <code>5_premium[]</code>, <code>5_unit[]</code>.
See <code>policy_grid_excel.php</code> <code>excel_headers</code> for paste column order per id.
</div>
</div>
<h2 id="excel-paste">Excel paste path</h2>
<p>
<code>policy_grid_excel.php</code> defines <code>excel_headers</code> 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.
</p>
<h2 id="related-routes">Related routes and follow-ups</h2>
<ul>
<li><code>POST client/premimum/edit</code> <code>ClientController::editClientPolicyPremium</code> (edit path; not detailed on this page).</li>
<li><code>GET util/delete-additional-rack-rate/(:any)</code> referenced elsewhere for removing extra GMC rack-rate tabs / data.</li>
<li><strong>Premium calculation at enrollment:</strong> Employee flows (e.g. onboarding) read slab / rack configuration through existing policy services this page documents <strong>where rack rows are authored</strong>, not every consumer.</li>
</ul>
<div class="callout warning">
<span>!</span>
<div>
<strong>Production caution:</strong> Saving grid <strong>1</strong> or <strong>2</strong> deactivates <strong>all</strong> rows in
<code>policy_premium_1</code> for the policy before insert. Other grids deactivate only rows sharing the same
<code>rack_rate_name</code> in <code>policy_premium_2</code>. Test on a copy of client policy data first.
</div>
</div>

View File

@ -0,0 +1,312 @@
<?php
/**
* File Upload Guard - content only
* app/Views/docs/file-uploads.php
*
* Documents the behavior of app/Filters/GlobalPostFileUploadGuard.php
*/
?>
<p>
File uploads are protected by <code>GlobalPostFileUploadGuard</code>, 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.
</p>
<div class="callout info">
<span>i</span>
<div>
<strong>Where this logic lives</strong>
The implementation is in <code>app/Filters/GlobalPostFileUploadGuard.php</code>.
The filter is aliased in <code>app/Config/Filters.php</code> and is also
applied globally in the <code>before</code> filter chain.
</div>
</div>
<h2 id="overview">Overview</h2>
<div class="mermaid-wrapper">
<div class="mermaid">
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
</div>
</div>
<h2 id="when-it-runs">When it runs</h2>
<p>
The filter returns immediately unless all of the following are true:
</p>
<ol class="steps">
<li>
<strong>The request method is <code>POST</code></strong>
<p>Non-POST requests are ignored by the guard.</p>
</li>
<li>
<strong>The request actually contains uploaded files</strong>
<p>If <code>$request-&gt;getFiles()</code> is empty, the filter exits without doing anything.</p>
</li>
<li>
<strong>Each uploaded file is valid enough to inspect</strong>
<p>Oversized uploads that fail at the PHP upload layer are still blocked and logged with a specific reason.</p>
</li>
</ol>
<p>
The filter also recurses through nested file input arrays, so it protects both
single-file and multi-file form structures.
</p>
<h2 id="allowed-file-types">Allowed file types</h2>
<p>
The allowlist is defined through <code>$allowedMimeMap</code>. The filter
resolves an expected MIME from the client extension and rejects any extension
that does not map to an approved MIME.
</p>
<table>
<thead>
<tr><th>MIME type</th><th>Allowed extensions</th></tr>
</thead>
<tbody>
<tr><td><code>image/jpeg</code></td><td><code>jpg</code>, <code>jpeg</code></td></tr>
<tr><td><code>image/png</code></td><td><code>png</code></td></tr>
<tr><td><code>image/gif</code></td><td><code>gif</code></td></tr>
<tr><td><code>image/webp</code></td><td><code>webp</code></td></tr>
<tr><td><code>application/pdf</code></td><td><code>pdf</code></td></tr>
<tr><td><code>application/msword</code></td><td><code>doc</code></td></tr>
<tr><td><code>application/vnd.openxmlformats-officedocument.wordprocessingml.document</code></td><td><code>docx</code></td></tr>
<tr><td><code>application/vnd.oasis.opendocument.text</code></td><td><code>odt</code></td></tr>
<tr><td><code>text/rtf</code> / <code>application/rtf</code></td><td><code>rtf</code></td></tr>
<tr><td><code>application/vnd.ms-excel</code></td><td><code>xls</code></td></tr>
<tr><td><code>application/vnd.openxmlformats-officedocument.spreadsheetml.sheet</code></td><td><code>xlsx</code></td></tr>
<tr><td><code>application/vnd.oasis.opendocument.spreadsheet</code></td><td><code>ods</code></td></tr>
<tr><td><code>text/csv</code> / <code>application/csv</code></td><td><code>csv</code></td></tr>
<tr><td><code>text/plain</code></td><td><code>txt</code></td></tr>
</tbody>
</table>
<div class="callout warning">
<span>!</span>
<div>
<strong>Notable exclusions</strong>
SVG is explicitly removed. Archive formats such as <code>zip</code>,
<code>rar</code>, and <code>7z</code> are blocked. The filter also separates
<code>xlsx</code> from old Excel MIME handling and keeps <code>txt</code> and
<code>csv</code> distinct.
</div>
</div>
<h2 id="blocked-extensions">Blocked extensions</h2>
<p>
The guard maintains a large denylist in <code>$blockedExtensions</code> to
stop common executable, script, archive, config, and sensitive file types.
</p>
<table>
<thead>
<tr><th>Category</th><th>Examples</th></tr>
</thead>
<tbody>
<tr><td>PHP / server code</td><td><code>php</code>, <code>phtml</code>, <code>phar</code>, <code>jsp</code>, <code>asp</code>, <code>aspx</code></td></tr>
<tr><td>Scripts</td><td><code>js</code>, <code>ts</code>, <code>jsx</code>, <code>tsx</code>, <code>sh</code>, <code>bash</code>, <code>ps1</code>, <code>bat</code>, <code>cmd</code></td></tr>
<tr><td>Binaries</td><td><code>exe</code>, <code>dll</code>, <code>msi</code>, <code>apk</code>, <code>deb</code>, <code>rpm</code>, <code>bin</code></td></tr>
<tr><td>Archives</td><td><code>zip</code>, <code>rar</code>, <code>7z</code>, <code>tar</code>, <code>gz</code>, <code>iso</code></td></tr>
<tr><td>Config / secrets</td><td><code>env</code>, <code>ini</code>, <code>htaccess</code>, <code>htpasswd</code>, <code>key</code>, <code>pem</code>, <code>p12</code></td></tr>
<tr><td>Database / logs</td><td><code>sql</code>, <code>db</code>, <code>sqlite</code>, <code>log</code>, <code>bak</code></td></tr>
<tr><td>Markup / risky text</td><td><code>html</code>, <code>htm</code>, <code>xhtml</code>, <code>xml</code>, <code>svg</code></td></tr>
</tbody>
</table>
<p>
Multiple extensions are handled defensively. If a filename like
<code>invoice.php.pdf</code> or <code>report.jpg.js</code> contains any blocked
extension in its middle segments, the file is rejected.
</p>
<h2 id="validation-flow">Validation flow</h2>
<p>
Each uploaded file passes through this validation order:
</p>
<ol class="steps">
<li>
<strong>Upload validity check</strong>
<p>If PHP reports an invalid upload and the error is a server/form size issue, the guard blocks immediately.</p>
</li>
<li>
<strong>Filename safety check</strong>
<p>Rejects null bytes, path separators, and filenames longer than 255 characters.</p>
</li>
<li>
<strong>Multiple-extension detection</strong>
<p>Rejects files that hide blocked extensions inside multi-part names.</p>
</li>
<li>
<strong>Forbidden extension check</strong>
<p>Rejects uploads whose client extension is directly on the blocked list.</p>
</li>
<li>
<strong>File size limit</strong>
<p>The hard application limit is <code>25 MB</code>.</p>
</li>
<li>
<strong>Real MIME detection</strong>
<p>Uses PHP <code>finfo(FILEINFO_MIME_TYPE)</code> on the temporary uploaded file.</p>
</li>
<li>
<strong>Expected MIME resolution</strong>
<p>Maps the client extension to one expected MIME from the allowlist.</p>
</li>
<li>
<strong>Magic byte validation</strong>
<p>Checks the actual file header against known signatures for supported formats.</p>
</li>
<li>
<strong>Strict MIME match</strong>
<p>The detected MIME must match the expected MIME exactly; generic fallback MIME values are not accepted.</p>
</li>
</ol>
<pre><code class="language-php">if ($request->getMethod() !== 'post') {
return;
}
$files = $request->getFiles();
if (empty($files)) {
return;
}</code></pre>
<h2 id="magic-bytes-check">Magic bytes check</h2>
<p>
The guard performs deep header checks using <code>$magicBytes</code> for
several formats:
</p>
<table>
<thead>
<tr><th>Type</th><th>Signature rule</th></tr>
</thead>
<tbody>
<tr><td>JPEG</td><td><code>FF D8 FF</code></td></tr>
<tr><td>PNG</td><td><code>89 50 4E 47 0D 0A 1A 0A</code></td></tr>
<tr><td>GIF</td><td><code>GIF87a</code> or <code>GIF89a</code></td></tr>
<tr><td>PDF</td><td><code>%PDF-</code></td></tr>
<tr><td>DOC / XLS (legacy)</td><td><code>D0 CF 11 E0</code></td></tr>
<tr><td>DOCX / XLSX / ODT / ODS</td><td><code>PK 03 04</code></td></tr>
<tr><td>WebP</td><td>Special-case check for <code>RIFF....WEBP</code></td></tr>
</tbody>
</table>
<p>
There is also an <code>scanForEmbeddedCode()</code> 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.
</p>
<h2 id="route-coverage">Route coverage</h2>
<p>
This guard is registered in two relevant places:
</p>
<table>
<thead>
<tr><th>Location</th><th>Effect</th></tr>
</thead>
<tbody>
<tr>
<td><code>app/Config/Filters.php</code> global <code>before</code> filters</td>
<td>Applies the guard to incoming requests globally before controller execution.</td>
</tr>
<tr>
<td><code>app/Config/Routes.php</code> <code>employeeRest</code> group</td>
<td>Also explicitly includes <code>GlobalPostFileUploadGuard</code> alongside rate-limit, app-signature, and JWT auth filters.</td>
</tr>
</tbody>
</table>
<pre><code class="language-php">$routes->group("employeeRest", ["filter" => ['GlobalPostFileUploadGuard', 'ratelimit', 'appSignature', 'authJWT']], function ($routes) {
// upload-related endpoints live here
});</code></pre>
<h2 id="blocked-response">Blocked response</h2>
<p>
When the filter rejects a file, it logs a critical event and immediately sends
a JSON error response with HTTP <code>403</code>.
</p>
<table>
<thead>
<tr><th>Response field</th><th>Meaning</th></tr>
</thead>
<tbody>
<tr><td><code>status</code></td><td><code>error</code></td></tr>
<tr><td><code>message</code></td><td>Security-policy rejection message including the reason.</td></tr>
<tr><td><code>debug</code></td><td>Detailed rejection reason only when <code>ENVIRONMENT === 'development'</code>.</td></tr>
</tbody>
</table>
<pre><code class="language-json">{
"status": "error",
"message": "File upload rejected: Security policy violation. Reason: MIME-extension mismatch.",
"debug": "MIME-extension mismatch"
}</code></pre>
<p>
The log entry includes the block reason, client IP, URI, input field, original
filename, MIME, extension, and size.
</p>
<h2 id="operational-notes">Operational notes</h2>
<ol class="steps">
<li>
<strong>Controller code never sees blocked files</strong>
<p>The filter sends the response directly and exits, so later controller logic does not run for rejected uploads.</p>
</li>
<li>
<strong>Client extension alone is never trusted</strong>
<p>The extension is only used to resolve the expected MIME; the real file MIME and header still have to match.</p>
</li>
<li>
<strong>25 MB is the app-level limit</strong>
<p>Server-side PHP upload limits can still reject larger files earlier, and the filter explicitly handles that error path.</p>
</li>
<li>
<strong>False positives are possible if MIME support differs by environment</strong>
<p>Because the check is strict, any environment mismatch in MIME detection can cause a block until the allowlist is updated deliberately.</p>
</li>
</ol>
<div class="callout success">
<span>+</span>
<div>
<strong>Practical takeaway</strong>
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.
</div>
</div>

View File

@ -0,0 +1,315 @@
<?php
/**
* Input Security Guard - content only
* app/Views/docs/input-security.php
*
* Based on app/Filters/SecurityInputFilter.php
*/
?>
<p>
<code>SecurityInputFilter</code> 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.
</p>
<div class="callout info">
<span>i</span>
<div>
<strong>Good name in Features</strong>
This docs page is listed as <code>Input Security Guard</code> because the
filter is not only about sanitizing forms. It is a request gate that checks
user-controlled input before normal application logic continues.
</div>
</div>
<h2 id="overview">Overview</h2>
<div class="mermaid-wrapper">
<div class="mermaid">
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]
</div>
</div>
<h2 id="where-it-runs">Where it runs</h2>
<p>
The filter is globally registered in <code>app/Config/Filters.php</code> in
the <code>before</code> chain, which means it runs for normal incoming web
requests unless the route is explicitly excluded.
</p>
<pre><code class="language-php">'SecurityInputFilter' => SecurityInputFilter::class,
'before' => [
'SecurityInputFilter' => [
'except' => [
'/client/notification/create',
'/ticket/crud_mail_template/*',
'test_mail',
'leads/sendMail',
'ticket/reply'
]
],
]</code></pre>
<p>
The filter only reads:
</p>
<ul>
<li><code>$request->getGet()</code></li>
<li><code>$request->getPost()</code></li>
</ul>
<p>
It does not inspect uploaded file contents. File uploads are handled by the
separate <code>GlobalPostFileUploadGuard</code> filter.
</p>
<h2 id="what-it-checks">What it checks</h2>
<p>
The filter uses a focused list of high-confidence XSS patterns to reduce false
positives while still blocking obvious injection attempts.
</p>
<table>
<thead>
<tr><th>Category</th><th>Examples from the filter</th></tr>
</thead>
<tbody>
<tr>
<td>Script tags</td>
<td><code>&lt;script</code>, <code>&lt;/script&gt;</code></td>
</tr>
<tr>
<td>JavaScript execution schemes</td>
<td><code>javascript:</code>, <code>vbscript:</code>, <code>data:text/html</code></td>
</tr>
<tr>
<td>Inline event handlers</td>
<td><code>onclick=</code>, <code>onerror=</code>, <code>onload=</code></td>
</tr>
<tr>
<td>Dangerous HTML tags</td>
<td><code>&lt;iframe</code>, <code>&lt;object</code>, <code>&lt;embed</code>, <code>&lt;applet</code>, <code>&lt;img</code></td>
</tr>
<tr>
<td>SVG / MathML vectors</td>
<td><code>&lt;svg</code>, <code>&lt;math</code></td>
</tr>
<tr>
<td>Meta refresh payloads</td>
<td><code>&lt;meta http-equiv="refresh"</code></td>
</tr>
<tr>
<td>Injected src/href handlers</td>
<td>HTML tags using <code>src=javascript:</code> or <code>href=data:</code></td>
</tr>
</tbody>
</table>
<div class="callout warning">
<span>!</span>
<div>
<strong>Detection, not rich sanitization</strong>
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.
</div>
</div>
<h2 id="canonicalization">Canonicalization</h2>
<p>
Before pattern matching, the filter canonicalizes each value:
</p>
<ol class="steps">
<li>
<strong>URL decode</strong>
<p>Helps catch encoded payloads that would otherwise bypass naive matching.</p>
</li>
<li>
<strong>HTML entity decode</strong>
<p>Turns entity-encoded payloads into their real characters before detection.</p>
</li>
<li>
<strong>Strip invisible control characters</strong>
<p>Removes null bytes and other control characters from the evaluation string.</p>
</li>
<li>
<strong>Trim the final value</strong>
<p>Reduces noise before regex evaluation.</p>
</li>
</ol>
<pre><code class="language-php">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);
}</code></pre>
<p>
Array inputs are converted to JSON first, then canonicalized as a string.
</p>
<h2 id="block-behavior">Block behavior</h2>
<p>
When a pattern matches, the filter logs security metadata and immediately
returns a JSON <code>403</code> response:
</p>
<table>
<thead>
<tr><th>Logged field</th><th>Purpose</th></tr>
</thead>
<tbody>
<tr><td><code>ip</code></td><td>Source IP address</td></tr>
<tr><td><code>method</code></td><td>Request method</td></tr>
<tr><td><code>uri</code></td><td>Current request URL</td></tr>
<tr><td><code>field</code></td><td>Input field name</td></tr>
<tr><td><code>attack</code></td><td>Static marker <code>XSS_PATTERN</code></td></tr>
<tr><td><code>length</code></td><td>Canonicalized payload length</td></tr>
<tr><td><code>hash</code></td><td>SHA-256 hash of the canonicalized payload</td></tr>
</tbody>
</table>
<p>
The raw input value is not logged directly. The filter logs intent metadata and
a hash instead.
</p>
<pre><code class="language-json">{
"status": 403,
"error": "Forbidden",
"message": "Malicious input detected"
}</code></pre>
<h2 id="filter-exceptions">Filter exceptions</h2>
<p>
Some routes are explicitly excluded from the global security-input filter:
</p>
<table>
<thead>
<tr><th>Excluded route</th><th>Why developers should care</th></tr>
</thead>
<tbody>
<tr>
<td><code>/client/notification/create</code></td>
<td>Global input blocking does not run here.</td>
</tr>
<tr>
<td><code>/ticket/crud_mail_template/*</code></td>
<td>Template-editing paths often need richer content and should be handled deliberately.</td>
</tr>
<tr>
<td><code>test_mail</code></td>
<td>Bypassed globally.</td>
</tr>
<tr>
<td><code>leads/sendMail</code></td>
<td>Bypassed globally.</td>
</tr>
<tr>
<td><code>ticket/reply</code></td>
<td>Bypassed globally.</td>
</tr>
</tbody>
</table>
<div class="callout danger">
<span>!</span>
<div>
<strong>Important developer rule</strong>
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.
</div>
</div>
<h2 id="developer-steps">Developer steps</h2>
<p>
When building a new form, endpoint, or feature that accepts user input, follow
this checklist:
</p>
<ol class="steps">
<li>
<strong>Assume GET and POST are inspected automatically</strong>
<p>If your route is not in the exception list, the filter already evaluates GET and POST fields before controller code runs.</p>
</li>
<li>
<strong>Do not rely on this filter as your only validation</strong>
<p>Business validation, field-level validation, and output escaping are still required.</p>
</li>
<li>
<strong>Be careful with HTML-capable inputs</strong>
<p>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.</p>
</li>
<li>
<strong>Only add filter exceptions deliberately</strong>
<p>If you exclude a route in <code>Filters.php</code>, add compensating server-side sanitization or allowlist logic in the receiving code.</p>
</li>
<li>
<strong>Test encoded attack strings too</strong>
<p>Because the filter canonicalizes input, test URL-encoded and HTML-entity-encoded payloads in addition to plain strings.</p>
</li>
<li>
<strong>Watch the logs when troubleshooting blocks</strong>
<p>The filter logs a structured critical event named <code>SECURITY_BLOCKED_REQUEST</code> with a payload hash and field name.</p>
</li>
</ol>
<h2 id="common-pitfalls">Common pitfalls</h2>
<table>
<thead>
<tr><th>Pitfall</th><th>Why it happens</th></tr>
</thead>
<tbody>
<tr>
<td>A rich text feature keeps returning 403</td>
<td>The submitted markup matches one of the high-confidence XSS patterns, and the route is still under the global filter.</td>
</tr>
<tr>
<td>Input looks harmless in raw form but still gets blocked</td>
<td>The canonicalization step decoded the payload into a dangerous form before matching.</td>
</tr>
<tr>
<td>A developer adds an exception without extra protection</td>
<td>The route bypasses the global blocker and now depends entirely on downstream validation.</td>
</tr>
<tr>
<td>Files are assumed to be covered here</td>
<td>Uploaded file contents are handled by the separate file-upload guard, not this filter.</td>
</tr>
</tbody>
</table>
<div class="callout success">
<span>+</span>
<div>
<strong>Practical takeaway</strong>
Treat <code>SecurityInputFilter</code> 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.
</div>
</div>

View File

@ -0,0 +1,175 @@
<?php
/**
* Installation - content only
* app/Views/docs/installation.php
*
* The controller already renders the header, sidebar, main wrapper, TOC,
* prev/next navigation, and footer.
*
* Only keep page-specific content in this file.
*/
?>
<p>
This guide walks you through setting up the project on a local development machine.
For production deployment, see the <a href="<?= base_url('docs/deployment') ?>">Deployment</a> page.
</p>
<div class="callout info">
<span></span>
<div>
<strong>Before you begin</strong>
Make sure PHP 8.1+, Composer 2.x, and MySQL 5.7+ are installed on your machine.
</div>
</div>
<!-- REQUIREMENTS -->
<h2 id="requirements">Requirements</h2>
<table>
<thead>
<tr><th>Dependency</th><th>Version</th><th>Notes</th></tr>
</thead>
<tbody>
<tr><td><code>PHP</code></td><td>8.1+</td><td>Required by CI 4.4+</td></tr>
<tr><td><code>MySQL</code></td><td>5.7 / 8.0</td><td>Primary database</td></tr>
<tr><td><code>Composer</code></td><td>2.x</td><td>Dependency management</td></tr>
<tr><td><code>Node.js</code></td><td>18+ (optional)</td><td>Only needed for asset pipeline</td></tr>
</tbody>
</table>
<!-- STEPS -->
<h2 id="steps">Steps</h2>
<ol class="steps">
<li>
<strong id="clone">Clone the repository</strong>
<div class="code-header">
<span class="code-filename">terminal</span>
<span class="code-lang">bash</span>
</div>
<pre><code class="language-bash">git clone https://github.com/your-org/myapp.git
cd myapp</code></pre>
</li>
<li>
<strong>Install PHP dependencies</strong>
<pre><code class="language-bash">composer install</code></pre>
</li>
<li>
<strong>Copy the environment file</strong>
<pre><code class="language-bash">cp env .env</code></pre>
<p>Edit <code>.env</code> with your local database credentials and base URL.</p>
</li>
<li>
<strong id="migrate">Run migrations and seeders</strong>
<pre><code class="language-bash">php spark migrate
php spark db:seed MainSeeder</code></pre>
</li>
<li>
<strong>Start the dev server</strong>
<pre><code class="language-bash">php spark serve</code></pre>
<p>App will be available at <code>http://localhost:8080</code>.</p>
</li>
</ol>
<div class="callout warning">
<span>⚠️</span>
<div>
<strong>Use 127.0.0.1, not localhost</strong>
MySQL on some setups resolves <code>localhost</code> to a socket path instead of TCP.
Use <code>127.0.0.1</code> in <code>.env</code> to avoid connection errors.
</div>
</div>
<!-- CONFIGURATION -->
<h2 id="configuration">Configuration</h2>
<p>Key variables to configure in <code>.env</code>:</p>
<table>
<thead>
<tr><th>Variable</th><th>Default</th><th>Description</th></tr>
</thead>
<tbody>
<tr>
<td>
<span class="param-name">CI_ENVIRONMENT</span>
<span class="badge req">required</span>
</td>
<td><code>production</code></td>
<td>Set to <code>development</code> locally to enable error display.</td>
</tr>
<tr>
<td>
<span class="param-name">database.default.hostname</span>
<span class="badge req">required</span>
</td>
<td></td>
<td>MySQL hostname. Use <code>127.0.0.1</code>.</td>
</tr>
<tr>
<td>
<span class="param-name">app.baseURL</span>
<span class="badge req">required</span>
</td>
<td></td>
<td>Full URL with trailing slash. e.g. <code>http://localhost:8080/</code></td>
</tr>
<tr>
<td>
<span class="param-name">JWT_SECRET</span>
<span class="badge opt">optional</span>
</td>
<td></td>
<td>Only needed if JWT API auth is enabled.</td>
</tr>
</tbody>
</table>
<div class="callout danger">
<span>🚫</span>
<div>
<strong>Never commit <code>.env</code></strong>
The file is in <code>.gitignore</code>. Use your CI/CD secrets manager for production values.
</div>
</div>
<!-- SAMPLE FLOWCHART -->
<h2 id="sample-flowchart">Sample flowchart</h2>
<p>
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
<code>&lt;div class="mermaid"&gt;</code> block like the example below.
</p>
<div class="callout success">
<span>i</span>
<div>
<strong>Reusable in other docs pages</strong>
Copy this section structure into any docs view and replace the diagram text with
your own flow.
</div>
</div>
<div class="mermaid-wrapper">
<div class="mermaid">
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>
</div>
<pre><code class="language-html">&lt;div class="mermaid"&gt;
flowchart TD
A[Start] --&gt; B[Process]
B --&gt; C[Done]
&lt;/div&gt;</code></pre>

View File

@ -0,0 +1,124 @@
<?php
/**
* Installation content only
* app/Views/docs/installation.php
*
* ⚠️ NO layout partials here. The controller wraps this automatically.
* Just write your h2, p, pre, table, callout blocks.
*
* Available $data variables injected by the controller:
* $title, $breadcrumb, $toc, $last_updated, $author, $read_time, $prev, $next
*/
?>
<p>
This guide walks you through setting up the project on a local development machine.
For production deployment, see the <a href="<?= base_url('docs/deployment') ?>">Deployment</a> page.
</p>
<div class="callout info">
<span></span>
<div>
<strong>Before you begin</strong>
Make sure PHP 8.1+, Composer 2.x, and MySQL 5.7+ are installed on your machine.
</div>
</div>
<!-- REQUIREMENTS -->
<h2 id="requirements">Requirements</h2>
<table>
<thead>
<tr><th>Dependency</th><th>Version</th><th>Notes</th></tr>
</thead>
<tbody>
<tr><td><code>PHP</code></td><td>8.1+</td><td>Required by CI 4.4+</td></tr>
<tr><td><code>MySQL</code></td><td>5.7 / 8.0</td><td>Primary database</td></tr>
<tr><td><code>Composer</code></td><td>2.x</td><td>Dependency management</td></tr>
<tr><td><code>Node.js</code></td><td>18+ (optional)</td><td>Only for asset pipeline</td></tr>
</tbody>
</table>
<!-- STEPS -->
<h2 id="steps">Steps</h2>
<ol class="steps">
<li>
<strong id="clone">Clone the repository</strong>
<div class="code-header">
<span class="code-filename">terminal</span>
<span class="code-lang">bash</span>
</div>
<pre><code class="language-bash">git clone https://github.com/your-org/myapp.git
cd myapp</code></pre>
</li>
<li>
<strong>Install PHP dependencies</strong>
<pre><code class="language-bash">composer install</code></pre>
</li>
<li>
<strong>Copy the environment file</strong>
<pre><code class="language-bash">cp env .env</code></pre>
<p>Edit <code>.env</code> with your local database credentials and base URL.</p>
</li>
<li>
<strong id="migrate">Run migrations and seeders</strong>
<pre><code class="language-bash">php spark migrate
php spark db:seed MainSeeder</code></pre>
</li>
<li>
<strong>Start the dev server</strong>
<pre><code class="language-bash">php spark serve</code></pre>
<p>App will be available at <code>http://localhost:8080</code>.</p>
</li>
</ol>
<div class="callout warning">
<span>⚠️</span>
<div>
<strong>Use 127.0.0.1, not localhost</strong>
MySQL on some setups resolves <code>localhost</code> to a socket path instead of TCP.
Use <code>127.0.0.1</code> in <code>.env</code> to avoid connection errors.
</div>
</div>
<!-- CONFIGURATION -->
<h2 id="configuration">Configuration</h2>
<p>Key variables to configure in <code>.env</code>:</p>
<table>
<thead>
<tr><th>Variable</th><th>Default</th><th>Description</th></tr>
</thead>
<tbody>
<tr>
<td><span class="param-name">CI_ENVIRONMENT</span> <span class="badge req">required</span></td>
<td><code>production</code></td>
<td>Set to <code>development</code> locally for detailed error display.</td>
</tr>
<tr>
<td><span class="param-name">database.default.hostname</span> <span class="badge req">required</span></td>
<td></td>
<td>MySQL hostname. Use <code>127.0.0.1</code>.</td>
</tr>
<tr>
<td><span class="param-name">app.baseURL</span> <span class="badge req">required</span></td>
<td></td>
<td>Full URL with trailing slash. e.g. <code>http://localhost:8080/</code></td>
</tr>
<tr>
<td><span class="param-name">JWT_SECRET</span> <span class="badge opt">optional</span></td>
<td></td>
<td>Only needed if JWT API auth is enabled.</td>
</tr>
</tbody>
</table>
<div class="callout danger">
<span>🚫</span>
<div>
<strong>Never commit <code>.env</code></strong>
The file is in <code>.gitignore</code>. Use your CI/CD secrets manager for production values.
</div>
</div>

View File

@ -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` | `<head>`, CSS tokens, top bar, opens `<div class="docs-layout">` |
| `docs_sidebar.php` | Left nav sidebar — edit the `$nav` array to add/remove pages |
| `docs_main_open.php` | Opens `<main>`, renders breadcrumb, h1, meta row |
| `docs_main_close.php` | Closes `</main>`, prev/next nav, right TOC, closes layout div |
| `docs_footer.php` | Global footer bar, hljs init, closes `</body></html>` |
| `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
<?php
/**
* Content only.
* The controller injects the layout and page metadata.
*/
?>
<p>...</p>
<h2 id="section-one">Section One</h2>
<p>...</p>
```
---
## 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 |
|---------------------|----------------------------------------|
| `<h2 id="...">` `<h3 id="...">` | 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 |
| `<ol class="steps">` | Numbered step list with connector lines |
| `<table>` | 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` + `<pre>` | 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);
}
```

View File

@ -0,0 +1,542 @@
<?php
/**
* Docs Footer Partial
* app/Views/docs/partials/docs_footer.php
*
* Closes the body/html tags and initialises shared client-side helpers.
* Always the last partial on every docs page.
*
* Variables:
* $app_name (string) defaults to 'Nhance PAM'
*/
$app_name = $app_name ?? 'Nhance PAM';
$year = date('Y');
?>
<script src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"></script>
<div id="mermaid-modal" class="mermaid-modal" hidden aria-hidden="true">
<button type="button" class="mermaid-modal__backdrop" aria-label="Close enlarged diagram"></button>
<div class="mermaid-modal__panel" role="dialog" aria-modal="true" aria-labelledby="mermaid-modal-title">
<div class="mermaid-modal__header">
<span id="mermaid-modal-title" class="mermaid-modal__title">Diagram</span>
<div class="mermaid-modal__actions">
<div class="mermaid-modal__zoom" aria-label="Zoom controls">
<button type="button" class="mermaid-modal__zoom-btn" data-zoom="out" aria-label="Zoom out"></button>
<span class="mermaid-modal__zoom-label" aria-live="polite">100%</span>
<button type="button" class="mermaid-modal__zoom-btn" data-zoom="in" aria-label="Zoom in">+</button>
<button type="button" class="mermaid-modal__zoom-btn mermaid-modal__zoom-btn--text" data-zoom="reset" aria-label="Reset zoom and pan">Reset</button>
</div>
<button type="button" class="mermaid-modal__close" aria-label="Close enlarged diagram">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M18 6 6 18M6 6l12 12"/>
</svg>
</button>
</div>
</div>
<p class="mermaid-modal__help">
<strong>Tips:</strong>
Drag to pan · Scroll or double-click to zoom in ·
<kbd>Ctrl</kbd>+double-click to zoom out ·
Use +//Reset or <kbd>+</kbd> <kbd></kbd> <kbd>0</kbd> to reset · <kbd>Esc</kbd> to close
</p>
<div class="mermaid-modal__viewport" aria-label="Diagram viewer">
<div class="mermaid-modal__stage"></div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function () {
// Syntax highlighting
if (window.hljs) hljs.highlightAll();
// Mermaid diagrams + enlarge controls
if (window.mermaid && document.querySelector('.mermaid')) {
mermaid.initialize({
startOnLoad: false,
theme: 'neutral',
securityLevel: 'loose'
});
mermaid.run({ querySelector: '.mermaid' }).then(initMermaidEnlarge).catch(initMermaidEnlarge);
}
function initMermaidEnlarge() {
const modal = document.getElementById('mermaid-modal');
const modalTitle = document.getElementById('mermaid-modal-title');
const modalViewport = modal && modal.querySelector('.mermaid-modal__viewport');
const modalStage = modal && modal.querySelector('.mermaid-modal__stage');
const zoomLabel = modal && modal.querySelector('.mermaid-modal__zoom-label');
if (!modal || !modalViewport || !modalStage) return;
const MIN_SCALE = 0.25;
const MAX_SCALE = 4;
const ZOOM_FACTOR = 1.15;
const expandIcon = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"/></svg>';
let view = { scale: 1, x: 0, y: 0 };
let drag = null;
function applyView() {
modalStage.style.transform = 'translate(' + view.x + 'px, ' + view.y + 'px) scale(' + view.scale + ')';
if (zoomLabel) zoomLabel.textContent = Math.round(view.scale * 100) + '%';
}
function resetView() {
view = { scale: 1, x: 0, y: 0 };
applyView();
}
function centerDiagram() {
const svg = modalStage.querySelector('svg');
if (!svg) return;
const vpRect = modalViewport.getBoundingClientRect();
const svgRect = svg.getBoundingClientRect();
view.x = Math.max(24, (vpRect.width - svgRect.width) / 2);
view.y = Math.max(24, (vpRect.height - svgRect.height) / 2);
applyView();
}
function zoomAt(factor, originX, originY) {
const vpRect = modalViewport.getBoundingClientRect();
const ox = (originX !== undefined) ? originX : vpRect.width / 2;
const oy = (originY !== undefined) ? originY : vpRect.height / 2;
const newScale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, view.scale * factor));
if (newScale === view.scale) return;
view.x = ox - (ox - view.x) * (newScale / view.scale);
view.y = oy - (oy - view.y) * (newScale / view.scale);
view.scale = newScale;
applyView();
}
document.querySelectorAll('.mermaid-wrapper').forEach(function (wrapper) {
if (wrapper.querySelector('.mermaid-enlarge-btn')) return;
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'mermaid-enlarge-btn';
btn.setAttribute('aria-label', 'View enlarged diagram');
btn.innerHTML = expandIcon;
btn.addEventListener('click', function () {
openMermaidModal(wrapper);
});
wrapper.appendChild(btn);
});
function getDiagramTitle(wrapper) {
if (wrapper.id) {
return wrapper.id.replace(/-/g, ' ').replace(/\b\w/g, function (c) { return c.toUpperCase(); });
}
var el = wrapper.previousElementSibling;
while (el) {
if (el.matches && el.matches('h2, h3')) return el.textContent.trim();
el = el.previousElementSibling;
}
return 'Diagram';
}
function openMermaidModal(wrapper) {
const svg = wrapper.querySelector('.mermaid svg');
if (!svg) return;
modalTitle.textContent = getDiagramTitle(wrapper);
modalStage.innerHTML = '';
modalStage.appendChild(svg.cloneNode(true));
resetView();
modal.hidden = false;
modal.setAttribute('aria-hidden', 'false');
document.body.classList.add('mermaid-modal-open');
requestAnimationFrame(centerDiagram);
modal.querySelector('.mermaid-modal__close').focus();
}
function closeMermaidModal() {
modal.hidden = true;
modal.setAttribute('aria-hidden', 'true');
modalStage.innerHTML = '';
drag = null;
modalViewport.classList.remove('is-dragging');
resetView();
document.body.classList.remove('mermaid-modal-open');
}
if (!modal.dataset.mermaidViewerReady) {
modal.dataset.mermaidViewerReady = '1';
modal.querySelector('.mermaid-modal__backdrop').addEventListener('click', closeMermaidModal);
modal.querySelector('.mermaid-modal__close').addEventListener('click', closeMermaidModal);
modal.addEventListener('click', function (e) {
const zoomBtn = e.target.closest('[data-zoom]');
if (!zoomBtn || modal.hidden) return;
const action = zoomBtn.getAttribute('data-zoom');
if (action === 'in') zoomAt(ZOOM_FACTOR);
else if (action === 'out') zoomAt(1 / ZOOM_FACTOR);
else if (action === 'reset') resetView();
});
modalViewport.addEventListener('wheel', function (e) {
if (modal.hidden) return;
e.preventDefault();
const rect = modalViewport.getBoundingClientRect();
const factor = e.deltaY < 0 ? ZOOM_FACTOR : 1 / ZOOM_FACTOR;
zoomAt(factor, e.clientX - rect.left, e.clientY - rect.top);
}, { passive: false });
modalViewport.addEventListener('dblclick', function (e) {
if (modal.hidden) return;
e.preventDefault();
const rect = modalViewport.getBoundingClientRect();
const factor = e.ctrlKey ? (1 / ZOOM_FACTOR) : ZOOM_FACTOR;
zoomAt(factor, e.clientX - rect.left, e.clientY - rect.top);
});
modalViewport.addEventListener('pointerdown', function (e) {
if (modal.hidden || e.button !== 0) return;
drag = {
pointerId: e.pointerId,
startX: e.clientX,
startY: e.clientY,
origX: view.x,
origY: view.y
};
modalViewport.setPointerCapture(e.pointerId);
modalViewport.classList.add('is-dragging');
});
modalViewport.addEventListener('pointermove', function (e) {
if (!drag || drag.pointerId !== e.pointerId) return;
view.x = drag.origX + (e.clientX - drag.startX);
view.y = drag.origY + (e.clientY - drag.startY);
applyView();
});
function endDrag(e) {
if (!drag || drag.pointerId !== e.pointerId) return;
drag = null;
modalViewport.classList.remove('is-dragging');
try { modalViewport.releasePointerCapture(e.pointerId); } catch (err) { /* ignore */ }
}
modalViewport.addEventListener('pointerup', endDrag);
modalViewport.addEventListener('pointercancel', endDrag);
document.addEventListener('keydown', function (e) {
if (modal.hidden) return;
if (e.key === 'Escape') {
closeMermaidModal();
return;
}
if (e.target.closest('input, textarea, select')) return;
if (e.key === '+' || e.key === '=') {
e.preventDefault();
zoomAt(ZOOM_FACTOR);
} else if (e.key === '-') {
e.preventDefault();
zoomAt(1 / ZOOM_FACTOR);
} else if (e.key === '0') {
e.preventDefault();
resetView();
}
});
}
}
// Active TOC link on scroll
const tocLinks = document.querySelectorAll('.docs-toc a');
const headings = document.querySelectorAll('main h2, main h3');
if (tocLinks.length && headings.length) {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
tocLinks.forEach(l => l.classList.remove('is-active-toc'));
const match = document.querySelector(
`.docs-toc a[href="#${entry.target.id}"]`
);
if (match) match.classList.add('is-active-toc');
}
});
},
{ rootMargin: '0px 0px -70% 0px' }
);
headings.forEach(h => { if (h.id) observer.observe(h); });
}
});
</script>
<style>
.docs-toc a.is-active-toc { color: var(--accent); font-weight: 500; }
.mermaid-wrapper {
position: relative;
margin: 20px 0 24px;
padding: 16px;
padding-top: 44px;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--bg2);
overflow-x: auto;
}
.mermaid-enlarge-btn {
position: absolute;
top: 10px;
right: 10px;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg);
color: var(--muted);
cursor: pointer;
transition: color 0.15s, border-color 0.15s, background 0.15s;
}
.mermaid-enlarge-btn:hover {
color: var(--accent);
border-color: var(--accent);
background: var(--accent-bg);
}
.mermaid {
min-width: 520px;
text-align: center;
}
.mermaid svg {
max-width: 100%;
height: auto;
}
body.mermaid-modal-open {
overflow: hidden;
}
.mermaid-modal {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.mermaid-modal[hidden] {
display: none;
}
.mermaid-modal__backdrop {
position: absolute;
inset: 0;
border: 0;
background: rgba(15, 23, 42, 0.55);
cursor: pointer;
}
.mermaid-modal__panel {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
width: min(96vw, 1400px);
height: min(92vh, 900px);
max-height: 92vh;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 12px;
box-shadow: 0 24px 48px rgba(15, 23, 42, 0.2);
overflow: hidden;
}
.mermaid-modal__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 18px;
border-bottom: 1px solid var(--border);
background: var(--bg2);
flex-shrink: 0;
}
.mermaid-modal__title {
font-size: 14px;
font-weight: 600;
color: var(--text);
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mermaid-modal__actions {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.mermaid-modal__zoom {
display: flex;
align-items: center;
gap: 6px;
padding: 2px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg);
}
.mermaid-modal__zoom-btn {
display: flex;
align-items: center;
justify-content: center;
min-width: 32px;
height: 32px;
padding: 0 8px;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--text);
font-size: 18px;
line-height: 1;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.mermaid-modal__zoom-btn--text {
font-size: 12px;
font-weight: 500;
padding: 0 10px;
}
.mermaid-modal__zoom-btn:hover {
background: var(--accent-bg);
color: var(--accent);
}
.mermaid-modal__zoom-label {
min-width: 44px;
font-family: var(--mono);
font-size: 12px;
color: var(--muted);
text-align: center;
user-select: none;
}
.mermaid-modal__close {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg);
color: var(--muted);
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
}
.mermaid-modal__close:hover {
color: var(--accent);
border-color: var(--accent);
}
.mermaid-modal__help {
flex-shrink: 0;
margin: 0;
padding: 8px 18px;
font-size: 12px;
line-height: 1.55;
color: var(--muted);
background: var(--bg2);
border-bottom: 1px solid var(--border);
}
.mermaid-modal__help strong {
color: var(--text);
font-weight: 600;
}
.mermaid-modal__help kbd {
font-family: var(--mono);
font-size: 11px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 4px;
padding: 1px 5px;
color: var(--text);
}
.mermaid-modal__viewport {
flex: 1;
min-height: 0;
overflow: hidden;
background: var(--bg);
cursor: grab;
touch-action: none;
user-select: none;
}
.mermaid-modal__viewport.is-dragging {
cursor: grabbing;
}
.mermaid-modal__stage {
display: inline-block;
transform-origin: 0 0;
will-change: transform;
padding: 24px;
}
.mermaid-modal__stage svg {
display: block;
max-width: none !important;
width: auto;
height: auto;
pointer-events: none;
}
/* ─── GLOBAL FOOTER BAR ──────────────────── */
.docs-global-footer {
border-top : 1px solid var(--border);
padding : 16px 24px;
font-size : 12px;
color : var(--muted);
display : flex;
justify-content: space-between;
align-items: center;
margin-top : auto;
}
.docs-global-footer a {
color : var(--muted);
text-decoration: none;
}
.docs-global-footer a:hover { color: var(--accent); }
</style>
<footer class="docs-global-footer">
<span>© <?= $year ?> <?= esc($app_name) ?>. Internal developer docs.</span>
<span>
Built with CodeIgniter 4 &nbsp;·&nbsp;
<a href="<?= base_url('docs/changelog') ?>">Changelog</a> &nbsp;·&nbsp;
<a href="<?= base_url('docs/contributing') ?>">Contributing</a>
</span>
</footer>
</body>
</html>

View File

@ -0,0 +1,288 @@
<?php
/**
* Docs Header Partial
* app/Views/docs/partials/docs_header.php
*
* Usage in any docs view:
* <?= view('docs/partials/docs_header', ['doc_title' => 'Installation']) ?>
*
* Variables:
* $doc_title (string) page title shown in <title> 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) ?></title>
<!-- Highlight.js (code syntax) -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
<!-- Google Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500;600&display=swap" rel="stylesheet" />
<style>
/* ─── RESET ──────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
/* ─── TOKENS ─────────────────────────────── */
:root {
--sidebar-w : 260px;
--topbar-h : 56px;
--bg : #ffffff;
--bg2 : #f7f8fa;
--border : #e2e5ea;
--text : #1a1d23;
--muted : #6b7280;
--accent : #2563eb;
--accent-bg : #eff4ff;
--code-bg : #f3f4f6;
--font : 'IBM Plex Sans', system-ui, sans-serif;
--mono : 'IBM Plex Mono', 'Fira Code', monospace;
}
/* ─── BASE ───────────────────────────────── */
body {
font-family : var(--font);
font-size : 15px;
line-height : 1.7;
color : var(--text);
background : var(--bg);
display : flex;
flex-direction: column;
min-height : 100vh;
}
/* ─── TOP BAR ────────────────────────────── */
.docs-topbar {
position : fixed;
top: 0; left: 0; right: 0;
z-index : 200;
height : var(--topbar-h);
background : var(--bg);
border-bottom : 1px solid var(--border);
display : flex;
align-items : center;
justify-content: space-between;
padding : 0 24px;
}
.docs-topbar__logo {
display : flex;
align-items : center;
gap : 10px;
text-decoration: none;
color : var(--text);
font-weight : 600;
font-size : 15px;
letter-spacing : -0.01em;
}
.docs-topbar__badge {
background : var(--accent);
color : #fff;
font-size : 10px;
font-weight : 600;
padding : 2px 8px;
border-radius : 4px;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.docs-topbar__right {
display : flex;
align-items: center;
gap : 16px;
}
.docs-topbar__version {
font-family : var(--mono);
font-size : 12px;
color : var(--muted);
background : var(--bg2);
border : 1px solid var(--border);
border-radius: 20px;
padding : 2px 10px;
}
.docs-topbar__search {
display : flex;
align-items : center;
gap : 8px;
border : 1px solid var(--border);
border-radius: 8px;
padding : 5px 12px;
font-size : 13px;
color : var(--muted);
background : var(--bg2);
cursor : text;
min-width : 200px;
}
.docs-topbar__search kbd {
font-family : var(--mono);
font-size : 11px;
background : var(--bg);
border : 1px solid var(--border);
border-radius: 4px;
padding : 1px 5px;
margin-left : auto;
}
/* ─── LAYOUT WRAPPER ─────────────────────── */
.docs-layout {
display : flex;
margin-top: var(--topbar-h);
min-height: calc(100vh - var(--topbar-h));
}
/* ─── TYPOGRAPHY (shared across pages) ───── */
h1 { font-size: 28px; font-weight: 600; letter-spacing: -0.02em; line-height: 1.3; margin-bottom: 8px; }
h2 { font-size: 20px; font-weight: 600; margin: 40px 0 12px; letter-spacing: -0.015em; }
h3 { font-size: 16px; font-weight: 600; margin: 28px 0 8px; }
p { margin-bottom: 16px; color: #2d3340; }
a { color: var(--accent); }
/* ─── CODE ───────────────────────────────── */
pre {
background : #1e1e2e;
border-radius: 8px;
padding : 18px 20px;
overflow-x : auto;
margin : 16px 0;
}
pre code {
font-family: var(--mono);
font-size : 13px;
line-height: 1.65;
color : #cdd6f4;
background : none;
}
code {
font-family : var(--mono);
font-size : 13px;
background : var(--code-bg);
color : #c7254e;
padding : 2px 5px;
border-radius: 4px;
}
.code-header {
background : #13131e;
border-radius: 8px 8px 0 0;
padding : 8px 16px;
display : flex;
align-items : center;
justify-content: space-between;
margin-bottom: -8px;
}
.code-filename { font-family: var(--mono); font-size: 12px; color: #a6adc8; }
.code-lang { font-size: 11px; color: #585b70; text-transform: uppercase; letter-spacing: 0.06em; }
/* ─── CALLOUTS ───────────────────────────── */
.callout {
border-radius: 8px;
padding : 14px 16px;
margin : 20px 0;
font-size : 14px;
display : flex;
gap : 10px;
align-items : flex-start;
border-left : 3px solid;
}
.callout strong { display: block; margin-bottom: 2px; font-weight: 600; }
.callout.info { background: #eff4ff; border-color: #2563eb; color: #1e40af; }
.callout.warning { background: #fffbeb; border-color: #f59e0b; color: #b45309; }
.callout.danger { background: #fef2f2; border-color: #f87171; color: #b91c1c; }
.callout.success { background: #f0fdf4; border-color: #4ade80; color: #15803d; }
/* ─── TABLES ─────────────────────────────── */
table { width: 100%; border-collapse: collapse; margin: 20px 0; font-size: 13.5px; }
th {
background : var(--bg2);
font-weight : 600;
font-size : 12px;
text-transform: uppercase;
letter-spacing: 0.05em;
color : var(--muted);
padding : 10px 14px;
border : 1px solid var(--border);
text-align : left;
}
td { padding: 10px 14px; border: 1px solid var(--border); vertical-align: top; }
tr:hover td { background: var(--bg2); }
/* ─── BADGES ─────────────────────────────── */
.badge {
display : inline-block;
font-size : 11px;
font-weight : 500;
padding : 2px 8px;
border-radius: 4px;
font-family : var(--mono);
}
.badge.get { background: #dbeafe; color: #1d4ed8; }
.badge.post { background: #dcfce7; color: #15803d; }
.badge.put { background: #fef9c3; color: #92400e; }
.badge.delete { background: #fee2e2; color: #991b1b; }
.badge.req { background: #fee2e2; color: #991b1b; font-size: 10px; text-transform: uppercase; letter-spacing: 0.05em; }
.badge.opt { background: var(--bg2); color: var(--muted); font-size: 10px; text-transform: uppercase; letter-spacing: 0.05em; }
/* ─── PARAM NAME ─────────────────────────── */
.param-name { font-family: var(--mono); font-size: 12.5px; color: var(--accent); }
/* ─── STEP LIST ──────────────────────────── */
.steps { list-style: none; counter-reset: steps; margin: 20px 0; }
.steps li { counter-increment: steps; position: relative; padding: 0 0 28px 44px; }
.steps li::before {
content : counter(steps);
position : absolute; left: 0; top: 2px;
width: 28px; height: 28px; border-radius: 50%;
background : var(--accent-bg); color: var(--accent);
font-size : 12px; font-weight: 600;
display : flex; align-items: center; justify-content: center;
}
.steps li::after {
content : '';
position: absolute; left: 13px; top: 32px; bottom: 0;
width : 1px; background: var(--border);
}
.steps li:last-child::after { display: none; }
.steps li strong { display: block; margin-bottom: 4px; }
</style>
</head>
<body>
<!-- ═══════════════════════════════════════════
TOP BAR
════════════════════════════════════════════ -->
<header class="docs-topbar">
<a href="<?= $base ?>docs" class="docs-topbar__logo">
<span class="docs-topbar__badge">CI4</span>
<?= esc($app_name) ?> Doc, <?= esc($app_version) ?>
</a>
<div class="docs-topbar__right">
<div class="docs-topbar__search">
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
Search docs
<kbd>⌘K</kbd>
</div>
</div>
</header>
<!-- ═══════════════════════════════════════════
LAYOUT WRAPPER (sidebar + main go inside)
════════════════════════════════════════════ -->
<div class="docs-layout">

View File

@ -0,0 +1,112 @@
<?php
/**
* Docs Main Close Partial
* app/Views/docs/partials/docs_main_close.php
*
* Closes the main content area, renders prev/next nav,
* and renders the right-side TOC. Always pair with docs_main_open.php.
*
* Variables:
* $prev_label (string) label for previous page link
* $prev_url (string) URL segment e.g. 'docs/introduction'
* $next_label (string) label for next page link
* $next_url (string) URL segment
* $toc (array) same array passed to docs_main_open.php
* ['label', 'href', 'level'(optional h3), 'tag'(optional)]
*/
$prev_label = $prev_label ?? '';
$prev_url = $prev_url ?? '';
$next_label = $next_label ?? '';
$next_url = $next_url ?? '';
$toc = $toc ?? [];
?>
<!-- ── PAGE CONTENT ENDS ABOVE THIS LINE ── -->
<!-- Prev / Next navigation -->
<div class="docs-page-nav">
<div>
<?php if ($prev_label && $prev_url): ?>
<a href="<?= base_url($prev_url) ?>" class="docs-page-nav__link">
<span class="docs-page-nav__dir"> Previous</span>
<span class="docs-page-nav__title"><?= esc($prev_label) ?></span>
</a>
<?php endif; ?>
</div>
<div>
<?php if ($next_label && $next_url): ?>
<a href="<?= base_url($next_url) ?>" class="docs-page-nav__link docs-page-nav__link--right">
<span class="docs-page-nav__dir">Next </span>
<span class="docs-page-nav__title"><?= esc($next_label) ?></span>
</a>
<?php endif; ?>
</div>
</div>
</main><!-- /.docs-main -->
<!-- ═══════════════════════════════════════════
RIGHT TOC
════════════════════════════════════════════ -->
<?php if (!empty($toc)): ?>
<nav class="docs-toc" aria-label="On this page">
<div class="docs-toc__title">On this page</div>
<?php foreach ($toc as $item): ?>
<a
href="<?= esc($item['href']) ?>"
class="<?= (!empty($item['level']) && $item['level'] === 'h3') ? 'is-sub' : '' ?>"
>
<span><?= esc($item['label']) ?></span>
<?php if (!empty($item['tag'])): ?>
<span class="docs-toc__tag"><?= esc($item['tag']) ?></span>
<?php endif; ?>
</a>
<?php endforeach; ?>
</nav>
<?php endif; ?>
</div><!-- /.docs-layout -->
<style>
/* ─── PREV / NEXT NAV ────────────────────── */
.docs-page-nav {
display : flex;
justify-content: space-between;
gap : 16px;
margin-top : 56px;
padding-top : 24px;
border-top : 1px solid var(--border);
}
.docs-page-nav__link {
display : flex;
flex-direction : column;
gap : 4px;
text-decoration: none;
color : var(--text);
padding : 12px 16px;
border : 1px solid var(--border);
border-radius : 8px;
transition : border-color 0.15s, background 0.15s;
min-width : 160px;
}
.docs-page-nav__link:hover {
border-color: var(--accent);
background : var(--accent-bg);
}
.docs-page-nav__link--right { text-align: right; }
.docs-page-nav__dir {
font-size : 12px;
color : var(--muted);
}
.docs-page-nav__title {
font-size : 14px;
font-weight: 500;
color : var(--accent);
}
</style>

View File

@ -0,0 +1,142 @@
<?php
/**
* Docs Main Open Partial
* app/Views/docs/partials/docs_main_open.php
*
* Opens the main content + right TOC wrapper.
* Always pair with docs_main_close.php.
*
* Variables:
* $doc_title (string) page heading (h1)
* $breadcrumb (string) section label, e.g. 'Getting Started'
* $last_updated (string) e.g. 'May 2025'
* $author (string) e.g. 'Core Team'
* $read_time (string) e.g. '5 min read'
* $toc (array) array of ['label', 'href', 'level'(optional), 'tag'(optional)]
* level: 'h3' indents it as a sub-item
*/
$doc_title = $doc_title ?? 'Page Title';
$breadcrumb = $breadcrumb ?? '';
$last_updated = $last_updated ?? '';
$author = $author ?? '';
$read_time = $read_time ?? '';
$toc = $toc ?? [];
?>
<style>
/* ─── MAIN CONTENT ───────────────────────── */
.docs-main {
flex : 1;
max-width: 760px;
padding : 48px 56px 80px;
min-width: 0;
}
.docs-main__breadcrumb {
font-size : 12px;
color : var(--muted);
margin-bottom: 12px;
display : flex;
align-items: center;
gap : 6px;
}
.docs-main__breadcrumb a {
color : var(--muted);
text-decoration: none;
}
.docs-main__breadcrumb a:hover { color: var(--accent); }
.docs-main__meta {
font-size : 13px;
color : var(--muted);
margin-bottom: 32px;
border-bottom: 1px solid var(--border);
padding-bottom: 24px;
display : flex;
flex-wrap : wrap;
gap : 16px;
}
/* ─── RIGHT TOC ──────────────────────────── */
.docs-toc {
width : 200px;
min-width: 200px;
padding : 56px 20px 0;
position : sticky;
top : calc(var(--topbar-h) + 32px);
height : fit-content;
align-self: flex-start;
flex-shrink: 0;
}
.docs-toc__title {
font-size : 11px;
font-weight : 600;
text-transform: uppercase;
letter-spacing: 0.07em;
color : var(--muted);
margin-bottom : 10px;
}
.docs-toc a {
display : flex;
align-items : center;
justify-content: space-between;
gap : 8px;
font-size : 12.5px;
color : var(--muted);
text-decoration: none;
padding : 3px 0;
transition : color 0.1s;
}
.docs-toc a:hover { color: var(--accent); }
.docs-toc a.is-sub { padding-left: 10px; font-size: 12px; }
.docs-toc__tag {
flex-shrink : 0;
font-size : 10px;
line-height : 1;
font-weight : 600;
letter-spacing : 0.04em;
text-transform : uppercase;
color : var(--muted);
background : var(--bg2);
border : 1px solid var(--border);
border-radius : 999px;
padding : 3px 6px;
}
</style>
<!-- ═══════════════════════════════════════════
CONTENT AREA + RIGHT TOC
════════════════════════════════════════════ -->
<main class="docs-main">
<!-- Breadcrumb -->
<?php if ($breadcrumb): ?>
<div class="docs-main__breadcrumb">
<a href="<?= base_url('docs') ?>">Docs</a>
<span></span>
<span><?= esc($breadcrumb) ?></span>
<?php if ($doc_title !== $breadcrumb): ?>
<span></span>
<span><?= esc($doc_title) ?></span>
<?php endif; ?>
</div>
<?php endif; ?>
<!-- Page title -->
<h1><?= esc($doc_title) ?></h1>
<!-- Meta row -->
<div class="docs-main__meta">
<?php if ($last_updated): ?><span>📅 Last updated: <?= esc($last_updated) ?></span><?php endif; ?>
<?php if ($author): ?><span>✍️ <?= esc($author) ?></span><?php endif; ?>
<?php if ($read_time): ?><span>⏱ <?= esc($read_time) ?></span><?php endif; ?>
</div>
<!-- ── PAGE CONTENT GOES BELOW THIS LINE ── -->

View File

@ -0,0 +1,175 @@
<?php
/**
* Docs Sidebar Partial
* app/Views/docs/partials/docs_sidebar.php
*
* Usage:
* <?= view('docs/partials/docs_sidebar', ['active_page' => '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'],
],
],
];
?>
<!-- ═══════════════════════════════════════════
SIDEBAR
════════════════════════════════════════════ -->
<aside class="docs-sidebar">
<?php foreach ($nav as $section): ?>
<span class="docs-sidebar__label">
<?= esc($section['label']) ?>
</span>
<?php foreach ($section['items'] as $item):
$is_active = ($item['id'] === $active_page);
?>
<a
href="<?= base_url($item['url']) ?>"
class="docs-sidebar__link <?= $is_active ? 'is-active' : '' ?>"
>
<span class="docs-sidebar__dot"></span>
<?= esc($item['label']) ?>
</a>
<?php endforeach; ?>
<?php endforeach; ?>
</aside>
<style>
/* ─── SIDEBAR ────────────────────────────── */
.docs-sidebar {
width : var(--sidebar-w);
min-width : var(--sidebar-w);
border-right: 1px solid var(--border);
padding : 24px 0 48px;
position : sticky;
top : var(--topbar-h);
height : calc(100vh - var(--topbar-h));
overflow-y : auto;
background : var(--bg);
flex-shrink: 0;
}
/* Thin scrollbar */
.docs-sidebar::-webkit-scrollbar { width: 4px; }
.docs-sidebar::-webkit-scrollbar-track { background: transparent; }
.docs-sidebar::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
.docs-sidebar__label {
display : block;
font-size : 11px;
font-weight : 600;
letter-spacing: 0.07em;
text-transform: uppercase;
color : var(--muted);
padding : 0 20px 6px;
margin-top : 20px;
}
/* Remove top margin from first label */
.docs-sidebar__label:first-child { margin-top: 0; }
.docs-sidebar__link {
display : flex;
align-items : center;
gap : 8px;
padding : 6px 20px;
font-size : 13.5px;
color : var(--text);
text-decoration: none;
border-left : 2px solid transparent;
transition : background 0.1s, color 0.1s;
}
.docs-sidebar__link:hover {
background: var(--bg2);
color : var(--accent);
}
.docs-sidebar__link.is-active {
background : var(--accent-bg);
color : var(--accent);
font-weight : 500;
border-left-color: var(--accent);
}
.docs-sidebar__dot {
width : 5px;
height : 5px;
border-radius: 50%;
background : var(--border);
flex-shrink : 0;
}
.docs-sidebar__link.is-active .docs-sidebar__dot {
background: var(--accent);
}
</style>

View File

@ -0,0 +1,137 @@
<?php
/**
* Sample docs content
* app/Views/docs/partials/installation.php
*
* Copy only the page content structure from this file when creating a new page.
* The controller renders the shared layout automatically.
*/
?>
<p>
This guide walks you through setting up the project on a local development machine.
For production deployment, see the <a href="<?= base_url('docs/deployment') ?>">Deployment</a> page.
</p>
<div class="callout info">
<span></span>
<div>
<strong>Before you begin</strong>
Make sure PHP 8.1+, Composer 2.x, and MySQL 5.7+ are installed on your machine.
</div>
</div>
<!-- REQUIREMENTS -->
<h2 id="requirements">Requirements</h2>
<table>
<thead>
<tr><th>Dependency</th><th>Version</th><th>Notes</th></tr>
</thead>
<tbody>
<tr><td><code>PHP</code></td><td>8.1+</td><td>Required by CI 4.4+</td></tr>
<tr><td><code>MySQL</code></td><td>5.7 / 8.0</td><td>Primary database</td></tr>
<tr><td><code>Composer</code></td><td>2.x</td><td>Dependency management</td></tr>
<tr><td><code>Node.js</code></td><td>18+ (optional)</td><td>Only needed for asset pipeline</td></tr>
</tbody>
</table>
<!-- STEPS -->
<h2 id="steps">Steps</h2>
<ol class="steps">
<li>
<strong id="clone">Clone the repository</strong>
<div class="code-header">
<span class="code-filename">terminal</span>
<span class="code-lang">bash</span>
</div>
<pre><code class="language-bash">git clone https://github.com/your-org/myapp.git
cd myapp</code></pre>
</li>
<li>
<strong>Install PHP dependencies</strong>
<pre><code class="language-bash">composer install</code></pre>
</li>
<li>
<strong>Copy the environment file</strong>
<pre><code class="language-bash">cp env .env</code></pre>
<p>Edit <code>.env</code> with your local database credentials and base URL.</p>
</li>
<li>
<strong id="migrate">Run migrations and seeders</strong>
<pre><code class="language-bash">php spark migrate
php spark db:seed MainSeeder</code></pre>
</li>
<li>
<strong>Start the dev server</strong>
<pre><code class="language-bash">php spark serve</code></pre>
<p>App will be available at <code>http://localhost:8080</code>.</p>
</li>
</ol>
<div class="callout warning">
<span>⚠️</span>
<div>
<strong>Use 127.0.0.1, not localhost</strong>
MySQL on some setups resolves <code>localhost</code> to a socket path instead of TCP.
Use <code>127.0.0.1</code> in <code>.env</code> to avoid connection errors.
</div>
</div>
<!-- CONFIGURATION -->
<h2 id="configuration">Configuration</h2>
<p>Key variables to configure in <code>.env</code>:</p>
<table>
<thead>
<tr><th>Variable</th><th>Default</th><th>Description</th></tr>
</thead>
<tbody>
<tr>
<td>
<span class="param-name">CI_ENVIRONMENT</span>
<span class="badge req">required</span>
</td>
<td><code>production</code></td>
<td>Set to <code>development</code> locally to enable error display.</td>
</tr>
<tr>
<td>
<span class="param-name">database.default.hostname</span>
<span class="badge req">required</span>
</td>
<td></td>
<td>MySQL hostname. Use <code>127.0.0.1</code>.</td>
</tr>
<tr>
<td>
<span class="param-name">app.baseURL</span>
<span class="badge req">required</span>
</td>
<td></td>
<td>Full URL with trailing slash. e.g. <code>http://localhost:8080/</code></td>
</tr>
<tr>
<td>
<span class="param-name">JWT_SECRET</span>
<span class="badge opt">optional</span>
</td>
<td></td>
<td>Only needed if JWT API auth is enabled.</td>
</tr>
</tbody>
</table>
<div class="callout danger">
<span>🚫</span>
<div>
<strong>Never commit <code>.env</code></strong>
The file is in <code>.gitignore</code>. Use your CI/CD secrets manager for production values.
</div>
</div>

View File

@ -0,0 +1,124 @@
<?php
/**
* Installation content only
* app/Views/docs/installation.php
*
* ⚠️ NO layout partials here. The controller wraps this automatically.
* Just write your h2, p, pre, table, callout blocks.
*
* Available $data variables injected by the controller:
* $title, $breadcrumb, $toc, $last_updated, $author, $read_time, $prev, $next
*/
?>
<p>
This guide walks you through setting up the project on a local development machine.
For production deployment, see the <a href="<?= base_url('docs/deployment') ?>">Deployment</a> page.
</p>
<div class="callout info">
<span></span>
<div>
<strong>Before you begin</strong>
Make sure PHP 8.1+, Composer 2.x, and MySQL 5.7+ are installed on your machine.
</div>
</div>
<!-- REQUIREMENTS -->
<h2 id="requirements">Requirements</h2>
<table>
<thead>
<tr><th>Dependency</th><th>Version</th><th>Notes</th></tr>
</thead>
<tbody>
<tr><td><code>PHP</code></td><td>8.1+</td><td>Required by CI 4.4+</td></tr>
<tr><td><code>MySQL</code></td><td>5.7 / 8.0</td><td>Primary database</td></tr>
<tr><td><code>Composer</code></td><td>2.x</td><td>Dependency management</td></tr>
<tr><td><code>Node.js</code></td><td>18+ (optional)</td><td>Only for asset pipeline</td></tr>
</tbody>
</table>
<!-- STEPS -->
<h2 id="steps">Steps</h2>
<ol class="steps">
<li>
<strong id="clone">Clone the repository</strong>
<div class="code-header">
<span class="code-filename">terminal</span>
<span class="code-lang">bash</span>
</div>
<pre><code class="language-bash">git clone https://github.com/your-org/myapp.git
cd myapp</code></pre>
</li>
<li>
<strong>Install PHP dependencies</strong>
<pre><code class="language-bash">composer install</code></pre>
</li>
<li>
<strong>Copy the environment file</strong>
<pre><code class="language-bash">cp env .env</code></pre>
<p>Edit <code>.env</code> with your local database credentials and base URL.</p>
</li>
<li>
<strong id="migrate">Run migrations and seeders</strong>
<pre><code class="language-bash">php spark migrate
php spark db:seed MainSeeder</code></pre>
</li>
<li>
<strong>Start the dev server</strong>
<pre><code class="language-bash">php spark serve</code></pre>
<p>App will be available at <code>http://localhost:8080</code>.</p>
</li>
</ol>
<div class="callout warning">
<span>⚠️</span>
<div>
<strong>Use 127.0.0.1, not localhost</strong>
MySQL on some setups resolves <code>localhost</code> to a socket path instead of TCP.
Use <code>127.0.0.1</code> in <code>.env</code> to avoid connection errors.
</div>
</div>
<!-- CONFIGURATION -->
<h2 id="configuration">Configuration</h2>
<p>Key variables to configure in <code>.env</code>:</p>
<table>
<thead>
<tr><th>Variable</th><th>Default</th><th>Description</th></tr>
</thead>
<tbody>
<tr>
<td><span class="param-name">CI_ENVIRONMENT</span> <span class="badge req">required</span></td>
<td><code>production</code></td>
<td>Set to <code>development</code> locally for detailed error display.</td>
</tr>
<tr>
<td><span class="param-name">database.default.hostname</span> <span class="badge req">required</span></td>
<td></td>
<td>MySQL hostname. Use <code>127.0.0.1</code>.</td>
</tr>
<tr>
<td><span class="param-name">app.baseURL</span> <span class="badge req">required</span></td>
<td></td>
<td>Full URL with trailing slash. e.g. <code>http://localhost:8080/</code></td>
</tr>
<tr>
<td><span class="param-name">JWT_SECRET</span> <span class="badge opt">optional</span></td>
<td></td>
<td>Only needed if JWT API auth is enabled.</td>
</tr>
</tbody>
</table>
<div class="callout danger">
<span>🚫</span>
<div>
<strong>Never commit <code>.env</code></strong>
The file is in <code>.gitignore</code>. Use your CI/CD secrets manager for production values.
</div>
</div>

View File

@ -0,0 +1,524 @@
<?php
/**
* S3 & CloudFront - content only
* app/Views/docs/s3-cloudfront.php
*/
?>
<p>
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.
</p>
<div class="callout info">
<span>i</span>
<div>
<strong>Dev-focused workflow</strong>
The process below is documented from the Windows batch script currently used
for S3 upload plus CloudFront invalidation in the dev workflow.
</div>
</div>
<h2 id="overview">Overview</h2>
<div class="mermaid-wrapper">
<div class="mermaid">
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]
</div>
</div>
<h2 id="prerequisites">Prerequisites</h2>
<p>
Before running the script, the local machine must already be able to execute
AWS CLI commands successfully.
</p>
<table>
<thead>
<tr><th>Requirement</th><th>Purpose</th></tr>
</thead>
<tbody>
<tr>
<td><code>aws s3 ls</code></td>
<td>Lists available buckets for the operator to choose from.</td>
</tr>
<tr>
<td><code>aws cloudfront list-distributions</code></td>
<td>Lists distributions so the operator can choose the target invalidation.</td>
</tr>
<tr>
<td>AWS credentials already configured</td>
<td>The script assumes AWS CLI authentication is already working.</td>
</tr>
<tr>
<td>Correct working directory</td>
<td><code>aws s3 sync . ...</code> uploads from the current folder, so the script must be run from the directory containing the files to publish.</td>
</tr>
</tbody>
</table>
<h2 id="selection-flow">Selection flow</h2>
<p>
The script begins with two interactive selections:
</p>
<ol class="steps">
<li>
<strong>Select the target S3 bucket</strong>
<p>It runs <code>aws s3 ls</code>, numbers the available buckets, and stores the selected bucket as <code>S3_BUCKET</code>.</p>
</li>
<li>
<strong>Select the target CloudFront distribution</strong>
<p>It runs <code>aws cloudfront list-distributions</code> and shows the distribution ID, domain name, and comment for each available entry.</p>
</li>
<li>
<strong>Confirm before deploy</strong>
<p>The operator can proceed, re-choose the bucket/distribution pair, or exit before any destructive operation starts.</p>
</li>
</ol>
<p>
The current UI-side mapping used for bucket to CloudFront pairing is:
</p>
<table>
<thead>
<tr><th>S3 bucket</th><th>CloudFront distribution ID</th></tr>
</thead>
<tbody>
<tr>
<td><code>uat-benefits-app-bucket</code></td>
<td><code>EUBZ8CDSV9KZZ</code></td>
</tr>
<tr>
<td><code>uat-hr-app-bucket</code></td>
<td><code>E9TNPRI9ITM1M</code></td>
</tr>
<tr>
<td><code>benefits-app-bucket</code></td>
<td><code>E1MKRK4U5MZ3BD</code></td>
</tr>
<tr>
<td><code>live-hr-app-bucket</code></td>
<td><code>E3TE01DPKHTD8B</code></td>
</tr>
</tbody>
</table>
<p>
These pairs are aligned with the bucket-to-distribution mapping currently used
in <code>app/Views/fedeploy.php</code>.
</p>
<div class="callout warning">
<span>!</span>
<div>
<strong>Interactive by design</strong>
This script is not written as a fixed one-click pipeline. It deliberately
pauses for operator selection and confirmation before deployment begins.
</div>
</div>
<h2 id="deployment-steps">Deployment steps</h2>
<p>
Once confirmed, the script executes five sequential steps:
</p>
<table>
<thead>
<tr><th>Step</th><th>What it does</th></tr>
</thead>
<tbody>
<tr>
<td><code>1/5</code></td>
<td>Deletes the existing contents of the selected S3 bucket with <code>aws s3 rm --recursive</code>.</td>
</tr>
<tr>
<td><code>2/5</code></td>
<td>Syncs the current directory into the bucket with <code>aws s3 sync</code>, excluding <code>*.bat</code> and <code>*.bat.*</code>.</td>
</tr>
<tr>
<td><code>3/5</code></td>
<td>Creates a CloudFront invalidation for <code>/*</code> and captures the invalidation ID.</td>
</tr>
<tr>
<td><code>4/5</code></td>
<td>Waits 45 seconds before the first status check.</td>
</tr>
<tr>
<td><code>5/5</code></td>
<td>Polls invalidation status until it becomes <code>Completed</code> or retries are exhausted.</td>
</tr>
</tbody>
</table>
<pre><code class="language-batch">aws s3 rm "%S3_BUCKET%" --recursive
aws s3 sync . "%S3_BUCKET%" --exclude "*.bat" --exclude "*.bat.*"
aws cloudfront create-invalidation --distribution-id %DIST_ID% --paths "/*"</code></pre>
<div class="callout danger">
<span>!</span>
<div>
<strong>Bucket cleanup is destructive</strong>
The script removes existing files from the selected S3 bucket before syncing
the new content. Confirm the selected bucket carefully before proceeding.
</div>
</div>
<h2 id="invalidation-polling">Invalidation polling</h2>
<p>
The script includes explicit status handling for CloudFront invalidation:
</p>
<table>
<thead>
<tr><th>Setting</th><th>Value</th><th>Purpose</th></tr>
</thead>
<tbody>
<tr>
<td><code>WAIT_SECONDS</code></td>
<td><code>45</code></td>
<td>Initial wait before the first invalidation status check.</td>
</tr>
<tr>
<td><code>MAX_STATUS_RETRIES</code></td>
<td><code>20</code></td>
<td>Upper bound for repeated status polling attempts.</td>
</tr>
<tr>
<td>Retry delay</td>
<td><code>15</code> seconds</td>
<td>Pause between repeated invalidation status checks.</td>
</tr>
</tbody>
</table>
<p>
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
<code>Completed</code>, the deployment is treated as successful.
</p>
<pre><code class="language-text">Completed
InProgress
</code></pre>
<h2 id="operational-notes">Operational notes</h2>
<ol class="steps">
<li>
<strong>Run from the correct publish directory</strong>
<p>The script syncs the current directory, so it should be started only from the folder whose contents should go to S3.</p>
</li>
<li>
<strong>Verify the chosen bucket and distribution before confirming</strong>
<p>The confirmation step exists to prevent publishing to the wrong S3 bucket or invalidating the wrong distribution.</p>
</li>
<li>
<strong>Keep batch files out of the published output</strong>
<p>The script explicitly excludes batch files during sync so deployment helpers are not uploaded into the target bucket.</p>
</li>
<li>
<strong>Watch for invalidation completion</strong>
<p>The script does not finish immediately after creating the invalidation; it waits and polls until the status is complete or retries are exhausted.</p>
</li>
</ol>
<div class="callout success">
<span>+</span>
<div>
<strong>Practical use</strong>
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.
</div>
</div>
<h2 id="full-script">Full script</h2>
<p>
Full reference copy of the current Windows batch script:
</p>
<pre><code class="language-batch">@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</code></pre>

View File

@ -0,0 +1,308 @@
<?php
/**
* TPA Recon content only
* app/Views/docs/tpa-recon.php
*
* Mirrors EmployeeController + EmployeePolicyModel + EmployeeServiceController behaviour.
*/
?>
<h2 id="what-this-is">What this is</h2>
<p>
<strong>TPA Recon</strong> (reconciliation) compares <strong>Nhance</strong> enrolment data with a
<strong>TPA API dump</strong> stored in <code>tpa_api_data</code> 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.
</p>
<p>
The flow is intentionally split across <code>EmployeeController</code> (report, proceed, heavy logic),
<code>EmployeePolicyModel::getTPADataVariationReport</code> (SQL slices), and
<code>EmployeeServiceController</code> (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.
</p>
<h2 id="glossary">Glossary</h2>
<table>
<thead>
<tr><th>Term</th><th>Meaning here</th></tr>
</thead>
<tbody>
<tr>
<td><code>batch_files.id</code> (often called <code>file_id</code> in code)</td>
<td>The batch row for the TPA import / variation context. <code>tpa_api_data.file_id</code> points at the same id.</td>
</tr>
<tr>
<td><code>tpa_api_data</code></td>
<td>One row per TPA member line for that file: <code>emp_code</code>, <code>name</code>, <code>dob</code>, <code>gender</code>, <code>relation</code>, optional <code>ref</code> (FK to <code>employee_polices.id</code>), <code>rec_type</code>, <code>action_flag_status</code> (e.g. <code>D</code> for deletion intent), <code>is_active</code>.</td>
</tr>
<tr>
<td><code>rec_type</code></td>
<td>Snapshot classification on <code>tpa_api_data</code>: <code>matched</code>, <code>need_to_review</code>, or <code>not_in_nhance</code>. Used to speed up repeat UI loads after the first full compute.</td>
</tr>
<tr>
<td><code>ref</code></td>
<td>When set, links a TPA row to <code>employee_polices.id</code> after strict matching in <code>reconTpaApiDataWithEmployeepolicies</code>.</td>
</tr>
<tr>
<td>Not in TPA</td>
<td>Nhance has an active policy member for the client/policy, but no TPA row with that <code>emp_code</code> in the dump.</td>
</tr>
<tr>
<td>Not in Nhance</td>
<td>TPA has an <code>emp_code</code> not present in the Nhance “master” list for that client/policy (see model method with <code>$all = true</code>).</td>
</tr>
<tr>
<td>Need to review / mismatch</td>
<td>Same <code>emp_code</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.</td>
</tr>
</tbody>
</table>
<h2 id="key-files-routes">Key files and routes</h2>
<ul>
<li><strong>Controller:</strong> <code>app/Controllers/EmployeeController.php</code>
<ul>
<li><code>getTPADataVariationReport($file_id, $type)</code></li>
<li><code>proceedTPADataVariationNextStep($file_id)</code></li>
<li><code>generateEmployeeUploadFromNotInNhance(...)</code> (protected)</li>
<li><code>initializeDeletionProcessForTpaApiData($file_id)</code> expects <code>['file_id' =&gt; batch_file_id]</code></li>
<li>Helpers: <code>reconcileDbWithTpa</code>, <code>reconTpaApiDataWithEmployeepolicies</code>, <code>updateEmployeeDataFromTpa</code>, <code>exportVariationReportExcel</code></li>
</ul>
</li>
<li><strong>Model slice:</strong> <code>app/Models/EmployeePolicyModel.php</code> <code>getTPADataVariationReport($client_id, $client_policy_id, $file_id, $emp_codes = [], $all = false)</code></li>
<li><strong>Excel / jobs:</strong> <code>app/Controllers/EmployeeServiceController.php</code> (inception, correction, disembark; queues jobs listed below)</li>
<li><strong>Job routing:</strong> <code>app/Controllers/JobWorker.php</code> maps job names to <code>EmployeeController</code> handlers</li>
<li><strong>Routes</strong> (employee group in <code>app/Config/Routes.php</code>):
<ul>
<li><code>GET employee/getTPADataVariationReport/(:num)</code> default second segment resolves to download-style behaviour</li>
<li><code>GET employee/getTPADataVariationReportView/(:num)</code> same action with <code>view</code> type (JSON for UI)</li>
<li><code>GET employee/proceedTPADataVariationNextStep/(:num)</code> query string <code>?tab=...</code> (see Proceed section)</li>
</ul>
</li>
<li><strong>UI:</strong> <code>app/Views/batch_list.php</code> download / view variation report links call the routes above</li>
</ul>
<h2 id="end-to-end-flow">End-to-end flow</h2>
<div class="mermaid-wrapper">
<div class="mermaid">
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]
</div>
</div>
<p>
“Proceed” runs immediate actions; ref sync and deletion initialization also run as <strong>queued jobs</strong>
after inception or correction Excel workflows complete in <code>EmployeeServiceController</code>.
</p>
<h2 id="variation-report">Variation report <code>getTPADataVariationReport</code></h2>
<p><strong>Inputs:</strong> <code>$file_id</code> (batch file id), <code>$type</code>:</p>
<ul>
<li><code>view</code> JSON API response with structured <code>data</code> (or empty array if nothing).</li>
<li><code>download</code> streams Excel via <code>exportVariationReportExcel</code> (three sheets).</li>
</ul>
<p>
The controller loads <code>batch_files</code> for <code>client_id</code>, <code>client_policy_id</code>, then decides
<strong>compute</strong> vs <strong>cached</strong> mode:
</p>
<ul>
<li><strong>Compute</strong> if there is no existing <code>rec_type</code> snapshot on active <code>tpa_api_data</code> for this file (non-empty <code>rec_type</code> on any row).</li>
<li><strong>Cached</strong> if a snapshot already exists avoids rewriting <code>rec_type</code> on every page load.</li>
</ul>
<p>
<strong>Compute path high level:</strong>
</p>
<ol>
<li>Load all active <code>tpa_api_data</code> for <code>file_id</code>; index by <code>emp_code</code>.</li>
<li>Initialize every TPA row id to <code>rec_type = matched</code> as a default.</li>
<li>Load Nhance rows from <code>EmployeePolicyModel::getTPADataVariationReport($client_id, $client_policy_id, $file_id)</code> (default branch: members with <code>tpa_id</code> null the “not yet linked / review” slice).</li>
<li>For each Nhance row, run <code>reconcileDbWithTpa</code> (see next section). Update <code>rec_type</code> on the matched TPA id(s) accordingly.</li>
<li>Load master <code>emp_code</code> list with <code>getTPADataVariationReport(..., [], true)</code>. Any TPA row whose code is not in that list <code>not_in_nhance</code>.</li>
<li><code>updateBatch</code> all <code>rec_type</code> values in a transaction.</li>
<li>Call <code>reconTpaApiDataWithEmployeepolicies(['file_id' =&gt; $file_id])</code> to populate <code>ref</code> where possible.</li>
</ol>
<p>
<strong>Cached path:</strong> Reads <code>not_in_nhance</code> and <code>need_to_review</code> rows from <code>tpa_api_data</code> by <code>rec_type</code>,
then rebuilds <code>mismatch_data</code> for the UI without re-running the full reconciliation loop.
</p>
<p>
<strong><code>not_in_tpa</code> (always “live”):</strong> Built from TPA emp_codes for the file and
<code>getTPADataVariationReport(..., $tpa_emp_codes)</code> Nhance members whose <code>emp_code</code> is not in the TPA set.
This list is <strong>not</strong> driven from the <code>rec_type</code> snapshot by design.
</p>
<p>
The response also includes counts and button flags for the “Not in Nhance” proceed action (inception vs
deletion tallies based on empty <code>ref</code> and <code>action_flag_status === 'D'</code>).
</p>
<h2 id="classifying-rec-type">Classifying <code>rec_type</code> <code>reconcileDbWithTpa</code></h2>
<p>
Given one Nhance row (<code>$db</code>) and all TPA rows for the same <code>emp_code</code> (<code>$tpaRows</code>), the controller walks TPA rows in order:
</p>
<ul>
<li><strong>Relation gate:</strong> <code>strtolower($db['relationship']) === strtolower($tpa['relation'])</code>. If it does not match, that TPA row is skipped.</li>
<li><strong>First passing row wins.</strong> Compare <code>name</code>, <code>dob</code>, <code>gender</code> (case-insensitive for gender).</li>
<li>Return <code>status = matched</code> with <code>tpa_record</code> and <code>not_matching</code> as the list of differing field names (may be empty = perfect match).</li>
<li>If no TPA row passes the relation gate <code>status = no_match</code>.</li>
</ul>
<p>
The report layer uses that result to set <code>rec_type</code> on TPA ids: perfect match <code>matched</code>;
matched with diffs <code>need_to_review</code>; no relation-level match mark candidate TPA rows for that
<code>emp_code</code> as <code>need_to_review</code>.
</p>
<h2 id="linking-ref-column">Linking <code>ref</code> <code>reconTpaApiDataWithEmployeepolicies</code></h2>
<p>
Parameter shape: <code>['file_id' =&gt; $batchFileId]</code> (same id as the TPA batch).
</p>
<ul>
<li>Loads active <code>tpa_api_data</code> for the file. Rows that already have a non-empty <code>ref</code> are skipped.</li>
<li>Builds Nhance candidates: active <code>employee_polices</code> joined to <code>employees</code> for the same <code>client_id</code> / <code>client_policy_id</code>, keyed by <code>emp_code</code>.</li>
<li>For each TPA row still needing <code>ref</code>, finds a candidate where <strong>all</strong> match exactly after normalization:
name, relationship vs relation, dob, gender.</li>
<li>Batch-updates <code>tpa_api_data.ref</code> to the chosen <code>employee_policies.id</code> inside a transaction.</li>
</ul>
<p>
This is stricter than <code>reconcileDbWithTpa</code> (which only compares three fields after relation match)
because it must pick a single policy row to link.
</p>
<h2 id="proceed-next-step">Proceed next step <code>proceedTPADataVariationNextStep</code></h2>
<p><strong>Route:</strong> <code>GET employee/proceedTPADataVariationNextStep/{file_id}?tab=...</code></p>
<table>
<thead>
<tr><th><code>tab</code></th><th>Behaviour</th></tr>
</thead>
<tbody>
<tr>
<td><code>not_in_nhance</code></td>
<td>Calls <code>generateEmployeeUploadFromNotInNhance</code>. On success, an inception-style file exists in <code>files</code> and format validation has run. Follow-up ref/deletion jobs are not invoked inline here (see background jobs).</td>
</tr>
<tr>
<td><code>need_to_review</code></td>
<td>Calls <code>updateEmployeeDataFromTpa(['batch_file_id' =&gt; (int) $file_id])</code>. This applies TPA-sourced values onto <code>employees</code> for reconciled mismatches (no correction Excel in this path). The handler checks <code>$generationResult['success']</code> (not <code>status</code>).</td>
</tr>
<tr>
<td>other / missing</td>
<td>Still logs “proceed” and returns a generic success message.</td>
</tr>
</tbody>
</table>
<div class="callout info">
<span>i</span>
<div>
<strong>Fix applied:</strong> The <code>need_to_review</code> branch previously passed the wrong parameter shape
to <code>updateEmployeeDataFromTpa</code> and read a non-existent <code>status</code> key. It now passes
<code>batch_file_id</code> and honours <code>success</code>, matching how queued jobs and QA utilities call the same method.
</div>
</div>
<h2 id="not-in-nhance-inception">Not in Nhance inception file <code>generateEmployeeUploadFromNotInNhance</code></h2>
<p><strong>Purpose:</strong> Turn TPA-only members into an <strong>Employee Upload with Events</strong> Excel so the normal onboarding pipeline can create them in Nhance.</p>
<ol>
<li>Validate <code>client_id</code>, <code>client_policy_id</code>, <code>client_branch_id</code> on the batch file.</li>
<li>Master codes = <code>getTPADataVariationReport(..., [], true)</code> <code>emp_code</code> list.</li>
<li>Select active <code>tpa_api_data</code> for this <code>file_id</code> whose <code>emp_code</code> is <strong>not</strong> in the master list.</li>
<li>Build headers from <code>EmployeeServiceController::getInceptionExcelColumns()</code>; map each TPA row (relation synonyms, <code>change_event</code> = <code>addition</code>, dates as <code>d-M-Y</code>, etc.).</li>
<li>Save XLSX under <code>WRITEPATH/uploads/excel/</code>, insert <code>files</code> row (<code>action = addition</code>, <code>status = inprogress</code>).</li>
<li>Run <code>excelFileFormatValidation</code> with <code>['file_id' =&gt; newFileId, 'batch_file_id' =&gt; batchFileId]</code>.</li>
</ol>
<h2 id="need-to-review-sync">Need to review DB sync <code>updateEmployeeDataFromTpa</code></h2>
<p>
Expects <code>['batch_file_id' =&gt; int]</code>. Loads the same Nhance slice as the report, loads TPA rows per
<code>emp_code</code>, reuses <code>reconcileDbWithTpa</code>. When status is <code>matched</code> and <code>not_matching</code> is non-empty,
writes allowed fields on <code>employees</code> (name, dob, gender, relationship from TPA relation, corporate email when present).
Returns <code>success</code>, <code>message</code>, and counts in <code>data</code>.
</p>
<h2 id="deletion-initialization">Deletion initialization <code>initializeDeletionProcessForTpaApiData</code></h2>
<p>
Parameter: <code>['file_id' =&gt; $batchFileId]</code> (same batch / TPA file id).
</p>
<p><strong>Business logic (as implemented):</strong></p>
<ol>
<li>Resolve the batch file; require client, policy, and branch.</li>
<li>From <code>tpa_api_data</code>, select distinct non-empty <code>ref</code> values where <code>file_id</code> matches, rows are active, and <code>action_flag_status = 'D'</code> (deletion intent from TPA).</li>
<li>Those <code>ref</code> values are <code>employee_polices.id</code> values already linked to TPA.</li>
<li>Load <strong>active Nhance members</strong> in the same client/policy scope whose <code>employee_polices.id</code> is <strong>in that set</strong> (<code>whereIn</code> on policy id). These are the rows that will appear on the generated deletion sheet.</li>
<li>Build a deletion-format <code>.xls</code>, create a <code>files</code> row with <code>action = deletion</code>, run <code>EmployeeServiceController::employeeDisembark</code> to create endorsements from the sheet.</li>
<li>Build a separate import-format workbook from export helpers, create a <code>batch_files</code> row, run <code>EmpDataServiceController::importDeletionValidation</code> for the batch import path.</li>
</ol>
<p>
A commented <code>rec_type = matched</code> filter exists in the query; it is intentionally not applied do not assume
<code>rec_type</code> gates deletion eligibility unless you change the code deliberately.
</p>
<h2 id="background-jobs-chain">Background jobs chain</h2>
<p>
After a successful <strong>inception-style</strong> onboarding from grouped family data
(<code>employeesOnboardProcess</code> path in <code>EmployeeServiceController</code>) when <code>batch_file_id</code> is present in params,
three jobs are enqueued in order:
</p>
<ol>
<li><code>updateEmployeeDataFromTpa</code> payload includes <code>file_id</code> (newly created processing file where applicable) and <code>batch_file_id</code></li>
<li><code>reconTpaApiDataWithEmployeepolicies</code> payload <code>['file_id' =&gt; batch_file_id]</code></li>
<li><code>initializeDeletionProcessForTpaApiData</code> payload <code>['file_id' =&gt; batch_file_id]</code></li>
</ol>
<p>
A similar trio is queued after <code>employeesCorrectionProcess</code> when <code>batch_file_id</code> is passed. This is how
ref linking and deletion initialization catch up <strong>after</strong> Excel-driven workflows finish, even when the
“Proceed” controller path does not call them inline.
</p>
<h2 id="new-developer-checklist">New developer checklist</h2>
<ol>
<li>Identify the <strong>batch file id</strong> you are debugging; confirm matching rows exist in <code>tpa_api_data</code> with the same <code>file_id</code>.</li>
<li>Open <strong>Variation report</strong> in view mode first inspect <code>not_in_tpa</code>, <code>not_in_nhance</code>, <code>mismatch_data</code> separately.</li>
<li>If <code>rec_type</code> looks stale, remember the controller only recomputes when no snapshot exists unless you clear or adjust <code>rec_type</code> in DB (there is no public “job” route in production docs).</li>
<li>When changing matching rules, update <strong>both</strong> <code>reconcileDbWithTpa</code> and <code>reconTpaApiDataWithEmployeepolicies</code> if they must stay aligned, or document intentional differences.</li>
<li>Before testing deletion flows on real clients, trace <code>action_flag_status</code> and <code>ref</code> on TPA rows deletion candidates are rows explicitly flagged <code>D</code> with a populated <code>ref</code>.</li>
<li>Watch <code>myLogger</code> entries prefixed with <code>TPA</code> / <code>TPA RECON</code> for operational breadcrumbs.</li>
</ol>
<p>
For QA-only utilities (guarded in production), see internal notes such as
<code>public/dev_logs/2026-04-03.md</code> for <code>updateEmployeeDataFromTpa</code> and related routes.
</p>

View File

@ -0,0 +1,249 @@
<?php
/**
* Visit offboard - content only
* app/Views/docs/visit-offboard.php
*
* Based on:
* - app/Controllers/EmployeeController.php (visitOffBoard, updateVisitoffboardStatus)
* - app/Controllers/EmpDataServiceController.php (deletion / endorsement job enqueue)
* - app/Controllers/JobWorker.php
* - app/Config/Routes.php, app/Config/Acl.php
*/
?>
<p>
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 <code>200</code> and a JSON body where <code>message</code> is
<code>success</code>, Nhance appends <code>_DEL</code> to
<code>employee_polices.wellness_onboard</code> for the affected rows.
</p>
<div class="callout info">
<span>i</span>
<div>
<strong>Core files</strong>
API and DB update logic live in
<code>app/Controllers/EmployeeController.php</code>
(<code>visitOffBoard()</code>, <code>updateVisitoffboardStatus()</code>).
The insurer deletion flow <code>importDeletionUpdateEndorsementID()</code>
enqueues the job from
<code>app/Controllers/EmpDataServiceController.php</code>. The worker maps
the job name in <code>app/Controllers/JobWorker.php</code>.
</div>
</div>
<h2 id="overview">Overview</h2>
<div class="mermaid-wrapper">
<div class="mermaid">
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]
</div>
</div>
<h2 id="when-it-is-queued">When it is queued</h2>
<p>
After <code>importDeletionUpdateEndorsementID()</code> applies policy and
endorsement updates from the deletion Excel, when
<code>$file['insurer_or_tpa'] == 'insurer'</code>, the controller enqueues
<code>visitOffBoard</code> alongside cash-deposit and BDS jobs. The payload
carries the list of <code>employee_polices.id</code> values processed in that
batch, the client policy number, and a fixed source string.
</p>
<pre><code class="language-php">$r = Jobs::addJob(['job_name' => 'visitOffBoard', 'payload' => [
'memberIds' => $employee_policy_table_primaryKey ?? [],
'policyNumber' => $policy_name['policy_no'] ?? null,
'source' => 'NHANCE',
]]);</code></pre>
<table>
<thead>
<tr><th>Payload key</th><th>Meaning</th></tr>
</thead>
<tbody>
<tr>
<td><code>memberIds</code></td>
<td>Array of <code>employee_polices.id</code> primary keys collected from the deletion Excel flow (<code>emp_policy_primarykey</code> per row).</td>
</tr>
<tr>
<td><code>policyNumber</code></td>
<td>Policy number from <code>getPolicyNameUsingClientPolicyId()</code> for the batch client policy.</td>
</tr>
<tr>
<td><code>source</code></td>
<td>Always <code>NHANCE</code> in the enqueue; <code>visitOffBoard()</code> also forces <code>source</code> to <code>NHANCE</code> before the HTTP call.</td>
</tr>
</tbody>
</table>
<pre><code class="language-php">'visitOffBoard' => [
'type' => 'CC',
'handler' => 'App\Controllers\EmployeeController',
],</code></pre>
<h2 id="visit-off-board-api">visitOffBoard()</h2>
<p>
The handler builds the Visit URL from environment configuration, appends the
path <code>delete-policy-with-dependents</code>, and POSTs JSON with
<code>memberIds</code>, <code>policyNumber</code>, and <code>source</code>.
Authorization uses a <code>JWT</code> prefix (this differs from the Visit
onboard upload path, which uses Basic auth in
<code>sendFamiliesToWellnessApi()</code>).
</p>
<pre><code class="language-php">$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,
]);</code></pre>
<table>
<thead>
<tr><th>Outcome</th><th>Behavior</th></tr>
</thead>
<tbody>
<tr>
<td>HTTP status <code>200</code></td>
<td>Decodes JSON, calls <code>updateVisitoffboardStatus()</code> with <code>memberIds</code> and <code>api_result</code>, returns that array shape to the worker.</td>
</tr>
<tr>
<td>Other HTTP status</td>
<td>Logs failure; returns a structured error array; DB is not updated here.</td>
</tr>
<tr>
<td>Network or client exception</td>
<td>Logs exception; returns error array with <code>error</code> message.</td>
</tr>
</tbody>
</table>
<h2 id="update-status">updateVisitoffboardStatus()</h2>
<p>
This method is only invoked from <code>visitOffBoard()</code> after a
<code>200</code> response. It validates the payload, requires
<code>$data['api_result']['message'] === 'success'</code>, then updates all
listed employee policy rows in one statement.
</p>
<ol class="steps">
<li>
<strong>Validate structure</strong>
<p>Empty or non-array input logs and returns <code>false</code>.</p>
</li>
<li>
<strong>Require API result</strong>
<p>Missing <code>api_result</code> logs and returns <code>false</code>.</p>
</li>
<li>
<strong>Require success message</strong>
<p>If <code>message</code> is not exactly <code>success</code>, logs and returns <code>false</code> (no DB update).</p>
</li>
<li>
<strong>Optional warning for missingMemberIds</strong>
<p>If the API returns <code>missingMemberIds</code>, it is logged but processing continues.</p>
</li>
<li>
<strong>Append offboard marker</strong>
<p>Runs <code>whereIn('id', $memberIds)</code> and sets <code>wellness_onboard</code> to <code>CONCAT(wellness_onboard, '_DEL')</code> so existing reference ids stay traceable.</p>
</li>
</ol>
<pre><code class="language-php">$this->employeePolicyModel
->whereIn('id', $memberIds)
->set('wellness_onboard', "CONCAT(wellness_onboard, '_DEL')", false)
->update();</code></pre>
<div class="callout warning">
<span>!</span>
<div>
<strong>HTTP 200 alone is not enough</strong>
The DB update runs only when the decoded body has
<code>message === 'success'</code>. A 200 with a failed business payload will
not append <code>_DEL</code>.
</div>
</div>
<h2 id="manual-test-route">Manual test route</h2>
<p>
<code>visitOffBoardCheck()</code> is a thin admin helper that builds a hardcoded
sample <code>$params</code> array and prints <code>visitOffBoard($params)</code>.
It is not part of the production deletion pipeline; use it only for targeted
debugging in non-production environments.
</p>
<pre><code class="language-php">$routes->get('/visitOffBoardCheck', 'EmployeeController::visitOffBoardCheck');</code></pre>
<p>
ACL restricts this path to admin role in <code>app/Config/Acl.php</code>
(<code>#^/visitOffBoardCheck#</code>).
</p>
<h2 id="developer-steps">Developer steps</h2>
<ol class="steps">
<li>
<strong>Keep payload keys aligned with the Visit API contract</strong>
<p>The job must supply <code>memberIds</code>, <code>policyNumber</code>, and <code>source</code> in the shape the delete endpoint expects.</p>
</li>
<li>
<strong>Confirm environment variables</strong>
<p><code>WELLNESS_ONBOARD_ENDPOINT_URL</code> must include the correct base (trailing slash behavior matters when concatenating <code>delete-policy-with-dependents</code>). <code>WELLNESS_ONBOARD_AUTHORIZATION</code> must be the token value expected after <code>JWT </code>.</p>
</li>
<li>
<strong>Do not call <code>updateVisitoffboardStatus()</code> directly for partial failures</strong>
<p>It trusts <code>api_result['message']</code>; wire new callers through <code>visitOffBoard()</code> or replicate its guards.</p>
</li>
<li>
<strong>Queue dependency</strong>
<p>As with other jobs, the worker must be running; otherwise the offboard job stays queued after deletion processing.</p>
</li>
</ol>
<h2 id="common-pitfalls">Common pitfalls</h2>
<table>
<thead>
<tr><th>Pitfall</th><th>Why it happens</th></tr>
</thead>
<tbody>
<tr>
<td><code>wellness_onboard</code> never gets <code>_DEL</code></td>
<td>HTTP not 200, or JSON <code>message</code> is not <code>success</code>, or <code>memberIds</code> empty / wrong type.</td>
</tr>
<tr>
<td>Visit receives wrong identifiers</td>
<td><code>memberIds</code> must match what the delete API expects (same identifiers family as onboard where applicable).</td>
</tr>
<tr>
<td>Auth works for onboard but not offboard</td>
<td>Offboard uses <code>JWT</code> header construction; onboard upload uses <code>Basic</code> in a different helper.</td>
</tr>
<tr>
<td>Job never enqueues</td>
<td>The enqueue block runs only when <code>insurer_or_tpa == 'insurer'</code> on that deletion file path.</td>
</tr>
</tbody>
</table>

View File

@ -0,0 +1,550 @@
<?php
/**
* Visit onboard - content only
* app/Views/docs/visit-onboard.php
*
* Based on:
* - app/Controllers/EmployeeController.php
* - app/Controllers/JobWorker.php
* - app/Config/Routes.php
* - app/Views/employee_upload.php
*/
?>
<p>
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
<code>employee_polices</code> rows, groups each family by
<code>emp_code</code>, sends one payload per family to the external Visit
API, and stores the returned <code>referenceId</code> back into
<code>employee_polices.wellness_onboard</code>.
</p>
<div class="callout info">
<span>i</span>
<div>
<strong>Core files</strong>
The status check, queue trigger, payload builder, API call, and DB update
logic all live in <code>app/Controllers/EmployeeController.php</code>. The
queue worker entry is registered in <code>app/Controllers/JobWorker.php</code>.
</div>
</div>
<h2 id="overview">Overview</h2>
<div class="mermaid-wrapper">
<div class="mermaid">
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]
</div>
</div>
<h2 id="prerequisites">Prerequisites</h2>
<div class="callout warning">
<span>!</span>
<div>
<strong>A valid Visit plan ID must be mapped to the client policy in the policy edit page before using Visit onboard.</strong>
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.
</div>
</div>
<p>
Before testing or using this feature, confirm that the selected client policy
has a valid <code>wellness_plan_id</code> configured in the policy edit screen.
This mapping is one of the core prerequisites checked by the current query.
</p>
<h2 id="entry-points">Entry points</h2>
<p>
The current implementation is split across two browser-facing routes and one
queued job handler:
</p>
<table>
<thead>
<tr><th>Entry point</th><th>Current responsibility</th></tr>
</thead>
<tbody>
<tr>
<td><code>GET checkWellnessOnboardStatus/{client_policy_id}</code></td>
<td>Counts eligible member rows and returns the number in <code>response.data</code>.</td>
</tr>
<tr>
<td><code>GET initiateWellnessOnboard/{client_policy_id}</code></td>
<td>Queues the background job and immediately returns <code>Process started</code>.</td>
</tr>
<tr>
<td><code>initiateWellnessOnboardJob($arr)</code></td>
<td>Processes one page, sends family payloads to the Visit API, persists results, and queues the next page if needed.</td>
</tr>
</tbody>
</table>
<pre><code class="language-php">$routes->get("checkWellnessOnboardStatus/(:any)", "EmployeeController::checkWellnessOnboardStatus/$1");
$routes->get("initiateWellnessOnboard/(:any)", "EmployeeController::initiateWellnessOnboard/$1");
'initiateWellnessOnboardJob' => [
'type' => 'CC',
'handler' => 'App\Controllers\EmployeeController',
],</code></pre>
<p>
On the admin page, <code>app/Views/employee_upload.php</code> 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.
</p>
<div class="callout warning">
<span>!</span>
<div>
<strong>Current trigger style</strong>
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 <code>Routes.php</code>, the frontend AJAX calls, and any ACL
expectations together.
</div>
</div>
<h2 id="eligibility-rules">Eligibility rules</h2>
<p>
Both <code>checkWellnessOnboardStatus()</code> and
<code>initiateWellnessOnboardJob()</code> use nearly the same base query. A
member is considered eligible only when all of these conditions are true:
</p>
<table>
<thead>
<tr><th>Condition</th><th>Meaning in current code</th></tr>
</thead>
<tbody>
<tr>
<td><code>employee_polices.client_policy_id = {selected id}</code></td>
<td>The job is always scoped to one chosen client policy.</td>
</tr>
<tr>
<td><code>employee_polices.is_active = 1</code></td>
<td>Only active employee policy rows are considered.</td>
</tr>
<tr>
<td><code>employee_polices.status = 'active'</code></td>
<td>Inactive policy-members are excluded.</td>
</tr>
<tr>
<td><code>employee_polices.wellness_onboard = '0'</code></td>
<td>Already onboarded rows are skipped because this column later stores the Visit <code>referenceId</code>.</td>
</tr>
<tr>
<td><code>employees.emp_status = 'active'</code> and <code>employees.is_active = 1</code></td>
<td>Only active employees/dependants are sent.</td>
</tr>
<tr>
<td><code>cp.wellness_plan_id</code> is present</td>
<td>The selected policy must have a wellness plan configured.</td>
</tr>
<tr>
<td><code>cp.wellness_vendor_id</code> is null, empty, or <code>0</code></td>
<td>This is how the current code filters policies for this flow today.</td>
</tr>
<tr>
<td><code>cp.policy_status = 1</code> and <code>cp.is_active = 1</code></td>
<td>The client policy itself must be active.</td>
</tr>
</tbody>
</table>
<p>
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.
</p>
<h2 id="async-flow">Async flow</h2>
<ol class="steps">
<li>
<strong>Check the pending count</strong>
<p><code>checkWellnessOnboardStatus($client_policy_id)</code> runs the eligibility query and returns <code>count($data)</code>.</p>
</li>
<li>
<strong>Queue the first job</strong>
<p><code>initiateWellnessOnboard($client_policy_id)</code> inserts a job with the name <code>initiateWellnessOnboardJob</code> and payload <code>['client_policy_id' =&gt; ...]</code>.</p>
</li>
<li>
<strong>Worker resolves the handler</strong>
<p><code>JobWorker::$event_class_mapping</code> maps that job name back to <code>EmployeeController</code>.</p>
</li>
<li>
<strong>Process one page of rows</strong>
<p>The job defaults to <code>page = 1</code>, <code>per_page = 50</code>, and calculates the SQL offset from those values.</p>
</li>
<li>
<strong>Group the current page by family</strong>
<p>Rows are grouped by <code>emp_code</code> before building API payloads.</p>
</li>
<li>
<strong>Send to Visit and persist the response</strong>
<p>The job posts each family payload, then stores the returned <code>referenceId</code> into all member rows for that family.</p>
</li>
<li>
<strong>Queue the next page only when needed</strong>
<p>If the current query returns exactly <code>per_page</code> rows, the job assumes more data may exist and queues <code>page + 1</code>.</p>
</li>
</ol>
<pre><code class="language-php">Jobs::addJob([
'job_name' => 'initiateWellnessOnboardJob',
'payload' => [
'client_policy_id' => $client_policy_id,
'page' => $page + 1,
'per_page' => $perPage,
]
]);</code></pre>
<p>
The legacy method <code>initiateWellnessOnboardJobOLD()</code> still exists in
the controller, but the active queue mapping points to the current
<code>initiateWellnessOnboardJob()</code> implementation.
</p>
<h2 id="family-payload">Family payload</h2>
<p>
The job builds one outbound payload per <code>emp_code</code>. The first row in
the family is used as the policy-level reference, and every family member
becomes one entry in <code>memberDetails</code>.
</p>
<table>
<thead>
<tr><th>Outbound field</th><th>Source in current code</th></tr>
</thead>
<tbody>
<tr><td><code>policyDetails.policyNumber</code></td><td><code>cp.policy_no</code></td></tr>
<tr><td><code>policyDetails.employeeId</code></td><td><code>emp_code</code></td></tr>
<tr><td><code>policyDetails.policyName</code></td><td>Hardcoded <code>GMC</code></td></tr>
<tr><td><code>policyDetails.policyStartDate</code></td><td><code>cp.policy_start_date</code></td></tr>
<tr><td><code>policyDetails.policyEndDate</code></td><td><code>cp.policy_end_date</code></td></tr>
<tr><td><code>policyDetails.plan</code></td><td><code>cp.wellness_plan_id</code></td></tr>
<tr><td><code>policyDetails.source</code></td><td>Hardcoded <code>NHANCE</code></td></tr>
<tr><td><code>policyDetails.employer</code></td><td><code>clients.short_name</code></td></tr>
<tr><td><code>memberDetails[].memberId</code></td><td><code>employee_polices.id</code></td></tr>
<tr><td><code>memberDetails[].relationshipName</code></td><td>Mapped by <code>mapRelationship()</code></td></tr>
<tr><td><code>memberDetails[].gender</code></td><td><code>M =&gt; Male</code>, otherwise <code>Female</code></td></tr>
</tbody>
</table>
<pre><code class="language-php">[
'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'],
],
],
]</code></pre>
<div class="callout warning">
<span>!</span>
<div>
<strong>Important mapping rule</strong>
<code>mapRelationship()</code> throws an exception for
<code>spouse</code> when gender is missing. If spouse data is incomplete, the
job can fail before the API call is made.
</div>
</div>
<h2 id="api-integration">API integration</h2>
<p>
<code>sendFamiliesToWellnessApi()</code> uses the CI4 cURL service and reads
its runtime configuration from environment values:
</p>
<table>
<thead>
<tr><th>Env key</th><th>Usage</th></tr>
</thead>
<tbody>
<tr>
<td><code>WELLNESS_ONBOARD_ENDPOINT_URL</code></td>
<td>Target URL for the Visit onboarding POST request.</td>
</tr>
<tr>
<td><code>WELLNESS_ONBOARD_AUTHORIZATION</code></td>
<td>Basic auth token value appended to the <code>Authorization</code> header.</td>
</tr>
</tbody>
</table>
<p>
Each family is posted as JSON with <code>http_errors = false</code> and a
<code>30</code> second timeout. The helper stores the raw API result back onto
the in-memory family array under <code>apiResponse</code> so the next step can
decide whether to persist anything.
</p>
<pre><code class="language-php">$response = $client->post($endpointUrl, [
'headers' => [
'Content-Type' => 'application/json',
'Authorization' => 'Basic ' . getenv('WELLNESS_ONBOARD_AUTHORIZATION'),
],
'body' => json_encode($family),
'http_errors' => false,
'timeout' => 30,
]);</code></pre>
<p>
If the HTTP client throws, the exception is captured into
<code>apiResponse['error']</code> with a synthetic <code>statusCode</code> of
<code>0</code>.
</p>
<h2 id="response-examples">Response examples</h2>
<div class="callout info">
<span>i</span>
<div>
<strong>Sample data only</strong>
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.
</div>
</div>
<p>
<strong>Visit success response</strong> (HTTP 2xx with a body the job can
decode). <code>updateWellnessOnboardResponseToDB()</code> reads
<code>referenceId</code> from the decoded JSON and writes it to
<code>employee_polices.wellness_onboard</code> for each
<code>policyDetails[].memberId</code> (employee policy row id).
</p>
<pre><code class="language-json">{
"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"
}</code></pre>
<p>
<strong>Visit failure responses</strong> (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
<code>referenceId</code>.
</p>
<pre><code class="language-json">{
"message": "failed",
"errorMessage": "Invalid name"
}</code></pre>
<pre><code class="language-json">{
"message": "failed",
"errorMessage": "Invalid mobileno"
}</code></pre>
<pre><code class="language-json">{
"message": "failed",
"errorMessage": "invalid [\"null\",\"string\"]: 100010"
}</code></pre>
<p>
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).
</p>
<h2 id="database-updates">Database updates</h2>
<p>
Successful persistence is done by
<code>updateWellnessOnboardResponseToDB()</code>. The method expects the Visit
API response to contain a <code>referenceId</code>. That value becomes the new
<code>wellness_onboard</code> value for every member in the family.
</p>
<table>
<thead>
<tr><th>Before</th><th>After successful onboard</th></tr>
</thead>
<tbody>
<tr>
<td><code>employee_polices.wellness_onboard = '0'</code></td>
<td><code>employee_polices.wellness_onboard = {referenceId}</code></td>
</tr>
</tbody>
</table>
<p>
The code collects all row updates for the page and writes them in one
<code>updateBatch(..., 'id')</code> call, using each
<code>memberDetails[].memberId</code> as the primary key.
</p>
<pre><code class="language-php">$allUpdates[] = [
'id' => $memberPk,
'wellness_onboard' => $referenceId,
];
$this->employeePolicyModel->updateBatch($allUpdates, 'id');</code></pre>
<h2 id="failure-behavior">Failure behavior</h2>
<table>
<thead>
<tr><th>Condition</th><th>Current behavior</th></tr>
</thead>
<tbody>
<tr>
<td>Missing <code>client_policy_id</code> in job payload</td>
<td>Logs an error and returns <code>true</code> so the worker can continue.</td>
</tr>
<tr>
<td>No rows found for a page</td>
<td>Logs batch completion and stops queue recursion.</td>
</tr>
<tr>
<td>Row missing <code>emp_code</code></td>
<td>Skips that row and logs a warning.</td>
</tr>
<tr>
<td>HTTP status <code>400</code>, <code>500</code>, or missing response data</td>
<td>Logs the API error and does not update <code>wellness_onboard</code>.</td>
</tr>
<tr>
<td>Missing <code>referenceId</code> in API response</td>
<td>Logs the issue and skips DB persistence for that family.</td>
</tr>
<tr>
<td>Queue context return value</td>
<td>The job returns <code>true</code> instead of using <code>$this->respond()</code>.</td>
</tr>
</tbody>
</table>
<div class="callout info">
<span>i</span>
<div>
<strong>Current logging style</strong>
The implementation uses multiple <code>log_message('error', ...)</code>
calls for progress tracing, not only for failures. Keep that in mind while
reading logs during QA or production support.
</div>
</div>
<h2 id="developer-steps">Developer steps</h2>
<ol class="steps">
<li>
<strong>Keep the browser trigger and route definitions in sync</strong>
<p>If you rename the route or change the method, update both <code>Routes.php</code> and the AJAX calls in <code>employee_upload.php</code>.</p>
</li>
<li>
<strong>Do not move the heavy loop back into the web request</strong>
<p><code>initiateWellnessOnboard()</code> should remain a thin queue trigger. The batching work belongs in the job handler.</p>
</li>
<li>
<strong>Preserve family grouping assumptions</strong>
<p><code>emp_code</code> is the family key. If source data changes, make sure all related members still group together correctly.</p>
</li>
<li>
<strong>Validate required member data before rollout</strong>
<p>Fields such as <code>emp_code</code>, <code>relationship</code>, <code>gender</code>, <code>dob</code>, <code>mobile</code>, and <code>email_corporate</code> affect payload quality.</p>
</li>
<li>
<strong>Keep the queue mapping intact</strong>
<p>If you rename <code>initiateWellnessOnboardJob</code>, update the corresponding entry in <code>JobWorker::$event_class_mapping</code>.</p>
</li>
<li>
<strong>Verify runtime configuration before testing</strong>
<p>The queue worker must be running, and both wellness environment variables must be present before QA can validate the end-to-end flow.</p>
</li>
</ol>
<h2 id="common-pitfalls">Common pitfalls</h2>
<table>
<thead>
<tr><th>Pitfall</th><th>Why it happens</th></tr>
</thead>
<tbody>
<tr>
<td>The Visit onboard button never appears</td>
<td>The status endpoint returned <code>0</code> because the selected policy failed one of the eligibility filters.</td>
</tr>
<tr>
<td>User sees <code>Process started</code> but no records change</td>
<td>The initial request only queues the job; no worker means no real processing.</td>
</tr>
<tr>
<td>Only some members get updated</td>
<td>Families with API errors, missing <code>referenceId</code>, or missing <code>emp_code</code> are skipped during persistence.</td>
</tr>
<tr>
<td>Spouse records fail unexpectedly</td>
<td><code>mapRelationship()</code> requires gender to translate spouse into <code>husband</code> or <code>wife</code>.</td>
</tr>
<tr>
<td>Count shown in UI does not equal number of API calls</td>
<td>The UI count is member-row based, but outbound requests are family-group based.</td>
</tr>
<tr>
<td>Pagination changes create odd onboarding batches</td>
<td>The job paginates raw rows first and groups families afterward, so careless query changes can alter how families are chunked.</td>
</tr>
</tbody>
</table>

View File

@ -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

View File

@ -0,0 +1,104 @@
<?php
use App\Libraries\RateLimiterService;
use CodeIgniter\Test\CIUnitTestCase;
/**
* @internal
*/
final class RateLimiterServiceSmokeTest extends CIUnitTestCase
{
private RateLimiterService $service;
protected function setUp(): void
{
parent::setUp();
helper('utility');
$this->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));
}
}