ABDM: add per-HI-type Consent Detail view (Phase 1)

- New Patient::getAbdmConsentArtifactDetail() helper builds a requested-vs-granted
  HI type breakdown for a patient's current ABDM consent artifact, using the
  bridge-team-confirmed 'Option A' model: overall status is single-valued
  (GRANTED/REVOKED/EXPIRED/DENIED), per-type status is derived by comparing the
  requested hiTypes (from the saved consent_request request_json) against the
  granted hi_types array (already flattened onto responses by
  M3HiuGatewayClient). No bridge changes or DB migrations required.
- New Patient::abdm_consent_detail(int \) JSON endpoint + route
  patient/abdm_consent_detail/(:num).
- New 'View Consent' link + Bootstrap modal on the OPD ABDM tab
  (Patient_Profile_Opd_V.php) rendering a lifecycle stepper, consent metadata
  (Consent ID, Purpose, Valid From/To, Erase Date), and a per-HI-type table
  (Document Type / Permission / Status / Timestamp).
- Fixed a bug found during live testing: once a consent reaches COMPLETED
  (after a successful data_fetch), the highest-priority workflow row has no
  hi_types field (it's a FHIR data bundle), which was causing all granted
  types to show as DENIED. Now searches workflow history for the most recent
  row that actually carries the granted hi_types payload.
- Live-verified against patient 15 (Meera Bisht): both requested HI types
  (OPConsultation, DiagnosticReport) correctly show GRANTED with timestamps.
This commit is contained in:
DevSoftTech Server 2026-07-28 16:17:10 +05:30
parent 37c3592f69
commit 7f47045c55
3 changed files with 443 additions and 0 deletions

View File

@ -501,6 +501,7 @@ $routes->group('billing', function($routes) {
$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_content_fetch_only/(:num)', 'Patient::abdm_content_fetch_only/$1');
$routes->get('patient/abdm_consent_detail/(:num)', 'Patient::abdm_consent_detail/$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');

View File

@ -1861,6 +1861,45 @@ class Patient extends BaseController
]);
}
/**
* Consent detail breakdown for the "View Consent" modal — shows the requested vs
* granted Health Information Types for the current consent artifact, plus the
* Requested -> Granted -> Revoked/Expired lifecycle timestamps for each type.
*/
public function abdm_consent_detail(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);
$abhaAddress = trim((string) ($abhaContext['abha_address'] ?? ''));
if ($abhaAddress === '') {
return $this->response->setStatusCode(422)->setJSON([
'ok' => 0,
'error' => 'ABHA Address not available for this patient.',
]);
}
$detail = $this->getAbdmConsentArtifactDetail($abhaAddress);
if ((int) ($detail['ok'] ?? 0) !== 1) {
return $this->response->setStatusCode(404)->setJSON($detail);
}
return $this->response->setJSON($detail);
}
public function abdm_timeline(int $pno)
{
if (! $this->db->tableExists('patient_master')) {
@ -3375,5 +3414,288 @@ class Patient extends BaseController
return $snapshot;
}
/**
* Builds the requested-vs-granted Health Information Type breakdown for a patient's
* current ABDM consent artifact, for the "View Consent" detail modal.
*
* Per ABDM M3 spec (confirmed with bridge team 2026-07-28): revocation is
* whole-artifact only (no partial per-HI-type revoke), so a single overall
* `status` + the granted `hi_types` array is sufficient to compute each
* requested type's effective status:
* - GRANTED : type is in the granted hi_types array and artifact status is GRANTED
* - DENIED : type was requested but is NOT in the granted hi_types array
* - REVOKED : artifact status is REVOKED (applies to the whole artifact)
* - EXPIRED : artifact status is EXPIRED
* - REQUESTED: artifact is still pending (no GRANTED/REVOKED/EXPIRED yet)
*/
private function getAbdmConsentArtifactDetail(string $abhaAddress): array
{
$abhaAddress = trim($abhaAddress);
if ($abhaAddress === '' || ! $this->db->tableExists('abdm_hiu_workflows')) {
return ['ok' => 0, 'error' => 'No ABDM consent activity found for this patient.'];
}
$fields = $this->db->getFieldNames('abdm_hiu_workflows') ?? [];
$select = ['id', 'operation', 'workflow_state', 'status', 'request_id', 'consent_id', 'created_at', 'updated_at', 'completed_at', 'expired_at', 'revoked_at', 'last_error', 'http_code', 'request_json', 'response_json'];
foreach (['abdm_consent_request_id', 'abdm_consent_artifact_id', 'gateway_request_id'] as $optionalField) {
if (in_array($optionalField, $fields, true)) {
$select[] = $optionalField;
}
}
$rows = $this->db->table('abdm_hiu_workflows')
->select(implode(', ', $select))
->where('abha_address', $abhaAddress)
->whereIn('operation', [
'consent_request',
'consent_status',
'consent_reconcile',
'data_fetch',
'consent_callback',
'hi_on_request_callback',
'hi_data_push_callback',
])
->orderBy('id', 'DESC')
->get(60)
->getResultArray();
if ($rows === []) {
return ['ok' => 0, 'error' => 'No ABDM consent activity found for this patient.'];
}
$best = null;
$bestPriority = -1;
$bestDecoded = [];
foreach ($rows as $row) {
$decoded = json_decode((string) ($row['response_json'] ?? ''), true);
if (! is_array($decoded)) {
$decoded = [];
}
$rawConsentStatus = strtoupper(trim((string) (
$decoded['consent']['status']
?? $decoded['consent_status']
?? $decoded['status']
?? $decoded['data']['consent']['status']
?? ''
)));
$operation = strtoupper(trim((string) ($row['operation'] ?? '')));
$status = strtoupper(trim((string) ($row['status'] ?? '')));
$state = strtoupper(trim((string) ($row['workflow_state'] ?? '')));
$phase = 'REQUESTED';
$priority = 120;
if (($operation === 'DATA_FETCH' || $operation === 'HI_DATA_PUSH_CALLBACK') && $status === 'SUCCESS') {
$phase = 'COMPLETED';
$priority = 500;
} elseif (in_array($rawConsentStatus, ['GRANTED', 'APPROVED', 'ACTIVE'], true)) {
$phase = 'GRANTED';
$priority = 430;
} elseif ($rawConsentStatus === 'REVOKED') {
$phase = 'REVOKED';
$priority = 320;
} elseif ($rawConsentStatus === 'EXPIRED') {
$phase = 'EXPIRED';
$priority = 310;
} elseif ($rawConsentStatus === 'DENIED') {
$phase = 'DENIED';
$priority = 300;
} elseif ($state === 'DATA_RECEIVED') {
$phase = 'COMPLETED';
$priority = 480;
} elseif ($state === 'GRANTED') {
$phase = 'GRANTED';
$priority = 420;
} elseif ($state === 'REVOKED') {
$phase = 'REVOKED';
$priority = 300;
} elseif ($state === 'EXPIRED') {
$phase = 'EXPIRED';
$priority = 290;
} elseif ($status === 'FAILED' && $operation === 'CONSENT_REQUEST') {
$phase = 'FAILED';
$priority = 260;
} elseif ($status === 'FAILED') {
$phase = 'REQUESTED';
$priority = 190;
} elseif (in_array($state, ['REQUESTED', 'PENDING', 'STATUS_CHECKED'], true)) {
$phase = 'REQUESTED';
$priority = 180;
}
if ($best === null || $priority > $bestPriority) {
$best = $row;
$best['_phase'] = $phase;
$bestPriority = $priority;
$bestDecoded = $decoded;
}
}
if ($best === null) {
return ['ok' => 0, 'error' => 'No ABDM consent activity found for this patient.'];
}
$phase = (string) $best['_phase'];
$consentId = trim((string) ($best['abdm_consent_artifact_id'] ?? $best['consent_id'] ?? $bestDecoded['consent_id'] ?? $bestDecoded['consentId'] ?? ''));
$consentRequestId = trim((string) ($best['abdm_consent_request_id'] ?? $bestDecoded['abdm_consent_request_id'] ?? $bestDecoded['consent_request_id'] ?? $bestDecoded['consentRequestId'] ?? ''));
$bestRequestId = trim((string) ($best['request_id'] ?? ''));
// Find the original consent_request row for this same chain, to get the
// requested hi_types list + the original requested-on timestamp.
$consentRequestRow = null;
foreach ($rows as $row) {
if (strtoupper((string) ($row['operation'] ?? '')) !== 'CONSENT_REQUEST') {
continue;
}
$rowConsentReqId = trim((string) ($row['abdm_consent_request_id'] ?? ''));
$rowRequestId = trim((string) ($row['request_id'] ?? ''));
if (($consentRequestId !== '' && $rowConsentReqId === $consentRequestId)
|| ($bestRequestId !== '' && $rowRequestId === $bestRequestId)
) {
$consentRequestRow = $row;
break;
}
}
if ($consentRequestRow === null) {
foreach ($rows as $row) {
if (strtoupper((string) ($row['operation'] ?? '')) === 'CONSENT_REQUEST') {
$consentRequestRow = $row;
break;
}
}
}
$requestedHiTypes = [];
$requestedOn = '';
$purpose = '';
$validFrom = '';
$validTo = '';
$eraseAt = '';
if (is_array($consentRequestRow)) {
$reqPayload = json_decode((string) ($consentRequestRow['request_json'] ?? ''), true);
if (! is_array($reqPayload)) {
$reqPayload = [];
}
$consentBlock = (array) ($reqPayload['consent'] ?? []);
$requestedHiTypes = $this->normalizeHiTypesList($consentBlock['hiTypes'] ?? $consentBlock['hi_types'] ?? []);
$requestedOn = trim((string) ($consentRequestRow['created_at'] ?? ''));
$purpose = trim((string) ($consentBlock['purpose']['text'] ?? $consentBlock['purpose']['code'] ?? ''));
$validFrom = trim((string) ($consentBlock['permission']['dateRange']['from'] ?? ''));
$validTo = trim((string) ($consentBlock['permission']['dateRange']['to'] ?? ''));
$eraseAt = trim((string) ($consentBlock['permission']['dataEraseAt'] ?? ''));
}
// Bridge flattens consent.hi_types -> top-level hi_types on consent_status /
// consent_reconcile / consent_callback responses (see M3HiuGatewayClient
// flatten logic). The "best" row for overall phase can be a data_fetch /
// hi_data_push_callback row (once records are COMPLETED) whose response is
// a FHIR data bundle with no hi_types field at all — so look separately for
// the most recent row that actually carries the granted hi_types payload.
$grantedHiTypes = [];
$grantedOn = '';
foreach ($rows as $row) {
$decoded = json_decode((string) ($row['response_json'] ?? ''), true);
if (! is_array($decoded)) {
continue;
}
$rowHiTypes = $this->normalizeHiTypesList($decoded['hi_types'] ?? $decoded['consent']['hi_types'] ?? []);
if ($rowHiTypes !== []) {
$grantedHiTypes = $rowHiTypes;
$grantedOn = trim((string) ($decoded['granted_at'] ?? $row['updated_at'] ?? ''));
break;
}
}
$revokedOn = trim((string) ($best['revoked_at'] ?? ''));
$expiredOn = trim((string) ($best['expired_at'] ?? ''));
if (in_array($phase, ['GRANTED', 'COMPLETED'], true) && $grantedOn === '') {
$grantedOn = trim((string) ($bestDecoded['granted_at'] ?? $best['updated_at'] ?? ''));
}
$items = [];
$typesForItems = $requestedHiTypes !== [] ? $requestedHiTypes : $grantedHiTypes;
foreach ($typesForItems as $hiType) {
$itemStatus = 'REQUESTED';
$itemTimestamp = $requestedOn;
if ($phase === 'REVOKED') {
$itemStatus = 'REVOKED';
$itemTimestamp = $revokedOn;
} elseif ($phase === 'EXPIRED') {
$itemStatus = 'EXPIRED';
$itemTimestamp = $expiredOn;
} elseif (in_array($phase, ['GRANTED', 'COMPLETED'], true)) {
if (in_array($hiType, $grantedHiTypes, true)) {
$itemStatus = 'GRANTED';
$itemTimestamp = $grantedOn;
} else {
$itemStatus = 'DENIED';
$itemTimestamp = $grantedOn;
}
} elseif ($phase === 'FAILED') {
$itemStatus = 'FAILED';
$itemTimestamp = trim((string) ($best['updated_at'] ?? ''));
}
$items[] = [
'document_name' => $hiType,
'permission' => 'VIEW',
'status' => $itemStatus,
'timestamp' => $itemTimestamp,
];
}
return [
'ok' => 1,
'consent' => [
'consent_id' => $consentId,
'consent_request_id' => $consentRequestId,
'abha_address' => $abhaAddress,
'status' => $phase,
'purpose' => $purpose !== '' ? $purpose : 'Care Management',
'requested_hi_types' => $requestedHiTypes,
'granted_hi_types' => $grantedHiTypes,
'valid_from' => $validFrom,
'valid_to' => $validTo,
'erase_at' => $eraseAt,
'requested_on' => $requestedOn,
'granted_on' => $grantedOn,
'revoked_on' => $revokedOn,
'expired_on' => $expiredOn,
'items' => $items,
],
];
}
/**
* Normalizes a hiTypes value (JSON string, array, or single string) into a clean,
* de-duplicated string array.
*
* @param mixed $value
*/
private function normalizeHiTypesList($value): array
{
if (is_string($value)) {
$decoded = json_decode($value, true);
$value = is_array($decoded) ? $decoded : [$value];
}
if (! is_array($value)) {
return [];
}
$out = [];
foreach ($value as $v) {
$v = trim((string) $v);
if ($v !== '' && ! in_array($v, $out, true)) {
$out[] = $v;
}
}
return $out;
}
}

