Add ABDM documents and patient profile sync UI

This commit is contained in:
DevSoftTech Server 2026-07-16 18:23:40 +05:30
parent 0e8e164335
commit 2b53c05a68
12 changed files with 2442 additions and 642 deletions

View File

@ -496,6 +496,11 @@ $routes->group('billing', function($routes) {
$routes->get('patient/show_profile_image/(:num)/(:num)', 'Patient::show_profile_image/$1/$2');
$routes->get('patient/show_profile_opd/(:num)', 'Patient::show_profile_opd/$1');
$routes->get('patient/show_profile_opd/(:num)/(:num)', 'Patient::show_profile_opd/$1/$2');
$routes->get('patient/abdm_documents/(:num)', 'Patient::abdm_documents/$1');
$routes->get('patient/abdm_document_detail/(:num)/(:num)', 'Patient::abdm_document_detail/$1/$2');
$routes->get('patient/abdm_content_request/(:num)', 'Patient::abdm_content_request/$1');
$routes->get('patient/abdm_content_auto_flow/(:num)', 'Patient::abdm_content_auto_flow/$1');
$routes->get('patient/abdm_timeline/(:num)', 'Patient::abdm_timeline/$1');
$routes->post('patient/save_profile_image/(:num)', 'Patient::save_profile_image/$1');
$routes->match(['get', 'post'], 'patient/patient_file_upload/(:num)', 'Patient::patient_file_upload/$1');
$routes->get('patient/city', 'Patient::city');
@ -632,13 +637,14 @@ $routes->post('AbdmGateway/consent_revoked_callback', 'AbdmGateway::consentRevok
$routes->post('AbdmGateway/record_linked_callback', 'AbdmGateway::recordLinkedCallback');
// ABDM M3 HIU (HMS -> Gateway)
$routes->get('AbdmHiu', 'AbdmHiu::index', ['filter' => $abdmPermFilter]);
$routes->get('AbdmHiu/documents', 'AbdmHiu::documents', ['filter' => $abdmPermFilter]);
$routes->get('AbdmHiu/patient_lookup', 'AbdmHiu::patientLookup', ['filter' => $abdmPermFilter]);
$routes->get('AbdmHiu/documents_list', 'AbdmHiu::documentsList', ['filter' => $abdmPermFilter]);
$routes->get('AbdmHiu/document_detail/(:num)', 'AbdmHiu::documentDetail/$1', ['filter' => $abdmPermFilter]);
$routes->get('AbdmHiu/timeline', 'AbdmHiu::timeline', ['filter' => $abdmPermFilter]);
$routes->get('AbdmHiu/poll_summary', 'AbdmHiu::pollSummary', ['filter' => $abdmPermFilter]);
$routes->post('AbdmHiu/consent_request', 'AbdmHiu::consentRequest', ['filter' => $abdmPermFilter]);
$routes->post('AbdmHiu/consent_request_status', 'AbdmHiu::consentRequestStatus', ['filter' => $abdmPermFilter]);
$routes->post('AbdmHiu/consent_request_fetch', 'AbdmHiu::consentRequestFetch', ['filter' => $abdmPermFilter]);
$routes->post('AbdmHiu/health_information_request', 'AbdmHiu::healthInformationRequest', ['filter' => $abdmPermFilter]);
$routes->post('AbdmHiu/consent_reconcile', 'AbdmHiu::consentReconcile', ['filter' => $abdmPermFilter]);
$routes->post('AbdmHiu/data_fetch', 'AbdmHiu::dataFetch', ['filter' => $abdmPermFilter]);
$routes->post('AbdmHiu/consent_update_callback', 'AbdmHiu::consentUpdateWebhook');
@ -646,13 +652,11 @@ $routes->post('AbdmHiu/health_information_on_request_callback', 'AbdmHiu::health
$routes->post('AbdmHiu/health_information_data_push_callback', 'AbdmHiu::healthInformationDataPushWebhook');
$routes->post('api/v3/hiu/consent/request', 'AbdmHiu::consentRequest', ['filter' => $abdmPermFilter]);
$routes->post('api/v3/hiu/consent/request/status', 'AbdmHiu::consentRequestStatus', ['filter' => $abdmPermFilter]);
$routes->post('api/v3/hiu/consent/request/fetch', 'AbdmHiu::consentRequestFetch', ['filter' => $abdmPermFilter]);
$routes->post('api/v3/hiu/consent/on-update', 'AbdmHiu::consentUpdateWebhook');
$routes->post('api/v3/hiu/consent/on_update', 'AbdmHiu::consentUpdateWebhook');
$routes->post('api/v3/hiu/health-information/on-request', 'AbdmHiu::healthInformationOnRequestWebhook');
$routes->post('api/v3/hiu/health-information/on_request', 'AbdmHiu::healthInformationOnRequestWebhook');
$routes->post('api/v3/hiu/data/push', 'AbdmHiu::healthInformationDataPushWebhook');
$routes->post('api/v3/hiu/health-information/request', 'AbdmHiu::healthInformationRequest', ['filter' => $abdmPermFilter]);
$routes->post('api/v1/hiu/consent/status', 'AbdmHiu::consentReconcile', ['filter' => $abdmPermFilter]);
$routes->post('api/v1/hiu/data/fetch', 'AbdmHiu::dataFetch', ['filter' => $abdmPermFilter]);
// HMS M2 adapter endpoints (Gateway -> HMS) - keep lowercase v1 path

View File

@ -3,10 +3,12 @@
namespace App\Controllers;
use App\Libraries\Abdm\M3HiuWorkflowService;
use App\Libraries\Abdm\M3HiuDocumentRepository;
class AbdmHiu extends BaseController
{
private M3HiuWorkflowService $service;
private M3HiuDocumentRepository $documentRepository;
public function initController(
\CodeIgniter\HTTP\RequestInterface $request,
@ -16,6 +18,7 @@ class AbdmHiu extends BaseController
parent::initController($request, $response, $logger);
$this->db = \Config\Database::connect();
$this->service = new M3HiuWorkflowService();
$this->documentRepository = new M3HiuDocumentRepository();
}
public function index()
@ -23,6 +26,48 @@ class AbdmHiu extends BaseController
return view('abdm/hiu_m3');
}
public function documents()
{
return view('abdm/hiu_documents');
}
public function documentsList()
{
$filters = [
'patient_id' => (int) ($this->request->getGet('patient_id') ?? 0),
'abha_address' => trim((string) ($this->request->getGet('abha_address') ?? '')),
'q' => trim((string) ($this->request->getGet('q') ?? '')),
];
$limit = (int) ($this->request->getGet('limit') ?? 100);
if ($limit <= 0 || $limit > 300) {
$limit = 100;
}
$rows = $this->documentRepository->listDocuments($filters, $limit);
return $this->response->setJSON([
'ok' => 1,
'count' => count($rows),
'items' => $rows,
]);
}
public function documentDetail($id = null)
{
$docId = (int) ($id ?? 0);
$row = $this->documentRepository->getDocument($docId);
if (empty($row)) {
return $this->response->setStatusCode(404)->setJSON([
'ok' => 0,
'error' => 'Document not found',
]);
}
return $this->response->setJSON([
'ok' => 1,
'item' => $row,
]);
}
public function consentRequest()
{
return $this->handleOperation('consent_request', 'REQUESTED');
@ -33,16 +78,6 @@ class AbdmHiu extends BaseController
return $this->handleOperation('consent_status', 'STATUS_CHECKED');
}
public function consentRequestFetch()
{
return $this->handleOperation('consent_fetch', 'CONSENT_FETCHED');
}
public function healthInformationRequest()
{
return $this->handleOperation('hi_request', 'DATA_REQUESTED');
}
public function consentReconcile()
{
return $this->handleOperation('consent_reconcile', 'STATUS_CHECKED');

View File

@ -1942,6 +1942,10 @@ class Ipd_discharge extends BaseController
// Decode HTML entities that might have been double-encoded
$value = html_entity_decode($value, ENT_QUOTES | ENT_HTML5, 'UTF-8');
if ($this->looksLikeMalformedDischargeHtml($value)) {
$value = $this->normalizeMalformedDischargeHtmlForMpdf($value);
}
return trim($value);
}
@ -1972,6 +1976,10 @@ class Ipd_discharge extends BaseController
// Check if content contains HTML tags (from HTML editor)
if (preg_match('/<(?:p|div|span|strong|b|i|em|ul|ol|li|table|tr|td|th|thead|tbody|h[1-6])\b[^>]*>/i', $text)) {
// Content is HTML - return as-is (already sanitized by normalizeRichText)
if ($this->looksLikeMalformedDischargeHtml($text)) {
$text = $this->normalizeMalformedDischargeHtmlForMpdf($text);
}
return $text;
}

View File

@ -1109,6 +1109,452 @@ class Patient extends BaseController
]);
}
public function abdm_documents(int $pno)
{
if (! $this->db->tableExists('patient_master')) {
return $this->response->setStatusCode(500)->setJSON([
'ok' => 0,
'error' => 'patient_master table not found',
]);
}
$patientRow = $this->db->table('patient_master')->where('id', $pno)->get()->getRowArray();
if (! is_array($patientRow)) {
return $this->response->setStatusCode(404)->setJSON([
'ok' => 0,
'error' => 'Patient not found',
]);
}
if (! $this->db->tableExists('abdm_hiu_documents')) {
return $this->response->setJSON([
'ok' => 1,
'count' => 0,
'items' => [],
'abha' => $this->buildAbhaProfileContext($patientRow),
]);
}
$limit = (int) ($this->request->getGet('limit') ?? 100);
if ($limit <= 0 || $limit > 300) {
$limit = 100;
}
$q = trim((string) ($this->request->getGet('q') ?? ''));
$abhaContext = $this->buildAbhaProfileContext($patientRow);
$builder = $this->db->table('abdm_hiu_documents d')
->select('d.id, d.patient_id, d.abha_address, d.document_title, d.document_date, d.care_context_reference, d.practitioner_name, d.organization_name, d.bundle_type, d.created_at')
->orderBy('d.document_date', 'DESC')
->orderBy('d.id', 'DESC')
->limit($limit);
$builder->groupStart()
->where('d.patient_id', $pno);
if ($abhaContext['abha_address'] !== '') {
$builder->orWhere('d.abha_address', $abhaContext['abha_address']);
}
$builder->groupEnd();
if ($q !== '') {
$builder->groupStart()
->like('d.document_title', $q)
->orLike('d.care_context_reference', $q)
->orLike('d.practitioner_name', $q)
->orLike('d.organization_name', $q)
->groupEnd();
}
$rows = $builder->get()->getResultArray();
return $this->response->setJSON([
'ok' => 1,
'count' => count($rows),
'items' => $rows,
'abha' => $abhaContext,
]);
}
public function abdm_document_detail(int $pno, int $docId)
{
if ($docId <= 0 || ! $this->db->tableExists('abdm_hiu_documents')) {
return $this->response->setStatusCode(404)->setJSON([
'ok' => 0,
'error' => 'Document not found',
]);
}
$patientRow = $this->db->table('patient_master')->where('id', $pno)->get()->getRowArray();
if (! is_array($patientRow)) {
return $this->response->setStatusCode(404)->setJSON([
'ok' => 0,
'error' => 'Patient not found',
]);
}
$abhaContext = $this->buildAbhaProfileContext($patientRow);
$builder = $this->db->table('abdm_hiu_documents d')
->select('*')
->where('d.id', $docId)
->groupStart()
->where('d.patient_id', $pno);
if ($abhaContext['abha_address'] !== '') {
$builder->orWhere('d.abha_address', $abhaContext['abha_address']);
}
$builder->groupEnd();
$row = $builder->get(1)->getRowArray();
if (! is_array($row) || empty($row)) {
return $this->response->setStatusCode(404)->setJSON([
'ok' => 0,
'error' => 'Document not found for patient',
]);
}
$row['summary'] = json_decode((string) ($row['summary_json'] ?? ''), true) ?: [];
return $this->response->setJSON([
'ok' => 1,
'item' => $row,
]);
}
public function abdm_content_request(int $pno)
{
if (! $this->db->tableExists('patient_master')) {
return $this->response->setStatusCode(500)->setJSON([
'ok' => 0,
'error' => 'patient_master table not found',
]);
}
$patientRow = $this->db->table('patient_master')->where('id', $pno)->get()->getRowArray();
if (! is_array($patientRow)) {
return $this->response->setStatusCode(404)->setJSON([
'ok' => 0,
'error' => 'Patient not found',
]);
}
$abhaContext = $this->buildAbhaProfileContext($patientRow);
if ($abhaContext['abha_address'] === '') {
return $this->response->setStatusCode(422)->setJSON([
'ok' => 0,
'error' => 'ABHA Address not available for this patient.',
'abha' => $abhaContext,
]);
}
if ((int) ($abhaContext['is_verified'] ?? 0) !== 1) {
return $this->response->setStatusCode(422)->setJSON([
'ok' => 0,
'error' => 'ABHA is not marked as verified in HMS. Please verify ABHA first, then request content.',
'abha' => $abhaContext,
]);
}
$service = new \App\Libraries\Abdm\M3HiuWorkflowService();
$payload = [
'patient_id' => $pno,
'abha_address' => $abhaContext['abha_address'],
'hiTypes' => ['OPConsultation', 'Prescription', 'DiagnosticReport', 'HealthDocument'],
];
try {
$result = $service->runOperation('consent_request', $payload);
} catch (\Throwable $e) {
return $this->response->setStatusCode(500)->setJSON([
'ok' => 0,
'error' => 'Unable to start content request: ' . $e->getMessage(),
]);
}
$ok = (int) ($result['ok'] ?? 0) === 1;
$httpCode = (int) ($result['http_code'] ?? ($ok ? 200 : 422));
if ($httpCode < 100 || $httpCode > 599) {
$httpCode = $ok ? 200 : 422;
}
if (! $ok) {
return $this->response->setStatusCode($httpCode)->setJSON([
'ok' => 0,
'error' => (string) ($result['error_text'] ?? 'Content request failed.'),
'data' => $result,
]);
}
return $this->response->setStatusCode($httpCode)->setJSON([
'ok' => 1,
'message' => 'Consent/content request created successfully.',
'workflow_state' => 'REQUESTED',
'request_id' => (string) ($result['request_id'] ?? ''),
'data' => $result,
]);
}
public function abdm_content_auto_flow(int $pno)
{
if (! $this->db->tableExists('patient_master')) {
return $this->response->setStatusCode(500)->setJSON([
'ok' => 0,
'error' => 'patient_master table not found',
]);
}
$patientRow = $this->db->table('patient_master')->where('id', $pno)->get()->getRowArray();
if (! is_array($patientRow)) {
return $this->response->setStatusCode(404)->setJSON([
'ok' => 0,
'error' => 'Patient not found',
]);
}
$abhaContext = $this->buildAbhaProfileContext($patientRow);
if ($abhaContext['abha_address'] === '') {
return $this->response->setStatusCode(422)->setJSON([
'ok' => 0,
'error' => 'ABHA Address not available for this patient.',
'abha' => $abhaContext,
]);
}
if ((int) ($abhaContext['is_verified'] ?? 0) !== 1) {
return $this->response->setStatusCode(422)->setJSON([
'ok' => 0,
'error' => 'ABHA is not marked as verified in HMS. Please verify ABHA first, then request content.',
'abha' => $abhaContext,
]);
}
$service = new \App\Libraries\Abdm\M3HiuWorkflowService();
$flowRefId = trim((string) (
$this->request->getGet('request_id')
?? $this->request->getPost('request_id')
?? ''
));
$consentRequestRef = '';
$consentArtifactRef = '';
$consentResult = null;
$reconcileResult = null;
$fetchResult = null;
if ($flowRefId === '') {
$consentPayload = [
'patient_id' => $pno,
'abha_address' => $abhaContext['abha_address'],
'hiTypes' => ['OPConsultation', 'Prescription', 'DiagnosticReport', 'HealthDocument'],
];
try {
$consentResult = $service->runOperation('consent_request', $consentPayload);
} catch (\Throwable $e) {
return $this->response->setStatusCode(500)->setJSON([
'ok' => 0,
'error' => 'Unable to start consent request: ' . $e->getMessage(),
]);
}
if ((int) ($consentResult['ok'] ?? 0) !== 1) {
$httpCode = (int) ($consentResult['http_code'] ?? 422);
if ($httpCode < 100 || $httpCode > 599) {
$httpCode = 422;
}
return $this->response->setStatusCode($httpCode)->setJSON([
'ok' => 0,
'phase' => 'REQUEST_FAILED',
'error' => (string) ($consentResult['error_text'] ?? 'Consent request failed.'),
'request_id' => (string) ($consentResult['request_id'] ?? ''),
'data' => $consentResult,
]);
}
$flowRefId = trim((string) (
$consentResult['consent_request_id']
?? $consentResult['abdm_consent_request_id']
?? $consentResult['consent_id']
?? $consentResult['request_id']
?? ''
));
$consentRequestRef = trim((string) (
$consentResult['consent_request_id']
?? $consentResult['abdm_consent_request_id']
?? ''
));
if ($consentRequestRef === '') {
$consentRequestRef = $flowRefId;
}
$consentArtifactRef = trim((string) (
$consentResult['consent_id']
?? $consentResult['abdm_consent_artifact_id']
?? ''
));
if ($consentArtifactRef === '' && $consentRequestRef !== '' && preg_match('/^REQ-/i', $consentRequestRef) !== 1) {
$consentArtifactRef = $consentRequestRef;
}
}
if ($flowRefId === '') {
return $this->response->setStatusCode(422)->setJSON([
'ok' => 0,
'phase' => 'REQUEST_MISSING',
'error' => 'Unable to determine consent reference for auto flow.',
]);
}
$reconcilePayload = [
'abha_address' => $abhaContext['abha_address'],
];
if ($consentRequestRef !== '') {
$reconcilePayload['consentRequestId'] = $consentRequestRef;
} else {
$reconcilePayload['request_id'] = $flowRefId;
}
try {
$reconcileResult = $service->runOperation('consent_reconcile', $reconcilePayload);
} catch (\Throwable $e) {
return $this->response->setStatusCode(500)->setJSON([
'ok' => 0,
'phase' => 'STATUS_FAILED',
'error' => 'Unable to reconcile consent status: ' . $e->getMessage(),
'request_id' => $flowRefId,
]);
}
if ((int) ($reconcileResult['ok'] ?? 0) !== 1) {
$httpCode = (int) ($reconcileResult['http_code'] ?? 422);
if ($httpCode < 100 || $httpCode > 599) {
$httpCode = 422;
}
return $this->response->setStatusCode($httpCode)->setJSON([
'ok' => 0,
'phase' => 'STATUS_FAILED',
'error' => (string) ($reconcileResult['error_text'] ?? 'Consent status check failed.'),
'request_id' => $flowRefId,
'data' => [
'consent_request' => $consentResult,
'consent_reconcile' => $reconcileResult,
],
]);
}
$state = $this->deriveAutoFlowState($reconcileResult);
$pollAgain = in_array($state, ['REQUESTED', 'PENDING'], true);
if ($state === 'GRANTED') {
$fetchPayload = [
'abha_address' => $abhaContext['abha_address'],
];
if ($consentArtifactRef !== '') {
$fetchPayload['consentId'] = $consentArtifactRef;
} elseif ($consentRequestRef !== '') {
$fetchPayload['consentRequestId'] = $consentRequestRef;
} else {
$fetchPayload['request_id'] = $flowRefId;
}
try {
$fetchResult = $service->runOperation('data_fetch', $fetchPayload);
} catch (\Throwable $e) {
return $this->response->setStatusCode(500)->setJSON([
'ok' => 0,
'phase' => 'FETCH_FAILED',
'error' => 'Unable to fetch ABDM content: ' . $e->getMessage(),
'request_id' => $flowRefId,
]);
}
if ((int) ($fetchResult['ok'] ?? 0) !== 1) {
$httpCode = (int) ($fetchResult['http_code'] ?? 422);
if ($httpCode < 100 || $httpCode > 599) {
$httpCode = 422;
}
return $this->response->setStatusCode($httpCode)->setJSON([
'ok' => 0,
'phase' => 'FETCH_FAILED',
'error' => (string) ($fetchResult['error_text'] ?? 'Data fetch failed.'),
'request_id' => $flowRefId,
'data' => [
'consent_request' => $consentResult,
'consent_reconcile' => $reconcileResult,
'data_fetch' => $fetchResult,
],
]);
}
$state = 'COMPLETED';
$pollAgain = false;
}
$message = match ($state) {
'COMPLETED' => 'Data fetch completed and documents should now be available.',
'GRANTED' => 'Consent granted. Proceeding to fetch data.',
'DENIED' => 'Consent denied/revoked/expired by patient.',
default => 'Consent request created. Waiting for patient approval.',
};
return $this->response->setJSON([
'ok' => 1,
'phase' => $state,
'poll_again' => $pollAgain ? 1 : 0,
'request_id' => $flowRefId,
'message' => $message,
'data' => [
'consent_request' => $consentResult,
'consent_reconcile' => $reconcileResult,
'data_fetch' => $fetchResult,
],
]);
}
public function abdm_timeline(int $pno)
{
if (! $this->db->tableExists('patient_master')) {
return $this->response->setStatusCode(500)->setJSON([
'ok' => 0,
'error' => 'patient_master table not found',
]);
}
$patientRow = $this->db->table('patient_master')->where('id', $pno)->get()->getRowArray();
if (! is_array($patientRow)) {
return $this->response->setStatusCode(404)->setJSON([
'ok' => 0,
'error' => 'Patient not found',
]);
}
$abhaContext = $this->buildAbhaProfileContext($patientRow);
if ($abhaContext['abha_address'] === '') {
return $this->response->setJSON([
'ok' => 1,
'count' => 0,
'items' => [],
'note' => 'ABHA address not available for timeline filter.',
]);
}
$limit = (int) ($this->request->getGet('limit') ?? 15);
if ($limit <= 0 || $limit > 100) {
$limit = 15;
}
$service = new \App\Libraries\Abdm\M3HiuWorkflowService();
$rows = $service->listTimeline([
'abha_address' => (string) $abhaContext['abha_address'],
], $limit);
return $this->response->setJSON([
'ok' => 1,
'count' => count($rows),
'items' => $rows,
]);
}
public function save_profile_image(int $pid)
{
$patient = $this->db->table('patient_master')->where('id', $pid)->get()->getRow();
@ -2201,5 +2647,69 @@ class Patient extends BaseController
);
}
/**
* @param array<string, mixed> $row
* @return array<string, mixed>
*/
private function buildAbhaProfileContext(array $row): array
{
$abhaField = $this->resolvePatientAbhaIdField();
$abhaIdRaw = $abhaField !== null ? trim((string) ($row[$abhaField] ?? '')) : '';
$abhaAddress = trim((string) ($row['abha_address'] ?? ''));
if ($abhaAddress === '' && $abhaIdRaw !== '' && $this->isLikelyAbhaAddress($abhaIdRaw)) {
$abhaAddress = $abhaIdRaw;
}
if ($abhaAddress === '' && preg_match('/abha_address\s*:\s*([A-Za-z0-9._-]+@[A-Za-z0-9.-]+)/i', (string) ($row['log'] ?? ''), $m) === 1) {
$abhaAddress = trim((string) ($m[1] ?? ''));
}
$verifiedStatus = strtoupper(trim((string) ($row['abha_verified_status'] ?? '')));
$kycVerified = (int) ($row['abha_kyc_verified'] ?? 0) === 1;
$mobileVerified = (int) ($row['abha_mobile_verified'] ?? 0) === 1;
$hasStatusColumn = array_key_exists('abha_verified_status', $row);
$isVerified = $verifiedStatus === 'VERIFIED' || ($kycVerified && $mobileVerified);
if (! $hasStatusColumn && $abhaAddress !== '') {
$isVerified = true;
}
return [
'abha_id' => $abhaIdRaw,
'abha_address' => $abhaAddress,
'verified_status' => $verifiedStatus,
'is_verified' => $isVerified ? 1 : 0,
];
}
private function deriveAutoFlowState(array $result): string
{
$blob = strtolower(json_encode($result, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: '');
if (str_contains($blob, 'granted') || str_contains($blob, 'approved') || str_contains($blob, 'consent_granted')) {
return 'GRANTED';
}
if (str_contains($blob, 'denied') || str_contains($blob, 'revoked') || str_contains($blob, 'expired') || str_contains($blob, 'rejected')) {
return 'DENIED';
}
if (str_contains($blob, 'requested') || str_contains($blob, 'pending') || str_contains($blob, 'await')) {
return 'PENDING';
}
$explicit = strtoupper(trim((string) ($result['workflow_state'] ?? $result['state'] ?? $result['status'] ?? '')));
if ($explicit !== '') {
if (in_array($explicit, ['GRANTED', 'APPROVED', 'COMPLETED'], true)) {
return 'GRANTED';
}
if (in_array($explicit, ['REVOKED', 'EXPIRED', 'DENIED', 'REJECTED'], true)) {
return 'DENIED';
}
}
return 'REQUESTED';
}
}

