From 39017a43cff3561943b6f7493d112abe63a22667 Mon Sep 17 00:00:00 2001 From: DevSoftTech Server Date: Fri, 9 Oct 2026 17:38:51 +0530 Subject: [PATCH] feat(abdm): enforce consent expiration, request-specific printing, and push sync enhancements --- app/Commands/AbdmPushSync.php | 29 +- app/Controllers/AbdmGateway.php | 60 ++-- app/Controllers/Patient.php | 277 +++++++++++++++++- .../Abdm/ConsentSessionListService.php | 109 +++++++ .../Abdm/Sync/AbdmTaskBoardSyncService.php | 199 +++++++------ app/Views/abdm/patient_request_list.php | 11 +- app/Views/billing/Patient_Profile_Opd_V.php | 92 +++++- 7 files changed, 649 insertions(+), 128 deletions(-) diff --git a/app/Commands/AbdmPushSync.php b/app/Commands/AbdmPushSync.php index 1bd9bd8..7d8b157 100644 --- a/app/Commands/AbdmPushSync.php +++ b/app/Commands/AbdmPushSync.php @@ -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, ]); diff --git a/app/Controllers/AbdmGateway.php b/app/Controllers/AbdmGateway.php index d65a542..2080f94 100644 --- a/app/Controllers/AbdmGateway.php +++ b/app/Controllers/AbdmGateway.php @@ -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, + ]); + } } } diff --git a/app/Controllers/Patient.php b/app/Controllers/Patient.php index 68de2f3..a116c62 100644 --- a/app/Controllers/Patient.php +++ b/app/Controllers/Patient.php @@ -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(); + } } + diff --git a/app/Libraries/Abdm/ConsentSessionListService.php b/app/Libraries/Abdm/ConsentSessionListService.php index 3a4d51b..e087ad7 100644 --- a/app/Libraries/Abdm/ConsentSessionListService.php +++ b/app/Libraries/Abdm/ConsentSessionListService.php @@ -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(); + } } + diff --git a/app/Libraries/Abdm/Sync/AbdmTaskBoardSyncService.php b/app/Libraries/Abdm/Sync/AbdmTaskBoardSyncService.php index 53dc926..c71670e 100644 --- a/app/Libraries/Abdm/Sync/AbdmTaskBoardSyncService.php +++ b/app/Libraries/Abdm/Sync/AbdmTaskBoardSyncService.php @@ -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; diff --git a/app/Views/abdm/patient_request_list.php b/app/Views/abdm/patient_request_list.php index 5cb5a8e..14c775f 100644 --- a/app/Views/abdm/patient_request_list.php +++ b/app/Views/abdm/patient_request_list.php @@ -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 += '' diff --git a/app/Views/billing/Patient_Profile_Opd_V.php b/app/Views/billing/Patient_Profile_Opd_V.php index 2e6ede9..54b7ed9 100644 --- a/app/Views/billing/Patient_Profile_Opd_V.php +++ b/app/Views/billing/Patient_Profile_Opd_V.php @@ -377,9 +377,6 @@ if ($patientPhotoPath === '') {
Consent Request History
- - Print All Records - ' + + 'Print'; + html += '' + '' + escHtml(rowId) + '' + '' + escHtml(status || 'UNKNOWN') + '' @@ -1299,7 +1319,7 @@ $(function() { + '' + fmtConsentTs(consent.valid_to) + '' + '' + '' - + (canFetch ? ('') : '-') + + (canFetch ? actionHtml : '-') + '' + ''; }); @@ -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('
This consent request expired on ' + escHtml(fmtConsentTs(eraseAt)) + '.
As per ABDM guidelines, health records are erased and no longer accessible.
'); + 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) ? '
' : ''; - $('#abdmFetchResultModalBody').html('
No fetched records found for this consent request yet.
' + fetchBtnHtml); + $('#abdmFetchResultModalBody').html('
No fetched records found for this consent request.
' + fetchBtnHtml); return; }