From 41b62315487f65b292770d3baa9e7575f855c323 Mon Sep 17 00:00:00 2001 From: DevSoftTech Server Date: Mon, 2 Nov 2026 11:17:14 +0530 Subject: [PATCH] fix(abdm): scope M1 mobile OTP verified ABHA addresses strictly to selected profile --- app/Controllers/Abha.php | 191 ++++++++++++++++++----- app/Views/partials/abha_mobile_modal.php | 7 + docs/abdm/ABDM_HTC_AUDIT_DEMO_GUIDE.md | 95 +++++++++++ 3 files changed, 255 insertions(+), 38 deletions(-) create mode 100644 docs/abdm/ABDM_HTC_AUDIT_DEMO_GUIDE.md diff --git a/app/Controllers/Abha.php b/app/Controllers/Abha.php index aa0c51d..d62e392 100644 --- a/app/Controllers/Abha.php +++ b/app/Controllers/Abha.php @@ -969,10 +969,14 @@ class Abha extends BaseController return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'Invalid request']); } - $txnId = trim((string) ($this->request->getPost('txn_id') ?? $this->request->getPost('txnId') ?? '')); - $otp = trim((string) ($this->request->getPost('otp') ?? '')); - $authMethod = strtoupper(trim((string) ($this->request->getPost('auth_method') ?? 'MOBILE_OTP'))); - $mobile = preg_replace('/\D/', '', trim((string) ($this->request->getPost('mobile') ?? ''))); + $txnId = trim((string) ($this->request->getPost('txn_id') ?? $this->request->getPost('txnId') ?? '')); + $otp = trim((string) ($this->request->getPost('otp') ?? '')); + $authMethod = strtoupper(trim((string) ($this->request->getPost('auth_method') ?? 'MOBILE_OTP'))); + $mobile = preg_replace('/\D/', '', trim((string) ($this->request->getPost('mobile') ?? ''))); + $selectedPatientId = (int) ($this->request->getPost('selected_patient_id') ?? 0); + $selectedPCode = trim((string) ($this->request->getPost('selected_p_code') ?? '')); + $selectedAbhaNumber = trim((string) ($this->request->getPost('selected_abha_number') ?? '')); + $selectedName = trim((string) ($this->request->getPost('selected_name') ?? '')); if ($txnId === '' || $otp === '') { return $this->response->setJSON(['ok' => 0, 'error_text' => 'Transaction ID and 6-digit OTP are required']); @@ -1002,33 +1006,69 @@ class Abha extends BaseController return ''; }; + // If multiple accounts were returned in $result['accounts'], pick the one matching fullProfile or selected profile + $targetLast4 = preg_replace('/\D/', '', substr( + $firstNonEmpty([ + $fullProfile['ABHANumber'] ?? null, + $fullProfile['abhaNumber'] ?? null, + $fullProfile['abha_id'] ?? null, + $selectedAbhaNumber, + ]), + -4 + )); + if (! empty($result['accounts']) && is_array($result['accounts'])) { + foreach ($result['accounts'] as $accCandidate) { + if (! is_array($accCandidate)) { + continue; + } + $candAbha = $firstNonEmpty([ + $accCandidate['ABHANumber'] ?? null, + $accCandidate['abhaNumber'] ?? null, + $accCandidate['abha_number'] ?? null, + $accCandidate['abha_id'] ?? null, + ]); + $candLast4 = preg_replace('/\D/', '', substr($candAbha, -4)); + if ($targetLast4 !== '' && $candLast4 !== '' && $candLast4 === $targetLast4) { + $account = $accCandidate; + break; + } + $candName = trim((string) ($accCandidate['name'] ?? $accCandidate['fullName'] ?? '')); + if ($targetLast4 === '' && $selectedName !== '' && $candName !== '' && strcasecmp($candName, $selectedName) === 0) { + $account = $accCandidate; + break; + } + } + } + $abhaNum = $firstNonEmpty([ + $fullProfile['ABHANumber'] ?? null, + $fullProfile['abhaNumber'] ?? null, + $fullProfile['abha_id'] ?? null, $account['ABHANumber'] ?? null, $account['abhaNumber'] ?? null, $account['abha_number'] ?? null, $account['abha_id'] ?? null, - $fullProfile['ABHANumber'] ?? null, - $fullProfile['abhaNumber'] ?? null, - $fullProfile['abha_id'] ?? null, + $selectedAbhaNumber, ]); $abhaAddress = $firstNonEmpty([ - $account['preferredAbhaAddress'] ?? null, - $account['preferredAddress'] ?? null, - $account['abhaAddress'] ?? null, - $account['abha_address'] ?? null, $fullProfile['preferredAbhaAddress'] ?? null, $fullProfile['preferredAddress'] ?? null, $fullProfile['abhaAddress'] ?? null, $fullProfile['abha_address'] ?? null, + $account['preferredAbhaAddress'] ?? null, + $account['preferredAddress'] ?? null, + $account['abhaAddress'] ?? null, + $account['abha_address'] ?? null, ]); $name = $firstNonEmpty([ + $fullProfile['name'] ?? null, + trim(($fullProfile['firstName'] ?? '') . ' ' . ($fullProfile['middleName'] ?? '') . ' ' . ($fullProfile['lastName'] ?? '')), $account['name'] ?? null, $account['fullName'] ?? null, $account['full_name'] ?? null, - $fullProfile['name'] ?? null, - trim(($fullProfile['firstName'] ?? '') . ' ' . ($fullProfile['middleName'] ?? '') . ' ' . ($fullProfile['lastName'] ?? '')), + $selectedName, ]); $genderRaw = $firstNonEmpty([ @@ -1160,12 +1200,43 @@ class Abha extends BaseController if ($abhaAddress !== '' && str_contains($abhaAddress, '@')) { $allAbhaAddresses[$abhaAddress] = true; } + if (! empty($fullProfile['phrAddress']) && is_array($fullProfile['phrAddress'])) { + foreach ($fullProfile['phrAddress'] as $phrAddr) { + if (is_string($phrAddr) && str_contains($phrAddr, '@')) { + $allAbhaAddresses[trim($phrAddr)] = true; + } + } + } + + $cleanAbhaDigits = preg_replace('/\D/', '', $abhaNum); + $last4Digits = strlen($cleanAbhaDigits) >= 4 ? substr($cleanAbhaDigits, -4) : ''; + if (! empty($result['accounts']) && is_array($result['accounts'])) { foreach ($result['accounts'] as $acc) { - if (is_array($acc)) { - foreach (['preferredAbhaAddress', 'preferredAddress', 'abhaAddress', 'abha_address'] as $ak) { - if (! empty($acc[$ak]) && is_string($acc[$ak]) && str_contains($acc[$ak], '@')) { - $allAbhaAddresses[trim($acc[$ak])] = true; + if (! is_array($acc)) { + continue; + } + $accAbhaRaw = (string) ($acc['ABHANumber'] ?? $acc['abhaNumber'] ?? $acc['abha_number'] ?? $acc['abha_id'] ?? ''); + $accAbhaDigits = preg_replace('/\D/', '', $accAbhaRaw); + $accName = trim((string) ($acc['name'] ?? $acc['fullName'] ?? '')); + + // Skip accounts belonging to other family members on the same mobile + if ($last4Digits !== '' && $accAbhaDigits !== '' && ! str_ends_with($accAbhaDigits, $last4Digits)) { + continue; + } + if ($last4Digits === '' && $name !== '' && $accName !== '' && stripos($accName, $name) === false && stripos($name, $accName) === false) { + continue; + } + + foreach (['preferredAbhaAddress', 'preferredAddress', 'abhaAddress', 'abha_address'] as $ak) { + if (! empty($acc[$ak]) && is_string($acc[$ak]) && str_contains($acc[$ak], '@')) { + $allAbhaAddresses[trim($acc[$ak])] = true; + } + } + if (! empty($acc['phrAddress']) && is_array($acc['phrAddress'])) { + foreach ($acc['phrAddress'] as $phrAddr) { + if (is_string($phrAddr) && str_contains($phrAddr, '@')) { + $allAbhaAddresses[trim($phrAddr)] = true; } } } @@ -1173,38 +1244,82 @@ class Abha extends BaseController } $db = \Config\Database::connect(); - $patientIdForAddrs = $patientInfo !== null ? (int)($patientInfo['patient_id'] ?? 0) : 0; - $cleanAbhaDigits = preg_replace('/\D/', '', $abhaNum); + $patientIdForAddrs = $patientInfo !== null ? (int) ($patientInfo['patient_id'] ?? 0) : $selectedPatientId; - $pBuilder = $db->table('patient_master'); - $pBuilder->groupStart(); - if ($patientIdForAddrs > 0) { - $pBuilder->where('id', $patientIdForAddrs); + // Match ONLY the local patient record(s) belonging to this specific ABHA profile + // (never pull all family members who share the same mobile number). + $matchedPids = []; + $matchedPCodes = []; + if ($selectedPCode !== '') { + $matchedPCodes[] = $selectedPCode; } - if ($cleanAbhaDigits !== '') { - $pBuilder->orWhere('abha_id', $cleanAbhaDigits); + + $pRows = []; + if ($patientIdForAddrs > 0 || strlen($cleanAbhaDigits) === 14) { + $pBuilder = $db->table('patient_master'); + $pBuilder->groupStart(); + if ($patientIdForAddrs > 0) { + $pBuilder->where('id', $patientIdForAddrs); + } + if (strlen($cleanAbhaDigits) === 14) { + $pBuilder->orWhere('abha_id', $cleanAbhaDigits); + } + $pBuilder->groupEnd(); + $pRows = $pBuilder->select('id, p_code, p_fname, abha_id, abha_address')->get()->getResultArray(); } - if ($profileMobile !== '') { - $pBuilder->orWhere('mphone1', $profileMobile)->orWhere('mphone2', $profileMobile); + + // Fallback: if not yet matched by patient_id or 14-digit abha_id, search by mobile AND filter strictly by last-4 ABHA digits or name + if (empty($pRows) && $profileMobile !== '') { + $mobCandidates = $db->table('patient_master') + ->select('id, p_code, p_fname, abha_id, abha_address') + ->groupStart() + ->where('mphone1', $profileMobile) + ->orWhere('mphone2', $profileMobile) + ->groupEnd() + ->get()->getResultArray(); + + foreach ($mobCandidates as $mc) { + $mcAbha = preg_replace('/\D/', '', (string) ($mc['abha_id'] ?? '')); + if ($last4Digits !== '' && $mcAbha !== '') { + if (str_ends_with($mcAbha, $last4Digits)) { + $pRows[] = $mc; + } + continue; + } + $mcName = trim((string) ($mc['p_fname'] ?? '')); + if ($name !== '' && $mcName !== '' && (stripos($mcName, $name) !== false || stripos($name, $mcName) !== false)) { + $pRows[] = $mc; + } + } } - $pBuilder->groupEnd(); - $pRows = $pBuilder->select('id, abha_address')->get()->getResultArray(); - $matchedPids = []; + foreach ($pRows as $pr) { - $matchedPids[] = (int)$pr['id']; - $pa = trim((string)($pr['abha_address'] ?? '')); + $matchedPids[] = (int) $pr['id']; + if (! empty($pr['p_code'])) { + $matchedPCodes[] = trim((string) $pr['p_code']); + } + $pa = trim((string) ($pr['abha_address'] ?? '')); if ($pa !== '' && str_contains($pa, '@')) { $allAbhaAddresses[$pa] = true; } } - if (! empty($matchedPids)) { - $txns = $db->table('abdm_link_transactions') - ->select('abha_address') - ->whereIn('patient_id', array_unique($matchedPids)) - ->get()->getResultArray(); + $matchedPids = array_values(array_filter(array_unique($matchedPids))); + $matchedPCodes = array_values(array_filter(array_unique($matchedPCodes))); + + if (! empty($matchedPids) || ! empty($matchedPCodes)) { + $txnBuilder = $db->table('abdm_link_transactions')->select('abha_address'); + $txnBuilder->groupStart(); + if (! empty($matchedPids)) { + $txnBuilder->whereIn('patient_id', $matchedPids); + } + if (! empty($matchedPCodes)) { + $txnBuilder->orWhereIn('patient_ref', $matchedPCodes); + } + $txnBuilder->groupEnd(); + $txns = $txnBuilder->get()->getResultArray(); foreach ($txns as $t) { - $ta = trim((string)($t['abha_address'] ?? '')); + $ta = trim((string) ($t['abha_address'] ?? '')); if ($ta !== '' && str_contains($ta, '@')) { $allAbhaAddresses[$ta] = true; } diff --git a/app/Views/partials/abha_mobile_modal.php b/app/Views/partials/abha_mobile_modal.php index 746e1da..ba1ca0a 100644 --- a/app/Views/partials/abha_mobile_modal.php +++ b/app/Views/partials/abha_mobile_modal.php @@ -594,6 +594,13 @@ window.AbhaMobileModal = (function () { postData.append('otp', otp); postData.append('auth_method', authMethod); postData.append('mobile', searchMobile); + if (selectedProfile) { + postData.append('selected_index', selectedProfile.index || ''); + postData.append('selected_patient_id', selectedProfile.patient_id || ''); + postData.append('selected_p_code', selectedProfile.p_code || ''); + postData.append('selected_abha_number', selectedProfile.abha_number || ''); + postData.append('selected_name', selectedProfile.name || ''); + } postData.append('', csrf()); fetch('', { diff --git a/docs/abdm/ABDM_HTC_AUDIT_DEMO_GUIDE.md b/docs/abdm/ABDM_HTC_AUDIT_DEMO_GUIDE.md new file mode 100644 index 0000000..72e8056 --- /dev/null +++ b/docs/abdm/ABDM_HTC_AUDIT_DEMO_GUIDE.md @@ -0,0 +1,95 @@ +# ABDM Sandbox Exit — HTC Interview & Audit Demo Guide + +**Saved Session Date:** 2026-10-29 +**Purpose:** Quick-reference readiness checklist, UI URLs, database log tables, and live demo script for the NHA / Empaneled Implementation Support Agency (ISA) Functional & Security Audit (HTC). + +--- + +## 1. Readiness Matrix (HIP, HIU & Consent Flow) + +### A. HIP (Health Information Provider — M1 & M2) +| Requirement | Status | HMS Implementation Reference | +| :--- | :---: | :--- | +| **Capture patient records digitally & link with ABHA** | Ready | Patient Registration & OPD/IPD Profile (`patient_master.abha_id`, `patient_master.abha_address`), plus Scan & Share Queue (`app/Controllers/AbdmOpdQueue.php`). | +| **Generate ABDM-compliant FHIR R4 records** | Ready | Generates NRCeS FHIR R4 `Bundle` (`type: "document"`, `Composition` first entry, SNOMED CT + LOINC coded) for all 7 HI types: `Prescription`, `OPConsultation`, `DischargeSummary`, `DiagnosticReport`, `ImmunizationRecord`, `WellnessRecord`, `Invoice`. | +| **Canonical Care Context Reference & Notification** | Ready | Structured care context references (`PRESC-{pid}-S{sid}-{YYYYMMDD}`, `OPD-...`, `DISCHARGE-...`, `LAB-...`, `RAD-...`, `INVOICE-...`, `WELLNESS-...`, `IMM-...`) notified via `/v3/hip/link/carecontext` and `/v3/hip/patients/sms/notify2` (`app/Controllers/AbdmGateway.php`). | +| **Share records only after valid patient consent** | Ready | HIP consent webhook (`POST /api/v3/hiu/consent/on-notify` -> `AbdmGateway::hipConsentNotifyCallback`) verifies `GRANTED` state and date range before Fidelius (ECDH Curve25519) encrypted health record push. | + +### B. HIU (Health Information User — M3) +| Requirement | Status | HMS Implementation Reference | +| :--- | :---: | :--- | +| **Raise consent requests** | Ready | Available in Patient OPD Profile (`Patient::abdm_content_request_custom`) and HIU Console (`AbdmHiu::consentRequest`). Supports purpose (`CAREMGT`), date range, expiry (`dataEraseAt`), and HI type selection. | +| **Fetch records after consent approval** | Ready | Processes `/v3/hiu/consent/request/on-status`, `/on-notify`, and `/health-information/on-request` callbacks (`app/Libraries/Abdm/M3HiuWorkflowService.php`), decrypts payloads, and stores documents in `abdm_hiu_documents` (`app/Libraries/Abdm/M3HiuDocumentRepository.php`). | +| **Honor consent expiry and revocation** | Ready | Enforced in `Patient::abdm_documents` and `AbdmGateway::consentRevokedCallback`: when consent transitions to `REVOKED`, `DENIED`, or passes `dataEraseAt` (`EXPIRED`), cached HIU documents are automatically purged (`purgeAbdmDocumentsByRefs`) and UI access is blocked. | +| **Display fetched records in HMS** | Ready | Clinical summary viewer, attachment viewer, and printable PDF (`Patient::abdm_records_pdf`, `app/Views/abdm/hiu_documents.php`). | + +--- + +## 2. Exact Screens, URLs & Database Tables for Audit Evidence + +Keep these **5 browser tabs** open during the HTC interview: + +### Tab 1: Live ABDM API & Bridge Logs +* **UI URL:** `/AbdmBridgeLog` +* **Controller / View:** `app/Controllers/AbdmBridgeLog.php` | `app/Views/abdm/bridge_log.php` +* **What to show the auditor:** + * Filter by channel (`eatria_bridge`, `bridge`, `abdm`) or status (`success`, `error`). + * Click **Detail** on any row to display the exact **Request JSON**, **Response JSON**, HTTP status code (`200` / `202`), and timestamp. +* **Database Table:** `abdm_api_logs` + +### Tab 2: FHIR R4 Bundle Inspector & Care Context Link Verifier +* **UI URL:** `/AbdmFhirInspector` +* **Controller / View:** `app/Controllers/AbdmFhirInspector.php` | `app/Views/abdm/fhir_inspector.php` +* **What to show the auditor:** + * Search any ABHA-linked patient. + * Show all Care Context references (`OPD-...`, `PRESC-...`, `LAB-...`, `RAD-...`, `DISCHARGE-...`, `INVOICE-...`) and `LINKED` status. + * Click **Preview FHIR Bundle** to display the NRCeS FHIR R4 `Bundle` JSON (`Composition`, `Patient`, `Practitioner`, `Organization`, `MedicationRequest`, `Observation`, etc.). +* **Database Tables:** `health_records`, `record_links`, `abdm_link_transactions` + +### Tab 3: ABDM HIP Task Board (Record Creation & Sync Queue) +* **UI URL:** `/AbdmTaskBoard` +* **Controller / View:** `app/Controllers/AbdmTaskBoard.php` | `app/Views/abdm/task_board.php` +* **What to show the auditor:** + * Real-time queue of OPD consultations, prescriptions, lab/radiology reports, discharge summaries, wellness, immunization, and invoices with their ABDM push and link status. +* **Database Tables:** `abdm_work_tasks`, `abdm_sync_outbox` + +### Tab 4: HIU Consent Request List & Fetched Documents Console +* **UI URL 1 (Consent Request List):** `/AbdmHiu` (`app/Views/abdm/patient_request_list.php`) + * Shows every consent request with `Status` (`REQUESTED`, `GRANTED`, `COMPLETED`, `REVOKED`, `EXPIRED`, `DENIED`), `HI Types`, `Requested On`, and `Expiry`. +* **UI URL 2 (Fetched Documents Viewer):** `/AbdmHiu/documents` (`app/Views/abdm/hiu_documents.php`) + * Displays decrypted health records fetched via HIU consent. +* **UI URL 3 (Patient OPD Profile ABDM Tab):** `/billing/patient/show_profile_opd/` + * Raise new consent requests, check live status, view fetched documents, or generate PDF (`/billing/patient/abdm_records_pdf/`). +* **Database Tables:** + * `abdm_hiu_workflows` (tracks M3 lifecycle states, `request_id`, `transaction_id`, `abdm_consent_request_id`, `abdm_consent_artifact_id`, `revoked_at`, `expired_at`) + * `abdm_hiu_consent_artifacts` (stores HIE-CM consent artefacts) + * `abdm_hiu_documents` (stores decrypted FHIR bundles) + * `abdm_consent_records` (stores HIP-side consent records) + +### Tab 5: Security & Compliance Audit Logs +* **Service:** `app/Libraries/AbdmAuditService.php` +* **Database Tables:** `audit_logs` and `abdm_audit_logs` + * Captures `actor_user_id`, `actor_name`, `ip_address`, `user_agent`, `abha_id`, `patient_id`, `consent_id`, `transaction_id`, `action` (`push_record`, `link_record`, `consent_request`, `consent_grant`, `consent_revoke`, `record_linked`, `consent_callback`, `abha_validate`), `outcome`, and JSON details. +* **Server File Logs:** `writable/logs/log-YYYY-MM-DD.log` + +--- + +## 3. Step-by-Step Live Demo Script for the Auditor + +1. **M1 — Patient Registration & ABHA Linkage:** + * Open Patient Registration or `/AbdmOpdQueue` (Scan & Share). + * Show a patient linked with an ABHA Number and `@sbx` ABHA Address. +2. **M2 (HIP) — Record Creation, FHIR R4 Generation & Care Context Linking:** + * Create/save an OPD Consultation or Prescription for the patient. + * Open `/AbdmFhirInspector` -> select the patient -> click **Preview Bundle** to show the NRCeS FHIR R4 JSON. + * Link the Care Context and show it appearing inside the **ABHA PHR Sandbox App** (`phrsbx.abdm.gov.in`) under `PRESC-...` / `OPD-...`. + * Show the corresponding API call log in `/AbdmBridgeLog`. +3. **M3 (HIU) — Consent Request, Patient Approval & Record Fetch:** + * Open the patient's OPD Profile (or `/AbdmHiu`) and click **Request Consent**. + * Approve the consent request in the **ABHA PHR Sandbox App**. + * Show status changing to `GRANTED` / `COMPLETED` in `/AbdmHiu`. + * Open the fetched records in the patient's ABDM Documents view (or `/AbdmHiu/documents`) to show the rendered clinical data. +4. **M3 Security Control — Consent Revocation & Expiry Enforcement:** + * In the **ABHA PHR Sandbox App**, revoke the granted consent. + * Refresh/click the consent in HMS — show that the status updates to `REVOKED`, cached documents for that consent are purged from `abdm_hiu_documents`, and HMS blocks viewing the records. +