View File

@ -0,0 +1,134 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class CreateAbdmHiuDocuments extends Migration
{
public function up(): void
{
$this->forge->addField([
'id' => [
'type' => 'BIGINT',
'constraint' => 20,
'unsigned' => true,
'auto_increment' => true,
],
'workflow_id' => [
'type' => 'BIGINT',
'constraint' => 20,
'unsigned' => true,
'null' => true,
],
'request_id' => [
'type' => 'VARCHAR',
'constraint' => 180,
'null' => true,
],
'transaction_id' => [
'type' => 'VARCHAR',
'constraint' => 180,
'null' => true,
],
'consent_ref' => [
'type' => 'VARCHAR',
'constraint' => 220,
'null' => true,
],
'consent_artifact_id' => [
'type' => 'VARCHAR',
'constraint' => 180,
'null' => true,
],
'abha_address' => [
'type' => 'VARCHAR',
'constraint' => 120,
'null' => true,
],
'patient_id' => [
'type' => 'BIGINT',
'constraint' => 20,
'unsigned' => true,
'null' => true,
],
'patient_name' => [
'type' => 'VARCHAR',
'constraint' => 190,
'null' => true,
],
'care_context_reference' => [
'type' => 'VARCHAR',
'constraint' => 190,
'null' => true,
],
'media' => [
'type' => 'VARCHAR',
'constraint' => 80,
'null' => true,
],
'bundle_id' => [
'type' => 'VARCHAR',
'constraint' => 190,
'null' => true,
],
'bundle_type' => [
'type' => 'VARCHAR',
'constraint' => 80,
'null' => true,
],
'document_title' => [
'type' => 'VARCHAR',
'constraint' => 255,
'null' => true,
],
'document_date' => [
'type' => 'DATETIME',
'null' => true,
],
'practitioner_name' => [
'type' => 'VARCHAR',
'constraint' => 190,
'null' => true,
],
'organization_name' => [
'type' => 'VARCHAR',
'constraint' => 190,
'null' => true,
],
'summary_json' => [
'type' => 'LONGTEXT',
'null' => true,
],
'raw_bundle' => [
'type' => 'LONGTEXT',
'null' => true,
],
'doc_hash' => [
'type' => 'CHAR',
'constraint' => 40,
'null' => false,
],
'created_at' => [
'type' => 'DATETIME',
'null' => true,
],
'updated_at' => [
'type' => 'DATETIME',
'null' => true,
],
]);
$this->forge->addKey('id', true);
$this->forge->addUniqueKey('doc_hash', 'uniq_abdm_hiu_doc_hash');
$this->forge->addKey(['patient_id', 'document_date'], false, false, 'idx_abdm_hiu_doc_patient_date');
$this->forge->addKey(['abha_address', 'document_date'], false, false, 'idx_abdm_hiu_doc_abha_date');
$this->forge->addKey(['request_id', 'created_at'], false, false, 'idx_abdm_hiu_doc_request');
$this->forge->createTable('abdm_hiu_documents', true);
}
public function down(): void
{
$this->forge->dropTable('abdm_hiu_documents', true);
}
}

View File

