diff --git a/app/Config/Acl.php b/app/Config/Acl.php index 1eeed0a..822c2b7 100644 --- a/app/Config/Acl.php +++ b/app/Config/Acl.php @@ -73,6 +73,12 @@ class Acl 'teams' => [] ], + // ===================== LOGIN DIAGNOSTIC ===================== + '#^/security/login-diagnostic#' => [ + 'roles' => [ADMIN_ROLE_ID, HEAD_ROLE_ID], + 'teams' => [] + ], + // ===================== INTERNAL TEST ===================== '#^/test#' => [ 'roles' => [ADMIN_ROLE_ID, HEAD_ROLE_ID], diff --git a/app/Config/Routes.php b/app/Config/Routes.php index 80256f5..0fce77a 100755 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -594,6 +594,11 @@ $routes->group('security/rate-limits', ['filter' => 'authMVC'], function ($route $routes->post('unblock-user', 'RateLimitAdminController::unblockUser'); }); +$routes->group('security/login-diagnostic', ['filter' => 'authMVC'], function ($routes) { + $routes->get('/', 'LoginDiagnosticController::index'); + $routes->post('diagnose', 'LoginDiagnosticController::diagnose'); +}); + //saml - routes // $routes->group("employeeRest", ['filter' => ['appSignature','AuthApiRateLimitFilter'] ], function ($routes) { diff --git a/app/Controllers/ClientController.php b/app/Controllers/ClientController.php index d8c4243..e38ab65 100755 --- a/app/Controllers/ClientController.php +++ b/app/Controllers/ClientController.php @@ -1253,7 +1253,8 @@ class ClientController extends AdminController $data['is_member_modify_allowed'] = $this->request->getPost('is_member_modify_allowed') ? 1 : 0; $data['enrolment_visibility'] = $this->request->getPost('enrolment_visibility') ? 1 : 0; $data['is_lgbtq'] = $this->request->getPost('is_lgbtq') ? 1 : 0; - $data['is_premium_summery'] = $this->request->getPost('is_premium_summery') ? 1 : 0; + $premiumSummary = (int) $this->request->getPost('is_premium_summery'); + $data['is_premium_summery'] = in_array($premiumSummary, [0, 1, 2], true) ? $premiumSummary : 0; if ($policy_type_id == 1 || $policy_type_id == 2 || $policy_type_id == 6 || $policy_type_id == 7) { @@ -1386,7 +1387,8 @@ class ClientController extends AdminController $data['policy_end_date'] = change_date_format($this->request->getPost('policy_end_date'), 'd-m-Y', 'Y-m-d'); // $data['is_member_modify_allowed'] = $this->request->getPost('is_member_modify_allowed') ? 1 : 0; $data['is_lgbtq'] = $this->request->getPost('is_lgbtq') ? 1 : 0; - $data['is_premium_summery'] = $this->request->getPost('is_premium_summery') ? 1 : 0; + $premiumSummary = (int) $this->request->getPost('is_premium_summery'); + $data['is_premium_summery'] = in_array($premiumSummary, [0, 1, 2], true) ? $premiumSummary : 0; if ($data['inception_type'] == 2) { $data['open_date'] = change_date_format($this->request->getPost('open_date'), 'd-m-Y', 'Y-m-d'); diff --git a/app/Controllers/LoginDiagnosticController.php b/app/Controllers/LoginDiagnosticController.php new file mode 100644 index 0000000..3efa0db --- /dev/null +++ b/app/Controllers/LoginDiagnosticController.php @@ -0,0 +1,68 @@ +request->getGet('email')); + $mobile = trim((string) $this->request->getGet('mobile')); + + $data = [ + 'title' => 'Employee Login Diagnostic', + 'email' => $email, + 'mobile' => $mobile, + 'result' => null, + 'has_search' => false, + ]; + + if ($email !== '' || $mobile !== '') { + $data['has_search'] = true; + $data['result'] = LoginDiagnosticHelper::diagnose( + $email !== '' ? $email : null, + $mobile !== '' ? $mobile : null + ); + } + + echo view('admin/login_diagnostic', $data); + } + + public function diagnose() + { + $email = trim((string) $this->request->getPost('email')); + $mobile = trim((string) $this->request->getPost('mobile')); + + if ($email === '' && $mobile === '') { + $json = $this->request->getJSON(true); + if (is_array($json)) { + $email = trim((string) ($json['email'] ?? '')); + $mobile = trim((string) ($json['mobile'] ?? '')); + } + } + + if ($this->request->isAJAX() || str_contains((string) $this->request->getHeaderLine('Accept'), 'application/json')) { + $result = LoginDiagnosticHelper::diagnose( + $email !== '' ? $email : null, + $mobile !== '' ? $mobile : null + ); + return $this->response->setJSON($result); + } + + $query = []; + if ($email !== '') { + $query['email'] = $email; + } + if ($mobile !== '') { + $query['mobile'] = $mobile; + } + + return redirect()->to(base_url('security/login-diagnostic?' . http_build_query($query))); + } +} diff --git a/app/Helpers/LoginDiagnosticHelper.php b/app/Helpers/LoginDiagnosticHelper.php new file mode 100644 index 0000000..d014953 --- /dev/null +++ b/app/Helpers/LoginDiagnosticHelper.php @@ -0,0 +1,1211 @@ + ['email' => null, 'mobile' => null], + 'verdict' => 'WILL_FAIL', + 'verdict_message' => 'Email or mobile number is required.', + 'problems' => [[ + 'severity' => 'high', + 'code' => 'INPUT_REQUIRED', + 'message' => 'Provide an email or mobile number.', + 'fix' => 'Enter email_corporate or mobile used at login.', + ]], + 'pre' => null, + 'post' => null, + 'merge' => null, + 'connectivity' => null, + ]; + } + + $connectivity = self::checkPostConnectivity(); + $pre = self::analyzeSide('pre', $email, $mobile); + $post = self::analyzeSide('post', $email, $mobile, $connectivity); + [$pre, $post] = self::crossLinkSelfDuplicates($pre, $post); + + $merge = self::simulateMerge($pre, $post); + $problems = self::buildProblems($email, $mobile, $pre, $post, $merge, $connectivity); + $verdict = self::buildVerdict($pre, $post, $merge, $problems); + + return [ + 'input' => ['email' => $email, 'mobile' => $mobile], + 'verdict' => $verdict['status'], + 'verdict_message' => $verdict['message'], + 'problems' => $problems, + 'pre' => $pre, + 'post' => $post, + 'merge' => $merge, + 'connectivity' => $connectivity, + ]; + } + + private static function normalize(?string $value): ?string + { + $value = trim((string) $value); + return $value === '' ? null : $value; + } + + private static function checkPostConnectivity(): array + { + $baseUrl = rtrim((string) env('POST_ENROLLMENT_BASEURL'), '/') . '/'; + $url = $baseUrl . 'getPostEmployeeDataForAuth'; + $result = [ + 'base_url' => $baseUrl, + 'api_reachable' => false, + 'api_http_code' => null, + 'api_message' => null, + 'post_db' => false, + 'post_db_message'=> null, + 'app_signature' => ! empty(getenv('APP_SIGNATURE')), + ]; + + try { + $db = Database::connect('postDB'); + $db->query('SELECT 1'); + $result['post_db'] = true; + } catch (\Throwable $e) { + $result['post_db_message'] = $e->getMessage(); + } + + try { + $client = \Config\Services::curlrequest(['timeout' => 8]); + $response = $client->post($url, [ + 'json' => ['email_id' => '__login_diagnostic_probe__'], + 'headers' => ['App-Signature' => (string) getenv('APP_SIGNATURE')], + 'http_errors' => false, + ]); + $code = $response->getStatusCode(); + $result['api_http_code'] = $code; + $result['api_reachable'] = $code > 0 && $code < 500; + if ($code === 403) { + $result['api_message'] = 'Forbidden — App-Signature may be mismatched.'; + $result['api_reachable'] = false; + } else { + $result['api_message'] = 'POST auth API responded.'; + } + } catch (\Throwable $e) { + $result['api_message'] = $e->getMessage(); + } + + return $result; + } + + private static function analyzeSide(string $side, ?string $email, ?string $mobile, ?array $connectivity = null): array + { + $out = [ + 'side' => $side, + 'raw_matches' => [], + 'raw_by_personal' => [], + 'eligible' => null, + 'eligible_via_api' => null, + 'filters_summary' => [], + 'client_short_name' => null, + 'client_id' => null, + 'token' => null, + 'credentials' => null, + 'duplicates' => null, + 'policies' => [], + 'error' => null, + ]; + + try { + if ($side === 'pre') { + $db = (new EmployeeModel())->db; + $statusEmp = ['draft', 'enrolled']; + $statusPol = ['draft', 'enrolled']; + } else { + if ($connectivity && empty($connectivity['post_db'])) { + $out['error'] = $connectivity['post_db_message'] ?? 'postDB unavailable'; + // Still try login-shaped API for eligible + $out['eligible_via_api'] = self::fetchPostEligibleViaApi($email, $mobile); + if (! empty($out['eligible_via_api'])) { + $out['eligible'] = $out['eligible_via_api']; + $out['client_short_name'] = $out['eligible']['client_short_name'] ?? null; + $out['client_id'] = $out['eligible']['client_id'] ?? null; + } + return $out; + } + $db = Database::connect('postDB'); + $statusEmp = ['active', 'expired']; + $statusPol = ['active', 'expired']; + } + + $raw = self::fetchRawMatches($db, $email, $mobile); + $out['raw_matches'] = $raw; + + if ($email !== null) { + $out['raw_by_personal'] = self::fetchByPersonalEmail($db, $email); + } + + $eligibleRows = self::fetchEligibleMatches($db, $email, $mobile, $statusEmp, $statusPol); + $out['filters_summary'] = self::scoreFilters($raw, $statusEmp, $statusPol); + + if (! empty($eligibleRows)) { + $winner = $eligibleRows[0]; + $client = self::fetchClient($db, (int) ($winner['client_id'] ?? 0), $side); + $winner['client_short_name'] = $client['short_name'] ?? ''; + $winner['client_name'] = $client['client_name'] ?? ''; + $winner['client_is_active'] = $client['is_active'] ?? null; + $out['credentials'] = [ + 'otp_set' => ! empty($winner['otp']), + 'mpin_set' => isset($winner['mpin']) && $winner['mpin'] !== null && $winner['mpin'] !== '', + 'password_set' => ! empty($winner['password']), + 'biometric_set' => self::isBiometricEnabled($winner['is_biometric_enabled'] ?? null), + 'mpin_skipped' => self::isTruthyFlag($winner['is_mpin_skipped'] ?? null), + 'is_biometric_enabled' => $winner['is_biometric_enabled'] ?? null, + 'is_mpin_skipped' => $winner['is_mpin_skipped'] ?? null, + ]; + $out['eligible'] = self::publicEmployeeRow($winner); + $out['client_short_name'] = $winner['client_short_name']; + $out['client_id'] = $winner['client_id'] ?? null; + $out['token'] = self::tokenStatus($winner['token_time_out'] ?? null); + } elseif (! empty($raw)) { + $first = $raw[0]; + $out['token'] = self::tokenStatus($first['token_time_out'] ?? null); + $out['credentials'] = [ + 'otp_set' => ! empty($first['otp_set']), + 'mpin_set' => ! empty($first['mpin_set']), + 'password_set' => ! empty($first['password_set']), + 'biometric_set' => ! empty($first['biometric_set']), + 'mpin_skipped' => ! empty($first['mpin_skipped']), + 'is_biometric_enabled' => $first['is_biometric_enabled'] ?? null, + 'is_mpin_skipped' => $first['is_mpin_skipped'] ?? null, + ]; + } + + // Mark winner among duplicates + foreach ($out['raw_matches'] as $i => &$row) { + $row['is_login_winner'] = false; + if ($out['eligible'] && (int) $row['id'] === (int) $out['eligible']['id']) { + $row['is_login_winner'] = true; + } elseif ($out['eligible'] === null && $i === 0) { + $row['is_login_pick_if_filters_passed'] = true; + } + } + unset($row); + + $classified = self::classifyRelationshipDuplicates($out['raw_matches']); + $out['raw_matches'] = $classified['rows']; + $out['duplicates'] = $classified['summary']; + + $policyEmployeeId = null; + if (! empty($out['eligible']['id'])) { + $policyEmployeeId = (int) $out['eligible']['id']; + } else { + foreach ($out['raw_matches'] as $row) { + if (! empty($row['is_self'])) { + $policyEmployeeId = (int) ($row['id'] ?? 0); + break; + } + } + } + if ($policyEmployeeId) { + try { + $out['policies'] = self::fetchEmployeeClientPolicies($db, $policyEmployeeId, $side); + } catch (\Throwable $policyEx) { + $out['policies'] = []; + $out['policies_error'] = $policyEx->getMessage(); + } + } + + if ($side === 'post') { + $out['eligible_via_api'] = self::fetchPostEligibleViaApi($email, $mobile); + } + } catch (\Throwable $e) { + $out['error'] = $e->getMessage(); + } + + return $out; + } + + private static function fetchRawMatches($db, ?string $email, ?string $mobile): array + { + $builder = $db->table('employees e') + ->select('e.id, e.client_id, e.client_branch_id, e.emp_code, e.name, e.relationship, e.mobile, e.email_corporate, e.email_personal, e.emp_status, e.is_active, e.otp, e.mpin, e.password, e.is_biometric_enabled, e.is_mpin_skipped, e.token_time_out, e.created_at, e.updated_at') + ->select('GROUP_CONCAT(DISTINCT CONCAT(ep.id, ":", ep.status, ":", ep.is_active) SEPARATOR ", ") AS policies', false) + ->join('employee_polices ep', 'ep.employee_id = e.id', 'left') + ->groupBy('e.id') + ->orderBy('e.id', 'DESC'); + + self::applyIdentity($builder, $email, $mobile, 'e'); + + $rows = $builder->get()->getResultArray(); + return array_map(static function (array $row): array { + $row['otp_set'] = ! empty($row['otp']); + $row['mpin_set'] = isset($row['mpin']) && $row['mpin'] !== null && $row['mpin'] !== ''; + $row['password_set'] = ! empty($row['password']); + $row['biometric_set'] = self::isBiometricEnabled($row['is_biometric_enabled'] ?? null); + $row['mpin_skipped'] = self::isTruthyFlag($row['is_mpin_skipped'] ?? null); + return self::publicEmployeeRow($row); + }, $rows); + } + + private static function fetchByPersonalEmail($db, string $email): array + { + $rows = $db->table('employees') + ->select('id, client_id, emp_code, name, relationship, mobile, email_corporate, email_personal, emp_status, is_active') + ->where('email_personal', $email) + ->orderBy('id', 'DESC') + ->get() + ->getResultArray(); + + return array_map([self::class, 'publicEmployeeRow'], $rows); + } + + private static function fetchEligibleMatches($db, ?string $email, ?string $mobile, array $statusEmp, array $statusPol): array + { + $builder = $db->table('employees e') + ->select('e.*') + ->join('employee_polices ep', 'ep.employee_id = e.id', 'inner') + ->where('e.is_active', 1) + ->where("TRIM(e.relationship) = 'self'", null, false) + ->whereIn('e.emp_status', $statusEmp) + ->where('ep.is_active', 1) + ->whereIn('ep.status', $statusPol) + ->orderBy('e.id', 'DESC'); + + self::applyIdentity($builder, $email, $mobile, 'e'); + + return $builder->get()->getResultArray(); + } + + private static function applyIdentity($builder, ?string $email, ?string $mobile, string $alias): void + { + // Mirror login helper: mobile wins if both provided + if ($mobile !== null) { + $builder->where("{$alias}.mobile", $mobile); + } elseif ($email !== null) { + $builder->where("{$alias}.email_corporate", $email); + } + } + + private static function fetchClient($db, int $clientId, string $side = 'pre'): array + { + if ($clientId <= 0) { + return []; + } + + if ($side === 'pre') { + $clientModel = new ClientModel(); + $row = $clientModel->select('id, client_name, short_name, is_active') + ->where('id', $clientId) + ->first(); + return $row ?: []; + } + + $row = $db->table('clients') + ->select('id, client_name, short_name, is_active') + ->where('id', $clientId) + ->get() + ->getRowArray(); + + return $row ?: []; + } + + private static function fetchPostEligibleViaApi(?string $email, ?string $mobile): ?array + { + try { + $url = rtrim((string) env('POST_ENROLLMENT_BASEURL'), '/') . '/getPostEmployeeDataForAuth'; + $payload = []; + if ($mobile !== null) { + $payload['mobile_number'] = $mobile; + } elseif ($email !== null) { + $payload['email_id'] = $email; + } + $client = \Config\Services::curlrequest(['timeout' => 10]); + $response = $client->post($url, [ + 'json' => $payload, + 'headers' => ['App-Signature' => (string) getenv('APP_SIGNATURE')], + 'http_errors' => false, + ]); + $body = json_decode($response->getBody(), true); + $data = $body['data'] ?? []; + if (empty($data) || ! is_array($data)) { + return null; + } + return self::publicEmployeeRow($data); + } catch (\Throwable $e) { + return null; + } + } + + private static function scoreFilters(array $rawMatches, array $statusEmp, array $statusPol): array + { + if (empty($rawMatches)) { + return [ + 'raw_found' => false, + 'self' => false, + 'is_active' => false, + 'emp_status_ok' => false, + 'has_policy' => false, + 'policy_status_ok' => false, + 'notes' => ['No raw employee row for this identity.'], + ]; + } + + // Score against highest-id row (login pick order) + $row = $rawMatches[0]; + $rel = strtolower(trim((string) ($row['relationship'] ?? ''))); + $selfOk = $rel === 'self'; + $activeOk = (int) ($row['is_active'] ?? 0) === 1; + $empOk = in_array((string) ($row['emp_status'] ?? ''), $statusEmp, true); + + $policies = (string) ($row['policies'] ?? ''); + $hasPolicy = $policies !== '' && $policies !== null; + $policyOk = false; + if ($hasPolicy) { + foreach (explode(', ', $policies) as $p) { + // id:status:is_active + $parts = explode(':', $p); + if (count($parts) >= 3) { + $st = $parts[1]; + $ia = (int) $parts[2]; + if ($ia === 1 && in_array($st, $statusPol, true)) { + $policyOk = true; + break; + } + } + } + } + + $notes = []; + if (! $selfOk) { + $notes[] = 'relationship is "' . ($row['relationship'] ?? '') . '" (need self).'; + } + if (! $activeOk) { + $notes[] = 'employee is_active != 1.'; + } + if (! $empOk) { + $notes[] = 'emp_status "' . ($row['emp_status'] ?? '') . '" not in [' . implode(',', $statusEmp) . '].'; + } + if (! $hasPolicy) { + $notes[] = 'no employee_polices row.'; + } elseif (! $policyOk) { + $notes[] = 'no policy with status in [' . implode(',', $statusPol) . '] and is_active=1.'; + } + + return [ + 'raw_found' => true, + 'self' => $selfOk, + 'is_active' => $activeOk, + 'emp_status_ok' => $empOk, + 'has_policy' => $hasPolicy, + 'policy_status_ok' => $policyOk, + 'notes' => $notes, + 'scored_employee_id' => $row['id'] ?? null, + ]; + } + + private static function tokenStatus($tokenTimeOut): array + { + $timeoutEnv = (int) (getenv('TOKENTIMEOUT') ?: 0); + if ($tokenTimeOut === null || $tokenTimeOut === '' || $tokenTimeOut === false) { + return [ + 'token_time_out' => null, + 'status' => 'NULL', + 'remaining_seconds' => null, + 'human' => 'No session (token_time_out is null)', + 'tokentimeout_env' => $timeoutEnv, + ]; + } + + $epoch = (int) $tokenTimeOut; + $now = time(); + $remaining = $epoch - $now; + $status = $remaining > 0 ? 'VALID' : 'EXPIRED'; + + return [ + 'token_time_out' => $epoch, + 'status' => $status, + 'remaining_seconds' => $remaining, + 'human' => date('Y-m-d H:i:s', $epoch) . ($remaining > 0 ? " ({$remaining}s left)" : ' (expired)'), + 'tokentimeout_env' => $timeoutEnv, + ]; + } + + private static function publicEmployeeRow(array $row): array + { + unset($row['otp'], $row['mpin'], $row['password']); + // Keep boolean flags only if credentials were extracted separately + return $row; + } + + private static function isBiometricEnabled($value): bool + { + return self::isTruthyFlag($value); + } + + private static function isTruthyFlag($value): bool + { + if ($value === null || $value === '') { + return false; + } + if (is_bool($value)) { + return $value; + } + + return in_array((string) $value, ['1', 'true', 'yes', 'on'], true); + } + + private static function simulateMerge(array $pre, array $post): array + { + $preEligible = $pre['eligible'] ?? null; + $postEligible = $post['eligible'] ?? ($post['eligible_via_api'] ?? null); + + $result = [ + 'pre_eligible' => ! empty($preEligible), + 'post_eligible' => ! empty($postEligible), + 'pre_short_name' => $preEligible['client_short_name'] ?? ($pre['client_short_name'] ?? null), + 'post_short_name' => $postEligible['client_short_name'] ?? ($post['client_short_name'] ?? null), + 'pre_created_at' => $preEligible['created_at'] ?? null, + 'post_created_at' => $postEligible['created_at'] ?? null, + 'short_name_match' => null, + 'would_return_both' => false, + 'winner' => null, + 'otp_sync_possible' => false, + 'reason' => '', + ]; + + if (empty($preEligible) && empty($postEligible)) { + $result['reason'] = 'Neither PRE nor POST has a login-eligible employee.'; + $result['winner'] = 'none'; + return $result; + } + + if (! empty($preEligible) && empty($postEligible)) { + $result['winner'] = 'pre'; + $result['reason'] = 'PRE-only eligible path.'; + return $result; + } + + if (empty($preEligible) && ! empty($postEligible)) { + $result['winner'] = 'post'; + $result['reason'] = 'POST-only path (PRE empty — login proxies to POST).'; + return $result; + } + + $preSn = (string) ($result['pre_short_name'] ?? ''); + $postSn = (string) ($result['post_short_name'] ?? ''); + $result['short_name_match'] = $preSn === $postSn; + + $preTs = strtotime((string) ($result['pre_created_at'] ?? '')) ?: 0; + $postTs = strtotime((string) ($result['post_created_at'] ?? '')) ?: 0; + $latest = $preTs > $postTs ? 'pre' : 'post'; + + if ($result['short_name_match']) { + $result['would_return_both'] = true; + $result['winner'] = 'both'; + $result['otp_sync_possible'] = true; + $result['reason'] = 'Same client_short_name — merge keeps both; OTP sync possible.'; + } else { + $result['would_return_both'] = false; + $result['winner'] = $latest; + $result['otp_sync_possible'] = false; + $result['reason'] = "client_short_name mismatch ('{$preSn}' vs '{$postSn}'). Only latest ({$latest}) is kept; other side dropped."; + } + + return $result; + } + + /** + * Client policies linked to this employee (Self) via employee_polices. + * PRE: enrollment open/close come from employee_polices (what the employee app uses). + * + * @return list> + */ + private static function fetchEmployeeClientPolicies($db, int $employeeId, string $side): array + { + if ($employeeId <= 0) { + return []; + } + + $builder = $db->table('employee_polices ep') + ->select('ep.id AS emp_policy_id, ep.employee_id, ep.client_policy_id, ep.status AS emp_policy_status, ep.is_active AS emp_policy_is_active') + ->select('cp.policy_no, cp.policy_start_date, cp.policy_end_date, cp.open_date, cp.close_date, cp.open_for_enrollment, cp.is_active AS client_policy_is_active, cp.client_id, cp.is_addon') + ->join('client_policy cp', 'cp.id = ep.client_policy_id', 'left') + ->where('ep.employee_id', $employeeId) + ->orderBy('ep.id', 'DESC'); + + // Enrollment dates exist only on PRE; wellness fields exist only on POST + if ($side === 'pre') { + $builder->select('ep.enrollment_open_date, ep.enrollment_close_date'); + } else { + $builder->select('ep.wellness_onboard, cp.wellness_plan_id, cp.wellness_vendor_id, cp.policy_type_id'); + } + + $rows = $builder->get()->getResultArray(); + $today = date('Y-m-d'); + $out = []; + + foreach ($rows as $row) { + $open = $side === 'pre' ? self::normalizeDate($row['enrollment_open_date'] ?? null) : null; + $close = $side === 'pre' ? self::normalizeDate($row['enrollment_close_date'] ?? null) : null; + $window = $side === 'pre' + ? self::enrollmentWindowStatus($open, $close, $today) + : 'N/A'; + + $wellnessOnboard = $side === 'post' ? ($row['wellness_onboard'] ?? null) : null; + $wellnessReady = $side === 'post' ? self::isWellnessOnboarded($wellnessOnboard) : null; + + $out[] = [ + 'employee_id' => $row['employee_id'] ?? $employeeId, + 'emp_policy_id' => $row['emp_policy_id'] ?? null, + 'client_policy_id' => $row['client_policy_id'] ?? null, + 'client_id' => $row['client_id'] ?? null, + 'policy_no' => $row['policy_no'] ?? null, + 'policy_type_id' => $side === 'post' ? ($row['policy_type_id'] ?? null) : null, + 'emp_policy_status' => $row['emp_policy_status'] ?? null, + 'emp_policy_is_active' => $row['emp_policy_is_active'] ?? null, + 'client_policy_is_active' => $row['client_policy_is_active'] ?? null, + 'is_addon' => $row['is_addon'] ?? null, + 'policy_start_date' => $row['policy_start_date'] ?? null, + 'policy_end_date' => $row['policy_end_date'] ?? null, + 'client_open_date' => $row['open_date'] ?? null, + 'client_close_date' => $row['close_date'] ?? null, + 'open_for_enrollment' => $row['open_for_enrollment'] ?? null, + 'enrollment_open_date' => $open, + 'enrollment_close_date' => $close, + 'enrollment_window' => $window, + 'show_enrollment_dates' => $side === 'pre', + 'wellness_onboard' => $wellnessOnboard, + 'wellness_onboard_raw' => $side === 'post' ? self::rawFlag($wellnessOnboard) : null, + 'wellness_ready' => $wellnessReady, + 'wellness_plan_id' => $side === 'post' ? ($row['wellness_plan_id'] ?? null) : null, + 'wellness_vendor_id' => $side === 'post' ? ($row['wellness_vendor_id'] ?? null) : null, + 'show_wellness' => $side === 'post', + ]; + } + + return $out; + } + + /** Matches POST getWellnessUrl: wellness_onboard != 0 (and not null/empty). */ + private static function isWellnessOnboarded($value): bool + { + if ($value === null || $value === '') { + return false; + } + + return (string) $value !== '0'; + } + + private static function rawFlag($value): string + { + if ($value === null || $value === '') { + return 'null'; + } + + return (string) $value; + } + + private static function hasWellnessPlanConfigured($planId): bool + { + if ($planId === null || $planId === '') { + return false; + } + + return (string) $planId !== '0'; + } + + private static function normalizeDate($value): ?string + { + if ($value === null || $value === '' || $value === '0000-00-00') { + return null; + } + $ts = strtotime((string) $value); + if ($ts === false) { + return (string) $value; + } + + return date('Y-m-d', $ts); + } + + private static function enrollmentWindowStatus(?string $open, ?string $close, string $today): string + { + if ($open === null && $close === null) { + return 'MISSING'; + } + if ($open !== null && $today < $open) { + return 'NOT_STARTED'; + } + if ($close !== null && $today > $close) { + return 'CLOSED'; + } + if ($open !== null && $close !== null && $today >= $open && $today <= $close) { + return 'OPEN'; + } + if ($open !== null && $close === null && $today >= $open) { + return 'OPEN'; + } + + return 'UNKNOWN'; + } + + /** + * Split Self vs Family; flag Self dups by client_id+emp_code; Family dups by client+name+rel. + * + * @param list> $rows + * @return array{rows: list>, summary: array} + */ + private static function classifyRelationshipDuplicates(array $rows): array + { + $selfRows = []; + $familyRows = []; + + foreach ($rows as $i => $row) { + $isSelf = self::isSelfRelationship($row['relationship'] ?? null); + $rows[$i]['is_self'] = $isSelf; + $rows[$i]['is_family'] = ! $isSelf; + $rows[$i]['is_self_duplicate'] = false; + $rows[$i]['is_family_duplicate'] = false; + $rows[$i]['is_cross_self_dup'] = false; + $rows[$i]['dup_badge'] = null; + + if ($isSelf) { + $selfRows[] = $i; + } else { + $familyRows[] = $i; + } + } + + // Self: group by client_id + emp_code + $selfGroups = []; + foreach ($selfRows as $i) { + $client = (string) ($rows[$i]['client_id'] ?? ''); + $code = trim((string) ($rows[$i]['emp_code'] ?? '')); + if ($code === '') { + $code = '__empty_emp_code__'; + } + $key = $client . '|' . $code; + $selfGroups[$key][] = $i; + } + + $selfDupGroups = []; + foreach ($selfGroups as $key => $indices) { + if (count($indices) < 2) { + continue; + } + [$clientId, $empCode] = explode('|', $key, 2); + $ids = []; + foreach ($indices as $i) { + $rows[$i]['is_self_duplicate'] = true; + $rows[$i]['dup_badge'] = 'SELF DUP'; + $ids[] = (int) ($rows[$i]['id'] ?? 0); + } + $selfDupGroups[] = [ + 'client_id' => $clientId, + 'emp_code' => $empCode === '__empty_emp_code__' ? '' : $empCode, + 'count' => count($indices), + 'ids' => $ids, + ]; + } + + // Family: group by client_id + lower(name) + lower(rel) + $familyGroups = []; + foreach ($familyRows as $i) { + $client = (string) ($rows[$i]['client_id'] ?? ''); + $name = strtolower(trim((string) ($rows[$i]['name'] ?? ''))); + $rel = strtolower(trim((string) ($rows[$i]['relationship'] ?? ''))); + $key = $client . '|' . $name . '|' . $rel; + $familyGroups[$key][] = $i; + } + + $familyDupGroups = []; + foreach ($familyGroups as $key => $indices) { + if (count($indices) < 2) { + continue; + } + $parts = explode('|', $key, 3); + $ids = []; + foreach ($indices as $i) { + $rows[$i]['is_family_duplicate'] = true; + if (empty($rows[$i]['dup_badge'])) { + $rows[$i]['dup_badge'] = 'FAMILY DUP'; + } + $ids[] = (int) ($rows[$i]['id'] ?? 0); + } + $familyDupGroups[] = [ + 'client_id' => $parts[0] ?? '', + 'name' => $parts[1] ?? '', + 'relationship' => $parts[2] ?? '', + 'count' => count($indices), + 'ids' => $ids, + ]; + } + + $selfList = []; + $familyList = []; + foreach ($rows as $row) { + if (! empty($row['is_self'])) { + $selfList[] = $row; + } else { + $familyList[] = $row; + } + } + + return [ + 'rows' => $rows, + 'summary' => [ + 'self_count' => count($selfList), + 'family_count' => count($familyList), + 'self_rows' => $selfList, + 'family_rows' => $familyList, + 'self_duplicate_groups' => $selfDupGroups, + 'family_duplicate_groups' => $familyDupGroups, + 'has_self_duplicates' => $selfDupGroups !== [], + 'has_family_duplicates' => $familyDupGroups !== [], + 'cross_self_dup_emp_codes'=> [], + ], + ]; + } + + private static function isSelfRelationship($relationship): bool + { + return strtolower(trim((string) $relationship)) === 'self'; + } + + /** + * If Self is duplicated on one side for emp_code, highlight same emp_code Self rows on the other side. + * + * @return array{0: array, 1: array} + */ + private static function crossLinkSelfDuplicates(array $pre, array $post): array + { + $preDupCodes = self::selfDupEmpCodes($pre); + $postDupCodes = self::selfDupEmpCodes($post); + + $preSelfCodes = self::selfEmpCodes($pre); + $postSelfCodes = self::selfEmpCodes($post); + + // Highlight peer side when this emp_code is Self-duplicated on one DB and exists as Self on the other + $crossFromPre = array_values(array_intersect($preDupCodes, $postSelfCodes)); + $crossFromPost = array_values(array_intersect($postDupCodes, $preSelfCodes)); + $crossCodes = array_values(array_unique(array_merge($crossFromPre, $crossFromPost))); + + if ($crossCodes === [] && $preDupCodes === [] && $postDupCodes === []) { + return [$pre, $post]; + } + + $markCodes = array_values(array_unique(array_merge($preDupCodes, $postDupCodes, $crossCodes))); + $pre = self::markCrossSelfDupRows($pre, $markCodes); + $post = self::markCrossSelfDupRows($post, $markCodes); + + if (! isset($pre['duplicates']) || ! is_array($pre['duplicates'])) { + $pre['duplicates'] = []; + } + if (! isset($post['duplicates']) || ! is_array($post['duplicates'])) { + $post['duplicates'] = []; + } + $pre['duplicates']['cross_self_dup_emp_codes'] = $crossCodes; + $post['duplicates']['cross_self_dup_emp_codes'] = $crossCodes; + + return [$pre, $post]; + } + + private static function selfEmpCodes(array $side): array + { + $codes = []; + foreach ($side['raw_matches'] ?? [] as $row) { + if (empty($row['is_self'])) { + continue; + } + $code = trim((string) ($row['emp_code'] ?? '')); + if ($code !== '') { + $codes[] = $code; + } + } + + return array_values(array_unique($codes)); + } + + private static function selfDupEmpCodes(array $side): array + { + $codes = []; + foreach ($side['duplicates']['self_duplicate_groups'] ?? [] as $g) { + $code = trim((string) ($g['emp_code'] ?? '')); + if ($code !== '') { + $codes[] = $code; + } + } + + return array_values(array_unique($codes)); + } + + private static function markCrossSelfDupRows(array $side, array $empCodes): array + { + $codeSet = array_fill_keys($empCodes, true); + + foreach ($side['raw_matches'] ?? [] as $i => $row) { + if (empty($row['is_self'])) { + continue; + } + $code = trim((string) ($row['emp_code'] ?? '')); + if ($code !== '' && isset($codeSet[$code])) { + $side['raw_matches'][$i]['is_cross_self_dup'] = true; + if (empty($side['raw_matches'][$i]['dup_badge'])) { + $side['raw_matches'][$i]['dup_badge'] = 'SELF DUP LINK'; + } elseif ($side['raw_matches'][$i]['dup_badge'] === 'SELF DUP') { + $side['raw_matches'][$i]['dup_badge'] = 'SELF DUP'; + } + } + } + + // Refresh grouped lists from annotated raw_matches + if (! empty($side['duplicates'])) { + $selfList = []; + $familyList = []; + foreach ($side['raw_matches'] as $row) { + if (! empty($row['is_self'])) { + $selfList[] = $row; + } else { + $familyList[] = $row; + } + } + $side['duplicates']['self_rows'] = $selfList; + $side['duplicates']['family_rows'] = $familyList; + } + + return $side; + } + + private static function addDuplicateProblems(callable $add, string $label, array $side): void + { + $dup = $side['duplicates'] ?? null; + if (! is_array($dup)) { + return; + } + + $prefix = strtoupper($label); + + foreach ($dup['self_duplicate_groups'] ?? [] as $g) { + $empCode = (string) ($g['emp_code'] ?? ''); + $client = (string) ($g['client_id'] ?? ''); + $count = (int) ($g['count'] ?? 0); + $ids = implode(', ', $g['ids'] ?? []); + $add( + 'high', + $prefix . '_SELF_DUPLICATES', + "{$label}: {$count} Self rows for emp_code '{$empCode}' under client_id {$client} (ids: {$ids}). Login picks highest id.", + 'Deactivate extra Self rows; keep one Self per client_id + emp_code.' + ); + } + + foreach ($dup['family_duplicate_groups'] ?? [] as $g) { + $name = (string) ($g['name'] ?? ''); + $rel = (string) ($g['relationship'] ?? ''); + $client = (string) ($g['client_id'] ?? ''); + $count = (int) ($g['count'] ?? 0); + $ids = implode(', ', $g['ids'] ?? []); + $add( + 'medium', + $prefix . '_FAMILY_DUPLICATES', + "{$label}: {$count} Family rows for '{$name}' / {$rel} under client_id {$client} (ids: {$ids}).", + 'Review family duplicates; deactivate wrong dependent rows.' + ); + } + } + + /** POST-only: wellness_onboard must be not null and not 0 for getWellnessUrl. */ + private static function addPostWellnessProblems(callable $add, array $post): void + { + $policies = $post['policies'] ?? []; + if ($policies === []) { + return; + } + + $anyPlanConfigured = false; + $anyOnboarded = false; + $notOnboardedWithPlan = []; + + foreach ($policies as $pol) { + $planOk = self::hasWellnessPlanConfigured($pol['wellness_plan_id'] ?? null); + $onboarded = ! empty($pol['wellness_ready']); + if ($planOk) { + $anyPlanConfigured = true; + } + if ($onboarded) { + $anyOnboarded = true; + } + if ($planOk && ! $onboarded) { + $notOnboardedWithPlan[] = $pol; + } + } + + if ($anyOnboarded) { + return; // at least one policy can drive wellness SSO + } + + if ($notOnboardedWithPlan !== []) { + foreach ($notOnboardedWithPlan as $pol) { + $pno = (string) ($pol['policy_no'] ?? $pol['client_policy_id'] ?? ''); + $raw = (string) ($pol['wellness_onboard_raw'] ?? 'null'); + $add( + 'high', + 'POST_WELLNESS_NOT_ONBOARDED', + "POST policy {$pno}: wellness_plan_id set but wellness_onboard is {$raw} (need not null and not 0). Wellness SSO will return Coming soon.", + 'Run wellness onboard for this client_policy so employee_polices.wellness_onboard is set to a non-zero value.' + ); + } + return; + } + + // No policy has wellness_onboard set (null/0) — always flag on POST + $samples = []; + foreach ($policies as $pol) { + $pno = (string) ($pol['policy_no'] ?? $pol['emp_policy_id'] ?? ''); + $raw = (string) ($pol['wellness_onboard_raw'] ?? 'null'); + $samples[] = "{$pno}={$raw}"; + } + $add( + 'medium', + 'POST_WELLNESS_ONBOARD_EMPTY', + 'POST: no employee_polices row has wellness_onboard set (not null / not 0). Values: ' . implode(', ', $samples) . '.', + 'Onboard the employee to wellness on the live policy (POST only). Login can still succeed without this.' + ); + + if ($anyPlanConfigured === false && ! empty($post['eligible'])) { + $add( + 'low', + 'POST_WELLNESS_PLAN_NOT_CONFIGURED', + 'POST employee policies have no wellness_plan_id; wellness SSO will not work.', + 'Set client_policy.wellness_plan_id (and vendor if needed) on the live policy, then onboard employees.' + ); + } + } + + private static function buildProblems(?string $email, ?string $mobile, array $pre, array $post, array $merge, array $connectivity): array + { + $problems = []; + + $add = static function (string $sev, string $code, string $msg, string $fix) use (&$problems) { + $problems[] = [ + 'severity' => $sev, + 'code' => $code, + 'message' => $msg, + 'fix' => $fix, + ]; + }; + + if (empty($connectivity['app_signature'])) { + $add('high', 'APP_SIGNATURE_MISSING', 'APP_SIGNATURE env is empty.', 'Set APP_SIGNATURE on PRE to match POST.'); + } + if (empty($connectivity['api_reachable'])) { + $add('high', 'POST_API_UNREACHABLE', $connectivity['api_message'] ?? 'POST employeeRest API not reachable.', 'Check POST_ENROLLMENT_BASEURL and App-Signature.'); + } + if (empty($connectivity['post_db'])) { + $add('medium', 'POST_DB_UNAVAILABLE', 'Direct postDB connection failed: ' . ($connectivity['post_db_message'] ?? ''), 'Fix database.postDB.* in .env for richer POST diagnostics.'); + } + + if ($email !== null && empty($pre['raw_matches']) && ! empty($pre['raw_by_personal'])) { + $add('high', 'EMAIL_IN_PERSONAL_ONLY', 'Email found in email_personal on PRE but login only checks email_corporate.', 'Copy/fix email_corporate on the self employee row.'); + } + + if (empty($pre['raw_matches']) && empty($post['raw_matches']) && empty($post['eligible_via_api'])) { + $add('high', 'NOT_IN_EITHER_DB', 'No employee row matched this identity in PRE or POST (raw).', 'Verify mobile/email_corporate spelling and formatting.'); + } + + if (! empty($pre['raw_matches']) && empty($pre['eligible'])) { + $notes = implode(' ', $pre['filters_summary']['notes'] ?? []); + $add('high', 'PRE_NOT_ELIGIBLE', 'PRE has raw match(es) but not login-eligible. ' . $notes, 'Fix relationship/status/policy to draft|enrolled for enrollment login.'); + } + + if (! empty($post['raw_matches']) && empty($post['eligible']) && empty($post['eligible_via_api'])) { + $notes = implode(' ', $post['filters_summary']['notes'] ?? []); + $add('high', 'POST_NOT_ELIGIBLE', 'POST has raw match(es) but not login-eligible. ' . $notes, 'Fix relationship/status/policy to active|expired for live login.'); + } + + if (($merge['winner'] ?? '') === 'none') { + $add('high', 'NO_ELIGIBLE_SIDE', 'Neither PRE nor POST is login-eligible.', 'Fix eligibility filters on at least one side.'); + } + + if ($merge['short_name_match'] === false && ($merge['pre_eligible'] ?? false) && ($merge['post_eligible'] ?? false)) { + $add( + 'high', + 'CLIENT_SHORT_NAME_MISMATCH', + $merge['reason'], + 'Align clients.short_name between PRE and POST for the same employer, or correct wrong client mapping.' + ); + } + + if (($merge['winner'] ?? '') === 'pre' && empty($merge['post_eligible'])) { + $add('medium', 'PRE_ONLY', 'Login will use PRE only; no POST eligible employee.', 'Normal mid-enrollment; after push to live, POST should become eligible.'); + } + + if (($merge['winner'] ?? '') === 'post' && empty($merge['pre_eligible'])) { + $add('medium', 'POST_ONLY', 'Login will proxy to POST only (no PRE eligible).', 'OK for live-only users; SAML requiring PRE will still fail.'); + } + + self::addDuplicateProblems($add, 'PRE', $pre); + self::addDuplicateProblems($add, 'POST', $post); + + if (! empty($pre['duplicates']['cross_self_dup_emp_codes'])) { + $codes = implode(', ', $pre['duplicates']['cross_self_dup_emp_codes']); + $add( + 'high', + 'PRE_POST_SELF_DUPLICATE', + "Self duplicate involves emp_code(s) [{$codes}] across PRE/POST (same emp_code; check same client). Login picks highest id per side.", + 'Keep one active Self row per client_id + emp_code on each DB; deactivate extras.' + ); + } + + // PRE employee enrollment window (employee_polices dates) + if (! empty($pre['policies_error'])) { + $add('medium', 'PRE_POLICIES_FETCH_ERROR', 'PRE policy list failed: ' . $pre['policies_error'], 'Check employee_polices / client_policy schema on enrollment DB.'); + } + if (! empty($post['policies_error'])) { + $add('medium', 'POST_POLICIES_FETCH_ERROR', 'POST policy list failed: ' . $post['policies_error'], 'POST has no enrollment_open/close columns — query must not select them.'); + } + if (! empty($post['eligible']) && empty($post['policies']) && empty($post['policies_error'])) { + $add( + 'medium', + 'POST_NO_POLICIES_LISTED', + 'POST employee is login-eligible but no employee_polices rows were returned for that employee id.', + 'Check POST employee_polices for that employee_id; eligibility join may differ from policy list query.' + ); + } + + // POST-only wellness_onboard (matches getWellnessUrl: wellness_onboard != 0) + self::addPostWellnessProblems($add, $post); + + foreach ($pre['policies'] ?? [] as $pol) { + $window = (string) ($pol['enrollment_window'] ?? ''); + $pno = (string) ($pol['policy_no'] ?? $pol['client_policy_id'] ?? ''); + if ($window === 'CLOSED') { + $add( + 'medium', + 'PRE_ENROLLMENT_CLOSED', + "PRE policy {$pno}: enrollment closed (close {$pol['enrollment_close_date']}).", + 'Extend enrollment_close_date on employee_polices or inform employee enrollment is closed.' + ); + } elseif ($window === 'NOT_STARTED') { + $add( + 'medium', + 'PRE_ENROLLMENT_NOT_STARTED', + "PRE policy {$pno}: enrollment not started (open {$pol['enrollment_open_date']}).", + 'Wait until enrollment_open_date or adjust the date on employee_polices.' + ); + } elseif ($window === 'MISSING' && ! empty($pol['emp_policy_id'])) { + $add( + 'low', + 'PRE_ENROLLMENT_DATES_MISSING', + "PRE policy {$pno}: enrollment_open_date / enrollment_close_date not set on employee_polices.", + 'Set enrollment dates on the employee policy (used by the employee app).' + ); + } + } + + $preCred = $pre['credentials'] ?? null; + $postCred = $post['credentials'] ?? null; + if ($preCred && $postCred) { + if (! empty($preCred['mpin_set']) xor ! empty($postCred['mpin_set'])) { + $add('medium', 'MPIN_OUT_OF_SYNC', 'MPIN set on only one side (PRE/POST).', 'Re-save MPIN after login when both sides are in merge, or set on both DBs.'); + } + if (! empty($preCred['password_set']) xor ! empty($postCred['password_set'])) { + $add('medium', 'PASSWORD_OUT_OF_SYNC', 'Password set on only one side. Password is not auto-synced.', 'Set/change password on both apps or use OTP/MPIN.'); + } + if (! empty($preCred['biometric_set']) xor ! empty($postCred['biometric_set'])) { + $add('medium', 'BIOMETRIC_OUT_OF_SYNC', 'Biometric enabled on only one side (PRE/POST).', 'Align is_biometric_enabled on both DBs or re-enable biometric after login when merge keeps both.'); + } + } + + $preTok = $pre['token']['status'] ?? null; + $postTok = $post['token']['status'] ?? null; + if ($preTok === 'VALID' && ($postTok === 'NULL' || $postTok === 'EXPIRED')) { + $add('medium', 'TOKEN_HALF_SESSION', 'PRE session valid but POST session null/expired — half-login risk.', 'User may need to re-verify OTP so both tokens refresh, or use correct token per app.'); + } + if ($postTok === 'VALID' && ($preTok === 'NULL' || $preTok === 'EXPIRED')) { + $add('low', 'TOKEN_POST_ONLY_SESSION', 'POST has active token_time_out; PRE does not.', 'Usually fine for POST-only usage.'); + } + if ($preTok === 'EXPIRED') { + $add('low', 'TOKEN_PRE_EXPIRED', 'PRE token_time_out is expired.', 'User must login again; logout API does not clear timeout today.'); + } + if ($postTok === 'EXPIRED') { + $add('low', 'TOKEN_POST_EXPIRED', 'POST token_time_out is expired.', 'User must login again on live app.'); + } + + if (! empty($pre['error'])) { + $add('high', 'PRE_QUERY_ERROR', 'PRE analysis error: ' . $pre['error'], 'Check enrollment DB connectivity.'); + } + if (! empty($post['error']) && empty($post['eligible']) && empty($post['eligible_via_api'])) { + $add('high', 'POST_QUERY_ERROR', 'POST analysis error: ' . $post['error'], 'Check postDB and/or POST API.'); + } + + // Severity sort + $order = ['high' => 0, 'medium' => 1, 'low' => 2]; + usort($problems, static function ($a, $b) use ($order) { + return ($order[$a['severity']] ?? 9) <=> ($order[$b['severity']] ?? 9); + }); + + return $problems; + } + + private static function buildVerdict(array $pre, array $post, array $merge, array $problems): array + { + $hasHigh = false; + foreach ($problems as $p) { + if (($p['severity'] ?? '') === 'high') { + $hasHigh = true; + break; + } + } + + $winner = $merge['winner'] ?? 'none'; + + if ($winner === 'none' || $hasHigh && empty($merge['pre_eligible']) && empty($merge['post_eligible'])) { + return [ + 'status' => 'WILL_FAIL', + 'message' => $problems[0]['message'] ?? 'Login will fail for this identity.', + ]; + } + + if ($winner === 'both' && ! $hasHigh) { + $hasMed = false; + foreach ($problems as $p) { + if (($p['severity'] ?? '') === 'medium') { + $hasMed = true; + break; + } + } + if ($hasMed) { + return [ + 'status' => 'OK_WITH_WARNINGS', + 'message' => 'Both PRE and POST eligible with matching client_short_name, but warnings exist.', + ]; + } + return [ + 'status' => 'OK', + 'message' => 'Both PRE and POST eligible; merge keeps both; OTP sync possible.', + ]; + } + + if ($winner === 'both' && $hasHigh) { + return [ + 'status' => 'OK_WITH_WARNINGS', + 'message' => 'Both sides eligible but high-severity issues remain — review problems list.', + ]; + } + + // pre-only, post-only, or mismatch latest-only + return [ + 'status' => 'PARTIAL', + 'message' => $merge['reason'] ?: 'Only one side will be used for login.', + ]; + } +} diff --git a/app/Helpers/excel_util_helper.php b/app/Helpers/excel_util_helper.php index c1f6159..9acb4f6 100755 --- a/app/Helpers/excel_util_helper.php +++ b/app/Helpers/excel_util_helper.php @@ -317,8 +317,7 @@ if(!function_exists('check_si')) $return_array['error'] = !empty($return_array['error']) ? ($return_array['error'].', '.'Sum insured not configured for this age slab') : 'Sum insured not configured for this age slab'; } - if(empty($received_si)) - { + if (isset($received_si) && trim((string) $received_si) === '') { $return_array['status'] = false; $return_array['error'] = "Sum insured value mandantory"; } diff --git a/app/Views/admin/login_diagnostic.php b/app/Views/admin/login_diagnostic.php new file mode 100644 index 0000000..7be4615 --- /dev/null +++ b/app/Views/admin/login_diagnostic.php @@ -0,0 +1,632 @@ + + + + + +<?= esc($title ?? 'Employee Login Diagnostic') ?> + + + +
+ +
+

