Fix: socket permissions para nginx

This commit is contained in:
2026-05-21 23:21:41 -06:00
parent b574ae1ed3
commit 28faa727b1
32 changed files with 2144 additions and 6 deletions
+41
View File
@@ -0,0 +1,41 @@
<?php
namespace App\Events;
use App\Models\WhatsappLog;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class NuevoMensajeWhatsApp implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(public WhatsappLog $log) {}
public function broadcastOn(): array
{
if (!$this->log->alumno_id) {
return [];
}
return [
new PrivateChannel('whatsapp-chat.' . $this->log->alumno_id),
];
}
public function broadcastWith(): array
{
return [
'id' => $this->log->id,
'tipo' => $this->log->tipo,
'mensaje' => $this->log->mensaje,
'media_type' => $this->log->media_type,
'alumno_id' => $this->log->alumno_id,
'telefono' => $this->log->telefono,
'created_at' => $this->log->created_at?->format('H:i'),
];
}
}
@@ -0,0 +1,190 @@
<?php
namespace App\Http\Controllers;
use App\Jobs\EnviarPromocionJob;
use App\Models\Alumno;
use App\Models\Plantel;
use App\Models\Promocion;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Storage;
class PromocionController extends Controller
{
public function index()
{
$plantelesIds = $this->plantelesDelUsuario()->pluck('id');
$promociones = Promocion::with('planteles')
->whereHas('planteles', fn($q) => $q->whereIn('plantels.id', $plantelesIds))
->latest()
->paginate(15);
return view('promocion.index', compact('promociones'));
}
public function create()
{
$planteles = $this->plantelesDelUsuario();
return view('promocion.create', compact('planteles'));
}
public function store(Request $request)
{
$data = $request->validate([
'titulo' => 'required|string|max:200',
'descripcion' => 'nullable|string|max:3000',
'imagen' => 'nullable|image|max:10240',
'fecha_inicio' => 'required|date',
'fecha_fin' => 'required|date|after_or_equal:fecha_inicio',
'status' => 'required|in:activo,inactivo',
'plantels' => 'required|array|min:1',
'plantels.*' => 'exists:plantels,id',
]);
$this->validarAccesoPlanteles($request->plantels ?? []);
if ($request->hasFile('imagen')) {
$data['imagen'] = $request->file('imagen')->store('promociones', 'public');
}
unset($data['plantels']);
$promocion = Promocion::create($data);
$promocion->planteles()->sync($request->plantels);
return redirect()->route('promocion.index')->with('success', 'Promoción creada correctamente.');
}
public function edit(Promocion $promocion)
{
$this->autorizarPlantel($promocion);
$planteles = $this->plantelesDelUsuario();
$plantelesSeleccionados = $promocion->planteles->pluck('id')->toArray();
return view('promocion.edit', compact('promocion', 'planteles', 'plantelesSeleccionados'));
}
public function update(Request $request, Promocion $promocion)
{
$this->autorizarPlantel($promocion);
$data = $request->validate([
'titulo' => 'required|string|max:200',
'descripcion' => 'nullable|string|max:3000',
'imagen' => 'nullable|image|max:10240',
'fecha_inicio' => 'required|date',
'fecha_fin' => 'required|date|after_or_equal:fecha_inicio',
'status' => 'required|in:activo,inactivo',
'plantels' => 'required|array|min:1',
'plantels.*' => 'exists:plantels,id',
]);
$this->validarAccesoPlanteles($request->plantels ?? []);
if ($request->hasFile('imagen')) {
if ($promocion->imagen) {
Storage::disk('public')->delete($promocion->imagen);
}
$data['imagen'] = $request->file('imagen')->store('promociones', 'public');
} elseif ($request->boolean('remove_imagen') && $promocion->imagen) {
Storage::disk('public')->delete($promocion->imagen);
$data['imagen'] = null;
}
unset($data['plantels']);
$promocion->update($data);
$promocion->planteles()->sync($request->plantels);
return redirect()->route('promocion.index')->with('success', 'Promoción actualizada.');
}
public function destroy(Promocion $promocion)
{
$this->autorizarPlantel($promocion);
if ($promocion->imagen) {
Storage::disk('public')->delete($promocion->imagen);
}
$promocion->delete();
return back()->with('success', 'Promoción eliminada.');
}
public function toggleStatus(Promocion $promocion)
{
$this->autorizarPlantel($promocion);
$nuevoStatus = $promocion->status === 'activo' ? 'inactivo' : 'activo';
$promocion->update(['status' => $nuevoStatus]);
return back()->with('success', $nuevoStatus === 'activo' ? 'Promoción activada.' : 'Promoción desactivada.');
}
public function enviar(Promocion $promocion)
{
$this->autorizarPlantel($promocion);
$plantelesIds = $promocion->planteles->pluck('id');
$prospectos = Alumno::where('status', 1)
->where('opt_out', false)
->whereHas('alumnos.plantelUsuarios', function ($q) use ($plantelesIds) {
$q->whereIn('plantels.id', $plantelesIds);
})
->get();
$despachados = 0;
foreach ($prospectos as $i => $alumno) {
if (!$promocion->alumnos()->where('alumno_id', $alumno->id)->exists()) {
$promocion->alumnos()->attach($alumno->id, ['status_envio' => 'pendiente']);
}
EnviarPromocionJob::dispatch($promocion, $alumno)
->delay(now()->addSeconds($i * 2));
$despachados++;
}
if ($despachados === 0) {
return back()->with('swal', [
'icon' => 'warning',
'title' => 'Sin destinatarios',
'text' => 'No se encontraron prospectos activos para los planteles de esta promoción.',
]);
}
return back()->with('swal', [
'icon' => 'success',
'title' => '¡Enviado!',
'text' => "Se programó el envío para {$despachados} prospecto(s).",
]);
}
private function plantelesDelUsuario()
{
return Auth::user()->plantelUsuarios;
}
private function autorizarPlantel(Promocion $promocion): void
{
$ids = $this->plantelesDelUsuario()->pluck('id');
$tieneAcceso = $promocion->planteles->pluck('id')->intersect($ids)->isNotEmpty();
if (!$tieneAcceso) {
abort(403);
}
}
private function validarAccesoPlanteles(array $plantelIds): void
{
$permitidos = $this->plantelesDelUsuario()->pluck('id');
foreach ($plantelIds as $id) {
if (!$permitidos->contains($id)) {
abort(403, 'No tienes acceso a uno de los planteles seleccionados.');
}
}
}
}
@@ -0,0 +1,109 @@
<?php
namespace App\Http\Controllers;
use App\Models\Plantel;
use App\Models\Promocion;
use App\Models\WhatsappLog;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class WhatsAppAnalyticsController extends Controller
{
public function index(Request $request)
{
$plantelesIds = Auth::user()->plantelUsuarios->pluck('id');
$planteles = Auth::user()->plantelUsuarios;
$query = WhatsappLog::query();
if ($request->filled('plantel_id')) {
$plantelId = (int) $request->plantel_id;
$query->whereNotNull('alumno_id')
->whereHas('alumno.alumnos.plantelUsuarios', function ($q) use ($plantelId) {
$q->where('plantels.id', $plantelId);
});
}
if ($request->filled('promocion_id')) {
$query->whereJsonContains('payload->promocion_id', (int) $request->promocion_id);
}
if ($request->filled('fecha_desde')) {
$query->where('created_at', '>=', $request->fecha_desde . ' 00:00:00');
}
if ($request->filled('fecha_hasta')) {
$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();
$logsRecientes = (clone $query)
->with('alumno.alumnos')
->latest('created_at')
->paginate(20);
$promociones = Promocion::whereHas('planteles', function ($q) use ($plantelesIds) {
$q->whereIn('plantels.id', $plantelesIds);
})->latest()->get();
return view('promocion.analytics', compact(
'enviados', 'fallidos', 'optOuts', 'recibidos',
'logsRecientes', 'planteles', 'promociones'
));
}
public function stats(Request $request)
{
$query = WhatsappLog::query();
if ($request->filled('plantel_id')) {
$plantelId = (int) $request->plantel_id;
$query->whereNotNull('alumno_id')
->whereHas('alumno.alumnos.plantelUsuarios', function ($q) use ($plantelId) {
$q->where('plantels.id', $plantelId);
});
}
return response()->json([
'enviados' => (clone $query)->where('tipo', 'enviado')->count(),
'recibidos' => (clone $query)->where('tipo', 'recibido')->count(),
'fallidos' => (clone $query)->where('tipo', 'error')->count(),
'opt_outs' => (clone $query)->where('tipo', 'opt_out')->count(),
]);
}
public function logs(Request $request)
{
$query = WhatsappLog::with('alumno.alumnos')
->orderByDesc('created_at');
if ($request->filled('fecha_desde')) {
$query->where('created_at', '>=', $request->fecha_desde);
}
if ($request->filled('fecha_hasta')) {
$query->where('created_at', '<=', $request->fecha_hasta . ' 23:59:59');
}
if ($request->filled('plantel_id')) {
$plantelId = (int) $request->plantel_id;
$query->whereNotNull('alumno_id')
->whereHas('alumno.alumnos.plantelUsuarios', function ($q) use ($plantelId) {
$q->where('plantels.id', $plantelId);
});
}
if ($request->filled('tipo')) {
$query->where('tipo', $request->tipo);
}
$logs = $query->paginate(50);
return response()->json($logs);
}
}
@@ -0,0 +1,127 @@
<?php
namespace App\Http\Controllers;
use App\Models\Alumno;
use App\Models\WhatsappLog;
use App\Services\WhatsAppService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class WhatsAppChatController extends Controller
{
public function index()
{
$plantelesIds = Auth::user()->plantelUsuarios->pluck('id');
// Prospectos del plantel que tienen historial de WhatsApp
$prospectos = Alumno::whereHas('alumnos.plantelUsuarios', function ($q) use ($plantelesIds) {
$q->whereIn('plantels.id', $plantelesIds);
})
->whereHas('whatsappLogs')
->with(['alumnos' => fn($q) => $q->select('users.id', 'users.name', 'users.apellidoPaterno', 'users.telefono', 'users.profile_photo_path')])
->get()
->map(function (Alumno $alumno) {
$ultimoMensaje = $alumno->whatsappLogs()->latest('created_at')->first();
$noLeidos = $alumno->whatsappLogs()->where('leido', false)->where('tipo', 'recibido')->count();
return [
'alumno' => $alumno,
'user' => $alumno->alumnos->first(),
'ultimo' => $ultimoMensaje,
'no_leidos' => $noLeidos,
];
})
->sortByDesc(fn($item) => optional($item['ultimo'])->created_at);
$totalNoLeidos = WhatsappLog::whereIn('alumno_id', $prospectos->pluck('alumno.id'))
->where('leido', false)
->where('tipo', 'recibido')
->count();
return view('promocion.chat', compact('prospectos', 'totalNoLeidos'));
}
public function show(Alumno $alumno)
{
$this->autorizarAlumno($alumno);
$logs = WhatsappLog::where('alumno_id', $alumno->id)
->orderBy('created_at')
->get();
// Marcar como leídos
WhatsappLog::where('alumno_id', $alumno->id)
->where('leido', false)
->update(['leido' => true]);
$user = $alumno->alumnos()->first();
return response()->json([
'alumno' => [
'id' => $alumno->id,
'nombre' => $user ? "{$user->name} {$user->apellidoPaterno}" : "Alumno #{$alumno->id}",
'avatar' => $user?->profile_photo_url ?? null,
'tel' => $alumno->telefonoWhatsapp(),
],
'logs' => $logs->map(fn($log) => [
'id' => $log->id,
'tipo' => $log->tipo,
'mensaje' => $log->mensaje,
'media_type' => $log->media_type,
'created_at' => $log->created_at?->format('d/m/Y H:i'),
]),
]);
}
public function send(Request $request, WhatsAppService $whatsapp)
{
$request->validate([
'alumno_id' => 'required|exists:alumnos,id',
'mensaje' => 'required|string|max:4096',
]);
$alumno = Alumno::findOrFail($request->alumno_id);
$this->autorizarAlumno($alumno);
if ($alumno->opt_out) {
return response()->json(['error' => 'El prospecto solicitó no recibir mensajes (opt-out).'], 422);
}
$telefono = $alumno->telefonoWhatsapp();
if (!$telefono) {
return response()->json(['error' => 'El prospecto no tiene teléfono registrado.'], 422);
}
$mensajeId = $whatsapp->enviarTexto($telefono, $request->mensaje);
$log = WhatsappLog::create([
'alumno_id' => $alumno->id,
'telefono' => $telefono,
'tipo' => 'enviado',
'mensaje' => $request->mensaje,
'payload' => ['enviado_por' => Auth::id(), 'whatsapp_id' => $mensajeId],
]);
return response()->json([
'id' => $log->id,
'tipo' => $log->tipo,
'mensaje' => $log->mensaje,
'created_at' => $log->created_at->format('d/m/Y H:i'),
]);
}
private function autorizarAlumno(Alumno $alumno): void
{
$plantelesIds = Auth::user()->plantelUsuarios->pluck('id');
$tiene = $alumno->alumnos()
->whereHas('plantelUsuarios', fn($q) => $q->whereIn('plantels.id', $plantelesIds))
->exists();
if (!$tiene) {
abort(403);
}
}
}
@@ -0,0 +1,116 @@
<?php
namespace App\Http\Controllers;
use App\Events\NuevoMensajeWhatsApp;
use App\Models\Alumno;
use App\Models\WhatsappLog;
use App\Services\WhatsAppService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
class WhatsAppWebhookController extends Controller
{
private array $optOutKeywords = ['stop', 'no', 'cancelar', 'detener', 'baja', 'salir'];
public function verify(Request $request)
{
$verifyToken = config('services.whatsapp.verify_token');
if (
$request->get('hub_mode') === 'subscribe' &&
$request->get('hub_verify_token') === $verifyToken
) {
return response($request->get('hub_challenge'), 200);
}
return response('Forbidden', 403);
}
public function handle(Request $request, WhatsAppService $whatsapp)
{
$payload = $request->all();
try {
$entry = $payload['entry'][0] ?? null;
$changes = $entry['changes'][0] ?? null;
$value = $changes['value'] ?? null;
$message = $value['messages'][0] ?? null;
if (!$message) {
return response()->json(['status' => 'ok']);
}
$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]);
}
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;
}
}
+84
View File
@@ -0,0 +1,84 @@
<?php
namespace App\Jobs;
use App\Models\Alumno;
use App\Models\Promocion;
use App\Models\WhatsappLog;
use App\Services\WhatsAppService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class EnviarPromocionJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $backoff = 60;
public function __construct(
public Promocion $promocion,
public Alumno $alumno
) {}
public function handle(WhatsAppService $whatsapp): void
{
$this->alumno->refresh();
if ($this->alumno->opt_out) {
return;
}
$telefono = $this->alumno->telefonoWhatsapp();
if (!$telefono) {
WhatsappLog::create([
'alumno_id' => $this->alumno->id,
'telefono' => 'sin_telefono',
'tipo' => 'error',
'mensaje' => 'Alumno sin teléfono registrado',
'payload' => ['promocion_id' => $this->promocion->id],
]);
$this->actualizarPivot('fallido', null);
return;
}
try {
$mensajeId = $whatsapp->enviarPromocion($telefono, $this->promocion);
} catch (\RuntimeException $e) {
WhatsappLog::create([
'alumno_id' => $this->alumno->id,
'telefono' => $telefono,
'tipo' => 'error',
'mensaje' => $e->getMessage(),
'payload' => ['promocion_id' => $this->promocion->id],
]);
$this->actualizarPivot('fallido', null);
return;
}
WhatsappLog::create([
'alumno_id' => $this->alumno->id,
'telefono' => $telefono,
'tipo' => 'enviado',
'mensaje' => $this->promocion->titulo,
'payload' => ['promocion_id' => $this->promocion->id, 'whatsapp_id' => $mensajeId],
]);
$this->actualizarPivot('enviado', $mensajeId);
}
private function actualizarPivot(string $status, ?string $mensajeId): void
{
$this->promocion->alumnos()->updateExistingPivot($this->alumno->id, [
'status_envio' => $status,
'enviado_at' => now(),
'whatsapp_mensaje_id' => $mensajeId,
]);
}
}
+23
View File
@@ -5,11 +5,17 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Alumno extends Model
{
protected $guarded = [];
protected $casts = [
'opt_out' => 'boolean',
'opt_out_at' => 'datetime',
];
public function existe($plantelId): bool
{
return $this->plantelUsuarios()
@@ -52,4 +58,21 @@ class Alumno extends Model
return $this->hasMany(Documento::class);
}
public function promociones(): BelongsToMany
{
return $this->belongsToMany(Promocion::class, 'alumno_promocion')
->withPivot(['enviado_at', 'status_envio', 'whatsapp_mensaje_id']);
}
public function whatsappLogs(): HasMany
{
return $this->hasMany(WhatsappLog::class);
}
public function telefonoWhatsapp(): ?string
{
$user = $this->alumnos()->first();
return $user?->telefono;
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Promocion extends Model
{
protected $table = 'promociones';
protected $guarded = [];
protected $casts = [
'fecha_inicio' => 'date',
'fecha_fin' => 'date',
];
public function planteles(): BelongsToMany
{
return $this->belongsToMany(Plantel::class, 'plantel_promocion');
}
public function alumnos(): BelongsToMany
{
return $this->belongsToMany(Alumno::class, 'alumno_promocion')
->withPivot(['enviado_at', 'status_envio', 'whatsapp_mensaje_id']);
}
public function isActiva(): bool
{
return $this->status === 'activo';
}
public function imagenUrl(): ?string
{
return $this->imagen ? asset('storage/' . $this->imagen) : null;
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class WhatsappLog extends Model
{
public $timestamps = false;
protected $guarded = [];
protected $casts = [
'payload' => 'array',
'leido' => 'boolean',
'created_at' => 'datetime',
];
public function alumno(): BelongsTo
{
return $this->belongsTo(Alumno::class);
}
}
+189
View File
@@ -0,0 +1,189 @@
<?php
namespace App\Services;
use App\Models\Alumno;
use App\Models\Promocion;
use App\Models\WhatsappLog;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
class WhatsAppService
{
private string $token;
private string $phoneId;
private string $apiUrl;
public function __construct()
{
$token = config('services.whatsapp.token');
$phoneId = config('services.whatsapp.phone_id');
if (!$token || !$phoneId) {
throw new \RuntimeException(
'WhatsApp no configurado. Agrega WHATSAPP_TOKEN y WHATSAPP_PHONE_ID en el archivo .env'
);
}
$this->token = $token;
$this->phoneId = $phoneId;
$this->apiUrl = "https://graph.facebook.com/v19.0/{$this->phoneId}/messages";
}
public function enviarTemplate(string $telefono, string $templateName, array $parametros = []): ?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 ($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 = [
'messaging_product' => 'whatsapp',
'to' => $this->normalizarTelefono($telefono),
];
if ($promocion->imagen) {
$payload['type'] = 'image';
$payload['image'] = [
'link' => asset('storage/' . $promocion->imagen),
'caption' => "{$promocion->titulo}\n\n{$promocion->descripcion}",
];
} else {
$payload['type'] = 'text';
$payload['text'] = ['body' => "{$promocion->titulo}\n\n{$promocion->descripcion}"];
}
$response = Http::withToken($this->token)->post($this->apiUrl, $payload);
if ($response->failed()) {
Log::error('WhatsApp promocion error', [
'telefono' => $telefono,
'status' => $response->status(),
'body' => $response->body(),
]);
throw new \RuntimeException($response->body());
}
return $response->json('messages.0.id');
}
public function procesarOptOut(string $telefono): void
{
$normalizado = $this->normalizarTelefono($telefono);
Alumno::whereHas('alumnos', function ($q) use ($normalizado) {
$q->where('telefono', $normalizado)
->orWhere('telefono', ltrim($normalizado, '52'));
})->each(function (Alumno $alumno) {
$alumno->update([
'opt_out' => true,
'opt_out_at' => now(),
]);
});
WhatsappLog::create([
'telefono' => $normalizado,
'tipo' => 'opt_out',
'mensaje' => 'Usuario solicitó opt-out',
]);
}
public function descargarMedia(string $mediaId): ?string
{
$infoResponse = Http::withToken($this->token)
->get("https://graph.facebook.com/v19.0/{$mediaId}");
if ($infoResponse->failed()) {
Log::error('WhatsApp media info error', ['mediaId' => $mediaId]);
return null;
}
$mediaUrl = $infoResponse->json('url');
$mimeType = $infoResponse->json('mime_type', 'application/octet-stream');
$extension = $this->extensionFromMime($mimeType);
$fileResponse = Http::withToken($this->token)->get($mediaUrl);
if ($fileResponse->failed()) {
return null;
}
$filename = 'whatsapp_media/' . $mediaId . '.' . $extension;
Storage::disk('public')->put($filename, $fileResponse->body());
return $filename;
}
public function enviarTexto(string $telefono, string $texto): ?string
{
$response = Http::withToken($this->token)->post($this->apiUrl, [
'messaging_product' => 'whatsapp',
'to' => $this->normalizarTelefono($telefono),
'type' => 'text',
'text' => ['body' => $texto],
]);
if ($response->failed()) {
return null;
}
return $response->json('messages.0.id');
}
private function normalizarTelefono(string $telefono): string
{
$limpio = preg_replace('/\D/', '', $telefono);
// Si ya tiene el prefijo 52 (12 dígitos), quítalo para re-agregarlo limpio
if (str_starts_with($limpio, '52') && strlen($limpio) === 12) {
$limpio = substr($limpio, 2);
}
// Siempre anteponer el código de país México
return '52' . $limpio;
}
private function extensionFromMime(string $mimeType): string
{
return match (true) {
str_contains($mimeType, 'jpeg') => 'jpg',
str_contains($mimeType, 'png') => 'png',
str_contains($mimeType, 'gif') => 'gif',
str_contains($mimeType, 'webp') => 'webp',
str_contains($mimeType, 'mp4') => 'mp4',
str_contains($mimeType, 'ogg') => 'ogg',
str_contains($mimeType, 'mpeg') => 'mp3',
str_contains($mimeType, 'pdf') => 'pdf',
str_contains($mimeType, 'msword') => 'doc',
str_contains($mimeType, 'spreadsheet')=> 'xlsx',
default => 'bin',
};
}
}