diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 181dfe6d..adf59c80 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -20,7 +20,10 @@ "Bash(php -l app/Models/Entrega.php)", "Bash(php -l app/Livewire/GrupoDocente.php)", "Bash(composer require *)", - "Bash(Select-String -Pattern \"entrega|Entrega\" -Context 0,0)" + "Bash(Select-String -Pattern \"entrega|Entrega\" -Context 0,0)", + "Bash(php -l app/Services/WhatsAppService.php)", + "Bash(php -l app/Http/Controllers/WhatsAppWebhookController.php)", + "Bash(php -l app/Http/Controllers/WhatsAppAnalyticsController.php)" ] } } diff --git a/app/Http/Controllers/WhatsAppAnalyticsController.php b/app/Http/Controllers/WhatsAppAnalyticsController.php index b986d00d..80d2edd9 100644 --- a/app/Http/Controllers/WhatsAppAnalyticsController.php +++ b/app/Http/Controllers/WhatsAppAnalyticsController.php @@ -37,12 +37,21 @@ class WhatsAppAnalyticsController extends Controller $query->where('created_at', '<=', $request->fecha_hasta . ' 23:59:59'); } - $enviados = (clone $query)->where('tipo', 'enviado')->count(); - $fallidos = (clone $query)->where('tipo', 'error')->count(); - $optOuts = (clone $query)->where('tipo', 'opt_out')->count(); - $recibidos = (clone $query)->where('tipo', 'recibido')->count(); + // Métricas siempre sobre todos los tipos (sin filtro de tipo) + $enviados = (clone $query)->where('tipo', 'enviado')->count(); + $fallidos = (clone $query)->where('tipo', 'error')->count(); + $optOuts = (clone $query)->where('tipo', 'opt_out')->count(); + $recibidos = (clone $query)->where('tipo', 'recibido')->count(); + $entregados = (clone $query)->where('tipo', 'status_delivered')->count(); + $leidos = (clone $query)->where('tipo', 'status_read')->count(); - $logsRecientes = (clone $query) + // El filtro de tipo solo aplica al listado de logs + $logsQuery = clone $query; + if ($request->filled('tipo')) { + $logsQuery->where('tipo', $request->tipo); + } + + $logsRecientes = $logsQuery ->with('alumno.alumnos') ->latest('created_at') ->paginate(20); @@ -52,7 +61,7 @@ class WhatsAppAnalyticsController extends Controller })->latest()->get(); return view('promocion.analytics', compact( - 'enviados', 'fallidos', 'optOuts', 'recibidos', + 'enviados', 'fallidos', 'optOuts', 'recibidos', 'entregados', 'leidos', 'logsRecientes', 'planteles', 'promociones' )); } diff --git a/app/Http/Controllers/WhatsAppWebhookController.php b/app/Http/Controllers/WhatsAppWebhookController.php index 81b4fc5b..46f3b244 100644 --- a/app/Http/Controllers/WhatsAppWebhookController.php +++ b/app/Http/Controllers/WhatsAppWebhookController.php @@ -7,6 +7,7 @@ use App\Models\Alumno; use App\Models\WhatsappLog; use App\Services\WhatsAppService; use Illuminate\Http\Request; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; class WhatsAppWebhookController extends Controller @@ -37,6 +38,13 @@ class WhatsAppWebhookController extends Controller $value = $changes['value'] ?? null; $message = $value['messages'][0] ?? null; + // Procesar status updates (entregado/leído/fallido) de Meta + if (!empty($value['statuses'])) { + foreach ($value['statuses'] as $status) { + $this->procesarStatus($status, $payload); + } + } + if (!$message) { return response()->json(['status' => 'ok']); } @@ -113,4 +121,34 @@ class WhatsAppWebhookController extends Controller return false; } + + private function procesarStatus(array $status, array $payload): void + { + $mensajeId = $status['id'] ?? null; + $estadoMeta = $status['status'] ?? null; // sent, delivered, read, failed + $telefono = $status['recipient_id'] ?? null; + $alumno = $this->alumnoPorTelefono($telefono); + + WhatsappLog::create([ + 'alumno_id' => $alumno?->id, + 'telefono' => $telefono, + 'tipo' => 'status_' . $estadoMeta, + 'mensaje' => "Acuse de {$estadoMeta} para mensaje {$mensajeId}", + 'payload' => $payload, + ]); + + // Actualizar pivot alumno_promocion cuando hay confirmación de entrega o lectura + if ($mensajeId && in_array($estadoMeta, ['delivered', 'read', 'failed'])) { + $nuevoStatus = match($estadoMeta) { + 'delivered' => 'entregado', + 'read' => 'leido', + 'failed' => 'fallido', + default => $estadoMeta, + }; + + DB::table('alumno_promocion') + ->where('whatsapp_mensaje_id', $mensajeId) + ->update(['status_envio' => $nuevoStatus]); + } + } } diff --git a/app/Jobs/EnviarPromocionJob.php b/app/Jobs/EnviarPromocionJob.php index bf066379..53e5619f 100644 --- a/app/Jobs/EnviarPromocionJob.php +++ b/app/Jobs/EnviarPromocionJob.php @@ -48,7 +48,8 @@ class EnviarPromocionJob implements ShouldQueue } try { - $mensajeId = $whatsapp->enviarPromocion($telefono, $this->promocion); + $nombre = $this->alumno->alumnos()->first()?->name ?? 'estudiante'; + $mensajeId = $whatsapp->enviarPromocion($telefono, $this->promocion, $nombre); } catch (\RuntimeException $e) { WhatsappLog::create([ 'alumno_id' => $this->alumno->id, diff --git a/app/Services/WhatsAppService.php b/app/Services/WhatsAppService.php index b2870b9e..548211d5 100644 --- a/app/Services/WhatsAppService.php +++ b/app/Services/WhatsAppService.php @@ -31,36 +31,48 @@ class WhatsAppService $this->apiUrl = "https://graph.facebook.com/v19.0/{$this->phoneId}/messages"; } - public function enviarTemplate(string $telefono, string $templateName, array $parametros = []): ?string - { - $components = []; + public function enviarTemplate(string $telefono, string $templateName, array $parametros = [], ?string $imagenUrl = null): ?string +{ + $components = []; - if (!empty($parametros)) { - $components[] = [ - 'type' => 'body', - 'parameters' => array_map(fn($p) => ['type' => 'text', 'text' => $p], $parametros), - ]; - } - - $response = Http::withToken($this->token)->post($this->apiUrl, [ - 'messaging_product' => 'whatsapp', - 'to' => $this->normalizarTelefono($telefono), - 'type' => 'template', - 'template' => [ - 'name' => $templateName, - 'language' => ['code' => 'es_MX'], - 'components' => $components, + if ($imagenUrl) { + $components[] = [ + 'type' => 'header', + 'parameters' => [ + [ + 'type' => 'image', + 'image' => ['link' => $imagenUrl], + ] ], - ]); - - if ($response->failed()) { - Log::error('WhatsApp template error', ['telefono' => $telefono, 'response' => $response->json()]); - return null; - } - - return $response->json('messages.0.id'); + ]; } + if (!empty($parametros)) { + $components[] = [ + 'type' => 'body', + 'parameters' => array_map(fn($p) => ['type' => 'text', 'text' => $p], $parametros), + ]; + } + + $response = Http::withToken($this->token)->post($this->apiUrl, [ + 'messaging_product' => 'whatsapp', + 'to' => $this->normalizarTelefono($telefono), + 'type' => 'template', + 'template' => [ + 'name' => $templateName, + 'language' => ['code' => 'es_MX'], + 'components' => $components, + ], + ]); + + if ($response->failed()) { + Log::error('WhatsApp template error', ['telefono' => $telefono, 'response' => $response->json()]); + return null; + } + + return $response->json('messages.0.id'); +} + /*public function enviarPromocion(string $telefono, Promocion $promocion): string { $payload = [ @@ -94,23 +106,27 @@ class WhatsAppService return $response->json('messages.0.id'); }*/ - public function enviarPromocion(string $telefono, Promocion $promocion): string +public function enviarPromocion(string $telefono, Promocion $promocion, string $nombre = ''): string { - $response = Http::withToken($this->token)->post($this->apiUrl, [ - 'messaging_product' => 'whatsapp', - 'to' => $this->normalizarTelefono($telefono), - 'type' => 'template', - 'template' => [ - 'name' => 'hello_world', - 'language' => ['code' => 'en_US'], - ], - ]); + $codigo = 'PROMO-' . strtoupper(substr(md5($promocion->id . $telefono), 0, 6)); - if ($response->failed()) { - throw new \RuntimeException($response->body()); + $mensajeId = $this->enviarTemplate( + $telefono, + 'promocion_laravel', + [ + $nombre, + $promocion->fecha_inicio->locale('es')->isoFormat('D [de] MMMM'), + $promocion->fecha_fin->locale('es')->isoFormat('D [de] MMMM'), + $codigo, + ], + $promocion->imagenUrl() + ); + + if (!$mensajeId) { + throw new \RuntimeException('No se pudo enviar la plantilla'); } - return $response->json('messages.0.id'); + return $mensajeId; } public function procesarOptOut(string $telefono): void diff --git a/resources/views/promocion/analytics.blade.php b/resources/views/promocion/analytics.blade.php index 9d7cab2c..e68ad750 100644 --- a/resources/views/promocion/analytics.blade.php +++ b/resources/views/promocion/analytics.blade.php @@ -7,7 +7,7 @@ {{-- Tarjetas de métricas --}}