View File

@ -348,6 +348,7 @@ if ($patientPhotoPath === '') {
<div class="d-flex align-items-center gap-2">
<span class="badge bg-secondary" id="abdmLastConsentBadge">Unknown</span>
<span id="abdmStatusBox" class="small text-muted flex-grow-1">Click "Fetch ABDM Records" to request and load the patient's ABDM health records.</span>
<button type="button" class="btn btn-link btn-sm p-0 d-none" id="btnViewAbdmConsent" data-bs-toggle="modal" data-bs-target="#abdmConsentDetailModal">View Consent</button>
</div>
</div>
</div>
@ -418,6 +419,20 @@ if ($patientPhotoPath === '') {
</div>
</div>
</div>
<div class="modal fade" id="abdmConsentDetailModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Consent Details</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body" id="abdmConsentDetailBody">
<div class="text-muted small">Loading...</div>
</div>
</div>
</div>
</div>
</section>
<script>
@ -426,6 +441,7 @@ $(function() {
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 abdmFetchOnlyUrl = '<?= base_url('billing/patient/abdm_content_fetch_only/' . (int) ($patient->id ?? 0)) ?>';
var abdmConsentDetailUrl = '<?= base_url('billing/patient/abdm_consent_detail/' . (int) ($patient->id ?? 0)) ?>';
var abdmDocDetailBaseUrl = '<?= base_url('billing/patient/abdm_document_detail/' . (int) ($patient->id ?? 0)) ?>';
var currentAbdmRows = [];
var autoFlowTimer = null;
@ -481,6 +497,103 @@ $(function() {
$('#btnFetchRecordsOnly').toggleClass('d-none', !show);
}
// "View Consent" is shown once any consent activity has started (not just IDLE).
function showViewConsentLink(show) {
$('#btnViewAbdmConsent').toggleClass('d-none', !show);
}
function consentItemBadgeClass(status) {
switch ((status || '').toString().toUpperCase()) {
case 'GRANTED': return 'bg-success-subtle text-success border border-success-subtle';
case 'REVOKED': return 'bg-secondary-subtle text-secondary border border-secondary-subtle';
case 'EXPIRED': return 'bg-warning-subtle text-warning border border-warning-subtle';
case 'DENIED': return 'bg-danger-subtle text-danger border border-danger-subtle';
case 'FAILED': return 'bg-danger-subtle text-danger border border-danger-subtle';
default: return 'bg-info-subtle text-info border border-info-subtle';
}
}
function fmtConsentTs(value) {
value = (value || '').toString().trim();
if (value === '') {
return '-';
}
// Accept both plain 'YYYY-MM-DD HH:MM:SS' and ISO forms.
var d = new Date(value.indexOf('T') === -1 ? value.replace(' ', 'T') : value);
if (isNaN(d.getTime())) {
return escHtml(value);
}
return escHtml(d.toLocaleString());
}
function renderAbdmConsentDetail(consent) {
if (!consent) {
return '<div class="text-danger small">No consent details available.</div>';
}
var steps = ['REQUESTED', 'GRANTED'];
var statusUpper = (consent.status || '').toString().toUpperCase();
if (statusUpper === 'REVOKED') { steps.push('REVOKED'); }
else if (statusUpper === 'EXPIRED') { steps.push('EXPIRED'); }
var stepperHtml = '<div class="d-flex align-items-center gap-2 mb-3 flex-wrap">';
steps.forEach(function(step, idx) {
var reached = steps.indexOf(statusUpper) >= idx || (statusUpper === 'COMPLETED' && step !== 'REVOKED' && step !== 'EXPIRED');
stepperHtml += '<span class="badge ' + (reached ? 'bg-success' : 'bg-secondary') + '">' + (idx + 1) + '. ' + escHtml(step.charAt(0) + step.slice(1).toLowerCase()) + '</span>';
if (idx < steps.length - 1) { stepperHtml += '<span class="text-muted">&rarr;</span>'; }
});
stepperHtml += '</div>';
var metaHtml = '<div class="row small text-muted mb-3">'
+ '<div class="col-md-6">Consent ID: <strong>' + escHtml(consent.consent_id || '-') + '</strong></div>'
+ '<div class="col-md-6">ABHA: <strong>' + escHtml(consent.abha_address || '-') + '</strong></div>'
+ '<div class="col-md-6">Purpose: <strong>' + escHtml(consent.purpose || '-') + '</strong></div>'
+ '<div class="col-md-6">Status: <strong>' + escHtml(consent.status || '-') + '</strong></div>'
+ '<div class="col-md-6">Valid From: <strong>' + fmtConsentTs(consent.valid_from) + '</strong></div>'
+ '<div class="col-md-6">Valid To: <strong>' + fmtConsentTs(consent.valid_to) + '</strong></div>'
+ '<div class="col-md-6">Requested On: <strong>' + fmtConsentTs(consent.requested_on) + '</strong></div>'
+ '<div class="col-md-6">Erase Date: <strong>' + fmtConsentTs(consent.erase_at) + '</strong></div>'
+ '</div>';
var rowsHtml = '';
(consent.items || []).forEach(function(item) {
var ts = item.status === 'GRANTED' ? item.timestamp
: item.status === 'REVOKED' ? item.timestamp
: item.status === 'EXPIRED' ? item.timestamp
: item.timestamp;
rowsHtml += '<tr>'
+ '<td>' + escHtml(item.document_name || '') + '</td>'
+ '<td>' + escHtml(item.permission || 'VIEW') + '</td>'
+ '<td><span class="badge ' + consentItemBadgeClass(item.status) + '">' + escHtml(item.status || '') + '</span></td>'
+ '<td>' + fmtConsentTs(ts) + '</td>'
+ '</tr>';
});
if (rowsHtml === '') {
rowsHtml = '<tr><td colspan="4" class="text-muted text-center">No health information types recorded for this request.</td></tr>';
}
var tableHtml = '<div class="table-responsive"><table class="table table-sm table-bordered mb-0">'
+ '<thead><tr><th>Document Type</th><th>Permission</th><th>Status</th><th>Timestamp</th></tr></thead>'
+ '<tbody>' + rowsHtml + '</tbody></table></div>';
return stepperHtml + metaHtml + tableHtml;
}
function loadAbdmConsentDetail() {
$('#abdmConsentDetailBody').html('<div class="text-muted small">Loading...</div>');
fetch(abdmConsentDetailUrl, { 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 consent details.');
}
$('#abdmConsentDetailBody').html(renderAbdmConsentDetail(data.consent));
})
.catch(function(err) {
$('#abdmConsentDetailBody').html('<div class="text-danger small">' + escHtml('Failed to load consent details: ' + (err.message || err)) + '</div>');
});
}
var zoom = 1;
var minZoom = 0.5;
var maxZoom = 3;
@ -697,6 +810,7 @@ $(function() {
}
showFetchRecordsButton(lastPhase === 'GRANTED' || lastPhase === 'COMPLETED');
showViewConsentLink(lastPhase !== '' && lastPhase !== 'IDLE');
updateConsentBadge(lastPhase, autoFlowNeedsFreshRequest);
var baseMsg = 'Loaded ' + currentAbdmRows.length + ' fetched ABDM record(s).';
@ -784,6 +898,7 @@ $(function() {
setFlowProgress(1);
}
showFetchRecordsButton(phase === 'GRANTED' || phase === 'COMPLETED');
showViewConsentLink(phase !== '' && phase !== 'IDLE');
updateConsentBadge(phase, autoFlowNeedsFreshRequest);
$('#abdmStatusBox').removeClass('text-danger').addClass('text-muted').text(info + (autoFlowRequestId ? (' | Request ID: ' + autoFlowRequestId) : ''));
@ -858,6 +973,7 @@ $(function() {
setFlowProgress(3);
showFetchRecordsButton(true);
showViewConsentLink(true);
updateConsentBadge('COMPLETED', false);
var info = (data.message || 'Records fetched successfully.').toString();
@ -925,6 +1041,10 @@ $(function() {
}
});
$('#abdmConsentDetailModal').on('show.bs.modal', function() {
loadAbdmConsentDetail();
});
$('[data-bs-toggle="tooltip"]').tooltip();
applyConsentButtonState();