Employee login diagnostic

+

PRE (enrollment) + POST (live) · read-only · does not send OTP

+
+ +
+
+
+ + +
+
+ + +
+ + Clear +

If both are filled, login logic uses mobile first (same as auth APIs).

+
+
+ + + 'status-ok', + 'OK_WITH_WARNINGS' => 'status-warn', + 'PARTIAL' => 'status-partial', + default => 'status-fail', + }; + $pre = $result['pre'] ?? []; + $post = $result['post'] ?? []; + $merge = $result['merge'] ?? []; + $problems = $result['problems'] ?? []; + $connectivity = $result['connectivity'] ?? []; + $pc = $pre['credentials'] ?? []; + $oc = $post['credentials'] ?? []; + $pt = $pre['token'] ?? []; + $ot = $post['token'] ?? []; + $yn = static fn ($ok) => $ok ? 'Yes' : 'No'; + $rawFlag = static function ($val): string { + if ($val === null || $val === '') { + return 'null'; + } + return (string) $val; + }; + $mark = static fn (bool $ok) => $ok ? '' : ''; +?> + +
+
+ + +
+ +
+ +
Problems (failed checks only)
+ + + + + + + + + + + + + + + + + + + + +
SeverityCodeMessageFix
No problems detected.
+ +
+
+
PRE (enrollment)
+ $pre, 'mark' => $mark]) ?> +
+
+
POST (live)
+ $post, 'mark' => $mark]) ?> +
+
+ +
+ + + + + + + +
+ +
+ + + + + + + + + + + + +
PRE eligible
POST eligible
PRE short_name
POST short_name
short_name match
PRE created_at
POST created_at
Would return both
Winner
OTP sync possible
Reason
+
+ +
+
+ +
+
+
Self ()
+ $selfRows]) ?> +
Family ()
+ $familyRows]) ?> +
+ + + +
+
PRE matches on email_personal only
+ $pre['raw_by_personal']]) ?> +
+ + +
+
+ +
+
+

