Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 103
0.00% covered (danger)
0.00%
0 / 3
CRAP
0.00% covered (danger)
0.00%
0 / 1
BackfillEmailStatus
0.00% covered (danger)
0.00%
0 / 103
0.00% covered (danger)
0.00%
0 / 3
930
0.00% covered (danger)
0.00%
0 / 1
 handle
0.00% covered (danger)
0.00%
0 / 72
0.00% covered (danger)
0.00%
0 / 1
420
 aggregateFinalStatus
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
30
 appendEventToWebhook
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
30
1<?php
2
3namespace App\Console\Commands;
4
5use App\Models\TblQuotations;
6use App\Models\TblSendgridWebhook;
7use Illuminate\Console\Command;
8use Illuminate\Support\Facades\Http;
9use 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 */
32class 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}