MERGE_TEST_CLAIM_PUSH_BUTTON
This commit is contained in:
commit
40fe8d8d03
@ -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 =
|
||||
|
||||
117
app/Commands/RateLimitBlocksReconcile.php
Normal file
117
app/Commands/RateLimitBlocksReconcile.php
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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],
|
||||
|
||||
@ -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');
|
||||
|
||||
|
||||
583
app/Controllers/Docs/DocsController.php
Normal file
583
app/Controllers/Docs/DocsController.php
Normal 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 don’t', 'href' => '#do-and-dont'],
|
||||
['label' => 'Common pitfalls', 'href' => '#common-pitfalls'],
|
||||
],
|
||||
'prev' => null,
|
||||
'next' => ['label' => 'Input Security Guard', 'url' => 'docs/input-security'],
|
||||
],
|
||||
|
||||
'input-security' => [
|
||||
'view' => 'docs/input-security',
|
||||
'title' => 'Input Security Guard',
|
||||
'breadcrumb' => 'Features',
|
||||
'last_updated' => 'May 2026',
|
||||
'author' => 'Core Team',
|
||||
'read_time' => '8 min read',
|
||||
'toc' => [
|
||||
['label' => 'Overview', 'href' => '#overview'],
|
||||
['label' => 'Where it runs', 'href' => '#where-it-runs'],
|
||||
['label' => 'What it checks', 'href' => '#what-it-checks'],
|
||||
['label' => 'Canonicalization', 'href' => '#canonicalization'],
|
||||
['label' => 'Block behavior', 'href' => '#block-behavior'],
|
||||
['label' => 'Filter exceptions', 'href' => '#filter-exceptions'],
|
||||
['label' => 'Developer steps', 'href' => '#developer-steps'],
|
||||
['label' => 'Common pitfalls', 'href' => '#common-pitfalls'],
|
||||
],
|
||||
'prev' => ['label' => 'ACL / Access Control', 'url' => 'docs/acl'],
|
||||
'next' => ['label' => 'File Upload Guard', 'url' => 'docs/file-uploads'],
|
||||
],
|
||||
|
||||
'file-uploads' => [
|
||||
'view' => 'docs/file-uploads',
|
||||
'title' => 'File Upload Guard',
|
||||
'breadcrumb' => 'Features',
|
||||
'last_updated' => 'May 2026',
|
||||
'author' => 'Core Team',
|
||||
'read_time' => '8 min read',
|
||||
'toc' => [
|
||||
['label' => 'Overview', 'href' => '#overview'],
|
||||
['label' => 'When it runs', 'href' => '#when-it-runs'],
|
||||
['label' => 'Allowed file types', 'href' => '#allowed-file-types'],
|
||||
['label' => 'Blocked extensions', 'href' => '#blocked-extensions'],
|
||||
['label' => 'Validation flow', 'href' => '#validation-flow'],
|
||||
['label' => 'Magic bytes check', 'href' => '#magic-bytes-check'],
|
||||
['label' => 'Route coverage', 'href' => '#route-coverage'],
|
||||
['label' => 'Blocked response', 'href' => '#blocked-response'],
|
||||
['label' => 'Operational notes', 'href' => '#operational-notes'],
|
||||
],
|
||||
'prev' => ['label' => 'Input Security Guard', 'url' => 'docs/input-security'],
|
||||
'next' => ['label' => 'Background Jobs', 'url' => 'docs/background-jobs'],
|
||||
],
|
||||
|
||||
'background-jobs' => [
|
||||
'view' => 'docs/background-jobs',
|
||||
'title' => 'Background Jobs',
|
||||
'breadcrumb' => 'Features',
|
||||
'last_updated' => 'May 2026',
|
||||
'author' => 'Core Team',
|
||||
'read_time' => '8 min read',
|
||||
'toc' => [
|
||||
['label' => 'Overview', 'href' => '#overview'],
|
||||
['label' => 'Queueing jobs', 'href' => '#queueing-jobs'],
|
||||
['label' => 'Worker lifecycle', 'href' => '#worker-lifecycle'],
|
||||
['label' => 'Handler registry', 'href' => '#handler-registry'],
|
||||
['label' => 'Sample handlers', 'href' => '#sample-handlers'],
|
||||
['label' => 'Status lifecycle', 'href' => '#status-lifecycle'],
|
||||
['label' => 'Failure behavior', 'href' => '#failure-behavior'],
|
||||
['label' => 'Running via CLI', 'href' => '#running-via-cli'],
|
||||
['label' => 'Live runner script', 'href' => '#live-runner-script'],
|
||||
['label' => 'Systemd service', 'href' => '#systemd-service'],
|
||||
['label' => 'Permissions setup', 'href' => '#permissions-setup'],
|
||||
['label' => 'Checking status', 'href' => '#checking-status'],
|
||||
['label' => 'Adding a new handler','href' => '#adding-a-new-handler'],
|
||||
],
|
||||
'prev' => ['label' => 'File Upload Guard','url' => 'docs/file-uploads'],
|
||||
'next' => ['label' => 'Visit onboard', 'url' => 'docs/visit-onboard'],
|
||||
],
|
||||
|
||||
'visit-onboard' => [
|
||||
'view' => 'docs/visit-onboard',
|
||||
'title' => 'Visit onboard',
|
||||
'breadcrumb' => 'Features',
|
||||
'last_updated' => 'May 2026',
|
||||
'author' => 'Core Team',
|
||||
'read_time' => '8 min read',
|
||||
'toc' => [
|
||||
['label' => 'Overview', 'href' => '#overview'],
|
||||
['label' => 'Prerequisites', 'href' => '#prerequisites'],
|
||||
['label' => 'Entry points', 'href' => '#entry-points'],
|
||||
['label' => 'Eligibility rules', 'href' => '#eligibility-rules'],
|
||||
['label' => 'Async flow', 'href' => '#async-flow'],
|
||||
['label' => 'Family payload', 'href' => '#family-payload'],
|
||||
['label' => 'API integration', 'href' => '#api-integration'],
|
||||
['label' => 'Response examples', 'href' => '#response-examples'],
|
||||
['label' => 'Database updates', 'href' => '#database-updates'],
|
||||
['label' => 'Failure behavior', 'href' => '#failure-behavior'],
|
||||
['label' => 'Developer steps', 'href' => '#developer-steps'],
|
||||
['label' => 'Common pitfalls', 'href' => '#common-pitfalls'],
|
||||
],
|
||||
'prev' => ['label' => 'Background Jobs', 'url' => 'docs/background-jobs'],
|
||||
'next' => ['label' => 'Visit offboard', 'url' => 'docs/visit-offboard'],
|
||||
],
|
||||
|
||||
'visit-offboard' => [
|
||||
'view' => 'docs/visit-offboard',
|
||||
'title' => 'Visit offboard',
|
||||
'breadcrumb' => 'Features',
|
||||
'last_updated' => 'May 2026',
|
||||
'author' => 'Core Team',
|
||||
'read_time' => '6 min read',
|
||||
'toc' => [
|
||||
['label' => 'Overview', 'href' => '#overview'],
|
||||
['label' => 'When it is queued', 'href' => '#when-it-is-queued'],
|
||||
['label' => 'visitOffBoard()', 'href' => '#visit-off-board-api'],
|
||||
['label' => 'updateVisitoffboardStatus()', 'href' => '#update-status'],
|
||||
['label' => 'Manual test route', 'href' => '#manual-test-route'],
|
||||
['label' => 'Developer steps', 'href' => '#developer-steps'],
|
||||
['label' => 'Common pitfalls', 'href' => '#common-pitfalls'],
|
||||
],
|
||||
'prev' => ['label' => 'Visit onboard', 'url' => 'docs/visit-onboard'],
|
||||
'next' => ['label' => 'Deployment', 'url' => 'docs/deployment'],
|
||||
],
|
||||
|
||||
'deployment' => [
|
||||
'view' => 'docs/deployment',
|
||||
'title' => 'Deployment',
|
||||
'breadcrumb' => 'DevOps',
|
||||
'last_updated' => 'May 2026',
|
||||
'author' => 'Core Team',
|
||||
'read_time' => '8 min read',
|
||||
'toc' => [
|
||||
['label' => 'Overview', 'href' => '#overview'],
|
||||
['label' => 'Dev cPanel flow', 'href' => '#dev-cpanel-flow', 'tag' => 'DEV'],
|
||||
['label' => 'cPanel deploy scripts', 'href' => '#cpanel-deploy-scripts', 'tag' => 'DEV'],
|
||||
['label' => 'Branch promotion flow', 'href' => '#branch-promotion-flow', 'tag' => 'UAT/LIVE'],
|
||||
['label' => 'Auto merge script', 'href' => '#auto-merge-script', 'tag' => 'UAT/LIVE'],
|
||||
['label' => 'Script usage', 'href' => '#script-usage', 'tag' => 'UAT/LIVE'],
|
||||
['label' => 'Log output', 'href' => '#log-output', 'tag' => 'UAT/LIVE'],
|
||||
['label' => 'Operational notes', 'href' => '#operational-notes', 'tag' => 'UAT/LIVE'],
|
||||
['label' => 'Code move scripts', 'href' => '#code-move-scripts', 'tag' => 'UAT/LIVE'],
|
||||
['label' => 'Manual fallback', 'href' => '#manual-fallback', 'tag' => 'DEV'],
|
||||
],
|
||||
'prev' => ['label' => 'Visit offboard', 'url' => 'docs/visit-offboard'],
|
||||
'next' => ['label' => 'CI/CD Pipeline', 'url' => 'docs/cicd'],
|
||||
],
|
||||
|
||||
'cicd' => [
|
||||
'view' => 'docs/cicd',
|
||||
'title' => 'CI/CD Pipeline',
|
||||
'breadcrumb' => 'DevOps',
|
||||
'last_updated' => 'May 2026',
|
||||
'author' => 'Core Team',
|
||||
'read_time' => '4 min read',
|
||||
'toc' => [
|
||||
['label' => 'Overview', 'href' => '#overview'],
|
||||
['label' => 'Dev pipeline', 'href' => '#dev-pipeline', 'tag' => 'DEV'],
|
||||
['label' => 'UAT and live flow', 'href' => '#uat-live-flow', 'tag' => 'UAT/LIVE'],
|
||||
['label' => 'Related docs', 'href' => '#related-docs'],
|
||||
],
|
||||
'prev' => ['label' => 'Deployment', 'url' => 'docs/deployment'],
|
||||
'next' => ['label' => 'S3 & CloudFront', 'url' => 'docs/s3-cloudfront'],
|
||||
],
|
||||
|
||||
's3-cloudfront' => [
|
||||
'view' => 'docs/s3-cloudfront',
|
||||
'title' => 'S3 & CloudFront',
|
||||
'breadcrumb' => 'DevOps',
|
||||
'last_updated' => 'May 2026',
|
||||
'author' => 'Core Team',
|
||||
'read_time' => '7 min read',
|
||||
'toc' => [
|
||||
['label' => 'Overview', 'href' => '#overview'],
|
||||
['label' => 'Prerequisites', 'href' => '#prerequisites'],
|
||||
['label' => 'Selection flow', 'href' => '#selection-flow'],
|
||||
['label' => 'Deployment steps', 'href' => '#deployment-steps'],
|
||||
['label' => 'Invalidation polling', 'href' => '#invalidation-polling'],
|
||||
['label' => 'Operational notes', 'href' => '#operational-notes'],
|
||||
['label' => 'Full script', 'href' => '#full-script'],
|
||||
],
|
||||
'prev' => ['label' => 'CI/CD Pipeline', 'url' => 'docs/cicd'],
|
||||
'next' => ['label' => 'Changelog', 'url' => 'docs/changelog'],
|
||||
],
|
||||
|
||||
'api-rate-limiter' => [
|
||||
'view' => 'docs/api-rate-limiter',
|
||||
'title' => 'API Rate Limiter',
|
||||
'breadcrumb' => 'Features',
|
||||
'last_updated' => 'May 2026',
|
||||
'author' => 'Core Team',
|
||||
'read_time' => '10 min read',
|
||||
'toc' => [
|
||||
['label' => 'Overview', 'href' => '#overview'],
|
||||
['label' => 'Core service', 'href' => '#core-service'],
|
||||
['label' => 'Auth API filter', 'href' => '#auth-api-filter'],
|
||||
['label' => 'JWT API filter', 'href' => '#jwt-api-filter'],
|
||||
['label' => 'Configuration', 'href' => '#configuration'],
|
||||
['label' => 'Change block counts and durations', 'href' => '#change-block-count-duration'],
|
||||
['label' => 'Manual unblock samples', 'href' => '#manual-unblock-samples'],
|
||||
['label' => 'Cache TTL and auto-release', 'href' => '#cache-ttl-auto-release'],
|
||||
['label' => 'DB reconciliation (cron)', 'href' => '#db-reconciliation-cron'],
|
||||
['label' => 'HTTP responses', 'href' => '#http-responses'],
|
||||
['label' => 'Wiring routes', 'href' => '#wiring-routes'],
|
||||
['label' => 'Blocked list URL', 'href' => '#blocked-list-url'],
|
||||
['label' => 'Smoke test command', 'href' => '#smoke-test-command'],
|
||||
['label' => 'Operational notes', 'href' => '#operational-notes'],
|
||||
],
|
||||
'prev' => ['label' => 'Email / Notifications', 'url' => 'docs/notifications'],
|
||||
'next' => ['label' => 'TPA Recon', 'url' => 'docs/tpa-recon'],
|
||||
],
|
||||
|
||||
'tpa-recon' => [
|
||||
'view' => 'docs/tpa-recon',
|
||||
'title' => 'TPA Recon',
|
||||
'breadcrumb' => 'Features',
|
||||
'last_updated' => 'May 2026',
|
||||
'author' => 'Core Team',
|
||||
'read_time' => '18 min read',
|
||||
'toc' => [
|
||||
['label' => 'What this is', 'href' => '#what-this-is'],
|
||||
['label' => 'Glossary', 'href' => '#glossary'],
|
||||
['label' => 'Key files and routes', 'href' => '#key-files-routes'],
|
||||
['label' => 'End-to-end flow', 'href' => '#end-to-end-flow'],
|
||||
['label' => 'Variation report', 'href' => '#variation-report'],
|
||||
['label' => 'Classifying rec_type', 'href' => '#classifying-rec-type'],
|
||||
['label' => 'Linking ref column', 'href' => '#linking-ref-column'],
|
||||
['label' => 'Proceed next step', 'href' => '#proceed-next-step'],
|
||||
['label' => 'Not in Nhance → inception', 'href' => '#not-in-nhance-inception'],
|
||||
['label' => 'Need to review → DB sync', 'href' => '#need-to-review-sync'],
|
||||
['label' => 'Deletion initialization', 'href' => '#deletion-initialization'],
|
||||
['label' => 'Background jobs chain', 'href' => '#background-jobs-chain'],
|
||||
['label' => 'New developer checklist', 'href' => '#new-developer-checklist'],
|
||||
],
|
||||
'prev' => ['label' => 'API Rate Limiter', 'url' => 'docs/api-rate-limiter'],
|
||||
'next' => ['label' => 'EB rack rate config', 'url' => 'docs/eb-rack-rate-config'],
|
||||
],
|
||||
|
||||
'eb-rack-rate-config' => [
|
||||
'view' => 'docs/eb-rack-rate-config',
|
||||
'title' => 'EB rack rate config',
|
||||
'breadcrumb' => 'Features',
|
||||
'last_updated' => 'May 2026',
|
||||
'author' => 'Core Team',
|
||||
'read_time' => '20 min read',
|
||||
'toc' => [
|
||||
['label' => 'Purpose', 'href' => '#purpose'],
|
||||
['label' => 'Premium calculation (all)', 'href' => '#premium-calculation-modes'],
|
||||
['label' => 'Applicable family members', 'href' => '#applicable-family-members'],
|
||||
['label' => 'Member radios (Yes / No / …)', 'href' => '#applicable-members-radio-meanings'],
|
||||
['label' => 'Rack matching vs family', 'href' => '#applicable-members-matching'],
|
||||
['label' => 'Rack vs family flowchart', 'href' => '#rack-rate-family-matching-flowchart'],
|
||||
['label' => 'How to configure a rack rate', 'href' => '#how-to-configure-rack-rate'],
|
||||
['label' => 'Where it appears in the UI', 'href' => '#ui-entry'],
|
||||
['label' => 'Load grid data (backend)', 'href' => '#getpolicy-grid-data'],
|
||||
['label' => 'Save rack rate (backend)', 'href' => '#create-premium'],
|
||||
['label' => 'Frontend flow', 'href' => '#frontend-flow'],
|
||||
['label' => 'Grid IDs and storage', 'href' => '#grid-ids-storage'],
|
||||
['label' => 'Grid types (1–13)', 'href' => '#grid-types-1-13'],
|
||||
['label' => 'Excel paste path', 'href' => '#excel-paste'],
|
||||
['label' => 'Related routes', 'href' => '#related-routes'],
|
||||
],
|
||||
'prev' => ['label' => 'TPA Recon', 'url' => 'docs/tpa-recon'],
|
||||
'next' => ['label' => 'EB rack rate calculation', 'url' => 'docs/eb-rack-rate-calculation'],
|
||||
],
|
||||
|
||||
'eb-rack-rate-calculation' => [
|
||||
'view' => 'docs/eb-rack-rate-calculation',
|
||||
'title' => 'EB rack rate calculation',
|
||||
'breadcrumb' => 'Features',
|
||||
'last_updated' => 'May 2026',
|
||||
'author' => 'Core Team',
|
||||
'read_time' => '18 min read',
|
||||
'toc' => [
|
||||
['label' => 'Scope and entry point', 'href' => '#scope'],
|
||||
['label' => 'Data loaded before premium', 'href' => '#inputs'],
|
||||
['label' => 'Excel row shape (numeric columns)', 'href' => '#excel-columns'],
|
||||
['label' => 'Flow: employeesOnboardPreprocess', 'href' => '#flow-preprocess'],
|
||||
['label' => 'Flow: calculate_premium_new', 'href' => '#flow-calculate-premium'],
|
||||
['label' => 'group_slab_rates_basedon_name', 'href' => '#flow-group-slabs'],
|
||||
['label' => 'get_familiy_composition', 'href' => '#flow-family-composition'],
|
||||
['label' => 'compare_incoming…_slab', 'href' => '#flow-compare-slab'],
|
||||
['label' => 'get_applicable_familiy_members', 'href' => '#flow-applicable-members'],
|
||||
['label' => 'Per-member transform and gate', 'href' => '#flow-per-member'],
|
||||
['label' => 'premium_calculation_manager', 'href' => '#flow-premium-manager'],
|
||||
['label' => 'Dependent addition extras', 'href' => '#dependent-addition'],
|
||||
['label' => 'Failure: zero successful families', 'href' => '#failure-modes'],
|
||||
['label' => 'Related', 'href' => '#related'],
|
||||
],
|
||||
'prev' => ['label' => 'EB rack rate config', 'url' => 'docs/eb-rack-rate-config'],
|
||||
'next' => ['label' => 'Endpoints', 'url' => 'docs/endpoints'],
|
||||
],
|
||||
|
||||
// ── Add more pages here following the same pattern ──────────────────
|
||||
];
|
||||
|
||||
|
||||
// =========================================================================
|
||||
// PUBLIC ROUTES
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* GET /docs → redirect to first page (introduction)
|
||||
*/
|
||||
public function index(): \CodeIgniter\HTTP\RedirectResponse
|
||||
{
|
||||
return redirect()->to(base_url('docs/introduction'));
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /docs/(:segment)
|
||||
*
|
||||
* Looks up the slug in $pages, assembles layout partials, and returns HTML.
|
||||
* The individual view only contains its own content — no layout boilerplate.
|
||||
*/
|
||||
public function page(string $slug): string
|
||||
{
|
||||
// 1. Resolve page config
|
||||
$config = $this->getPageConfig($slug);
|
||||
|
||||
// 2. Render the inner content view (must exist)
|
||||
$content = $this->renderContentView($config['view'], $config);
|
||||
|
||||
// 3. Assemble full layout and return
|
||||
return $this->renderDocPage($config, $content);
|
||||
}
|
||||
|
||||
|
||||
// =========================================================================
|
||||
// CORE LAYOUT ASSEMBLER
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Wraps any content string with the full docs layout (header, sidebar,
|
||||
* main open/close, footer) and returns the complete HTML page.
|
||||
*
|
||||
* @param array $config Page config entry from $this->pages
|
||||
* @param string $content Rendered HTML from the content-only view
|
||||
*/
|
||||
protected function renderDocPage(array $config, string $content): string
|
||||
{
|
||||
$sharedLayout = [
|
||||
'app_name' => $this->appName,
|
||||
'app_version' => $this->appVersion,
|
||||
'nav' => $this->nav, // sidebar needs the full nav
|
||||
];
|
||||
|
||||
$header = view('docs/partials/docs_header', array_merge($sharedLayout, [
|
||||
'doc_title' => $config['title'],
|
||||
]));
|
||||
|
||||
$sidebar = view('docs/partials/docs_sidebar', array_merge($sharedLayout, [
|
||||
'active_page' => $config['id'],
|
||||
]));
|
||||
|
||||
$mainOpen = view('docs/partials/docs_main_open', [
|
||||
'doc_title' => $config['title'],
|
||||
'breadcrumb' => $config['breadcrumb'] ?? '',
|
||||
'last_updated' => $config['last_updated'] ?? '',
|
||||
'author' => $config['author'] ?? '',
|
||||
'read_time' => $config['read_time'] ?? '',
|
||||
'toc' => $config['toc'] ?? [],
|
||||
]);
|
||||
|
||||
$mainClose = view('docs/partials/docs_main_close', [
|
||||
'toc' => $config['toc'] ?? [],
|
||||
'prev_label' => $config['prev']['label'] ?? '',
|
||||
'prev_url' => $config['prev']['url'] ?? '',
|
||||
'next_label' => $config['next']['label'] ?? '',
|
||||
'next_url' => $config['next']['url'] ?? '',
|
||||
]);
|
||||
|
||||
$footer = view('docs/partials/docs_footer', [
|
||||
'app_name' => $this->appName,
|
||||
]);
|
||||
|
||||
return $header . $sidebar . $mainOpen . $content . $mainClose . $footer;
|
||||
}
|
||||
|
||||
|
||||
// =========================================================================
|
||||
// HELPERS
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Resolve a URL slug to its page config array.
|
||||
* Injects 'id' (the slug) so partials can use it without repeating it.
|
||||
*
|
||||
* @throws PageNotFoundException
|
||||
*/
|
||||
protected function getPageConfig(string $slug): array
|
||||
{
|
||||
if (! array_key_exists($slug, $this->pages)) {
|
||||
throw new PageNotFoundException("Docs page not found: {$slug}");
|
||||
}
|
||||
|
||||
return array_merge($this->pages[$slug], ['id' => $slug]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the content-only view file.
|
||||
* Passes the full config as view data so the content view
|
||||
* can access $title, $toc, etc. if needed.
|
||||
*
|
||||
* @throws PageNotFoundException if the view file does not exist on disk
|
||||
*/
|
||||
protected function renderContentView(string $viewPath, array $data = []): string
|
||||
{
|
||||
$fullPath = APPPATH . 'Views/' . $viewPath . '.php';
|
||||
|
||||
if (! file_exists($fullPath)) {
|
||||
throw new PageNotFoundException("Docs view file not found: {$viewPath}.php");
|
||||
}
|
||||
|
||||
// Remove 'view' key so it doesn't shadow anything inside the view
|
||||
unset($data['view']);
|
||||
|
||||
return view($viewPath, $data);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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'] ?? '')),
|
||||
|
||||
65
app/Controllers/RateLimitAdminController.php
Normal file
65
app/Controllers/RateLimitAdminController.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
19
app/Database/rate_limit_blocks.sql
Normal file
19
app/Database/rate_limit_blocks.sql
Normal 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;
|
||||
@ -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']);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
102
app/Views/admin/rate_limit_blocks.php
Normal file
102
app/Views/admin/rate_limit_blocks.php
Normal 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>
|
||||
@ -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
114
app/Views/docs/README.md
Normal 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
480
app/Views/docs/acl.php
Normal 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()->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 don’t</h2>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Do</th><th>Don’t</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>
|
||||
520
app/Views/docs/api-rate-limiter.php
Normal file
520
app/Views/docs/api-rate-limiter.php
Normal 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> filter’s <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"><?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>),
|
||||
CodeIgniter’s <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 level’s 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><= 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 && php spark rate-limit:reconcile-blocks >> /path/to/logs/rate-limit-reconcile.log 2>&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>
|
||||
566
app/Views/docs/background-jobs.php
Normal file
566
app/Views/docs/background-jobs.php
Normal 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 <service-user>:<service-group> 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
79
app/Views/docs/cicd.php
Normal 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 & 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 & CloudFront</a></td>
|
||||
<td>Dev batch script for S3 upload and CloudFront invalidation.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
451
app/Views/docs/deployment.php
Normal file
451
app/Views/docs/deployment.php
Normal 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 <REPO_PATH> '<COMMIT_MESSAGE_SUFFIX>'")
|
||||
print("Usage Example: python auto_merge.py './nhance' '<COMMIT_MESSAGE_SUFFIX>'")
|
||||
print("Usage Example: python auto_merge.py './nhance-enrollment' '<COMMIT_MESSAGE_SUFFIX>'")
|
||||
sys.exit(1)
|
||||
|
||||
repo_path = sys.argv[1].strip()
|
||||
commit_suffix = sys.argv[2].strip().upper()
|
||||
|
||||
auto_merge(repo_path, commit_suffix)
|
||||
</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 "<REPO_PATH>" "<COMMIT_MESSAGE_SUFFIX>"</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>
|
||||
80
app/Views/docs/docs_footer.php
Normal file
80
app/Views/docs/docs_footer.php
Normal 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 ·
|
||||
<a href="<?= base_url('docs/changelog') ?>">Changelog</a> ·
|
||||
<a href="<?= base_url('docs/contributing') ?>">Contributing</a>
|
||||
</span>
|
||||
</footer>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
288
app/Views/docs/docs_header.php
Normal file
288
app/Views/docs/docs_header.php
Normal 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">
|
||||
109
app/Views/docs/docs_main_close.php
Normal file
109
app/Views/docs/docs_main_close.php
Normal 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>
|
||||
125
app/Views/docs/docs_main_open.php
Normal file
125
app/Views/docs/docs_main_open.php
Normal 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 ── -->
|
||||
175
app/Views/docs/docs_sidebar.php
Normal file
175
app/Views/docs/docs_sidebar.php
Normal 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>
|
||||
922
app/Views/docs/eb-rack-rate-calculation.php
Normal file
922
app/Views/docs/eb-rack-rate-calculation.php
Normal 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 row’s 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 rack’s <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 1–13)</h2>
|
||||
|
||||
<p>
|
||||
Resolves <code>$emp_data['temp']['grid_name']</code> to the rack bucket, takes that rack’s <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 member’s
|
||||
<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>
|
||||
463
app/Views/docs/eb-rack-rate-config.php
Normal file
463
app/Views/docs/eb-rack-rate-config.php
Normal 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 (1–13): <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 rate’s 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 family’s
|
||||
members and relationships</strong> (who is on the cover and in what roles) against <strong>each configured rack
|
||||
rate’s applicable members</strong> (the rules saved in <code>additional_relationship</code> for that tab’s 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> 1–13 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 3–9; if <code>family_floater == 1</code>, it prefers grid ids 10–11 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>3–8, 10–13:</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 1–13 (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 (1–13) — <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 1–13 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 1–2.
|
||||
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 grade–based 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>10–11</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 3–13:</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>
|
||||
312
app/Views/docs/file-uploads.php
Normal file
312
app/Views/docs/file-uploads.php
Normal 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->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>
|
||||
315
app/Views/docs/input-security.php
Normal file
315
app/Views/docs/input-security.php
Normal 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><script</code>, <code></script></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><iframe</code>, <code><object</code>, <code><embed</code>, <code><applet</code>, <code><img</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>SVG / MathML vectors</td>
|
||||
<td><code><svg</code>, <code><math</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Meta refresh payloads</td>
|
||||
<td><code><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>
|
||||
175
app/Views/docs/installation.php
Normal file
175
app/Views/docs/installation.php
Normal 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><div class="mermaid"></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"><div class="mermaid">
|
||||
flowchart TD
|
||||
A[Start] --> B[Process]
|
||||
B --> C[Done]
|
||||
</div></code></pre>
|
||||
124
app/Views/docs/installation_content.php
Normal file
124
app/Views/docs/installation_content.php
Normal 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>
|
||||
114
app/Views/docs/partials/README.md
Normal file
114
app/Views/docs/partials/README.md
Normal 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);
|
||||
}
|
||||
```
|
||||
542
app/Views/docs/partials/docs_footer.php
Normal file
542
app/Views/docs/partials/docs_footer.php
Normal 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 ·
|
||||
<a href="<?= base_url('docs/changelog') ?>">Changelog</a> ·
|
||||
<a href="<?= base_url('docs/contributing') ?>">Contributing</a>
|
||||
</span>
|
||||
</footer>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
288
app/Views/docs/partials/docs_header.php
Normal file
288
app/Views/docs/partials/docs_header.php
Normal 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">
|
||||
112
app/Views/docs/partials/docs_main_close.php
Normal file
112
app/Views/docs/partials/docs_main_close.php
Normal 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>
|
||||
142
app/Views/docs/partials/docs_main_open.php
Normal file
142
app/Views/docs/partials/docs_main_open.php
Normal 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 ── -->
|
||||
175
app/Views/docs/partials/docs_sidebar.php
Normal file
175
app/Views/docs/partials/docs_sidebar.php
Normal 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>
|
||||
137
app/Views/docs/partials/installation.php
Normal file
137
app/Views/docs/partials/installation.php
Normal 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>
|
||||
124
app/Views/docs/partials/installation_content.php
Normal file
124
app/Views/docs/partials/installation_content.php
Normal 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>
|
||||
524
app/Views/docs/s3-cloudfront.php
Normal file
524
app/Views/docs/s3-cloudfront.php
Normal 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>
|
||||
308
app/Views/docs/tpa-recon.php
Normal file
308
app/Views/docs/tpa-recon.php
Normal 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' => 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' => $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' => $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' => (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' => newFileId, 'batch_file_id' => 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' => 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' => $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' => batch_file_id]</code></li>
|
||||
<li><code>initializeDeletionProcessForTpaApiData</code> — payload <code>['file_id' => 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>
|
||||
249
app/Views/docs/visit-offboard.php
Normal file
249
app/Views/docs/visit-offboard.php
Normal 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>
|
||||
550
app/Views/docs/visit-onboard.php
Normal file
550
app/Views/docs/visit-onboard.php
Normal 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' => ...]</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 => 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>
|
||||
15
nhance_php_queue_server.service
Normal file
15
nhance_php_queue_server.service
Normal 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
|
||||
104
tests/unit/RateLimiterServiceSmokeTest.php
Normal file
104
tests/unit/RateLimiterServiceSmokeTest.php
Normal 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));
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user