fix(abdm): enforce granted hi_types and dateRange, check live consent status before fetch, safe json parsing

This commit is contained in:
DevSoftTech Server 2026-10-16 13:38:53 +05:30
parent 7f6ae881e7
commit e41e6bd96f
3 changed files with 362 additions and 72 deletions

View File

@ -5296,6 +5296,91 @@ class AbdmGateway extends BaseController
$db = \Config\Database::connect();
$records = [];
// Parse granted HI types and date range filters (from ABDM consent)
$rawHiTypes = $body['hiTypes'] ?? $body['hi_types'] ?? null;
if (is_string($rawHiTypes)) {
$rawHiTypes = [$rawHiTypes];
}
$grantedHiTypes = null;
$normMap = [
'opconsultrecord' => 'OPConsultation',
'opconsultation' => 'OPConsultation',
'diagnosticreportrecord' => 'DiagnosticReport',
'diagnosticreport' => 'DiagnosticReport',
'prescriptionrecord' => 'Prescription',
'prescription' => 'Prescription',
'dischargesummaryrecord' => 'DischargeSummary',
'dischargesummary' => 'DischargeSummary',
'healthdocumentrecord' => 'HealthDocumentRecord',
'healthdocument' => 'HealthDocumentRecord',
'immunizationrecord' => 'ImmunizationRecord',
'immunization' => 'ImmunizationRecord',
'wellnessrecord' => 'WellnessRecord',
'wellness' => 'WellnessRecord',
'invoicerecord' => 'Invoice',
'invoice' => 'Invoice',
];
if (is_array($rawHiTypes) && ! empty($rawHiTypes)) {
$grantedHiTypes = [];
foreach ($rawHiTypes as $ht) {
$k = strtolower(trim((string) $ht));
$grantedHiTypes[] = $normMap[$k] ?? trim((string) $ht);
}
}
$dateRange = $body['dateRange'] ?? $body['date_range'] ?? null;
$dateFrom = null;
$dateTo = null;
if (is_array($dateRange)) {
if (! empty($dateRange['from'])) {
$dateFrom = substr(trim((string) $dateRange['from']), 0, 10);
}
if (! empty($dateRange['to'])) {
$dateTo = substr(trim((string) $dateRange['to']), 0, 10);
}
}
$isTypeAllowed = static function (string $type) use ($grantedHiTypes, $normMap): bool {
if ($grantedHiTypes === null || empty($grantedHiTypes)) {
return true;
}
$std = $normMap[strtolower(trim($type))] ?? trim($type);
return in_array($std, $grantedHiTypes, true);
};
$isDateInRange = static function (?string $dateStr) use ($dateFrom, $dateTo): bool {
if ($dateStr === null || trim($dateStr) === '') {
return true;
}
$d = substr(trim($dateStr), 0, 10);
if ($dateFrom !== null && $d < $dateFrom) {
return false;
}
if ($dateTo !== null && $d > $dateTo) {
return false;
}
return true;
};
$extractBundleDate = static function (array $bundle): ?string {
if (! empty($bundle['timestamp'])) {
return substr((string) $bundle['timestamp'], 0, 10);
}
foreach ($bundle['entry'] ?? [] as $e) {
$res = $e['resource'] ?? [];
if (! empty($res['date'])) {
return substr((string) $res['date'], 0, 10);
}
if (! empty($res['effectiveDateTime'])) {
return substr((string) $res['effectiveDateTime'], 0, 10);
}
if (! empty($res['authoredOn'])) {
return substr((string) $res['authoredOn'], 0, 10);
}
}
return null;
};
foreach ($careContextRefs as $ref) {
$ref = trim((string) $ref);
if ($ref === '') {
@ -5304,9 +5389,16 @@ class AbdmGateway extends BaseController
// Strategy 0a: Live On-Demand Fresh Generation for LAB / Diagnostic Report (LAB-xxxx or RAD-xxxx)
if (preg_match('/^(?:LAB|RAD)-/i', $ref)) {
if (! $isTypeAllowed('DiagnosticReport')) {
continue;
}
try {
$diag = $this->buildDiagnosticReportBundle($ref);
if ($diag !== null && !empty($diag['bundle'])) {
if ($diag !== null && ! empty($diag['bundle'])) {
$bDate = $extractBundleDate($diag['bundle']);
if (! $isDateInRange($bDate)) {
continue;
}
$records[] = [
'careContextReference' => $ref,
'hiType' => $diag['hiType'] ?? 'DiagnosticReportRecord',
@ -5323,9 +5415,16 @@ class AbdmGateway extends BaseController
// Strategy 0b: Live On-Demand Fresh Generation for Discharge Summary (DISCHARGE-xxxx or IPD-xxxx)
if (preg_match('/^(?:DISCHARGE|IPD)-/i', $ref)) {
if (! $isTypeAllowed('DischargeSummary')) {
continue;
}
try {
$discharge = $this->buildDischargeSummaryBundle($ref);
if ($discharge !== null && !empty($discharge['bundle'])) {
if ($discharge !== null && ! empty($discharge['bundle'])) {
$bDate = $extractBundleDate($discharge['bundle']);
if (! $isDateInRange($bDate)) {
continue;
}
$records[] = [
'careContextReference' => $ref,
'hiType' => $discharge['hiType'] ?? 'DischargeSummaryRecord',
@ -5342,9 +5441,19 @@ class AbdmGateway extends BaseController
// Strategy 0c: Live On-Demand Fresh Generation for OPD Consult & Prescription Records
if (preg_match('/^(OPD|PRESCRIPTION|PRESC|RX)-(\d+)(?:-S(\d+))?/i', $ref, $m)) {
$refPrefix = strtoupper($m[1]);
$isPresc = in_array($refPrefix, ['PRESC', 'PRESCRIPTION', 'RX'], true);
$reqType = $isPresc ? 'Prescription' : 'OPConsultation';
if (! $isTypeAllowed($reqType)) {
continue;
}
try {
$opd = $this->buildOpConsultationBundle($ref);
if ($opd !== null && !empty($opd['bundle'])) {
if ($opd !== null && ! empty($opd['bundle'])) {
$bDate = $extractBundleDate($opd['bundle']);
if (! $isDateInRange($bDate)) {
continue;
}
$records[] = [
'careContextReference' => $ref,
'hiType' => $opd['hiType'] ?? 'OPConsultRecord',
@ -5361,6 +5470,9 @@ class AbdmGateway extends BaseController
// Strategy 0b-reg: Live On-Demand Fresh Generation for Patient Registration / File (REG-xxxx)
if (preg_match('/^REG-(.+)$/i', $ref, $regM)) {
if (! $isTypeAllowed('HealthDocumentRecord')) {
continue;
}
$pRef = trim($regM[1]);
$patientRow = null;
if ($db->tableExists('patient_master')) {
@ -5378,12 +5490,16 @@ class AbdmGateway extends BaseController
$abhaId = $this->resolvePatientAbhaIdentifier($patId);
$patientResource = $this->buildAbdmPatientResource($patientRow, $patId, $abhaId);
$docTitle = 'Patient File - ' . $this->patientDisplayName($patientRow);
$regDate = $patientRow['created_at'] ?? $patientRow['insert_date'] ?? date('Y-m-d');
if (! $isDateInRange($regDate)) {
continue;
}
$html = '<div xmlns="http://www.w3.org/1999/xhtml"><h3>' . esc($docTitle) . '</h3>'
. '<p><strong>Patient Name:</strong> ' . esc($this->patientDisplayName($patientRow)) . '</p>'
. '<p><strong>Patient Code:</strong> ' . esc($patientRow['p_code'] ?? '') . '</p>'
. '<p><strong>Gender:</strong> ' . esc($patientRow['gender'] ?? '') . '</p>'
. '<p><strong>Age:</strong> ' . esc($patientRow['age'] ?? '') . '</p>'
. '<p><strong>Registration Date:</strong> ' . esc($patientRow['created_at'] ?? $patientRow['insert_date'] ?? date('Y-m-d')) . '</p>'
. '<p><strong>Registration Date:</strong> ' . esc($regDate) . '</p>'
. '</div>';
$bundle = $this->buildSimpleHealthDocumentBundle($patientResource, $docTitle, 'text/html', base64_encode($html));
$records[] = [
@ -5399,6 +5515,9 @@ class AbdmGateway extends BaseController
// Strategy 0b: Live On-Demand Fresh Generation for Invoice Record
if (preg_match('/^INVOICE-(CHG|OPD|IPD)-(\d+)/i', $ref, $invM)) {
if (! $isTypeAllowed('Invoice')) {
continue;
}
$invSource = match(strtoupper($invM[1])) {
'CHG' => 'charges_invoice',
'OPD' => 'opd_invoice',
@ -5407,7 +5526,11 @@ class AbdmGateway extends BaseController
$invId = (int) $invM[2];
try {
$invPayload = $this->buildInvoiceSourceRecordPayload($invSource, $invId, 0, '');
if ($invPayload !== null && !empty($invPayload['bundle'])) {
if ($invPayload !== null && ! empty($invPayload['bundle'])) {
$bDate = $extractBundleDate($invPayload['bundle']);
if (! $isDateInRange($bDate)) {
continue;
}
$records[] = [
'careContextReference' => $ref,
'hiType' => 'InvoiceRecord',
@ -5423,6 +5546,9 @@ class AbdmGateway extends BaseController
// Strategy 0c: Live Retrieval & On-Demand Fresh Generation for Wellness Record
if (str_starts_with(strtoupper($ref), 'WELLNESS-')) {
if (! $isTypeAllowed('WellnessRecord')) {
continue;
}
$foundBundle = null;
$foundDisplay = null;
@ -5518,6 +5644,10 @@ class AbdmGateway extends BaseController
}
if ($foundBundle !== null && ! empty($foundBundle)) {
$bDate = $extractBundleDate($foundBundle);
if (! $isDateInRange($bDate)) {
continue;
}
$records[] = [
'careContextReference' => $ref,
'hiType' => 'WellnessRecord',
@ -5530,10 +5660,17 @@ class AbdmGateway extends BaseController
// Strategy 0d: Live On-Demand Fresh Generation for Health Document Record (DOC-file-{id}, DOC-{id})
if (preg_match('/^DOC-(?:file-)?(\d+)/i', $ref, $docM)) {
if (! $isTypeAllowed('HealthDocumentRecord')) {
continue;
}
$docTargetId = (int) $docM[1];
try {
$docPayload = $this->buildHealthDocumentRecordPayload(0, '', $docTargetId);
if ($docPayload !== null && !empty($docPayload['bundle'])) {
if ($docPayload !== null && ! empty($docPayload['bundle'])) {
$bDate = $extractBundleDate($docPayload['bundle']);
if (! $isDateInRange($bDate)) {
continue;
}
$records[] = [
'careContextReference' => $ref,
'hiType' => 'HealthDocumentRecord',
@ -5563,7 +5700,7 @@ class AbdmGateway extends BaseController
'push_status' => 'linked',
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
]);
}
}
continue;
@ -5575,11 +5712,18 @@ class AbdmGateway extends BaseController
// Strategy 0e: Live On-Demand Fresh Generation for Immunization Record (IMM-{id})
if (preg_match('/^IMM-(?:PAT-(\d+)|(?:(\d+)-V)?(\d+))/i', $ref, $immM)) {
if (! $isTypeAllowed('ImmunizationRecord')) {
continue;
}
$immTargetId = (int) (! empty($immM[3]) ? $immM[3] : (! empty($immM[2]) ? $immM[2] : ($immM[1] ?? 0)));
$immPatientId = (int) (! empty($immM[1]) ? $immM[1] : (! empty($immM[2]) ? $immM[2] : 0));
try {
$immPayload = $this->buildImmunizationGatewayPayload($immPatientId, $immTargetId, '', false);
if ($immPayload !== null && ! empty($immPayload['bundle'])) {
$bDate = $extractBundleDate($immPayload['bundle']);
if (! $isDateInRange($bDate)) {
continue;
}
$vaccineName = 'Vaccine';
if (! empty($immPayload['records'][0]['vaccine_name'])) {
$vaccineName = trim((string) $immPayload['records'][0]['vaccine_name']);
@ -5645,7 +5789,7 @@ class AbdmGateway extends BaseController
$hrRow = $hrBuilder->get(1)->getRowArray();
}
if ($hrRow && !empty($hrRow['record_data'])) {
if ($hrRow && ! empty($hrRow['record_data'])) {
$bundleData = json_decode((string) $hrRow['record_data'], true);
if (is_array($bundleData)) {
$recHiType = $hrRow['hi_type'] ?? $hrRow['record_type'] ?? 'OPConsultRecord';
@ -5654,6 +5798,13 @@ class AbdmGateway extends BaseController
} elseif (str_starts_with($ref, 'OPD-')) {
$recHiType = 'OPConsultRecord';
}
if (! $isTypeAllowed($recHiType)) {
continue;
}
$bDate = $extractBundleDate($bundleData);
if (! $isDateInRange($bDate)) {
continue;
}
$defaultDisp = match ($recHiType) {
'ImmunizationRecord' => 'Immunization Record',
'DiagnosticReportRecord' => 'Diagnostic Report',
@ -5683,15 +5834,23 @@ class AbdmGateway extends BaseController
}
}
if ($hrRow && !empty($hrRow['fhir_bundle_enc'])) {
if ($hrRow && ! empty($hrRow['fhir_bundle_enc'])) {
try {
$enc = new FhirEncryptionService();
$decrypted = $enc->decrypt((string) $hrRow['fhir_bundle_enc']);
$bundleData = json_decode($decrypted, true);
if (is_array($bundleData)) {
$recHiType = $hrRow['hi_type'] ?? $hrRow['record_type'] ?? 'OPConsultRecord';
if (! $isTypeAllowed($recHiType)) {
continue;
}
$bDate = $extractBundleDate($bundleData);
if (! $isDateInRange($bDate)) {
continue;
}
$records[] = [
'careContextReference' => $ref,
'hiType' => $hrRow['hi_type'] ?? $hrRow['record_type'] ?? 'OPConsultRecord',
'hiType' => $recHiType,
'display' => $hrRow['care_context_display'] ?? 'Consultation Record',
'bundle' => $bundleData,
];
@ -5701,14 +5860,21 @@ class AbdmGateway extends BaseController
}
}
// Strategy B: Build compliant FHIR Document Bundle from patient_master & OPD
// STRICTLY restricted to OPD/consultation care contexts.
// NEVER synthesize dummy consultation records for invoices, discharge, labs, radiology, etc.
if (preg_match('/^(?:OPD|PRESCRIPTION|PRESC|CONSULT)/i', $ref)) {
$isPresc = (bool) preg_match('/^(?:PRESC|PRESCRIPTION)/i', $ref);
$reqType = $isPresc ? 'Prescription' : 'OPConsultation';
if (! $isTypeAllowed($reqType)) {
continue;
}
$bundle = $this->assembleFhirBundleForCareContext($ref);
if ($bundle !== null) {
$isPresc = (bool) preg_match('/^(?:PRESC|PRESCRIPTION)/i', $ref);
$bDate = $extractBundleDate($bundle);
if (! $isDateInRange($bDate)) {
continue;
}
$records[] = [
'careContextReference' => $ref,
'hiType' => $isPresc ? 'PrescriptionRecord' : 'OPConsultRecord',

View File

@ -2017,36 +2017,37 @@ class Patient extends BaseController
// documents from BOTH external and same-entity facilities are returned.
$sessionIds = $this->resolveAllRelatedConsentIds($filterConsentRequestId);
// ABDM M3 HIU compliance: check if this consent request is expired
$wfCheck = $this->db->table('abdm_hiu_workflows')
->select(['id', 'workflow_state', 'request_json', 'response_json', 'expired_at'])
// ABDM M3 HIU compliance: check if this consent request is expired, revoked, or denied
$wfCheckQuery = $this->db->table('abdm_hiu_workflows')
->select(['id', 'workflow_state', 'request_json', 'expired_at'])
->groupStart()
->where('request_id', $filterConsentRequestId)
->orWhere('consent_id', $filterConsentRequestId)
->orWhereIn('abdm_consent_request_id', $sessionIds !== [] ? $sessionIds : [$filterConsentRequestId])
->orWhereIn('abdm_consent_artifact_id', $sessionIds !== [] ? $sessionIds : [$filterConsentRequestId])
->groupEnd()
->orWhereIn('abdm_consent_artifact_id', $sessionIds !== [] ? $sessionIds : [$filterConsentRequestId]);
if (ctype_digit($filterConsentRequestId)) {
$wfCheckQuery->orWhere('id', (int) $filterConsentRequestId);
}
$wfCheck = $wfCheckQuery->groupEnd()
->orderBy('id', 'DESC')
->get(1)
->getRowArray();
$isFilterExpired = false;
if (is_array($wfCheck)) {
if (strtoupper(trim((string) ($wfCheck['workflow_state'] ?? ''))) === 'EXPIRED') {
$isFilterExpired = true;
} else {
$filterEraseAt = $this->extractDataEraseAtFromWorkflowRow($wfCheck);
if ($filterEraseAt !== '') {
$filterTs = strtotime($filterEraseAt);
if ($filterTs !== false && $filterTs <= time()) {
$isFilterExpired = true;
}
$wfState = is_array($wfCheck) ? strtoupper(trim((string) ($wfCheck['workflow_state'] ?? ''))) : '';
$isFilterTerminal = in_array($wfState, ['EXPIRED', 'DENIED', 'REVOKED'], true);
if (! $isFilterTerminal && is_array($wfCheck)) {
$filterEraseAt = $this->extractDataEraseAtFromWorkflowRow($wfCheck);
if ($filterEraseAt !== '') {
$filterTs = strtotime($filterEraseAt);
if ($filterTs !== false && $filterTs <= time()) {
$isFilterTerminal = true;
$wfState = 'EXPIRED';
}
}
}
if ($isFilterExpired) {
// Purge cached records for this expired consent as per ABDM dataEraseAt guidelines
if ($isFilterTerminal) {
// Purge cached records for this expired/revoked/denied consent as per ABDM dataEraseAt guidelines
$purgeRefs = array_unique(array_merge([$filterConsentRequestId], $sessionIds));
$this->purgeAbdmDocumentsByRefs($purgeRefs);
return $this->response->setJSON([
@ -2054,7 +2055,8 @@ class Patient extends BaseController
'count' => 0,
'items' => [],
'expired' => 1,
'error' => 'This consent request has expired. Health records are erased and no longer accessible as per ABDM guidelines.',
'status' => $wfState !== '' ? $wfState : 'EXPIRED',
'error' => 'This consent request is ' . ($wfState !== '' ? $wfState : 'EXPIRED') . '. Health records are erased and no longer accessible as per ABDM guidelines.',
'abha' => $abhaContext,
'last_sync' => $lastSync,
]);
@ -2216,24 +2218,28 @@ class Patient extends BaseController
$docFields = $this->db->getFieldNames('abdm_hiu_documents') ?? [];
$sessionIds = $this->resolveAllRelatedConsentIds($consentRequestId);
// ABDM M3 HIU compliance: check if consent has expired
$wfCheck = $this->db->table('abdm_hiu_workflows')
->select(['id', 'workflow_state', 'request_json', 'response_json', 'expired_at'])
// ABDM M3 HIU compliance: check if consent has expired, been denied, or revoked
$wfCheckQuery = $this->db->table('abdm_hiu_workflows')
->select(['id', 'workflow_state', 'request_json', 'expired_at'])
->groupStart()
->where('request_id', $consentRequestId)
->orWhere('consent_id', $consentRequestId)
->orWhereIn('abdm_consent_request_id', $sessionIds !== [] ? $sessionIds : [$consentRequestId])
->orWhereIn('abdm_consent_artifact_id', $sessionIds !== [] ? $sessionIds : [$consentRequestId])
->groupEnd()
->orWhereIn('abdm_consent_artifact_id', $sessionIds !== [] ? $sessionIds : [$consentRequestId]);
if (ctype_digit($consentRequestId)) {
$wfCheckQuery->orWhere('id', (int) $consentRequestId);
}
$wfCheck = $wfCheckQuery->groupEnd()
->orderBy('id', 'DESC')
->get(1)
->getRowArray();
if (is_array($wfCheck)) {
$eraseAt = $this->extractDataEraseAtFromWorkflowRow($wfCheck);
if (strtoupper(trim((string) ($wfCheck['workflow_state'] ?? ''))) === 'EXPIRED' || ($eraseAt !== '' && strtotime($eraseAt) <= time())) {
$wfState = strtoupper(trim((string) ($wfCheck['workflow_state'] ?? '')));
if (in_array($wfState, ['EXPIRED', 'DENIED', 'REVOKED'], true) || ($eraseAt !== '' && strtotime($eraseAt) <= time())) {
$this->purgeAbdmDocumentsByRefs(array_merge([$consentRequestId], $sessionIds));
return $this->response->setStatusCode(403)->setBody('This consent request has expired. Health records are erased and cannot be viewed or printed as per ABDM guidelines.');
return $this->response->setStatusCode(403)->setBody('This consent request is ' . ($wfState !== '' ? $wfState : 'EXPIRED') . '. Health records are erased and cannot be viewed or printed as per ABDM guidelines.');
}
}
@ -3098,46 +3104,95 @@ class Patient extends BaseController
?? ''
));
$service = new \App\Libraries\Abdm\M3HiuWorkflowService();
if ($overrideConsentId !== '' || $overrideConsentRequestId !== '') {
$consentArtifactRef = $overrideConsentId;
$consentRequestRef = $overrideConsentRequestId;
$lookupTarget = $consentRequestRef !== '' ? $consentRequestRef : $consentArtifactRef;
// Verify whether this specific consent request is expired under ABDM dataEraseAt rules
$checkWf = $this->db->table('abdm_hiu_workflows')
->select(['id', 'workflow_state', 'request_json', 'response_json', 'expired_at'])
// Actively reconcile live status with the bridge before fetching records
$reconcilePayload = [
'abha_address' => $abhaContext['abha_address'],
'request_id' => $lookupTarget,
'abdm_consent_request_id' => $consentRequestRef,
'abdm_consent_artifact_id'=> $consentArtifactRef,
'consent_id' => $consentArtifactRef !== '' ? $consentArtifactRef : $lookupTarget,
];
$liveStatus = '';
try {
$reconcile = $service->runOperation('consent_reconcile', $reconcilePayload);
if (is_array($reconcile) && (int) ($reconcile['ok'] ?? 0) === 1) {
$liveStatus = strtoupper(trim((string) (
$reconcile['workflow_state']
?? $reconcile['status']
?? $reconcile['consent_status']
?? $reconcile['consent']['status']
?? $reconcile['consentDetail']['status']
?? ''
)));
}
} catch (\Throwable $e) {
log_message('warning', '[abdm_content_fetch_only] consent_reconcile pre-fetch check warning: ' . $e->getMessage());
$reconcile = [];
}
// Check local workflow anchor row
$checkWfQuery = $this->db->table('abdm_hiu_workflows')
->select(['id', 'workflow_state', 'request_json', 'expired_at'])
->groupStart()
->where('request_id', $lookupTarget)
->orWhere('consent_id', $lookupTarget)
->orWhere('abdm_consent_request_id', $lookupTarget)
->orWhere('abdm_consent_artifact_id', $lookupTarget)
->groupEnd()
->orWhere('abdm_consent_artifact_id', $lookupTarget);
if (ctype_digit($lookupTarget)) {
$checkWfQuery->orWhere('id', (int) $lookupTarget);
}
$checkWf = $checkWfQuery->groupEnd()
->orderBy('id', 'DESC')
->get(1)
->getRowArray();
$isTargetExpired = false;
$anchorRowId = is_array($checkWf) ? (int) ($checkWf['id'] ?? 0) : 0;
if ($anchorRowId > 0 && ! empty($reconcile) && (int) ($reconcile['ok'] ?? 0) === 1) {
$this->persistConsentPhaseOnRequestRow($anchorRowId, $reconcile);
}
$currentStatus = $liveStatus !== '' ? $liveStatus : strtoupper(trim((string) ($checkWf['workflow_state'] ?? '')));
// Verify whether this specific consent request is expired under ABDM dataEraseAt rules
$isTargetExpired = ($currentStatus === 'EXPIRED');
$targetEraseAt = '';
if (is_array($checkWf)) {
if (strtoupper(trim((string) ($checkWf['workflow_state'] ?? ''))) === 'EXPIRED') {
$isTargetExpired = true;
}
if (! $isTargetExpired && is_array($checkWf)) {
$targetEraseAt = $this->extractDataEraseAtFromWorkflowRow($checkWf);
if ($targetEraseAt !== '') {
$targetTs = strtotime($targetEraseAt);
if ($targetTs !== false && $targetTs <= time()) {
$isTargetExpired = true;
$currentStatus = 'EXPIRED';
}
}
}
if ($isTargetExpired) {
if ($isTargetExpired || in_array($currentStatus, ['EXPIRED', 'DENIED', 'REVOKED'], true)) {
$statusWord = $currentStatus !== '' ? $currentStatus : 'EXPIRED';
$relatedPurgeIds = $this->resolveAllRelatedConsentIds($lookupTarget);
$this->purgeAbdmDocumentsByRefs(array_merge([$lookupTarget], $relatedPurgeIds));
return $this->response->setStatusCode(422)->setJSON([
'ok' => 0,
'phase' => 'EXPIRED',
'error' => 'This consent request expired on ' . ($targetEraseAt ? date('d/m/Y h:i:s A', strtotime($targetEraseAt)) : 'an earlier date') . '. Under ABDM guidelines, records cannot be fetched or accessed under an expired consent. Please create a new consent request.',
'phase' => $statusWord,
'status' => $statusWord,
'error' => "This consent request is {$statusWord}. Under ABDM guidelines, records cannot be fetched or accessed under an {$statusWord} consent. Please create a new consent request.",
]);
}
if (! in_array($currentStatus, ['GRANTED', 'COMPLETED', 'ACTIVE', 'APPROVED'], true)) {
return $this->response->setStatusCode(422)->setJSON([
'ok' => 0,
'phase' => $currentStatus !== '' ? $currentStatus : 'REQUESTED',
'status' => $currentStatus !== '' ? $currentStatus : 'REQUESTED',
'error' => "Consent is not granted yet (current status: {$currentStatus}). Once approved by the patient in their ABHA app, please try again.",
]);
}
@ -3469,7 +3524,35 @@ class Patient extends BaseController
]);
}
return $this->response->setJSON($this->getAbdmConsentRequestsList($abhaAddress));
$rawList = $this->getAbdmConsentRequestsList($abhaAddress);
$latestRequest = $rawList['requests'][0] ?? null;
if ($latestRequest && ! in_array(strtoupper(trim((string) ($latestRequest['status'] ?? ''))), ['EXPIRED', 'DENIED', 'REVOKED'], true)) {
$sessionRequestId = trim((string) ($latestRequest['request_id'] ?? ''));
$sessionConsentRequestId = trim((string) ($latestRequest['consent_request_id'] ?? ''));
$rawConsentId = trim((string) ($latestRequest['consent_id'] ?? ''));
$sessionConsentId = $rawConsentId !== '' ? trim((string) explode(':', $rawConsentId)[0]) : '';
$anchorRowId = (int) ($latestRequest['id'] ?? 0);
if ($sessionRequestId !== '' || $sessionConsentRequestId !== '' || $sessionConsentId !== '') {
try {
$service = new \App\Libraries\Abdm\M3HiuWorkflowService();
$reconcile = $service->runOperation('consent_reconcile', [
'abha_address' => $abhaAddress,
'request_id' => $sessionRequestId,
'abdm_consent_request_id' => $sessionConsentRequestId,
'abdm_consent_artifact_id'=> $sessionConsentId,
'consent_id' => $sessionConsentId,
]);
if ((int) ($reconcile['ok'] ?? 0) === 1 && $anchorRowId > 0) {
$this->persistConsentPhaseOnRequestRow($anchorRowId, $reconcile);
$rawList = $this->getAbdmConsentRequestsList($abhaAddress);
}
} catch (\Throwable $e) {
// Silent fallback to cached list
}
}
}
return $this->response->setJSON($rawList);
}
/**
@ -7003,6 +7086,27 @@ class Patient extends BaseController
$ids = [$ref];
if (ctype_digit($ref) && $this->db->tableExists('abdm_hiu_workflows')) {
$fields = $this->db->getFieldNames('abdm_hiu_workflows') ?? [];
$selCols = ['id'];
foreach (['abdm_consent_request_id', 'abdm_consent_artifact_id', 'consent_id', 'request_id', 'gateway_request_id'] as $sc) {
if (in_array($sc, $fields, true)) {
$selCols[] = $sc;
}
}
$anchorRow = $this->db->table('abdm_hiu_workflows')->select(implode(', ', $selCols))->where('id', (int) $ref)->get(1)->getRowArray();
if (is_array($anchorRow)) {
foreach ($selCols as $col) {
if ($col !== 'id') {
$val = trim((string) ($anchorRow[$col] ?? ''));
if ($val !== '' && ! preg_match('/^REQ-/i', $val)) {
$ids[] = $val;
}
}
}
}
}
if (strpos($ref, ':') !== false) {
$parts = explode(':', $ref);
foreach ($parts as $p) {
@ -7097,8 +7201,12 @@ class Patient extends BaseController
*/
private function extractDataEraseAtFromWorkflowRow(array $row): string
{
if (! empty($row['expired_at'])) {
return (string) $row['expired_at'];
}
$req = json_decode((string) ($row['request_json'] ?? ''), true);
$resp = json_decode((string) ($row['response_json'] ?? ''), true);
$respStr = (string) ($row['response_json'] ?? '');
$resp = (strlen($respStr) > 0 && strlen($respStr) <= 65536) ? json_decode($respStr, true) : null;
$containers = [
is_array($req) ? ($req['consent'] ?? null) : null,
is_array($req) ? ($req['consentDetail'] ?? null) : null,

View File

@ -1153,6 +1153,22 @@ $(function() {
var fetchOnlyRunningIdx = -1;
var currentAbdmFetchModalIdx = -1;
function safeParseJson(resp) {
return resp.text().then(function(text) {
var data;
try {
data = JSON.parse(text);
} catch (e) {
var cleanText = (text || '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
throw new Error('Server returned an unexpected response (HTTP ' + resp.status + '): ' + (cleanText ? cleanText.substring(0, 150) : 'Empty response'));
}
if (!resp.ok && (!data || data.ok !== 1)) {
throw new Error((data && data.error) || ('Server returned HTTP ' + resp.status));
}
return data;
});
}
function consentStatusBadgeClass(status) {
switch ((status || '').toString().toUpperCase()) {
case 'GRANTED': return 'bg-info-subtle text-info border border-info-subtle';
@ -1276,8 +1292,6 @@ $(function() {
$('#btnNewAbdmRequest').prop('disabled', autoFlowRunning || fetchOnlyRunningIdx !== -1);
}
// Renders one request's detail (from already-loaded list data, no extra
// network call) into the shared Consent Details modal.
function showAbdmConsentDetailForIndex(idx) {
var consent = currentAbdmRequests[idx];
$('#abdmConsentDetailBody').html(renderAbdmConsentDetail(consent));
@ -1337,7 +1351,7 @@ $(function() {
$btn.prop('disabled', true).html('<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>Refreshing...');
fetch(abdmConsentRequestsUrl, { credentials: 'same-origin' })
.then(function(resp) { return resp.json(); })
.then(safeParseJson)
.then(function(data) {
if (!data || data.ok !== 1) {
throw new Error((data && data.error) || 'Unable to load consent request history.');
@ -1355,7 +1369,7 @@ $(function() {
}
// Re-pulls data for a specific (already GRANTED/COMPLETED) request row using
// its saved consent reference, without creating a brand-new consent request.
// its saved consent reference, verifying consent status first.
function runFetchRecordsForRow(idx) {
if (autoFlowRunning || fetchOnlyRunningIdx !== -1) {
return;
@ -1374,21 +1388,22 @@ $(function() {
isExpired = true;
}
}
if (isExpired) {
setAbdmStatus('This consent request has expired. Under ABDM guidelines, records cannot be fetched or accessed under an expired consent.', true);
if (isExpired || status === 'DENIED' || status === 'REVOKED') {
setAbdmStatus('This consent request is ' + (status || 'EXPIRED') + '. Under ABDM guidelines, records cannot be fetched or accessed.', true);
loadAbdmConsentRequests();
return;
}
fetchOnlyRunningIdx = idx;
currentAbdmFetchModalIdx = idx;
applyNewRequestButtonState();
setAbdmStatus('Fetching latest records using existing granted consent...', false);
setAbdmStatus('Verifying consent status and fetching latest records...', false);
openAbdmFetchResultModal();
$('#abdmFetchResultModalBody').html('<div class="text-muted small text-center">Fetching latest records...</div>');
$('#abdmFetchResultModalBody').html('<div class="text-muted small text-center p-4"><div class="spinner-border spinner-border-sm me-2 text-primary" role="status"></div>Verifying consent status & fetching records...</div>');
var url = abdmFetchOnlyUrl + '?consent_id=' + encodeURIComponent(consent.consent_id || '') + '&consent_request_id=' + encodeURIComponent(consent.consent_request_id || '');
fetch(url, { credentials: 'same-origin' })
.then(function(resp) { return resp.json(); })
.then(safeParseJson)
.then(function(data) {
if (!data || data.ok !== 1) {
throw new Error((data && data.error) || 'Fetch failed.');
@ -1404,9 +1419,10 @@ $(function() {
fetchOnlyRunningIdx = -1;
applyNewRequestButtonState();
setAbdmStatus('Fetch failed: ' + (err.message || err), true);
// Even if the fetch call itself failed, still show whatever records
// are already saved locally for this consent request.
loadAbdmFetchResultModalData(consent);
// Immediately refresh table so that if consent is expired/denied/revoked,
// the row badge turns EXPIRED/DENIED and the "Show Data" button is removed!
loadAbdmConsentRequests();
$('#abdmFetchResultModalBody').html('<div class="alert alert-warning small text-center m-3">' + escHtml(err.message || err) + '</div>');
});
}
@ -1428,9 +1444,9 @@ $(function() {
isExpired = true;
}
}
if (isExpired) {
if (isExpired || status === 'DENIED' || status === 'REVOKED') {
$('#abdmPrintFetchedRecordsBtn').hide();
$('#abdmFetchResultModalBody').html('<div class="text-muted small text-center p-3">This consent request expired on <strong>' + escHtml(fmtConsentTs(eraseAt)) + '</strong>.<br>As per ABDM guidelines, health records are erased and no longer accessible.</div>');
$('#abdmFetchResultModalBody').html('<div class="text-muted small text-center p-3">This consent request is <strong>' + escHtml(status || 'EXPIRED') + '</strong>.<br>As per ABDM guidelines, health records are erased and no longer accessible.</div>');
return;
}
@ -1440,7 +1456,7 @@ $(function() {
}
fetch(url, { credentials: 'same-origin' })
.then(function(resp) { return resp.json(); })
.then(safeParseJson)
.then(function(data) {
if (!data || data.ok !== 1) {
throw new Error((data && data.error) || 'Unable to load fetched records.');
@ -1789,7 +1805,7 @@ $(function() {
}
fetch(url, { credentials: 'same-origin' })
.then(function(resp) { return resp.json(); })
.then(safeParseJson)
.then(function(data) {
if (!data || data.ok !== 1) {
throw new Error((data && data.error) || 'Unable to load ABDM records.');
@ -1816,7 +1832,7 @@ $(function() {
}
fetch(url, { credentials: 'same-origin' })
.then(function(resp) { return resp.json(); })
.then(safeParseJson)
.then(function(data) {
if (!data || data.ok !== 1) {
throw new Error((data && data.error) || 'Auto flow failed.');
@ -1868,7 +1884,7 @@ $(function() {
return;
}
fetch(abdmDocDetailBaseUrl + '/' + encodeURIComponent(docId), { credentials: 'same-origin' })
.then(function(resp) { return resp.json(); })
.then(safeParseJson)
.then(function(data) {
if (!data || data.ok !== 1) {
throw new Error((data && data.error) || 'Unable to load document detail.');
@ -1899,7 +1915,7 @@ $(function() {
setAbdmStatus('Checking live status with ABDM bridge…');
fetch(abdmCheckLiveStatusUrl, { credentials: 'same-origin' })
.then(function(resp) { return resp.json(); })
.then(safeParseJson)
.then(function(data) {
if (!data || data.ok !== 1) {
throw new Error((data && data.error) || 'Live status check failed.');