db = $db ?? db_connect(); $this->outboxModel = new AbdmSyncOutboxModel($this->db); } /** * Enqueue a deep-link SMS notification for a patient. * Takes < 1ms and operates strictly in local DB, so internet downtime never blocks caller. * * @param int $patientId patient_master ID * @param string $phoneNumber 10-digit mobile number * @param string $hipName Optional facility display name * @param string $trigger Trigger source event, e.g. 'patient.created' */ public function enqueue( int $patientId, string $phoneNumber, string $hipName = '', string $trigger = 'patient.created' ): ?int { if (! $this->db->tableExists('abdm_sync_outbox')) { return null; } $phone = preg_replace('/\D/', '', $phoneNumber); if (strlen($phone) < 10) { return null; } $phone = substr($phone, -10); if ($hipName === '' && $this->db->tableExists('hospital_setting')) { $hs = $this->db->table('hospital_setting')->where('s_name', 'H_name')->get(1)->getRowArray(); $hipName = trim((string) ($hs['s_value'] ?? '')); } // Idempotency key per patient & phone prevents duplicate spam on same day $idempotencyKey = 'sms_notify:' . $patientId . ':' . $phone . ':' . date('Ymd'); $payload = [ 'patient_id' => $patientId, 'phone_number' => $phone, 'hip_name' => $hipName, 'trigger' => $trigger, 'enqueued_at' => Time::now('Asia/Kolkata')->toDateTimeString(), ]; $payloadJson = (string) json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); if ($payloadJson === '' || $payloadJson === 'null') { return null; } $existing = $this->outboxModel->where('idempotency_key', $idempotencyKey)->first(); // If already completed today, don't re-queue if ($existing && ($existing['status'] ?? '') === self::STATUS_DONE) { return (int) $existing['id']; } $row = [ 'entity_type' => 'sms_notify', 'entity_id' => (string) $patientId, 'idempotency_key' => $idempotencyKey, 'payload_json' => $payloadJson, 'status' => self::STATUS_PENDING, 'next_retry_at' => null, 'last_error' => null, 'locked_at' => null, 'worker_id' => null, ]; if ($existing) { $this->outboxModel->update((int) $existing['id'], $row); return (int) $existing['id']; } $this->outboxModel->insert($row); $insertId = $this->outboxModel->getInsertID(); return $insertId > 0 ? (int) $insertId : null; } /** * Process pending and retryable SMS notify tasks from the outbox. * Gracefully catches network/internet issues and sets exponential backoff. * * @param int $limit Max records to process per run * @param string $workerId Identifier of the worker process * @param bool $force If true, ignores next_retry_at delay * @return array */ public function processBatch(int $limit = 20, string $workerId = 'cron-abdm-sms', bool $force = false): array { $summary = [ 'processed' => 0, 'sent' => 0, 'failed' => 0, 'dead' => 0, 'skipped' => 0, ]; if (! $this->db->tableExists('abdm_sync_outbox')) { return $summary; } $now = Time::now('Asia/Kolkata')->toDateTimeString(); $builder = $this->db->table('abdm_sync_outbox') ->where('entity_type', 'sms_notify') ->whereIn('status', [self::STATUS_PENDING, self::STATUS_FAILED]); if (! $force) { $builder->groupStart() ->where('next_retry_at IS NULL', null, false) ->orWhere('next_retry_at <=', $now) ->groupEnd(); } $rows = $builder->orderBy('id', 'ASC') ->limit(max(1, $limit)) ->get() ->getResultArray(); if (empty($rows)) { return $summary; } foreach ($rows as $row) { $id = (int) ($row['id'] ?? 0); if ($id <= 0) { $summary['skipped']++; continue; } // Atomic optimistic locking $locked = $this->db->table('abdm_sync_outbox') ->where('id', $id) ->whereIn('status', [self::STATUS_PENDING, self::STATUS_FAILED]) ->update([ 'status' => self::STATUS_IN_PROGRESS, 'locked_at' => $now, 'worker_id' => $workerId, ]); if (! $locked) { $summary['skipped']++; continue; } $summary['processed']++; $payload = json_decode((string) ($row['payload_json'] ?? '{}'), true); if (! is_array($payload)) { $payload = []; } $patientId = (int) ($payload['patient_id'] ?? $row['entity_id'] ?? 0); $phone = preg_replace('/\D/', '', (string) ($payload['phone_number'] ?? '')); if (strlen($phone) < 10) { $this->outboxModel->update($id, [ 'status' => self::STATUS_DEAD, 'last_error' => 'Invalid phone number in payload: ' . ($payload['phone_number'] ?? ''), 'locked_at' => null, 'worker_id' => null, ]); $summary['dead']++; continue; } $phone = substr($phone, -10); $hipName = trim((string) ($payload['hip_name'] ?? '')); if ($hipName === '' && $this->db->tableExists('hospital_setting')) { $hs = $this->db->table('hospital_setting')->where('s_name', 'H_name')->get(1)->getRowArray(); $hipName = trim((string) ($hs['s_value'] ?? '')); } $result = $this->dispatchSmsNotify($phone, $hipName, $patientId); if ((bool) ($result['ok'] ?? false)) { $this->outboxModel->update($id, [ 'status' => self::STATUS_DONE, 'next_retry_at' => null, 'last_error' => null, 'locked_at' => null, 'worker_id' => null, ]); // Also update corresponding abdm_work_tasks if present if ($patientId > 0 && $this->db->tableExists('abdm_work_tasks')) { $reqId = trim((string) ($result['request_id'] ?? '')); $workMsg = 'Deep-link SMS dispatched to ' . $phone . ($reqId !== '' ? ' (Req: ' . $reqId . ')' : ''); $this->db->table('abdm_work_tasks') ->where('patient_id', $patientId) ->where('task_type', 'patient_abha_create') ->update([ 'last_action_result' => $workMsg, 'updated_at' => Time::now('Asia/Kolkata')->toDateTimeString(), ]); } $summary['sent']++; continue; } // Failure handling — distinguish retryable vs dead $retryCount = ((int) ($row['retry_count'] ?? 0)) + 1; $isRetryable = (bool) ($result['retryable'] ?? true); $errMsg = (string) ($result['message'] ?? 'SMS dispatch failed'); if (! $isRetryable || $retryCount > count($this->retryScheduleSeconds)) { $this->outboxModel->update($id, [ 'status' => self::STATUS_DEAD, 'retry_count' => $retryCount, 'last_error' => mb_substr($errMsg, 0, 1000), 'locked_at' => null, 'worker_id' => null, ]); $summary['dead']++; } else { $delaySec = $this->retryScheduleSeconds[$retryCount - 1] ?? 1800; $nextRetryAt = date('Y-m-d H:i:s', time() + $delaySec); $this->outboxModel->update($id, [ 'status' => self::STATUS_FAILED, 'retry_count' => $retryCount, 'next_retry_at' => $nextRetryAt, 'last_error' => mb_substr($errMsg, 0, 1000), 'locked_at' => null, 'worker_id' => null, ]); $summary['failed']++; } } return $summary; } /** * Dispatch single SMS notify via configured connector. * * @return array{ok: bool, retryable: bool, message: string, request_id?: string} */ public function dispatchSmsNotify(string $phone, string $hipName, int $patientId = 0): array { try { $connector = AbdmConnectorFactory::make(); $response = $connector->hipSmsNotify([ 'phone_number' => $phone, 'hip_name' => $hipName, ]); $ok = (int) ($response['ok'] ?? 0); $httpCode = (int) ($response['http_code'] ?? 0); if ($ok === 1 || ($httpCode >= 200 && $httpCode < 300)) { return [ 'ok' => true, 'retryable' => false, 'request_id' => (string) ($response['request_id'] ?? ''), 'message' => (string) ($response['message'] ?? 'SMS notification accepted by ABDM Gateway.'), ]; } $errMsg = (string) ($response['error_text'] ?? $response['error'] ?? $response['message'] ?? 'ABDM SMS notification failed'); // 400 Bad Request is permanent; 500, 502, 503, 504, 0 (network down) are retryable $isRetryable = ! in_array($httpCode, [400, 404, 422], true); return [ 'ok' => false, 'retryable' => $isRetryable, 'message' => 'ABDM Error: ' . $errMsg . ' (HTTP ' . $httpCode . ')', ]; } catch (\Throwable $e) { return [ 'ok' => false, 'retryable' => true, 'message' => 'Network/Connection error (will retry): ' . $e->getMessage(), ]; } } /** * Scan recent patients registered without ABHA and queue them if not already present. */ public function backfillUnnotifiedPatients(int $limit = 50, int $daysBack = 7): int { if (! $this->db->tableExists('patient_master') || ! $this->db->tableExists('abdm_sync_outbox')) { return 0; } $cutoff = date('Y-m-d H:i:s', strtotime("-{$daysBack} days")); $builder = $this->db->table('patient_master') ->select('id, mphone1, p_fname') ->where('insert_date >=', $cutoff) ->groupStart() ->where('abha_id IS NULL', null, false) ->orWhere('abha_id', '') ->groupEnd() ->groupStart() ->where('abha_address IS NULL', null, false) ->orWhere('abha_address', '') ->groupEnd() ->where('mphone1 IS NOT NULL', null, false) ->where('mphone1 !=', '') ->orderBy('id', 'DESC') ->limit(max(1, $limit)); $patients = $builder->get()->getResultArray(); $enqueued = 0; foreach ($patients as $p) { $pid = (int) ($p['id'] ?? 0); $phone = preg_replace('/\D/', '', (string) ($p['mphone1'] ?? '')); if ($pid <= 0 || strlen($phone) < 10) { continue; } // Check if already in outbox $existing = $this->outboxModel ->where('entity_type', 'sms_notify') ->where('entity_id', (string) $pid) ->first(); if ($existing) { continue; } $id = $this->enqueue($pid, substr($phone, -10), '', 'backfill'); if ($id !== null) { $enqueued++; } } return $enqueued; } /** * Get queue statistics for monitoring. * * @return array */ public function getCounters(): array { $counts = [ 'pending' => 0, 'in_progress' => 0, 'done' => 0, 'failed' => 0, 'dead' => 0, ]; if (! $this->db->tableExists('abdm_sync_outbox')) { return $counts; } $rows = $this->db->table('abdm_sync_outbox') ->select('status, COUNT(*) as total') ->where('entity_type', 'sms_notify') ->groupBy('status') ->get() ->getResultArray(); foreach ($rows as $row) { $status = (string) ($row['status'] ?? ''); $total = (int) ($row['total'] ?? 0); if (array_key_exists($status, $counts)) { $counts[$status] += $total; } } return $counts; } }