ABDM: add manual Consent Request form with editable HI types + date range (Phase 2)

- New Patient::abdm_content_request_custom(int \) endpoint builds a custom
  consent{} block (purpose, hiTypes, permission.dateRange, dataEraseAt) from
  user-submitted hi_types[]/date_from/date_to/purpose form fields, instead of
  always using M3HiuWorkflowService::buildDefaultConsent()'s hardcoded
  OPConsultation+DiagnosticReport / 365-day defaults. Reuses the existing
  requester/hiu-id resolution and hiTypes normalization already built into
  preparePayloadForOperation() for the consent_request operation, so no
  M3HiuWorkflowService changes were needed.
- New route (POST) patient/abdm_content_request_custom/(:num).
- New 'Custom Request' button + '#abdmCustomConsentModal' form
  (Patient_Profile_Opd_V.php) with HI type checkboxes (OPConsultation,
  DiagnosticReport, Prescription, DischargeSummary, HealthDocument,
  ImmunizationRecord, Wellness, Invoice), Valid From/To date inputs
  (defaulted to today-365d..today), and a Purpose text field. On submit,
  reuses the existing auto-flow polling loop (runAutoFlowStep) with the
  returned request_id to track status through to data fetch.
- Live-verified end-to-end on patient 11 (Devender Singh): submitted a
  request for OPConsultation + DiagnosticReport + Prescription; sandbox
  granted only OPConsultation/DiagnosticReport, and the Phase 1 consent
  detail modal correctly showed Prescription as DENIED alongside the two
  GRANTED types, confirming the partial-grant per-HI-type breakdown works
  correctly end-to-end.
This commit is contained in:
DevSoftTech Server 2026-07-28 16:23:47 +05:30
parent 7f47045c55
commit 48d19f0ff7
3 changed files with 326 additions and 0 deletions

View File

@ -502,6 +502,7 @@ $routes->group('billing', function($routes) {
$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->post('patient/abdm_content_request_custom/(:num)', 'Patient::abdm_content_request_custom/$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

@ -1320,6 +1320,165 @@ class Patient extends BaseController
]);
}
/**
* Manual consent request form submission — lets the user pick specific Health
* Information Types and an editable validity date range instead of always
* using the hardcoded defaults from M3HiuWorkflowService::buildDefaultConsent().
* Returns the same shape as abdm_content_request() so the existing JS auto-flow
* polling (runAutoFlowStep) can pick up the request_id and continue tracking
* consent status / data fetch to completion.
*/
public function abdm_content_request_custom(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,
]);
}
$hiTypesInput = $this->request->getPost('hi_types');
if (! is_array($hiTypesInput)) {
$decoded = json_decode((string) $hiTypesInput, true);
$hiTypesInput = is_array($decoded) ? $decoded : [];
}
$hiTypes = $this->normalizeHiTypesList($hiTypesInput);
if ($hiTypes === []) {
return $this->response->setStatusCode(422)->setJSON([
'ok' => 0,
'error' => 'Select at least one Health Information Type.',
]);
}
$dateFromInput = trim((string) $this->request->getPost('date_from'));
$dateToInput = trim((string) $this->request->getPost('date_to'));
$purposeInput = trim((string) $this->request->getPost('purpose'));
$istTz = new \DateTimeZone('Asia/Kolkata');
$utcTz = new \DateTimeZone('UTC');
$nowIst = new \DateTimeImmutable('now', $istTz);
try {
$fromIst = $dateFromInput !== '' ? new \DateTimeImmutable($dateFromInput, $istTz) : $nowIst->modify('-365 days');
} catch (\Throwable $e) {
return $this->response->setStatusCode(422)->setJSON([
'ok' => 0,
'error' => 'Invalid "Valid From" date.',
]);
}
try {
$toIst = $dateToInput !== '' ? new \DateTimeImmutable($dateToInput, $istTz) : $nowIst;
} catch (\Throwable $e) {
return $this->response->setStatusCode(422)->setJSON([
'ok' => 0,
'error' => 'Invalid "Valid To" date.',
]);
}
if ($fromIst->getTimestamp() >= $toIst->getTimestamp()) {
return $this->response->setStatusCode(422)->setJSON([
'ok' => 0,
'error' => '"Valid From" date must be earlier than "Valid To" date.',
]);
}
$safeNowIst = $nowIst->modify('-120 seconds');
if ($toIst->getTimestamp() > $safeNowIst->getTimestamp()) {
$toIst = $safeNowIst;
}
$fromUtc = $fromIst->setTime(0, 0, 0)->setTimezone($utcTz)->format('Y-m-d\TH:i:s.000\Z');
$toUtc = $toIst->setTimezone($utcTz)->format('Y-m-d\TH:i:s.000\Z');
$eraseAtUtc = $nowIst->modify('+365 days')->setTime(23, 59, 59)->setTimezone($utcTz)->format('Y-m-d\TH:i:s.000\Z');
$consent = [
'purpose' => [
'code' => 'CAREMGT',
'text' => $purposeInput !== '' ? $purposeInput : 'Care Management',
'refUri' => 'https://abdm.gov.in',
],
'patient' => [
'id' => $abhaContext['abha_address'],
],
'hiTypes' => $hiTypes,
'permission' => [
'accessMode' => 'VIEW',
'dateRange' => [
'from' => $fromUtc,
'to' => $toUtc,
],
'dataEraseAt' => $eraseAtUtc,
'frequency' => [
'unit' => 'HOUR',
'value' => 1,
'repeats' => 0,
],
],
];
$service = new \App\Libraries\Abdm\M3HiuWorkflowService();
$payload = [
'patient_id' => $pno,
'abha_address' => $abhaContext['abha_address'],
'consent' => $consent,
];
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')) {

