feat: WhatsApp promociones — fechas en español, status webhook y analytics inspector

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-05-24 15:42:26 -06:00
parent 19a236818e
commit 61b18df8e8
6 changed files with 273 additions and 73 deletions
+4 -1
View File
@@ -20,7 +20,10 @@
"Bash(php -l app/Models/Entrega.php)", "Bash(php -l app/Models/Entrega.php)",
"Bash(php -l app/Livewire/GrupoDocente.php)", "Bash(php -l app/Livewire/GrupoDocente.php)",
"Bash(composer require *)", "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)"
] ]
} }
} }
@@ -37,12 +37,21 @@ class WhatsAppAnalyticsController extends Controller
$query->where('created_at', '<=', $request->fecha_hasta . ' 23:59:59'); $query->where('created_at', '<=', $request->fecha_hasta . ' 23:59:59');
} }
// Métricas siempre sobre todos los tipos (sin filtro de tipo)
$enviados = (clone $query)->where('tipo', 'enviado')->count(); $enviados = (clone $query)->where('tipo', 'enviado')->count();
$fallidos = (clone $query)->where('tipo', 'error')->count(); $fallidos = (clone $query)->where('tipo', 'error')->count();
$optOuts = (clone $query)->where('tipo', 'opt_out')->count(); $optOuts = (clone $query)->where('tipo', 'opt_out')->count();
$recibidos = (clone $query)->where('tipo', 'recibido')->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') ->with('alumno.alumnos')
->latest('created_at') ->latest('created_at')
->paginate(20); ->paginate(20);
@@ -52,7 +61,7 @@ class WhatsAppAnalyticsController extends Controller
})->latest()->get(); })->latest()->get();
return view('promocion.analytics', compact( return view('promocion.analytics', compact(
'enviados', 'fallidos', 'optOuts', 'recibidos', 'enviados', 'fallidos', 'optOuts', 'recibidos', 'entregados', 'leidos',
'logsRecientes', 'planteles', 'promociones' 'logsRecientes', 'planteles', 'promociones'
)); ));
} }
@@ -7,6 +7,7 @@ use App\Models\Alumno;
use App\Models\WhatsappLog; use App\Models\WhatsappLog;
use App\Services\WhatsAppService; use App\Services\WhatsAppService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
class WhatsAppWebhookController extends Controller class WhatsAppWebhookController extends Controller
@@ -37,6 +38,13 @@ class WhatsAppWebhookController extends Controller
$value = $changes['value'] ?? null; $value = $changes['value'] ?? null;
$message = $value['messages'][0] ?? 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) { if (!$message) {
return response()->json(['status' => 'ok']); return response()->json(['status' => 'ok']);
} }
@@ -113,4 +121,34 @@ class WhatsAppWebhookController extends Controller
return false; 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]);
}
}
} }
+2 -1
View File
@@ -48,7 +48,8 @@ class EnviarPromocionJob implements ShouldQueue
} }
try { try {
$mensajeId = $whatsapp->enviarPromocion($telefono, $this->promocion); $nombre = $this->alumno->alumnos()->first()?->name ?? 'estudiante';
$mensajeId = $whatsapp->enviarPromocion($telefono, $this->promocion, $nombre);
} catch (\RuntimeException $e) { } catch (\RuntimeException $e) {
WhatsappLog::create([ WhatsappLog::create([
'alumno_id' => $this->alumno->id, 'alumno_id' => $this->alumno->id,
+30 -14
View File
@@ -31,10 +31,22 @@ class WhatsAppService
$this->apiUrl = "https://graph.facebook.com/v19.0/{$this->phoneId}/messages"; $this->apiUrl = "https://graph.facebook.com/v19.0/{$this->phoneId}/messages";
} }
public function enviarTemplate(string $telefono, string $templateName, array $parametros = []): ?string public function enviarTemplate(string $telefono, string $templateName, array $parametros = [], ?string $imagenUrl = null): ?string
{ {
$components = []; $components = [];
if ($imagenUrl) {
$components[] = [
'type' => 'header',
'parameters' => [
[
'type' => 'image',
'image' => ['link' => $imagenUrl],
]
],
];
}
if (!empty($parametros)) { if (!empty($parametros)) {
$components[] = [ $components[] = [
'type' => 'body', 'type' => 'body',
@@ -94,23 +106,27 @@ class WhatsAppService
return $response->json('messages.0.id'); 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, [ $codigo = 'PROMO-' . strtoupper(substr(md5($promocion->id . $telefono), 0, 6));
'messaging_product' => 'whatsapp',
'to' => $this->normalizarTelefono($telefono),
'type' => 'template',
'template' => [
'name' => 'hello_world',
'language' => ['code' => 'en_US'],
],
]);
if ($response->failed()) { $mensajeId = $this->enviarTemplate(
throw new \RuntimeException($response->body()); $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 public function procesarOptOut(string $telefono): void
+157 -24
View File
@@ -7,7 +7,7 @@
{{-- Tarjetas de métricas --}} {{-- Tarjetas de métricas --}}
<div class="row mb-4"> <div class="row mb-4">
<div class="col-xl-3 col-md-6 mb-4"> <div class="col-xl-2 col-md-4 mb-4">
<div class="card border-left-success shadow h-100 py-2"> <div class="card border-left-success shadow h-100 py-2">
<div class="card-body"> <div class="card-body">
<div class="row no-gutters align-items-center"> <div class="row no-gutters align-items-center">
@@ -15,15 +15,41 @@
<div class="text-xs font-weight-bold text-success text-uppercase mb-1">Enviados</div> <div class="text-xs font-weight-bold text-success text-uppercase mb-1">Enviados</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">{{ number_format($enviados) }}</div> <div class="h5 mb-0 font-weight-bold text-gray-800">{{ number_format($enviados) }}</div>
</div> </div>
<div class="col-auto"> <div class="col-auto"><i class="fas fa-paper-plane fa-2x text-gray-300"></i></div>
<i class="fas fa-paper-plane fa-2x text-gray-300"></i>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="col-xl-3 col-md-6 mb-4"> <div class="col-xl-2 col-md-4 mb-4">
<div class="card border-left-primary shadow h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-primary text-uppercase mb-1">Entregados</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">{{ number_format($entregados) }}</div>
</div>
<div class="col-auto"><i class="fas fa-check-double fa-2x text-gray-300"></i></div>
</div>
</div>
</div>
</div>
<div class="col-xl-2 col-md-4 mb-4">
<div class="card border-left-info shadow h-100 py-2" style="border-left-color:#00b2ff!important">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-uppercase mb-1" style="color:#00b2ff">Leídos</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">{{ number_format($leidos) }}</div>
</div>
<div class="col-auto"><i class="fas fa-eye fa-2x text-gray-300"></i></div>
</div>
</div>
</div>
</div>
<div class="col-xl-2 col-md-4 mb-4">
<div class="card border-left-info shadow h-100 py-2"> <div class="card border-left-info shadow h-100 py-2">
<div class="card-body"> <div class="card-body">
<div class="row no-gutters align-items-center"> <div class="row no-gutters align-items-center">
@@ -31,15 +57,13 @@
<div class="text-xs font-weight-bold text-info text-uppercase mb-1">Recibidos</div> <div class="text-xs font-weight-bold text-info text-uppercase mb-1">Recibidos</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">{{ number_format($recibidos) }}</div> <div class="h5 mb-0 font-weight-bold text-gray-800">{{ number_format($recibidos) }}</div>
</div> </div>
<div class="col-auto"> <div class="col-auto"><i class="fas fa-inbox fa-2x text-gray-300"></i></div>
<i class="fas fa-inbox fa-2x text-gray-300"></i>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="col-xl-3 col-md-6 mb-4"> <div class="col-xl-2 col-md-4 mb-4">
<div class="card border-left-danger shadow h-100 py-2"> <div class="card border-left-danger shadow h-100 py-2">
<div class="card-body"> <div class="card-body">
<div class="row no-gutters align-items-center"> <div class="row no-gutters align-items-center">
@@ -47,15 +71,13 @@
<div class="text-xs font-weight-bold text-danger text-uppercase mb-1">Fallidos</div> <div class="text-xs font-weight-bold text-danger text-uppercase mb-1">Fallidos</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">{{ number_format($fallidos) }}</div> <div class="h5 mb-0 font-weight-bold text-gray-800">{{ number_format($fallidos) }}</div>
</div> </div>
<div class="col-auto"> <div class="col-auto"><i class="fas fa-exclamation-triangle fa-2x text-gray-300"></i></div>
<i class="fas fa-exclamation-triangle fa-2x text-gray-300"></i>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="col-xl-3 col-md-6 mb-4"> <div class="col-xl-2 col-md-4 mb-4">
<div class="card border-left-warning shadow h-100 py-2"> <div class="card border-left-warning shadow h-100 py-2">
<div class="card-body"> <div class="card-body">
<div class="row no-gutters align-items-center"> <div class="row no-gutters align-items-center">
@@ -63,14 +85,47 @@
<div class="text-xs font-weight-bold text-warning text-uppercase mb-1">Opt-Outs</div> <div class="text-xs font-weight-bold text-warning text-uppercase mb-1">Opt-Outs</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">{{ number_format($optOuts) }}</div> <div class="h5 mb-0 font-weight-bold text-gray-800">{{ number_format($optOuts) }}</div>
</div> </div>
<div class="col-auto"> <div class="col-auto"><i class="fas fa-ban fa-2x text-gray-300"></i></div>
<i class="fas fa-ban fa-2x text-gray-300"></i>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
@if($enviados > 0)
{{-- Barra de progreso del embudo de entrega --}}
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-funnel-dollar mr-1"></i> Embudo de entrega
</h6>
</div> </div>
<div class="card-body">
<div class="mb-1 d-flex justify-content-between small font-weight-bold">
<span>Enviados</span><span>{{ $enviados }}</span>
</div>
<div class="progress mb-3" style="height:20px">
<div class="progress-bar bg-success" style="width:100%">100%</div>
</div>
@if($entregados > 0)
<div class="mb-1 d-flex justify-content-between small font-weight-bold">
<span>Entregados</span><span>{{ $entregados }} ({{ round($entregados/$enviados*100) }}%)</span>
</div>
<div class="progress mb-3" style="height:20px">
<div class="progress-bar bg-primary" style="width:{{ round($entregados/$enviados*100) }}%">{{ round($entregados/$enviados*100) }}%</div>
</div>
@endif
@if($leidos > 0)
<div class="mb-1 d-flex justify-content-between small font-weight-bold">
<span>Leídos</span><span>{{ $leidos }} ({{ round($leidos/$enviados*100) }}%)</span>
</div>
<div class="progress mb-1" style="height:20px">
<div class="progress-bar" style="width:{{ round($leidos/$enviados*100) }}%; background:#00b2ff">{{ round($leidos/$enviados*100) }}%</div>
</div>
@endif
</div>
</div>
@endif
{{-- Filtros --}} {{-- Filtros --}}
<div class="card shadow mb-4"> <div class="card shadow mb-4">
@@ -106,6 +161,15 @@
@endforeach @endforeach
</select> </select>
</div> </div>
<div class="form-group mr-3 mb-2">
<label class="mr-2 font-weight-bold">Tipo</label>
<select name="tipo" class="form-control form-control-sm">
<option value="">Todos</option>
@foreach(['enviado','recibido','error','opt_out','status_sent','status_delivered','status_read','status_failed'] as $t)
<option value="{{ $t }}" {{ request('tipo') === $t ? 'selected' : '' }}>{{ $t }}</option>
@endforeach
</select>
</div>
<div class="form-group mr-3 mb-2"> <div class="form-group mr-3 mb-2">
<label class="mr-2 font-weight-bold">Desde</label> <label class="mr-2 font-weight-bold">Desde</label>
<input type="date" name="fecha_desde" class="form-control form-control-sm" <input type="date" name="fecha_desde" class="form-control form-control-sm"
@@ -128,10 +192,11 @@
{{-- Logs recientes --}} {{-- Logs recientes --}}
<div class="card shadow mb-4"> <div class="card shadow mb-4">
<div class="card-header py-3"> <div class="card-header py-3 d-flex align-items-center justify-content-between">
<h6 class="m-0 font-weight-bold text-primary"> <h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-list mr-1"></i> Logs recientes <i class="fas fa-list mr-1"></i> Logs recientes
</h6> </h6>
<span class="small text-muted">Haz clic en <i class="fas fa-code"></i> para ver el payload raw del webhook</span>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="table-responsive"> <div class="table-responsive">
@@ -141,21 +206,26 @@
<th style="width:50px">#</th> <th style="width:50px">#</th>
<th>Prospecto</th> <th>Prospecto</th>
<th style="width:110px">Teléfono</th> <th style="width:110px">Teléfono</th>
<th style="width:90px" class="text-center">Tipo</th> <th style="width:120px" class="text-center">Tipo</th>
<th>Mensaje</th> <th>Mensaje</th>
<th style="width:80px">Media</th> <th style="width:80px">Media</th>
<th style="width:140px">Fecha</th> <th style="width:140px">Fecha</th>
<th style="width:50px" class="text-center">JSON</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@forelse($logsRecientes as $log) @forelse($logsRecientes as $log)
@php @php
$user = $log->alumno?->alumnos->first(); $user = $log->alumno?->alumnos->first();
$badge = match($log->tipo) { $badge = match(true) {
'enviado' => 'success', $log->tipo === 'enviado' => 'success',
'recibido' => 'info', $log->tipo === 'recibido' => 'info',
'opt_out' => 'warning', $log->tipo === 'opt_out' => 'warning',
'error' => 'danger', $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', default => 'secondary',
}; };
@endphp @endphp
@@ -163,7 +233,7 @@
<td>{{ $log->id }}</td> <td>{{ $log->id }}</td>
<td> <td>
@if($user) @if($user)
{{ $user->name }} {{ $user->apellidoPaterno }} {{ $user->name }} {{ $user->apellidoPaterno ?? '' }}
@else @else
<span class="text-muted"></span> <span class="text-muted"></span>
@endif @endif
@@ -183,10 +253,22 @@
@endif @endif
</td> </td>
<td class="small">{{ $log->created_at?->format('d/m/Y H:i') }}</td> <td class="small">{{ $log->created_at?->format('d/m/Y H:i') }}</td>
<td class="text-center">
@if($log->payload)
<button type="button" class="btn btn-xs btn-outline-secondary btn-payload"
data-id="{{ $log->id }}"
data-payload="{{ htmlspecialchars(json_encode($log->payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE), ENT_QUOTES) }}"
title="Ver payload">
<i class="fas fa-code"></i>
</button>
@else
@endif
</td>
</tr> </tr>
@empty @empty
<tr> <tr>
<td colspan="7" class="text-center text-muted py-3">Sin registros.</td> <td colspan="8" class="text-center text-muted py-3">Sin registros.</td>
</tr> </tr>
@endforelse @endforelse
</tbody> </tbody>
@@ -199,4 +281,55 @@
</div> </div>
</div> </div>
{{-- Modal inspector de payload --}}
<div class="modal fade" id="payloadModal" tabindex="-1" role="dialog">
<div class="modal-dialog modal-lg modal-dialog-scrollable" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="fas fa-code mr-1"></i> Payload del webhook
<small class="text-muted ml-2" id="payloadLogId"></small>
</h5>
<button type="button" class="close" data-dismiss="modal">
<span>&times;</span>
</button>
</div>
<div class="modal-body p-0">
<div class="d-flex justify-content-end p-2 border-bottom bg-light">
<button type="button" class="btn btn-xs btn-outline-secondary" id="btnCopyPayload">
<i class="fas fa-copy mr-1"></i> Copiar
</button>
</div>
<pre id="payloadContent" class="m-0 p-3" style="background:#1e1e1e;color:#d4d4d4;font-size:12px;max-height:500px;overflow:auto;border-radius:0 0 4px 4px"></pre>
</div>
</div>
</div>
</div>
@push('scripts')
<script>
document.querySelectorAll('.btn-payload').forEach(function(btn) {
btn.addEventListener('click', function() {
var payload = this.getAttribute('data-payload');
var id = this.getAttribute('data-id');
document.getElementById('payloadLogId').textContent = '#' + id;
document.getElementById('payloadContent').textContent = payload;
$('#payloadModal').modal('show');
});
});
document.getElementById('btnCopyPayload').addEventListener('click', function() {
var text = document.getElementById('payloadContent').textContent;
navigator.clipboard.writeText(text).then(function() {
var btn = document.getElementById('btnCopyPayload');
btn.innerHTML = '<i class="fas fa-check mr-1"></i> Copiado';
setTimeout(function() {
btn.innerHTML = '<i class="fas fa-copy mr-1"></i> Copiar';
}, 2000);
});
});
</script>
@endpush
@endsection @endsection