494 lines
21 KiB
PHP
494 lines
21 KiB
PHP
<?php
|
|
|
|
namespace App\Libraries\Abdm;
|
|
|
|
use CodeIgniter\I18n\Time;
|
|
|
|
class M3HiuDocumentRepository
|
|
{
|
|
private \CodeIgniter\Database\BaseConnection $db;
|
|
|
|
public function __construct()
|
|
{
|
|
$this->db = \Config\Database::connect();
|
|
}
|
|
|
|
public function persistFromDataFetch(array $payload, array $result, int $workflowId = 0): array
|
|
{
|
|
if (! $this->db->tableExists('abdm_hiu_documents')) {
|
|
return ['saved' => 0, 'updated' => 0, 'skipped' => 0];
|
|
}
|
|
|
|
$sessions = $this->extractSessions($result);
|
|
if (empty($sessions)) {
|
|
return ['saved' => 0, 'updated' => 0, 'skipped' => 0];
|
|
}
|
|
|
|
$this->ensureConsentRequestIdColumn();
|
|
|
|
$requestId = trim((string) ($result['request_id'] ?? $payload['request_id'] ?? $payload['requestId'] ?? ''));
|
|
$abhaAddress = trim((string) ($payload['abha_address'] ?? $result['abha_address'] ?? ''));
|
|
// The specific consent request (e.g. Method 1/2's targeted request) that
|
|
// triggered this fetch, when known. Method 3 (fetch-all-by-abha-address)
|
|
// spans many past consent requests at once, so this is intentionally left
|
|
// blank for that broad fetch rather than mis-attributing every document to
|
|
// whichever single request happened to be passed in.
|
|
$consentRequestId = trim((string) (
|
|
$payload['abdm_consent_request_id']
|
|
?? $payload['consent_request_id']
|
|
?? $payload['consentRequestId']
|
|
?? ''
|
|
));
|
|
|
|
$saved = 0;
|
|
$updated = 0;
|
|
$skipped = 0;
|
|
$now = Time::now('Asia/Kolkata')->toDateTimeString();
|
|
|
|
foreach ($sessions as $session) {
|
|
if (! is_array($session)) {
|
|
continue;
|
|
}
|
|
|
|
$transactionId = trim((string) ($session['transaction_id'] ?? ''));
|
|
$consentRef = trim((string) ($session['consent_id'] ?? ''));
|
|
$consentArtifactId = $this->extractConsentArtifactId($consentRef);
|
|
$records = is_array($session['decrypted_data'] ?? null) ? (array) $session['decrypted_data'] : [];
|
|
|
|
foreach ($records as $record) {
|
|
if (! is_array($record)) {
|
|
$skipped++;
|
|
continue;
|
|
}
|
|
|
|
$careContextRef = trim((string) ($record['careContextReference'] ?? ''));
|
|
$media = trim((string) ($record['media'] ?? ''));
|
|
$bundle = $this->decodeBundle($record['decrypted_data'] ?? null);
|
|
if (! is_array($bundle) || empty($bundle)) {
|
|
$skipped++;
|
|
continue;
|
|
}
|
|
|
|
$summary = $this->buildSummary($bundle);
|
|
$resolvedAbha = $abhaAddress !== '' ? $abhaAddress : trim((string) ($summary['abha_address'] ?? ''));
|
|
$patientMap = $this->mapPatientByAbha($resolvedAbha, (string) ($summary['abha_number'] ?? ''));
|
|
|
|
// NOTE: transaction_id and consent_ref are intentionally EXCLUDED
|
|
// from the dedup key. Confirmed empirically: the SAME clinical
|
|
// document (identical bundle_id) gets re-delivered by the bridge
|
|
// under a DIFFERENT transaction_id/consent_ref every time it is
|
|
// re-fetched (e.g. via the by-ABHA-address historical fetch, which
|
|
// returns every past consent session), so including them created a
|
|
// brand-new duplicate row per fetch instead of updating the
|
|
// existing one. bundle_id is the FHIR Bundle resource's own `id`
|
|
// (set once by the HIP) and is stable across re-fetches/consents,
|
|
// so pair it with care_context_reference (the clinical encounter
|
|
// reference) to uniquely identify a document. request_id is also
|
|
// excluded since it is unique per polling/fetch call.
|
|
$bundleId = trim((string) ($summary['bundle_id'] ?? ''));
|
|
$docHash = $bundleId !== ''
|
|
? sha1('bundle:' . $careContextRef . '|' . $bundleId)
|
|
: sha1(implode('|', [
|
|
$transactionId,
|
|
$consentRef,
|
|
$careContextRef,
|
|
(string) ($summary['bundle_id'] ?? ''),
|
|
]));
|
|
|
|
$row = [
|
|
'workflow_id' => $workflowId > 0 ? $workflowId : null,
|
|
'request_id' => $requestId !== '' ? $requestId : null,
|
|
'transaction_id' => $transactionId !== '' ? $transactionId : null,
|
|
'consent_ref' => $consentRef !== '' ? $consentRef : null,
|
|
'consent_artifact_id' => $consentArtifactId !== '' ? $consentArtifactId : null,
|
|
'abha_address' => $resolvedAbha !== '' ? $resolvedAbha : null,
|
|
'patient_id' => (int) ($patientMap['patient_id'] ?? 0) > 0 ? (int) $patientMap['patient_id'] : null,
|
|
'patient_name' => trim((string) ($summary['patient_name'] ?? '')) !== '' ? trim((string) ($summary['patient_name'] ?? '')) : null,
|
|
'care_context_reference' => $careContextRef !== '' ? $careContextRef : null,
|
|
'media' => $media !== '' ? $media : null,
|
|
'bundle_id' => trim((string) ($summary['bundle_id'] ?? '')) !== '' ? trim((string) ($summary['bundle_id'] ?? '')) : null,
|
|
'bundle_type' => trim((string) ($summary['bundle_type'] ?? '')) !== '' ? trim((string) ($summary['bundle_type'] ?? '')) : null,
|
|
'document_title' => trim((string) ($summary['document_title'] ?? '')) !== '' ? trim((string) ($summary['document_title'] ?? '')) : null,
|
|
'document_date' => $this->toDateTimeString((string) ($summary['document_date'] ?? '')),
|
|
'practitioner_name' => trim((string) ($summary['practitioner_name'] ?? '')) !== '' ? trim((string) ($summary['practitioner_name'] ?? '')) : null,
|
|
'organization_name' => trim((string) ($summary['organization_name'] ?? '')) !== '' ? trim((string) ($summary['organization_name'] ?? '')) : null,
|
|
'summary_json' => json_encode($summary, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
|
'raw_bundle' => json_encode($bundle, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
|
'updated_at' => $now,
|
|
];
|
|
|
|
// Only set (and never blank out on update) when this specific fetch
|
|
// actually knew which consent request it belonged to.
|
|
if ($consentRequestId !== '') {
|
|
$row['consent_request_id'] = $consentRequestId;
|
|
}
|
|
|
|
$existing = $this->db->table('abdm_hiu_documents')
|
|
->select('id')
|
|
->where('doc_hash', $docHash)
|
|
->get(1)
|
|
->getRowArray();
|
|
|
|
if (! empty($existing)) {
|
|
$this->db->table('abdm_hiu_documents')->where('id', (int) $existing['id'])->update($row);
|
|
$updated++;
|
|
continue;
|
|
}
|
|
|
|
$row['doc_hash'] = $docHash;
|
|
$row['created_at'] = $now;
|
|
$this->db->table('abdm_hiu_documents')->insert($row);
|
|
$saved++;
|
|
}
|
|
}
|
|
|
|
return ['saved' => $saved, 'updated' => $updated, 'skipped' => $skipped];
|
|
}
|
|
|
|
/**
|
|
* Idempotent backfill so deployments that predate the Consent Request ID
|
|
* enrichment (added 2026-07-29) don't fail with "Unknown column" on save.
|
|
*/
|
|
private function ensureConsentRequestIdColumn(): void
|
|
{
|
|
$fields = $this->db->getFieldNames('abdm_hiu_documents') ?? [];
|
|
if (in_array('consent_request_id', $fields, true)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$this->db->query('ALTER TABLE abdm_hiu_documents ADD COLUMN consent_request_id VARCHAR(190) NULL AFTER consent_artifact_id');
|
|
} catch (\Throwable $e) {
|
|
// Best-effort; if the ALTER fails (e.g. concurrent request already
|
|
// added it, or insufficient DB privileges) just skip enrichment for
|
|
// this call rather than breaking the fetch.
|
|
}
|
|
}
|
|
|
|
public function listDocuments(array $filters, int $limit = 100): array
|
|
{
|
|
if (! $this->db->tableExists('abdm_hiu_documents')) {
|
|
return [];
|
|
}
|
|
|
|
$builder = $this->db->table('abdm_hiu_documents')
|
|
->select('*')
|
|
->orderBy('id', 'DESC')
|
|
->limit($limit);
|
|
|
|
$patientId = (int) ($filters['patient_id'] ?? 0);
|
|
if ($patientId > 0) {
|
|
$builder->where('patient_id', $patientId);
|
|
}
|
|
|
|
$abhaAddress = trim((string) ($filters['abha_address'] ?? ''));
|
|
if ($abhaAddress !== '') {
|
|
$builder->where('abha_address', $abhaAddress);
|
|
}
|
|
|
|
$q = trim((string) ($filters['q'] ?? ''));
|
|
if ($q !== '') {
|
|
$builder->groupStart()
|
|
->like('patient_name', $q)
|
|
->orLike('care_context_reference', $q)
|
|
->orLike('document_title', $q)
|
|
->orLike('practitioner_name', $q)
|
|
->orLike('organization_name', $q)
|
|
->groupEnd();
|
|
}
|
|
|
|
return $builder->get()->getResultArray();
|
|
}
|
|
|
|
public function getDocument(int $id): ?array
|
|
{
|
|
if ($id <= 0 || ! $this->db->tableExists('abdm_hiu_documents')) {
|
|
return null;
|
|
}
|
|
|
|
$row = $this->db->table('abdm_hiu_documents')->where('id', $id)->get(1)->getRowArray();
|
|
if (empty($row)) {
|
|
return null;
|
|
}
|
|
|
|
$row['summary'] = json_decode((string) ($row['summary_json'] ?? ''), true) ?: [];
|
|
$row['bundle'] = json_decode((string) ($row['raw_bundle'] ?? ''), true) ?: [];
|
|
|
|
// Documents saved before attachment extraction was added won't have
|
|
// 'attachments' in their stored summary_json; derive it live from the
|
|
// raw bundle instead of requiring a brand-new fetch.
|
|
if (empty($row['summary']['attachments']) && ! empty($row['bundle'])) {
|
|
$row['summary']['attachments'] = $this->extractAttachments($row['bundle']);
|
|
}
|
|
|
|
return $row;
|
|
}
|
|
|
|
private function extractSessions(array $result): array
|
|
{
|
|
$sessions = $result['sessions'] ?? null;
|
|
if (is_array($sessions)) {
|
|
return $sessions;
|
|
}
|
|
|
|
$nested = $result['data']['sessions'] ?? null;
|
|
return is_array($nested) ? $nested : [];
|
|
}
|
|
|
|
private function extractConsentArtifactId(string $consentRef): string
|
|
{
|
|
$consentRef = trim($consentRef);
|
|
if ($consentRef === '') {
|
|
return '';
|
|
}
|
|
$parts = explode(':', $consentRef);
|
|
return trim((string) ($parts[0] ?? ''));
|
|
}
|
|
|
|
private function decodeBundle($raw): array
|
|
{
|
|
if (is_array($raw)) {
|
|
return $raw;
|
|
}
|
|
|
|
$text = trim((string) $raw);
|
|
if ($text === '') {
|
|
return [];
|
|
}
|
|
|
|
$decoded = json_decode($text, true);
|
|
return is_array($decoded) ? $decoded : [];
|
|
}
|
|
|
|
private function buildSummary(array $bundle): array
|
|
{
|
|
$entries = is_array($bundle['entry'] ?? null) ? (array) $bundle['entry'] : [];
|
|
$patient = [];
|
|
$composition = [];
|
|
$practitioner = [];
|
|
$organization = [];
|
|
$conditions = [];
|
|
$vitals = [];
|
|
$medications = [];
|
|
|
|
foreach ($entries as $entry) {
|
|
$resource = is_array($entry['resource'] ?? null) ? (array) $entry['resource'] : [];
|
|
$type = trim((string) ($resource['resourceType'] ?? ''));
|
|
if ($type === '') {
|
|
continue;
|
|
}
|
|
|
|
if ($type === 'Patient' && empty($patient)) {
|
|
$patient = $resource;
|
|
} elseif ($type === 'Composition' && empty($composition)) {
|
|
$composition = $resource;
|
|
} elseif ($type === 'Practitioner' && empty($practitioner)) {
|
|
$practitioner = $resource;
|
|
} elseif ($type === 'Organization' && empty($organization)) {
|
|
$organization = $resource;
|
|
} elseif ($type === 'Condition') {
|
|
$conditions[] = [
|
|
'text' => trim((string) (($resource['code']['text'] ?? '') ?: ($resource['code']['coding'][0]['display'] ?? ''))),
|
|
];
|
|
} elseif ($type === 'Observation') {
|
|
$codeText = trim((string) (($resource['code']['text'] ?? '') ?: ($resource['code']['coding'][0]['display'] ?? '')));
|
|
$val = '';
|
|
if (isset($resource['valueQuantity']) && is_array($resource['valueQuantity'])) {
|
|
$val = trim((string) (($resource['valueQuantity']['value'] ?? '') . ' ' . ($resource['valueQuantity']['unit'] ?? '')));
|
|
}
|
|
if ($codeText !== '' || $val !== '') {
|
|
$vitals[] = ['name' => $codeText, 'value' => $val];
|
|
}
|
|
} elseif ($type === 'MedicationRequest') {
|
|
$medications[] = [
|
|
'name' => trim((string) (($resource['medicationCodeableConcept']['text'] ?? '') ?: ($resource['medicationCodeableConcept']['coding'][0]['display'] ?? ''))),
|
|
'dose' => trim((string) ($resource['dosageInstruction'][0]['text'] ?? '')),
|
|
];
|
|
}
|
|
}
|
|
|
|
$patientName = trim((string) (($patient['name'][0]['text'] ?? '') ?: implode(' ', (array) ($patient['name'][0]['given'] ?? []))));
|
|
$abhaNumber = '';
|
|
foreach ((array) ($patient['identifier'] ?? []) as $identifier) {
|
|
$system = trim((string) ($identifier['system'] ?? ''));
|
|
if (stripos($system, 'healthid.ndhm.gov.in') !== false) {
|
|
$abhaNumber = preg_replace('/\D+/', '', (string) ($identifier['value'] ?? '')) ?? '';
|
|
break;
|
|
}
|
|
}
|
|
|
|
$practitionerName = trim((string) (($practitioner['name'][0]['text'] ?? '') ?: implode(' ', (array) ($practitioner['name'][0]['given'] ?? []))));
|
|
$organizationName = trim((string) ($organization['name'] ?? ''));
|
|
|
|
return [
|
|
'bundle_id' => trim((string) ($bundle['id'] ?? '')),
|
|
'bundle_type' => trim((string) (($bundle['resourceType'] ?? '') . '/' . ($bundle['type'] ?? ''))),
|
|
'document_title' => trim((string) ($composition['title'] ?? '')),
|
|
'document_date' => trim((string) ($composition['date'] ?? $bundle['timestamp'] ?? '')),
|
|
'patient_name' => $patientName,
|
|
'patient_gender' => trim((string) ($patient['gender'] ?? '')),
|
|
'patient_birth_date' => trim((string) ($patient['birthDate'] ?? '')),
|
|
'abha_number' => $abhaNumber,
|
|
'abha_address' => $abhaNumber !== '' ? ($abhaNumber . '@sbx') : '',
|
|
'practitioner_name' => $practitionerName,
|
|
'organization_name' => $organizationName,
|
|
'conditions' => $conditions,
|
|
'vitals' => $vitals,
|
|
'medications' => $medications,
|
|
'attachments' => $this->extractAttachments($bundle),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Extracts attached scanned images/PDFs/generic documents (DocumentReference
|
|
* resources, whose content may carry the base64 attachment data inline or
|
|
* reference a separate Binary resource) from a FHIR bundle.
|
|
*
|
|
* @return array<int, array{title: string, content_type: string, data: string}>
|
|
*/
|
|
public function extractAttachments(array $bundle): array
|
|
{
|
|
$entries = is_array($bundle['entry'] ?? null) ? (array) $bundle['entry'] : [];
|
|
$documentReferences = [];
|
|
|
|
// Binary resources are usually referenced by DocumentReference.content[].attachment.url
|
|
// (e.g. "Binary/xyz" or a matching fullUrl) rather than carrying the base64 data inline,
|
|
// so index them by both id and fullUrl for lookup in the second pass below.
|
|
$binariesById = [];
|
|
foreach ($entries as $entry) {
|
|
$resource = is_array($entry['resource'] ?? null) ? (array) $entry['resource'] : [];
|
|
if (trim((string) ($resource['resourceType'] ?? '')) !== 'Binary') {
|
|
continue;
|
|
}
|
|
$data = trim((string) ($resource['data'] ?? ''));
|
|
$contentType = trim((string) ($resource['contentType'] ?? ''));
|
|
if ($data === '') {
|
|
continue;
|
|
}
|
|
$binary = ['data' => $data, 'content_type' => $contentType];
|
|
$id = trim((string) ($resource['id'] ?? ''));
|
|
if ($id !== '') {
|
|
$binariesById[$id] = $binary;
|
|
}
|
|
$fullUrl = trim((string) ($entry['fullUrl'] ?? ''));
|
|
if ($fullUrl !== '') {
|
|
$binariesById[$fullUrl] = $binary;
|
|
}
|
|
}
|
|
|
|
foreach ($entries as $entry) {
|
|
$resource = is_array($entry['resource'] ?? null) ? (array) $entry['resource'] : [];
|
|
if (trim((string) ($resource['resourceType'] ?? '')) !== 'DocumentReference') {
|
|
continue;
|
|
}
|
|
|
|
foreach ((array) ($resource['content'] ?? []) as $content) {
|
|
$attachment = is_array($content['attachment'] ?? null) ? (array) $content['attachment'] : [];
|
|
if ($attachment === []) {
|
|
continue;
|
|
}
|
|
|
|
$contentType = trim((string) ($attachment['contentType'] ?? ''));
|
|
$data = trim((string) ($attachment['data'] ?? ''));
|
|
if ($data === '') {
|
|
$refUrl = trim((string) ($attachment['url'] ?? ''));
|
|
$refId = str_replace('Binary/', '', $refUrl);
|
|
$binary = $binariesById[$refUrl] ?? $binariesById[$refId] ?? null;
|
|
if (is_array($binary)) {
|
|
$data = (string) ($binary['data'] ?? '');
|
|
if ($contentType === '') {
|
|
$contentType = (string) ($binary['content_type'] ?? '');
|
|
}
|
|
}
|
|
}
|
|
|
|
if ($data === '') {
|
|
continue;
|
|
}
|
|
|
|
$documentReferences[] = [
|
|
'title' => trim((string) (
|
|
($resource['description'] ?? '')
|
|
?: ($resource['type']['text'] ?? '')
|
|
?: ($resource['type']['coding'][0]['display'] ?? '')
|
|
?: ($attachment['title'] ?? 'Attached Document')
|
|
)),
|
|
'content_type' => $contentType !== '' ? $contentType : 'application/octet-stream',
|
|
'data' => $data,
|
|
];
|
|
}
|
|
}
|
|
|
|
return $documentReferences;
|
|
}
|
|
|
|
private function mapPatientByAbha(string $abhaAddress, string $abhaNumber): array
|
|
{
|
|
if (! $this->db->tableExists('patient_master')) {
|
|
return ['patient_id' => 0];
|
|
}
|
|
|
|
$fields = $this->db->getFieldNames('patient_master') ?? [];
|
|
$idCol = $this->resolveExistingColumn($fields, ['id']);
|
|
if ($idCol === null) {
|
|
return ['patient_id' => 0];
|
|
}
|
|
|
|
$abhaAddressCol = $this->resolveExistingColumn($fields, ['abha_address', 'abha_addr']);
|
|
$abhaNumberCol = $this->resolveExistingColumn($fields, ['abha_id', 'abha_no', 'abha_number', 'abha']);
|
|
|
|
if ($abhaAddress !== '' && $abhaAddressCol !== null) {
|
|
$row = $this->db->table('patient_master')
|
|
->select($idCol . ' AS patient_id')
|
|
->where($abhaAddressCol, $abhaAddress)
|
|
->orderBy($idCol, 'DESC')
|
|
->get(1)
|
|
->getRowArray();
|
|
if (! empty($row)) {
|
|
return ['patient_id' => (int) ($row['patient_id'] ?? 0)];
|
|
}
|
|
}
|
|
|
|
$digits = preg_replace('/\D+/', '', $abhaNumber) ?? '';
|
|
if ($digits !== '' && $abhaNumberCol !== null) {
|
|
$row = $this->db->table('patient_master')
|
|
->select($idCol . ' AS patient_id')
|
|
->where($abhaNumberCol, $digits)
|
|
->orderBy($idCol, 'DESC')
|
|
->get(1)
|
|
->getRowArray();
|
|
if (! empty($row)) {
|
|
return ['patient_id' => (int) ($row['patient_id'] ?? 0)];
|
|
}
|
|
}
|
|
|
|
return ['patient_id' => 0];
|
|
}
|
|
|
|
private function resolveExistingColumn(array $fields, array $candidates): ?string
|
|
{
|
|
foreach ($candidates as $candidate) {
|
|
if (in_array($candidate, $fields, true)) {
|
|
return $candidate;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private function toDateTimeString(string $value): ?string
|
|
{
|
|
$value = trim($value);
|
|
if ($value === '') {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
$dt = new \DateTime($value);
|
|
return $dt->format('Y-m-d H:i:s');
|
|
} catch (\Throwable $e) {
|
|
return null;
|
|
}
|
|
}
|
|
}
|