View File

@ -316,6 +316,9 @@ if ($patientPhotoPath === '') {
<span class="abdm-btn-spinner spinner-border spinner-border-sm me-1 d-none" role="status" aria-hidden="true"></span>
<span id="btnAutoAbdmFlowLabel">Fetch ABDM Records</span>
</button>
<button type="button" class="btn btn-outline-primary" id="btnCustomAbdmRequest" data-bs-toggle="modal" data-bs-target="#abdmCustomConsentModal" <?= ($abhaIsVerified && $patientAbhaAddress !== '') ? '' : 'disabled' ?>>
Custom Request
</button>
<button type="button" class="btn btn-success d-none" id="btnFetchRecordsOnly">
<span class="abdm-btn-spinner spinner-border spinner-border-sm me-1 d-none" role="status" aria-hidden="true"></span>
<span id="btnFetchRecordsOnlyLabel">Fetch Records</span>
@ -433,6 +436,94 @@ if ($patientPhotoPath === '') {
</div>
</div>
</div>
<div class="modal fade" id="abdmCustomConsentModal" 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">New Consent Request</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label fw-semibold">Health Information Types</label>
<div class="row row-cols-2 row-cols-md-3 g-2" id="abdmCustomHiTypes">
<div class="col">
<div class="form-check">
<input class="form-check-input" type="checkbox" value="OPConsultation" id="hiOPConsultation" checked>
<label class="form-check-label" for="hiOPConsultation">OP Consultation</label>
</div>
</div>
<div class="col">
<div class="form-check">
<input class="form-check-input" type="checkbox" value="DiagnosticReport" id="hiDiagnosticReport" checked>
<label class="form-check-label" for="hiDiagnosticReport">Diagnostic Report</label>
</div>
</div>
<div class="col">
<div class="form-check">
<input class="form-check-input" type="checkbox" value="Prescription" id="hiPrescription">
<label class="form-check-label" for="hiPrescription">Prescription</label>
</div>
</div>
<div class="col">
<div class="form-check">
<input class="form-check-input" type="checkbox" value="DischargeSummary" id="hiDischargeSummary">
<label class="form-check-label" for="hiDischargeSummary">Discharge Summary</label>
</div>
</div>
<div class="col">
<div class="form-check">
<input class="form-check-input" type="checkbox" value="HealthDocument" id="hiHealthDocument">
<label class="form-check-label" for="hiHealthDocument">Health Document</label>
</div>
</div>
<div class="col">
<div class="form-check">
<input class="form-check-input" type="checkbox" value="ImmunizationRecord" id="hiImmunizationRecord">
<label class="form-check-label" for="hiImmunizationRecord">Immunization Record</label>
</div>
</div>
<div class="col">
<div class="form-check">
<input class="form-check-input" type="checkbox" value="Wellness" id="hiWellness">
<label class="form-check-label" for="hiWellness">Wellness</label>
</div>
</div>
<div class="col">
<div class="form-check">
<input class="form-check-input" type="checkbox" value="Invoice" id="hiInvoice">
<label class="form-check-label" for="hiInvoice">Invoice</label>
</div>
</div>
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-md-6">
<label class="form-label fw-semibold" for="abdmCustomDateFrom">Valid From</label>
<input type="date" class="form-control" id="abdmCustomDateFrom">
</div>
<div class="col-md-6">
<label class="form-label fw-semibold" for="abdmCustomDateTo">Valid To</label>
<input type="date" class="form-control" id="abdmCustomDateTo">
</div>
</div>
<div class="mb-2">
<label class="form-label fw-semibold" for="abdmCustomPurpose">Purpose</label>
<input type="text" class="form-control" id="abdmCustomPurpose" value="Care Management">
</div>
<div class="small text-danger d-none" id="abdmCustomConsentError"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="btnSendCustomConsent">
<span class="spinner-border spinner-border-sm me-1 d-none" id="abdmCustomConsentSpinner" role="status" aria-hidden="true"></span>
Send Consent Request
</button>
</div>
</div>
</div>
</div>
</section>
<script>
@ -442,6 +533,7 @@ $(function() {
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 abdmCustomRequestUrl = '<?= base_url('billing/patient/abdm_content_request_custom/' . (int) ($patient->id ?? 0)) ?>';
var abdmDocDetailBaseUrl = '<?= base_url('billing/patient/abdm_document_detail/' . (int) ($patient->id ?? 0)) ?>';
var currentAbdmRows = [];
var autoFlowTimer = null;
@ -1045,6 +1137,80 @@ $(function() {
loadAbdmConsentDetail();
});
$('#abdmCustomConsentModal').on('show.bs.modal', function() {
$('#abdmCustomConsentError').addClass('d-none').text('');
if (!$('#abdmCustomDateFrom').val()) {
var from = new Date();
from.setDate(from.getDate() - 365);
$('#abdmCustomDateFrom').val(from.toISOString().slice(0, 10));
}
if (!$('#abdmCustomDateTo').val()) {
$('#abdmCustomDateTo').val(new Date().toISOString().slice(0, 10));
}
});
$(document).off('click.abdmOpd', '#btnSendCustomConsent').on('click.abdmOpd', '#btnSendCustomConsent', function() {
if (autoFlowRunning || fetchOnlyRunning) {
return;
}
var hiTypes = [];
$('#abdmCustomHiTypes input:checked').each(function() {
hiTypes.push($(this).val());
});
if (!hiTypes.length) {
$('#abdmCustomConsentError').removeClass('d-none').text('Select at least one Health Information Type.');
return;
}
var dateFrom = $('#abdmCustomDateFrom').val();
var dateTo = $('#abdmCustomDateTo').val();
if (dateFrom && dateTo && dateFrom >= dateTo) {
$('#abdmCustomConsentError').removeClass('d-none').text('"Valid From" date must be earlier than "Valid To" date.');
return;
}
var $btn = $('#btnSendCustomConsent');
$btn.prop('disabled', true);
$('#abdmCustomConsentSpinner').removeClass('d-none');
$('#abdmCustomConsentError').addClass('d-none').text('');
$.ajax({
url: abdmCustomRequestUrl,
method: 'POST',
data: {
hi_types: hiTypes,
date_from: dateFrom,
date_to: dateTo,
purpose: $('#abdmCustomPurpose').val()
}
}).done(function(data) {
if (!data || data.ok !== 1) {
throw new Error((data && data.error) || 'Consent request failed.');
}
var bsModal = bootstrap.Modal.getOrCreateInstance(document.getElementById('abdmCustomConsentModal'));
bsModal.hide();
stopAutoFlowLoop();
autoFlowAttempts = 0;
autoFlowNeedsFreshRequest = false;
autoFlowRequestId = (data.request_id || '').toString();
setFlowProgress(1);
autoFlowRunning = true;
applyConsentButtonState();
$('#abdmStatusBox').removeClass('text-danger').addClass('text-muted').text('Sending custom consent request to patient\'s ABHA app...');
runAutoFlowStep();
}).fail(function(xhr) {
var data = xhr.responseJSON;
var errMsg = (data && data.error) || 'Consent request failed.';
$('#abdmCustomConsentError').removeClass('d-none').text(errMsg);
}).always(function() {
$btn.prop('disabled', false);
$('#abdmCustomConsentSpinner').addClass('d-none');
});
});
$('[data-bs-toggle="tooltip"]').tooltip();
applyConsentButtonState();