feat(abdm): enforce consent expiration, request-specific printing, and push sync enhancements

This commit is contained in:
DevSoftTech Server 2026-10-09 17:38:51 +05:30
parent 7472364002
commit 39017a43cf
7 changed files with 649 additions and 128 deletions

View File

@ -108,6 +108,25 @@ class AbdmPushSync extends BaseCommand
$patientId = (int) ($row['patient_id'] ?? 0);
$patient = $this->loadPatientDemographics($db, $patientId);
$abhaId = trim((string) ($row['abha_id'] ?? ''));
$hiType = (string) ($row['hi_type'] ?? '');
$ccRef = (string) ($row['care_context_reference'] ?? '');
if ($hiType === '') {
$hiType = (str_starts_with($ccRef, 'PRESC-') || str_starts_with($ccRef, 'RX-'))
? 'PrescriptionRecord'
: 'OPConsultRecord';
}
$vDate = substr((string) ($row['created_at'] ?? date('Y-m-d')), 0, 10);
$dateStr = date('d M Y', strtotime($vDate));
$ccDisplay = match ($hiType) {
'PrescriptionRecord' => 'Prescription - ' . $dateStr,
'OPConsultRecord' => 'Consultation Record - ' . $dateStr,
'DischargeSummaryRecord' => 'Discharge Summary - ' . $dateStr,
'DiagnosticReportRecord' => 'Diagnostic Report - ' . $dateStr,
'ImmunizationRecord' => 'Immunization Record - ' . $dateStr,
'WellnessRecord' => 'Wellness Record - ' . $dateStr,
'InvoiceRecord' => 'Invoice - ' . $dateStr,
default => (string) ($row['care_context_display'] ?? ($hiType . ' ' . $dateStr)),
};
try {
$result = $connector->pushRecord([
@ -117,11 +136,11 @@ class AbdmPushSync extends BaseCommand
'abha_address' => str_contains($abhaId, '@') ? $abhaId : $patient['abha_address'],
'gender' => $patient['gender'],
'year_of_birth' => $patient['year_of_birth'],
'hi_type' => (string) ($row['hi_type'] ?? ''),
'record_type' => (string) ($row['hi_type'] ?? ''),
'visit_date' => substr((string) ($row['created_at'] ?? date('Y-m-d')), 0, 10),
'care_context_reference' => (string) ($row['care_context_reference'] ?? ''),
'care_context_display' => (string) ($row['care_context_display'] ?? (($row['hi_type'] ?? '') . ' ' . substr((string) ($row['created_at'] ?? ''), 0, 10))),
'hi_type' => $hiType,
'record_type' => $hiType,
'visit_date' => $vDate,
'care_context_reference' => $ccRef,
'care_context_display' => $ccDisplay,
'record_data' => $bundle,
]);

View File

@ -1730,7 +1730,7 @@ class AbdmGateway extends BaseController
$healthRecordId = $this->storeHealthRecord([
'patient_id' => $patientId,
'abha_id' => $hasAbha ? $abhaId : '',
'hi_type' => 'OPConsultRecord',
'hi_type' => $hiType,
'entity_type' => 'opd',
'entity_id' => (string) $opdId,
'fhir_bundle' => $bundleJson,
@ -5649,15 +5649,20 @@ class AbdmGateway extends BaseController
$bundleData = json_decode((string) $hrRow['record_data'], true);
if (is_array($bundleData)) {
$recHiType = $hrRow['hi_type'] ?? $hrRow['record_type'] ?? 'OPConsultRecord';
if (str_starts_with($ref, 'PRESC-') || str_starts_with($ref, 'RX-')) {
$recHiType = 'PrescriptionRecord';
} elseif (str_starts_with($ref, 'OPD-')) {
$recHiType = 'OPConsultRecord';
}
$defaultDisp = match ($recHiType) {
'ImmunizationRecord' => 'Immunization Record',
'ImmunizationRecord' => 'Immunization Record',
'DiagnosticReportRecord' => 'Diagnostic Report',
'DischargeSummaryRecord' => 'Discharge Summary',
'PrescriptionRecord' => 'Prescription Record',
'WellnessRecord' => 'Wellness Record',
'InvoiceRecord' => 'Invoice Record',
'HealthDocumentRecord' => 'Health Document',
default => 'Consultation Record',
'PrescriptionRecord' => 'Prescription Record',
'WellnessRecord' => 'Wellness Record',
'InvoiceRecord' => 'Invoice Record',
'HealthDocumentRecord' => 'Health Document',
default => 'Consultation Record',
};
$disp = ! empty($hrRow['care_context_display']) ? $hrRow['care_context_display'] : $defaultDisp;
if ($recHiType === 'ImmunizationRecord' && ! empty($hrRow['entity_id']) && $db->tableExists('immunization_records')) {
@ -5964,7 +5969,13 @@ class AbdmGateway extends BaseController
$targetOpdId = 0;
if ($targetSessionId > 0 && $db->tableExists('opd_prescription')) {
$pRow = $db->table('opd_prescription')->select('opd_id')->where('id', $targetSessionId)->get(1)->getRowArray();
$pRow = $db->table('opd_prescription')
->select('opd_id')
->groupStart()
->where('id', $targetSessionId)
->orWhere('session_id', $targetSessionId)
->groupEnd()
->get(1)->getRowArray();
$targetOpdId = (int) ($pRow['opd_id'] ?? 0);
}
if ($targetOpdId <= 0 && $targetPatientId > 0 && $db->tableExists('opd_master')) {
@ -6020,13 +6031,12 @@ class AbdmGateway extends BaseController
$bundleJson = json_encode($bundle, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$exHr = $db->table('health_records')->where('care_context_reference', $ref)->get(1)->getRowArray();
if ($exHr) {
if (empty($exHr['record_data'])) {
$db->table('health_records')->where('id', (int) $exHr['id'])->update([
'record_data' => $bundleJson,
'push_status' => 'linked',
'updated_at' => date('Y-m-d H:i:s'),
]);
}
$db->table('health_records')->where('id', (int) $exHr['id'])->update([
'hi_type' => $hiType,
'record_data' => $bundleJson,
'push_status' => 'linked',
'updated_at' => date('Y-m-d H:i:s'),
]);
} else {
$db->table('health_records')->insert([
'care_context_reference' => $ref,
@ -7385,9 +7395,9 @@ class AbdmGateway extends BaseController
$ccRef = 'HR-' . (int) ($row['id'] ?? 0);
}
$rowEntType = strtolower(trim((string) ($row['entity_type'] ?? '')));
// Skip redundant/legacy separate PRESC- and WELLNESS- contexts (now unified under OPD-),
// but allow genuine standalone nursing Wellness records (entity_type = 'wellness')
if ($rowEntType !== 'wellness' && (str_starts_with($ccRef, 'PRESC-') || str_starts_with($ccRef, 'WELLNESS-'))) {
// Allow genuine separate PRESC- contexts (PrescriptionRecord),
// but skip redundant/legacy WELLNESS- aliases (now unified under OPD- or patient_wellness_records)
if ($rowEntType !== 'wellness' && str_starts_with($ccRef, 'WELLNESS-')) {
continue;
}
// OPD- and INVOICE- contexts are enriched with doctor/session/bill details in steps 4 & 6
@ -7725,6 +7735,20 @@ class AbdmGateway extends BaseController
'is_fhir_ready' => $hasFindings || $hasMeds || $hasVitals,
'is_primary' => false,
]);
if ($hasMeds) {
$prescCcRef = 'PRESC-' . $patientId . '-S' . $sessionId . '-' . $cleanDate;
$prescDisplay = 'Prescription - ' . $dateStr;
$addContext([
'careContextId' => $prescCcRef,
'referenceNumber' => $prescCcRef,
'display' => $prescDisplay,
'record_type' => 'PrescriptionRecord',
'patient_id' => $patientId,
'is_fhir_ready' => true,
'is_primary' => false,
]);
}
}
}

View File

@ -2016,6 +2016,50 @@ class Patient extends BaseController
// Resolve all related umbrella and artifact IDs bidirectionally so
// 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'])
->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()
->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;
}
}
}
}
if ($isFilterExpired) {
// Purge cached records for this expired consent as per ABDM dataEraseAt guidelines
$purgeRefs = array_unique(array_merge([$filterConsentRequestId], $sessionIds));
$this->purgeAbdmDocumentsByRefs($purgeRefs);
return $this->response->setJSON([
'ok' => 1,
'count' => 0,
'items' => [],
'expired' => 1,
'error' => 'This consent request has expired. Health records are erased and no longer accessible as per ABDM guidelines.',
'abha' => $abhaContext,
'last_sync' => $lastSync,
]);
}
if ($sessionIds !== []) {
$builder->groupStart();
$hasClause = false;
@ -2171,6 +2215,28 @@ class Patient extends BaseController
} elseif ($consentRequestId !== '') {
$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'])
->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()
->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())) {
$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.');
}
}
if ($sessionIds !== []) {
$builder->groupStart();
$hasClause = false;
@ -2198,7 +2264,7 @@ class Patient extends BaseController
}
$builder->limit(30);
} else {
$builder->limit(15);
return $this->response->setStatusCode(400)->setBody('A valid consent request ID is required to print health records.');
}
$rows = $builder->get()->getResultArray();
@ -3033,12 +3099,59 @@ class Patient extends BaseController
));
if ($overrideConsentId !== '' || $overrideConsentRequestId !== '') {
$phase = 'GRANTED';
$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'])
->groupStart()
->where('request_id', $lookupTarget)
->orWhere('consent_id', $lookupTarget)
->orWhere('abdm_consent_request_id', $lookupTarget)
->orWhere('abdm_consent_artifact_id', $lookupTarget)
->groupEnd()
->orderBy('id', 'DESC')
->get(1)
->getRowArray();
$isTargetExpired = false;
$targetEraseAt = '';
if (is_array($checkWf)) {
if (strtoupper(trim((string) ($checkWf['workflow_state'] ?? ''))) === 'EXPIRED') {
$isTargetExpired = true;
}
$targetEraseAt = $this->extractDataEraseAtFromWorkflowRow($checkWf);
if ($targetEraseAt !== '') {
$targetTs = strtotime($targetEraseAt);
if ($targetTs !== false && $targetTs <= time()) {
$isTargetExpired = true;
}
}
}
if ($isTargetExpired) {
$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 = 'GRANTED';
} else {
$lastSync = $this->getLatestAbdmSyncSnapshot((string) ($abhaContext['abha_address'] ?? ''));
$phase = strtoupper(trim((string) ($lastSync['phase'] ?? '')));
if ($phase === 'EXPIRED') {
return $this->response->setStatusCode(422)->setJSON([
'ok' => 0,
'phase' => 'EXPIRED',
'error' => 'The latest consent request has expired. Under ABDM guidelines, records cannot be fetched or accessed under an expired consent. Please create a new consent request.',
]);
}
if (! in_array($phase, ['GRANTED', 'COMPLETED'], true)) {
return $this->response->setStatusCode(422)->setJSON([
'ok' => 0,
@ -5351,6 +5464,32 @@ class Patient extends BaseController
$snapshot['operation'] = (string) ($best['operation'] ?? '');
$snapshot['status'] = (string) ($best['status'] ?? '');
// Check if latest consent session has expired based on erase_at/dataEraseAt
if ($latestSessionStartId > 0 && $this->db->tableExists('abdm_hiu_workflows')) {
$anchorRow = $this->db->table('abdm_hiu_workflows')
->select(['id', 'workflow_state', 'request_json', 'response_json', 'expired_at'])
->where('id', $latestSessionStartId)
->get(1)
->getRowArray();
if (is_array($anchorRow)) {
$anchorEraseAt = $this->extractDataEraseAtFromWorkflowRow($anchorRow);
$isAnchorExpired = false;
if (strtoupper(trim((string) ($anchorRow['workflow_state'] ?? ''))) === 'EXPIRED') {
$isAnchorExpired = true;
} elseif ($anchorEraseAt !== '') {
$anchorEraseTs = strtotime($anchorEraseAt);
if ($anchorEraseTs !== false && $anchorEraseTs <= time()) {
$isAnchorExpired = true;
}
}
if ($isAnchorExpired) {
$snapshot['phase'] = 'EXPIRED';
$snapshot['message'] = 'Consent expired on ' . ($anchorEraseAt ? date('d/m/Y h:i:s A', strtotime($anchorEraseAt)) : 'an earlier date') . '. Start a new request to fetch updated records.';
$snapshot['restart_required'] = true;
}
}
}
// A consent request stuck in REQUESTED/PENDING for more than ABDM_PENDING_STALE_SECONDS
// (1 hour) with no GRANTED/DENIED callback from the bridge is considered abandoned by
// PHR/ABHA — the patient likely never saw/approved it, or the bridge silently dropped
@ -6164,6 +6303,59 @@ class Patient extends BaseController
$grantedOn = trim((string) ($bestDecoded['granted_at'] ?? $best['updated_at'] ?? ''));
}
// ABDM M3 HIU compliance: evaluate consent expiration against erase_at (dataEraseAt)
$isConsentExpired = false;
if ($phase === 'EXPIRED') {
$isConsentExpired = true;
} elseif ($eraseAt !== '') {
$eraseTimestamp = strtotime($eraseAt);
if ($eraseTimestamp !== false && $eraseTimestamp <= time()) {
$isConsentExpired = true;
}
}
if (! $isConsentExpired && $expiredOn !== '') {
$expTime = strtotime($expiredOn);
if ($expTime !== false && $expTime <= time()) {
$isConsentExpired = true;
}
}
if ($isConsentExpired) {
$phase = 'EXPIRED';
if ($expiredOn === '') {
$expiredOn = $eraseAt !== '' ? $eraseAt : (string) ($best['expired_at'] ?? date('Y-m-d H:i:s'));
}
// Persist terminal EXPIRED state to abdm_hiu_workflows for this session
if ($this->db->tableExists('abdm_hiu_workflows')) {
$sessionIdsToUpdate = [];
foreach ($rows as $r) {
$rState = strtoupper(trim((string) ($r['workflow_state'] ?? '')));
if ($rState !== 'EXPIRED' && ! empty($r['id'])) {
$sessionIdsToUpdate[] = (int) $r['id'];
}
}
if ($sessionIdsToUpdate !== []) {
$updateData = ['workflow_state' => 'EXPIRED'];
$wfFields = $this->db->getFieldNames('abdm_hiu_workflows') ?? [];
if (in_array('expired_at', $wfFields, true)) {
$updateData['expired_at'] = date('Y-m-d H:i:s', (isset($eraseTimestamp) && $eraseTimestamp) ? $eraseTimestamp : time());
}
$this->db->table('abdm_hiu_workflows')->whereIn('id', $sessionIdsToUpdate)->update($updateData);
}
}
// ABDM M3 HIU compliance: erase stored health records once dataEraseAt is reached
$purgeRefs = array_filter([$consentRequestId, $consentId]);
if ($purgeRefs !== []) {
$sessionDocIds = $this->resolveAllRelatedConsentIds($consentRequestId !== '' ? $consentRequestId : $consentId);
if ($sessionDocIds !== []) {
$purgeRefs = array_merge($purgeRefs, $sessionDocIds);
}
$this->purgeAbdmDocumentsByRefs($purgeRefs);
}
}
$items = [];
$typesForItems = $requestedHiTypes !== [] ? $requestedHiTypes : $grantedHiTypes;
foreach ($typesForItems as $hiType) {
@ -6893,5 +7085,86 @@ class Patient extends BaseController
log_message('warning', '[triggerBackgroundHiuPoll] ' . $e->getMessage());
}
}
/**
* Extracts dataEraseAt timestamp from a workflow row's request_json or response_json.
*/
private function extractDataEraseAtFromWorkflowRow(array $row): string
{
$req = json_decode((string) ($row['request_json'] ?? ''), true);
$resp = json_decode((string) ($row['response_json'] ?? ''), true);
$containers = [
is_array($req) ? ($req['consent'] ?? null) : null,
is_array($req) ? ($req['consentDetail'] ?? null) : null,
is_array($req) ? ($req['data']['consent'] ?? null) : null,
is_array($req) ? $req : null,
is_array($resp) ? ($resp['consent'] ?? null) : null,
is_array($resp) ? ($resp['consentDetail'] ?? null) : null,
is_array($resp) ? ($resp['data']['consent'] ?? null) : null,
is_array($resp) ? $resp : null,
];
foreach ($containers as $c) {
if (! is_array($c)) {
continue;
}
$erase = trim((string) (
$c['permission']['dataEraseAt']
?? $c['permission']['data_erase_at']
?? (is_array($c['expiry'] ?? null) ? ($c['expiry']['date'] ?? null) : ($c['expiry'] ?? null))
?? $c['erase_at']
?? $c['eraseAt']
?? $c['dataEraseAt']
?? $c['data_erase_at']
?? ''
));
if ($erase !== '') {
return $erase;
}
}
return '';
}
/**
* Deletes cached health records from abdm_hiu_documents for the given consent request
* or artifact references, complying with ABDM M3 dataEraseAt guidelines.
*/
private function purgeAbdmDocumentsByRefs(array $refs): int
{
$refs = array_values(array_unique(array_filter(array_map('trim', $refs))));
if ($refs === [] || ! $this->db->tableExists('abdm_hiu_documents')) {
return 0;
}
$docFields = $this->db->getFieldNames('abdm_hiu_documents') ?? [];
$builder = $this->db->table('abdm_hiu_documents');
$builder->groupStart();
$hasClause = false;
if (in_array('consent_request_id', $docFields, true)) {
$builder->whereIn('consent_request_id', $refs);
$hasClause = true;
}
if (in_array('consent_artifact_id', $docFields, true)) {
if ($hasClause) {
$builder->orWhereIn('consent_artifact_id', $refs);
} else {
$builder->whereIn('consent_artifact_id', $refs);
$hasClause = true;
}
}
if (in_array('consent_ref', $docFields, true)) {
if ($hasClause) {
$builder->orWhereIn('consent_ref', $refs);
} else {
$builder->whereIn('consent_ref', $refs);
$hasClause = true;
}
}
$builder->groupEnd();
if (! $hasClause) {
return 0;
}
$builder->delete();
return (int) $this->db->affectedRows();
}
}

View File

@ -702,6 +702,55 @@ class ConsentSessionListService
));
}
// ABDM M3 HIU compliance: evaluate consent expiration against erase_at (dataEraseAt)
$isConsentExpired = false;
if ($phase === 'EXPIRED') {
$isConsentExpired = true;
} elseif ($eraseAt !== '') {
$eraseTimestamp = strtotime($eraseAt);
if ($eraseTimestamp !== false && $eraseTimestamp <= time()) {
$isConsentExpired = true;
}
}
if (! $isConsentExpired && $expiredOn !== '') {
$expTime = strtotime($expiredOn);
if ($expTime !== false && $expTime <= time()) {
$isConsentExpired = true;
}
}
if ($isConsentExpired) {
$phase = 'EXPIRED';
if ($expiredOn === '') {
$expiredOn = $eraseAt !== '' ? $eraseAt : (string) ($best['expired_at'] ?? date('Y-m-d H:i:s'));
}
// Persist terminal EXPIRED state to abdm_hiu_workflows for this session
if ($this->db->tableExists('abdm_hiu_workflows')) {
$sessionIdsToUpdate = [];
foreach ($rows as $r) {
$rState = strtoupper(trim((string) ($r['workflow_state'] ?? '')));
if ($rState !== 'EXPIRED' && ! empty($r['id'])) {
$sessionIdsToUpdate[] = (int) $r['id'];
}
}
if ($sessionIdsToUpdate !== []) {
$updateData = ['workflow_state' => 'EXPIRED'];
$wfFields = $this->db->getFieldNames('abdm_hiu_workflows') ?? [];
if (in_array('expired_at', $wfFields, true)) {
$updateData['expired_at'] = date('Y-m-d H:i:s', (isset($eraseTimestamp) && $eraseTimestamp) ? $eraseTimestamp : time());
}
$this->db->table('abdm_hiu_workflows')->whereIn('id', $sessionIdsToUpdate)->update($updateData);
}
}
// ABDM M3 HIU compliance: erase stored health records once dataEraseAt is reached
$purgeRefs = array_filter([$consentRequestId, $consentId]);
if ($purgeRefs !== []) {
$this->purgeAbdmDocumentsByRefs($purgeRefs);
}
}
$items = [];
$typesForItems = $requestedHiTypes !== [] ? $requestedHiTypes : $grantedHiTypes;
foreach ($typesForItems as $hiType) {
@ -791,4 +840,64 @@ class ConsentSessionListService
return $out;
}
/**
* Deletes cached health records from abdm_hiu_documents for the given consent request
* or artifact references, complying with ABDM M3 dataEraseAt guidelines.
*/
private function purgeAbdmDocumentsByRefs(array $refs): int
{
$refs = array_values(array_unique(array_filter(array_map('trim', $refs))));
if ($refs === [] || ! $this->db->tableExists('abdm_hiu_documents')) {
return 0;
}
// Also resolve any sibling artifact IDs under these umbrella consent_request_ids
if ($this->db->tableExists('abdm_hiu_consent_artifacts')) {
$siblingRows = $this->db->table('abdm_hiu_consent_artifacts')
->select('artifact_id, consent_request_id')
->whereIn('consent_request_id', $refs)
->get()
->getResultArray();
foreach ($siblingRows as $sr) {
$aId = trim((string) ($sr['artifact_id'] ?? ''));
if ($aId !== '') {
$refs[] = $aId;
}
}
$refs = array_values(array_unique(array_filter($refs)));
}
$docFields = $this->db->getFieldNames('abdm_hiu_documents') ?? [];
$builder = $this->db->table('abdm_hiu_documents');
$builder->groupStart();
$hasClause = false;
if (in_array('consent_request_id', $docFields, true)) {
$builder->whereIn('consent_request_id', $refs);
$hasClause = true;
}
if (in_array('consent_artifact_id', $docFields, true)) {
if ($hasClause) {
$builder->orWhereIn('consent_artifact_id', $refs);
} else {
$builder->whereIn('consent_artifact_id', $refs);
$hasClause = true;
}
}
if (in_array('consent_ref', $docFields, true)) {
if ($hasClause) {
$builder->orWhereIn('consent_ref', $refs);
} else {
$builder->whereIn('consent_ref', $refs);
$hasClause = true;
}
}
$builder->groupEnd();
if (! $hasClause) {
return 0;
}
$builder->delete();
return (int) $this->db->affectedRows();
}
}

