From 19a236818e41e96af314e9af4a4d4d3673d7af76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fernando=20P=C3=A9rez?= Date: Fri, 22 May 2026 22:22:36 -0600 Subject: [PATCH 01/12] =?UTF-8?q?Carga=20y=20configuraci=C3=B3n=20de=20pla?= =?UTF-8?q?tilla=20de=20api=20de=20whats=20app?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 3 ++- app/Services/WhatsAppService.php | 21 ++++++++++++++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index f9509c63..ba8090e1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,7 +22,8 @@ RUN npm ci && \ npm run build RUN mkdir -p storage/framework/sessions storage/framework/views storage/framework/cache storage/app/livewire-tmp storage/logs \ - && chmod -R 775 storage bootstrap/cache + && chmod -R 775 storage bootstrap/cache \ + && chown -R nobody:nginx storage bootstrap/cache RUN php artisan storage:link diff --git a/app/Services/WhatsAppService.php b/app/Services/WhatsAppService.php index c19ba1b6..b2870b9e 100644 --- a/app/Services/WhatsAppService.php +++ b/app/Services/WhatsAppService.php @@ -61,7 +61,7 @@ class WhatsAppService return $response->json('messages.0.id'); } - public function enviarPromocion(string $telefono, Promocion $promocion): string + /*public function enviarPromocion(string $telefono, Promocion $promocion): string { $payload = [ 'messaging_product' => 'whatsapp', @@ -92,8 +92,27 @@ class WhatsAppService } return $response->json('messages.0.id'); + }*/ + + public function enviarPromocion(string $telefono, Promocion $promocion): 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'], + ], + ]); + + if ($response->failed()) { + throw new \RuntimeException($response->body()); } + return $response->json('messages.0.id'); +} + public function procesarOptOut(string $telefono): void { $normalizado = $this->normalizarTelefono($telefono); From 61b18df8e860b98cc53c8dd676a703010094d727 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fernando=20P=C3=A9rez?= Date: Sun, 24 May 2026 15:42:26 -0600 Subject: [PATCH 02/12] =?UTF-8?q?feat:=20WhatsApp=20promociones=20?= =?UTF-8?q?=E2=80=94=20fechas=20en=20espa=C3=B1ol,=20status=20webhook=20y?= =?UTF-8?q?=20analytics=20inspector?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Corrige formato de fechas a español con Carbon isoFormat('D [de] MMMM') - Webhook procesa statuses de Meta (delivered/read/failed) y actualiza pivot alumno_promocion - Analytics: nuevas métricas de entregados/leídos, embudo de entrega, filtro por tipo - Inspector de payload raw por log con modal y botón copiar Co-Authored-By: Claude Sonnet 4.6 --- .claude/settings.local.json | 5 +- .../WhatsAppAnalyticsController.php | 21 +- .../Controllers/WhatsAppWebhookController.php | 38 ++++ app/Jobs/EnviarPromocionJob.php | 3 +- app/Services/WhatsAppService.php | 94 +++++---- resources/views/promocion/analytics.blade.php | 185 +++++++++++++++--- 6 files changed, 273 insertions(+), 73 deletions(-) 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 --}}
-
+
@@ -15,15 +15,41 @@
Enviados
{{ number_format($enviados) }}
-
- -
+
-
+
+
+
+
+
+
Entregados
+
{{ number_format($entregados) }}
+
+
+
+
+
+
+ +
+
+
+
+
+
Leídos
+
{{ number_format($leidos) }}
+
+
+
+
+
+
+ +
@@ -31,15 +57,13 @@
Recibidos
{{ number_format($recibidos) }}
-
- -
+
-
+
@@ -47,15 +71,13 @@
Fallidos
{{ number_format($fallidos) }}
-
- -
+
-
+
@@ -63,15 +85,48 @@
Opt-Outs
{{ number_format($optOuts) }}
-
- -
+
+ @if($enviados > 0) + {{-- Barra de progreso del embudo de entrega --}} +
+
+
+ Embudo de entrega +
+
+
+
+ Enviados{{ $enviados }} +
+
+
100%
+
+ @if($entregados > 0) +
+ Entregados{{ $entregados }} ({{ round($entregados/$enviados*100) }}%) +
+
+
{{ round($entregados/$enviados*100) }}%
+
+ @endif + @if($leidos > 0) +
+ Leídos{{ $leidos }} ({{ round($leidos/$enviados*100) }}%) +
+
+
{{ round($leidos/$enviados*100) }}%
+
+ @endif +
+
+ @endif + {{-- Filtros --}}
@@ -106,6 +161,15 @@ @endforeach
+
+ + +
-
+
Logs recientes
+ Haz clic en para ver el payload raw del webhook
@@ -141,29 +206,34 @@ # Prospecto Teléfono - Tipo + Tipo Mensaje Media Fecha + JSON @forelse($logsRecientes as $log) @php $user = $log->alumno?->alumnos->first(); - $badge = match($log->tipo) { - 'enviado' => 'success', - 'recibido' => 'info', - 'opt_out' => 'warning', - 'error' => 'danger', - default => 'secondary', + $badge = match(true) { + $log->tipo === 'enviado' => 'success', + $log->tipo === 'recibido' => 'info', + $log->tipo === 'opt_out' => 'warning', + $log->tipo === 'error' => 'danger', + $log->tipo === 'status_delivered' => 'primary', + $log->tipo === 'status_read' => 'info', + $log->tipo === 'status_sent' => 'secondary', + $log->tipo === 'status_failed' => 'danger', + default => 'secondary', }; @endphp {{ $log->id }} @if($user) - {{ $user->name }} {{ $user->apellidoPaterno }} + {{ $user->name }} {{ $user->apellidoPaterno ?? '' }} @else @endif @@ -183,10 +253,22 @@ @endif {{ $log->created_at?->format('d/m/Y H:i') }} + + @if($log->payload) + + @else + — + @endif + @empty - Sin registros. + Sin registros. @endforelse @@ -199,4 +281,55 @@
+ +{{-- Modal inspector de payload --}} + + +@push('scripts') + +@endpush + @endsection From f246a709c455625acf323bbfa612d5a397cebf58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fernando=20P=C3=A9rez?= Date: Sun, 24 May 2026 22:01:37 -0600 Subject: [PATCH 03/12] =?UTF-8?q?feat:=20WhatsApp=20webhook=20=E2=80=94=20?= =?UTF-8?q?reescritura=20del=20controller=20y=20rutas=20en=20web.php?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Simplifica WhatsAppWebhookController: messages crea WhatsappLog con tipo 'recibido', statuses actualiza el log existente por whatsapp_mensaje_id, opt-out detecta solo STOP/cancelar con regex. Agrega rutas GET/POST /webhook/whatsapp fuera del grupo auth en web.php (POST sin CSRF). Co-Authored-By: Claude Sonnet 4.6 --- .../Controllers/WhatsAppWebhookController.php | 153 ++++-------------- routes/web.php | 4 + 2 files changed, 34 insertions(+), 123 deletions(-) diff --git a/app/Http/Controllers/WhatsAppWebhookController.php b/app/Http/Controllers/WhatsAppWebhookController.php index 46f3b244..4beac987 100644 --- a/app/Http/Controllers/WhatsAppWebhookController.php +++ b/app/Http/Controllers/WhatsAppWebhookController.php @@ -2,153 +2,60 @@ namespace App\Http\Controllers; -use App\Events\NuevoMensajeWhatsApp; -use App\Models\Alumno; use App\Models\WhatsappLog; use App\Services\WhatsAppService; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; -use Illuminate\Support\Facades\DB; -use Illuminate\Support\Facades\Log; +use Illuminate\Http\Response; class WhatsAppWebhookController extends Controller { - private array $optOutKeywords = ['stop', 'no', 'cancelar', 'detener', 'baja', 'salir']; - - public function verify(Request $request) + public function verify(Request $request): Response { $verifyToken = config('services.whatsapp.verify_token'); if ( - $request->get('hub_mode') === 'subscribe' && - $request->get('hub_verify_token') === $verifyToken + $request->query('hub_mode') === 'subscribe' && + $request->query('hub_verify_token') === $verifyToken ) { - return response($request->get('hub_challenge'), 200); + return response($request->query('hub_challenge'), 200); } return response('Forbidden', 403); } - public function handle(Request $request, WhatsAppService $whatsapp) + public function handle(Request $request, WhatsAppService $whatsapp): JsonResponse { - $payload = $request->all(); + $payload = $request->all(); + $value = data_get($payload, 'entry.0.changes.0.value', []); + $messages = data_get($value, 'messages', []); + $statuses = data_get($value, 'statuses', []); - try { - $entry = $payload['entry'][0] ?? null; - $changes = $entry['changes'][0] ?? null; - $value = $changes['value'] ?? null; - $message = $value['messages'][0] ?? null; + foreach ($messages as $message) { + $telefono = data_get($message, 'from'); + $texto = data_get($message, 'text.body', ''); - // Procesar status updates (entregado/leído/fallido) de Meta - if (!empty($value['statuses'])) { - foreach ($value['statuses'] as $status) { - $this->procesarStatus($status, $payload); - } + WhatsappLog::create([ + 'telefono' => $telefono, + 'tipo' => 'recibido', + 'mensaje' => $texto, + 'whatsapp_mensaje_id' => data_get($message, 'id'), + 'payload' => $payload, + ]); + + if (preg_match('/\b(STOP|cancelar)\b/i', $texto)) { + $whatsapp->procesarOptOut($telefono); } + } - if (!$message) { - return response()->json(['status' => 'ok']); - } + foreach ($statuses as $status) { + $mensajeId = data_get($status, 'id'); + $statusEnvio = data_get($status, 'status'); - $telefono = $message['from'] ?? null; - $tipo = $message['type'] ?? 'text'; - $alumno = $this->alumnoPorTelefono($telefono); - $alumnoId = $alumno?->id; - - $logData = [ - 'alumno_id' => $alumnoId, - 'telefono' => $telefono, - 'tipo' => 'recibido', - 'payload' => $payload, - ]; - - if ($tipo === 'text') { - $texto = $message['text']['body'] ?? ''; - $logData['mensaje'] = $texto; - $logData['media_type'] = null; - - if ($this->esOptOut($texto)) { - $whatsapp->procesarOptOut($telefono); - $logData['tipo'] = 'opt_out'; - } - } elseif (in_array($tipo, ['image', 'video', 'audio', 'document'])) { - $mediaId = $message[$tipo]['id'] ?? null; - - if ($mediaId) { - $ruta = $whatsapp->descargarMedia($mediaId); - $logData['mensaje'] = $ruta; - $logData['media_type'] = $tipo; - } - } - - $log = WhatsappLog::create($logData); - - if ($alumnoId) { - broadcast(new NuevoMensajeWhatsApp($log)); - - // Marcar como no leído para el badge - WhatsappLog::where('id', $log->id)->update(['leido' => false]); - } - } catch (\Throwable $e) { - Log::error('WhatsApp webhook error', ['error' => $e->getMessage(), 'payload' => $payload]); + WhatsappLog::where('whatsapp_mensaje_id', $mensajeId) + ->update(['status_envio' => $statusEnvio]); } return response()->json(['status' => 'ok']); } - - private function alumnoPorTelefono(?string $telefono): ?Alumno - { - if (!$telefono) { - return null; - } - - $limpio = preg_replace('/\D/', '', $telefono); - $sinCodigo = strlen($limpio) === 12 ? substr($limpio, 2) : $limpio; - - return Alumno::whereHas('alumnos', function ($q) use ($limpio, $sinCodigo) { - $q->where('telefono', $limpio)->orWhere('telefono', $sinCodigo); - })->first(); - } - - private function esOptOut(string $texto): bool - { - $normalizado = strtolower(trim($texto)); - - foreach ($this->optOutKeywords as $keyword) { - if ($normalizado === $keyword || str_contains($normalizado, $keyword)) { - return true; - } - } - - 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/routes/web.php b/routes/web.php index ede7c225..0b9395ae 100644 --- a/routes/web.php +++ b/routes/web.php @@ -54,6 +54,10 @@ Route::get('/',function(){return view('auth.login');}); Route::get('/prueba',function(){return view('prueba.index');}); Route::post('/stripe/webhook', [StripeWebhookController::class, 'handle']); +Route::get('/webhook/whatsapp', [\App\Http\Controllers\WhatsAppWebhookController::class, 'verify']); +Route::post('/webhook/whatsapp', [\App\Http\Controllers\WhatsAppWebhookController::class, 'handle']) + ->withoutMiddleware([\App\Http\Middleware\VerifyCsrfToken::class]); + Route::middleware(['auth:sanctum',config('jetstream.auth_session'),'verified', ])->group(function () { From a0c3194e88b8be389d9c2fb9b9a8604106951c40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fernando=20P=C3=A9rez?= Date: Thu, 28 May 2026 20:44:13 -0600 Subject: [PATCH 04/12] =?UTF-8?q?feat:=20WhatsApp=20=E2=80=94=20par=C3=A1m?= =?UTF-8?q?etros=20nombrados=20en=20template,=20API=20v25.0=20e=20imagenUr?= =?UTF-8?q?l=20con=20soporte=20de=20URL=20externas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Actualiza graph.facebook.com de v19.0 a v25.0 - Cambia parámetros posicionales a nombrados (parameter_name) en enviarTemplate para compatibilidad con plantillas avanzadas - enviarPromocion ahora pasa nombre fallback 'estudiante' y claves explícitas - imagenUrl() soporta imágenes con URL absoluta (http/https) además de rutas relativas de storage - Agrega Log::info del payload completo antes del envío para facilitar debugging Co-Authored-By: Claude Sonnet 4.6 --- .claude/settings.local.json | 9 +++++- app/Models/Promocion.php | 8 ++++- app/Services/WhatsAppService.php | 55 ++++++++++++++++++++++---------- 3 files changed, 53 insertions(+), 19 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index adf59c80..d09ec082 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -23,7 +23,14 @@ "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)" + "Bash(php -l app/Http/Controllers/WhatsAppAnalyticsController.php)", + "Bash(Test-Path *)", + "Bash(git -C \"C:/Proyectos/SistemaEducativoLaravel\" status)", + "Bash(git -C \"C:/Proyectos/SistemaEducativoLaravel\" diff)", + "Bash(git *)", + "Bash(dir C:\\\\Proyectos\\\\SistemaEducativoLaravel\\\\public *)", + "Bash(findstr \"APP_URL\" \"C:\\\\Proyectos\\\\SistemaEducativoLaravel\\\\.env.example\")", + "Bash(findstr \"APP_URL\" \"C:\\\\Proyectos\\\\SistemaEducativoLaravel\\\\.env\")" ] } } diff --git a/app/Models/Promocion.php b/app/Models/Promocion.php index e13b13ad..c56d2898 100644 --- a/app/Models/Promocion.php +++ b/app/Models/Promocion.php @@ -34,6 +34,12 @@ class Promocion extends Model public function imagenUrl(): ?string { - return $this->imagen ? asset('storage/' . $this->imagen) : null; + if (!$this->imagen) return null; + + if (str_starts_with($this->imagen, 'http')) { + return $this->imagen; + } + + return asset('storage/' . $this->imagen); } } diff --git a/app/Services/WhatsAppService.php b/app/Services/WhatsAppService.php index 548211d5..4172d450 100644 --- a/app/Services/WhatsAppService.php +++ b/app/Services/WhatsAppService.php @@ -28,7 +28,7 @@ class WhatsAppService $this->token = $token; $this->phoneId = $phoneId; - $this->apiUrl = "https://graph.facebook.com/v19.0/{$this->phoneId}/messages"; + $this->apiUrl = "https://graph.facebook.com/v25.0/{$this->phoneId}/messages"; } public function enviarTemplate(string $telefono, string $templateName, array $parametros = [], ?string $imagenUrl = null): ?string @@ -48,11 +48,30 @@ class WhatsAppService } if (!empty($parametros)) { - $components[] = [ - 'type' => 'body', - 'parameters' => array_map(fn($p) => ['type' => 'text', 'text' => $p], $parametros), - ]; - } + $components[] = [ + 'type' => 'body', + 'parameters' => array_map(fn($nombre, $valor) => [ + 'type' => 'text', + 'text' => (string) $valor, + 'parameter_name' => $nombre, + ], array_keys($parametros), array_values($parametros)), + ]; +} + +Log::info('WhatsApp payload', [ + 'url' => $this->apiUrl, + 'body' => [ + 'messaging_product' => 'whatsapp', + 'to' => $this->normalizarTelefono($telefono), + 'type' => 'template', + 'template' => [ + 'name' => $templateName, + 'language' => ['code' => 'es_MX'], + 'components' => $components, + ], + ] +]); + $response = Http::withToken($this->token)->post($this->apiUrl, [ 'messaging_product' => 'whatsapp', @@ -66,7 +85,7 @@ class WhatsAppService ]); if ($response->failed()) { - Log::error('WhatsApp template error', ['telefono' => $telefono, 'response' => $response->json()]); + Log::error('WhatsApp template error', ['telefono' => $telefono, 'response' => $response->json(), 'body' => $response->body()]); return null; } @@ -111,16 +130,18 @@ public function enviarPromocion(string $telefono, Promocion $promocion, string $ $codigo = 'PROMO-' . strtoupper(substr(md5($promocion->id . $telefono), 0, 6)); $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() - ); + $telefono, + 'promocion_laravel', + [ + 'nombre' => $nombre ?: 'estudiante', + 'fecha_inicio' => $promocion->fecha_inicio->locale('es')->isoFormat('D [de] MMMM'), + 'fecha_fin' => $promocion->fecha_fin->locale('es')->isoFormat('D [de] MMMM'), + 'codigo' => $codigo, + ], + $promocion->imagenUrl() +); + + if (!$mensajeId) { throw new \RuntimeException('No se pudo enviar la plantilla'); From 3f48c0bc7c9fc754e95315add5f32210c2f5856b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fernando=20P=C3=A9rez?= Date: Thu, 28 May 2026 21:06:08 -0600 Subject: [PATCH 05/12] =?UTF-8?q?fix:=20acceso=20p=C3=BAblico=20a=20im?= =?UTF-8?q?=C3=A1genes=20de=20storage=20para=20WhatsApp=20y=20navegador?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Mueve la ruta /storage/{path} fuera del middleware de auth para que sea pública; necesario para que los servidores de WhatsApp y el navegador puedan cargar imágenes de promociones sin autenticación - Dockerfile: agrega 'rm -rf public/storage' antes de storage:link para evitar que COPY copie la junction de Windows y deje el symlink roto en el contenedor - Crea .dockerignore: excluye public/storage, .git, .env y artefactos de build del contexto de Docker para builds más limpios y seguros Co-Authored-By: Claude Sonnet 4.6 --- .dockerignore | 8 ++++++++ Dockerfile | 2 +- routes/web.php | 13 +++++++------ 3 files changed, 16 insertions(+), 7 deletions(-) create mode 100644 .dockerignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..9543ce91 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +.git +.env +node_modules +public/storage +storage/framework/cache +storage/framework/sessions +storage/framework/views +storage/logs diff --git a/Dockerfile b/Dockerfile index ba8090e1..6d4af7ff 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,7 +25,7 @@ RUN mkdir -p storage/framework/sessions storage/framework/views storage/framewor && chmod -R 775 storage bootstrap/cache \ && chown -R nobody:nginx storage bootstrap/cache -RUN php artisan storage:link +RUN rm -rf public/storage && php artisan storage:link COPY nginx.conf /etc/nginx/nginx.conf COPY supervisord.conf /etc/supervisord.conf diff --git a/routes/web.php b/routes/web.php index 0b9395ae..c49e4c9b 100644 --- a/routes/web.php +++ b/routes/web.php @@ -58,6 +58,12 @@ Route::get('/webhook/whatsapp', [\App\Http\Controllers\WhatsAppWebhookControlle Route::post('/webhook/whatsapp', [\App\Http\Controllers\WhatsAppWebhookController::class, 'handle']) ->withoutMiddleware([\App\Http\Middleware\VerifyCsrfToken::class]); +Route::get('/storage/{path}', function (string $path) { + $fullPath = storage_path('app/public/' . $path); + abort_unless(file_exists($fullPath), 404); + return response()->file($fullPath); +})->where('path', '.*'); + Route::middleware(['auth:sanctum',config('jetstream.auth_session'),'verified', ])->group(function () { @@ -160,12 +166,7 @@ Route::middleware(['auth:sanctum',config('jetstream.auth_session'),'verified', Route::resource('/plantel',PlantelController::class); Route::get('/registros', [CodeController::class,'registros' ])->name('registros'); Route::resource('/role',RoleController::class); - Route::get('/storage/{path}', function ($path) { $fullPath = storage_path('app/public/' . $path); - if (!file_exists($fullPath)) { - abort(404); - } - return response()->file($fullPath); - })->where('path', '.*')->middleware('auth'); + // Route::get('/test-chat', function () { broadcast(new MessageSent('Hola realtime ')); return 'Mensaje enviado';}); Route::resource('/turno',TurnoController::class); Route::resource('/user',UserController::class); From eaf5d953a812ca63f4f0e4384b5e202ca46f400a Mon Sep 17 00:00:00 2001 From: Isma-acv <56331822+Isma-acv@users.noreply.github.com> Date: Fri, 5 Jun 2026 13:46:35 -0600 Subject: [PATCH 06/12] Update README.md --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 0165a773..8ee9b9f2 100644 --- a/README.md +++ b/README.md @@ -57,3 +57,5 @@ If you discover a security vulnerability within Laravel, please send an e-mail t ## License The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT). + +Holaaa From c90a707419f22e4f88c7f8661af62b1e34b82d9c Mon Sep 17 00:00:00 2001 From: Isma-acv <56331822+Isma-acv@users.noreply.github.com> Date: Fri, 5 Jun 2026 13:47:19 -0600 Subject: [PATCH 07/12] Update README.md --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index 8ee9b9f2..0165a773 100644 --- a/README.md +++ b/README.md @@ -57,5 +57,3 @@ If you discover a security vulnerability within Laravel, please send an e-mail t ## License The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT). - -Holaaa From 13ddc396b58e5d6261179752e44eab25b059438e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fernando=20P=C3=A9rez?= Date: Sat, 6 Jun 2026 23:43:09 -0600 Subject: [PATCH 08/12] actualizacion general del proyecto --- resources/views/livewire/data-prospecto.blade.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/resources/views/livewire/data-prospecto.blade.php b/resources/views/livewire/data-prospecto.blade.php index ad0cbfd8..93ecf971 100644 --- a/resources/views/livewire/data-prospecto.blade.php +++ b/resources/views/livewire/data-prospecto.blade.php @@ -9,6 +9,9 @@ @if ($prospecto) + + +
From cb9c58dda22f0c0cbe323e5f551474c457543073 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fernando=20P=C3=A9rez?= Date: Sat, 6 Jun 2026 23:45:28 -0600 Subject: [PATCH 09/12] actualizacion general del proyecto con .env --- .env.example | 54 ++++++++++++++++++++++++++++++++++------------------ 1 file changed, 35 insertions(+), 19 deletions(-) diff --git a/.env.example b/.env.example index 4ba8a2af..b08279df 100644 --- a/.env.example +++ b/.env.example @@ -40,26 +40,42 @@ QUEUE_CONNECTION=database CACHE_STORE=database # CACHE_PREFIX= -MEMCACHED_HOST=127.0.0.1 +APP_NAME="Sistema Educativo CUIEP" +APP_ENV=local +APP_KEY=base64:1EprnplCgW3bOVa+E65gRv3OUX+ls4Pylw50UqGgiWU= +APP_DEBUG=true +APP_URL=http://localhost:8000 +APP_LOCALE=es -REDIS_CLIENT=phpredis -REDIS_HOST=127.0.0.1 -REDIS_PASSWORD=null -REDIS_PORT=6379 +DB_CONNECTION=mysql +DB_HOST=127.0.0.1 +DB_PORT=3306 +DB_DATABASE=sistemaeducativo +DB_USERNAME=laravel +DB_PASSWORD= -MAIL_MAILER=log -MAIL_SCHEME=null -MAIL_HOST=127.0.0.1 -MAIL_PORT=2525 -MAIL_USERNAME=null -MAIL_PASSWORD=null -MAIL_FROM_ADDRESS="hello@example.com" -MAIL_FROM_NAME="${APP_NAME}" +SESSION_DRIVER=file +QUEUE_CONNECTION=database +BROADCAST_CONNECTION=reverb -AWS_ACCESS_KEY_ID= -AWS_SECRET_ACCESS_KEY= -AWS_DEFAULT_REGION=us-east-1 -AWS_BUCKET= -AWS_USE_PATH_STYLE_ENDPOINT=false +REVERB_APP_ID=748566 +REVERB_APP_KEY=fmoknd2swp3clsqfbdpg +REVERB_APP_SECRET=vahbjut789g3d7rdphzg +REVERB_HOST=127.0.0.1 +REVERB_PORT=8080 +REVERB_SCHEME=http -VITE_APP_NAME="${APP_NAME}" +VITE_REVERB_HOST=127.0.0.1 +VITE_REVERB_PORT=8080 +VITE_REVERB_SCHEME=http +VITE_REVERB_APP_KEY="${REVERB_APP_KEY}" + +OPENPAY_ID= +OPENPAY_PRIVATE_KEY= +OPENPAY_PUBLIC_KEY= +OPENPAY_PRODUCTION=false + +WHATSAPP_TOKEN=EAANZBf4YEsjABRnUjl4d5PQS33GwnMZA6u9nFLvXpJAR4ZAlMEjg33AOPI6ommzCuQZAjZABJ4QclDptJXA0aGvZCPzL2dhiELU5mZBEJ2eZALu4SbZAaQQ6umgq7uCSh4yOsk58urzM9P6W5JMZBbl1LT5ZBqmCvBKKC0rAFtZBZB2GiwN54mlSZAifZAq9kbuUuZAzkwZDZD +WHATSAPP_PHONE_ID=1168611189662826 +WHATSAPP_WABA_ID=1652562415964289 +WHATSAPP_VERIFY_TOKEN=muronegro_webhook_2026 From 04b8c38130581cb7d3678992a59a5d965af3657e Mon Sep 17 00:00:00 2001 From: Ismael Gutierrez Acevedo Date: Wed, 10 Jun 2026 13:01:40 -0600 Subject: [PATCH 10/12] SECU-2 Crear una rama en local --- package-lock.json | 2 +- routes/web.php | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index b7595cbc..5c44c287 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "SistemaEducativoLaravel", + "name": "apps", "lockfileVersion": 3, "requires": true, "packages": { diff --git a/routes/web.php b/routes/web.php index c49e4c9b..59d3aa34 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,5 +1,4 @@ Date: Wed, 10 Jun 2026 15:00:13 -0600 Subject: [PATCH 11/12] SECU-2 Crear una rama en local --- routes/web.php | 1 - 1 file changed, 1 deletion(-) diff --git a/routes/web.php b/routes/web.php index 59d3aa34..6322c3d2 100644 --- a/routes/web.php +++ b/routes/web.php @@ -46,7 +46,6 @@ use App\Http\Controllers\UserController; use App\Models\Documento; use Illuminate\Support\Facades\Route; use Laravel\Jetstream\Http\Controllers\Livewire\UserProfileController; - Route::match(['GET','POST'],'/unsubscribe', [UnsubscribeController::class,'handle']); Route::get('/',function(){return view('auth.login');}); From 57af7260eabdf14e79c84aee012c2399cb5b44ba Mon Sep 17 00:00:00 2001 From: Ismael Gutierrez Acevedo Date: Wed, 10 Jun 2026 15:28:09 -0600 Subject: [PATCH 12/12] SECU-2 Crear una rama en local --- routes/web.php | 1 + 1 file changed, 1 insertion(+) diff --git a/routes/web.php b/routes/web.php index 6322c3d2..6d94dc69 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,4 +1,5 @@