diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 181dfe6d..d09ec082 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -20,7 +20,17 @@ "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)", + "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/.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/.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 diff --git a/Dockerfile b/Dockerfile index f9509c63..6d4af7ff 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,9 +22,10 @@ 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 +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/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..4beac987 100644 --- a/app/Http/Controllers/WhatsAppWebhookController.php +++ b/app/Http/Controllers/WhatsAppWebhookController.php @@ -2,115 +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\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', ''); - if (!$message) { - return response()->json(['status' => 'ok']); + 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); } + } - $telefono = $message['from'] ?? null; - $tipo = $message['type'] ?? 'text'; - $alumno = $this->alumnoPorTelefono($telefono); - $alumnoId = $alumno?->id; + foreach ($statuses as $status) { + $mensajeId = data_get($status, 'id'); + $statusEnvio = data_get($status, 'status'); - $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; - } } 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/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 c19ba1b6..4172d450 100644 --- a/app/Services/WhatsAppService.php +++ b/app/Services/WhatsAppService.php @@ -28,40 +28,71 @@ 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 - { - $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'); + ]; } - public function enviarPromocion(string $telefono, Promocion $promocion): string + if (!empty($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', + '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(), 'body' => $response->body()]); + return null; + } + + return $response->json('messages.0.id'); +} + + /*public function enviarPromocion(string $telefono, Promocion $promocion): string { $payload = [ 'messaging_product' => 'whatsapp', @@ -92,8 +123,33 @@ class WhatsAppService } return $response->json('messages.0.id'); + }*/ + +public function enviarPromocion(string $telefono, Promocion $promocion, string $nombre = ''): string +{ + $codigo = 'PROMO-' . strtoupper(substr(md5($promocion->id . $telefono), 0, 6)); + + $mensajeId = $this->enviarTemplate( + $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'); } + return $mensajeId; +} + public function procesarOptOut(string $telefono): void { $normalizado = $this->normalizarTelefono($telefono); 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/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) + + +