Fix: socket permissions para nginx
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user