Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
0.00% |
0 / 103 |
|
0.00% |
0 / 3 |
CRAP | |
0.00% |
0 / 1 |
| BackfillEmailStatus | |
0.00% |
0 / 103 |
|
0.00% |
0 / 3 |
930 | |
0.00% |
0 / 1 |
| handle | |
0.00% |
0 / 72 |
|
0.00% |
0 / 1 |
420 | |||
| aggregateFinalStatus | |
0.00% |
0 / 12 |
|
0.00% |
0 / 1 |
30 | |||
| appendEventToWebhook | |
0.00% |
0 / 19 |
|
0.00% |
0 / 1 |
30 | |||
| 1 | <?php |
| 2 | |
| 3 | namespace App\Console\Commands; |
| 4 | |
| 5 | use App\Models\TblQuotations; |
| 6 | use App\Models\TblSendgridWebhook; |
| 7 | use Illuminate\Console\Command; |
| 8 | use Illuminate\Support\Facades\Http; |
| 9 | use Illuminate\Support\Facades\Log; |
| 10 | |
| 11 | /** |
| 12 | * FIRE-864 — re-polls SendGrid's Activity API for every quotation whose |
| 13 | * tracked status is still "processed" (or missing) and updates the local |
| 14 | * record with the real final status. |
| 15 | * |
| 16 | * Runs on a schedule (see Kernel.php) so the bug Jorge reported — emails |
| 17 | * that stay at "processed" forever when the webhook fails to deliver — |
| 18 | * gets corrected automatically. Also updates budget_status_id to 22 |
| 19 | * ("Correo erróneo") when SendGrid confirms a bounce/drop. |
| 20 | * |
| 21 | * Kept separate from clear:email-processing because: |
| 22 | * - clear:email-processing only handles rows where x_status is NOT NULL |
| 23 | * and != 'Completed'. It ignores NULL x_status rows where the webhook |
| 24 | * never fired at all. |
| 25 | * - This command also targets those NULL rows, and handles the one-time |
| 26 | * cleanup Jorge asked for (limpiar todos los emails en processed). |
| 27 | * |
| 28 | * Safe to run repeatedly: fetches at most --limit rows per run (default |
| 29 | * 200) to keep API usage bounded. Rate-limits itself to avoid tripping |
| 30 | * SendGrid's Activity API quotas. |
| 31 | */ |
| 32 | class BackfillEmailStatus extends Command |
| 33 | { |
| 34 | protected $signature = 'email:backfill-status |
| 35 | {--limit=200 : Maximum number of quotations to check per run} |
| 36 | {--sleep-ms=200 : Milliseconds to wait between SendGrid API calls} |
| 37 | {--dry-run : Report what would change without writing}'; |
| 38 | |
| 39 | protected $description = 'Re-poll SendGrid for all emails stuck in processed/null status and update Titan (FIRE-864)'; |
| 40 | |
| 41 | private const ERROR_EVENTS = ['deferred', 'bounce', 'dropped', 'spamreport', 'invalid', 'blocked']; |
| 42 | |
| 43 | private const CORREO_ERRONEO_BUDGET_STATUS_ID = 22; |
| 44 | |
| 45 | public function handle(): int |
| 46 | { |
| 47 | $limit = (int) $this->option('limit'); |
| 48 | $sleepMs = (int) $this->option('sleep-ms'); |
| 49 | $dryRun = (bool) $this->option('dry-run'); |
| 50 | |
| 51 | $apiKey = config('services.sendgrid.api_key'); |
| 52 | if (empty($apiKey)) { |
| 53 | $this->error('services.sendgrid.api_key is not configured. Aborting.'); |
| 54 | return 1; |
| 55 | } |
| 56 | |
| 57 | $rows = TblQuotations::query() |
| 58 | ->whereNotNull('x_message_id') |
| 59 | ->where(function ($q): void { |
| 60 | $q->whereNull('x_status') |
| 61 | ->orWhereIn('x_status', ['processed', 'Processing']); |
| 62 | }) |
| 63 | ->orderByDesc('updated_at') |
| 64 | ->limit($limit) |
| 65 | ->get(['id', 'email', 'x_message_id', 'x_status', 'budget_status_id']); |
| 66 | |
| 67 | if ($rows->isEmpty()) { |
| 68 | $this->info('No quotations match the backfill criteria.'); |
| 69 | return 0; |
| 70 | } |
| 71 | |
| 72 | $this->info("Checking {$rows->count()} quotations…"); |
| 73 | |
| 74 | $stats = ['checked' => 0, 'updated' => 0, 'bounced' => 0, 'delivered' => 0, 'still_processed' => 0, 'failed' => 0]; |
| 75 | |
| 76 | foreach ($rows as $row) { |
| 77 | $stats['checked']++; |
| 78 | |
| 79 | $emails = array_filter(array_map('trim', explode(',', (string) $row->email))); |
| 80 | if (empty($emails) || empty($row->x_message_id)) { |
| 81 | continue; |
| 82 | } |
| 83 | |
| 84 | $finalStatuses = []; |
| 85 | |
| 86 | foreach ($emails as $email) { |
| 87 | $query = urlencode('msg_id LIKE "' . $row->x_message_id . '%" AND to_email IN ("' . $email . '")'); |
| 88 | |
| 89 | try { |
| 90 | $response = Http::withToken($apiKey) |
| 91 | ->timeout(15) |
| 92 | ->get("https://api.sendgrid.com/v3/messages?limit=10&query={$query}"); |
| 93 | } catch (\Throwable $e) { |
| 94 | Log::channel('clear_email_processing')->warning("email:backfill-status — SendGrid API error for quote {$row->id}: " . $e->getMessage()); |
| 95 | $stats['failed']++; |
| 96 | continue; |
| 97 | } |
| 98 | |
| 99 | if (! $response->ok()) { |
| 100 | Log::channel('clear_email_processing')->warning("email:backfill-status — SendGrid returned {$response->status()} for quote {$row->id}."); |
| 101 | $stats['failed']++; |
| 102 | continue; |
| 103 | } |
| 104 | |
| 105 | $body = $response->json(); |
| 106 | $status = $body['messages'][0]['status'] ?? 'processed'; |
| 107 | $finalStatuses[] = $status; |
| 108 | |
| 109 | // Mirror the event-shape the webhook would have delivered |
| 110 | if ($status === 'delivered' && isset($body['messages'][0])) { |
| 111 | $this->appendEventToWebhook($row->x_message_id, $body['messages'][0], $dryRun); |
| 112 | } |
| 113 | |
| 114 | if ($sleepMs > 0) { |
| 115 | usleep($sleepMs * 1000); |
| 116 | } |
| 117 | } |
| 118 | |
| 119 | $newStatus = $this->aggregateFinalStatus($finalStatuses, count($emails)); |
| 120 | |
| 121 | $updatePayload = []; |
| 122 | if ($newStatus !== null && $newStatus !== $row->x_status) { |
| 123 | $updatePayload['x_status'] = $newStatus; |
| 124 | } |
| 125 | |
| 126 | if ( |
| 127 | in_array($newStatus, ['Error', 'Error - Bounce'], true) |
| 128 | && (int) $row->budget_status_id !== self::CORREO_ERRONEO_BUDGET_STATUS_ID |
| 129 | ) { |
| 130 | $updatePayload['budget_status_id'] = self::CORREO_ERRONEO_BUDGET_STATUS_ID; |
| 131 | $stats['bounced']++; |
| 132 | } |
| 133 | |
| 134 | if ($newStatus === 'Completed') { |
| 135 | $stats['delivered']++; |
| 136 | } |
| 137 | |
| 138 | if ($newStatus === 'Processing') { |
| 139 | $stats['still_processed']++; |
| 140 | } |
| 141 | |
| 142 | if (! empty($updatePayload)) { |
| 143 | $stats['updated']++; |
| 144 | if ($dryRun) { |
| 145 | $this->line("[dry-run] quote {$row->id} (msg {$row->x_message_id}): " . json_encode($updatePayload)); |
| 146 | } else { |
| 147 | TblQuotations::where('id', $row->id)->update($updatePayload); |
| 148 | Log::channel('clear_email_processing')->info("email:backfill-status updated quote {$row->id}: " . json_encode($updatePayload)); |
| 149 | } |
| 150 | } |
| 151 | } |
| 152 | |
| 153 | $this->info(sprintf( |
| 154 | 'Backfill complete: checked=%d updated=%d bounced=%d delivered=%d still_processed=%d failed=%d', |
| 155 | $stats['checked'], $stats['updated'], $stats['bounced'], |
| 156 | $stats['delivered'], $stats['still_processed'], $stats['failed'] |
| 157 | )); |
| 158 | |
| 159 | Log::channel('clear_email_processing')->info('email:backfill-status finished: ' . json_encode($stats)); |
| 160 | |
| 161 | return 0; |
| 162 | } |
| 163 | |
| 164 | /** |
| 165 | * Collapse the per-recipient final statuses into the single x_status |
| 166 | * value Titan expects on tbl_quotations. |
| 167 | */ |
| 168 | private function aggregateFinalStatus(array $statuses, int $totalEmails): ?string |
| 169 | { |
| 170 | if (empty($statuses)) { |
| 171 | return null; |
| 172 | } |
| 173 | |
| 174 | $delivered = count(array_filter($statuses, fn($s) => $s === 'delivered')); |
| 175 | $errored = count(array_filter($statuses, fn($s) => in_array($s, self::ERROR_EVENTS, true))); |
| 176 | $bounced = count(array_filter($statuses, fn($s) => in_array($s, ['bounce', 'dropped', 'blocked'], true))); |
| 177 | |
| 178 | if ($delivered === $totalEmails) { |
| 179 | return 'Completed'; |
| 180 | } |
| 181 | if ($bounced > 0) { |
| 182 | return 'Error - Bounce'; |
| 183 | } |
| 184 | if ($errored > 0) { |
| 185 | return 'Error'; |
| 186 | } |
| 187 | return 'Processing'; |
| 188 | } |
| 189 | |
| 190 | /** |
| 191 | * Store the retrieved event in tbl_sendgrid_webhook so the "Eventos de |
| 192 | * correo electrónico" modal reflects reality without another API call. |
| 193 | */ |
| 194 | private function appendEventToWebhook(string $xMessageId, array $message, bool $dryRun): void |
| 195 | { |
| 196 | $row = TblSendgridWebhook::where('x_message_id', $xMessageId)->first(); |
| 197 | if (! $row) { |
| 198 | return; |
| 199 | } |
| 200 | |
| 201 | $events = json_decode((string) $row->json_body, true) ?: []; |
| 202 | |
| 203 | // Shape-match the webhook event format the UI expects |
| 204 | $event = [ |
| 205 | 'email' => $message['to_email'] ?? null, |
| 206 | 'event' => $message['status'] ?? 'processed', |
| 207 | 'timestamp' => isset($message['last_event_time']) ? strtotime((string) $message['last_event_time']) : null, |
| 208 | 'open' => $message['opens_count'] ?? 0, |
| 209 | 'click' => $message['clicks_count'] ?? 0, |
| 210 | 'source' => 'activity-api-backfill', |
| 211 | ]; |
| 212 | |
| 213 | $events[] = $event; |
| 214 | |
| 215 | if ($dryRun) { |
| 216 | return; |
| 217 | } |
| 218 | |
| 219 | TblSendgridWebhook::where('x_message_id', $xMessageId)->update([ |
| 220 | 'json_body' => json_encode($events), |
| 221 | 'updated_at' => date('Y-m-d H:i:s'), |
| 222 | ]); |
| 223 | } |
| 224 | } |