@ -0,0 +1,349 @@
<?php
namespace App\Libraries\Abdm;
use CodeIgniter\I18n\Time;
class M3HiuDocumentRepository
{
private \CodeIgniter\Database\BaseConnection $db;
public function __construct()
{
$this->db = \Config\Database::connect();
}
public function persistFromDataFetch(array $payload, array $result, int $workflowId = 0): array
{
if (! $this->db->tableExists('abdm_hiu_documents')) {
return ['saved' => 0, 'updated' => 0, 'skipped' => 0];
}
$sessions = $this->extractSessions($result);
if (empty($sessions)) {
return ['saved' => 0, 'updated' => 0, 'skipped' => 0];
}
$requestId = trim((string) ($result['request_id'] ?? $payload['request_id'] ?? $payload['requestId'] ?? ''));
$abhaAddress = trim((string) ($payload['abha_address'] ?? $result['abha_address'] ?? ''));
$saved = 0;
$updated = 0;
$skipped = 0;
$now = Time::now('Asia/Kolkata')->toDateTimeString();
foreach ($sessions as $session) {
if (! is_array($session)) {
continue;
}
$transactionId = trim((string) ($session['transaction_id'] ?? ''));
$consentRef = trim((string) ($session['consent_id'] ?? ''));
$consentArtifactId = $this->extractConsentArtifactId($consentRef);
$records = is_array($session['decrypted_data'] ?? null) ? (array) $session['decrypted_data'] : [];
foreach ($records as $record) {
if (! is_array($record)) {
$skipped++;
continue;
}
$careContextRef = trim((string) ($record['careContextReference'] ?? ''));
$media = trim((string) ($record['media'] ?? ''));
$bundle = $this->decodeBundle($record['decrypted_data'] ?? null);
if (! is_array($bundle) || empty($bundle)) {
$skipped++;
continue;
}
$summary = $this->buildSummary($bundle);
$resolvedAbha = $abhaAddress !== '' ? $abhaAddress : trim((string) ($summary['abha_address'] ?? ''));
$patientMap = $this->mapPatientByAbha($resolvedAbha, (string) ($summary['abha_number'] ?? ''));
$docHash = sha1(implode('|', [
$requestId,
$transactionId,
$consentRef,
$careContextRef,
(string) ($summary['bundle_id'] ?? ''),
]));
$row = [
'workflow_id' => $workflowId > 0 ? $workflowId : null,
'request_id' => $requestId !== '' ? $requestId : null,
'transaction_id' => $transactionId !== '' ? $transactionId : null,
'consent_ref' => $consentRef !== '' ? $consentRef : null,
'consent_artifact_id' => $consentArtifactId !== '' ? $consentArtifactId : null,
'abha_address' => $resolvedAbha !== '' ? $resolvedAbha : null,
'patient_id' => (int) ($patientMap['patient_id'] ?? 0) > 0 ? (int) $patientMap['patient_id'] : null,
'patient_name' => trim((string) ($summary['patient_name'] ?? '')) !== '' ? trim((string) ($summary['patient_name'] ?? '')) : null,
'care_context_reference' => $careContextRef !== '' ? $careContextRef : null,
'media' => $media !== '' ? $media : null,
'bundle_id' => trim((string) ($summary['bundle_id'] ?? '')) !== '' ? trim((string) ($summary['bundle_id'] ?? '')) : null,
'bundle_type' => trim((string) ($summary['bundle_type'] ?? '')) !== '' ? trim((string) ($summary['bundle_type'] ?? '')) : null,
'document_title' => trim((string) ($summary['document_title'] ?? '')) !== '' ? trim((string) ($summary['document_title'] ?? '')) : null,
'document_date' => $this->toDateTimeString((string) ($summary['document_date'] ?? '')),
'practitioner_name' => trim((string) ($summary['practitioner_name'] ?? '')) !== '' ? trim((string) ($summary['practitioner_name'] ?? '')) : null,
'organization_name' => trim((string) ($summary['organization_name'] ?? '')) !== '' ? trim((string) ($summary['organization_name'] ?? '')) : null,
'summary_json' => json_encode($summary, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'raw_bundle' => json_encode($bundle, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'updated_at' => $now,
];
$existing = $this->db->table('abdm_hiu_documents')
->select('id')
->where('doc_hash', $docHash)
->get(1)
->getRowArray();
if (! empty($existing)) {
$this->db->table('abdm_hiu_documents')->where('id', (int) $existing['id'])->update($row);
$updated++;
continue;
}
$row['doc_hash'] = $docHash;
$row['created_at'] = $now;
$this->db->table('abdm_hiu_documents')->insert($row);
$saved++;
}
}
return ['saved' => $saved, 'updated' => $updated, 'skipped' => $skipped];
}
public function listDocuments(array $filters, int $limit = 100): array
{
if (! $this->db->tableExists('abdm_hiu_documents')) {
return [];
}
$builder = $this->db->table('abdm_hiu_documents')
->select('*')
->orderBy('id', 'DESC')
->limit($limit);
$patientId = (int) ($filters['patient_id'] ?? 0);
if ($patientId > 0) {
$builder->where('patient_id', $patientId);
}
$abhaAddress = trim((string) ($filters['abha_address'] ?? ''));
if ($abhaAddress !== '') {
$builder->where('abha_address', $abhaAddress);
}
$q = trim((string) ($filters['q'] ?? ''));
if ($q !== '') {
$builder->groupStart()
->like('patient_name', $q)
->orLike('care_context_reference', $q)
->orLike('document_title', $q)
->orLike('practitioner_name', $q)
->orLike('organization_name', $q)
->groupEnd();
}
return $builder->get()->getResultArray();
}
public function getDocument(int $id): ?array
{
if ($id <= 0 || ! $this->db->tableExists('abdm_hiu_documents')) {
return null;
}
$row = $this->db->table('abdm_hiu_documents')->where('id', $id)->get(1)->getRowArray();
if (empty($row)) {
return null;
}
$row['summary'] = json_decode((string) ($row['summary_json'] ?? ''), true) ?: [];
$row['bundle'] = json_decode((string) ($row['raw_bundle'] ?? ''), true) ?: [];
return $row;
}
private function extractSessions(array $result): array
{
$sessions = $result['sessions'] ?? null;
if (is_array($sessions)) {
return $sessions;
}
$nested = $result['data']['sessions'] ?? null;
return is_array($nested) ? $nested : [];
}
private function extractConsentArtifactId(string $consentRef): string
{
$consentRef = trim($consentRef);
if ($consentRef === '') {
return '';
}
$parts = explode(':', $consentRef);
return trim((string) ($parts[0] ?? ''));
}
private function decodeBundle($raw): array
{
if (is_array($raw)) {
return $raw;
}
$text = trim((string) $raw);
if ($text === '') {
return [];
}
$decoded = json_decode($text, true);
return is_array($decoded) ? $decoded : [];
}
private function buildSummary(array $bundle): array
{
$entries = is_array($bundle['entry'] ?? null) ? (array) $bundle['entry'] : [];
$patient = [];
$composition = [];
$practitioner = [];
$organization = [];
$conditions = [];
$vitals = [];
$medications = [];
foreach ($entries as $entry) {
$resource = is_array($entry['resource'] ?? null) ? (array) $entry['resource'] : [];
$type = trim((string) ($resource['resourceType'] ?? ''));
if ($type === '') {
continue;
}
if ($type === 'Patient' && empty($patient)) {
$patient = $resource;
} elseif ($type === 'Composition' && empty($composition)) {
$composition = $resource;
} elseif ($type === 'Practitioner' && empty($practitioner)) {
$practitioner = $resource;
} elseif ($type === 'Organization' && empty($organization)) {
$organization = $resource;
} elseif ($type === 'Condition') {
$conditions[] = [
'text' => trim((string) (($resource['code']['text'] ?? '') ?: ($resource['code']['coding'][0]['display'] ?? ''))),
];
} elseif ($type === 'Observation') {
$codeText = trim((string) (($resource['code']['text'] ?? '') ?: ($resource['code']['coding'][0]['display'] ?? '')));
$val = '';
if (isset($resource['valueQuantity']) && is_array($resource['valueQuantity'])) {
$val = trim((string) (($resource['valueQuantity']['value'] ?? '') . ' ' . ($resource['valueQuantity']['unit'] ?? '')));
}
if ($codeText !== '' || $val !== '') {
$vitals[] = ['name' => $codeText, 'value' => $val];
}
} elseif ($type === 'MedicationRequest') {
$medications[] = [
'name' => trim((string) (($resource['medicationCodeableConcept']['text'] ?? '') ?: ($resource['medicationCodeableConcept']['coding'][0]['display'] ?? ''))),
'dose' => trim((string) ($resource['dosageInstruction'][0]['text'] ?? '')),
];
}
}
$patientName = trim((string) (($patient['name'][0]['text'] ?? '') ?: implode(' ', (array) ($patient['name'][0]['given'] ?? []))));
$abhaNumber = '';
foreach ((array) ($patient['identifier'] ?? []) as $identifier) {
$system = trim((string) ($identifier['system'] ?? ''));
if (stripos($system, 'healthid.ndhm.gov.in') !== false) {
$abhaNumber = preg_replace('/\D+/', '', (string) ($identifier['value'] ?? '')) ?? '';
break;
}
}
$practitionerName = trim((string) (($practitioner['name'][0]['text'] ?? '') ?: implode(' ', (array) ($practitioner['name'][0]['given'] ?? []))));
$organizationName = trim((string) ($organization['name'] ?? ''));
return [
'bundle_id' => trim((string) ($bundle['id'] ?? '')),
'bundle_type' => trim((string) (($bundle['resourceType'] ?? '') . '/' . ($bundle['type'] ?? ''))),
'document_title' => trim((string) ($composition['title'] ?? '')),
'document_date' => trim((string) ($composition['date'] ?? $bundle['timestamp'] ?? '')),
'patient_name' => $patientName,
'patient_gender' => trim((string) ($patient['gender'] ?? '')),
'patient_birth_date' => trim((string) ($patient['birthDate'] ?? '')),
'abha_number' => $abhaNumber,
'abha_address' => $abhaNumber !== '' ? ($abhaNumber . '@sbx') : '',
'practitioner_name' => $practitionerName,
'organization_name' => $organizationName,
'conditions' => $conditions,
'vitals' => $vitals,
'medications' => $medications,
];
}
private function mapPatientByAbha(string $abhaAddress, string $abhaNumber): array
{
if (! $this->db->tableExists('patient_master')) {
return ['patient_id' => 0];
}
$fields = $this->db->getFieldNames('patient_master') ?? [];
$idCol = $this->resolveExistingColumn($fields, ['id']);
if ($idCol === null) {
return ['patient_id' => 0];
}
$abhaAddressCol = $this->resolveExistingColumn($fields, ['abha_address', 'abha_addr']);
$abhaNumberCol = $this->resolveExistingColumn($fields, ['abha_id', 'abha_no', 'abha_number', 'abha']);
if ($abhaAddress !== '' && $abhaAddressCol !== null) {
$row = $this->db->table('patient_master')
->select($idCol . ' AS patient_id')
->where($abhaAddressCol, $abhaAddress)
->orderBy($idCol, 'DESC')
->get(1)
->getRowArray();
if (! empty($row)) {
return ['patient_id' => (int) ($row['patient_id'] ?? 0)];
}
}
$digits = preg_replace('/\D+/', '', $abhaNumber) ?? '';
if ($digits !== '' && $abhaNumberCol !== null) {
$row = $this->db->table('patient_master')
->select($idCol . ' AS patient_id')
->where($abhaNumberCol, $digits)
->orderBy($idCol, 'DESC')
->get(1)
->getRowArray();
if (! empty($row)) {
return ['patient_id' => (int) ($row['patient_id'] ?? 0)];
}
}
return ['patient_id' => 0];
}
private function resolveExistingColumn(array $fields, array $candidates): ?string
{
foreach ($candidates as $candidate) {
if (in_array($candidate, $fields, true)) {
return $candidate;
}
}
return null;
}
private function toDateTimeString(string $value): ?string
{
$value = trim($value);
if ($value === '') {
return null;
}
try {
$dt = new \DateTime($value);
return $dt->format('Y-m-d H:i:s');
} catch (\Throwable $e) {
return null;
}
}
}

View File

@ -41,16 +41,6 @@ class M3HiuGatewayClient
return $this->call('/v3/hiu/consent/request/status', $payload, 'consent.request.status');
}
public function consentRequestFetch(array $payload): array
{
return $this->call('/v3/hiu/consent/request/fetch', $payload, 'consent.request.fetch');
}
public function healthInformationRequest(array $payload): array
{
return $this->call('/v1/hiu/data/request', $payload, 'health-information.request');
}
public function reconcileConsentStatus(array $payload): array
{
return $this->callGet('/v1/hiu/consent/status', $payload, 'consent.status.reconcile');
@ -121,9 +111,7 @@ class M3HiuGatewayClient
if ($curlErr !== '') {
$errorText = $curlErr;
} elseif ($ok !== 1) {
$errorText = $isJson
? (string) ($decoded['message'] ?? $decoded['error_text'] ?? $decoded['error'] ?? ('HTTP ' . $httpCode))
: ('HTTP ' . $httpCode . ' non-JSON response');
$errorText = $this->extractErrorText($decoded, $httpCode, $isJson);
}
if ($this->isCloudFrontBlockedResponse($httpCode, $raw, $decoded ?? [])) {
@ -267,9 +255,7 @@ class M3HiuGatewayClient
if ($curlErr !== '') {
$errorText = $curlErr;
} elseif ($ok !== 1) {
$errorText = $isJson
? (string) ($decoded['message'] ?? $decoded['error_text'] ?? $decoded['error'] ?? ('HTTP ' . $httpCode))
: ('HTTP ' . $httpCode . ' non-JSON response');
$errorText = $this->extractErrorText($decoded, $httpCode, $isJson);
}
if ($this->isCloudFrontBlockedResponse($httpCode, $raw, $decoded ?? [])) {
@ -440,6 +426,33 @@ class M3HiuGatewayClient
]);
}
private function extractErrorText($decoded, int $httpCode, bool $isJson): string
{
if (! $isJson || ! is_array($decoded)) {
return 'HTTP ' . $httpCode . ' non-JSON response';
}
$code = trim((string) (
$decoded['data']['error']['code']
?? $decoded['error']['code']
?? ''
));
$message = trim((string) (
$decoded['data']['error']['message']
?? $decoded['error']['message']
?? $decoded['message']
?? $decoded['error_text']
?? (is_string($decoded['error'] ?? null) ? $decoded['error'] : '')
));
$combined = trim(($code !== '' ? $code . ' ' : '') . $message);
if ($combined !== '') {
return $combined;
}
return 'HTTP ' . $httpCode;
}
private function findFirstValueByKeys(array $root, array $keys): string
{
$queue = [$root];

View File

@ -4,16 +4,19 @@ namespace App\Libraries\Abdm;
use CodeIgniter\I18n\Time;
use App\Libraries\Abdm\M3HiuGatewayClient;
use App\Libraries\Abdm\M3HiuDocumentRepository;
class M3HiuWorkflowService
{
private \CodeIgniter\Database\BaseConnection $db;
private M3HiuGatewayClient $client;
private M3HiuDocumentRepository $documentRepository;
public function __construct()
{
$this->db = \Config\Database::connect();
$this->client = new M3HiuGatewayClient();
$this->documentRepository = new M3HiuDocumentRepository();
}
public function runOperation(string $operation, array $payload): array
@ -21,6 +24,24 @@ class M3HiuWorkflowService
$hasWorkflowTable = $this->db->tableExists('abdm_hiu_workflows');
$normalized = $this->normalizePayload($payload);
$retiredError = $this->retiredOperationError($operation, $normalized);
if ($retiredError !== null) {
if ($hasWorkflowTable) {
$this->upsertWorkflow(
$operation,
$normalized,
$retiredError,
'RETIRED',
'failed',
(int) ($retiredError['http_code'] ?? 410),
false
);
$retiredError['workflow_persisted'] = 1;
}
return $retiredError;
}
$prepared = $this->preparePayloadForOperation($operation, $normalized);
if (($prepared['ok'] ?? 0) !== 1) {
$failedResult = [
@ -50,7 +71,7 @@ class M3HiuWorkflowService
}
$dispatchPayload = (array) ($prepared['payload'] ?? []);
if ($hasWorkflowTable && ! in_array($operation, ['consent_status', 'consent_reconcile', 'data_fetch', 'hi_request'], true)) {
if ($hasWorkflowTable && ! in_array($operation, ['consent_status', 'consent_reconcile', 'data_fetch'], true)) {
$duplicate = $this->findDuplicate($operation, $normalized);
if ($duplicate !== null) {
$dupResponse = json_decode((string) ($duplicate['response_json'] ?? ''), true);
@ -82,8 +103,14 @@ class M3HiuWorkflowService
}
if ($hasWorkflowTable) {
$this->upsertWorkflow($operation, $persistPayload, $result, $state, $status, $httpCode, $retryable);
$workflowId = $this->upsertWorkflow($operation, $persistPayload, $result, $state, $status, $httpCode, $retryable);
$result['workflow_persisted'] = 1;
if ($operation === 'data_fetch' && $ok) {
$docPersist = $this->documentRepository->persistFromDataFetch($persistPayload, $result, $workflowId);
$result['documents_persisted'] = (int) ($docPersist['saved'] ?? 0);
$result['documents_updated'] = (int) ($docPersist['updated'] ?? 0);
}
} else {
$result['workflow_persisted'] = 0;
$result['workflow_warning'] = 'abdm_hiu_workflows table not found; operation executed without timeline persistence';
@ -92,6 +119,26 @@ class M3HiuWorkflowService
return $result;
}
private function retiredOperationError(string $operation, array $payload): ?array
{
if (! in_array($operation, ['consent_fetch', 'hi_request'], true)) {
return null;
}
return [
'ok' => 0,
'http_code' => 410,
'error_text' => 'Operation ' . $operation . ' is retired. Use consent_reconcile and data_fetch polling only.',
'retryable' => 0,
'request_id' => (string) ($payload['request_id'] ?? ''),
'transaction_id' => (string) ($payload['transaction_id'] ?? ''),
'consent_id' => (string) ($payload['consent_id'] ?? ''),
'abha_address' => (string) ($payload['abha_address'] ?? ''),
'workflow_state' => 'RETIRED',
'replacement_ops' => ['consent_reconcile', 'data_fetch'],
];
}
public function retryDue(int $limit = 20): array
{
if (! $this->db->tableExists('abdm_hiu_workflows')) {
@ -122,6 +169,11 @@ class M3HiuWorkflowService
continue;
}
if (in_array($operation, ['consent_fetch', 'hi_request'], true)) {
$summary['skipped']++;
continue;
}
$result = $this->dispatch($operation, $payload);
$ok = (int) ($result['ok'] ?? 0) === 1;
$state = $this->resolveState($operation, $result);
@ -179,58 +231,41 @@ class M3HiuWorkflowService
if ((int) ($consentResult['ok'] ?? 0) === 1) {
$summary['consent_updates']++;
$resolvedConsentId = trim((string) ($consentResult['abdm_consent_artifact_id'] ?? $consentResult['consent_id'] ?? ''));
if ($resolvedConsentId !== '' && ! $this->hasSuccessfulHiRequest($requestId, $resolvedConsentId)) {
if (! $this->shouldAttemptHiRequest($requestId, $resolvedConsentId)) {
$summary['skipped']++;
continue;
}
$hiPayload = $payload;
$hiPayload['consent_id'] = $resolvedConsentId;
$hiPayload['abdm_consent_artifact_id'] = $resolvedConsentId;
$hiPayload['transaction_id'] = $this->generateFreshHiTransactionId($requestId, trim((string) ($payload['transaction_id'] ?? '')));
$hiResult = $this->runOperation('hi_request', $hiPayload);
if ((int) ($hiResult['ok'] ?? 0) !== 1) {
$summary['failed']++;
}
$consentState = strtolower(trim((string) (
$consentResult['workflow_state']
?? $consentResult['status']
?? $consentResult['consent_status']
?? ''
)));
if ($consentState !== 'granted') {
$summary['skipped']++;
continue;
}
} else {
$summary['failed']++;
}
}
$hiRows = $this->db->table('abdm_hiu_workflows')
->select('*')
->where('operation', 'hi_request')
->where('status', 'success')
->orderBy('id', 'DESC')
->get($limit)
->getResultArray();
$resolvedConsentId = trim((string) (
$consentResult['abdm_consent_artifact_id']
?? $consentResult['consent_id']
?? $consentResult['abdm_consent_request_id']
?? $consentResult['consent_request_id']
?? ''
));
if ($resolvedConsentId === '' || $this->isGatewayRequestIdPattern($resolvedConsentId)) {
$summary['skipped']++;
continue;
}
$seenTransactions = [];
foreach ($hiRows as $row) {
$transactionId = trim((string) ($row['transaction_id'] ?? ''));
$requestId = trim((string) ($row['request_id'] ?? ''));
$key = $transactionId !== '' ? $transactionId : $requestId;
if ($key === '' || isset($seenTransactions[$key])) {
$summary['skipped']++;
continue;
}
$seenTransactions[$key] = true;
$dataPayload = $payload;
$dataPayload['consent_id'] = $resolvedConsentId;
$dataPayload['consent_request_id'] = $resolvedConsentId;
$dataPayload['abdm_consent_artifact_id'] = $resolvedConsentId;
$dataPayload['abdm_consent_request_id'] = $resolvedConsentId;
$payload = [
'request_id' => $requestId,
'transaction_id' => $transactionId,
'consent_id' => trim((string) ($row['consent_id'] ?? '')),
'abdm_consent_artifact_id' => trim((string) ($row['abdm_consent_artifact_id'] ?? '')),
'abha_address' => trim((string) ($row['abha_address'] ?? '')),
'hfr_id' => $this->normalizeStoredHfrId((string) ($row['hfr_id'] ?? '')),
];
$dataResult = $this->runOperation('data_fetch', $payload);
if ((int) ($dataResult['ok'] ?? 0) === 1) {
$summary['data_updates']++;
$dataResult = $this->runOperation('data_fetch', $dataPayload);
if ((int) ($dataResult['ok'] ?? 0) === 1) {
$summary['data_updates']++;
} else {
$summary['failed']++;
}
} else {
$summary['failed']++;
}
@ -558,8 +593,6 @@ class M3HiuWorkflowService
return match ($operation) {
'consent_request' => $this->client->consentRequest($payload),
'consent_status' => $this->client->consentRequestStatus($payload),
'consent_fetch' => $this->client->consentRequestFetch($payload),
'hi_request' => $this->client->healthInformationRequest($payload),
'consent_reconcile' => $this->client->reconcileConsentStatus($payload),
'data_fetch' => $this->client->fetchDecryptedData($payload),
default => ['ok' => 0, 'http_code' => 400, 'error_text' => 'Unsupported operation: ' . $operation, 'retryable' => 0],
@ -701,6 +734,7 @@ class M3HiuWorkflowService
}
}
// Preserve requester.identifier for bridge deployments that validate strict requester schema.
$requesterIdentifierValue = trim((string) ($consent['requester']['identifier']['value'] ?? ''));
if ($requesterIdentifierValue === '') {
$requesterIdentifierValue = $hiuId !== '' ? $hiuId : trim((string) ($clean['hfr_id'] ?? ''));
@ -717,6 +751,15 @@ class M3HiuWorkflowService
$consent['requester']['identifier']['value'] = $requesterIdentifierValue;
}
$consent['hiTypes'] = $this->normalizeConsentHiTypes($consent['hiTypes'] ?? []);
if (empty($consent['hiTypes'])) {
return [
'ok' => 0,
'http_code' => 422,
'error_text' => 'consent.hiTypes is required for consent request.',
];
}
return [
'ok' => 1,
'payload' => $base + ['consent' => $consent],
@ -749,83 +792,6 @@ class M3HiuWorkflowService
];
}
if ($operation === 'consent_fetch') {
$consentId = trim((string) ($clean['abdm_consent_artifact_id'] ?? $clean['consentId'] ?? $clean['consent_id'] ?? ''));
if ($consentId === '') {
$consentId = $this->resolveAbdmConsentArtifactIdFromWorkflow($clean);
}
if ($consentId === '') {
return [
'ok' => 0,
'http_code' => 422,
'error_text' => 'consentId is required for consent fetch. Consent artifact is not available yet; run Check Consent Status until consent is GRANTED, then fetch using artifact id.',
];
}
if ($this->isGatewayRequestIdPattern($consentId)) {
return [
'ok' => 0,
'http_code' => 422,
'error_text' => 'mapping_error: consentId appears to be a local temporary request id; use ABDM consent artifact id only.',
];
}
return [
'ok' => 1,
'payload' => $base + ['consentId' => $consentId],
];
}
if ($operation === 'hi_request') {
$hiRequest = $clean['hiRequest'] ?? null;
if (! is_array($hiRequest) || empty($hiRequest)) {
$hiRequest = $this->buildDefaultHiRequest($clean);
}
if (! is_array($hiRequest) || empty($hiRequest)) {
return [
'ok' => 0,
'http_code' => 422,
'error_text' => 'hiRequest object is required for health information request.',
];
}
$consentId = trim((string) ($hiRequest['consent']['id'] ?? $clean['consentId'] ?? $clean['consent_id'] ?? ''));
if ($consentId === '') {
return [
'ok' => 0,
'http_code' => 422,
'error_text' => 'hiRequest.consent.id is required for health information request.',
];
}
if (! isset($hiRequest['consent']) || ! is_array($hiRequest['consent'])) {
$hiRequest['consent'] = [];
}
$hiRequest['consent']['id'] = $consentId;
$requestIdRef = trim((string) ($clean['request_id'] ?? $clean['requestId'] ?? ''));
$transactionId = $this->generateFreshHiTransactionId(
$requestIdRef,
trim((string) ($hiRequest['transactionId'] ?? $clean['transactionId'] ?? $clean['transaction_id'] ?? ''))
);
$hiRequest['transactionId'] = $transactionId;
if (! isset($hiRequest['dateRange']) || ! is_array($hiRequest['dateRange'])) {
$hiRequest['dateRange'] = [
'from' => gmdate('Y-m-d\T00:00:00.000\Z', strtotime('-365 days')),
'to' => gmdate('Y-m-d\T00:00:00.000\Z'),
];
}
$hiRequest['dateRange'] = $this->resolveSafeHiDateRange($requestIdRef, $consentId, $hiRequest['dateRange']);
if (trim((string) ($hiRequest['dataPushUrl'] ?? '')) === '') {
$hiRequest['dataPushUrl'] = rtrim((string) (getenv('EATRIA_BRIDGE_URL') ?: 'https://abdm-bridge.e-atria.in/api'), '/') . '/v3/hiu/data/push';
}
return [
'ok' => 1,
'payload' => $base + ['transactionId' => $transactionId, 'transaction_id' => $transactionId, 'hiRequest' => $hiRequest],
];
}
if ($operation === 'consent_reconcile') {
$requestIdRef = trim((string) ($clean['request_id'] ?? $clean['requestId'] ?? ''));
$consentRequestId = trim((string) ($clean['abdm_consent_request_id'] ?? $clean['consentRequestId'] ?? ''));
@ -857,27 +823,34 @@ class M3HiuWorkflowService
}
if ($operation === 'data_fetch') {
$transactionId = trim((string) ($clean['transaction_id'] ?? $clean['transactionId'] ?? ''));
$consentId = trim((string) ($clean['abdm_consent_artifact_id'] ?? $clean['consentId'] ?? $clean['consent_id'] ?? ''));
if ($consentId !== '' && $this->isGatewayRequestIdPattern($consentId)) {
$consentId = '';
$consentId = trim((string) (
$clean['abdm_consent_artifact_id']
?? $clean['consentId']
?? $clean['consent_id']
?? $clean['abdm_consent_request_id']
?? $clean['consentRequestId']
?? $clean['consent_request_id']
?? $clean['request_id']
?? $clean['requestId']
?? ''
));
if ($consentId === '') {
$consentId = $this->resolveAbdmConsentArtifactIdFromWorkflow($clean);
}
if ($consentId === '') {
$consentId = $this->resolveAbdmConsentRequestIdFromWorkflow($clean);
}
if ($transactionId === '' && $consentId === '') {
if ($consentId === '') {
return [
'ok' => 0,
'http_code' => 422,
'error_text' => 'One of transaction_id or consent_id is required for data fetch polling.',
'error_text' => 'consent_id is required for decrypted data fetch polling. Use consent artifact id, consent request id, or original request_id after consent is GRANTED.',
];
}
$query = [];
if ($transactionId !== '') {
$query['transaction_id'] = $transactionId;
}
if ($consentId !== '') {
$query['consent_id'] = $consentId;
}
$query['consent_id'] = $consentId;
return [
'ok' => 1,
@ -977,272 +950,55 @@ class M3HiuWorkflowService
return $consent;
}
private function buildDefaultHiRequest(array $payload): array
{
$consentId = trim((string) ($payload['abdm_consent_artifact_id'] ?? $payload['consentId'] ?? $payload['consent_id'] ?? ''));
if ($consentId === '' || $this->isGatewayRequestIdPattern($consentId)) {
return [];
}
$requestIdRef = trim((string) ($payload['request_id'] ?? $payload['requestId'] ?? ''));
$transactionId = $this->generateFreshHiTransactionId(
$requestIdRef,
trim((string) ($payload['transactionId'] ?? $payload['transaction_id'] ?? ''))
);
return [
'consent' => [
'id' => $consentId,
],
'dateRange' => $this->resolveSafeHiDateRange($requestIdRef, $consentId, [
'from' => gmdate('Y-m-d\T00:00:00.000\Z', strtotime('-365 days')),
'to' => gmdate('Y-m-d\T00:00:00.000\Z'),
]),
'dataPushUrl' => rtrim((string) (getenv('EATRIA_BRIDGE_URL') ?: 'https://abdm-bridge.e-atria.in/api'), '/') . '/v3/hiu/data/push',
'transactionId' => $transactionId,
];
}
/**
* Build a safe HI date range by enforcing consent-granted permission window
* (from consent fetch/reconcile details), then clamping with from <= to <= now.
* Normalize legacy/record hiTypes to HIU consent hiTypes accepted by bridge.
*
* @param array<string, mixed> $fallbackRange
* @return array<string, string>
* @param mixed $hiTypes
* @return array<int, string>
*/
private function resolveSafeHiDateRange(string $requestIdRef, string $consentId, array $fallbackRange): array
private function normalizeConsentHiTypes($hiTypes): array
{
$grantedRange = $this->resolveGrantedConsentDateRange($requestIdRef, $consentId);
$consentRange = ! empty($grantedRange)
? $grantedRange
: $this->resolveConsentRequestDateRangeByRequestId($requestIdRef);
$fromRaw = trim((string) ($consentRange['from'] ?? $fallbackRange['from'] ?? ''));
$toRaw = trim((string) ($consentRange['to'] ?? $fallbackRange['to'] ?? ''));
$nowMs = (int) floor(microtime(true) * 1000);
$fromMs = $this->parseIsoUtcToEpochMillis($fromRaw);
$toMs = $this->parseIsoUtcToEpochMillis($toRaw);
$grantedFromMs = $this->parseIsoUtcToEpochMillis((string) ($grantedRange['from'] ?? ''));
$grantedToMs = $this->parseIsoUtcToEpochMillis((string) ($grantedRange['to'] ?? ''));
if ($fromMs === null) {
$fromMs = $nowMs - (365 * 24 * 60 * 60 * 1000);
if (is_string($hiTypes)) {
$hiTypes = [$hiTypes];
}
if ($toMs === null) {
$toMs = $nowMs;
}
// HMS-side requirement: keep HI range equal to or strictly within granted consent permission range.
if ($grantedFromMs !== null && $fromMs < $grantedFromMs) {
$fromMs = $grantedFromMs;
}
if ($grantedToMs !== null && $toMs > $grantedToMs) {
$toMs = $grantedToMs;
}
if ($toMs > $nowMs) {
$toMs = $nowMs;
}
if ($fromMs > $toMs) {
if ($grantedFromMs !== null && $grantedFromMs <= $toMs) {
$fromMs = $grantedFromMs;
} else {
$fromMs = $toMs;
}
}
return [
'from' => $this->formatEpochMillisToIsoUtc($fromMs),
'to' => $this->formatEpochMillisToIsoUtc($toMs),
];
}
private function parseIsoUtcToEpochMillis(string $value): ?int
{
$raw = trim($value);
if ($raw === '') {
return null;
}
try {
$dt = new \DateTimeImmutable($raw);
} catch (\Throwable) {
return null;
}
$sec = (int) $dt->format('U');
$micro = (int) $dt->format('u');
return ($sec * 1000) + intdiv($micro, 1000);
}
private function formatEpochMillisToIsoUtc(int $epochMs): string
{
$sec = intdiv($epochMs, 1000);
$ms = $epochMs - ($sec * 1000);
if ($ms < 0) {
$ms = 0;
}
$dt = (new \DateTimeImmutable('@' . $sec))->setTimezone(new \DateTimeZone('UTC'));
return $dt->format('Y-m-d\TH:i:s') . '.' . str_pad((string) $ms, 3, '0', STR_PAD_LEFT) . 'Z';
}
/**
* @return array{from?: string, to?: string}
*/
private function resolveGrantedConsentDateRange(string $requestId, string $consentId): array
{
if (! $this->db->tableExists('abdm_hiu_workflows')) {
if (! is_array($hiTypes)) {
return [];
}
$fields = $this->db->getFieldNames('abdm_hiu_workflows') ?? [];
$builder = $this->db->table('abdm_hiu_workflows')
->select('response_json, request_json')
->whereIn('operation', ['consent_fetch', 'consent_reconcile', 'consent_status', 'consent_callback']);
$hasFilter = false;
$builder->groupStart();
if ($requestId !== '') {
$builder->orWhere('request_id', $requestId);
$hasFilter = true;
}
if ($consentId !== '') {
$builder->orWhere('consent_id', $consentId);
$hasFilter = true;
if (in_array('abdm_consent_artifact_id', $fields, true)) {
$builder->orWhere('abdm_consent_artifact_id', $consentId);
}
}
$builder->groupEnd();
if (! $hasFilter) {
return [];
}
$rows = $builder->orderBy('id', 'DESC')->get(30)->getResultArray();
foreach ($rows as $row) {
foreach (['response_json', 'request_json'] as $jsonCol) {
$decoded = json_decode((string) ($row[$jsonCol] ?? ''), true);
if (! is_array($decoded)) {
continue;
}
$range = $this->extractPermissionDateRange($decoded);
if (! empty($range)) {
return $range;
}
}
}
return [];
}
/**
* @param array<string, mixed> $data
* @return array{from?: string, to?: string}
*/
private function extractPermissionDateRange(array $data): array
{
$pathCandidates = [
$data['consent']['permission']['dateRange'] ?? null,
$data['consentDetail']['permission']['dateRange'] ?? null,
$data['data']['consent']['permission']['dateRange'] ?? null,
$data['data']['consentDetail']['permission']['dateRange'] ?? null,
$data['response']['consent']['permission']['dateRange'] ?? null,
$data['response']['consentDetail']['permission']['dateRange'] ?? null,
$map = [
'opconsultrecord' => 'OPConsultation',
'opconsultation' => 'OPConsultation',
'diagnosticreportrecord' => 'DiagnosticReport',
'diagnosticreport' => 'DiagnosticReport',
'prescriptionrecord' => 'Prescription',
'prescription' => 'Prescription',
'dischargesummaryrecord' => 'DischargeSummary',
'dischargesummary' => 'DischargeSummary',
'healthdocumentrecord' => 'HealthDocument',
'healthdocument' => 'HealthDocument',
'immunization' => 'ImmunizationRecord',
'immunizationrecord' => 'ImmunizationRecord',
'wellness' => 'Wellness',
'wellnessrecord' => 'Wellness',
'invoice' => 'Invoice',
'invoicerecord' => 'Invoice',
];
foreach ($pathCandidates as $candidate) {
if (is_array($candidate)) {
$from = trim((string) ($candidate['from'] ?? ''));
$to = trim((string) ($candidate['to'] ?? ''));
if ($from !== '' || $to !== '') {
$out = [];
if ($from !== '') {
$out['from'] = $from;
}
if ($to !== '') {
$out['to'] = $to;
}
return $out;
}
}
}
if (isset($data['permission']) && is_array($data['permission'])) {
$range = $data['permission']['dateRange'] ?? null;
if (is_array($range)) {
$from = trim((string) ($range['from'] ?? ''));
$to = trim((string) ($range['to'] ?? ''));
if ($from !== '' || $to !== '') {
$out = [];
if ($from !== '') {
$out['from'] = $from;
}
if ($to !== '') {
$out['to'] = $to;
}
return $out;
}
}
}
foreach ($data as $value) {
if (! is_array($value)) {
$normalized = [];
foreach ($hiTypes as $type) {
$raw = trim((string) $type);
if ($raw === '') {
continue;
}
$nested = $this->extractPermissionDateRange($value);
if (! empty($nested)) {
return $nested;
$key = strtolower($raw);
$resolved = $map[$key] ?? $raw;
if (! in_array($resolved, $normalized, true)) {
$normalized[] = $resolved;
}
}
return [];
}
/**
* @return array{from?: string, to?: string}
*/
private function resolveConsentRequestDateRangeByRequestId(string $requestId): array
{
if ($requestId === '' || ! $this->db->tableExists('abdm_hiu_workflows')) {
return [];
}
$row = $this->db->table('abdm_hiu_workflows')
->select('request_json')
->where('operation', 'consent_request')
->where('request_id', $requestId)
->orderBy('id', 'DESC')
->get(1)
->getRowArray();
if (empty($row['request_json'])) {
return [];
}
$decoded = json_decode((string) $row['request_json'], true);
if (! is_array($decoded)) {
return [];
}
$from = trim((string) ($decoded['consent']['permission']['dateRange']['from'] ?? ''));
$to = trim((string) ($decoded['consent']['permission']['dateRange']['to'] ?? ''));
$out = [];
if ($from !== '') {
$out['from'] = $from;
}
if ($to !== '') {
$out['to'] = $to;
}
return $out;
return $normalized;
}
private function readSettingValue(string $key): string
@ -1460,102 +1216,6 @@ class M3HiuWorkflowService
return null;
}
private function hasSuccessfulHiRequest(string $requestId, string $consentId): bool
{
if (! $this->db->tableExists('abdm_hiu_workflows')) {
return false;
}
$builder = $this->db->table('abdm_hiu_workflows')
->select('id')
->where('operation', 'hi_request')
->where('status', 'success');
$builder->groupStart();
if ($requestId !== '') {
$builder->orWhere('request_id', $requestId);
}
if ($consentId !== '') {
$builder->orWhere('consent_id', $consentId);
if (in_array('abdm_consent_artifact_id', $this->db->getFieldNames('abdm_hiu_workflows') ?? [], true)) {
$builder->orWhere('abdm_consent_artifact_id', $consentId);
}
}
$builder->groupEnd();
$row = $builder->orderBy('id', 'DESC')->get(1)->getRowArray();
return ! empty($row);
}
private function shouldAttemptHiRequest(string $requestId, string $consentId): bool
{
if (! $this->db->tableExists('abdm_hiu_workflows')) {
return true;
}
$row = $this->latestHiRequestRow($requestId, $consentId);
if (empty($row)) {
return true;
}
$status = strtolower(trim((string) ($row['status'] ?? '')));
$retryable = (int) ($row['is_retryable'] ?? 0) === 1;
$retryCount = (int) ($row['retry_count'] ?? 0);
$nextRetryAt = trim((string) ($row['next_retry_at'] ?? ''));
if ($status !== 'failed' || ! $retryable) {
return true;
}
if ($nextRetryAt === '') {
return $retryCount < 4;
}
return strtotime($nextRetryAt) <= time();
}
private function latestHiRequestRow(string $requestId, string $consentId): array
{
if (! $this->db->tableExists('abdm_hiu_workflows')) {
return [];
}
$fields = $this->db->getFieldNames('abdm_hiu_workflows') ?? [];
$builder = $this->db->table('abdm_hiu_workflows')
->select('id, status, is_retryable, retry_count, next_retry_at')
->where('operation', 'hi_request');
$builder->groupStart();
$hasCondition = false;
if ($requestId !== '') {
$builder->where('request_id', $requestId);
$hasCondition = true;
}
if ($consentId !== '') {
if ($hasCondition) {
$builder->orWhere('consent_id', $consentId);
} else {
$builder->where('consent_id', $consentId);
$hasCondition = true;
}
if (in_array('abdm_consent_artifact_id', $fields, true)) {
$builder->orWhere('abdm_consent_artifact_id', $consentId);
}
}
$builder->groupEnd();
if (! $hasCondition) {
return [];
}
return $builder->orderBy('id', 'DESC')->get(1)->getRowArray() ?? [];
}
private function generateFreshHiTransactionId(string $requestId, string $currentTransactionId): string
{
return $this->generateUuidV4();
}
private function generateUuidV4(): string
{
$bytes = random_bytes(16);
@ -1574,7 +1234,7 @@ class M3HiuWorkflowService
int $httpCode,
bool $retryable,
int $forceId = 0
): void {
): int {
$now = Time::now('Asia/Kolkata')->toDateTimeString();
$hmsRequestId = $this->firstNonEmptyString([
$payload['request_id'] ?? null,
@ -1706,11 +1366,12 @@ class M3HiuWorkflowService
if ($id > 0) {
$this->db->table('abdm_hiu_workflows')->where('id', $id)->update($row);
return;
return $id;
}
$row['created_at'] = $now;
$this->db->table('abdm_hiu_workflows')->insert($row);
return (int) ($this->db->insertID() ?: 0);
}
private function isGatewayRequestIdPattern(string $value): bool

View File

@ -13,9 +13,11 @@ class AbdmGatewayPushClient
'OPConsultRecord',
'DiagnosticReportRecord',
'DischargeSummaryRecord',
'ImmunizationRecord',
'InvoiceRecord',
'PrescriptionRecord',
'HealthDocumentRecord',
'WellnessRecord',
];
public function __construct(?BaseConnection $db = null)

View File

@ -0,0 +1,269 @@
<div class="container-fluid py-3">
<div class="card shadow-sm mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">ABDM Fetched Patient Documents</h5>
<a class="btn btn-sm btn-outline-primary" href="<?= base_url('AbdmHiu') ?>">Back To HIU Console</a>
</div>
<div class="card-body">
<div class="row g-2 align-items-end">
<div class="col-md-4">
<label class="form-label mb-1">Search Patient</label>
<input id="patient_search" class="form-control" placeholder="Name / UHID / ABHA / Mobile">
</div>
<div class="col-md-2">
<button id="btnPatientSearch" class="btn btn-outline-secondary w-100">Search</button>
</div>
<div class="col-md-4">
<label class="form-label mb-1">Search Documents</label>
<input id="doc_search" class="form-control" placeholder="title / care context / doctor">
</div>
<div class="col-md-2">
<button id="btnDocSearch" class="btn btn-outline-primary w-100">Load Documents</button>
</div>
</div>
<div class="small text-muted mt-2" id="patient_status">Select a patient to view mapped ABDM documents.</div>
<div class="table-responsive mt-3">
<table class="table table-sm table-hover" id="patient_table">
<thead>
<tr>
<th>Action</th>
<th>Patient</th>
<th>UHID</th>
<th>ABHA Address</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</div>
<div class="row g-3">
<div class="col-lg-5">
<div class="card shadow-sm h-100">
<div class="card-header">Document List</div>
<div class="card-body p-0">
<div class="table-responsive" style="max-height: 560px;">
<table class="table table-sm table-striped mb-0" id="doc_table">
<thead>
<tr>
<th>Date</th>
<th>Title</th>
<th>Care Context</th>
<th>Doctor</th>
</tr>
</thead>
<tbody>
<tr><td colspan="4" class="text-muted text-center">No documents loaded.</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="col-lg-7">
<div class="card shadow-sm h-100">
<div class="card-header">Document Detail</div>
<div class="card-body" id="doc_detail_box">
<div class="text-muted">Select a document to view human-readable summary.</div>
</div>
</div>
</div>
</div>
</div>
<script>
(function () {
var selectedPatient = null;
var docs = [];
var patientLookupUrl = '<?= base_url('AbdmHiu/patient_lookup') ?>';
var docsListUrl = '<?= base_url('AbdmHiu/documents_list') ?>';
var docDetailBase = '<?= base_url('AbdmHiu/document_detail') ?>';
function esc(v) {
return (v || '').toString()
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
function parseDate(v) {
var t = (v || '').toString().trim();
if (!t) return '-';
var d = new Date(t.replace(' ', 'T'));
if (isNaN(d.getTime())) return t;
return d.toLocaleString();
}
function renderPatientRows(items) {
var tbody = document.querySelector('#patient_table tbody');
tbody.innerHTML = '';
if (!items.length) {
tbody.innerHTML = '<tr><td colspan="4" class="text-muted text-center">No patients found.</td></tr>';
return;
}
items.forEach(function (p, idx) {
var tr = document.createElement('tr');
tr.innerHTML = '' +
'<td><button class="btn btn-sm btn-outline-primary" data-p-idx="' + idx + '">Select</button></td>' +
'<td>' + esc(p.patient_name) + '</td>' +
'<td>' + esc(p.patient_code) + '</td>' +
'<td>' + esc(p.abha_address) + '</td>';
tbody.appendChild(tr);
});
tbody.querySelectorAll('button[data-p-idx]').forEach(function (btn) {
btn.addEventListener('click', function () {
var idx = parseInt(btn.getAttribute('data-p-idx'), 10);
if (!Number.isNaN(idx) && items[idx]) {
selectedPatient = items[idx];
document.getElementById('patient_status').textContent = 'Selected: ' +
(selectedPatient.patient_name || '-') + ' | ABHA: ' + (selectedPatient.abha_address || '-');
loadDocuments();
}
});
});
}
function searchPatients() {
var q = document.getElementById('patient_search').value.trim();
var url = patientLookupUrl + '?q=' + encodeURIComponent(q);
fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest' } })
.then(function (r) { return r.json(); })
.then(function (res) {
if ((res.ok || 0) !== 1) {
document.getElementById('patient_status').textContent = res.error || 'Search failed';
return;
}
renderPatientRows(res.items || []);
document.getElementById('patient_status').textContent = (res.count || 0) + ' patient(s) found';
})
.catch(function (e) {
document.getElementById('patient_status').textContent = e.message || 'Search failed';
});
}
function renderDocs(items) {
docs = items || [];
var tbody = document.querySelector('#doc_table tbody');
tbody.innerHTML = '';
if (!docs.length) {
tbody.innerHTML = '<tr><td colspan="4" class="text-muted text-center">No ABDM documents mapped for selected patient yet.</td></tr>';
return;
}
docs.forEach(function (doc, idx) {
var tr = document.createElement('tr');
tr.style.cursor = 'pointer';
tr.setAttribute('data-doc-idx', idx);
tr.innerHTML = '' +
'<td>' + esc(parseDate(doc.document_date || doc.created_at)) + '</td>' +
'<td>' + esc(doc.document_title || '-') + '</td>' +
'<td>' + esc(doc.care_context_reference || '-') + '</td>' +
'<td>' + esc(doc.practitioner_name || '-') + '</td>';
tbody.appendChild(tr);
});
tbody.querySelectorAll('tr[data-doc-idx]').forEach(function (row) {
row.addEventListener('click', function () {
var idx = parseInt(row.getAttribute('data-doc-idx'), 10);
if (!Number.isNaN(idx) && docs[idx]) {
loadDocumentDetail(docs[idx].id);
}
});
});
loadDocumentDetail(docs[0].id);
}
function loadDocuments() {
if (!selectedPatient) {
return;
}
var q = document.getElementById('doc_search').value.trim();
var url = docsListUrl +
'?patient_id=' + encodeURIComponent(selectedPatient.patient_id || 0) +
'&abha_address=' + encodeURIComponent(selectedPatient.abha_address || '') +
'&q=' + encodeURIComponent(q);
fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest' } })
.then(function (r) { return r.json(); })
.then(function (res) {
if ((res.ok || 0) !== 1) {
return;
}
renderDocs(res.items || []);
});
}
function renderList(label, arr, itemFormatter) {
arr = Array.isArray(arr) ? arr : [];
if (!arr.length) {
return '<div class="mb-2"><div class="fw-semibold">' + esc(label) + '</div><div class="text-muted">No data</div></div>';
}
var html = '<div class="mb-2"><div class="fw-semibold">' + esc(label) + '</div><ul class="mb-0">';
arr.forEach(function (item) {
html += '<li>' + itemFormatter(item) + '</li>';
});
html += '</ul></div>';
return html;
}
function loadDocumentDetail(id) {
var box = document.getElementById('doc_detail_box');
box.innerHTML = '<div class="text-muted">Loading...</div>';
fetch(docDetailBase + '/' + encodeURIComponent(id), { headers: { 'X-Requested-With': 'XMLHttpRequest' } })
.then(function (r) { return r.json(); })
.then(function (res) {
if ((res.ok || 0) !== 1) {
box.innerHTML = '<div class="text-danger">Unable to load detail.</div>';
return;
}
var item = res.item || {};
var s = item.summary || {};
var html = '' +
'<div class="row g-2 mb-3">' +
'<div class="col-md-6"><div><span class="text-muted">Patient:</span> <strong>' + esc(s.patient_name || item.patient_name || '-') + '</strong></div></div>' +
'<div class="col-md-6"><div><span class="text-muted">ABHA:</span> ' + esc(item.abha_address || '-') + '</div></div>' +
'<div class="col-md-6"><div><span class="text-muted">Document:</span> ' + esc(item.document_title || '-') + '</div></div>' +
'<div class="col-md-6"><div><span class="text-muted">Date:</span> ' + esc(parseDate(item.document_date || '')) + '</div></div>' +
'<div class="col-md-6"><div><span class="text-muted">Doctor:</span> ' + esc(item.practitioner_name || '-') + '</div></div>' +
'<div class="col-md-6"><div><span class="text-muted">Organization:</span> ' + esc(item.organization_name || '-') + '</div></div>' +
'<div class="col-md-6"><div><span class="text-muted">Care Context:</span> ' + esc(item.care_context_reference || '-') + '</div></div>' +
'<div class="col-md-6"><div><span class="text-muted">Bundle:</span> ' + esc(item.bundle_type || '-') + '</div></div>' +
'</div>';
html += renderList('Diagnoses', s.conditions || [], function (x) {
return esc((x && x.text) || '-');
});
html += renderList('Vitals', s.vitals || [], function (x) {
return esc(((x && x.name) || '-') + ': ' + ((x && x.value) || '-'));
});
html += renderList('Medications', s.medications || [], function (x) {
var name = (x && x.name) || '-';
var dose = (x && x.dose) || '';
return esc(name + (dose ? (' | ' + dose) : ''));
});
box.innerHTML = html;
})
.catch(function (e) {
box.innerHTML = '<div class="text-danger">' + esc(e.message || 'Unable to load detail') + '</div>';
});
}
document.getElementById('btnPatientSearch').addEventListener('click', searchPatients);
document.getElementById('btnDocSearch').addEventListener('click', loadDocuments);
})();
</script>

View File

@ -8,7 +8,10 @@
<span id="poll_summary_badge" class="badge bg-secondary">Poll: waiting</span>
<button class="btn btn-sm btn-outline-warning" id="btnPollErrors" type="button">Show Poll Errors</button>
</div>
<button class="btn btn-sm btn-outline-primary" id="btnRefreshTimeline">Refresh Timeline</button>
<div class="d-flex gap-2">
<button class="btn btn-sm btn-outline-success" id="btnOpenDocumentsAjax" type="button">View Fetched Documents</button>
<button class="btn btn-sm btn-outline-primary" id="btnRefreshTimeline">Refresh Timeline</button>
</div>
</div>
<div class="card-body">
<div id="poll_error_panel" class="alert alert-warning d-none mb-3">
@ -88,8 +91,7 @@
<span class="badge bg-secondary" data-step="1">1. Select Patient</span>
<span class="badge bg-secondary" data-step="2">2. Create Consent Request</span>
<span class="badge bg-secondary" data-step="3">3. Check Consent Status</span>
<span class="badge bg-secondary" data-step="4">4. Access Consent Artifact</span>
<span class="badge bg-secondary" data-step="5">5. Request Health Information</span>
<span class="badge bg-secondary" data-step="4">4. Poll &amp; Fetch Decrypted Data</span>
</div>
</div>
</div>
@ -113,8 +115,7 @@
<div class="d-flex gap-2 flex-wrap">
<button class="btn btn-primary" id="btnConsentRequest" data-op="consent_request" disabled>Create Consent Request</button>
<button class="btn btn-outline-primary" id="btnConsentStatus" data-op="consent_status" disabled>Check Consent Status</button>
<button class="btn btn-outline-primary" id="btnConsentFetch" data-op="consent_fetch" disabled>Fetch Consent Artifact</button>
<button class="btn btn-success" id="btnHiRequest" data-op="hi_request" disabled>Request Health Information</button>
<button class="btn btn-outline-success" id="btnConsentFetch" data-op="data_fetch" disabled>Poll &amp; Fetch Decrypted Data</button>
</div>
<div class="d-flex gap-3 flex-wrap mt-2">
@ -124,12 +125,38 @@
</div>
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" id="toggleAutoNextStep">
<label class="form-check-label small" for="toggleAutoNextStep">Auto Next Step (Status -> Fetch -> HI)</label>
<label class="form-check-label small" for="toggleAutoNextStep">Auto Next Step (Status -> Data Fetch)</label>
</div>
</div>
<hr>
<pre id="result_box" class="bg-dark text-light p-3 rounded" style="min-height:120px">{}</pre>
<div class="card border-success mt-3 d-none" id="data_preview_card">
<div class="card-header py-2 d-flex justify-content-between align-items-center">
<div class="fw-semibold">Decrypted Data Preview</div>
<div id="data_preview_meta" class="small text-muted">No data sessions yet.</div>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm table-striped mb-0" id="data_preview_table">
<thead>
<tr>
<th>Txn ID</th>
<th>Consent Ref</th>
<th>Status</th>
<th>Records</th>
<th>Care Contexts</th>
<th>Bundle Type</th>
</tr>
</thead>
<tbody>
<tr><td colspan="6" class="text-muted text-center">Run Poll &amp; Fetch Decrypted Data to load records.</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
@ -177,6 +204,85 @@
</div>
</div>
</div>
<div class="col-12 d-none" id="ajax_docs_panel">
<div class="card shadow-sm border-success">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0">Fetched ABDM Documents (AJAX View)</h6>
<button class="btn btn-sm btn-outline-secondary" id="btnCloseAjaxDocs" type="button">Close</button>
</div>
<div class="card-body">
<div class="row g-2 align-items-end">
<div class="col-md-4">
<label class="form-label mb-1">Search Patient</label>
<input id="ajax_doc_patient_search" class="form-control" placeholder="Name / UHID / ABHA / Mobile">
</div>
<div class="col-md-2">
<button id="btnAjaxDocPatientSearch" class="btn btn-outline-secondary w-100" type="button">Search</button>
</div>
<div class="col-md-4">
<label class="form-label mb-1">Search Documents</label>
<input id="ajax_doc_search" class="form-control" placeholder="title / care context / doctor">
</div>
<div class="col-md-2">
<button id="btnAjaxDocSearch" class="btn btn-outline-primary w-100" type="button">Load Documents</button>
</div>
<div class="col-12">
<div class="small text-muted" id="ajax_doc_patient_status">Select a patient to view mapped ABDM documents.</div>
</div>
</div>
<div class="table-responsive mt-2">
<table class="table table-sm table-hover mb-0" id="ajax_doc_patient_table">
<thead>
<tr>
<th>Action</th>
<th>Patient</th>
<th>UHID</th>
<th>ABHA Address</th>
</tr>
</thead>
<tbody>
<tr><td colspan="4" class="text-muted text-center">Search patient to start.</td></tr>
</tbody>
</table>
</div>
<div class="row g-3 mt-1">
<div class="col-lg-5">
<div class="card h-100">
<div class="card-header py-2">Document List</div>
<div class="card-body p-0">
<div class="table-responsive" style="max-height: 420px;">
<table class="table table-sm table-striped mb-0" id="ajax_doc_table">
<thead>
<tr>
<th>Date</th>
<th>Title</th>
<th>Care Context</th>
<th>Doctor</th>
</tr>
</thead>
<tbody>
<tr><td colspan="4" class="text-muted text-center">No documents loaded.</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="col-lg-7">
<div class="card h-100">
<div class="card-header py-2">Document Detail</div>
<div class="card-body" id="ajax_doc_detail_box">
<div class="text-muted">Select a document to view human-readable summary.</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@ -219,13 +325,15 @@
var opRoute = {
consent_request: '<?= base_url('AbdmHiu/consent_request') ?>',
consent_status: '<?= base_url('AbdmHiu/consent_reconcile') ?>',
consent_fetch: '<?= base_url('AbdmHiu/consent_request_fetch') ?>',
hi_request: '<?= base_url('AbdmHiu/health_information_request') ?>',
data_fetch: '<?= base_url('AbdmHiu/data_fetch') ?>'
};
var pollSummaryUrl = '<?= base_url('AbdmHiu/poll_summary') ?>';
var patientLookupUrl = '<?= base_url('AbdmHiu/patient_lookup') ?>';
var docListUrl = '<?= base_url('AbdmHiu/documents_list') ?>';
var docDetailBaseUrl = '<?= base_url('AbdmHiu/document_detail') ?>';
var ajaxDocSelectedPatient = null;
var ajaxDocItems = [];
function escHtml(v) {
return (v || '').toString()
@ -275,7 +383,7 @@
var state = (consentStateText || '').trim();
var lockedCreate = hasExistingConsentFlowContext();
var policyNote = lockedCreate
? '<span class="badge bg-warning text-dark ms-1">Policy: reuse logged consent flow (Status -> Fetch -> HI)</span>'
? '<span class="badge bg-warning text-dark ms-1">Policy: reuse logged consent flow (Status -> Data Fetch)</span>'
: '<span class="badge bg-light text-dark ms-1">Policy: create consent only once per active flow</span>';
var cooldownNote = isAutoCoolingDown()
? '<span class="badge bg-danger ms-1">Auto-next paused: ' + escHtml(autoCooldownReason || 'temporary cooldown') + '</span>'
@ -310,13 +418,13 @@
function isValidConsentArtifactId(v) {
var val = (v || '').toString().trim();
return val !== '' && !isGatewayRequestId(val);
return val !== '';
}
function hasExistingConsentFlowContext() {
var consentRequestRef = getFieldValue(['consent_request_id', 'consent_id']);
var consentArtifactRef = getFieldValue(['consent_artifact_id', 'consent_id']);
return (consentRequestRef !== '' && !isGatewayRequestId(consentRequestRef)) || isValidConsentArtifactId(consentArtifactRef);
return consentRequestRef !== '' || isValidConsentArtifactId(consentArtifactRef);
}
function canCreateConsentRequest(consentStateText) {
@ -437,7 +545,7 @@
transactionId: transactionId,
abhaAddress: abhaAddress,
hfrId: pickFirstNonEmpty([row && row.hfr_id, req.hfr_id]),
consentRequestId: isGatewayRequestId(consentRequestId) ? '' : consentRequestId,
consentRequestId: consentRequestId,
consentArtifactId: isValidConsentArtifactId(consentArtifactIdRaw) ? consentArtifactIdRaw : '',
operation: (row && row.operation ? row.operation : '').toString().toLowerCase(),
workflowState: (row && row.workflow_state ? row.workflow_state : '').toString().toLowerCase(),
@ -463,14 +571,14 @@
if (wf === 'REQUESTED') {
return 'REQUESTED';
}
if (wf === 'CONSENT_FETCHED') {
return 'FETCHED';
if (wf === 'CONSENT_FETCHED' || wf === 'DATA_RECEIVED') {
return 'DATA_RECEIVED';
}
if (wf === 'STATUS_CHECKED') {
return 'STATUS_CHECKED';
}
if (wf === 'DATA_REQUESTED' || wf === 'DATA_RECEIVED') {
return 'FETCHED';
return 'DATA_RECEIVED';
}
if ((ctx.status || '').toLowerCase() === 'failed') {
return 'FAILED';
@ -486,7 +594,7 @@
cls = 'bg-success';
} else if (['REQUESTED', 'PENDING', 'STATUS_CHECKED'].indexOf(s) !== -1) {
cls = 'bg-warning text-dark';
} else if (['FETCHED', 'CONSENT_FETCHED'].indexOf(s) !== -1) {
} else if (['FETCHED', 'CONSENT_FETCHED', 'DATA_RECEIVED'].indexOf(s) !== -1) {
cls = 'bg-info text-dark';
} else if (['FAILED', 'DENIED', 'REVOKED', 'EXPIRED'].indexOf(s) !== -1) {
cls = 'bg-danger';
@ -567,10 +675,10 @@
return { op: 'consent_status', label: 'Check Consent Status', cls: 'btn-outline-primary' };
}
if (status === 'GRANTED' || status === 'STATUS_CHECKED' || state === 'GRANTED' || state === 'STATUS_CHECKED') {
return { op: 'consent_fetch', label: 'Access Consent Artifact', cls: 'btn-outline-primary' };
return { op: 'data_fetch', label: 'Poll & Fetch Decrypted Data', cls: 'btn-outline-success' };
}
if (hasArtifact && (state === 'CONSENT_FETCHED' || status === 'FETCHED')) {
return { op: 'hi_request', label: 'Request Health Information', cls: 'btn-outline-success' };
if (hasArtifact && (state === 'CONSENT_FETCHED' || state === 'DATA_RECEIVED' || status === 'FETCHED' || status === 'DATA_RECEIVED')) {
return { op: 'data_fetch', label: 'Poll & Fetch Decrypted Data', cls: 'btn-outline-success' };
}
if (hasArtifact) {
@ -659,19 +767,18 @@
var hasArtifact = !!(latest && latest.consent_id);
var selectedAbha = (selectedPatient.abha_address || '').toString().trim();
var hasHiRequest = (selectedItems || []).some(function (row) {
var hasDataFetch = (selectedItems || []).some(function (row) {
var ctx = extractWorkflowContext(row);
var op = (row.operation || '').toString().toLowerCase();
var status = (row.status || '').toString().toLowerCase();
return ctx.abhaAddress === selectedAbha && op === 'hi_request' && status === 'success';
return ctx.abhaAddress === selectedAbha && op === 'data_fetch' && status === 'success';
});
var done = {
1: true,
2: hasConsentRequest,
3: hasStatusChecked,
4: hasArtifact,
5: hasHiRequest
4: hasDataFetch
};
itemsHost.querySelectorAll('span[data-step]').forEach(function (el) {
@ -725,8 +832,7 @@
actions.push('<button type="button" class="btn btn-sm ' + ((nextAction && nextAction.op === 'consent_status') ? 'btn-primary' : 'btn-outline-primary') + '" data-row-action="consent_status" data-row-idx="' + rowIdx + '">Check Status</button>');
}
if (canFetch) {
actions.push('<button type="button" class="btn btn-sm ' + ((nextAction && nextAction.op === 'consent_fetch') ? 'btn-primary' : 'btn-outline-primary') + '" data-row-action="consent_fetch" data-row-idx="' + rowIdx + '">Fetch Artifact</button>');
actions.push('<button type="button" class="btn btn-sm ' + ((nextAction && nextAction.op === 'hi_request') ? 'btn-primary' : 'btn-outline-success') + '" data-row-action="hi_request" data-row-idx="' + rowIdx + '">Request HI</button>');
actions.push('<button type="button" class="btn btn-sm ' + ((nextAction && nextAction.op === 'data_fetch') ? 'btn-primary' : 'btn-outline-success') + '" data-row-action="data_fetch" data-row-idx="' + rowIdx + '">Fetch Decrypted Data</button>');
}
var tr = document.createElement('tr');
@ -770,8 +876,8 @@
if (ctx.lastError.indexOf('consent record not found') !== -1 && hasStatusRef) {
return { op: 'consent_status', label: 'Check Consent Status' };
}
if (ctx.operation === 'hi_request' && hasArtifact) {
return { op: 'hi_request', label: 'Retry Request HI' };
if (ctx.operation === 'data_fetch' && hasArtifact) {
return { op: 'data_fetch', label: 'Retry Data Fetch' };
}
}
@ -783,20 +889,16 @@
if (ctx.workflowState === 'requested' && hasStatusRef) {
return { op: 'consent_status', label: 'Check Consent Status' };
}
if ((ctx.workflowState === 'granted' || ctx.workflowState === 'approved' || ctx.workflowState === 'status_checked') && hasArtifact) {
return { op: 'consent_fetch', label: 'Fetch Consent Artifact' };
if ((ctx.workflowState === 'granted' || ctx.workflowState === 'approved' || ctx.workflowState === 'status_checked') && hasStatusRef) {
return { op: 'data_fetch', label: 'Fetch Decrypted Data' };
}
if (hasStatusRef) {
return { op: 'consent_status', label: 'Check Consent Status' };
}
}
if (ctx.operation === 'consent_fetch' && hasArtifact) {
return { op: 'hi_request', label: 'Request Health Information' };
}
if (ctx.operation === 'hi_request' && hasArtifact) {
return { op: 'hi_request', label: 'Request Health Information' };
if ((ctx.operation === 'consent_fetch' || ctx.operation === 'data_fetch') && hasArtifact) {
return { op: 'data_fetch', label: 'Fetch Decrypted Data' };
}
return null;
@ -827,7 +929,7 @@
if (status === 'failed') {
if (error.indexOf('consentid is required') !== -1) {
return { level: 'warning', message: 'Consent artifact id missing. Run Check Consent Status until consent is GRANTED, then Fetch Consent Artifact.' };
return { level: 'warning', message: 'Consent id missing. Run Check Consent Status until consent is GRANTED, then fetch decrypted data.' };
}
if (error.indexOf('consent record not found') !== -1) {
return { level: 'warning', message: 'Consent not found on gateway. Verify request_id/consentRequestId and run Check Consent Status again.' };
@ -846,17 +948,14 @@
return { level: 'info', message: 'Consent still REQUESTED. Wait for patient approval in ABDM app, then Check Consent Status again.' };
}
if ((state === 'granted' || state === 'approved' || state === 'status_checked') && hasArtifact) {
return { level: 'success', message: 'Consent seems ready. Next step: ' + (nextAction ? nextAction.label : 'Fetch Consent Artifact') + ', then Request Health Information.' };
return { level: 'success', message: 'Consent seems ready. Next step: ' + (nextAction ? nextAction.label : 'Fetch Decrypted Data') + '.' };
}
if (state === 'granted' || state === 'approved' || state === 'status_checked') {
return { level: 'info', message: 'Consent state is ready, but artifact id is missing in this row. Use Latest Log Context and retry Fetch.' };
return { level: 'info', message: 'Consent state is ready. Use Latest Log Context and run data fetch with consent id.' };
}
}
if (operation === 'consent_fetch') {
return { level: 'success', message: 'Consent artifact fetched. Next step: Request Health Information.' };
}
if (operation === 'hi_request') {
return { level: 'success', message: 'Health information request sent. Next step: monitor timeline/poll summary for data updates.' };
if (operation === 'consent_fetch' || operation === 'data_fetch') {
return { level: 'success', message: 'Data polling executed. Monitor timeline/poll summary for decrypted records.' };
}
return { level: 'muted', message: 'Use this row context and continue with the next HIU operation.' };
@ -964,14 +1063,13 @@
var hasPatient = !!selectedPatient;
var consentRequestRef = getFieldValue(['consent_request_id', 'consent_id']);
var consentArtifactRef = getFieldValue(['consent_artifact_id', 'consent_id']);
var hasConsentRequestRef = (consentRequestRef !== '' && !isGatewayRequestId(consentRequestRef));
var hasConsentArtifactRef = (consentArtifactRef !== '' && !isGatewayRequestId(consentArtifactRef));
var hasConsentRequestRef = (consentRequestRef !== '');
var hasConsentArtifactRef = (consentArtifactRef !== '');
var canFetchByState = isConsentApproved(consentStateText) || isTerminalConsentState(consentStateText);
document.getElementById('btnConsentRequest').disabled = !canCreateConsentRequest(consentStateText);
document.getElementById('btnConsentStatus').disabled = !hasConsentRequestRef;
document.getElementById('btnConsentFetch').disabled = !(hasConsentArtifactRef && isSuccessConsentState(consentStateText) && canFetchByState);
document.getElementById('btnHiRequest').disabled = !(hasConsentArtifactRef && isConsentApproved(consentStateText));
document.getElementById('btnConsentFetch').disabled = !((hasConsentArtifactRef || hasConsentRequestRef) && isSuccessConsentState(consentStateText) && canFetchByState);
updateReadinessBox(consentStateText);
}
@ -986,7 +1084,7 @@
var latest = p.latest_consent || {};
var consentRequestRef = (latest.abdm_consent_request_id || '').toString();
var consentArtifactRef = (latest.abdm_consent_artifact_id || latest.consent_id || '').toString();
if (consentRequestRef && !isGatewayRequestId(consentRequestRef)) {
if (consentRequestRef) {
setFieldValue(['consent_request_id', 'consent_id'], consentRequestRef);
}
if (isValidConsentArtifactId(consentArtifactRef)) {
@ -1088,6 +1186,43 @@
return /^[A-Za-z0-9._-]+@[A-Za-z0-9.-]+$/.test((v || '').toString().trim());
}
function normalizeConsentHiTypes(hiTypes) {
var arr = Array.isArray(hiTypes) ? hiTypes : (hiTypes ? [hiTypes] : []);
var map = {
opconsultrecord: 'OPConsultation',
opconsultation: 'OPConsultation',
diagnosticreportrecord: 'DiagnosticReport',
diagnosticreport: 'DiagnosticReport',
prescriptionrecord: 'Prescription',
prescription: 'Prescription',
dischargesummaryrecord: 'DischargeSummary',
dischargesummary: 'DischargeSummary',
invoice: 'Invoice',
invoicerecord: 'Invoice',
healthdocumentrecord: 'HealthDocument',
healthdocument: 'HealthDocument',
immunization: 'ImmunizationRecord',
immunizationrecord: 'ImmunizationRecord',
wellness: 'Wellness',
wellnessrecord: 'Wellness'
};
var out = [];
arr.forEach(function (item) {
var raw = (item || '').toString().trim();
if (!raw) {
return;
}
var key = raw.toLowerCase();
var resolved = map[key] || raw;
if (out.indexOf(resolved) === -1) {
out.push(resolved);
}
});
return out;
}
function buildGatewayPayload(op, raw) {
var requestId = (raw.requestId || raw.request_id || '').toString().trim();
var timestamp = (raw.timestamp || '').toString().trim();
@ -1141,6 +1276,11 @@
throw new Error('Patient ABHA address must be in format like 91510165305101@sbx.');
}
consent.hiTypes = normalizeConsentHiTypes(consent.hiTypes || []);
if (!consent.hiTypes.length) {
throw new Error('consent.hiTypes is required for consent request.');
}
base.consent = consent;
return base;
}
@ -1157,73 +1297,27 @@
if (requestIdRef) {
base.request_id = requestIdRef;
}
if (consentRequestId && !isGatewayRequestId(consentRequestId)) {
if (consentRequestId) {
base.consent_request_id = consentRequestId;
}
if (consentIdRef && !isGatewayRequestId(consentIdRef)) {
if (consentIdRef) {
base.consent_id = consentIdRef;
}
return base;
}
if (op === 'consent_fetch') {
var consentId = (raw.abdm_consent_artifact_id || raw.consentId || '').toString().trim();
if (op === 'data_fetch') {
var consentIdFetch = (raw.abdm_consent_artifact_id || raw.consentId || raw.consent_id || '').toString().trim();
var consentRequestRefFetch = (raw.abdm_consent_request_id || raw.consentRequestId || raw.consent_request_id || '').toString().trim();
// Gateway accepts original consent request id in consent_id for NAT polling.
var requestIdRefFetch = (raw.request_id || raw.requestId || '').toString().trim();
var consentRequestIdFetch = (raw.abdm_consent_request_id || raw.consentRequestId || '').toString().trim();
if (consentId && isGatewayRequestId(consentId)) {
throw new Error('mapping_error: consentId looks like gateway/local request_id (REQ-...). Use ABDM consent artifact id only.');
var consentRef = consentIdFetch || consentRequestRefFetch || requestIdRefFetch;
if (!consentRef) {
throw new Error('consent_id is required for decrypted data fetch. Run status polling until consent is GRANTED.');
}
if (consentId) {
base.consentId = consentId;
}
if (requestIdRefFetch) {
base.request_id = requestIdRefFetch;
}
if (consentRequestIdFetch && !isGatewayRequestId(consentRequestIdFetch)) {
base.consent_request_id = consentRequestIdFetch;
}
if (!base.consentId && !base.request_id && !base.consent_request_id) {
throw new Error('No fetch context found. Select a consent row first, then click Access Consent Artifact.');
}
return base;
}
if (op === 'hi_request') {
var hiRequest = raw.hiRequest;
if (!hiRequest || typeof hiRequest !== 'object' || Array.isArray(hiRequest)) {
hiRequest = {
consent: {
id: (raw.consentId || raw.consent_id || '').toString().trim()
},
dateRange: {
from: new Date(Date.now() - (365 * 24 * 60 * 60 * 1000)).toISOString(),
to: new Date().toISOString()
},
dataPushUrl: 'https://abdm-bridge.e-atria.in/api/v3/hiu/data/push',
transactionId: (raw.transactionId || raw.transaction_id || '').toString().trim() || genReq('TXN-HIU')
};
}
if (!hiRequest.consent || typeof hiRequest.consent !== 'object') {
hiRequest.consent = {};
}
if (!hiRequest.consent.id) {
hiRequest.consent.id = (raw.consentId || raw.consent_id || '').toString().trim();
}
if (!(hiRequest.consent.id || '').toString().trim()) {
throw new Error('hiRequest.consent.id is required for health information request.');
}
if (!hiRequest.transactionId) {
hiRequest.transactionId = (raw.transactionId || raw.transaction_id || '').toString().trim() || genReq('TXN-HIU');
}
setFieldValue(['transaction_id'], hiRequest.transactionId);
base.hiRequest = hiRequest;
base.transactionId = hiRequest.transactionId;
base.consent_id = consentRef;
return base;
}
@ -1231,9 +1325,92 @@
}
function setResult(obj) {
renderDataPreview(obj);
document.getElementById('result_box').textContent = JSON.stringify(obj, null, 2);
}
function parseMaybeJson(raw) {
if (raw && typeof raw === 'object') {
return raw;
}
var txt = (raw || '').toString().trim();
if (!txt) {
return null;
}
try {
return JSON.parse(txt);
} catch (e) {
return null;
}
}
function extractSessionsFromResponse(res) {
var data = (res && typeof res === 'object') ? (res.data || {}) : {};
var sessions = [];
if (Array.isArray(data.sessions)) {
sessions = data.sessions;
} else if (Array.isArray(res.sessions)) {
sessions = res.sessions;
}
return sessions;
}
function summarizeBundleType(decryptedItem) {
var parsed = parseMaybeJson((decryptedItem || {}).decrypted_data);
if (!parsed || typeof parsed !== 'object') {
return '-';
}
var bundleType = (parsed.type || '').toString().trim();
var resourceType = (parsed.resourceType || '').toString().trim();
if (resourceType && bundleType) {
return resourceType + ' / ' + bundleType;
}
return resourceType || bundleType || '-';
}
function renderDataPreview(res) {
var card = document.getElementById('data_preview_card');
var meta = document.getElementById('data_preview_meta');
var tbody = document.querySelector('#data_preview_table tbody');
if (!card || !meta || !tbody) {
return;
}
var sessions = extractSessionsFromResponse(res);
if (!sessions.length) {
card.classList.add('d-none');
return;
}
card.classList.remove('d-none');
var totalRecords = 0;
sessions.forEach(function (s) {
var items = Array.isArray(s.decrypted_data) ? s.decrypted_data : [];
totalRecords += items.length;
});
meta.textContent = 'Sessions: ' + sessions.length + ' | Records: ' + totalRecords;
tbody.innerHTML = '';
sessions.forEach(function (session) {
var records = Array.isArray(session.decrypted_data) ? session.decrypted_data : [];
var contexts = records.map(function (r) {
return (r.careContextReference || '').toString().trim();
}).filter(function (v) { return v !== ''; });
var tr = document.createElement('tr');
tr.innerHTML = '' +
'<td>' + escHtml((session.transaction_id || '-').toString()) + '</td>' +
'<td>' + escHtml((session.consent_id || '-').toString()) + '</td>' +
'<td>' + escHtml((session.status || '-').toString()) + '</td>' +
'<td>' + records.length + '</td>' +
'<td>' + escHtml((contexts.slice(0, 3).join(', ') || '-').toString()) + '</td>' +
'<td>' + escHtml(summarizeBundleType(records[0] || {})) + '</td>';
tbody.appendChild(tr);
});
}
function postJson(url, body) {
return fetch(url, {
method: 'POST',
@ -1247,6 +1424,238 @@
});
}
function parseDisplayDate(v) {
var txt = (v || '').toString().trim();
if (!txt) {
return '-';
}
var d = new Date(txt.replace(' ', 'T'));
if (isNaN(d.getTime())) {
return txt;
}
return d.toLocaleString();
}
function openAjaxDocsPanel() {
var panel = document.getElementById('ajax_docs_panel');
if (!panel) {
return;
}
panel.classList.remove('d-none');
panel.scrollIntoView({ behavior: 'smooth', block: 'start' });
if (!ajaxDocSelectedPatient) {
ajaxDocsSearchPatients();
}
}
function closeAjaxDocsPanel() {
var panel = document.getElementById('ajax_docs_panel');
if (!panel) {
return;
}
panel.classList.add('d-none');
}
function ajaxDocsRenderPatients(items) {
var tbody = document.querySelector('#ajax_doc_patient_table tbody');
if (!tbody) {
return;
}
tbody.innerHTML = '';
if (!items.length) {
tbody.innerHTML = '<tr><td colspan="4" class="text-muted text-center">No patients found.</td></tr>';
return;
}
items.forEach(function (p, idx) {
var tr = document.createElement('tr');
tr.innerHTML = '' +
'<td><button class="btn btn-sm btn-outline-primary" data-ajax-doc-patient-idx="' + idx + '">Select</button></td>' +
'<td>' + escHtml(p.patient_name || '') + '</td>' +
'<td>' + escHtml(p.patient_code || '') + '</td>' +
'<td>' + escHtml(p.abha_address || '') + '</td>';
tbody.appendChild(tr);
});
tbody.querySelectorAll('button[data-ajax-doc-patient-idx]').forEach(function (btn) {
btn.addEventListener('click', function () {
var idx = parseInt(btn.getAttribute('data-ajax-doc-patient-idx'), 10);
if (Number.isNaN(idx) || !items[idx]) {
return;
}
ajaxDocSelectedPatient = items[idx];
var status = document.getElementById('ajax_doc_patient_status');
if (status) {
status.textContent = 'Selected: ' + (ajaxDocSelectedPatient.patient_name || '-') + ' | ABHA: ' + (ajaxDocSelectedPatient.abha_address || '-');
}
ajaxDocsLoadDocuments();
});
});
}
function ajaxDocsSearchPatients() {
var q = getFieldValue(['ajax_doc_patient_search']);
fetch(patientLookupUrl + '?q=' + encodeURIComponent(q), {
headers: { 'X-Requested-With': 'XMLHttpRequest' }
})
.then(function (r) { return r.json(); })
.then(function (res) {
if ((res.ok || 0) !== 1) {
var status = document.getElementById('ajax_doc_patient_status');
if (status) {
status.textContent = res.error || 'Patient search failed';
}
return;
}
ajaxDocsRenderPatients(Array.isArray(res.items) ? res.items : []);
var s = document.getElementById('ajax_doc_patient_status');
if (s) {
s.textContent = (res.count || 0) + ' patient(s) found';
}
})
.catch(function (e) {
var status = document.getElementById('ajax_doc_patient_status');
if (status) {
status.textContent = e.message || 'Patient search failed';
}
});
}
function ajaxDocsRenderList(items) {
ajaxDocItems = items || [];
var tbody = document.querySelector('#ajax_doc_table tbody');
if (!tbody) {
return;
}
tbody.innerHTML = '';
if (!ajaxDocItems.length) {
tbody.innerHTML = '<tr><td colspan="4" class="text-muted text-center">No documents loaded.</td></tr>';
var box = document.getElementById('ajax_doc_detail_box');
if (box) {
box.innerHTML = '<div class="text-muted">No documents found for selected patient.</div>';
}
return;
}
ajaxDocItems.forEach(function (doc, idx) {
var tr = document.createElement('tr');
tr.style.cursor = 'pointer';
tr.setAttribute('data-ajax-doc-idx', idx);
tr.innerHTML = '' +
'<td>' + escHtml(parseDisplayDate(doc.document_date || doc.created_at)) + '</td>' +
'<td>' + escHtml(doc.document_title || '-') + '</td>' +
'<td>' + escHtml(doc.care_context_reference || '-') + '</td>' +
'<td>' + escHtml(doc.practitioner_name || '-') + '</td>';
tbody.appendChild(tr);
});
tbody.querySelectorAll('tr[data-ajax-doc-idx]').forEach(function (row) {
row.addEventListener('click', function () {
var idx = parseInt(row.getAttribute('data-ajax-doc-idx'), 10);
if (Number.isNaN(idx) || !ajaxDocItems[idx]) {
return;
}
ajaxDocsLoadDetail((ajaxDocItems[idx].id || 0));
});
});
ajaxDocsLoadDetail((ajaxDocItems[0].id || 0));
}
function ajaxDocsLoadDocuments() {
if (!ajaxDocSelectedPatient) {
setResult({ ok: 0, message: 'Select a patient first in fetched documents panel.' });
return;
}
var q = getFieldValue(['ajax_doc_search']);
var url = docListUrl
+ '?patient_id=' + encodeURIComponent(ajaxDocSelectedPatient.patient_id || 0)
+ '&abha_address=' + encodeURIComponent(ajaxDocSelectedPatient.abha_address || '')
+ '&q=' + encodeURIComponent(q);
fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest' } })
.then(function (r) { return r.json(); })
.then(function (res) {
if ((res.ok || 0) !== 1) {
ajaxDocsRenderList([]);
return;
}
ajaxDocsRenderList(Array.isArray(res.items) ? res.items : []);
})
.catch(function () {
ajaxDocsRenderList([]);
});
}
function ajaxDocsRenderBulletList(title, rows, formatter) {
var list = Array.isArray(rows) ? rows : [];
if (!list.length) {
return '<div class="mb-2"><div class="fw-semibold">' + escHtml(title) + '</div><div class="text-muted">No data</div></div>';
}
var html = '<div class="mb-2"><div class="fw-semibold">' + escHtml(title) + '</div><ul class="mb-0">';
list.forEach(function (row) {
html += '<li>' + formatter(row) + '</li>';
});
html += '</ul></div>';
return html;
}
function ajaxDocsLoadDetail(id) {
var box = document.getElementById('ajax_doc_detail_box');
if (!box) {
return;
}
if (!id) {
box.innerHTML = '<div class="text-muted">Select a document to view detail.</div>';
return;
}
box.innerHTML = '<div class="text-muted">Loading...</div>';
fetch(docDetailBaseUrl + '/' + encodeURIComponent(id), {
headers: { 'X-Requested-With': 'XMLHttpRequest' }
})
.then(function (r) { return r.json(); })
.then(function (res) {
if ((res.ok || 0) !== 1) {
box.innerHTML = '<div class="text-danger">Unable to load detail.</div>';
return;
}
var item = res.item || {};
var s = item.summary || {};
var html = ''
+ '<div class="row g-2 mb-3">'
+ '<div class="col-md-6"><span class="text-muted">Patient:</span> <strong>' + escHtml(s.patient_name || item.patient_name || '-') + '</strong></div>'
+ '<div class="col-md-6"><span class="text-muted">ABHA:</span> ' + escHtml(item.abha_address || '-') + '</div>'
+ '<div class="col-md-6"><span class="text-muted">Document:</span> ' + escHtml(item.document_title || '-') + '</div>'
+ '<div class="col-md-6"><span class="text-muted">Date:</span> ' + escHtml(parseDisplayDate(item.document_date || '')) + '</div>'
+ '<div class="col-md-6"><span class="text-muted">Doctor:</span> ' + escHtml(item.practitioner_name || '-') + '</div>'
+ '<div class="col-md-6"><span class="text-muted">Organization:</span> ' + escHtml(item.organization_name || '-') + '</div>'
+ '<div class="col-md-6"><span class="text-muted">Care Context:</span> ' + escHtml(item.care_context_reference || '-') + '</div>'
+ '<div class="col-md-6"><span class="text-muted">Bundle:</span> ' + escHtml(item.bundle_type || '-') + '</div>'
+ '</div>';
html += ajaxDocsRenderBulletList('Diagnoses', s.conditions || [], function (row) {
return escHtml((row && row.text) || '-');
});
html += ajaxDocsRenderBulletList('Vitals', s.vitals || [], function (row) {
return escHtml(((row && row.name) || '-') + ': ' + ((row && row.value) || '-'));
});
html += ajaxDocsRenderBulletList('Medications', s.medications || [], function (row) {
var name = (row && row.name) || '-';
var dose = (row && row.dose) || '';
return escHtml(name + (dose ? (' | ' + dose) : ''));
});
box.innerHTML = html;
})
.catch(function (e) {
box.innerHTML = '<div class="text-danger">' + escHtml(e.message || 'Unable to load detail') + '</div>';
});
}
function run(op, options) {
options = options || {};
var url = opRoute[op];
@ -1278,7 +1687,7 @@
if ((res.ok || 0) === 1) {
var data = res.data || {};
var consentReqRef = (data.abdm_consent_request_id || data.consentRequestId || data.consent_request_id || '').toString();
if (consentReqRef && !isGatewayRequestId(consentReqRef)) {
if (consentReqRef) {
setFieldValue(['consent_request_id', 'consent_id'], consentReqRef);
}
@ -1294,37 +1703,32 @@
startConsentPolling();
}
// Ordered workflow automation: status success -> fetch artifact, then fetch success -> request HI.
if (op === 'consent_status' && isSuccessConsentState(state) && isValidConsentArtifactId(consentArtifactRef)) {
// Ordered workflow automation: status success -> data fetch polling.
if (op === 'consent_status' && isSuccessConsentState(state) && (isValidConsentArtifactId(consentArtifactRef) || consentReqRef)) {
if (isAutoNextStepEnabled() && !isAutoCoolingDown()) {
run('consent_fetch', { silent: true });
run('data_fetch', { silent: true });
}
}
if (op === 'consent_fetch' && isConsentApproved(state) && isValidConsentArtifactId(consentArtifactRef)) {
if (op === 'data_fetch' && isConsentApproved(state) && isValidConsentArtifactId(consentArtifactRef)) {
// Always stop status polling once artifact fetch succeeds.
stopConsentPolling();
if (isAutoNextStepEnabled() && !isAutoCoolingDown()) {
run('hi_request', { silent: true });
}
}
if (op === 'hi_request') {
clearAutoCooldown();
}
if ((op === 'consent_status' || op === 'consent_fetch') && isTerminalConsentState(state)) {
if ((op === 'consent_status' || op === 'data_fetch') && isTerminalConsentState(state)) {
stopConsentPolling();
}
} else if (op === 'consent_status' || op === 'consent_fetch') {
} else if (op === 'consent_status' || op === 'data_fetch') {
var errState = resolveConsentStateFromResponse(res, res.data || {});
var errText = (res.error || ((res.data || {}).error_text) || '').toString().toLowerCase();
if (isTerminalConsentState(errState) || errText.indexOf('mapping_error') !== -1) {
stopConsentPolling();
}
} else if (op === 'hi_request') {
var hiErrText = (res.error || ((res.data || {}).error_text) || '').toString().toLowerCase();
var hiCode = parseInt(((res.data || {}).http_code || res.http_code || 0), 10);
if (hiCode === 403 || hiErrText.indexOf('cloudfront') !== -1 || hiErrText.indexOf('request blocked') !== -1) {
setAutoCooldown(180000, 'HI request blocked (403). Retry after cooldown or after gateway unblocks endpoint.');
syncButtons();
if (op === 'data_fetch') {
var dataCode = parseInt(((res.data || {}).http_code || res.http_code || 0), 10);
if (dataCode === 403 || errText.indexOf('cloudfront') !== -1 || errText.indexOf('request blocked') !== -1) {
setAutoCooldown(180000, 'Data fetch blocked (403). Retry after cooldown or after gateway unblocks endpoint.');
syncButtons();
}
}
}
@ -1531,6 +1935,40 @@
if (btnRefreshTimeline) {
btnRefreshTimeline.addEventListener('click', fetchTimeline);
}
var btnOpenDocumentsAjax = document.getElementById('btnOpenDocumentsAjax');
if (btnOpenDocumentsAjax) {
btnOpenDocumentsAjax.addEventListener('click', openAjaxDocsPanel);
}
var btnCloseAjaxDocs = document.getElementById('btnCloseAjaxDocs');
if (btnCloseAjaxDocs) {
btnCloseAjaxDocs.addEventListener('click', closeAjaxDocsPanel);
}
var btnAjaxDocPatientSearch = document.getElementById('btnAjaxDocPatientSearch');
if (btnAjaxDocPatientSearch) {
btnAjaxDocPatientSearch.addEventListener('click', ajaxDocsSearchPatients);
}
var btnAjaxDocSearch = document.getElementById('btnAjaxDocSearch');
if (btnAjaxDocSearch) {
btnAjaxDocSearch.addEventListener('click', ajaxDocsLoadDocuments);
}
var ajaxDocPatientSearch = document.getElementById('ajax_doc_patient_search');
if (ajaxDocPatientSearch) {
ajaxDocPatientSearch.addEventListener('keydown', function (e) {
if (e.key === 'Enter') {
e.preventDefault();
ajaxDocsSearchPatients();
}
});
}
var ajaxDocSearch = document.getElementById('ajax_doc_search');
if (ajaxDocSearch) {
ajaxDocSearch.addEventListener('keydown', function (e) {
if (e.key === 'Enter') {
e.preventDefault();
ajaxDocsLoadDocuments();
}
});
}
var btnUseLatestLog = document.getElementById('btnUseLatestLog');
if (btnUseLatestLog) {
btnUseLatestLog.addEventListener('click', function () {

View File

@ -6,6 +6,18 @@ $backTitle = trim((string) ($backTitle ?? 'Profile'));
if ($backUrl === '') {
$backUrl = base_url('billing/patient/person_record') . '/' . (int) ($patient->id ?? 0) . '/0';
}
$patientAbhaId = trim((string) ($patient->abha_id ?? $patient->abha_no ?? $patient->abha ?? ''));
$patientAbhaAddress = trim((string) ($patient->abha_address ?? ''));
if ($patientAbhaAddress === '' && $patientAbhaId !== '' && strpos($patientAbhaId, '@') !== false) {
$patientAbhaAddress = $patientAbhaId;
}
if ($patientAbhaAddress === '' && preg_match('/abha_address\s*:\s*([A-Za-z0-9._-]+@[A-Za-z0-9.-]+)/i', (string) ($patient->log ?? ''), $abhaLogMatch) === 1) {
$patientAbhaAddress = trim((string) ($abhaLogMatch[1] ?? ''));
}
$abhaVerifiedStatus = strtoupper(trim((string) ($patient->abha_verified_status ?? '')));
$abhaKycVerified = (int) ($patient->abha_kyc_verified ?? 0) === 1;
$abhaMobileVerified = (int) ($patient->abha_mobile_verified ?? 0) === 1;
$abhaIsVerified = $abhaVerifiedStatus === 'VERIFIED' || ($abhaKycVerified && $abhaMobileVerified);
?>
<div class="pagetitle">
@ -30,6 +42,18 @@ if ($backUrl === '') {
<div class="card-body">
<p class="mb-3">Patient: <strong><?= esc($patient->p_fname ?? '') ?></strong></p>
<ul class="nav nav-tabs nav-tabs-bordered" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#opd-history-tab" type="button" role="tab">OPD History</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#opd-abdm-tab" type="button" role="tab">ABDM Fetched Data</button>
</li>
</ul>
<div class="tab-content pt-2">
<div class="tab-pane fade show active" id="opd-history-tab" role="tabpanel">
<style>
.opd-history-file-card {
border: 1px solid #dee2e6;
@ -79,6 +103,28 @@ if ($backUrl === '') {
.opd-scan-modal-img.is-dragging {
cursor: grabbing;
}
.abdm-doc-detail-list {
max-height: 45vh;
overflow: auto;
}
.abdm-flow-step {
font-size: 12px;
border: 1px solid #ced4da;
border-radius: 999px;
padding: 2px 8px;
color: #6c757d;
background: #f8f9fa;
}
.abdm-flow-step.is-active {
color: #0d6efd;
border-color: #0d6efd;
background: #e7f1ff;
}
.abdm-flow-step.is-done {
color: #198754;
border-color: #198754;
background: #e8f7ee;
}
</style>
<?php if (empty($opdGroups)) { ?>
@ -174,6 +220,75 @@ if ($backUrl === '') {
</div>
<?php } ?>
<?php } ?>
</div>
<div class="tab-pane fade" id="opd-abdm-tab" role="tabpanel">
<div class="alert alert-light border d-flex justify-content-between align-items-center flex-wrap gap-2 mt-2">
<div>
<div class="fw-semibold">ABHA Context</div>
<div class="small text-muted">
ABHA ID: <strong><?= $patientAbhaId !== '' ? esc($patientAbhaId) : 'Not available' ?></strong>
&nbsp;|&nbsp;
ABHA Address: <strong><?= $patientAbhaAddress !== '' ? esc($patientAbhaAddress) : 'Not available' ?></strong>
&nbsp;|&nbsp;
Status:
<?php if ($abhaIsVerified) { ?>
<span class="badge bg-success-subtle text-success border border-success-subtle">VERIFIED</span>
<?php } else { ?>
<span class="badge bg-warning-subtle text-warning border border-warning-subtle"><?= $abhaVerifiedStatus !== '' ? esc($abhaVerifiedStatus) : 'UNVERIFIED' ?></span>
<?php } ?>
</div>
</div>
<div class="d-flex gap-2 flex-wrap">
<button type="button" class="btn btn-outline-primary btn-sm" id="btnLoadAbdmDocs">Refresh Fetched Data</button>
<button type="button" class="btn btn-primary btn-sm" id="btnAutoAbdmFlow" <?= ($abhaIsVerified && $patientAbhaAddress !== '') ? '' : 'disabled' ?>>Start ABDM Sync</button>
</div>
</div>
<div id="abdmStatusBox" class="small text-muted mb-2">Click "Refresh Fetched Data" to load ABDM records mapped to this patient.</div>
<div class="border rounded p-2 mb-3 bg-light">
<div class="d-flex flex-wrap align-items-center gap-2 mb-2" id="abdmFlowSteps">
<span class="abdm-flow-step" data-step="1">1. Requested</span>
<span class="abdm-flow-step" data-step="2">2. Granted</span>
<span class="abdm-flow-step" data-step="3">3. Fetched</span>
</div>
<div class="progress" style="height: 8px;">
<div id="abdmFlowProgressBar" class="progress-bar" role="progressbar" style="width:0%"></div>
</div>
</div>
<div class="row g-3">
<div class="col-lg-5">
<div class="input-group input-group-sm mb-2">
<input id="abdmDocSearch" class="form-control" placeholder="Search title, care context, doctor">
<button class="btn btn-outline-secondary" type="button" id="btnSearchAbdmDocs">Search</button>
</div>
<div class="table-responsive border rounded">
<table class="table table-sm table-hover mb-0" id="abdmDocTable">
<thead>
<tr>
<th>Date</th>
<th>Title</th>
<th>Care Context</th>
</tr>
</thead>
<tbody>
<tr><td colspan="3" class="text-muted text-center">No records loaded.</td></tr>
</tbody>
</table>
</div>
</div>
<div class="col-lg-7">
<div class="border rounded p-3 bg-light" id="abdmDocDetailBox">
<div class="text-muted">Select a fetched document to view details.</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@ -199,6 +314,16 @@ if ($backUrl === '') {
<script>
$(function() {
var patientId = <?= (int) ($patient->id ?? 0) ?>;
var abdmDocumentsUrl = '<?= base_url('billing/patient/abdm_documents/' . (int) ($patient->id ?? 0)) ?>';
var abdmAutoFlowUrl = '<?= base_url('billing/patient/abdm_content_auto_flow/' . (int) ($patient->id ?? 0)) ?>';
var abdmDocDetailBaseUrl = '<?= base_url('billing/patient/abdm_document_detail/' . (int) ($patient->id ?? 0)) ?>';
var currentAbdmRows = [];
var autoFlowTimer = null;
var autoFlowRequestId = '';
var autoFlowAttempts = 0;
var autoFlowMaxAttempts = 15;
var zoom = 1;
var minZoom = 0.5;
var maxZoom = 3;
@ -295,5 +420,257 @@ $(function() {
isDragging = false;
$('#opdScanModalImg').removeClass('is-dragging');
});
function escHtml(val) {
return $('<div>').text(val == null ? '' : String(val)).html();
}
function fmtDateTime(val) {
var text = (val || '').toString().trim();
if (!text) {
return '-';
}
return text.replace('T', ' ');
}
function renderAbdmRows(rows) {
var html = '';
if (!rows || !rows.length) {
html = '<tr><td colspan="3" class="text-muted text-center">No ABDM fetched records found.</td></tr>';
$('#abdmDocTable tbody').html(html);
$('#abdmDocDetailBox').html('<div class="text-muted">No records available for this patient.</div>');
return;
}
rows.forEach(function(row) {
html += '<tr class="abdm-doc-row" data-id="' + escHtml(row.id) + '">' +
'<td>' + escHtml(fmtDateTime(row.document_date || row.created_at)) + '</td>' +
'<td>' + escHtml(row.document_title || '-') + '</td>' +
'<td>' + escHtml(row.care_context_reference || '-') + '</td>' +
'</tr>';
});
$('#abdmDocTable tbody').html(html);
}
function renderAbdmDetail(item) {
if (!item) {
$('#abdmDocDetailBox').html('<div class="text-muted">Document details unavailable.</div>');
return;
}
var summary = item.summary || {};
var conditions = Array.isArray(summary.conditions) ? summary.conditions : [];
var vitals = Array.isArray(summary.vitals) ? summary.vitals : [];
var meds = Array.isArray(summary.medications) ? summary.medications : [];
var html = '';
html += '<div class="fw-semibold mb-2">' + escHtml(item.document_title || 'ABDM Document') + '</div>';
html += '<div class="small text-muted mb-2">Date: ' + escHtml(fmtDateTime(item.document_date || item.created_at)) + '</div>';
html += '<div class="small mb-2">Care Context: <strong>' + escHtml(item.care_context_reference || '-') + '</strong></div>';
html += '<div class="small mb-2">Doctor: <strong>' + escHtml(item.practitioner_name || '-') + '</strong></div>';
html += '<div class="small mb-3">Organization: <strong>' + escHtml(item.organization_name || '-') + '</strong></div>';
html += '<div class="row g-2">';
html += '<div class="col-md-4"><div class="border rounded p-2 h-100"><div class="fw-semibold small mb-1">Diagnoses</div><ul class="mb-0 small abdm-doc-detail-list">';
if (conditions.length) {
conditions.forEach(function(c) {
html += '<li>' + escHtml(c.text || '-') + '</li>';
});
} else {
html += '<li class="text-muted">No diagnosis entries</li>';
}
html += '</ul></div></div>';
html += '<div class="col-md-4"><div class="border rounded p-2 h-100"><div class="fw-semibold small mb-1">Vitals</div><ul class="mb-0 small abdm-doc-detail-list">';
if (vitals.length) {
vitals.forEach(function(v) {
html += '<li>' + escHtml((v.name || '-') + ': ' + (v.value || '-')) + '</li>';
});
} else {
html += '<li class="text-muted">No vitals</li>';
}
html += '</ul></div></div>';
html += '<div class="col-md-4"><div class="border rounded p-2 h-100"><div class="fw-semibold small mb-1">Medications</div><ul class="mb-0 small abdm-doc-detail-list">';
if (meds.length) {
meds.forEach(function(m) {
html += '<li>' + escHtml((m.name || '-') + (m.dose ? (' | ' + m.dose) : '')) + '</li>';
});
} else {
html += '<li class="text-muted">No medications</li>';
}
html += '</ul></div></div>';
html += '</div>';
$('#abdmDocDetailBox').html(html);
}
function loadAbdmDocs() {
var q = ($('#abdmDocSearch').val() || '').toString().trim();
var url = abdmDocumentsUrl + '?limit=200';
if (q !== '') {
url += '&q=' + encodeURIComponent(q);
}
$('#abdmStatusBox').removeClass('text-danger').addClass('text-muted').text('Loading ABDM records...');
fetch(url, { credentials: 'same-origin' })
.then(function(resp) { return resp.json(); })
.then(function(data) {
if (!data || data.ok !== 1) {
throw new Error((data && data.error) || 'Unable to load ABDM records.');
}
currentAbdmRows = Array.isArray(data.items) ? data.items : [];
renderAbdmRows(currentAbdmRows);
$('#abdmStatusBox').removeClass('text-danger').addClass('text-muted').text('Loaded ' + currentAbdmRows.length + ' fetched ABDM record(s).');
})
.catch(function(err) {
$('#abdmStatusBox').removeClass('text-muted').addClass('text-danger').text('ABDM load failed: ' + (err.message || err));
});
}
function setFlowProgress(step) {
var current = Number(step || 0);
if (current < 0) {
current = 0;
}
if (current > 3) {
current = 3;
}
var width = 0;
if (current === 1) {
width = 34;
} else if (current === 2) {
width = 67;
} else if (current >= 3) {
width = 100;
}
$('#abdmFlowProgressBar').css('width', width + '%');
$('#abdmFlowSteps .abdm-flow-step').each(function() {
var $el = $(this);
var stepNo = Number($el.data('step') || 0);
$el.removeClass('is-active is-done');
if (stepNo < current) {
$el.addClass('is-done');
} else if (stepNo === current && current > 0) {
$el.addClass('is-active');
}
});
}
function stopAutoFlowLoop() {
if (autoFlowTimer) {
clearTimeout(autoFlowTimer);
autoFlowTimer = null;
}
}
function runAutoFlowStep() {
var url = abdmAutoFlowUrl;
if (autoFlowRequestId) {
url += '?request_id=' + encodeURIComponent(autoFlowRequestId);
}
fetch(url, { credentials: 'same-origin' })
.then(function(resp) { return resp.json(); })
.then(function(data) {
if (!data || data.ok !== 1) {
throw new Error((data && data.error) || 'Auto flow failed.');
}
autoFlowRequestId = (data.request_id || autoFlowRequestId || '').toString();
var phase = (data.phase || '').toString();
var msg = (data.message || '').toString();
var info = msg !== '' ? msg : ('Phase: ' + (phase || 'UNKNOWN'));
if (phase === 'COMPLETED') {
setFlowProgress(3);
} else if (phase === 'GRANTED') {
setFlowProgress(2);
} else if (phase === 'REQUESTED' || phase === 'PENDING') {
setFlowProgress(1);
}
$('#abdmStatusBox').removeClass('text-danger').addClass('text-muted').text(info + (autoFlowRequestId ? (' | Request ID: ' + autoFlowRequestId) : ''));
var shouldPoll = Number(data.poll_again || 0) === 1;
if (shouldPoll && autoFlowAttempts < autoFlowMaxAttempts) {
autoFlowAttempts += 1;
autoFlowTimer = setTimeout(runAutoFlowStep, 8000);
return;
}
$('#btnAutoAbdmFlow').prop('disabled', false).text('One-Click Sync');
if (phase === 'COMPLETED') {
loadAbdmDocs();
}
})
.catch(function(err) {
stopAutoFlowLoop();
$('#btnAutoAbdmFlow').prop('disabled', false).text('One-Click Sync');
$('#abdmStatusBox').removeClass('text-muted').addClass('text-danger').text('Auto flow failed: ' + (err.message || err));
});
}
function loadAbdmDocDetail(docId) {
if (!docId) {
return;
}
fetch(abdmDocDetailBaseUrl + '/' + encodeURIComponent(docId), { credentials: 'same-origin' })
.then(function(resp) { return resp.json(); })
.then(function(data) {
if (!data || data.ok !== 1) {
throw new Error((data && data.error) || 'Unable to load document detail.');
}
renderAbdmDetail(data.item || null);
})
.catch(function(err) {
$('#abdmDocDetailBox').html('<div class="text-danger">' + escHtml('Detail load failed: ' + (err.message || err)) + '</div>');
});
}
$(document).off('click.abdmOpd', '#btnLoadAbdmDocs').on('click.abdmOpd', '#btnLoadAbdmDocs', function() {
loadAbdmDocs();
});
$(document).off('click.abdmOpd', '#btnSearchAbdmDocs').on('click.abdmOpd', '#btnSearchAbdmDocs', function() {
loadAbdmDocs();
});
$(document).off('keypress.abdmOpd', '#abdmDocSearch').on('keypress.abdmOpd', '#abdmDocSearch', function(e) {
if (e.which === 13) {
e.preventDefault();
loadAbdmDocs();
}
});
$(document).off('click.abdmOpd', '#abdmDocTable .abdm-doc-row').on('click.abdmOpd', '#abdmDocTable .abdm-doc-row', function() {
$('#abdmDocTable .abdm-doc-row').removeClass('table-active');
$(this).addClass('table-active');
var docId = $(this).data('id');
loadAbdmDocDetail(docId);
});
$(document).off('click.abdmOpd', '#btnAutoAbdmFlow').on('click.abdmOpd', '#btnAutoAbdmFlow', function() {
var $btn = $(this);
if ($btn.prop('disabled')) {
return;
}
stopAutoFlowLoop();
autoFlowRequestId = '';
autoFlowAttempts = 0;
setFlowProgress(1);
$btn.prop('disabled', true).text('Sync Running...');
$('#abdmStatusBox').removeClass('text-danger').addClass('text-muted').text('Starting one-click sync flow...');
runAutoFlowStep();
});
$(document).off('shown.bs.tab.abdmOpd', '[data-bs-target="#opd-abdm-tab"]').on('shown.bs.tab.abdmOpd', '[data-bs-target="#opd-abdm-tab"]', function() {
if (!currentAbdmRows.length) {
loadAbdmDocs();
}
});
});
</script>