View File

@ -107,22 +107,22 @@ class AbdmTaskBoardSyncService
$opdIds = array_values(array_unique(array_map(static fn ($r) => (int) ($r['opd_id'] ?? 0), $validRows)));
// 2. Fetch latest FHIR documents for these OPD records
$docByOpd = [];
// 2. Fetch latest FHIR documents for these OPD records (both OPConsultRecord and PrescriptionRecord)
$docsByOpd = [];
if (! empty($opdIds) && $this->db->tableExists('opd_fhir_documents')) {
$docRows = $this->db->table('opd_fhir_documents')
->select('id, opd_id, opd_session_id, bundle_type, bundle_json, generated_at')
->select('id, opd_id, opd_session_id, bundle_type, bundle_json, generated_at, updated_at, created_at')
->whereIn('opd_id', $opdIds)
->whereIn('bundle_type', ['OPConsultRecord', 'MedicationRequestBundle', 'PrescriptionRecord'])
->orderBy("CASE WHEN bundle_type = 'OPConsultRecord' THEN 1 ELSE 2 END", 'ASC', false)
->orderBy('id', 'DESC')
->get()
->getResultArray();
foreach ($docRows as $doc) {
$oid = (int) ($doc['opd_id'] ?? 0);
if ($oid > 0 && ! isset($docByOpd[$oid])) {
$docByOpd[$oid] = $doc;
$bType = trim((string) ($doc['bundle_type'] ?? ''));
if ($oid > 0 && $bType !== '' && ! isset($docsByOpd[$oid][$bType])) {
$docsByOpd[$oid][$bType] = $doc;
}
}
}
@ -131,20 +131,22 @@ class AbdmTaskBoardSyncService
$hrByOpd = [];
if (! empty($opdIds) && $this->db->tableExists('health_records')) {
$sessionIds = [];
foreach ($docByOpd as $d) {
$sId = (int) ($d['opd_session_id'] ?? 0);
if ($sId > 0) {
$sessionIds[] = (string) $sId;
foreach ($docsByOpd as $oid => $bTypes) {
foreach ($bTypes as $d) {
$sId = (int) ($d['opd_session_id'] ?? 0);
if ($sId > 0) {
$sessionIds[] = (string) $sId;
}
}
}
$searchEntityIds = array_merge(
$searchEntityIds = array_values(array_unique(array_merge(
array_map(static fn ($v) => (string) $v, $opdIds),
$sessionIds
);
)));
$hrRows = $this->db->table('health_records')
->select('id, entity_id, push_status, abdm_txn_id, bridge_record_id, care_context_reference, push_at, linked_at, updated_at')
->select('id, entity_id, hi_type, push_status, abdm_txn_id, bridge_record_id, care_context_reference, push_at, linked_at, updated_at')
->where('entity_type', 'opd')
->whereIn('entity_id', $searchEntityIds)
->orderBy('id', 'DESC')
@ -153,11 +155,14 @@ class AbdmTaskBoardSyncService
foreach ($hrRows as $hr) {
$eid = (int) ($hr['entity_id'] ?? 0);
$ccRef = trim((string) ($hr['care_context_reference'] ?? ''));
$ht = trim((string) ($hr['hi_type'] ?? ''));
if ($eid <= 0) {
continue;
}
if (! isset($hrByOpd[$eid])) {
$hrByOpd[$eid] = $hr;
$typeKey = (str_starts_with($ccRef, 'PRESC-') || $ht === 'PrescriptionRecord') ? 'PrescriptionRecord' : 'OPConsultRecord';
if (! isset($hrByOpd[$eid][$typeKey])) {
$hrByOpd[$eid][$typeKey] = $hr;
}
}
}
@ -175,54 +180,24 @@ class AbdmTaskBoardSyncService
continue;
}
$doc = $docByOpd[$opdId] ?? null;
if ($doc === null) {
$opdDocs = $docsByOpd[$opdId] ?? [];
if (empty($opdDocs)) {
// No FHIR bundle generated yet for this OPD
continue;
}
$sessionId = (int) ($doc['opd_session_id'] ?? 0);
// Reconcile: check if health_records row was stored under opd_id or session_id
$hr = $hrByOpd[$opdId] ?? ($sessionId > 0 ? ($hrByOpd[$sessionId] ?? null) : null);
// If stored under session_id, reconcile entity_id to opdId
if ($hr !== null && (string) ($hr['entity_id'] ?? '') === (string) $sessionId && (string) $sessionId !== (string) $opdId) {
try {
$this->db->table('health_records')
->where('id', (int) $hr['id'])
->update(['entity_id' => (string) $opdId]);
$hr['entity_id'] = (string) $opdId;
} catch (\Throwable) {
}
}
$pushStatus = strtolower(trim((string) ($hr['push_status'] ?? '')));
// Already pushed/submitted/linked successfully?
if (in_array($pushStatus, ['queued', 'linked', 'pushed'], true)) {
$summary['skipped']++;
continue;
}
$consultDate = (string) ($row['apointment_date'] ?? '');
$visitDate = $consultDate !== '' ? date('Y-m-d', strtotime($consultDate)) : date('Y-m-d');
$cleanDate = str_replace('-', '', $visitDate);
$derivedCcRef = 'OPD-' . $patientId . '-S' . ($sessionId > 0 ? $sessionId : 0) . '-' . $cleanDate;
$careContextRef = trim((string) ($hr['care_context_reference'] ?? ''));
if ($careContextRef === '') {
$careContextRef = $derivedCcRef;
}
// Check cooling period from the latest modification/generation timestamp
$docTimestamps = array_filter([
! empty($doc['updated_at']) ? (string) $doc['updated_at'] : null,
! empty($doc['generated_at']) ? (string) $doc['generated_at'] : null,
! empty($hr['updated_at']) ? (string) $hr['updated_at'] : null,
! empty($doc['created_at']) ? (string) $doc['created_at'] : null,
]);
$lastModified = ! empty($docTimestamps) ? max($docTimestamps) : ($row['apointment_date'] ?? date('Y-m-d H:i:s'));
$allTimestamps = [];
foreach ($opdDocs as $d) {
if (! empty($d['updated_at'])) $allTimestamps[] = (string) $d['updated_at'];
if (! empty($d['generated_at'])) $allTimestamps[] = (string) $d['generated_at'];
if (! empty($d['created_at'])) $allTimestamps[] = (string) $d['created_at'];
}
$lastModified = ! empty($allTimestamps) ? max($allTimestamps) : ($row['apointment_date'] ?? date('Y-m-d H:i:s'));
$cooling = self::calculateCooling('opd_prescription_publish', $lastModified);
if ($cooling['is_cooling_active']) {
@ -231,7 +206,7 @@ class AbdmTaskBoardSyncService
'opd_id' => $opdId,
'patient' => trim((string) ($row['P_name'] ?? '')),
'abha' => trim((string) ($row['abha_id'] ?? '')),
'care_context' => $careContextRef,
'care_context' => 'OPD-' . $patientId . '-...',
'last_modified' => $lastModified,
'remaining_minutes' => $cooling['remaining_minutes'],
'auto_link_at' => $cooling['auto_link_at'],
@ -242,42 +217,92 @@ class AbdmTaskBoardSyncService
$summary['eligible']++;
if ($dryRun) {
$summary['details'][] = [
'opd_id' => $opdId,
'patient' => trim((string) ($row['P_name'] ?? '')),
'abha' => trim((string) ($row['abha_id'] ?? '')),
'care_context' => $careContextRef,
'status' => 'dry_run_eligible',
// Define targets: 1) OPConsultRecord, 2) PrescriptionRecord (if prescribed)
$syncTargets = [];
// Target A: OPConsultRecord
if (! empty($opdDocs['OPConsultRecord'])) {
$cDoc = $opdDocs['OPConsultRecord'];
$sId = (int) ($cDoc['opd_session_id'] ?? 0);
$cHr = $hrByOpd[$opdId]['OPConsultRecord'] ?? ($sId > 0 ? ($hrByOpd[$sId]['OPConsultRecord'] ?? null) : null);
$derivedRef = 'OPD-' . $patientId . '-S' . ($sId > 0 ? $sId : 0) . '-' . $cleanDate;
$ccRef = trim((string) ($cHr['care_context_reference'] ?? ''));
if ($ccRef === '') {
$ccRef = $derivedRef;
}
$syncTargets[] = [
'doc' => $cDoc,
'hr' => $cHr,
'ref' => $ccRef,
];
$processedCount++;
continue;
}
// Perform care context linking and push to ABDM bridge
$pushResult = $this->linkAndPushOpdRecord($row, $doc, $careContextRef, $visitDate, $hr);
// Target B: PrescriptionRecord (separate context for PHR App Prescriptions tab)
$pDoc = $opdDocs['PrescriptionRecord'] ?? ($opdDocs['MedicationRequestBundle'] ?? null);
if (! empty($pDoc)) {
$sId = (int) ($pDoc['opd_session_id'] ?? 0);
$pHr = $hrByOpd[$opdId]['PrescriptionRecord'] ?? ($sId > 0 ? ($hrByOpd[$sId]['PrescriptionRecord'] ?? null) : null);
$derivedRef = 'PRESC-' . $patientId . '-S' . ($sId > 0 ? $sId : 0) . '-' . $cleanDate;
$ccRef = trim((string) ($pHr['care_context_reference'] ?? ''));
if ($ccRef === '') {
$ccRef = $derivedRef;
}
$syncTargets[] = [
'doc' => $pDoc,
'hr' => $pHr,
'ref' => $ccRef,
];
}
foreach ($syncTargets as $target) {
$docToPush = $target['doc'];
$hrToUse = $target['hr'];
$ccRefToUse = $target['ref'];
$pushStatus = strtolower(trim((string) ($hrToUse['push_status'] ?? '')));
// Already pushed/submitted/linked successfully?
if (in_array($pushStatus, ['queued', 'linked', 'pushed'], true)) {
$summary['skipped']++;
continue;
}
if ($dryRun) {
$summary['details'][] = [
'opd_id' => $opdId,
'patient' => trim((string) ($row['P_name'] ?? '')),
'abha' => trim((string) ($row['abha_id'] ?? '')),
'care_context' => $ccRefToUse,
'status' => 'dry_run_eligible',
];
continue;
}
// Perform care context linking and push to ABDM bridge
$pushResult = $this->linkAndPushOpdRecord($row, $docToPush, $ccRefToUse, $visitDate, $hrToUse);
if ($pushResult['ok'] === 1) {
$summary['linked']++;
$summary['details'][] = [
'opd_id' => $opdId,
'patient' => trim((string) ($row['P_name'] ?? '')),
'care_context' => $ccRefToUse,
'queue_id' => $pushResult['queue_id'] ?? '',
'bridge_record_id' => $pushResult['bridge_record_id'] ?? null,
'status' => 'linked',
];
} else {
$summary['failed']++;
$summary['details'][] = [
'opd_id' => $opdId,
'patient' => trim((string) ($row['P_name'] ?? '')),
'care_context' => $ccRefToUse,
'error' => $pushResult['error'] ?? 'Unknown push error',
'status' => 'failed',
];
}
}
$processedCount++;
if ($pushResult['ok'] === 1) {
$summary['linked']++;
$summary['details'][] = [
'opd_id' => $opdId,
'patient' => trim((string) ($row['P_name'] ?? '')),
'care_context' => $careContextRef,
'queue_id' => $pushResult['queue_id'] ?? '',
'bridge_record_id' => $pushResult['bridge_record_id'] ?? null,
'status' => 'linked',
];
} else {
$summary['failed']++;
$summary['details'][] = [
'opd_id' => $opdId,
'patient' => trim((string) ($row['P_name'] ?? '')),
'care_context' => $careContextRef,
'error' => $pushResult['error'] ?? 'Unknown push error',
'status' => 'failed',
];
}
}
return $summary;

View File

@ -184,10 +184,19 @@
var html = '';
items.forEach(function(consent, idx) {
var status = (consent.status || '').toString().toUpperCase();
var eraseAt = (consent.erase_at || '').toString().trim();
var isExpired = (status === 'EXPIRED');
if (!isExpired && eraseAt !== '') {
var ed = new Date(eraseAt.indexOf('T') === -1 ? eraseAt.replace(' ', 'T') : eraseAt);
if (!isNaN(ed.getTime()) && ed.getTime() <= Date.now()) {
isExpired = true;
status = 'EXPIRED';
}
}
var hiTypes = (consent.requested_hi_types && consent.requested_hi_types.length) ? consent.requested_hi_types : (consent.granted_hi_types || []);
var hiTypesText = hiTypes.length ? hiTypes.join(', ') : '-';
var patientId = Number(consent.patient_id || 0);
var canFetch = (status === 'GRANTED' || status === 'COMPLETED') && patientId > 0;
var canFetch = (status === 'GRANTED' || status === 'COMPLETED') && patientId > 0 && !isExpired;
var canOpen = patientId > 0;
html += '<tr>'

View File

@ -377,9 +377,6 @@ if ($patientPhotoPath === '') {
<div class="d-flex justify-content-between align-items-center mb-2">
<div class="fw-semibold">Consent Request History</div>
<div class="d-flex align-items-center">
<a href="<?= base_url('billing/patient/abdm_records_pdf/' . (int) ($patient->id ?? 0)) ?>" target="_blank" class="btn btn-outline-primary btn-sm py-0 px-2 me-3" title="Print all fetched health records for this patient as PDF">
<i class="fa fa-print me-1"></i> Print All Records
</a>
<button type="button" class="btn btn-link btn-sm p-0 me-3" id="btnCheckLiveAbdmStatus">
<span class="spinner-border spinner-border-sm d-none" id="abdmLiveStatusSpinner"></span>
Check Live Status
@ -397,7 +394,7 @@ if ($patientPhotoPath === '') {
<th>Date From</th>
<th>Date To</th>
<th>View</th>
<th class="text-end">Show Data</th>
<th class="text-end">Show Data / Print</th>
</tr>
</thead>
<tbody>
@ -1207,8 +1204,16 @@ $(function() {
return '<div class="text-danger small">No consent details available.</div>';
}
var steps = ['REQUESTED', 'GRANTED'];
var statusUpper = (consent.status || '').toString().toUpperCase();
var eraseAt = (consent.erase_at || '').toString().trim();
if (statusUpper !== 'EXPIRED' && eraseAt !== '') {
var ed = new Date(eraseAt.indexOf('T') === -1 ? eraseAt.replace(' ', 'T') : eraseAt);
if (!isNaN(ed.getTime()) && ed.getTime() <= Date.now()) {
statusUpper = 'EXPIRED';
}
}
var steps = ['REQUESTED', 'GRANTED'];
if (statusUpper === 'REVOKED') { steps.push('REVOKED'); }
else if (statusUpper === 'EXPIRED') { steps.push('EXPIRED'); }
@ -1224,7 +1229,7 @@ $(function() {
+ '<div class="col-md-6">Consent ID: <strong>' + escHtml(consent.consent_id || consent.abdm_consent_artifact_id || consent.consent_request_id || consent.request_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">Status: <strong>' + escHtml(statusUpper || '-') + '</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>'
@ -1233,14 +1238,15 @@ $(function() {
var rowsHtml = '';
(consent.items || []).forEach(function(item) {
var ts = item.status === 'GRANTED' ? item.timestamp
: item.status === 'REVOKED' ? item.timestamp
: item.status === 'EXPIRED' ? item.timestamp
var itemStatus = statusUpper === 'EXPIRED' ? 'EXPIRED' : (item.status || '');
var ts = itemStatus === 'GRANTED' ? item.timestamp
: itemStatus === 'REVOKED' ? item.timestamp
: itemStatus === 'EXPIRED' ? (consent.expired_on || consent.erase_at || 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><span class="badge ' + consentItemBadgeClass(itemStatus) + '">' + escHtml(itemStatus || '') + '</span></td>'
+ '<td>' + fmtConsentTs(ts) + '</td>'
+ '</tr>';
});
@ -1288,9 +1294,23 @@ $(function() {
var html = '';
requests.forEach(function(consent, idx) {
var status = (consent.status || '').toString().toUpperCase();
var canFetch = (status === 'GRANTED' || status === 'COMPLETED');
var eraseAt = (consent.erase_at || '').toString().trim();
var isExpired = (status === 'EXPIRED');
if (!isExpired && eraseAt !== '') {
var ed = new Date(eraseAt.indexOf('T') === -1 ? eraseAt.replace(' ', 'T') : eraseAt);
if (!isNaN(ed.getTime()) && ed.getTime() <= Date.now()) {
isExpired = true;
status = 'EXPIRED';
}
}
var canFetch = (status === 'GRANTED' || status === 'COMPLETED') && !isExpired;
var rowId = consent.id || (idx + 1);
var reqId = (consent.consent_request_id || consent.consent_id || consent.request_id || '').toString().trim();
var printUrl = abdmPrintPdfUrl + (reqId ? ('?consent_request_id=' + encodeURIComponent(reqId)) : '');
var actionHtml = '<button type="button" class="btn btn-link btn-sm p-0 abdm-fetch-request-btn me-2" data-idx="' + idx + '">Show Data</button>'
+ '<a href="' + escHtml(printUrl) + '" target="_blank" class="btn btn-link btn-sm p-0 text-decoration-none" title="Print health records for this consent request"><i class="fa fa-print me-1"></i>Print</a>';
html += '<tr>'
+ '<td class="small">' + escHtml(rowId) + '</td>'
+ '<td><span class="badge ' + consentStatusBadgeClass(status) + '">' + escHtml(status || 'UNKNOWN') + '</span></td>'
@ -1299,7 +1319,7 @@ $(function() {
+ '<td class="small">' + fmtConsentTs(consent.valid_to) + '</td>'
+ '<td><button type="button" class="btn btn-link btn-sm p-0 abdm-view-request-btn" data-idx="' + idx + '">View</button></td>'
+ '<td class="text-end">'
+ (canFetch ? ('<button type="button" class="btn btn-link btn-sm p-0 abdm-fetch-request-btn" data-idx="' + idx + '">Show Data</button>') : '<span class="text-muted small">-</span>')
+ (canFetch ? actionHtml : '<span class="text-muted small">-</span>')
+ '</td>'
+ '</tr>';
});
@ -1344,6 +1364,21 @@ $(function() {
if (!consent) {
return;
}
var status = (consent.status || '').toString().toUpperCase();
var eraseAt = (consent.erase_at || '').toString().trim();
var isExpired = (status === 'EXPIRED');
if (!isExpired && eraseAt !== '') {
var ed = new Date(eraseAt.indexOf('T') === -1 ? eraseAt.replace(' ', 'T') : eraseAt);
if (!isNaN(ed.getTime()) && ed.getTime() <= Date.now()) {
isExpired = true;
}
}
if (isExpired) {
setAbdmStatus('This consent request has expired. Under ABDM guidelines, records cannot be fetched or accessed under an expired consent.', true);
return;
}
fetchOnlyRunningIdx = idx;
currentAbdmFetchModalIdx = idx;
applyNewRequestButtonState();
@ -1381,8 +1416,24 @@ $(function() {
}
function loadAbdmFetchResultModalData(consent) {
var reqId = (consent && consent.consent_request_id) ? consent.consent_request_id : '';
var reqId = (consent && (consent.consent_request_id || consent.consent_id || consent.request_id)) ? (consent.consent_request_id || consent.consent_id || consent.request_id) : '';
currentAbdmFetchConsentRequestId = reqId;
var status = (consent && consent.status ? consent.status : '').toString().toUpperCase();
var eraseAt = (consent && consent.erase_at ? consent.erase_at : '').toString().trim();
var isExpired = (status === 'EXPIRED');
if (!isExpired && eraseAt !== '') {
var ed = new Date(eraseAt.indexOf('T') === -1 ? eraseAt.replace(' ', 'T') : eraseAt);
if (!isNaN(ed.getTime()) && ed.getTime() <= Date.now()) {
isExpired = true;
}
}
if (isExpired) {
$('#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>');
return;
}
var url = abdmDocumentsUrl + '?limit=200&include_summary=1';
if (reqId !== '') {
url += '&consent_request_id=' + encodeURIComponent(reqId);
@ -1405,10 +1456,21 @@ $(function() {
function renderAbdmFetchResultModal(docs) {
if (!docs || !docs.length) {
$('#abdmPrintFetchedRecordsBtn').hide();
var fetchBtnHtml = currentAbdmFetchModalIdx >= 0
var activeConsent = currentAbdmFetchModalIdx >= 0 ? currentAbdmRequests[currentAbdmFetchModalIdx] : null;
var isModalConsentExpired = false;
if (activeConsent) {
var mStat = (activeConsent.status || '').toString().toUpperCase();
var mErase = (activeConsent.erase_at || '').toString().trim();
if (mStat === 'EXPIRED') { isModalConsentExpired = true; }
else if (mErase !== '') {
var mEd = new Date(mErase.indexOf('T') === -1 ? mErase.replace(' ', 'T') : mErase);
if (!isNaN(mEd.getTime()) && mEd.getTime() <= Date.now()) { isModalConsentExpired = true; }
}
}
var fetchBtnHtml = (currentAbdmFetchModalIdx >= 0 && !isModalConsentExpired)
? '<div class="text-center mt-2"><button type="button" class="btn btn-sm btn-primary abdm-fetch-again-btn" data-idx="' + currentAbdmFetchModalIdx + '">Fetch Data</button></div>'
: '';
$('#abdmFetchResultModalBody').html('<div class="text-muted small text-center">No fetched records found for this consent request yet.</div>' + fetchBtnHtml);
$('#abdmFetchResultModalBody').html('<div class="text-muted small text-center">No fetched records found for this consent request.</div>' + fetchBtnHtml);
return;
}