PRE: enrollment open/close on employee_polices. POST: live policy mapping. Wellness details are on the Wellness tab (POST only).

+ +
PRE policies error:
+ + +
POST policies error:
+ + 'window-open', + 'CLOSED' => 'window-closed', + 'NOT_STARTED' => 'window-pending', + default => 'window-missing', + }; + }; + $renderPolicies = static function (string $title, array $policies, bool $showEnrollment) use ($windowClass): void { + ?> +
+
()
+ +

None

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Emp policyClient policyPolicy noEP statusEP activePolicy periodEnrollment openEnrollment closeWindowCP open_for_enrollmentCP open/close
+
+ +
+ +
+
+ +
+
+

POST only. Wellness SSO needs wellness_onboard not null and not 0 (same as getWellnessUrl). PRE has no wellness fields.

+ 0; + ?> + + + + + + + + + + + + + +
Overall wellness ready + + ( of policies onboarded) +
Policies with wellness_plan_id
RuleReady = wellness_onboard is not null and not 0
+ +
+
POST wellness by policy ()
+ +

No POST employee policies to check.

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Emp policyClient policyPolicy noEP statusEP activewellness_onboardWellness readywellness_plan_idwellness_vendor_id
+
+ +
+
+
+ +
+ + + + + + + + + + + + + + + + +
PREPOST
OTP set
MPIN set
MPIN skipped
Biometric set
is_biometric_enabled (raw)
Password set
+
+ +
+ + + + + + + + + +
PREPOST
Status
Details
TOKENTIMEOUT env seconds
+
+ +
+ + + + + + + + + +
POST_ENROLLMENT_BASEURL
App-Signature configured
POST API reachable (HTTP )
API message
postDB connected
postDB error
+
+ + + + +
+
+ WILL_FAIL + No diagnostic result returned. +
+
+ + +
+ + diff --git a/app/Views/admin/login_diagnostic_matches.php b/app/Views/admin/login_diagnostic_matches.php new file mode 100644 index 0000000..d7cd483 --- /dev/null +++ b/app/Views/admin/login_diagnostic_matches.php @@ -0,0 +1,62 @@ + + +

None

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IDClientEmp codeNameRelStatusActiveMobileEmail corpPoliciesCreated
+ + WINNER + + + + + + + highest id + +
+
+ diff --git a/app/Views/admin/login_diagnostic_side.php b/app/Views/admin/login_diagnostic_side.php new file mode 100644 index 0000000..5ed9245 --- /dev/null +++ b/app/Views/admin/login_diagnostic_side.php @@ -0,0 +1,48 @@ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Raw found + +
Eligible for login
self
is_active
emp_status OK
has policy
policy status OK
client_id
short_name
employee id
emp_code
name
emp_status
mobile
email_corporate
Notes
API eligible via getPostEmployeeDataForAuth (id )
diff --git a/app/Views/client_policy.php b/app/Views/client_policy.php index 95cfd0f..9c88d56 100755 --- a/app/Views/client_policy.php +++ b/app/Views/client_policy.php @@ -393,13 +393,13 @@ input:checked + .slider:before { Is LGBTQ Enable -
- +
+ +
@@ -1114,11 +1114,7 @@ $('body').on('click', '.btnPolicyEdit', function() { $('#is_lgbtq').prop('checked', false); } - if (res.data.is_premium_summery == 1) { - $('#is_premium_summery').prop('checked', true); - } else { - $('#is_premium_summery').prop('checked', false); - } + $('#is_premium_summery').val(res.data.is_premium_summery ?? 0).trigger('change'); // Member Modify Data Enable or Disable // if (res.data.is_member_modify_allowed == 1) { @@ -2247,11 +2243,7 @@ function getClientPolicyDataForEdit(client_policy_id) { $('#is_lgbtq').prop('checked', false); } - if (res.data.is_premium_summery == 1) { - $('#is_premium_summery').prop('checked', true); - } else { - $('#is_premium_summery').prop('checked', false); - } + $('#is_premium_summery').val(res.data.is_premium_summery ?? 0).trigger('change'); if (res.data.policy_type_id == '4' || res.data.policy_type_id == '5' || res.data.policy_type_id == '72') {