Gate legacy bridge events by ABHA verification and add cleanup command
This commit is contained in:
parent
b7f789b1c3
commit
3c6121a4ad
39
app/Commands/BridgeCleanupAbhaVerified.php
Normal file
39
app/Commands/BridgeCleanupAbhaVerified.php
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Commands;
|
||||||
|
|
||||||
|
use App\Libraries\BridgeSyncService;
|
||||||
|
use CodeIgniter\CLI\BaseCommand;
|
||||||
|
use CodeIgniter\CLI\CLI;
|
||||||
|
|
||||||
|
class BridgeCleanupAbhaVerified extends BaseCommand
|
||||||
|
{
|
||||||
|
protected $group = 'Bridge';
|
||||||
|
protected $name = 'bridge:cleanup-abha-verified';
|
||||||
|
protected $description = 'Mark legacy bridge queue rows as skipped when event requires ABHA-verified patient but patient is not verified.';
|
||||||
|
protected $usage = 'bridge:cleanup-abha-verified [--limit 500] [--dry-run]';
|
||||||
|
protected $arguments = [];
|
||||||
|
protected $options = [
|
||||||
|
'--limit' => 'Maximum rows to inspect (default: 500).',
|
||||||
|
'--dry-run' => 'Show what would be skipped without updating rows.',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function run(array $params)
|
||||||
|
{
|
||||||
|
$limit = (int) (CLI::getOption('limit') ?? 500);
|
||||||
|
if ($limit <= 0) {
|
||||||
|
$limit = 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
$dryRun = CLI::getOption('dry-run') !== null;
|
||||||
|
|
||||||
|
$service = new BridgeSyncService();
|
||||||
|
$summary = $service->cleanupAbhaVerifiedOnlyQueue($limit, $dryRun);
|
||||||
|
|
||||||
|
CLI::write('Bridge ABHA Cleanup Summary' . ($dryRun ? ' (dry-run)' : ''), 'yellow');
|
||||||
|
CLI::write('Scanned: ' . (int) ($summary['scanned'] ?? 0));
|
||||||
|
CLI::write('Marked skipped: ' . (int) ($summary['marked_skipped'] ?? 0), 'green');
|
||||||
|
CLI::write('Kept: ' . (int) ($summary['kept'] ?? 0));
|
||||||
|
CLI::write('Decode errors: ' . (int) ($summary['decode_errors'] ?? 0), ((int) ($summary['decode_errors'] ?? 0) > 0 ? 'red' : 'green'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,13 @@ use CodeIgniter\I18n\Time;
|
||||||
|
|
||||||
class BridgeSyncService
|
class BridgeSyncService
|
||||||
{
|
{
|
||||||
|
private const ABHA_VERIFIED_ONLY_EVENT_TYPES = [
|
||||||
|
'opd.fhir.generated',
|
||||||
|
'patient.created',
|
||||||
|
'patient.updated',
|
||||||
|
'ipd.discharge.updated',
|
||||||
|
];
|
||||||
|
|
||||||
private BaseConnection $db;
|
private BaseConnection $db;
|
||||||
private BridgeSyncQueueModel $queueModel;
|
private BridgeSyncQueueModel $queueModel;
|
||||||
|
|
||||||
|
|
@ -26,6 +33,10 @@ class BridgeSyncService
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (! $this->canEnqueueEventForPatient($eventType, $payload)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
$payloadJson = (string) json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
$payloadJson = (string) json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
if ($payloadJson === '' || $payloadJson === 'null') {
|
if ($payloadJson === '' || $payloadJson === 'null') {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -48,6 +59,111 @@ class BridgeSyncService
|
||||||
return $insertId > 0 ? (int) $insertId : null;
|
return $insertId > 0 ? (int) $insertId : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
*/
|
||||||
|
private function canEnqueueEventForPatient(string $eventType, array $payload): bool
|
||||||
|
{
|
||||||
|
if (! in_array($eventType, self::ABHA_VERIFIED_ONLY_EVENT_TYPES, true)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$patientId = $this->resolvePatientIdForVerifiedOnlyEvent($eventType, $payload);
|
||||||
|
if ($patientId <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->isPatientAbhaVerified($patientId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
*/
|
||||||
|
private function resolvePatientIdForVerifiedOnlyEvent(string $eventType, array $payload): int
|
||||||
|
{
|
||||||
|
$patientId = (int) ($payload['patient_id'] ?? 0);
|
||||||
|
if ($patientId > 0) {
|
||||||
|
return $patientId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($eventType === 'opd.fhir.generated') {
|
||||||
|
$opdId = (int) ($payload['opd_id'] ?? 0);
|
||||||
|
if ($opdId <= 0 && isset($payload['bundle_json']) && is_array($payload['bundle_json'])) {
|
||||||
|
$opdId = (int) ($payload['bundle_json']['opd_id'] ?? 0);
|
||||||
|
}
|
||||||
|
if ($opdId > 0 && $this->db->tableExists('opd_master')) {
|
||||||
|
$row = $this->db->table('opd_master')
|
||||||
|
->select('p_id')
|
||||||
|
->where('id', $opdId)
|
||||||
|
->get(1)
|
||||||
|
->getRowArray();
|
||||||
|
|
||||||
|
return (int) ($row['p_id'] ?? 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isPatientAbhaVerified(int $patientId): bool
|
||||||
|
{
|
||||||
|
if ($patientId <= 0 || ! $this->db->tableExists('patient_master')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$allFields = $this->db->getFieldNames('patient_master') ?? [];
|
||||||
|
$selectFields = [];
|
||||||
|
foreach (['abha_verified_status', 'abha_kyc_verified', 'abha_mobile_verified', 'abha_id', 'abha_no', 'abha', 'abha_address'] as $field) {
|
||||||
|
if (in_array($field, $allFields, true)) {
|
||||||
|
$selectFields[] = $field;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($selectFields)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$row = $this->db->table('patient_master')
|
||||||
|
->select(implode(',', $selectFields))
|
||||||
|
->where('id', $patientId)
|
||||||
|
->get(1)
|
||||||
|
->getRowArray();
|
||||||
|
|
||||||
|
if (! is_array($row) || $row === []) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$abhaLinked = false;
|
||||||
|
foreach (['abha_id', 'abha_no', 'abha', 'abha_address'] as $field) {
|
||||||
|
if (array_key_exists($field, $row) && trim((string) $row[$field]) !== '') {
|
||||||
|
$abhaLinked = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $abhaLinked) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$verifiedStatus = strtoupper(trim((string) ($row['abha_verified_status'] ?? '')));
|
||||||
|
if (in_array($verifiedStatus, ['1', 'VERIFIED', 'YES', 'Y', 'TRUE'], true)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$kycVerified = (int) ($row['abha_kyc_verified'] ?? 0) === 1;
|
||||||
|
$mobileVerified = (int) ($row['abha_mobile_verified'] ?? 0) === 1;
|
||||||
|
if ($kycVerified && $mobileVerified) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backward-compat fallback for legacy schemas without verification meta columns.
|
||||||
|
$hasVerificationMeta = in_array('abha_verified_status', $allFields, true)
|
||||||
|
|| in_array('abha_kyc_verified', $allFields, true)
|
||||||
|
|| in_array('abha_mobile_verified', $allFields, true);
|
||||||
|
|
||||||
|
return ! $hasVerificationMeta && $abhaLinked;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
|
|
@ -124,10 +240,24 @@ class BridgeSyncService
|
||||||
|
|
||||||
$dispatch = $this->buildDispatchContext($row, $payload);
|
$dispatch = $this->buildDispatchContext($row, $payload);
|
||||||
if (($dispatch['ok'] ?? false) !== true) {
|
if (($dispatch['ok'] ?? false) !== true) {
|
||||||
|
$reason = trim((string) ($dispatch['error'] ?? 'No dispatch configuration'));
|
||||||
|
|
||||||
|
if ($dispatch['terminal'] ?? false) {
|
||||||
|
// No real destination for this event type — skip permanently, don't retry/error.
|
||||||
|
$this->db->table('bridge_sync_queue')->where('id', $queueId)->update([
|
||||||
|
'status' => 'skipped',
|
||||||
|
'next_attempt_at' => null,
|
||||||
|
'last_error' => mb_substr($reason, 0, 500),
|
||||||
|
'locked_at' => null,
|
||||||
|
'locked_by' => null,
|
||||||
|
]);
|
||||||
|
$summary['skipped']++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
$nextAttempts = $attempts + 1;
|
$nextAttempts = $attempts + 1;
|
||||||
$isFinalFailure = $nextAttempts >= $maxAttempts;
|
$isFinalFailure = $nextAttempts >= $maxAttempts;
|
||||||
$delaySeconds = min(3600, (int) pow(2, min(10, $nextAttempts)) * 30);
|
$delaySeconds = min(3600, (int) pow(2, min(10, $nextAttempts)) * 30);
|
||||||
$reason = trim((string) ($dispatch['error'] ?? 'No dispatch configuration'));
|
|
||||||
|
|
||||||
$this->db->table('bridge_sync_queue')->where('id', $queueId)->update([
|
$this->db->table('bridge_sync_queue')->where('id', $queueId)->update([
|
||||||
'status' => $isFinalFailure ? 'failed' : 'retry',
|
'status' => $isFinalFailure ? 'failed' : 'retry',
|
||||||
|
|
@ -230,6 +360,64 @@ class BridgeSyncService
|
||||||
return $summary;
|
return $summary;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleanup helper for historical rows created before ABHA verification gating.
|
||||||
|
*
|
||||||
|
* @return array<string, int>
|
||||||
|
*/
|
||||||
|
public function cleanupAbhaVerifiedOnlyQueue(int $limit = 500, bool $dryRun = false): array
|
||||||
|
{
|
||||||
|
$summary = [
|
||||||
|
'scanned' => 0,
|
||||||
|
'marked_skipped' => 0,
|
||||||
|
'kept' => 0,
|
||||||
|
'decode_errors' => 0,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (! $this->db->tableExists('bridge_sync_queue')) {
|
||||||
|
return $summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = $this->db->table('bridge_sync_queue')
|
||||||
|
->select('id,event_type,payload_json,status')
|
||||||
|
->whereIn('event_type', self::ABHA_VERIFIED_ONLY_EVENT_TYPES)
|
||||||
|
->whereIn('status', ['pending', 'retry', 'failed'])
|
||||||
|
->orderBy('id', 'ASC')
|
||||||
|
->limit(max(1, $limit))
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$summary['scanned']++;
|
||||||
|
$eventType = (string) ($row['event_type'] ?? '');
|
||||||
|
$payload = json_decode((string) ($row['payload_json'] ?? ''), true);
|
||||||
|
if (! is_array($payload)) {
|
||||||
|
$summary['decode_errors']++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->canEnqueueEventForPatient($eventType, $payload)) {
|
||||||
|
$summary['kept']++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $dryRun) {
|
||||||
|
$this->db->table('bridge_sync_queue')
|
||||||
|
->where('id', (int) ($row['id'] ?? 0))
|
||||||
|
->update([
|
||||||
|
'status' => 'skipped',
|
||||||
|
'next_attempt_at' => null,
|
||||||
|
'locked_at' => null,
|
||||||
|
'locked_by' => null,
|
||||||
|
'last_error' => 'Skipped by ABHA verified-only cleanup',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
$summary['marked_skipped']++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $summary;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $requestPayload
|
* @param array<string, mixed> $requestPayload
|
||||||
*/
|
*/
|
||||||
|
|
@ -358,7 +546,15 @@ class BridgeSyncService
|
||||||
return $this->buildAbdmBridgeDispatchContext($row, $payload);
|
return $this->buildAbdmBridgeDispatchContext($row, $payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->buildBridgeDispatchContext($row, $payload, 'bridge');
|
// Only snomed.*/abdm.*/nhcx. events have a real bridge destination configured
|
||||||
|
// (CSNOtk only implements the SNOMED terminology endpoint on /api/bridge and
|
||||||
|
// 404s on generic app events like ipd.*/patient.*/lab.* — see bridge debug logs).
|
||||||
|
// Mark these as a terminal skip instead of retrying/erroring forever.
|
||||||
|
return [
|
||||||
|
'ok' => false,
|
||||||
|
'terminal' => true,
|
||||||
|
'error' => 'No bridge destination configured for event type "' . $eventType . '" (only snomed.*, abdm.*, nhcx.* events are routed)',
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user