diff --git a/app/AI/Prompts/SystemPromptAdmin.php b/app/AI/Prompts/SystemPromptAdmin.php new file mode 100644 index 0000000..199f942 --- /dev/null +++ b/app/AI/Prompts/SystemPromptAdmin.php @@ -0,0 +1,72 @@ +'); + $faqPos = strpos($content, '## ❓ Preguntas Frecuentes'); + + if ($cap4Pos !== false && $faqPos !== false && $faqPos > $cap4Pos) { + $content = substr($content, 0, $cap4Pos) . substr($content, $faqPos); + } + + return $content; + } +} diff --git a/app/AI/Tools/CargarPuntajeTool.php b/app/AI/Tools/CargarPuntajeTool.php new file mode 100644 index 0000000..cb54d13 --- /dev/null +++ b/app/AI/Tools/CargarPuntajeTool.php @@ -0,0 +1,30 @@ + "No se encontró el partido con ID: {$id_evento}"]); + } + + $evento->update([ + 'marcador_local' => $marcador_local, + 'marcador_visitante' => $marcador_visitante, + ]); + + return json_encode([ + 'success' => true, + 'mensaje' => "Puntaje actualizado: {$marcador_local} - {$marcador_visitante}", + ]); + } +} diff --git a/app/AI/Tools/CrearPartidoTool.php b/app/AI/Tools/CrearPartidoTool.php new file mode 100644 index 0000000..1b94e5b --- /dev/null +++ b/app/AI/Tools/CrearPartidoTool.php @@ -0,0 +1,43 @@ + (string) Str::uuid(), + 'id_equipo_local' => $id_equipo_local, + 'id_equipo_visitante' => $id_equipo_visitante, + 'fecha_evento' => $fecha_evento, + 'hora_inicio' => str_contains($hora_inicio, ':') ? $hora_inicio . (strlen($hora_inicio) === 5 ? ':00' : '') : $hora_inicio, + 'hora_fin' => str_contains($hora_fin, ':') ? $hora_fin . (strlen($hora_fin) === 5 ? ':00' : '') : $hora_fin, + 'sede' => $sede, + 'id_torneo' => $id_torneo, + 'precio' => $precio ?? 0, + 'fase' => Evento::FASE_REGULAR, + ]); + + return json_encode([ + 'success' => true, + 'id_evento' => $evento->id_evento, + 'mensaje' => "Partido creado correctamente. ID: {$evento->id_evento}", + ]); + } catch (\Throwable $e) { + return json_encode(['error' => 'No se pudo crear el partido: ' . $e->getMessage()]); + } + } +} diff --git a/app/AI/Tools/EliminarNoticiaTool.php b/app/AI/Tools/EliminarNoticiaTool.php new file mode 100644 index 0000000..0497111 --- /dev/null +++ b/app/AI/Tools/EliminarNoticiaTool.php @@ -0,0 +1,29 @@ + "No se encontró la noticia con ID: {$id_noticia}"]); + } + + $titulo = $noticia->titulo; + $noticia->delete(); + + return json_encode([ + 'success' => true, + 'mensaje' => "Noticia \"{$titulo}\" (ID {$id_noticia}) eliminada correctamente.", + ]); + } catch (\Throwable $e) { + return json_encode(['error' => 'No se pudo eliminar la noticia: ' . $e->getMessage()]); + } + } +} diff --git a/app/AI/Tools/EliminarPartidoTool.php b/app/AI/Tools/EliminarPartidoTool.php new file mode 100644 index 0000000..0308804 --- /dev/null +++ b/app/AI/Tools/EliminarPartidoTool.php @@ -0,0 +1,28 @@ + "No se encontró el partido con ID: {$id_evento}"]); + } + + $evento->delete(); + + return json_encode([ + 'success' => true, + 'mensaje' => "Partido {$id_evento} eliminado correctamente (soft delete).", + ]); + } catch (\Throwable $e) { + return json_encode(['error' => 'No se pudo eliminar el partido: ' . $e->getMessage()]); + } + } +} diff --git a/app/AI/Tools/ListarEquiposTool.php b/app/AI/Tools/ListarEquiposTool.php new file mode 100644 index 0000000..3ab9def --- /dev/null +++ b/app/AI/Tools/ListarEquiposTool.php @@ -0,0 +1,33 @@ +join('torneo_equipo', 'equipos.id_equipo', '=', 'torneo_equipo.id_equipo') + ->where('torneo_equipo.id_torneo', $id_torneo); + + if ($grupo !== null) { + $query->where('torneo_equipo.grupo', $grupo); + } + + $query->select('equipos.id_equipo', 'equipos.categoria', 'equipos.division', 'equipos.id_club'); + } + + $equipos = $query->get()->map(fn($e) => [ + 'id_equipo' => $e->id_equipo, + 'categoria' => $e->categoria, + 'division' => $e->division, + 'club' => $e->club?->nombre, + ]); + + return json_encode($equipos); + } +} diff --git a/app/AI/Tools/ListarEventosTool.php b/app/AI/Tools/ListarEventosTool.php new file mode 100644 index 0000000..fb6aa72 --- /dev/null +++ b/app/AI/Tools/ListarEventosTool.php @@ -0,0 +1,40 @@ +whereNull('deleted_at'); + + if ($fecha_desde) { + $query->whereDate('fecha_evento', '>=', $fecha_desde); + } + if ($fecha_hasta) { + $query->whereDate('fecha_evento', '<=', $fecha_hasta); + } + if ($id_torneo) { + $query->where('id_torneo', $id_torneo); + } + + $eventos = $query->orderBy('fecha_evento')->get()->map(fn($e) => [ + 'id_evento' => $e->id_evento, + 'fecha' => $e->fecha_evento?->format('Y-m-d'), + 'hora' => $e->hora_inicio?->format('H:i'), + 'local' => $e->equipoLocal?->club?->nombre, + 'visitante' => $e->equipoVisitante?->club?->nombre, + 'marcador_local' => $e->marcador_local, + 'marcador_visitante' => $e->marcador_visitante, + 'sede' => $e->sede, + ]); + + return json_encode($eventos); + } +} diff --git a/app/AI/Tools/ListarTorneosTool.php b/app/AI/Tools/ListarTorneosTool.php new file mode 100644 index 0000000..a1b8f0e --- /dev/null +++ b/app/AI/Tools/ListarTorneosTool.php @@ -0,0 +1,20 @@ +get()->map(fn ($t) => [ + 'id_torneo' => $t->id, + 'nombre' => $t->nombre, + 'fecha_inicio' => $t->fecha_inicio?->format('Y-m-d'), + 'fecha_fin' => $t->fecha_fin?->format('Y-m-d'), + ]); + + return json_encode($torneos); + } +} diff --git a/app/AI/Tools/RedactarNoticiaTool.php b/app/AI/Tools/RedactarNoticiaTool.php new file mode 100644 index 0000000..271ad01 --- /dev/null +++ b/app/AI/Tools/RedactarNoticiaTool.php @@ -0,0 +1,33 @@ + $titulo, + 'contenido' => $contenido, + 'fecha' => now(), + 'id_torneo' => $id_torneo, + 'categoria' => $categoria, + ]); + + return json_encode([ + 'success' => true, + 'id_noticia' => $noticia->id, + 'mensaje' => "Noticia \"{$titulo}\" creada correctamente.", + ]); + } catch (\Throwable $e) { + return json_encode(['error' => 'No se pudo crear la noticia: ' . $e->getMessage()]); + } + } +} diff --git a/app/Console/Commands/CleanupOldEvents.php b/app/Console/Commands/CleanupOldEvents.php new file mode 100644 index 0000000..6c0d370 --- /dev/null +++ b/app/Console/Commands/CleanupOldEvents.php @@ -0,0 +1,51 @@ +subDays((int)$dias); + + $eventosAEliminar = \App\Models\Evento::withTrashed() + ->where('fecha_evento', '<', $fechaLimite->toDateString()) + ->get(); + $total = $eventosAEliminar->count(); + + if ($total === 0) { + $this->info("No hay eventos antiguos para eliminar."); + return; + } + + foreach ($eventosAEliminar as $evento) { + /** @var \App\Models\Evento $evento */ + // Eliminar QRs asociados + $evento->qrCodes()->delete(); + // Ya no eliminamos el evento para mantener registro de puntos y goleadores + // $evento->delete(); + } + + $this->info("Se han limpiado los QRs de $total eventos antiguos (Antigüedad > $dias días). Los eventos permanecen en el sistema."); + } +} diff --git a/app/Console/Commands/OptimizeImages.php b/app/Console/Commands/OptimizeImages.php new file mode 100644 index 0000000..d2a430c --- /dev/null +++ b/app/Console/Commands/OptimizeImages.php @@ -0,0 +1,196 @@ + ['maxWidth' => 1600, 'quality' => 82], + 'noticias' => ['maxWidth' => 1200, 'quality' => 82], + 'promos' => ['maxWidth' => 1200, 'quality' => 82], + 'clubes' => ['maxWidth' => 512, 'quality' => 85], + 'sponsors' => ['maxWidth' => 600, 'quality' => 85], + 'qr' => ['maxWidth' => 800, 'quality' => 85], + ]; + + public function handle(): int + { + $apply = (bool) $this->option('apply'); + $only = $this->option('folder'); + $noBackup = (bool) $this->option('no-backup'); + + if (!extension_loaded('gd')) { + $this->error('La extension GD de PHP no esta instalada.'); + return self::FAILURE; + } + + // Usa la config real del disk 'public' (respeta override de Hostinger) + $base = realpath(config('filesystems.disks.public.root')) ?: config('filesystems.disks.public.root'); + if (!is_dir($base)) { + $this->error("No existe: $base"); + return self::FAILURE; + } + $this->line("Base: $base"); + + $backupBase = $base . '/_backup_optimize'; + if ($apply && !$noBackup && !is_dir($backupBase)) { + mkdir($backupBase, 0755, true); + } + + $this->line(''); + $this->line($apply + ? 'MODO APLICACION — los archivos seran reemplazados.' + : 'MODO DRY-RUN — no se modifica nada. Pasa --apply para aplicar.'); + $this->line(''); + + $totalOrig = 0; $totalNew = 0; $totalProcessed = 0; $totalSkipped = 0; + + foreach ($this->config as $folder => $cfg) { + if ($only && $only !== $folder) continue; + + $path = $base . '/' . $folder; + if (!is_dir($path)) { + $this->warn(" - $folder/ (no existe, skip)"); + continue; + } + + $this->info("Carpeta: $folder/ (max {$cfg['maxWidth']}px, q={$cfg['quality']})"); + $files = glob($path . '/*.{jpg,jpeg,png,webp,JPG,JPEG,PNG,WEBP}', GLOB_BRACE); + + $folderOrig = 0; $folderNew = 0; $folderProcessed = 0; $folderSkipped = 0; + + foreach ($files as $file) { + $origSize = filesize($file); + $totalOrig += $origSize; $folderOrig += $origSize; + + $info = @getimagesize($file); + if (!$info) { $folderSkipped++; $totalSkipped++; continue; } + + [$w, $h] = $info; + $needsResize = $w > $cfg['maxWidth']; + $needsRecomp = $origSize > 100 * 1024; // skip si ya pesa <100KB + + if (!$needsResize && !$needsRecomp) { + $folderSkipped++; $totalSkipped++; $totalNew += $origSize; $folderNew += $origSize; + continue; + } + + $result = $this->processImage($file, $cfg['maxWidth'], $cfg['quality']); + if ($result === null) { + $folderSkipped++; $totalSkipped++; $totalNew += $origSize; $folderNew += $origSize; + continue; + } + + [$newBytes, $newW, $newH] = $result; + + if ($newBytes >= $origSize) { + $folderSkipped++; $totalSkipped++; $totalNew += $origSize; $folderNew += $origSize; + $this->line(sprintf(" - %-40s %dKB no mejora, skip", basename($file), $origSize / 1024)); + continue; + } + + $folderProcessed++; $totalProcessed++; + $totalNew += $newBytes; $folderNew += $newBytes; + + $reduction = round((1 - $newBytes / $origSize) * 100, 1); + $this->line(sprintf( + " %s %-40s %dKB -> %dKB (-%s%%) %dx%d -> %dx%d", + $apply ? 'OK' : '--', + basename($file), $origSize / 1024, $newBytes / 1024, $reduction, + $w, $h, $newW, $newH + )); + + if ($apply) { + if (!$noBackup) { + $backupDir = $backupBase . '/' . $folder; + if (!is_dir($backupDir)) mkdir($backupDir, 0755, true); + copy($file, $backupDir . '/' . basename($file)); + } + file_put_contents($file, $result[3]); + } + } + + $this->line(sprintf( + " -> %s files procesados, %s skip, %s -> %s (-%s%%)", + $folderProcessed, $folderSkipped, + $this->fmt($folderOrig), $this->fmt($folderNew), + $folderOrig > 0 ? round((1 - $folderNew / max($folderOrig, 1)) * 100, 1) : 0 + )); + $this->line(''); + } + + $this->line(str_repeat('=', 60)); + $this->info(sprintf( + 'TOTAL: %d procesados, %d skip. %s -> %s (ahorro: %s, -%s%%)', + $totalProcessed, $totalSkipped, + $this->fmt($totalOrig), $this->fmt($totalNew), + $this->fmt($totalOrig - $totalNew), + $totalOrig > 0 ? round((1 - $totalNew / max($totalOrig, 1)) * 100, 1) : 0 + )); + + if (!$apply && $totalProcessed > 0) { + $this->line(''); + $this->warn('Para aplicar realmente: php artisan optimize:images --apply'); + } + + return self::SUCCESS; + } + + private function processImage(string $file, int $maxWidth, int $quality): ?array + { + $ext = strtolower(pathinfo($file, PATHINFO_EXTENSION)); + + $img = match ($ext) { + 'jpg', 'jpeg' => @imagecreatefromjpeg($file), + 'png' => @imagecreatefrompng($file), + 'webp' => @imagecreatefromwebp($file), + default => null, + }; + if (!$img) return null; + + $w = imagesx($img); $h = imagesy($img); + $newW = $w; $newH = $h; + + if ($w > $maxWidth) { + $newW = $maxWidth; + $newH = (int) round($h * ($maxWidth / $w)); + $resized = imagecreatetruecolor($newW, $newH); + + if (in_array($ext, ['png', 'webp'])) { + imagealphablending($resized, false); + imagesavealpha($resized, true); + $transparent = imagecolorallocatealpha($resized, 0, 0, 0, 127); + imagefilledrectangle($resized, 0, 0, $newW, $newH, $transparent); + } + + imagecopyresampled($resized, $img, 0, 0, 0, 0, $newW, $newH, $w, $h); + $img = $resized; + } + + ob_start(); + match ($ext) { + 'jpg', 'jpeg' => imagejpeg($img, null, $quality), + 'png' => imagepng($img, null, 9), + 'webp' => imagewebp($img, null, $quality), + }; + $bytes = ob_get_clean(); + + return [strlen($bytes), $newW, $newH, $bytes]; + } + + private function fmt(int $bytes): string + { + if ($bytes < 1024) return $bytes . 'B'; + if ($bytes < 1024 * 1024) return round($bytes / 1024, 1) . 'KB'; + return round($bytes / 1024 / 1024, 2) . 'MB'; + } +} diff --git a/app/Console/Commands/PurgeAgentThreads.php b/app/Console/Commands/PurgeAgentThreads.php new file mode 100644 index 0000000..c95e329 --- /dev/null +++ b/app/Console/Commands/PurgeAgentThreads.php @@ -0,0 +1,21 @@ +delete(); + + $this->info("Se eliminaron {$deleted} hilo(s) expirado(s)."); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/RecordatorioPartidos.php b/app/Console/Commands/RecordatorioPartidos.php new file mode 100644 index 0000000..2f56a12 --- /dev/null +++ b/app/Console/Commands/RecordatorioPartidos.php @@ -0,0 +1,86 @@ +addHours(24); + $hasta = Carbon::now()->addHours(50); // ventana de ~26hs para no perder ninguno + + $eventos = Evento::with(['equipoLocal.club', 'equipoVisitante.club']) + ->whereNotNull('id_equipo_local') + ->whereNotNull('id_equipo_visitante') + ->whereBetween('fecha_evento', [$desde->toDateString(), $hasta->toDateString()]) + ->get(); + + if ($eventos->isEmpty()) { + $this->info('No hay partidos en las próximas 48hs.'); + return self::SUCCESS; + } + + $totalNotif = 0; + + foreach ($eventos as $evento) { + $nombreLocal = $evento->equipoLocal->club->nombre ?? '?'; + $nombreVisitante = $evento->equipoVisitante->club->nombre ?? '?'; + $fechaStr = $evento->fecha_evento->format('d/m/Y'); + $horaStr = $evento->hora_inicio ? $evento->hora_inicio->format('H:i') : ''; + $sedeStr = $evento->sede ? " | {$evento->sede}" : ''; + + $titulo = "⏰ Partido mañana: {$nombreLocal} vs {$nombreVisitante}"; + $mensaje = "Recordatorio: el partido es el {$fechaStr}" . ($horaStr ? " a las {$horaStr}" : '') . $sedeStr . '.'; + $url = '/eventos/' . $evento->id_evento; + + // Recolectar destinatarios + $idEquipos = array_filter([$evento->id_equipo_local, $evento->id_equipo_visitante]); + $destinatarios = []; + $yaAgregados = []; + + $seguimientos = EquipoSeguimiento::whereIn('id_equipo', $idEquipos)->get(); + foreach ($seguimientos as $s) { + $key = $s->tipo_usuario . ':' . $s->id_usuario; + if (!isset($yaAgregados[$key])) { + $destinatarios[] = ['tipo' => $s->tipo_usuario, 'id' => $s->id_usuario]; + $yaAgregados[$key] = true; + } + } + + $jugadores = \DB::table('jugador_equipo') + ->whereIn('id_equipo', $idEquipos) + ->pluck('id_jugador'); + + foreach ($jugadores as $idJ) { + $key = 'jugador:' . $idJ; + if (!isset($yaAgregados[$key])) { + $destinatarios[] = ['tipo' => 'jugador', 'id' => $idJ]; + $yaAgregados[$key] = true; + } + } + + if (empty($destinatarios)) continue; + + if ($this->option('test')) { + $this->line(" [TEST] Evento {$evento->id_evento}: {$titulo} → " . count($destinatarios) . " destinatarios"); + } else { + $notifService->enviarMasivo($destinatarios, 'partido', $titulo, $mensaje, $url); + } + + $totalNotif += count($destinatarios); + } + + $this->info("✅ Recordatorios enviados: {$eventos->count()} partidos, {$totalNotif} notificaciones."); + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/ReporteSemanal.php b/app/Console/Commands/ReporteSemanal.php new file mode 100644 index 0000000..9eb1abb --- /dev/null +++ b/app/Console/Commands/ReporteSemanal.php @@ -0,0 +1,106 @@ +copy()->subDays(7)->startOfDay(); + $semanaAnteriorHasta = $ahora->copy()->subDay()->endOfDay(); + $proximaSemanaHasta = $ahora->copy()->addDays(7)->endOfDay(); + + // ── Partidos jugados la semana anterior ── + $jugados = Evento::with(['equipoLocal.club', 'equipoVisitante.club']) + ->whereBetween('fecha_evento', [$semanaAnteriorDesde->toDateString(), $semanaAnteriorHasta->toDateString()]) + ->whereNotNull('marcador_local') + ->get(); + + // ── Próximos partidos ── + $proximos = Evento::with(['equipoLocal.club', 'equipoVisitante.club']) + ->where('fecha_evento', '>=', $ahora->toDateString()) + ->where('fecha_evento', '<=', $proximaSemanaHasta->toDateString()) + ->orderBy('fecha_evento') + ->orderBy('hora_inicio') + ->get(); + + // ── Top goleadores ── + $topGoleadores = DB::table('evento_jugador') + ->join('jugadores', 'evento_jugador.id_jugador', '=', 'jugadores.id_jugador') + ->selectRaw('jugadores.nombre, jugadores.apellido, SUM(evento_jugador.puntos) as total_puntos, COUNT(evento_jugador.id_evento) as partidos') + ->groupBy('jugadores.id_jugador', 'jugadores.nombre', 'jugadores.apellido') + ->orderByDesc('total_puntos') + ->limit(10) + ->get(); + + // ── QRs de la semana ── + $qrsSemana = DB::table('qr_codes') + ->where('creado', '>=', $semanaAnteriorDesde) + ->count(); + + $qrsValidados = DB::table('qr_codes') + ->where('creado', '>=', $semanaAnteriorDesde) + ->where('escaneos_restantes', 0) + ->count(); + + $data = compact('jugados', 'proximos', 'topGoleadores', 'qrsSemana', 'qrsValidados', 'semanaAnteriorDesde', 'semanaAnteriorHasta'); + + if ($this->option('dry-run')) { + $this->info('=== REPORTE SEMANAL (DRY RUN) ==='); + $this->line("Partidos jugados: {$jugados->count()}"); + $this->line("Próximos partidos: {$proximos->count()}"); + $this->line("Top goleador: " . ($topGoleadores->first() ? $topGoleadores->first()->apellido . ' (' . $topGoleadores->first()->total_puntos . ' pts)' : 'N/A')); + $this->line("QRs generados: {$qrsSemana} | Validados: {$qrsValidados}"); + return self::SUCCESS; + } + + // 1. Priorizar email configurado en sistema + $emails = []; + $configEmail = \App\Models\Configuracion::get('email_reportes'); + if ($configEmail) { + $emails[] = $configEmail; + } + + // 2. Si no hay, buscar superadmins (rol=1) + if (empty($emails)) { + $emails = DB::table('admin_users')->where('rol', 1)->whereNotNull('email')->pluck('email')->toArray(); + } + + // 3. Fallback: email del .env + if (empty($emails)) { + $fallback = config('mail.from.address'); + if ($fallback) $emails = [$fallback]; + } + + if (empty($emails)) { + $this->error('No hay destinatarios configurados para enviar el reporte.'); + return self::FAILURE; + } + + try { + Mail::send('emails.reporte_semanal', $data, function ($mail) use ($emails, $ahora) { + $mail->to($emails) + ->subject("📊 Reporte Semanal ONAPB — Semana del " . $ahora->startOfWeek()->format('d/m')); + }); + $this->info("✅ Reporte enviado a: " . implode(', ', $emails)); + } catch (\Exception $e) { + Log::error('Error enviando reporte semanal: ' . $e->getMessage()); + $this->error('Error: ' . $e->getMessage()); + return self::FAILURE; + } + + return self::SUCCESS; + } +} diff --git a/app/Http/Controllers/Admin/AdminUserController.php b/app/Http/Controllers/Admin/AdminUserController.php new file mode 100644 index 0000000..3042a13 --- /dev/null +++ b/app/Http/Controllers/Admin/AdminUserController.php @@ -0,0 +1,115 @@ +checkSuperAdmin($request); + $usuarios = AdminUser::with('club')->orderBy('id', 'desc')->paginate(20); + return view('admin.usuarios.index', compact('usuarios')); + } + + public function create(Request $request) + { + $this->checkSuperAdmin($request); + $usuario = null; + $clubes = Club::orderBy('nombre')->get(); + return view('admin.usuarios.form', compact('usuario', 'clubes')); + } + + public function store(Request $request) + { + $this->checkSuperAdmin($request); + + $data = $request->validate([ + 'username' => 'required|string|max:50|unique:admin_users', + 'password' => 'required|string|min:6', + 'role' => 'required|integer|in:1,2', + 'id_club' => 'nullable|integer|exists:clubes,id_club' + ]); + + if ($data['role'] == 2 && empty($data['id_club'])) { + return back()->withErrors(['id_club' => 'Si el rol es Admin de Club, se requiere un club asociado.'])->withInput(); + } + + if ($data['role'] == 1) { + $data['id_club'] = null; // Superadmins no pertenecen a un club específico en este contexto + } + + $data['password'] = Hash::make($data['password']); + + AdminUser::create($data); + + return redirect()->route('admin.usuarios.index')->with('admin_msg', 'Administrador creado exitosamente.'); + } + + public function edit(Request $request, $id) + { + $this->checkSuperAdmin($request); + $usuario = AdminUser::findOrFail($id); + $clubes = Club::orderBy('nombre')->get(); + return view('admin.usuarios.form', compact('usuario', 'clubes')); + } + + public function update(Request $request, $id) + { + $this->checkSuperAdmin($request); + $usuario = AdminUser::findOrFail($id); + + $data = $request->validate([ + 'username' => ['required', 'string', 'max:50', Rule::unique('admin_users')->ignore($usuario->id)], + 'password' => 'nullable|string|min:6', + 'role' => 'required|integer|in:1,2', + 'id_club' => 'nullable|integer|exists:clubes,id_club' + ]); + + if ($data['role'] == 2 && empty($data['id_club'])) { + return back()->withErrors(['id_club' => 'Si el rol es Admin de Club, se requiere un club asociado.'])->withInput(); + } + + if ($data['role'] == 1) { + $data['id_club'] = null; + } + + if (!empty($data['password'])) { + $data['password'] = Hash::make($data['password']); + } else { + unset($data['password']); + } + + $usuario->update($data); + + return redirect()->route('admin.usuarios.index')->with('admin_msg', 'Administrador actualizado exitosamente.'); + } + + public function destroy(Request $request, $id) + { + $this->checkSuperAdmin($request); + + $usuario = AdminUser::findOrFail($id); + + if ($usuario->id == session('admin_id')) { + return back()->with('admin_error', 'No puedes eliminar tu propio usuario.'); + } + + $usuario->delete(); + + return redirect()->route('admin.usuarios.index')->with('admin_msg', 'Administrador eliminado.'); + } +} diff --git a/app/Http/Controllers/Admin/CarouselItemController.php b/app/Http/Controllers/Admin/CarouselItemController.php new file mode 100644 index 0000000..afecb19 --- /dev/null +++ b/app/Http/Controllers/Admin/CarouselItemController.php @@ -0,0 +1,113 @@ +checkGeneralAdmin($request); + $items = CarouselItem::orderBy('orden', 'asc')->latest()->get(); + return view('admin.carousel.index', compact('items')); + } + + public function create(Request $request) + { + $this->checkGeneralAdmin($request); + return view('admin.carousel.create'); + } + + public function store(Request $request) + { + $this->checkGeneralAdmin($request); + $request->validate([ + 'titulo' => 'nullable|string|max:255', + 'subtitulo' => 'nullable|string|max:255', + 'boton_texto' => 'nullable|string|max:255', + 'boton_enlace' => 'nullable|string|max:255', + 'imagen' => 'required|image|mimes:jpeg,png,jpg,webp|max:5120', + 'orden' => 'nullable|integer', + 'activo' => 'nullable|boolean', + ]); + + $data = $request->except('imagen'); + $data['activo'] = $request->has('activo'); + + if ($request->hasFile('imagen')) { + $path = app(ImageOptimizer::class)->storeAndOptimize($request->file('imagen'), 'carousel'); + $data['imagen'] = 'storage/' . $path; + } + + CarouselItem::create($data); + + return redirect()->route('admin.carousel.index')->with('admin_msg', 'Slide creado exitosamente.'); + } + + public function edit(Request $request, CarouselItem $carouselItem) + { + $this->checkGeneralAdmin($request); + return view('admin.carousel.edit', compact('carouselItem')); + } + + public function update(Request $request, CarouselItem $carouselItem) + { + $this->checkGeneralAdmin($request); + $request->validate([ + 'titulo' => 'nullable|string|max:255', + 'subtitulo' => 'nullable|string|max:255', + 'boton_texto' => 'nullable|string|max:255', + 'boton_enlace' => 'nullable|string|max:255', + 'imagen' => 'nullable|image|mimes:jpeg,png,jpg,webp|max:5120', + 'orden' => 'nullable|integer', + 'activo' => 'nullable|boolean', + ]); + + $data = $request->except('imagen'); + $data['activo'] = $request->has('activo'); + + if ($request->hasFile('imagen')) { + // Eliminar imagen anterior si existe + if ($carouselItem->imagen) { + $oldPath = public_path($carouselItem->imagen); + if (File::exists($oldPath)) { + File::delete($oldPath); + } + } + + $path = app(ImageOptimizer::class)->storeAndOptimize($request->file('imagen'), 'carousel'); + $data['imagen'] = 'storage/' . $path; + } + + $carouselItem->update($data); + + return redirect()->route('admin.carousel.index')->with('admin_msg', 'Slide actualizado exitosamente.'); + } + + public function destroy(Request $request, CarouselItem $carouselItem) + { + $this->checkGeneralAdmin($request); + if ($carouselItem->imagen) { + $imagePath = public_path($carouselItem->imagen); + if (File::exists($imagePath)) { + File::delete($imagePath); + } + } + + $carouselItem->delete(); + + return redirect()->route('admin.carousel.index')->with('admin_msg', 'Slide eliminado exitosamente.'); + } +} diff --git a/app/Http/Controllers/Admin/CategoriaController.php b/app/Http/Controllers/Admin/CategoriaController.php new file mode 100644 index 0000000..66a8ef3 --- /dev/null +++ b/app/Http/Controllers/Admin/CategoriaController.php @@ -0,0 +1,74 @@ +checkSuperAdmin($request); + $categorias = Categoria::latest()->get(); + return view('admin.categorias.index', compact('categorias')); + } + + public function create(Request $request) + { + $this->checkSuperAdmin($request); + return view('admin.categorias.form', ['categoria' => null]); + } + + public function store(Request $request) + { + $this->checkSuperAdmin($request); + $data = $request->validate([ + 'nombre' => 'required|string|max:50', + 'edad_min' => 'required|integer', + 'edad_max' => 'required|integer|gte:edad_min', + 'genero' => 'nullable|string|max:20', + ]); + $data['es_libre'] = $request->has('es_libre'); + Categoria::create($data); + return redirect()->route('admin.categorias.index')->with('admin_msg', 'Categoría creada.'); + } + + public function edit(Request $request, $id) + { + $this->checkSuperAdmin($request); + $categoria = Categoria::findOrFail($id); + return view('admin.categorias.form', compact('categoria')); + } + + public function update(Request $request, $id) + { + $this->checkSuperAdmin($request); + $categoria = Categoria::findOrFail($id); + $data = $request->validate([ + 'nombre' => 'required|string|max:50', + 'edad_min' => 'required|integer', + 'edad_max' => 'required|integer|gte:edad_min', + 'genero' => 'nullable|string|max:20', + ]); + $data['es_libre'] = $request->has('es_libre'); + $categoria->update($data); + return redirect()->route('admin.categorias.index')->with('admin_msg', 'Categoría actualizada.'); + } + + public function destroy(Request $request, $id) + { + $this->checkSuperAdmin($request); + $categoria = Categoria::findOrFail($id); + $categoria->delete(); + return redirect()->route('admin.categorias.index')->with('admin_msg', 'Categoría eliminada.'); + } +} diff --git a/app/Http/Controllers/Admin/FixtureController.php b/app/Http/Controllers/Admin/FixtureController.php new file mode 100644 index 0000000..7101fa7 --- /dev/null +++ b/app/Http/Controllers/Admin/FixtureController.php @@ -0,0 +1,380 @@ +fixtureService = $fixtureService; + } + + private function checkSuperAdmin() + { + if (!session('admin_logged_in') || session('admin_role') != 1) { + abort(403, 'Solo Súper Administradores pueden generar fixtures.'); + } + } + + /** + * POST /admin/torneos/{id}/generar-fixture — Preview del fixture + */ + public function preview(Request $request, int $id) + { + $this->checkSuperAdmin(); + + $torneo = Torneo::with('equipos.club')->findOrFail($id); + + $request->validate([ + 'fecha_inicio' => 'required|date|after_or_equal:today', + 'dias_entre_jornadas' => 'required|integer|min:1|max:60', + 'sede_default' => 'nullable|string|max:200', + 'doble_rueda' => 'nullable|boolean', + ]); + + try { + $partidos = $this->fixtureService->generarRoundRobin( + torneo: $torneo, + fechaInicio: $request->input('fecha_inicio'), + diasEntreJornadas: (int) $request->input('dias_entre_jornadas', 7), + sedeDefault: $request->input('sede_default', ''), + dobleRueda: (bool) $request->input('doble_rueda', false), + ); + + // Enriquecer con nombres para la vista + $partidosEnriquecidos = collect($partidos)->map(function ($p) { + $local = \App\Models\Equipo::with('club')->find($p['id_equipo_local']); + $visitante = \App\Models\Equipo::with('club')->find($p['id_equipo_visitante']); + return array_merge($p, [ + 'nombre_local' => ($local->club->nombre ?? '?') . ' (' . ($local->categoria ?? '') . ')', + 'nombre_visitante' => ($visitante->club->nombre ?? '?') . ' (' . ($visitante->categoria ?? '') . ')', + ]); + })->toArray(); + + } catch (\InvalidArgumentException $e) { + return back()->with('admin_error', $e->getMessage()); + } + + $fixtureParams = [ + 'fecha_inicio' => $request->input('fecha_inicio'), + 'dias_entre_jornadas' => $request->input('dias_entre_jornadas', 7), + 'sede_default' => $request->input('sede_default', ''), + 'doble_rueda' => (bool) $request->input('doble_rueda', false), + ]; + + return view('admin.torneos.fixture_preview', compact('torneo', 'partidosEnriquecidos', 'fixtureParams')); + + } + + /** + * POST /admin/torneos/{id}/confirmar-fixture — Persiste el fixture + */ + public function confirmar(Request $request, int $id) + { + $this->checkSuperAdmin(); + + $torneo = Torneo::with('equipos.club')->findOrFail($id); + + $request->validate([ + 'fecha_inicio' => 'required|date', + 'dias_entre_jornadas' => 'required|integer|min:1', + 'sede_default' => 'nullable|string|max:200', + 'doble_rueda' => 'nullable|boolean', + ]); + + try { + $partidos = $this->fixtureService->generarRoundRobin( + torneo: $torneo, + fechaInicio: $request->input('fecha_inicio'), + diasEntreJornadas: (int) $request->input('dias_entre_jornadas', 7), + sedeDefault: $request->input('sede_default', ''), + dobleRueda: (bool) $request->input('doble_rueda', false), + ); + + $creados = $this->fixtureService->persistirFixture($partidos, $torneo); + + } catch (\InvalidArgumentException $e) { + return back()->with('admin_error', $e->getMessage()); + } + + return redirect()->route('admin.torneos.edit', $id) + ->with('admin_msg', "✅ Fixture generado correctamente: {$creados} partidos creados."); + } + + public function importForm(int $id) + { + $this->checkSuperAdmin(); + $torneo = Torneo::findOrFail($id); + return view('admin.torneos.importar', compact('torneo')); + } + + public function importStore(Request $request, int $id) + { + $this->checkSuperAdmin(); + $torneo = Torneo::with(['equipos.club'])->findOrFail($id); + + $request->validate([ + 'texto_importar' => 'required|string', + ]); + + $lineas = explode("\n", $request->input('texto_importar')); + $creados = 0; + $errores = []; + + foreach ($lineas as $index => $linea) { + $linea = trim($linea); + if (empty($linea)) continue; + + try { + // Formato esperado: Fecha, ClubL, CatL, ClubV, CatV, MarcadorL, MarcadorV, Sede, Grupo + $partes = str_getcsv($linea); + + if (count($partes) < 7) { + throw new \Exception("Formato insuficiente. Se esperan al menos 7 columnas."); + } + + $fechaRaw = trim($partes[0]); + $fecha = \Carbon\Carbon::parse(str_replace('/', '-', $fechaRaw))->format('Y-m-d'); + + $nombreClubL = trim($partes[1]); + $catL = trim($partes[2]); + $nombreClubV = trim($partes[3]); + $catV = trim($partes[4]); + $marcadorL = trim($partes[5]); + $marcadorV = trim($partes[6]); + $sede = $partes[7] ?? null; + $grupo = $partes[8] ?? null; + + // Buscar equipos con validación de grupo (prioridad) y categoría + $equipoL = $this->buscarEquipo($nombreClubL, $catL, $torneo, $grupo); + $equipoV = $this->buscarEquipo($nombreClubV, $catV, $torneo, $grupo); + + if (!$equipoL || !$equipoV) { + $missing = !$equipoL ? "'{$nombreClubL} ({$catL})'" : "'{$nombreClubV} ({$catV})'"; + throw new \Exception("No se encontró el equipo {$missing} en el grupo '" . ($grupo ?? 'N/A') . "' ni por categoría."); + } + + \App\Models\Evento::create([ + 'id_evento' => uniqid('ev_imp_'), + 'id_torneo' => $id, + 'fase' => \App\Models\Evento::FASE_REGULAR, + 'fecha_evento' => $fecha, + 'id_equipo_local' => $equipoL->id_equipo, + 'id_equipo_visitante' => $equipoV->id_equipo, + 'marcador_local' => $marcadorL !== '' ? (int)$marcadorL : null, + 'marcador_visitante' => $marcadorV !== '' ? (int)$marcadorV : null, + 'nombre_evento' => $equipoL->club->nombre . ' vs ' . $equipoV->club->nombre, + 'hora_inicio' => '19:00:00', + 'hora_fin' => '21:00:00', + 'sede' => $sede, + 'precio' => 0, + ]); + + $creados++; + } catch (\Exception $e) { + $errores[] = "Línea " . ($index + 1) . ": " . $e->getMessage(); + continue; + } + } + + $msg = "Se importaron {$creados} partidos exitosamente."; + if (count($errores) > 0) { + return back()->with('admin_msg', $msg)->with('admin_error', "Se detectaron errores en " . count($errores) . " líneas:
" . implode("
", $errores)); + } + + return redirect()->route('admin.torneos.edit', $id)->with('admin_msg', $msg); + } + + private function buscarEquipo(string $clubName, string $category, Torneo $torneo, ?string $grupo = null) + { + $clubNameNorm = $this->normalizeString($clubName); + $categoryNorm = $this->normalizeString($category); + $grupoNorm = $grupo ? $this->normalizeString($grupo) : null; + + // PASO 1: Prioridad absoluta al GRUPO (vía pivot) + if ($grupoNorm) { + $match = $torneo->equipos->first(function($e) use ($clubNameNorm, $grupoNorm) { + $eClubName = $this->normalizeString($e->club->nombre ?? ''); + $eGrupo = $this->normalizeString($e->pivot->grupo ?? ''); + + return ($eGrupo === $grupoNorm) && + (strpos($eClubName, $clubNameNorm) !== false || strpos($clubNameNorm, $eClubName) !== false); + }); + if ($match) return $match; + } + + // PASO 2: Fallback a búsqueda por CATEGORÍA si el grupo no coincide o no se especificó + return $torneo->equipos->first(function($e) use ($clubNameNorm, $categoryNorm) { + $eClubName = $this->normalizeString($e->club->nombre ?? ''); + $eCategory = $this->normalizeString($e->categoria ?? ''); + + $matchClub = (strpos($eClubName, $clubNameNorm) !== false || strpos($clubNameNorm, $eClubName) !== false); + $matchCat = (strpos($eCategory, $categoryNorm) !== false || strpos($categoryNorm, $eCategory) !== false); + + return $matchClub && $matchCat; + }); + } + + private function normalizeString(?string $str): string + { + if (!$str) return ''; + $str = mb_strtolower(trim($str), 'UTF-8'); + $str = str_replace( + ['á', 'é', 'í', 'ó', 'ú', 'ü', 'ñ'], + ['a', 'e', 'i', 'o', 'u', 'u', 'n'], + $str + ); + // Eliminar caracteres no alfanuméricos para un matching más flexible + return preg_replace('/[^a-z0-9]/', '', $str); + } + + public function generarPlayoffs(Request $request, int $id) + { + $this->checkSuperAdmin(); + $torneo = Torneo::findOrFail($id); + $grupo = $request->input('grupo'); + $formato = (int) $request->input('formato', 1); // 1, 3, 5 + + if (!$grupo) { + return back()->with('admin_error', 'Debes seleccionar un grupo para generar los playoffs.'); + } + + $ts = new \App\Services\TournamentService(); + $standings = $ts->getStandings($id, true); + + if (!isset($standings[$grupo])) { + return back()->with('admin_error', "No hay equipos en el grupo {$grupo}."); + } + + $top8 = array_slice($standings[$grupo], 0, 8); + + if (count($top8) < 2) { + return back()->with('admin_error', "Se necesitan al menos 2 equipos para generar playoffs."); + } + + // Determinar emparejamientos (1 vs 8, 4 vs 5, 2 vs 7, 3 vs 6) + // Usamos el seeding oficial: 1v8, 4v5, 2v7, 3v6 + $parejas = [ + ['local' => $top8[0]['id'], 'visit' => $top8[7]['id'] ?? null, 'nro' => 1], + ['local' => $top8[3]['id'] ?? null, 'visit' => $top8[4]['id'] ?? null, 'nro' => 2], + ['local' => $top8[1]['id'] ?? null, 'visit' => $top8[6]['id'] ?? null, 'nro' => 3], + ['local' => $top8[2]['id'] ?? null, 'visit' => $top8[5]['id'] ?? null, 'nro' => 4], + ]; + + $count = 0; + foreach ($parejas as $p) { + if (!$p['local'] || !$p['visit']) continue; + + $local = \App\Models\Equipo::with('club')->find($p['local']); + $visit = \App\Models\Equipo::with('club')->find($p['visit']); + + // Crear N partidos según el formato + for ($i = 0; $i < $formato; $i++) { + \App\Models\Evento::create([ + 'id_evento' => uniqid('ply_'), + 'id_torneo' => $id, + 'fase' => \App\Models\Evento::FASE_CUARTOS, + 'numero_partido_bracket' => $p['nro'], + 'id_equipo_local' => $p['local'], + 'id_equipo_visitante' => $p['visit'], + 'nombre_evento' => "4tos (J".($i+1)."): " . $local->club->nombre . " vs " . $visit->club->nombre . " ({$grupo})", + 'fecha_evento' => now()->addDays(7 + ($i * 3))->format('Y-m-d'), + 'hora_inicio' => '20:00:00', + 'hora_fin' => '22:00:00', + 'precio' => 0, + ]); + $count++; + } + } + + return redirect()->route('admin.torneos.playoffs.manage', $id) + ->with('admin_msg', "Playoffs generados: {$count} partidos creados para el grupo {$grupo}."); + } + + public function managePlayoffs(int $id) + { + $this->checkSuperAdmin(); + $torneo = Torneo::findOrFail($id); + + $ts = new \App\Services\TournamentService(); + $bracket = $ts->getPlayoffBrackets($id); + + $grupos = \DB::table('torneo_equipo')->where('id_torneo', $id)->distinct()->pluck('grupo')->filter(); + + return view('admin.torneos.playoff_manage', compact('torneo', 'bracket', 'grupos')); + } + + public function avanzarGanador(Request $request, int $id) + { + $this->checkSuperAdmin(); + $faseActual = (int) $request->input('fase'); + $nroBracket = (int) $request->input('nro_bracket'); + $idGanador = (int) $request->input('id_ganador'); + + $torneo = Torneo::findOrFail($id); + $ganador = \App\Models\Equipo::with('club')->findOrFail($idGanador); + + // Mapeo de avance + // Cuartos 1 & 2 -> Semis 1 + // Cuartos 3 & 4 -> Semis 2 + // Semis 1 & 2 -> Final 1 + $mapping = [ + \App\Models\Evento::FASE_CUARTOS => [ + 1 => ['next_fase' => \App\Models\Evento::FASE_SEMIS, 'next_nro' => 1, 'side' => 'local'], + 2 => ['next_fase' => \App\Models\Evento::FASE_SEMIS, 'next_nro' => 1, 'side' => 'visitante'], + 3 => ['next_fase' => \App\Models\Evento::FASE_SEMIS, 'next_nro' => 2, 'side' => 'local'], + 4 => ['next_fase' => \App\Models\Evento::FASE_SEMIS, 'next_nro' => 2, 'side' => 'visitante'], + ], + \App\Models\Evento::FASE_SEMIS => [ + 1 => ['next_fase' => \App\Models\Evento::FASE_FINAL, 'next_nro' => 1, 'side' => 'local'], + 2 => ['next_fase' => \App\Models\Evento::FASE_FINAL, 'next_nro' => 1, 'side' => 'visitante'], + ] + ]; + + if (!isset($mapping[$faseActual][$nroBracket])) { + return back()->with('admin_error', 'No se puede avanzar desde esta fase.'); + } + + $next = $mapping[$faseActual][$nroBracket]; + + // Buscar si ya existe el partido en la siguiente fase + $eventoNext = \App\Models\Evento::where('id_torneo', $id) + ->where('fase', $next['next_fase']) + ->where('numero_partido_bracket', $next['next_nro']) + ->first(); + + if ($eventoNext) { + if ($next['side'] == 'local') { + $eventoNext->id_equipo_local = $idGanador; + } else { + $eventoNext->id_equipo_visitante = $idGanador; + } + $eventoNext->nombre_evento = ($eventoNext->equipoLocal->club->nombre ?? 'TBD') . ' vs ' . ($eventoNext->equipoVisitante->club->nombre ?? 'TBD'); + $eventoNext->save(); + } else { + // Crear el primer partido de la serie (por defecto 1 partido, el admin puede agregar más) + \App\Models\Evento::create([ + 'id_evento' => uniqid('ply_adv_'), + 'id_torneo' => $id, + 'fase' => $next['next_fase'], + 'numero_partido_bracket' => $next['next_nro'], + 'id_equipo_local' => $next['side'] == 'local' ? $idGanador : null, + 'id_equipo_visitante' => $next['side'] == 'visitante' ? $idGanador : null, + 'nombre_evento' => $next['side'] == 'local' ? ($ganador->club->nombre . " vs TBD") : ("TBD vs " . $ganador->club->nombre), + 'fecha_evento' => now()->addDays(14)->format('Y-m-d'), + 'hora_inicio' => '20:00:00', + 'hora_fin' => '22:00:00', + 'precio' => 0, + ]); + } + + return back()->with('admin_msg', "✅ Equipo {$ganador->club->nombre} avanzado a la siguiente ronda."); + } +} diff --git a/app/Http/Controllers/Admin/PaseController.php b/app/Http/Controllers/Admin/PaseController.php new file mode 100644 index 0000000..e05d4c5 --- /dev/null +++ b/app/Http/Controllers/Admin/PaseController.php @@ -0,0 +1,129 @@ +checkGeneralAdmin($request); + + $query = Pase::with(['jugador', 'clubOrigen', 'clubDestino']); + + if (session('admin_role') == 2) { + $idClub = session('admin_id_club'); + $query->where(function ($q) use ($idClub) { + $q->where('id_club_origen', $idClub) + ->orWhere('id_club_destino', $idClub); + }); + } + + $pases = $query->orderBy('created_at', 'desc')->paginate(20); + return view('admin.pases.index', compact('pases')); + } + + public function create(Request $request) + { + $this->checkGeneralAdmin($request); + return view('admin.pases.create'); + } + + public function store(Request $request) + { + $this->checkGeneralAdmin($request); + + $request->validate([ + 'documento' => 'required|string|exists:jugadores,documento' + ], [ + 'documento.exists' => 'No se encontró un jugador con ese DNI.' + ]); + + $jugador = Jugador::where('documento', $request->documento)->first(); + + // Si el jugador ya pertenece al club destino, error + $idClubDestino = session('admin_role') == 2 ? session('admin_id_club') : $request->input('id_club_destino'); + + if (!$idClubDestino) { + return back()->withErrors(['id_club_destino' => 'Debe especificar el club destino.']); + } + + if ($jugador->id_club_actual == $idClubDestino) { + return back()->withErrors(['documento' => 'Este jugador ya pertenece a tu club.']); + } + + // Crear la petición de pase + Pase::create([ + 'id_jugador' => $jugador->id_jugador, + 'id_club_origen' => $jugador->id_club_actual, + 'id_club_destino' => $idClubDestino, + 'estado' => 'Pendiente' + ]); + + return redirect()->route('admin.pases.index')->with('admin_msg', 'Solicitud de pase creada correctamente y está pendiente de aprobación.'); + } + + public function aprobar(Request $request, $id) + { + $this->checkSuperAdmin($request); + $pase = Pase::findOrFail($id); + + if ($pase->estado !== 'Pendiente') { + return back()->withErrors(['pase' => 'Este pase ya fue procesado.']); + } + + $pase->update(['estado' => 'Aprobado']); + + // Actualizar jugador + if ($pase->jugador) { + // Desvincular de equipos del club de origen + $equiposOldClubIds = $pase->jugador->equipos() + ->where('id_club', $pase->id_club_origen) + ->pluck('equipos.id_equipo'); + + if ($equiposOldClubIds->count() > 0) { + $pase->jugador->equipos()->detach($equiposOldClubIds); + } + + $pase->jugador->update([ + 'id_club_origen' => $pase->id_club_origen, + 'id_club_actual' => $pase->id_club_destino + ]); + } + + return redirect()->route('admin.pases.index')->with('admin_msg', 'Pase aprobado correctamente y jugador desvinculado de equipos anteriores.'); + } + + public function rechazar(Request $request, $id) + { + $this->checkSuperAdmin($request); + $pase = Pase::findOrFail($id); + + if ($pase->estado !== 'Pendiente') { + return back()->withErrors(['pase' => 'Este pase ya fue procesado.']); + } + + $pase->update(['estado' => 'Rechazado']); + + return redirect()->route('admin.pases.index')->with('admin_msg', 'Pase rechazado.'); + } +} diff --git a/app/Http/Controllers/AdminController.php b/app/Http/Controllers/AdminController.php new file mode 100644 index 0000000..10de9e6 --- /dev/null +++ b/app/Http/Controllers/AdminController.php @@ -0,0 +1,1667 @@ +checkGeneralAdmin($request); + + if (session('admin_role') == 1) { + $stats = [ + 'clubes' => Club::count(), + 'equipos' => Equipo::count(), + 'jugadores' => Jugador::count(), + 'eventos' => Evento::count(), + 'promociones' => Promocion::count(), + 'noticias' => Noticia::count(), + ]; + $miClub = null; + } else { + $idClub = session('admin_id_club'); + $miClub = Club::find($idClub); + + $stats = [ + 'equipos' => Equipo::where('id_club', $idClub)->count(), + 'jugadores' => Jugador::where('id_club_actual', $idClub)->count(), + 'eventos' => Evento::whereHas('equipoLocal', function($q) use ($idClub) { + $q->where('id_club', $idClub); + })->orWhereHas('equipoVisitante', function($q) use ($idClub) { + $q->where('id_club', $idClub); + })->count(), + 'promociones' => Promocion::count(), // Promociones son generales + ]; + } + + return view('admin.dashboard', compact('stats', 'miClub')); + } + + // ══════════════════════════════════ + // CLUBES + // ══════════════════════════════════ + public function clubesIndex(Request $request) + { + $this->checkSuperAdmin($request); + $search = trim((string) $request->input('q', '')); + + // Recién creados/editados primero; registros viejos sin timestamps caen al fallback por id. + $query = Club::withCount('equipos', 'jugadores') + ->orderByRaw("COALESCE(updated_at, '1970-01-01') DESC") + ->orderBy('id_club', 'desc'); + + if ($search !== '') { + $query->where('nombre', 'like', '%' . $search . '%'); + } + + $clubes = $query->get(); + return view('admin.clubes.index', compact('clubes', 'search')); + } + + public function clubesCreate(Request $request) + { + $this->checkSuperAdmin($request); + return view('admin.clubes.form', ['club' => null]); + } + + public function clubesStore(Request $request) + { + $this->checkSuperAdmin($request); + $data = $request->validate([ + 'id_club' => 'nullable|integer', + 'nombre' => 'required|string|max:100', + 'es_seleccion' => 'nullable|boolean', + ]); + $data['es_seleccion'] = $request->boolean('es_seleccion'); + Club::create($data); + return redirect()->route('admin.clubes.index')->with('admin_msg', 'Club creado correctamente.'); + } + + public function clubesEdit(Request $request, $id) + { + $this->checkGeneralAdmin($request); + if (session('admin_role') == 2 && session('admin_id_club') != $id) { + abort(403, 'No tienes permiso para editar este club.'); + } + $club = Club::findOrFail($id); + return view('admin.clubes.form', compact('club')); + } + + public function clubesUpdate(Request $request, $id) + { + $this->checkGeneralAdmin($request); + if (session('admin_role') == 2 && session('admin_id_club') != $id) { + abort(403, 'No tienes permiso para editar este club.'); + } + + $club = Club::findOrFail($id); + + $rules = [ + 'qr_color_texto' => 'nullable|string|max:20', + 'qr_background' => 'nullable|image|mimes:jpeg,png,jpg,webp|max:2048', + 'logo_club' => 'nullable|image|mimes:jpeg,png,jpg,webp|max:1024' + ]; + + if (session('admin_role') == 1) { + $rules['nombre'] = 'required|string|max:100'; + $rules['es_seleccion'] = 'nullable|boolean'; + } + + $data = $request->validate($rules); + + if (session('admin_role') == 1) { + $data['es_seleccion'] = $request->boolean('es_seleccion'); + } + + // Manejo de Logo del Club + if ($request->hasFile('logo_club')) { + // Eliminar logo anterior si existe + if ($club->imagen && file_exists(public_path($club->imagen))) { + @unlink(public_path($club->imagen)); + } + $logoPath = app(ImageOptimizer::class)->storeAndOptimize($request->file('logo_club'), 'clubes'); + $data['imagen'] = 'storage/' . $logoPath; + } + + // Manejo de Fondo QR + if ($request->hasFile('qr_background')) { + // Eliminar fondo anterior si existe + if ($club->qr_background && file_exists(public_path($club->qr_background))) { + @unlink(public_path($club->qr_background)); + } + $path = app(ImageOptimizer::class)->storeAndOptimize($request->file('qr_background'), 'qr'); + $data['qr_background'] = 'storage/' . $path; + } + + $club->update($data); + + if (session('admin_role') == 2) { + return back()->with('admin_msg', 'Plantilla de QR actualizada correctamente.'); + } + + return redirect()->route('admin.clubes.index')->with('admin_msg', 'Club actualizado correctamente.'); + } + + public function clubesDestroy(Request $request, $id) + { + $this->checkSuperAdmin($request); + $club = Club::findOrFail($id); + $club->delete(); + return redirect()->route('admin.clubes.index')->with('admin_msg', 'Club eliminado correctamente.'); + } + + // ══════════════════════════════════ + // EQUIPOS + // ══════════════════════════════════ + public function equiposIndex(Request $request) + { + $this->checkGeneralAdmin($request); + $search = trim((string) $request->input('q', '')); + + $query = Equipo::with('club')->withCount('jugadores') + ->orderByRaw("COALESCE(updated_at, '1970-01-01') DESC") + ->orderBy('id_equipo', 'desc'); + + if (session('admin_role') == 2) { + $query->where('id_club', session('admin_id_club')); + } + + if ($search !== '') { + $like = '%' . $search . '%'; + $query->where(function ($q) use ($like, $search) { + $q->where('categoria', 'like', $like) + ->orWhere('division', 'like', $like) + ->orWhereHas('club', function ($c) use ($like) { + $c->where('nombre', 'like', $like); + }); + + if (ctype_digit($search)) { + $q->orWhere('id_equipo', (int) $search); + } + }); + } + + $equipos = $query->get(); + return view('admin.equipos.index', compact('equipos', 'search')); + } + + public function equiposCreate(Request $request) + { + $this->checkGeneralAdmin($request); + + if (session('admin_role') == 1) { + $clubes = Club::orderBy('nombre')->get(); + } else { + $clubes = Club::where('id_club', session('admin_id_club'))->get(); + } + + $categorias = \App\Models\Categoria::orderBy('nombre')->get(); + return view('admin.equipos.form', ['equipo' => null, 'clubes' => $clubes, 'categorias' => $categorias]); + } + + public function equiposStore(Request $request) + { + $this->checkGeneralAdmin($request); + + $data = $request->validate([ + 'id_club' => session('admin_role') == 1 ? 'required|integer|exists:clubes,id_club' : 'nullable', + 'categoria' => 'required|string|max:20', + 'division' => 'required|string|max:5', + ]); + + if (session('admin_role') == 2) { + $data['id_club'] = session('admin_id_club'); + } + + Equipo::create($data); + return redirect()->route('admin.equipos.index')->with('admin_msg', 'Equipo creado correctamente.'); + } + + public function equiposEdit(Request $request, $id) + { + $this->checkGeneralAdmin($request); + $equipo = Equipo::findOrFail($id); + + if (session('admin_role') == 2 && $equipo->id_club != session('admin_id_club')) { + abort(403, 'No tienes permiso para editar este equipo.'); + } + + if (session('admin_role') == 1) { + $clubes = Club::orderBy('nombre')->get(); + } else { + $clubes = Club::where('id_club', session('admin_id_club'))->get(); + } + + $categorias = \App\Models\Categoria::orderBy('nombre')->get(); + return view('admin.equipos.form', compact('equipo', 'clubes', 'categorias')); + } + + public function equiposUpdate(Request $request, $id) + { + $this->checkGeneralAdmin($request); + $equipo = Equipo::findOrFail($id); + + if (session('admin_role') == 2 && $equipo->id_club != session('admin_id_club')) { + abort(403, 'No tienes permiso para editar este equipo.'); + } + + $data = $request->validate([ + 'id_club' => session('admin_role') == 1 ? 'required|integer|exists:clubes,id_club' : 'nullable', + 'categoria' => 'required|string|max:20', + 'division' => 'required|string|max:5', + ]); + + if (session('admin_role') == 2) { + $data['id_club'] = session('admin_id_club'); + } + + $equipo->update($data); + return redirect()->route('admin.equipos.index')->with('admin_msg', 'Equipo actualizado correctamente.'); + } + + public function equiposDestroy(Request $request, $id) + { + $this->checkGeneralAdmin($request); + $equipo = Equipo::findOrFail($id); + + if (session('admin_role') == 2 && $equipo->id_club != session('admin_id_club')) { + abort(403, 'No tienes permiso para eliminar este equipo.'); + } + + $equipo->delete(); + return redirect()->route('admin.equipos.index')->with('admin_msg', 'Equipo eliminado correctamente.'); + } + + public function equipoJugadores($id) + { + $this->checkGeneralAdmin(request()); + $equipo = Equipo::with('club', 'jugadores')->findOrFail($id); + + if (session('admin_role') == 2 && $equipo->id_club != session('admin_id_club')) { + abort(403, 'No tienes permiso para gestionar jugadores de este equipo.'); + } + + return view('admin.equipos.jugadores', compact('equipo')); + } + + public function equipoAddJugador(Request $request, $id) + { + $this->checkGeneralAdmin($request); + $equipo = Equipo::findOrFail($id); + + if (session('admin_role') == 2 && $equipo->id_club != session('admin_id_club')) { + abort(403); + } + + $data = $request->validate([ + 'id_jugador' => 'required|string|exists:jugadores,id_jugador', + ]); + + // Verificar que el jugador sea del mismo club, salvo que el equipo pertenezca a una selección + $jugador = Jugador::where('id_jugador', $data['id_jugador'])->firstOrFail(); + $esSeleccion = $equipo->club && $equipo->club->es_seleccion; + if (!$esSeleccion && $jugador->id_club_actual != $equipo->id_club) { + return back()->with('admin_error', 'El jugador no pertenece al mismo club que el equipo.'); + } + + // Evitar duplicados + if ($equipo->jugadores()->where('jugador_equipo.id_jugador', $data['id_jugador'])->exists()) { + return back()->with('admin_error', 'El jugador ya está asignado a este equipo.'); + } + + $equipo->jugadores()->attach($data['id_jugador'], ['fecha_alta' => now()]); + + return back()->with('admin_msg', 'Jugador asignado correctamente.'); + } + + public function equipoRemoveJugador($id, $id_jugador) + { + $this->checkGeneralAdmin(request()); + $equipo = Equipo::findOrFail($id); + + if (session('admin_role') == 2 && $equipo->id_club != session('admin_id_club')) { + abort(403); + } + + $equipo->jugadores()->detach($id_jugador); + + return back()->with('admin_msg', 'Jugador removido del equipo.'); + } + + public function jugadoresSearchAjax(Request $request) + { + $this->checkGeneralAdmin($request); + $q = $request->input('q'); + + $query = Jugador::query(); + + if (session('admin_role') == 2) { + $query->where('id_club_actual', session('admin_id_club')); + } + + if ($q) { + $query->where(function($sub) use ($q) { + $sub->where('nombre', 'like', "%$q%") + ->orWhere('apellido', 'like', "%$q%") + ->orWhere('documento', 'like', "%$q%"); + }); + } + + $jugadores = $query->limit(10)->get(['id_jugador', 'nombre', 'apellido', 'documento']); + + return response()->json($jugadores); + } + + public function jugadoresCategoriaPorEdad(Request $request) + { + $this->checkGeneralAdmin($request); + $fecha = $request->input('fecha'); + if (!$fecha) return response()->json(['categoria' => 'Sin categoría']); + + $anio = \Carbon\Carbon::parse($fecha)->format('Y'); + $edadCategoria = date('Y') - $anio; + + $categoria = \App\Models\Categoria::where('edad_min', '<=', $edadCategoria) + ->where('edad_max', '>=', $edadCategoria) + ->first(); + + return response()->json(['categoria' => $categoria ? $categoria->nombre : 'Sin categoría']); + } + + + // ══════════════════════════════════ + // JUGADORES + // ══════════════════════════════════ + public function jugadoresIndex(Request $request) + { + $this->checkGeneralAdmin($request); + $search = $request->input('q'); + $query = Jugador::with('clubActual') + ->orderByRaw("COALESCE(updated_at, '1970-01-01') DESC") + ->orderByRaw('CAST(id_jugador AS UNSIGNED) DESC'); + + if (session('admin_role') == 2) { + $query->where('id_club_actual', session('admin_id_club')); + } + + if ($search) { + // Tokenizar por espacios. Cada token debe matchear como INICIO de palabra + // dentro de "apellido nombre" — así "Man Adriel" encuentra a MAN ADRIEL pero + // no a ROMAN PEREZ. El DNI sigue aceptando match por substring directo. + $tokens = array_values(array_filter( + array_map( + fn($t) => preg_replace('/[^\p{L}\p{N}]/u', '', $t), + preg_split('/\s+/u', $search) + ), + fn($t) => mb_strlen($t) > 0 + )); + + $query->where(function ($q) use ($tokens, $search) { + $q->where('documento', 'like', '%' . $search . '%'); + + if (!empty($tokens)) { + $q->orWhere(function ($qq) use ($tokens) { + foreach ($tokens as $token) { + $qq->whereRaw( + "LOWER(CONCAT(apellido, ' ', nombre)) REGEXP ?", + ['[[:<:]]' . mb_strtolower($token)] + ); + } + }); + } + }); + } + + $jugadores = $query->paginate(25); + $clubes = session('admin_role') == 1 ? Club::orderBy('nombre')->get() : []; + + return view('admin.jugadores.index', compact('jugadores', 'search', 'clubes')); + } + + public function jugadoresCreate(Request $request) + { + $this->checkGeneralAdmin($request); + $clubes = Club::orderBy('nombre')->get(); + return view('admin.jugadores.form', ['jugador' => null, 'clubes' => $clubes]); + } + + public function jugadoresStore(Request $request) + { + $this->checkGeneralAdmin($request); + $data = $request->validate([ + 'documento' => 'required|string|max:20', + 'nombre' => 'required|string|max:100', + 'apellido' => 'required|string|max:100', + 'fecha_nacimiento' => 'required|date', + 'id_club_actual' => 'nullable|integer|exists:clubes,id_club', + 'id_club_origen' => 'required|integer|exists:clubes,id_club', + ]); + + // Verificar si el DNI ya existe. withTrashed: el índice UNIQUE de la BD considera + // también soft-deleted, así que la validación debe contemplarlos para no caer en error 1062. + $existente = Jugador::withTrashed()->where('documento', $data['documento'])->first(); + if ($existente) { + if ($existente->trashed()) { + return back()->withInput()->withErrors([ + 'documento' => "Este DNI pertenece a un jugador que está en la papelera. Restauralo desde allí o contactá al superadmin." + ]); + } + $clubNombre = $existente->clubActual ? $existente->clubActual->nombre : 'Sin Club'; + return back()->withInput()->withErrors([ + 'documento' => "No se puede registrar al jugador dado que ya pertenece al club $clubNombre." + ]); + } + + if (session('admin_role') == 2) { + $data['id_club_actual'] = session('admin_id_club'); + // Nota: id_club_origen podría ser el mismo o diferente, + // pero el Id del jugador siempre se basa en el de origen. + } + + $data['nombre'] = strtoupper(trim($data['nombre'])); + $data['apellido'] = strtoupper(trim($data['apellido'])); + $data['activo'] = 0; // Se valida luego en /asociate + if (isset($data['fecha_nacimiento'])) { + $data['edad'] = \Carbon\Carbon::parse($data['fecha_nacimiento'])->age; + // $data['categoria'] ya no se asigna, ahora es dinámica + } + + $idClub = $data['id_club_origen']; + $yearFull = \Carbon\Carbon::parse($data['fecha_nacimiento'])->format('Y'); + $data['id_jugador'] = $this->generarIdJugador($idClub, $yearFull); + + Jugador::create($data); + return redirect()->route('admin.jugadores.index')->with('admin_msg', 'Jugador creado correctamente. Puede activarse en /asociate.'); + } + + private function generarIdJugador($idClub, $yearFull) + { + $yearShort = \Carbon\Carbon::parse($yearFull . '-01-01')->format('y'); + $prefix = $idClub . $yearShort; + $secuencia = $this->obtenerSiguienteSecuencia($idClub, $yearFull, $prefix); + + return sprintf('%s%02d', $prefix, $secuencia); + } + + private function obtenerSiguienteSecuencia($idClub, $yearFull, $prefix) + { + // withTrashed: incluir jugadores soft-deleted para no reusar IDs y chocar con la PK. + $ultimoId = (string)Jugador::withTrashed() + ->where('id_jugador', 'LIKE', $prefix . '%') + ->whereRaw("id_jugador REGEXP '^[0-9]+$'") + ->orderByRaw('CAST(id_jugador AS UNSIGNED) DESC') + ->value('id_jugador'); + + $secuencia = 1; + if ($ultimoId && str_starts_with($ultimoId, $prefix)) { + $secuenciaStr = substr($ultimoId, strlen($prefix)); + $secuencia = (int)$secuenciaStr + 1; + } + return $secuencia; + } + + public function jugadoresImport(Request $request) + { + $this->checkGeneralAdmin($request); + $request->validate([ + 'csv_file' => 'required|file|mimes:csv,txt', + 'id_club' => session('admin_role') == 1 ? 'nullable|integer' : 'nullable' + ]); + + $file = $request->file('csv_file'); + $handle = fopen($file->getRealPath(), 'r'); + + $successCount = 0; + $omittedCount = 0; + $errorCount = 0; + $teamAssignedCount = 0; + $errors = []; + + // Determinar Club Target + $targetClubId = session('admin_role') == 2 ? session('admin_id_club') : ($request->input('id_club') ?? 99); + + $currentCategory = null; + $formatType = 'legacy'; // legacy, cab, internal + $localSequences = []; // Cache para evitar duplicados en el mismo loop + + // Leer primeras líneas para detectar formato (el CAB puede empezar con una categoría) + $detectLines = []; + for ($i=0; $i<5; $i++) { + $l = fgets($handle); + if ($l) $detectLines[] = $l; + } + rewind($handle); + + $fullContentSample = implode("\n", $detectLines); + if (str_contains($fullContentSample, 'ESTADO LICENCIA') || str_contains($fullContentSample, 'TIPO') || str_contains($fullContentSample, 'JUGADOR')) { + $formatType = 'cab'; + } elseif (str_contains($fullContentSample, 'DNI;Apellido;Nombre;Fecha Nacimiento')) { + $formatType = 'internal'; + } + + while (($row = fgetcsv($handle, 1000, ";")) !== FALSE) { + if (empty(array_filter($row))) continue; + + // --- DETECCION DE CATEGORIA (Solo CAB) --- + // Si la primera columna tiene texto y el resto está vacío, es una categoría (ej: "PREMINI B;;;;;") + if ($formatType == 'cab' && !empty($row[0]) && empty(array_filter(array_slice($row, 1)))) { + $currentCategory = trim($row[0]); + continue; + } + + // Saltos de cabecera + if ($formatType == 'cab' && $row[0] == 'ESTADO LICENCIA') continue; + if ($formatType == 'internal' && $row[0] == 'DNI') continue; + + try { + $dni = ''; $apellido = ''; $nombre = ''; $fechaNac = ''; + $idClubOrigen = $targetClubId; + $idClubActual = $targetClubId; + $isJugador = true; + + if ($formatType == 'cab') { + // Formato: ESTADO LICENCIA;NIF;NOMBRE;FECHA_ALTA;BAJA;TIPO;FECHA NACIMIENTO;NACIONALIDAD + if (count($row) < 7) continue; + $dni = trim($row[1]); + $fullName = trim($row[2]); // "APELLIDO, NOMBRE" + $tipo = strtoupper(trim($row[5])); + $fechaRaw = trim($row[6]); // d/m/Y + + if ($tipo !== 'JUGADOR') { + $isJugador = false; + } + + if ($isJugador) { + $parts = explode(',', $fullName); + $apellido = trim($parts[0]); + $nombre = isset($parts[1]) ? trim($parts[1]) : ''; + + // Parsear d/m/Y + $dateParts = explode('/', $fechaRaw); + if (count($dateParts) == 3) { + $fechaNac = "{$dateParts[2]}-{$dateParts[1]}-{$dateParts[0]}"; + } + } + } elseif ($formatType == 'internal') { + // Formato: DNI; Apellido; Nombre; Fecha Nacimiento (d/m/Y); ID Club Origen; ID Club Actual; Categoria; Activo + $dni = trim($row[0]); + $apellido = trim($row[1]); + $nombre = trim($row[2]); + $fechaRaw = trim($row[3]); + $idClubOrigen = isset($row[4]) ? (int)trim($row[4]) : $targetClubId; + $idClubActual = isset($row[5]) ? (int)trim($row[5]) : $targetClubId; + + $dateParts = explode('/', $fechaRaw); + if (count($dateParts) == 3) { + $fechaNac = "{$dateParts[2]}-{$dateParts[1]}-{$dateParts[0]}"; + } + } else { + // Formato Legado: DNI; Apellido; Nombre; ddmmaaaa; id_club_origen + if (count($row) < 4) continue; + $dni = trim($row[0]); + + // SEGURIDAD: Si el DNI no es numérico en formato legado, probablemente sea una cabecera o ruido + if (!is_numeric($dni)) continue; + + $apellido = trim($row[1]); + $nombre = trim($row[2]); + $fechaRaw = trim($row[3]); // ddmmaaaa + $idClubOrigen = isset($row[4]) ? (int)trim($row[4]) : $targetClubId; + + if (strlen($fechaRaw) == 8) { + $fechaNac = substr($fechaRaw, 4, 4) . "-" . substr($fechaRaw, 2, 2) . "-" . substr($fechaRaw, 0, 2); + } + } + + if (!$isJugador || !$dni || !$apellido || !$nombre || !$fechaNac) continue; + + // Verificar existencia + $jugador = Jugador::where('documento', $dni)->first(); + $anioNac = date('Y', strtotime($fechaNac)); + + $data = [ + 'documento' => $dni, + 'apellido' => strtoupper(trim($apellido)), + 'nombre' => strtoupper(trim($nombre)), + 'fecha_nacimiento' => $fechaNac, + 'id_club_origen' => $idClubOrigen, + 'id_club_actual' => $idClubActual, + 'edad' => \Carbon\Carbon::parse($fechaNac)->age, + ]; + + if (!$jugador) { + // Generar ID con cache local para evitar colisiones en el mismo loop + $prefix = $idClubOrigen . date('y', strtotime($fechaNac)); + if (!isset($localSequences[$prefix])) { + // Obtener la base inicial de la BDD + $localSequences[$prefix] = $this->obtenerSiguienteSecuencia($idClubOrigen, $anioNac, $prefix); + } else { + $localSequences[$prefix]++; + } + + $data['id_jugador'] = sprintf('%s%02d', $prefix, $localSequences[$prefix]); + $data['activo'] = 0; + $jugador = Jugador::create($data); + $successCount++; + } else { + // El usuario prefiere NO pisar datos si el jugador ya existe + $omittedCount++; + } + + // --- MATCHING DE EQUIPO --- + if ($currentCategory && $jugador) { + $equipo = Equipo::where('id_club', $idClubActual) + ->where('categoria', 'LIKE', $currentCategory) + ->first(); + if ($equipo) { + // Evitar duplicados en pivot + if (!$equipo->jugadores()->where('jugador_equipo.id_jugador', $jugador->id_jugador)->exists()) { + $equipo->jugadores()->attach($jugador->id_jugador, ['fecha_alta' => now()]); + $teamAssignedCount++; + } + } + } + + } catch (\Exception $e) { + $errorCount++; + $errors[] = "Error en fila DNI $dni: " . $e->getMessage(); + } + } + fclose($handle); + + $msg = "Importación finalizada ({$formatType}). $successCount nuevos creados, $omittedCount ya registrados (no modificados)."; + if ($teamAssignedCount > 0) $msg .= " $teamAssignedCount asignaciones a equipos realizadas."; + + if ($errorCount > 0) { + return redirect()->route('admin.jugadores.index')->with('admin_msg', $msg)->with('admin_error', implode(" | ", array_slice($errors, 0, 5))); + } + + return redirect()->route('admin.jugadores.index')->with('admin_msg', $msg); + } + + public function jugadoresExport(Request $request) + { + $this->checkGeneralAdmin($request); + + $headers = [ + "Content-type" => "text/csv; charset=UTF-8", + "Content-Disposition" => "attachment; filename=jugadores_" . date('Ymd_His') . ".csv", + "Pragma" => "no-cache", + "Cache-Control" => "must-revalidate, post-check=0, pre-check=0", + "Expires" => "0" + ]; + + $callback = function() { + $file = fopen('php://output', 'w'); + // Añadir BOM para Excel (UTF-8) + fprintf($file, chr(0xEF).chr(0xBB).chr(0xBF)); + + // Cabeceras + fputcsv($file, [ + 'DNI', 'Apellido', 'Nombre', 'Fecha Nacimiento', 'ID Club Origen', 'ID Club Actual', 'Categoria', 'Activo' + ], ";"); + + $query = Jugador::orderBy('apellido'); + + if (session('admin_role') == 2) { + $query->where('id_club_actual', session('admin_id_club')); + } + + $jugadores = $query->get(); + + foreach ($jugadores as $j) { + fputcsv($file, [ + $j->documento, + $j->apellido, + $j->nombre, + $j->fecha_nacimiento ? $j->fecha_nacimiento->format('d/m/Y') : '', + $j->id_club_origen ?? 99, + $j->id_club_actual ?? 99, + $j->categoria_calculada, + $j->activo ? 'SI' : 'NO' + ], ";"); + } + fclose($file); + }; + + return response()->stream($callback, 200, $headers); + } + + public function jugadoresEdit(Request $request, $id) + { + $this->checkGeneralAdmin($request); + $jugador = Jugador::findOrFail($id); + + if (session('admin_role') == 2 && $jugador->id_club_actual != session('admin_id_club')) { + abort(403, 'No puedes editar un jugador que no pertenece a tu club.'); + } + + $clubes = session('admin_role') == 1 ? Club::orderBy('nombre')->get() : []; + return view('admin.jugadores.form', compact('jugador', 'clubes')); + } + + public function jugadoresUpdate(Request $request, $id) + { + $this->checkGeneralAdmin($request); + $jugador = Jugador::findOrFail($id); + + if (session('admin_role') == 2 && $jugador->id_club_actual != session('admin_id_club')) { + abort(403, 'No puedes editar un jugador que no pertenece a tu club.'); + } + + $data = $request->validate([ + 'documento' => 'required|string|max:20|unique:jugadores,documento,' . $id . ',id_jugador', + 'nombre' => 'required|string|max:100', + 'apellido' => 'required|string|max:100', + 'fecha_nacimiento' => 'required|date', + 'id_club_actual' => 'nullable|integer|exists:clubes,id_club', + 'id_club_origen' => 'nullable|integer|exists:clubes,id_club', + ]); + + if (session('admin_role') == 2) { + $data['id_club_actual'] = session('admin_id_club'); // Forzamos a no cambiarlo + } + + if (isset($data['fecha_nacimiento'])) { + $data['edad'] = \Carbon\Carbon::parse($data['fecha_nacimiento'])->age; + } + + $jugador->update($data); + return redirect()->route('admin.jugadores.index')->with('admin_msg', 'Jugador actualizado correctamente.'); + } + + public function jugadoresDestroy(Request $request, $id) + { + $this->checkGeneralAdmin($request); + $jugador = Jugador::findOrFail($id); + + if (session('admin_role') == 2 && $jugador->id_club_actual != session('admin_id_club')) { + abort(403, 'No tienes permiso para eliminar este jugador.'); + } + + $jugador->delete(); + return redirect()->route('admin.jugadores.index')->with('admin_msg', 'Jugador eliminado correctamente.'); + } + + // ══════════════════════════════════ + // EVENTOS + // ══════════════════════════════════ + public function escanearQr(Request $request) + { + $this->checkGeneralAdmin($request); + + $query = Evento::orderByRaw(" + CAST(SUBSTRING_INDEX(nombre_evento, 'PARTIDO ', -1) AS UNSIGNED) ASC, + nombre_evento ASC, + fecha_evento ASC, + hora_inicio ASC + "); + + $ahora = \Carbon\Carbon::now(); + $query->where(function($q) use ($ahora) { + $q->where(function($sub) { + $sub->whereNull('marcador_local') + ->orWhereNull('marcador_visitante'); + }) + ->orWhere('fecha_evento', '>', $ahora->toDateString()) + ->orWhere(function($q2) use ($ahora) { + $q2->where('fecha_evento', '=', $ahora->toDateString()) + ->where('hora_fin', '>', $ahora->toTimeString()); + }); + }); + + if (session('admin_role') == 2) { + $idClub = session('admin_id_club'); + $query->where(function ($q) use ($idClub) { + $q->whereHas('equipoLocal', function ($q2) use ($idClub) { + $q2->where('id_club', $idClub); + })->orWhereHas('equipoVisitante', function ($q2) use ($idClub) { + $q2->where('id_club', $idClub); + }); + }); + } + + $eventos = $query->get(); + return view('admin.escanear_qr', compact('eventos')); + } + + public function eventosIndex(Request $request) + { + $this->checkGeneralAdmin($request); + + $estado = $request->get('estado', 'todos'); + $query = Evento::with(['equipoLocal.club', 'equipoVisitante.club'])->orderBy('fecha_evento', 'desc'); + + $tz = 'America/Argentina/Buenos_Aires'; + $ahora = \Carbon\Carbon::now($tz); + + // Filtro por estado + if ($estado == 'finalizados') { + $query->whereNotNull('marcador_local') + ->whereNotNull('marcador_visitante') + ->where(function($q) use ($ahora) { + $q->where('fecha_evento', '<', $ahora->toDateString()) + ->orWhere(function($q2) use ($ahora) { + $q2->where('fecha_evento', '=', $ahora->toDateString()) + ->where('hora_fin', '<=', $ahora->toTimeString()); + }); + }); + } elseif ($estado == 'pendientes') { + $query->where(function($q) use ($ahora) { + $q->whereNull('marcador_local') + ->orWhereNull('marcador_visitante') + ->orWhere('fecha_evento', '>', $ahora->toDateString()) + ->orWhere(function($q2) use ($ahora) { + $q2->where('fecha_evento', '=', $ahora->toDateString()) + ->where('hora_fin', '>', $ahora->toTimeString()); + }); + }); + } + + if (session('admin_role') == 2) { + $idClub = session('admin_id_club'); + $query->where(function($q) use ($idClub) { + $q->whereHas('equipoLocal', function ($q2) use ($idClub) { + $q2->where('id_club', $idClub); + })->orWhereHas('equipoVisitante', function ($q2) use ($idClub) { + $q2->where('id_club', $idClub); + }); + }); + } + + $eventos = $query->orderBy('id_evento', 'desc')->get(); + return view('admin.eventos.index', compact('eventos', 'estado')); + } + + public function eventosCreate(Request $request) + { + $this->checkSuperAdmin($request); + $equipos = Equipo::with('club')->get(); + $torneos = \App\Models\Torneo::orderBy('nombre')->get(); + $torneoEquipos = DB::table('torneo_equipo')->get(); + return view('admin.eventos.form', ['evento' => null, 'equipos' => $equipos, 'torneos' => $torneos, 'torneoEquipos' => $torneoEquipos]); + } + + public function eventosStore(Request $request) + { + $this->checkSuperAdmin($request); + $data = $request->validate([ + 'nombre_evento' => 'nullable|string|max:200', + 'id_torneo' => 'nullable|integer|exists:torneos,id', + 'fecha_evento' => 'required|date', + 'hora_inicio' => 'required', + 'hora_fin' => 'required', + 'sede' => 'required|string|max:200', + 'id_equipo_local' => 'required|integer|exists:equipos,id_equipo', + 'id_equipo_visitante' => 'required|integer|exists:equipos,id_equipo', + 'precio' => 'nullable|numeric|min:0', + 'marcador_local' => 'nullable|integer|min:0', + 'marcador_visitante' => 'nullable|integer|min:0', + ]); + + // Validaciones Deportivas + $local = Equipo::findOrFail($data['id_equipo_local']); + $visit = Equipo::findOrFail($data['id_equipo_visitante']); + + if ($local->categoria != $visit->categoria) { + return back()->withInput()->withErrors(['id_equipo_visitante' => 'Los equipos deben pertenecer a la misma categoría.']); + } + + if (!empty($data['id_torneo'])) { + $torneo = \App\Models\Torneo::findOrFail($data['id_torneo']); + $localInTorneo = $torneo->equipos()->where('equipos.id_equipo', $data['id_equipo_local'])->first(); + $visitInTorneo = $torneo->equipos()->where('equipos.id_equipo', $data['id_equipo_visitante'])->first(); + + if (!$localInTorneo || !$visitInTorneo) { + return back()->withInput()->withErrors(['id_torneo' => 'Uno o ambos equipos no están inscritos en este torneo.']); + } + + if ($localInTorneo->pivot->grupo != $visitInTorneo->pivot->grupo) { + return back()->withInput()->withErrors(['id_equipo_visitante' => 'Los equipos deben pertenecer al mismo grupo dentro del torneo.']); + } + } + + // Autogenerar ID + $data['id_evento'] = bin2hex(random_bytes(4)); // 8 caracteres + + if (empty($data['nombre_evento'])) { + $grupoName = 'General'; + if (!empty($data['id_torneo'])) { + $rel = DB::table('torneo_equipo') + ->where('id_torneo', $data['id_torneo']) + ->where('id_equipo', $data['id_equipo_local']) + ->first(); + if ($rel) $grupoName = $rel->grupo ?? 'General'; + } + $data['nombre_evento'] = ($local->club->nombre ?? 'Local') . ' vs ' . ($visit->club->nombre ?? 'Visitante') . " ({$grupoName})"; + } + + Evento::create($data); + return redirect()->route('admin.eventos.index')->with('admin_msg', 'Evento creado correctamente.'); + } + + public function eventosEdit(Request $request, $id) + { + $this->checkGeneralAdmin($request); + $evento = Evento::findOrFail($id); + + if (session('admin_role') == 2) { + $idClub = session('admin_id_club'); + $isLocal = $evento->equipoLocal && $evento->equipoLocal->id_club == $idClub; + $isVisitante = $evento->equipoVisitante && $evento->equipoVisitante->id_club == $idClub; + if (!$isLocal && !$isVisitante) { + abort(403, 'No tienes permiso para editar este evento.'); + } + } + $equipos = Equipo::with('club')->get(); + $torneos = \App\Models\Torneo::orderBy('nombre')->get(); + $torneoEquipos = DB::table('torneo_equipo')->get(); + return view('admin.eventos.form', compact('evento', 'equipos', 'torneos', 'torneoEquipos')); + } + + public function eventosUpdate(Request $request, $id) + { + $this->checkGeneralAdmin($request); + $evento = Evento::findOrFail($id); + + if (session('admin_role') == 2) { + $idClub = session('admin_id_club'); + $isLocal = $evento->equipoLocal && $evento->equipoLocal->id_club == $idClub; + $isVisitante = $evento->equipoVisitante && $evento->equipoVisitante->id_club == $idClub; + if (!$isLocal && !$isVisitante) { + abort(403, 'No tienes permiso para editar este evento.'); + } + + // Club admins only edit limite_qr_jugador + $data = $request->validate([ + 'limite_qr_jugador' => 'required|integer|min:0', + ]); + $evento->update(['limite_qr_jugador' => $data['limite_qr_jugador']]); + return redirect()->route('admin.eventos.index')->with('admin_msg', 'Pase de QRs para el evento actualizado.'); + } + + // Si es edición, restringimos los campos permitidos según solicitud del usuario + if ($id) { + $data = $request->validate([ + 'fecha_evento' => 'required|date', + 'hora_inicio' => 'required', + 'hora_fin' => 'required', + 'limite_qr_jugador' => 'nullable|integer|min:0', + 'marcador_local' => 'nullable|integer|min:0', + 'marcador_visitante' => 'nullable|integer|min:0', + 'nombre_evento' => 'nullable|string|max:200', + ]); + + // Conservar valores que no deberían cambiar para que la validación posterior (deporte) no falle + $data['id_torneo'] = $evento->id_torneo; + $data['id_equipo_local'] = $evento->id_equipo_local; + $data['id_equipo_visitante'] = $evento->id_equipo_visitante; + + // Si el nombre viene vacío en edición, también lo autogeneramos? + // El usuario pidió que se setee automáticamente al no poner nada. + if (empty($data['nombre_evento'])) { + $local = Equipo::findOrFail($data['id_equipo_local']); + $visit = Equipo::findOrFail($data['id_equipo_visitante']); + $grupoName = 'General'; + if (!empty($data['id_torneo'])) { + $rel = DB::table('torneo_equipo') + ->where('id_torneo', $data['id_torneo']) + ->where('id_equipo', $data['id_equipo_local']) + ->first(); + if ($rel) $grupoName = $rel->grupo ?? 'General'; + } + $data['nombre_evento'] = ($local->club->nombre ?? 'Local') . ' vs ' . ($visit->club->nombre ?? 'Visitante') . " ({$grupoName})"; + } + + $data['sede'] = $evento->sede; + $data['precio'] = $evento->precio; + } else { + $data = $request->validate([ + 'nombre_evento' => 'nullable|string|max:200', + 'id_torneo' => 'nullable|integer|exists:torneos,id', + 'fecha_evento' => 'required|date', + 'hora_inicio' => 'required', + 'hora_fin' => 'required', + 'sede' => 'nullable|string|max:200', + 'id_equipo_local' => 'nullable|integer|exists:equipos,id_equipo', + 'id_equipo_visitante' => 'nullable|integer|exists:equipos,id_equipo', + 'precio' => 'nullable|numeric|min:0', + 'limite_qr_jugador' => 'nullable|integer|min:0', + 'marcador_local' => 'nullable|integer|min:0', + 'marcador_visitante' => 'nullable|integer|min:0', + ]); + } + + // Validaciones Deportivas + if ($data['id_equipo_local'] && $data['id_equipo_visitante']) { + $local = Equipo::findOrFail($data['id_equipo_local']); + $visit = Equipo::findOrFail($data['id_equipo_visitante']); + + if ($local->categoria != $visit->categoria) { + return back()->withInput()->withErrors(['id_equipo_visitante' => 'Los equipos deben pertenecer a la misma categoría.']); + } + + if (!empty($data['id_torneo'])) { + $torneo = \App\Models\Torneo::findOrFail($data['id_torneo']); + $localInTorneo = $torneo->equipos()->where('equipos.id_equipo', $data['id_equipo_local'])->first(); + $visitInTorneo = $torneo->equipos()->where('equipos.id_equipo', $data['id_equipo_visitante'])->first(); + + if (!$localInTorneo || !$visitInTorneo) { + return back()->withInput()->withErrors(['id_torneo' => 'Uno o ambos equipos no están inscritos en este torneo.']); + } + + if ($localInTorneo->pivot->grupo != $visitInTorneo->pivot->grupo) { + return back()->withInput()->withErrors(['id_equipo_visitante' => 'Los equipos deben pertenecer al mismo grupo dentro del torneo.']); + } + } + + // Autogenerar Nombre si es nulo + if (empty($data['nombre_evento'])) { + $data['nombre_evento'] = ($local->club->nombre ?? 'Local') . ' vs ' . ($visit->club->nombre ?? 'Visitante'); + } + } + + $evento->update($data); + return redirect()->route('admin.eventos.index')->with('admin_msg', 'Evento actualizado correctamente.'); + } + + public function eventosDestroy(Request $request, $id) + { + $this->checkSuperAdmin($request); + $evento = Evento::findOrFail($id); + + // Al eliminar un evento, también eliminamos sus QRs para que no queden "huérfanos" + $evento->qrCodes()->delete(); + + $evento->delete(); + return redirect()->route('admin.eventos.index')->with('admin_msg', 'Evento eliminado correctamente.'); + } + + // ══════════════════════════════════ + // PROMOCIONES / LUGARES + // ══════════════════════════════════ + public function promocionesIndex(Request $request) + { + $this->checkSuperAdmin($request); + $promociones = Promocion::withCount('promoQrs')->orderBy('id', 'desc')->get(); + return view('admin.promociones.index', compact('promociones')); + } + + public function promocionesCreate(Request $request) + { + $this->checkSuperAdmin($request); + return view('admin.promociones.form', ['promocion' => null]); + } + + public function promocionesStore(Request $request) + { + $this->checkSuperAdmin($request); + $data = $request->validate([ + 'nombre' => 'required|string|max:100', + 'direccion' => 'required|string|max:150', + 'lat' => 'nullable|numeric', + 'lng' => 'nullable|numeric', + 'contacto' => 'nullable|string|max:100', + 'descripcion' => 'nullable', + 'descripcion_lugar' => 'nullable', + 'categoria' => 'nullable|string|max:50', + 'imagen_file' => 'nullable|image|max:2048', + ]); + + if ($request->hasFile('imagen_file')) { + $path = app(ImageOptimizer::class)->storeAndOptimize($request->file('imagen_file'), 'promos'); + $data['imagen'] = $path; + } + unset($data['imagen_file']); + + Promocion::create($data); + return redirect()->route('admin.promociones.index')->with('admin_msg', 'Promoción creada correctamente.'); + } + + public function promocionesEdit(Request $request, $id) + { + $this->checkSuperAdmin($request); + $promocion = Promocion::findOrFail($id); + return view('admin.promociones.form', compact('promocion')); + } + + public function promocionesUpdate(Request $request, $id) + { + $this->checkSuperAdmin($request); + $promocion = Promocion::findOrFail($id); + $data = $request->validate([ + 'nombre' => 'required|string|max:100', + 'direccion' => 'required|string|max:150', + 'lat' => 'nullable|numeric', + 'lng' => 'nullable|numeric', + 'contacto' => 'nullable|string|max:100', + 'descripcion' => 'nullable', + 'descripcion_lugar' => 'nullable', + 'categoria' => 'nullable|string|max:50', + 'imagen_file' => 'nullable|image|max:2048', + ]); + + if ($request->hasFile('imagen_file')) { + $path = app(ImageOptimizer::class)->storeAndOptimize($request->file('imagen_file'), 'promos'); + $data['imagen'] = $path; + } + unset($data['imagen_file']); + + $promocion->update($data); + return redirect()->route('admin.promociones.index')->with('admin_msg', 'Promoción actualizada correctamente.'); + } + + public function promocionesDestroy(Request $request, $id) + { + $this->checkSuperAdmin($request); + $promocion = Promocion::findOrFail($id); + $promocion->delete(); + return redirect()->route('admin.promociones.index')->with('admin_msg', 'Promoción eliminada correctamente.'); + } + + // ══════════════════════════════════ + // NOTICIAS + // ══════════════════════════════════ + public function noticiasIndex(Request $request) + { + $this->checkSuperAdmin($request); + $noticias = Noticia::orderBy('fecha', 'desc')->orderBy('id', 'desc')->get(); + return view('admin.noticias.index', compact('noticias')); + } + + public function noticiasCreate(Request $request) + { + $this->checkSuperAdmin($request); + $torneos = \App\Models\Torneo::orderBy('nombre')->get(); + return view('admin.noticias.form', ['noticia' => null, 'torneos' => $torneos]); + } + + public function noticiasStore(Request $request) + { + $this->checkSuperAdmin($request); + $data = $request->validate([ + 'titulo' => 'required|string|max:200', + 'contenido' => 'required', + 'imagen_file' => 'nullable|image|max:5120', + 'categoria' => 'nullable|string|max:50', + 'id_torneo' => 'nullable|integer|exists:torneos,id', + ]); + + if ($request->hasFile('imagen_file')) { + $path = app(ImageOptimizer::class)->storeAndOptimize($request->file('imagen_file'), 'noticias'); + $data['imagen'] = 'storage/' . $path; + } + unset($data['imagen_file']); + + $data['fecha'] = now(); + Noticia::create($data); + return redirect()->route('admin.noticias.index')->with('admin_msg', 'Noticia creada correctamente.'); + } + + public function noticiasEdit(Request $request, $id) + { + $this->checkSuperAdmin($request); + $noticia = Noticia::findOrFail($id); + $torneos = \App\Models\Torneo::orderBy('nombre')->get(); + return view('admin.noticias.form', compact('noticia', 'torneos')); + } + + public function noticiasUpdate(Request $request, $id) + { + $this->checkSuperAdmin($request); + $noticia = Noticia::findOrFail($id); + $data = $request->validate([ + 'titulo' => 'required|string|max:200', + 'contenido' => 'required', + 'imagen_file' => 'nullable|image|max:5120', + 'categoria' => 'nullable|string|max:50', + 'id_torneo' => 'nullable|integer|exists:torneos,id', + ]); + + if ($request->hasFile('imagen_file')) { + // Eliminar imagen anterior si existe + if ($noticia->imagen && !str_starts_with($noticia->imagen, 'http') && file_exists(public_path($noticia->imagen))) { + @unlink(public_path($noticia->imagen)); + } + $path = app(ImageOptimizer::class)->storeAndOptimize($request->file('imagen_file'), 'noticias'); + $data['imagen'] = 'storage/' . $path; + } + unset($data['imagen_file']); + + $noticia->update($data); + return redirect()->route('admin.noticias.index')->with('admin_msg', 'Noticia actualizada correctamente.'); + } + + public function noticiasDestroy(Request $request, $id) + { + $this->checkSuperAdmin($request); + $noticia = Noticia::findOrFail($id); + $noticia->delete(); + return redirect()->route('admin.noticias.index')->with('admin_msg', 'Noticia eliminada correctamente.'); + } + + // ══════════════════════════════════ + // ESCANEAR / VALIDAR QR + // ══════════════════════════════════ + public function validarQr(Request $request) + { + $this->checkGeneralAdmin($request); + $id_qr = $request->input('id_qr'); + $id_evento = $request->input('id_evento'); + + if (!$id_qr) { + return response()->json(['valid' => false, 'message' => 'QR inválido.']); + } + + $qr = QrCode::with('evento', 'jugador', 'aficionado')->where('id_qr', $id_qr)->first(); + + if (!$qr) { + return response()->json(['valid' => false, 'message' => 'QR no encontrado.']); + } + + // Si se seleccionó un evento, exigir que coincida + if ($id_evento && (string)$qr->id_evento !== (string)$id_evento) { + return response()->json(['valid' => false, 'message' => 'Este QR corresponde a otro evento.']); + } + + // Verificar vigencia del evento + if ($qr->evento) { + $ahora = \Carbon\Carbon::now(); + + $f = ($qr->evento->fecha_evento instanceof \Carbon\Carbon) ? $qr->evento->fecha_evento->format('Y-m-d') : substr($qr->evento->fecha_evento, 0, 10); + $h1 = ($qr->evento->hora_inicio instanceof \Carbon\Carbon) ? $qr->evento->hora_inicio->format('H:i:s') : $qr->evento->hora_inicio; + $h2 = ($qr->evento->hora_fin instanceof \Carbon\Carbon) ? $qr->evento->hora_fin->format('H:i:s') : $qr->evento->hora_fin; + + $inicio = \Carbon\Carbon::parse("$f $h1"); + $fin = \Carbon\Carbon::parse("$f $h2"); + + if ($fin->lessThanOrEqualTo($inicio)) { + $fin->addDay(); + } + + if ($ahora < $inicio) { + return response()->json(['valid' => false, 'message' => '⏳ El evento todavía no comenzó.']); + } + if ($ahora > $fin) { + return response()->json(['valid' => false, 'message' => 'Evento finalizado, QR inválido.']); + } + } + + // Verificar escaneos restantes + if ((int)$qr->escaneos_restantes <= 0) { + return response()->json(['valid' => false, 'message' => '❌ QR ya utilizado.']); + } + + // Decrementar escaneos (concurrency-safe via DB) + $affected = \Illuminate\Support\Facades\DB::table('qr_codes') + ->where('id_qr', $id_qr) + ->where('escaneos_restantes', '>', 0) + ->when($id_evento, function ($query) use ($id_evento) { + return $query->where('id_evento', $id_evento); + }) + ->decrement('escaneos_restantes'); + + if ($affected === 0) { + return response()->json(['valid' => false, 'message' => '❌ QR ya utilizado.']); + } + + // Info del titular según tipo de QR + $titular = ''; + $categoriaNombre = ''; + + if ($qr->jugador) { + $categoriaNombre = $qr->jugador->categoria_calculada; + } + + if ($qr->tipo_qr === 'invitado' && $qr->jugador) { + $titular = 'Jugador: ' . $qr->jugador->nombre . ' ' . $qr->jugador->apellido . ' (' . $categoriaNombre . ')'; + } elseif ($qr->tipo_qr === 'publico') { + if ($qr->aficionado) { + $titular = 'Aficionado: ' . $qr->aficionado->nombre . ' ' . $qr->aficionado->apellido; + } else { + $titular = 'QR de entrada pública (sin referencia)'; + } + } else { + $titular = $qr->jugador + ? $qr->jugador->nombre . ' ' . $qr->jugador->apellido . ' (' . $categoriaNombre . ')' + : ($qr->aficionado ? $qr->aficionado->nombre . ' ' . $qr->aficionado->apellido : 'Desconocido'); + } + + $mensajeValido = '✅ Acceso válido — Jugador del evento'; + + if ($qr->tipo_qr === 'libre_50') { + $mensajeValido = '✅ Tenés descuento en la entrada, 50% (Jugador Categoría Libre)'; + } + + return response()->json([ + 'valid' => true, + 'message' => $mensajeValido, + 'data' => [ + 'evento' => $qr->evento ? $qr->evento->nombre_evento : $qr->id_evento, + 'titular' => $titular, + 'categoria' => $categoriaNombre, + 'tipo' => $qr->tipo_qr, + 'restantes' => ($qr->escaneos_restantes > 0 ? $qr->escaneos_restantes - 1 : 0), + ], + ]); + } + + // ══════════════════════════════════ + // CONFIGURACIÓN GENERAL + // ══════════════════════════════════ + public function settingsIndex(Request $request) + { + $this->checkSuperAdmin($request); + $diasExpiracion = \App\Models\Configuracion::get('dias_expiracion_eventos', 30); + $backupFreq = \App\Models\Configuracion::get('backup_frequency', 'daily'); + $emailReportes = \App\Models\Configuracion::get('email_reportes', 'asociados@onapb.com'); + $lastRun = \App\Models\Configuracion::get('last_scheduler_run', 'Nunca detectado'); + + return view('admin.settings', compact('diasExpiracion', 'backupFreq', 'lastRun', 'emailReportes')); + } + + public function settingsUpdate(Request $request) + { + $this->checkSuperAdmin($request); + $data = $request->validate([ + 'dias_expiracion_eventos' => 'required|integer|min:1', + 'backup_frequency' => 'required|string|in:daily,weekly,monthly', + 'email_reportes' => 'required|email', + ]); + + \App\Models\Configuracion::set('dias_expiracion_eventos', $data['dias_expiracion_eventos'], 'Días de antigüedad para borrar eventos y QRs'); + \App\Models\Configuracion::set('backup_frequency', $data['backup_frequency'], 'Frecuencia de backups automáticos (daily, weekly, monthly)'); + \App\Models\Configuracion::set('email_reportes', $data['email_reportes'], 'Email principal para recibir reportes del sistema'); + + return back()->with('admin_msg', 'Configuración actualizada correctamente.'); + } + + public function runManualTask(Request $request) + { + $this->checkSuperAdmin($request); + $command = $request->input('command'); + + try { + switch ($command) { + case 'cleanup': + \Illuminate\Support\Facades\Artisan::call('app:cleanup-old-events'); + $msg = '✅ Tarea de limpieza ejecutada.'; + break; + case 'backup': + // Usamos backup:run para forzar un backup completo + \Illuminate\Support\Facades\Artisan::call('backup:run'); + $msg = '✅ Proceso de backup iniciado.'; + break; + case 'report': + \Illuminate\Support\Facades\Artisan::call('reportes:semanal'); + $msg = '✅ Informe semanal enviado.'; + break; + default: + throw new \Exception('Comando no reconocido.'); + } + + return back()->with('admin_msg', $msg . ' (Salida: ' . \Illuminate\Support\Facades\Artisan::output() . ')'); + } catch (\Exception $e) { + \Illuminate\Support\Facades\Log::error("Error ejecutando comando manual: " . $e->getMessage()); + return back()->with('admin_error', 'Error al ejecutar tarea: ' . $e->getMessage()); + } + } + + // ══════════════════════════════════ + // SPONSORS + // ══════════════════════════════════ + public function sponsorsIndex(Request $request) + { + $this->checkSuperAdmin($request); + $sponsors = Sponsor::orderBy('orden')->latest()->get(); + return view('admin.sponsors.index', compact('sponsors')); + } + + public function sponsorsCreate(Request $request) + { + $this->checkSuperAdmin($request); + return view('admin.sponsors.form'); + } + + public function sponsorsStore(Request $request) + { + $this->checkSuperAdmin($request); + $data = $request->validate([ + 'nombre' => 'required|string|max:100', + 'imagen' => 'required|image|max:5120', + 'url' => 'nullable|url|max:255', + 'activo' => 'nullable|boolean', + 'orden' => 'nullable|integer', + ]); + + if ($request->hasFile('imagen')) { + $path = app(ImageOptimizer::class)->storeAndOptimize($request->file('imagen'), 'sponsors'); + $data['imagen'] = 'storage/' . $path; + } + + $data['activo'] = $request->has('activo'); + $data['orden'] = $data['orden'] ?? 0; + + Sponsor::create($data); + + return redirect()->route('admin.sponsors.index')->with('admin_msg', 'Sponsor creado correctamente.'); + } + + public function sponsorsEdit(Request $request, $id) + { + $this->checkSuperAdmin($request); + $sponsor = Sponsor::findOrFail($id); + return view('admin.sponsors.form', compact('sponsor')); + } + + public function sponsorsUpdate(Request $request, $id) + { + $this->checkSuperAdmin($request); + $sponsor = Sponsor::findOrFail($id); + + $data = $request->validate([ + 'nombre' => 'required|string|max:100', + 'imagen' => 'nullable|image|max:5120', + 'url' => 'nullable|url|max:255', + 'activo' => 'nullable|boolean', + 'orden' => 'nullable|integer', + ]); + + if ($request->hasFile('imagen')) { + // Eliminar imagen anterior si existe + if ($sponsor->imagen && file_exists(public_path($sponsor->imagen))) { + @unlink(public_path($sponsor->imagen)); + } + + $path = app(ImageOptimizer::class)->storeAndOptimize($request->file('imagen'), 'sponsors'); + $data['imagen'] = 'storage/' . $path; + } + + $data['activo'] = $request->has('activo'); + $data['orden'] = $data['orden'] ?? 0; + + $sponsor->update($data); + + return redirect()->route('admin.sponsors.index')->with('admin_msg', 'Sponsor actualizado correctamente.'); + } + + public function sponsorsDestroy(Request $request, $id) + { + $this->checkSuperAdmin($request); + $sponsor = Sponsor::findOrFail($id); + + if ($sponsor->imagen && file_exists(public_path($sponsor->imagen))) { + @unlink(public_path($sponsor->imagen)); + } + + $sponsor->delete(); + + return redirect()->route('admin.sponsors.index')->with('admin_msg', 'Sponsor eliminado correctamente.'); + } + + // ══════════════════════════════════ + // TORNEOS + // ══════════════════════════════════ + public function torneosIndex(Request $request) + { + $this->checkSuperAdmin($request); + $torneos = \App\Models\Torneo::withCount('equipos')->latest()->get(); + return view('admin.torneos.index', compact('torneos')); + } + + public function torneosCreate(Request $request) + { + $this->checkSuperAdmin($request); + return view('admin.torneos.form', ['torneo' => null]); + } + + public function torneosStore(Request $request) + { + $this->checkSuperAdmin($request); + $data = $request->validate([ + 'nombre' => 'required|string|max:100', + 'fecha_inicio' => 'nullable|date', + 'fecha_fin' => 'nullable|date', + ]); + \App\Models\Torneo::create($data); + return redirect()->route('admin.torneos.index')->with('admin_msg', 'Torneo creado correctamente.'); + } + + public function torneosEdit(Request $request, $id) + { + $this->checkSuperAdmin($request); + $torneo = \App\Models\Torneo::with('equipos.club')->findOrFail($id); + $clubes = Club::with('equipos')->orderBy('nombre')->get(); + return view('admin.torneos.form', compact('torneo', 'clubes')); + } + + public function torneosUpdate(Request $request, $id) + { + $this->checkSuperAdmin($request); + $torneo = \App\Models\Torneo::findOrFail($id); + $data = $request->validate([ + 'nombre' => 'required|string|max:100', + 'fecha_inicio' => 'nullable|date', + 'fecha_fin' => 'nullable|date', + ]); + $torneo->update($data); + return redirect()->route('admin.torneos.index')->with('admin_msg', 'Torneo actualizado correctamente.'); + } + + public function torneosDestroy(Request $request, $id) + { + $this->checkSuperAdmin($request); + $torneo = \App\Models\Torneo::findOrFail($id); + $torneo->delete(); + return redirect()->route('admin.torneos.index')->with('admin_msg', 'Torneo eliminado correctamente.'); + } + + public function torneoAddEquipo(Request $request, $id) + { + $this->checkSuperAdmin($request); + $torneo = \App\Models\Torneo::findOrFail($id); + $data = $request->validate([ + 'id_equipo' => 'required|integer|exists:equipos,id_equipo', + 'grupo' => 'nullable|string|max:50', + ]); + + if ($torneo->equipos()->where('torneo_equipo.id_equipo', $data['id_equipo'])->exists()) { + return back()->with('admin_error', 'El equipo ya está asignado a este torneo.'); + } + + $torneo->equipos()->attach($data['id_equipo'], ['grupo' => $data['grupo']]); + return back()->with('admin_msg', 'Equipo asignado al torneo correctamente.'); + } + + public function torneoRemoveEquipo($id, $id_equipo) + { + $this->checkSuperAdmin(request()); + $torneo = \App\Models\Torneo::findOrFail($id); + $torneo->equipos()->detach($id_equipo); + return back()->with('admin_msg', 'Equipo removido del torneo.'); + } + + public function eventosStats($id) + { + $this->checkGeneralAdmin(request()); + $evento = Evento::with(['equipoLocal.jugadores', 'equipoVisitante.jugadores'])->findOrFail($id); + + // Restricción para Administradores de Club: Solo partidos donde participa su club + if (session('admin_role') == 2) { + $idClub = session('admin_id_club'); + if ($evento->equipoLocal->id_club != $idClub && $evento->equipoVisitante->id_club != $idClub) { + abort(403, 'No tienes permiso para gestionar estadísticas de este partido.'); + } + } + + $stats = \App\Models\EventoJugador::where('id_evento', $id)->get()->keyBy('id_jugador'); + return view('admin.eventos.stats', compact('evento', 'stats')); + } + + public function eventosStatsStore(Request $request, $id) + { + $this->checkGeneralAdmin($request); + + // Validación de seguridad para Edición + if (session('admin_role') == 2) { + $evento = Evento::findOrFail($id); + $idClub = session('admin_id_club'); + if ($evento->equipoLocal->id_club != $idClub && $evento->equipoVisitante->id_club != $idClub) { + abort(403, 'No tienes permiso para editar estadísticas de este partido.'); + } + } + + $request->validate([ + 'stats' => 'required|array', + 'stats.*.puntos' => 'required|integer|min:0', + 'stats.*.faltas' => 'required|integer|min:0|max:5', + ]); + + foreach ($request->stats as $id_jugador => $vals) { + // Si es Admin de Club, solo puede guardar sus propios jugadores + if (session('admin_role') == 2) { + $jugador = Jugador::find($id_jugador); + if (!$jugador || $jugador->id_club_actual != session('admin_id_club')) { + continue; + } + } + + \App\Models\EventoJugador::updateOrCreate( + ['id_evento' => $id, 'id_jugador' => $id_jugador], + ['puntos' => $vals['puntos'], 'faltas' => $vals['faltas']] + ); + } + + return redirect()->route('admin.eventos.index')->with('admin_msg', 'Estadísticas guardadas correctamente.'); + } +} diff --git a/app/Http/Controllers/AdminUserController.php b/app/Http/Controllers/AdminUserController.php new file mode 100644 index 0000000..8cf1ca3 --- /dev/null +++ b/app/Http/Controllers/AdminUserController.php @@ -0,0 +1,55 @@ +json($users); + } + + public function show($id) + { + $user = AdminUser::findOrFail($id); + return response()->json($user); + } + + public function store(Request $request) + { + $data = $request->validate([ + 'username' => 'required|string|max:50|unique:admin_users', + 'password' => 'required|string', + 'role' => 'nullable|integer', + ]); + $data['password'] = bcrypt($data['password']); + $user = AdminUser::create($data); + return response()->json($user, 201); + } + + public function update(Request $request, $id) + { + $user = AdminUser::findOrFail($id); + $data = $request->validate([ + 'username' => 'sometimes|string|max:50|unique:admin_users,username,' . $id, + 'password' => 'sometimes|string', + 'role' => 'sometimes|integer', + ]); + if (isset($data['password'])) { + $data['password'] = bcrypt($data['password']); + } + $user->update($data); + return response()->json($user); + } + + public function destroy($id) + { + $user = AdminUser::findOrFail($id); + $user->delete(); + return response()->json(null, 204); + } +} diff --git a/app/Http/Controllers/AficionadoController.php b/app/Http/Controllers/AficionadoController.php new file mode 100644 index 0000000..f3eadd7 --- /dev/null +++ b/app/Http/Controllers/AficionadoController.php @@ -0,0 +1,67 @@ +json($aficionados); + } + + public function show($id) + { + $aficionado = Aficionado::findOrFail($id); + return response()->json($aficionado); + } + + public function store(Request $request) + { + $data = $request->validate([ + 'nombre' => 'required|string|max:100', + 'apellido' => 'required|string|max:100', + 'dni' => 'required|string|max:20|unique:aficionados', + 'fecha_nacimiento' => 'nullable|date', + 'email' => 'nullable|email|max:150', + 'telefono' => 'nullable|string|max:50', + 'localidad' => 'nullable|string|max:100', + 'password' => 'nullable|string', + ]); + if (isset($data['password'])) { + $data['password'] = bcrypt($data['password']); + } + $aficionado = Aficionado::create($data); + return response()->json($aficionado, 201); + } + + public function update(Request $request, $id) + { + $aficionado = Aficionado::findOrFail($id); + $data = $request->validate([ + 'nombre' => 'sometimes|string|max:100', + 'apellido' => 'sometimes|string|max:100', + 'dni' => 'sometimes|string|max:20|unique:aficionados,dni,' . $id . ',id_aficionado', + 'fecha_nacimiento' => 'nullable|date', + 'email' => 'nullable|email|max:150', + 'telefono' => 'nullable|string|max:50', + 'localidad' => 'nullable|string|max:100', + 'password' => 'nullable|string', + ]); + if (isset($data['password'])) { + $data['password'] = bcrypt($data['password']); + } + $aficionado->update($data); + return response()->json($aficionado); + } + + public function destroy($id) + { + $aficionado = Aficionado::findOrFail($id); + $aficionado->delete(); + return response()->json(null, 204); + } +} diff --git a/app/Http/Controllers/AuthController.php b/app/Http/Controllers/AuthController.php new file mode 100644 index 0000000..a801d3a --- /dev/null +++ b/app/Http/Controllers/AuthController.php @@ -0,0 +1,411 @@ +post('https://challenges.cloudflare.com/turnstile/v0/siteverify', [ + 'secret' => config('services.turnstile.secret_key'), + 'response' => $token, + 'remoteip' => request()->ip(), + ]); + + return $response->successful() && $response->json('success'); + } + + public function login(Request $request) + { + $tipo = $request->input('tipo'); + + if ($tipo === 'admin') { + return $this->loginAdmin($request); + } + + return $this->loginPlayer($request); + } + + public function loginPlayer(Request $request) + { + if (!$this->verifyTurnstile($request->input('cf-turnstile-response'))) { + return back()->with('login_error', 'Error de verificación de seguridad (Turnstile).')->with('login_tab', 'player'); + } + + $dni = $request->input('dni'); + $password = $request->input('password'); + + $jugador = Jugador::where('documento', $dni)->where('activo', true)->first(); + + if ($jugador && $jugador->password && Hash::check($password, $jugador->password)) { + $request->session()->put('user_logged_in', true); + $request->session()->put('user_tipo', 'jugador'); + $request->session()->put('user_id', $jugador->id_jugador); + $request->session()->put('user_name', $jugador->nombre . ' ' . $jugador->apellido); + $request->session()->put('user_documento', $jugador->documento); + $request->session()->put('user_ultimo_acceso', time()); + + return redirect()->intended('/'); + } + + $aficionado = Aficionado::where('dni', $dni)->first(); + + if ($aficionado && $aficionado->password && Hash::check($password, $aficionado->password)) { + $request->session()->put('user_logged_in', true); + $request->session()->put('user_tipo', 'aficionado'); + $request->session()->put('user_id', $aficionado->id_aficionado); + $request->session()->put('user_name', $aficionado->nombre . ' ' . $aficionado->apellido); + $request->session()->put('user_documento', $aficionado->dni); + $request->session()->put('user_ultimo_acceso', time()); + + return redirect()->intended('/'); + } + + return back()->with('login_error', 'DNI o contraseña incorrectos')->with('login_tab', 'player'); + } + + public function loginAdmin(Request $request) + { + if (!$this->verifyTurnstile($request->input('cf-turnstile-response'))) { + return back()->with('login_error', 'Error de verificación de seguridad (Turnstile).')->with('login_tab', 'admin'); + } + + $username = $request->input('username'); + $password = $request->input('password'); + + $admin = AdminUser::whereRaw('BINARY `username` = ?', [$username])->first(); + + if ($admin && Hash::check($password, $admin->password)) { + $request->session()->put('admin_logged_in', true); + $request->session()->put('admin_id', $admin->id); + $request->session()->put('admin_username', $admin->username); + $request->session()->put('admin_role', $admin->role); + $request->session()->put('admin_id_club', $admin->id_club); + + if ($admin->id_club && $admin->club) { + $request->session()->put('admin_club_nombre', $admin->club->nombre); + } + + $request->session()->put('ultimo_acceso', time()); + + return redirect()->intended('/'); + } + + return back()->with('login_error', 'Usuario o contraseña incorrectos')->with('login_tab', 'admin'); + } + + public function logout(Request $request) + { + $isAdmin = $request->session()->get('admin_logged_in'); + + if ($isAdmin) { + $request->session()->forget(['admin_logged_in', 'admin_id', 'admin_username', 'admin_role', 'ultimo_acceso']); + $msg = 'Sesión de administrador cerrada correctamente.'; + } else { + $request->session()->forget(['user_logged_in', 'user_tipo', 'user_id', 'user_name', 'user_documento', 'user_ultimo_acceso']); + $msg = 'Sesión cerrada correctamente.'; + } + + return redirect('/?logout_msg=' . urlencode($msg)); + } + + public function showLoginForm() + { + return view('welcome'); + } + + public function recuperar(Request $request) + { + if (!$this->verifyTurnstile($request->input('cf-turnstile-response'))) { + return back()->with('mensaje', '⚠️ Error de verificación de seguridad (Captcha).'); + } + + $dni = trim($request->input('dni')); + $email = trim($request->input('email')); + + if (empty($dni) || empty($email)) { + return back()->with('mensaje', 'Debes ingresar tu DNI y correo electrónico.'); + } + + $jugador = Jugador::where('documento', $dni)->where('email', $email)->first(); + $aficionado = Aficionado::where('dni', $dni)->where('email', $email)->first(); + + $usuario = $jugador ?: $aficionado; + + if (!$usuario) { + return back()->with('mensaje', 'No se encontró un usuario con ese DNI y correo.'); + } + + $token = bin2hex(random_bytes(16)); + $expires = now()->addHour(); + + if ($jugador) { + $jugador->update([ + 'reset_token' => $token, + 'reset_expira' => $expires + ]); + } else { + $aficionado->update([ + 'reset_token' => $token, + 'reset_expira' => $expires + ]); + } + try { + Mail::to($usuario->email)->send(new ResetPasswordMail($usuario, $token)); + } catch (\Exception $e) { + Log::error("Error enviando mail de recuperación: " . $e->getMessage()); + } + + return back()->with('mensaje', '📩 Te enviamos un correo con las instrucciones para recuperar tu contraseña.'); + } + + public function resetPasswordForm($token) + { + // Verificar que el token exista y no esté expirado + $jugador = Jugador::where('reset_token', $token)->where('reset_expira', '>', now())->first(); + $aficionado = Aficionado::where('reset_token', $token)->where('reset_expira', '>', now())->first(); + + if (!$jugador && !$aficionado) { + return redirect()->route('recuperar')->with('mensaje', '❌ El enlace es inválido o ya expiró. Solicitá uno nuevo.'); + } + + return view('auth.reset_password', compact('token')); + } + + public function resetPassword(Request $request) + { + $request->validate([ + 'token' => 'required|string', + 'password' => 'required|confirmed|min:6', + ]); + + $token = $request->input('token'); + + $jugador = Jugador::where('reset_token', $token)->where('reset_expira', '>', now())->first(); + $aficionado = Aficionado::where('reset_token', $token)->where('reset_expira', '>', now())->first(); + + $usuario = $jugador ?: $aficionado; + + if (!$usuario) { + return redirect()->route('recuperar')->with('mensaje', '❌ El enlace es inválido o ya expiró. Solicitá uno nuevo.'); + } + + $usuario->update([ + 'password' => bcrypt($request->input('password')), + 'reset_token' => null, + 'reset_expira' => null, + ]); + + return redirect('/')->with('login_success', '✅ Contraseña cambiada correctamente. Ya podés iniciar sesión.'); + } + + public function registroAficionado(Request $request) + { + if (!$this->verifyTurnstile($request->input('cf-turnstile-response'))) { + return back()->with('registro_msg', '⚠️ Error de verificación de seguridad (Captcha).')->withInput(); + } + + $data = $request->validate([ + 'nombre' => 'required|string|max:100', + 'apellido' => 'required|string|max:100', + 'dni' => 'required|string|unique:aficionados,dni', + 'email' => 'required|email|unique:aficionados,email', + 'fecha_nacimiento' => 'nullable|date', + 'telefono' => 'nullable|string|max:50', + 'localidad' => 'nullable|string|max:100', + 'password' => 'required|confirmed|min:6', + ]); + + $data['password'] = bcrypt($data['password']); + $data['fecha_registro'] = now(); + + $aficionado = Aficionado::create($data); + + try { + Mail::to($aficionado->email)->send(new WelcomeMail($aficionado, 'aficionado')); + } catch (\Exception $e) { + Log::error("Error enviando mail de bienvenida a Aficionado: " . $e->getMessage()); + } + + return redirect()->route('asociate')->with('mensaje', '✅ Te registraste correctamente. Ya podés iniciar sesión.'); + } + + public function buscarJugador(Request $request) + { + $request->validate([ + 'nombre' => 'required|string', + 'apellido' => 'required|string', + 'dni' => 'required|string', + 'acepto' => 'required', + ]); + + $dni = preg_replace('/[^0-9]/', '', $request->input('dni')); + $nombre = strtoupper(trim($request->input('nombre'))); + $apellido = strtoupper(trim($request->input('apellido'))); + + // Buscar jugador por DNI + $jugador = Jugador::where('documento', $dni)->first(); + + if (!$jugador) { + // Verificar si ya está registrado como aficionado + $aficionado = Aficionado::where('dni', $dni)->first(); + if ($aficionado) { + return redirect()->route('asociate')->with('mensaje', '⚠️ Ya estás registrado como aficionado.'); + } + // No existe - se debe registrar como aficionado + return redirect()->route('asociate')->with('mensaje', '⚠️ No encontramos tu registro como jugador. Podés registrarte como aficionado.'); + } + + // --- Lógica Smart Match --- + $nombreEnBD = $this->normalizeString($jugador->nombre ?? ''); + $apellidoEnBD = $this->normalizeString($jugador->apellido ?? ''); + $terminosBD = explode(' ', $nombreEnBD . ' ' . $apellidoEnBD); + + $palabrasNombreIn = explode(' ', $this->normalizeString($nombre)); + $palabrasApellidoIn = explode(' ', $this->normalizeString($apellido)); + + $nombreCoincide = false; + foreach ($palabrasNombreIn as $p) { + if ($this->isApproxMatch($p, $terminosBD)) { + $nombreCoincide = true; + break; + } + } + + $apellidoCoincide = false; + foreach ($palabrasApellidoIn as $p) { + if ($this->isApproxMatch($p, $terminosBD)) { + $apellidoCoincide = true; + break; + } + } + + if (!$nombreCoincide || !$apellidoCoincide) { + return redirect()->route('asociate')->with('mensaje', '⚠️ El DNI ingresado no coincide con el Nombre y Apellido proporcionados. Por favor, verifica tus datos.'); + } + // --- Fin Smart Match --- + + if ($jugador->activo) { + return redirect()->route('asociate')->with('registro_msg', 'Este jugador ya está registrado en el sistema.'); + } + + // Jugador encontrado e inactivo - mostrar formulario para completar + $club = null; + if ($jugador->id_club_actual) { + $clubObj = \App\Models\Club::find($jugador->id_club_actual); + $club = $clubObj ? $clubObj->nombre : null; + } + + $jugador_encontrado = [ + 'documento' => $jugador->documento, + 'nombre' => $jugador->nombre, + 'apellido' => $jugador->apellido, + 'fecha_nacimiento' => $jugador->fecha_nacimiento, + 'club' => $club, + 'categoria' => $jugador->categoria, + ]; + + return view('auth.asociate', compact('jugador_encontrado'))->with('tab', 'jugador'); + } + + public function completarRegistroJugador(Request $request) + { + if (!$this->verifyTurnstile($request->input('cf-turnstile-response'))) { + return back()->with('registro_msg', '⚠️ Error de verificación de seguridad (Captcha).')->withInput(); + } + + $request->validate([ + 'dni' => 'required|string', + 'email' => 'required|email', + 'telefono' => 'nullable|string', + 'password' => 'required|confirmed|min:6', + ]); + + $dni = preg_replace('/[^0-9]/', '', $request->input('dni')); + + $jugador = Jugador::where('documento', $dni)->first(); + + if (!$jugador) { + return redirect()->route('asociate')->with('registro_msg', 'Jugador no encontrado.'); + } + + if ($jugador->activo) { + return redirect()->route('asociate')->with('registro_msg', 'Este jugador ya está registrado.'); + } + + $jugador->update([ + 'email' => $request->input('email'), + 'telefono' => $request->input('telefono'), + 'password' => bcrypt($request->input('password')), + 'activo' => 1, + 'fecha_registro' => now(), + ]); + + try { + Mail::to($jugador->email)->send(new WelcomeMail($jugador, 'jugador')); + } catch (\Exception $e) { + Log::error("Error enviando mail de bienvenida a Jugador: " . $e->getMessage()); + } + + return redirect()->route('asociate')->with('mensaje', '✅ Registro completado exitosamente. Ya podés iniciar sesión.'); + } + + /** + * Normaliza un string para comparaciones (mayúsculas, sin acentos, sin espacios extras) + */ + private function normalizeString($str) + { + $unwanted_array = ['Š'=>'S', 'š'=>'s', 'Ž'=>'Z', 'ž'=>'z', 'À'=>'A', 'Á'=>'A', 'Â'=>'A', 'Ã'=>'A', 'Ä'=>'A', 'Å'=>'A', 'Æ'=>'A', 'Ç'=>'C', 'È'=>'E', 'É'=>'E', + 'Ê'=>'E', 'Ë'=>'E', 'Ì'=>'I', 'Í'=>'I', 'Î'=>'I', 'Ï'=>'I', 'Ñ'=>'N', 'Ò'=>'O', 'Ó'=>'O', 'Ô'=>'O', 'Õ'=>'O', 'Ö'=>'O', 'Ø'=>'O', 'Ù'=>'U', + 'Ú'=>'U', 'Û'=>'U', 'Ü'=>'U', 'Ý'=>'Y', 'Þ'=>'B', 'ß'=>'Ss', 'à'=>'a', 'á'=>'a', 'â'=>'a', 'ã'=>'a', 'ä'=>'a', 'å'=>'a', 'æ'=>'a', 'ç'=>'c', + 'è'=>'e', 'é'=>'e', 'ê'=>'e', 'ë'=>'e', 'ì'=>'i', 'í'=>'i', 'î'=>'i', 'ï'=>'i', 'ð'=>'o', 'ñ'=>'n', 'ò'=>'o', 'ó'=>'o', 'ô'=>'o', 'õ'=>'o', + 'ö'=>'o', 'ø'=>'o', 'ù'=>'u', 'ú'=>'u', 'û'=>'u', 'ý'=>'y', 'þ'=>'b', 'ÿ'=>'y']; + + $str = strtr($str, $unwanted_array); + return strtoupper(trim(preg_replace('/\s+/', ' ', $str))); + } + + /** + * Comprueba si una palabra coincide aproximadamente con alguna de la lista + */ + private function isApproxMatch($word, $list) + { + if (strlen($word) < 3) return false; + + foreach ($list as $item) { + if (strlen($item) < 3) continue; + + // Coincidencia exacta o contenida + if ($word === $item || strpos($item, $word) !== false || strpos($word, $item) !== false) { + return true; + } + + // Levenshtein para errores de tipeo (máximo 1 de distancia) + if (levenshtein($word, $item) <= 1) { + return true; + } + } + return false; + } +} diff --git a/app/Http/Controllers/ClubController.php b/app/Http/Controllers/ClubController.php new file mode 100644 index 0000000..9ad2eb8 --- /dev/null +++ b/app/Http/Controllers/ClubController.php @@ -0,0 +1,47 @@ +get(); + return response()->json($clubes); + } + + public function show($id) + { + $club = Club::with('equipos', 'jugadores')->findOrFail($id); + return response()->json($club); + } + + public function store(Request $request) + { + $data = $request->validate([ + 'nombre' => 'required|string|max:100', + ]); + $club = Club::create($data); + return response()->json($club, 201); + } + + public function update(Request $request, $id) + { + $club = Club::findOrFail($id); + $data = $request->validate([ + 'nombre' => 'sometimes|string|max:100', + ]); + $club->update($data); + return response()->json($club); + } + + public function destroy($id) + { + $club = Club::findOrFail($id); + $club->delete(); + return response()->json(null, 204); + } +} diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php new file mode 100644 index 0000000..8677cd5 --- /dev/null +++ b/app/Http/Controllers/Controller.php @@ -0,0 +1,8 @@ +getUserRole(); + + // Segmentar contenido + $filteredMd = $this->segmentMarkdown($md, $role); + + $parsedown = new \Parsedown(); + $content = $parsedown->text($filteredMd); + } + + return view('documentacion.index', compact('content')); + } + + /** + * Detecta el rol del usuario basado en las variables de sesión + */ + private function getUserRole() + { + if (session()->get('admin_logged_in')) { + $adminRole = session()->get('admin_role'); + return ($adminRole == 1) ? 'superadmin' : 'admin_club'; + } + + if (session()->get('user_logged_in')) { + return session()->get('user_tipo'); // 'jugador' o 'aficionado' + } + + return 'visitante'; + } + + /** + * Segmenta y filtra el Markdown según el rol + */ + private function segmentMarkdown($md, $role) + { + $markers = [ + 'cap1' => '', + 'cap2' => '', + 'cap3' => '', + 'cap4' => '', + 'cap5' => '', + 'faq' => '## ❓ Preguntas Frecuentes' + ]; + + // Encontrar posiciones de los marcadores + $positions = []; + foreach ($markers as $key => $marker) { + $pos = strpos($md, $marker); + if ($pos !== false) { + $positions[$key] = $pos; + } + } + asort($positions); + + $keys = array_keys($positions); + $sections = []; + + // Intro (antes del primer capítulo) + $sections['intro'] = substr($md, 0, $positions[$keys[0]]); + + // Capítulos y FAQ + for ($i = 0; $i < count($keys); $i++) { + $start = $positions[$keys[$i]]; + $end = isset($keys[$i+1]) ? $positions[$keys[$i+1]] : strlen($md); + $sections[$keys[$i]] = substr($md, $start, $end - $start); + } + + // Determinar qué capítulos mostrar + $allowedChapters = [1]; // Todos ven el Cap 1 + $showSections = ['intro', 'cap1', 'faq']; + + switch ($role) { + case 'superadmin': + $allowedChapters = [1, 2, 3, 4, 5]; + $showSections = ['intro', 'cap1', 'cap2', 'cap3', 'cap4', 'cap5', 'faq']; + break; + case 'admin_club': + $allowedChapters = [1, 4]; + $showSections = ['intro', 'cap1', 'cap4', 'faq']; + break; + case 'jugador': + $allowedChapters = [1, 2]; + $showSections = ['intro', 'cap1', 'cap2', 'faq']; + break; + case 'aficionado': + $allowedChapters = [1, 3]; + $showSections = ['intro', 'cap1', 'cap3', 'faq']; + break; + default: // visitante + $allowedChapters = [1]; + $showSections = ['intro', 'cap1', 'faq']; + break; + } + + // Filtrar Tabla de Contenidos en la Intro + $sections['intro'] = $this->filterTOC($sections['intro'], $allowedChapters); + + // Unir secciones seleccionadas + $finalMd = ""; + foreach ($showSections as $s) { + if (isset($sections[$s])) { + $finalMd .= $sections[$s] . "\n\n---\n\n"; + } + } + + return $finalMd; + } + + /** + * Filtra la tabla de contenidos para mostrar solo los capítulos permitidos + */ + private function filterTOC($intro, $allowedChapters) + { + $lines = explode("\n", $intro); + $filteredLines = []; + $inTable = false; + + foreach ($lines as $line) { + if (strpos($line, '| Capítulo | Perfil |') !== false) { + $inTable = true; + $filteredLines[] = $line; + continue; + } + + if ($inTable) { + if (trim($line) === '' || (strpos($line, '|') === false && trim($line) !== '')) { + $inTable = false; + $filteredLines[] = $line; + continue; + } + + if (strpos($line, '|---|') !== false) { + $filteredLines[] = $line; + continue; + } + + // Filtrar fila de la tabla + $matched = false; + foreach ($allowedChapters as $cap) { + if (strpos($line, "[Capítulo $cap]") !== false) { + $matched = true; + break; + } + } + + if ($matched) { + $filteredLines[] = $line; + } + } else { + $filteredLines[] = $line; + } + } + return implode("\n", $filteredLines); + } + + public function download() + { + $path = base_path('misc/MANUAL_USUARIO.md'); + + if (!File::exists($path)) { + abort(404, 'No se pudo generar el manual porque el archivo base no existe.'); + } + + $md = File::get($path); + + // Detectar rol de la sesión + $role = $this->getUserRole(); + + // Segmentar contenido + $filteredMd = $this->segmentMarkdown($md, $role); + + // Convertir Markdown a HTML + $parsedown = new \Parsedown(); + $htmlContent = $parsedown->text($filteredMd); + + // Preparar Logo en Base64 para el PDF + $logoBase64 = null; + $logoPath = public_path('logo.png'); + if (File::exists($logoPath)) { + $type = pathinfo($logoPath, PATHINFO_EXTENSION); + $data = File::get($logoPath); + $logoBase64 = 'data:image/' . $type . ';base64,' . base64_encode($data); + } + + // Depuración temporal: Descomenta si querés ver qué rol detecta antes de generar el PDF + // dd('Rol detectado: ' . $role); + + // Generar PDF con dompdf + $pdf = Pdf::loadView('documentacion.pdf', [ + 'content' => $htmlContent, + 'logo' => $logoBase64 + ]); + + // Ajustar papel y orientación + $pdf->setPaper('A4', 'portrait'); + + // Nombre de archivo único para evitar caché de navegador + $filename = 'Manual_Segmentado_' . $role . '_' . time() . '.pdf'; + + return $pdf->download($filename); + } +} diff --git a/app/Http/Controllers/EquipoController.php b/app/Http/Controllers/EquipoController.php new file mode 100644 index 0000000..acbb688 --- /dev/null +++ b/app/Http/Controllers/EquipoController.php @@ -0,0 +1,57 @@ +get(); + return response()->json($equipos); + } + + public function show($id) + { + $equipo = Equipo::with('club', 'jugadores')->findOrFail($id); + return response()->json($equipo); + } + + public function publicShow($id) + { + $equipo = Equipo::with(['club', 'jugadores'])->findOrFail($id); + return view('equipos.show', compact('equipo')); + } + + public function store(Request $request) + { + $data = $request->validate([ + 'id_club' => 'required|integer|exists:clubes,id_club', + 'categoria' => 'required|string|max:20', + 'division' => 'nullable|string|max:5', + ]); + $equipo = Equipo::create($data); + return response()->json($equipo, 201); + } + + public function update(Request $request, $id) + { + $equipo = Equipo::findOrFail($id); + $data = $request->validate([ + 'id_club' => 'sometimes|integer|exists:clubes,id_club', + 'categoria' => 'sometimes|string|max:20', + 'division' => 'nullable|string|max:5', + ]); + $equipo->update($data); + return response()->json($equipo); + } + + public function destroy($id) + { + $equipo = Equipo::findOrFail($id); + $equipo->delete(); + return response()->json(null, 204); + } +} diff --git a/app/Http/Controllers/EventoController.php b/app/Http/Controllers/EventoController.php new file mode 100644 index 0000000..826cd21 --- /dev/null +++ b/app/Http/Controllers/EventoController.php @@ -0,0 +1,84 @@ +get('fecha', now()->toDateString()); + $fechaSeleccionada = Carbon::parse($fechaStr); + + // Generar rango de fechas (Ayer, Hoy, Mañana + otros) + $fechasNav = []; + for ($i = -3; $i <= 3; $i++) { + $d = now()->addDays($i); + $fechasNav[] = [ + 'label' => $this->getFechaLabel($d), + 'fecha' => $d->toDateString(), + 'active' => $d->isSameDay($fechaSeleccionada) + ]; + } + + $eventos = Evento::with(['equipoLocal.club', 'equipoVisitante.club']) + ->whereDate('fecha_evento', $fechaSeleccionada->toDateString()) + ->orderBy('hora_inicio', 'asc') + ->get() + ->map(function ($e) { + $e->estado = $this->calcularEstado($e->fecha_evento, $e->hora_inicio, $e->hora_fin); + return $e; + }); + + return view('eventos.index', compact('eventos', 'fechasNav', 'fechaSeleccionada')); + } + + private function getFechaLabel(Carbon $date) + { + $fecha = $date->format('d/m'); + if ($date->isToday()) return "HOY $fecha"; + if ($date->isYesterday()) return "AYER $fecha"; + if ($date->isTomorrow()) return "MAÑANA $fecha"; + + return $date->translatedFormat('D d/m'); + } + + public function show($id) + { + $evento = Evento::with(['equipoLocal.club', 'equipoVisitante.club', 'qrCodes']) + ->findOrFail($id); + + $evento->estado = $this->calcularEstado($evento->fecha_evento, $evento->hora_inicio, $evento->hora_fin); + + $isAdmin = session()->has('admin_logged_in') && session('admin_logged_in'); + $isUser = session()->has('user_logged_in') && session('user_logged_in'); + + return view('eventos.show', compact('evento', 'isAdmin', 'isUser')); + } + + private function calcularEstado($fechaEvento, $horaInicio, $horaFin) + { + $tz = new \DateTimeZone(config('app.timezone', 'America/Argentina/Buenos_Aires')); + + // Asegurarnos de tener strings limpios (Y-m-d y H:i:s) + $f = ($fechaEvento instanceof \Carbon\Carbon) ? $fechaEvento->format('Y-m-d') : substr($fechaEvento, 0, 10); + $h1 = ($horaInicio instanceof \Carbon\Carbon) ? $horaInicio->format('H:i:s') : $horaInicio; + $h2 = ($horaFin instanceof \Carbon\Carbon) ? $horaFin->format('H:i:s') : $horaFin; + + $inicio = new \DateTime("$f $h1", $tz); + $fin = new \DateTime("$f $h2", $tz); + + if ($fin <= $inicio) { + $fin->modify('+1 day'); + } + + $ahora = new \DateTime('now', $tz); + + if ($ahora >= $inicio && $ahora <= $fin) return 'Activo'; + if ($ahora < $inicio) return 'Próximo'; + return 'Finalizado'; + } +} diff --git a/app/Http/Controllers/GeniusAgentController.php b/app/Http/Controllers/GeniusAgentController.php new file mode 100644 index 0000000..7de4886 --- /dev/null +++ b/app/Http/Controllers/GeniusAgentController.php @@ -0,0 +1,81 @@ +validate([ + 'message' => ['required', 'string', 'max:1000'], + 'thread_id' => ['nullable', 'string', 'uuid'], + ]); + + $message = $request->string('message')->trim()->toString(); + + if (session('admin_logged_in')) { + return $this->handleAdmin($message, $request->input('thread_id')); + } + + return $this->handlePublic($request, $message); + } + + private function handleAdmin(string $message, ?string $threadId): JsonResponse + { + $adminId = (int) session('admin_id'); + $isSuperadmin = (int) session('admin_role') === 1; + $thread = AgentThread::findOrCreateForAdmin($threadId, $adminId); + + $reply = $this->service->chatAdmin($message, $thread, $isSuperadmin); + + return response()->json([ + 'reply' => $reply, + 'thread_id' => $thread->thread_id, + ]); + } + + private function handlePublic(Request $request, string $message): JsonResponse + { + $maxMessages = (int) config('services.genius.max_messages_per_session', 20); + $windowMin = (int) config('services.genius.session_window_minutes', 60); + + $session = $request->session(); + $startedAt = $session->get('agent_window_started_at'); + $count = (int) $session->get('agent_window_count', 0); + + if ($startedAt === null || (time() - (int) $startedAt) > $windowMin * 60) { + $startedAt = time(); + $count = 0; + } + + if ($maxMessages > 0 && $count >= $maxMessages) { + $remaining = max(1, (int) ceil(($windowMin * 60 - (time() - (int) $startedAt)) / 60)); + return response()->json([ + 'reply' => "Llegaste al límite de {$maxMessages} consultas por sesión. " + . "Volvé a intentar en {$remaining} minuto(s) o contactá directamente a OnAPB.", + 'limit_reached' => true, + ]); + } + + $history = $session->get('agent_messages', []); + $reply = $this->service->chatPublic($message, $history); + + $history[] = ['role' => 'user', 'content' => $message]; + $history[] = ['role' => 'assistant', 'content' => $reply]; + + $session->put('agent_messages', $history); + $session->put('agent_window_started_at', $startedAt); + $session->put('agent_window_count', $count + 1); + + return response()->json(['reply' => $reply]); + } +} diff --git a/app/Http/Controllers/HomeController.php b/app/Http/Controllers/HomeController.php new file mode 100644 index 0000000..e32bb4c --- /dev/null +++ b/app/Http/Controllers/HomeController.php @@ -0,0 +1,86 @@ +getEventos(); + $promociones = Promocion::whereNotNull('descripcion') + ->where('descripcion', '!=', '') + ->orderBy('id', 'desc') + ->get(); + + $carouselItems = CarouselItem::where('activo', true) + ->orderBy('orden', 'asc') + ->get(); + + $noticias = Noticia::orderBy('id', 'desc') + ->take(3) + ->get(); + + $torneos = Torneo::orderBy('id', 'desc') + ->get(); + + $tournamentService = new TournamentService(); + $standingsData = []; + foreach ($torneos as $t) { + $standingsData[$t->id] = $tournamentService->getStandings($t->id); + } + + return view('welcome', compact('eventos', 'promociones', 'carouselItems', 'noticias', 'torneos', 'standingsData')); + } + + private function getEventos() + { + $eventos = Evento::with(['equipoLocal.club', 'equipoVisitante.club']) + ->orderBy('fecha_evento', 'asc') + ->orderBy('hora_inicio', 'asc') + ->get() + ->map(function ($e) { + $estado = $this->calcularEstado($e->fecha_evento, $e->hora_inicio, $e->hora_fin); + if (in_array($estado, ['Activo', 'Próximo'])) { + $e->estado = $estado; + return $e; + } + return null; + }) + ->filter() + ->values(); + + return $eventos; + } + + private function calcularEstado($fechaEvento, $horaInicio, $horaFin) + { + $tz = new \DateTimeZone(config('app.timezone', 'America/Argentina/Buenos_Aires')); + + // Asegurarnos de tener strings limpios (Y-m-d y H:i:s) + $f = ($fechaEvento instanceof \Carbon\Carbon) ? $fechaEvento->format('Y-m-d') : substr($fechaEvento, 0, 10); + $h1 = ($horaInicio instanceof \Carbon\Carbon) ? $horaInicio->format('H:i:s') : $horaInicio; + $h2 = ($horaFin instanceof \Carbon\Carbon) ? $horaFin->format('H:i:s') : $horaFin; + + $inicio = new \DateTime("$f $h1", $tz); + $fin = new \DateTime("$f $h2", $tz); + + if ($fin <= $inicio) { + $fin->modify('+1 day'); + } + + $ahora = new \DateTime('now', $tz); + + if ($ahora >= $inicio && $ahora <= $fin) return 'Activo'; + if ($ahora < $inicio) return 'Próximo'; + return 'Finalizado'; + } +} diff --git a/app/Http/Controllers/JugadorController.php b/app/Http/Controllers/JugadorController.php new file mode 100644 index 0000000..1867573 --- /dev/null +++ b/app/Http/Controllers/JugadorController.php @@ -0,0 +1,76 @@ +get(); + return response()->json($jugadores); + } + + public function show($id) + { + $jugador = Jugador::with('clubActual', 'clubOrigen', 'equipos')->findOrFail($id); + return response()->json($jugador); + } + + public function store(Request $request) + { + $data = $request->validate([ + 'id_jugador' => 'nullable|string|max:6', + 'documento' => 'required|string|max:20|unique:jugadores', + 'nombre' => 'required|string|max:100', + 'apellido' => 'required|string|max:100', + 'fecha_nacimiento' => 'nullable|date', + 'edad' => 'nullable|integer', + 'categoria' => 'nullable|string|max:20', + 'id_club_actual' => 'nullable|integer|exists:clubes,id_club', + 'id_club_origen' => 'nullable|integer|exists:clubes,id_club', + 'activo' => 'nullable|boolean', + 'email' => 'nullable|email|max:150', + 'telefono' => 'nullable|string|max:50', + 'password' => 'nullable|string', + ]); + if (isset($data['password'])) { + $data['password'] = bcrypt($data['password']); + } + $jugador = Jugador::create($data); + return response()->json($jugador, 201); + } + + public function update(Request $request, $id) + { + $jugador = Jugador::findOrFail($id); + $data = $request->validate([ + 'documento' => 'sometimes|string|max:20|unique:jugadores,documento,' . $id . ',id_jugador', + 'nombre' => 'sometimes|string|max:100', + 'apellido' => 'sometimes|string|max:100', + 'fecha_nacimiento' => 'nullable|date', + 'edad' => 'nullable|integer', + 'categoria' => 'nullable|string|max:20', + 'id_club_actual' => 'nullable|integer|exists:clubes,id_club', + 'id_club_origen' => 'nullable|integer|exists:clubes,id_club', + 'activo' => 'nullable|boolean', + 'email' => 'nullable|email|max:150', + 'telefono' => 'nullable|string|max:50', + 'password' => 'nullable|string', + ]); + if (isset($data['password'])) { + $data['password'] = bcrypt($data['password']); + } + $jugador->update($data); + return response()->json($jugador); + } + + public function destroy($id) + { + $jugador = Jugador::findOrFail($id); + $jugador->delete(); + return response()->json(null, 204); + } +} diff --git a/app/Http/Controllers/JugadorEquipoController.php b/app/Http/Controllers/JugadorEquipoController.php new file mode 100644 index 0000000..60e9d3a --- /dev/null +++ b/app/Http/Controllers/JugadorEquipoController.php @@ -0,0 +1,73 @@ +get(); + return response()->json($relaciones); + } + + public function show($idJugador, $idEquipo) + { + $relacion = JugadorEquipo::where('id_jugador', $idJugador) + ->where('id_equipo', $idEquipo) + ->with('jugador', 'equipo') + ->firstOrFail(); + return response()->json($relacion); + } + + public function store(Request $request) + { + $data = $request->validate([ + 'id_jugador' => 'required|string|max:6|exists:jugadores,id_jugador', + 'id_equipo' => 'required|integer|exists:equipos,id_equipo', + 'fecha_alta' => 'nullable|date', + ]); + $relacion = JugadorEquipo::create($data); + return response()->json($relacion, 201); + } + + public function update(Request $request, $idJugador, $idEquipo) + { + $relacion = JugadorEquipo::where('id_jugador', $idJugador) + ->where('id_equipo', $idEquipo) + ->firstOrFail(); + + $data = $request->validate([ + 'fecha_alta' => 'nullable|date', + ]); + $relacion->update($data); + return response()->json($relacion); + } + + public function destroy($idJugador, $idEquipo) + { + $relacion = JugadorEquipo::where('id_jugador', $idJugador) + ->where('id_equipo', $idEquipo) + ->firstOrFail(); + $relacion->delete(); + return response()->json(null, 204); + } + + public function porJugador($idJugador) + { + $relaciones = JugadorEquipo::where('id_jugador', $idJugador) + ->with('equipo') + ->get(); + return response()->json($relaciones); + } + + public function porEquipo($idEquipo) + { + $relaciones = JugadorEquipo::where('id_equipo', $idEquipo) + ->with('jugador') + ->get(); + return response()->json($relaciones); + } +} diff --git a/app/Http/Controllers/NoticiaController.php b/app/Http/Controllers/NoticiaController.php new file mode 100644 index 0000000..d2e017c --- /dev/null +++ b/app/Http/Controllers/NoticiaController.php @@ -0,0 +1,61 @@ +get(); + + if ($request->expectsJson()) { + return response()->json($noticias); + } + + return view('noticias.index', compact('noticias')); + } + + public function show($id) + { + $noticia = Noticia::findOrFail($id); + + if (request()->expectsJson()) { + return response()->json($noticia); + } + + return view('noticias.show', compact('noticia')); + } + + public function store(Request $request) + { + $data = $request->validate([ + 'titulo' => 'required|string|max:200', + 'contenido' => 'required', + 'imagen' => 'nullable|string|max:200', + ]); + $noticia = Noticia::create($data); + return response()->json($noticia, 201); + } + + public function update(Request $request, $id) + { + $noticia = Noticia::findOrFail($id); + $data = $request->validate([ + 'titulo' => 'sometimes|string|max:200', + 'contenido' => 'sometimes', + 'imagen' => 'nullable|string|max:200', + ]); + $noticia->update($data); + return response()->json($noticia); + } + + public function destroy($id) + { + $noticia = Noticia::findOrFail($id); + $noticia->delete(); + return response()->json(null, 204); + } +} diff --git a/app/Http/Controllers/NotificacionController.php b/app/Http/Controllers/NotificacionController.php new file mode 100644 index 0000000..eae56a9 --- /dev/null +++ b/app/Http/Controllers/NotificacionController.php @@ -0,0 +1,143 @@ +service = $service; + } + + // ── Helper: usuario logueado ── + private function getUserSession(): ?array + { + if (!session()->has('user_logged_in')) return null; + return [ + 'tipo' => session('user_tipo'), + 'id' => session('user_id'), + ]; + } + + /** + * GET /notificaciones — Listado completo paginado + */ + public function index() + { + $u = $this->getUserSession(); + if (!$u) return redirect('/')->with('panel_error', 'Debés iniciar sesión.'); + + $notificaciones = $this->service->obtenerTodas($u['tipo'], $u['id']); + $totalNoLeidas = $this->service->contarNoLeidas($u['tipo'], $u['id']); + + return view('notificaciones.index', compact('notificaciones', 'totalNoLeidas')); + } + + /** + * POST /notificaciones/{id}/leer — Marcar una como leída + */ + public function marcarLeida(int $id) + { + $u = $this->getUserSession(); + if (!$u) return response()->json(['ok' => false], 401); + + $this->service->marcarLeida($id, $u['tipo'], $u['id']); + return response()->json(['ok' => true]); + } + + /** + * POST /notificaciones/leer-todas — Marcar todas como leídas + */ + public function marcarTodasLeidas() + { + $u = $this->getUserSession(); + if (!$u) return response()->json(['ok' => false], 401); + + $count = $this->service->marcarTodasLeidas($u['tipo'], $u['id']); + return response()->json(['ok' => true, 'marcadas' => $count]); + } + + /** + * GET /notificaciones/count — Badge AJAX (devuelve JSON {count: N}) + */ + public function count() + { + $u = $this->getUserSession(); + if (!$u) return response()->json(['count' => 0]); + + return response()->json(['count' => $this->service->contarNoLeidas($u['tipo'], $u['id'])]); + } + + /** + * GET /notificaciones/latest — ID de la última no leída + */ + public function latest() + { + $u = $this->getUserSession(); + if (!$u) return response()->json(['id' => 0]); + + $notif = $this->service->obtenerNoLeidas($u['tipo'], $u['id'])->first(); + return response()->json([ + 'id' => $notif ? $notif->id : 0, + 'titulo' => $notif ? $notif->titulo : '', + 'mensaje' => $notif ? $notif->mensaje : '' + ]); + } + + /** + * DELETE /notificaciones/{id} — Eliminar una notificación + */ + public function eliminar(int $id) + { + $u = $this->getUserSession(); + if (!$u) return response()->json(['ok' => false], 401); + + $this->service->eliminar($id, $u['tipo'], $u['id']); + return response()->json(['ok' => true]); + } + + /** + * DELETE /notificaciones — Eliminar todas + */ + public function eliminarTodas() + { + $u = $this->getUserSession(); + if (!$u) return response()->json(['ok' => false], 401); + + $this->service->eliminarTodas($u['tipo'], $u['id']); + return response()->json(['ok' => true]); + } + /** + * POST /notificaciones/subscribe — Guardar suscripción para Web Push + */ + public function subscribe(Request $request) + { + $u = $this->getUserSession(); + if (!$u) return response()->json(['ok' => false, 'error' => 'No session'], 401); + + $request->validate([ + 'endpoint' => 'required', + 'keys.p256dh' => 'required', + 'keys.auth' => 'required', + ]); + + \App\Models\PushSubscription::updateOrCreate( + [ + 'id_usuario' => (string) $u['id'], + 'tipo_usuario' => $u['tipo'], + 'endpoint' => $request->endpoint, + ], + [ + 'p256dh' => $request->keys['p256dh'], + 'auth' => $request->keys['auth'], + ] + ); + + return response()->json(['ok' => true]); + } +} diff --git a/app/Http/Controllers/PanelController.php b/app/Http/Controllers/PanelController.php new file mode 100644 index 0000000..c92dd3a --- /dev/null +++ b/app/Http/Controllers/PanelController.php @@ -0,0 +1,363 @@ +notifService = $notifService; + } + + // ── Helper: obtener usuario logueado ── + private function getUser() + { + if (!session()->has('user_logged_in')) { + return null; + } + + $tipo = session('user_tipo'); + $id = session('user_id'); + + if ($tipo === 'jugador') { + return ['user' => Jugador::find($id), 'tipo' => $tipo]; + } + return ['user' => Aficionado::find($id), 'tipo' => $tipo]; + } + + // ══════════════════════════════════ + // PANEL PRINCIPAL + // ══════════════════════════════════ + public function index(Request $request) + { + $data = $this->getUser(); + if (!$data || !$data['user']) { + session()->flush(); + return redirect('/?logout_msg=' . urlencode('Tu usuario no fue encontrado. Iniciá sesión nuevamente.')); + } + + $user = $data['user']; + $userTipo = $data['tipo']; + $userId = session('user_id'); + + // Cargar relaciones de club/equipos para jugadores + if ($userTipo === 'jugador') { + $user->load(['clubActual', 'equipos.club']); + } + + // Obtener QRs del usuario (eventos que NO estén borrados) + if ($userTipo === 'jugador') { + $qrCodes = QrCode::whereHas('evento') + ->where('id_jugador', $user->id_jugador) + ->orderBy('creado', 'desc') + ->get(); + } else { + $qrCodes = QrCode::whereHas('evento') + ->where('id_aficionado', $user->id_aficionado) + ->orderBy('creado', 'desc') + ->get(); + } + + // Obtener beneficios de promociones + $promoQrs = PromoQr::with('promocion') + ->where('id_usuario', $userId) + ->where('tipo_usuario', $userTipo) + ->orderBy('generado_en', 'desc') + ->get(); + + return view('panel.index', compact('user', 'userTipo', 'qrCodes', 'promoQrs')); + } + + // ══════════════════════════════════ + // ACTUALIZAR DATOS + // ══════════════════════════════════ + public function actualizarDatos(Request $request) + { + $data = $this->getUser(); + if (!$data) return redirect('/'); + + $request->validate([ + 'email' => 'required|email', + 'telefono' => 'required|string', + 'localidad' => 'nullable|string', + ]); + + $user = $data['user']; + + if ($data['tipo'] === 'jugador') { + $user->update([ + 'email' => $request->input('email'), + 'telefono' => $request->input('telefono'), + ]); + } else { + $user->update([ + 'email' => $request->input('email'), + 'telefono' => $request->input('telefono'), + 'localidad' => $request->input('localidad'), + ]); + } + + return back()->with('panel_msg', 'Datos actualizados correctamente.'); + } + + // ══════════════════════════════════ + // CAMBIAR CONTRASEÑA + // ══════════════════════════════════ + public function cambiarPassword(Request $request) + { + $data = $this->getUser(); + if (!$data) return redirect('/'); + + $request->validate([ + 'password_actual' => 'required|string', + 'password_nueva' => 'required|confirmed|min:6', + ]); + + $user = $data['user']; + + if ($user->password) { + if (!Hash::check($request->input('password_actual'), $user->password)) { + return back()->with('panel_error', 'La contraseña actual es incorrecta.'); + } + } + + $user->update([ + 'password' => bcrypt($request->input('password_nueva')), + ]); + + return back()->with('panel_msg', 'Contraseña cambiada correctamente.'); + } + + // ══════════════════════════════════ + // SOLICITAR QR PARA EVENTO + // ══════════════════════════════════ + public function solicitarQr(Request $request) + { + $data = $this->getUser(); + if (!$data || !$data['user']) return redirect('/'); + + $user = $data['user']; + $userTipo = $data['tipo']; + + $id_evento = $request->input('id_evento'); + $evento = Evento::with(['equipoLocal', 'equipoVisitante'])->find($id_evento); + + if (!$evento) { + return back()->with('panel_error', 'Evento no encontrado.'); + } + + $qrs_a_generar = 0; + + if ($userTipo === 'jugador') { + // Verificar si ya generó QRs para este evento + $yaGenero = QrCode::where('id_evento', $id_evento) + ->where('id_jugador', $user->id_jugador) + ->count(); + + if ($yaGenero > 0) { + return back()->with('panel_error', 'Ya solicitaste QRs para este evento.'); + } + + // Verificar si el jugador está activo + if (!$user->activo) { + return back()->with('panel_error', 'Tu registro no está activo. Completá tu registro primero.'); + } + + // Verificar si pertenece a alguno de los equipos del evento + $pertenece = DB::table('jugador_equipo') + ->where('id_jugador', $user->id_jugador) + ->whereIn('id_equipo', array_filter([$evento->id_equipo_local, $evento->id_equipo_visitante])) + ->exists(); + + if ($pertenece) { + $qrs_a_generar = $evento->limite_qr_jugador ?? 3; // Límite configurable + $tipoQr = 'invitado'; + } else { + // Check if category is "libre" + $edadCategoria = date('Y') - \Carbon\Carbon::parse($user->fecha_nacimiento)->format('Y'); + $categoriaDB = \App\Models\Categoria::where('edad_min', '<=', $edadCategoria) + ->where('edad_max', '>=', $edadCategoria) + ->first(); + + if ($categoriaDB && $categoriaDB->es_libre) { + $qrs_a_generar = 1; + $tipoQr = 'libre_50'; // Identificador para 50% de descuento + } else { + return back()->with('panel_error', 'No podés generar QR para este partido ya que no pertenecés a los equipos ni sos categoría Libre. Deberás abonar la totalidad de la entrada en puerta.'); + } + } + + if ($qrs_a_generar === 0) { + return back()->with('panel_error', 'No se permiten QRs para este evento.'); + } + + // Generar QRs + for ($i = 0; $i < $qrs_a_generar; $i++) { + QrCode::create([ + 'id_qr' => uniqid('qr_'), + 'id_evento' => $id_evento, + 'id_jugador' => $user->id_jugador, + 'tipo_qr' => $tipoQr ?? 'invitado', + 'escaneos_restantes' => 1, + 'creado' => now(), + ]); + } + + } else { + // Aficionado: no puede solicitar QRs para eventos + return back()->with('panel_error', 'Los aficionados no pueden solicitar QRs para eventos. Adquirí tu entrada directamente en el lugar.'); + } + + // Enviar mail con QRs + try { + Mail::to($user->email)->send(new QrCodeMail($user, $evento, $qrs_a_generar)); + } catch (\Exception $e) { + Log::error("Error enviando mail de QRs: " . $e->getMessage()); + } + + // Notificación interna + $this->notifService->enviar( + $userTipo, + $userTipo === 'jugador' ? $user->id_jugador : $user->id_aficionado, + 'sistema', + '🎫 QRs Generados', + "Tus {$qrs_a_generar} QR(s) para el evento " . ($evento->nombre_evento ?? 'seleccionado') . " ya están disponibles.", + route('panel.mis.qrs', ['evento' => $id_evento]) + ); + + return redirect()->route('panel.mis.qrs', ['evento' => $id_evento]) + ->with('panel_msg', "¡QR(s) generados correctamente! ({$qrs_a_generar})"); + } + + // ══════════════════════════════════ + // MIS QRS (per evento) + // ══════════════════════════════════ + public function misQrs(Request $request) + { + $data = $this->getUser(); + if (!$data || !$data['user']) return redirect('/'); + + $user = $data['user']; + $userTipo = $data['tipo']; + $id_evento = $request->query('evento'); + + // Traer QRs del usuario (solo de eventos activos) + $query = QrCode::whereHas('evento') + ->with(['evento.equipoLocal.club', 'evento.equipoVisitante.club', 'evento.torneo']); + + if ($userTipo === 'jugador') { + $query->where('id_jugador', $user->id_jugador); + $user->load('clubActual'); + } else { + $query->where('id_aficionado', $user->id_aficionado); + } + + if ($id_evento) { + $query->where('id_evento', $id_evento); + $evento = Evento::find($id_evento); + } else { + $evento = null; + } + + $qrs = $query->orderBy('creado', 'desc')->get(); + + // Adjuntar información de grupo si hay torneo + foreach ($qrs as $qr) { + if ($qr->evento && $qr->evento->id_torneo && $qr->evento->id_equipo_local) { + $rel = DB::table('torneo_equipo') + ->where('id_torneo', $qr->evento->id_torneo) + ->where('id_equipo', $qr->evento->id_equipo_local) + ->first(); + if ($rel) { + $qr->evento->grupo_nombre = $rel->grupo ?? 'General'; + } + } + } + + return view('panel.mis_qrs', compact('user', 'userTipo', 'qrs', 'evento')); + } + + // ══════════════════════════════════ + // GENERAR QR PARA PROMOCIÓN + // ══════════════════════════════════ + public function generarPromoQr(Request $request) + { + $data = $this->getUser(); + if (!$data || !$data['user']) return redirect('/'); + + $user = $data['user']; + $userTipo = $data['tipo']; + $userId = session('user_id'); + + $id_promo = $request->input('id_promo'); + $promo = Promocion::find($id_promo); + + if (!$promo) { + return back()->with('panel_error', 'Promoción no encontrada.'); + } + + // Verificar si ya generó QR para esta promo + $yaGenero = PromoQr::where('id_promo', $id_promo) + ->where('id_usuario', $userId) + ->where('tipo_usuario', $userTipo) + ->count(); + + if ($yaGenero > 0) { + return back()->with('panel_error', 'Ya generaste un QR para esta promoción.'); + } + + $id_qr = bin2hex(random_bytes(8)); + + PromoQr::create([ + 'id_qr' => $id_qr, + 'id_promo' => $id_promo, + 'id_usuario' => $userId, + 'tipo_usuario' => $userTipo, + 'generado_en' => now(), + 'usado' => false, + ]); + + return redirect()->route('panel.promo.qr.ver', ['id' => $id_qr]) + ->with('panel_msg', '¡QR de beneficio generado correctamente!'); + } + + // ══════════════════════════════════ + // VER QR DE PROMOCIÓN + // ══════════════════════════════════ + public function verPromoQr($id) + { + $data = $this->getUser(); + if (!$data || !$data['user']) return redirect('/'); + + $user = $data['user']; + $userTipo = $data['tipo']; + + if ($userTipo === 'jugador') { + $user->load('clubActual'); + } + + $promoQr = PromoQr::with('promocion') + ->where('id_qr', $id) + ->where('id_usuario', session('user_id')) + ->where('tipo_usuario', $userTipo) + ->firstOrFail(); + + return view('panel.promo_qr', compact('promoQr', 'user', 'userTipo')); + } +} diff --git a/app/Http/Controllers/PromoQrController.php b/app/Http/Controllers/PromoQrController.php new file mode 100644 index 0000000..4f424c7 --- /dev/null +++ b/app/Http/Controllers/PromoQrController.php @@ -0,0 +1,52 @@ +get(); + return response()->json($promoQrs); + } + + public function show($id) + { + $promoQr = PromoQr::with('promocion', 'usuario')->findOrFail($id); + return response()->json($promoQr); + } + + public function store(Request $request) + { + $data = $request->validate([ + 'id_promo' => 'required|integer|exists:promociones,id', + 'id_usuario' => 'required|integer', + 'tipo_usuario' => 'required|in:jugador,aficionado', + ]); + $data['id_qr'] = Str::uuid()->toString(); + $promoQr = PromoQr::create($data); + return response()->json($promoQr, 201); + } + + public function update(Request $request, $id) + { + $promoQr = PromoQr::findOrFail($id); + $data = $request->validate([ + 'usado' => 'nullable|boolean', + 'usado_en' => 'nullable', + ]); + $promoQr->update($data); + return response()->json($promoQr); + } + + public function destroy($id) + { + $promoQr = PromoQr::findOrFail($id); + $promoQr->delete(); + return response()->json(null, 204); + } +} diff --git a/app/Http/Controllers/PromocionController.php b/app/Http/Controllers/PromocionController.php new file mode 100644 index 0000000..9d97dfe --- /dev/null +++ b/app/Http/Controllers/PromocionController.php @@ -0,0 +1,70 @@ +expectsJson()) { + return response()->json($promociones); + } + + $categorias = $promociones->pluck('categoria')->filter()->unique()->values(); + + return view('promos.index', compact('promociones', 'categorias')); + } + + public function show($id) + { + $promocion = Promocion::with('promoQrs')->findOrFail($id); + return response()->json($promocion); + } + + public function store(Request $request) + { + $data = $request->validate([ + 'nombre' => 'required|string|max:100', + 'direccion' => 'required|string|max:150', + 'lat' => 'nullable|numeric', + 'lng' => 'nullable|numeric', + 'contacto' => 'nullable|string|max:100', + 'descripcion' => 'nullable', + 'descripcion_lugar' => 'nullable', + 'categoria' => 'nullable|string|max:50', + 'imagen' => 'nullable|string|max:200', + ]); + $promocion = Promocion::create($data); + return response()->json($promocion, 201); + } + + public function update(Request $request, $id) + { + $promocion = Promocion::findOrFail($id); + $data = $request->validate([ + 'nombre' => 'sometimes|string|max:100', + 'direccion' => 'sometimes|string|max:150', + 'lat' => 'nullable|numeric', + 'lng' => 'nullable|numeric', + 'contacto' => 'nullable|string|max:100', + 'descripcion' => 'nullable', + 'descripcion_lugar' => 'nullable', + 'categoria' => 'nullable|string|max:50', + 'imagen' => 'nullable|string|max:200', + ]); + $promocion->update($data); + return response()->json($promocion); + } + + public function destroy($id) + { + $promocion = Promocion::findOrFail($id); + $promocion->delete(); + return response()->json(null, 204); + } +} diff --git a/app/Http/Controllers/QrCodeController.php b/app/Http/Controllers/QrCodeController.php new file mode 100644 index 0000000..559b56b --- /dev/null +++ b/app/Http/Controllers/QrCodeController.php @@ -0,0 +1,57 @@ +get(); + return response()->json($qrCodes); + } + + public function show($id) + { + $qrCode = QrCode::with('evento', 'jugador', 'aficionado')->findOrFail($id); + return response()->json($qrCode); + } + + public function store(Request $request) + { + $data = $request->validate([ + 'id_evento' => 'required|string|max:36', + 'id_jugador' => 'nullable|string|max:6', + 'tipo_qr' => 'required|in:invitado,publico', + 'escaneos_restantes' => 'nullable|integer|min:1', + 'id_aficionado' => 'nullable|integer', + ]); + $data['id_qr'] = Str::uuid()->toString(); + $qrCode = QrCode::create($data); + return response()->json($qrCode, 201); + } + + public function update(Request $request, $id) + { + $qrCode = QrCode::findOrFail($id); + $data = $request->validate([ + 'id_evento' => 'sometimes|string|max:36', + 'id_jugador' => 'nullable|string|max:6', + 'tipo_qr' => 'sometimes|in:invitado,publico', + 'escaneos_restantes' => 'nullable|integer|min:1', + 'id_aficionado' => 'nullable|integer', + ]); + $qrCode->update($data); + return response()->json($qrCode); + } + + public function destroy($id) + { + $qrCode = QrCode::findOrFail($id); + $qrCode->delete(); + return response()->json(null, 204); + } +} diff --git a/app/Http/Controllers/QrDownloadController.php b/app/Http/Controllers/QrDownloadController.php new file mode 100644 index 0000000..c606bd7 --- /dev/null +++ b/app/Http/Controllers/QrDownloadController.php @@ -0,0 +1,129 @@ +where('id_qr', $id) + ->firstOrFail(); + + // Verificar que el usuario tenga permiso para descargar este QR + $isUser = session()->has('user_logged_in') && session('user_logged_in'); + $isAdmin = session()->has('admin_logged_in') && session('admin_logged_in'); + + if ($isUser) { + $userId = session('user_id'); + $userTipo = session('user_tipo'); + if ($userTipo === 'jugador' && $qr->id_jugador != $userId) abort(403); + if ($userTipo === 'aficionado' && $qr->id_aficionado != $userId) abort(403); + } elseif (!$isAdmin) { + abort(403); + } + + $user = $qr->jugador ?: $qr->aficionado; + $userTipo = $qr->id_jugador ? 'jugador' : 'aficionado'; + + // Convertir imágenes a Base64 para DomPDF (más fiable que URLs remotas/locales) + $qrImageBase64 = ''; + try { + $qrUrl = "https://api.qrserver.com/v1/create-qr-code/?size=300x300&data=" . urlencode($qr->id_qr); + $qrContent = @file_get_contents($qrUrl); + if ($qrContent) { + $qrImageBase64 = 'data:image/png;base64,' . base64_encode($qrContent); + } + } catch (\Exception $e) { } + + $club = ($qr->evento && $qr->evento->equipoLocal && $qr->evento->equipoLocal->club) ? $qr->evento->equipoLocal->club : null; + + $backgroundBase64 = null; + if ($club && $club->qr_background) { + // 1. Intentar en storage/app/public (ruta interna Laravel) + $pathInStorage = str_replace(['storage/', 'storage\\'], '', $club->qr_background); + $bgPath = storage_path('app/public/' . $pathInStorage); + + // 2. Intentar en public_path (desarrollo local o symlink funcional) + if (!file_exists($bgPath)) { + $bgPath = public_path($club->qr_background); + } + + // 3. Intentar estructura Hostinger (public_html es hermano de la carpeta laravel) + if (!file_exists($bgPath)) { + $bgPath = base_path('../public_html/' . $club->qr_background); + } + + if (file_exists($bgPath)) { + $bgContent = @file_get_contents($bgPath); + if ($bgContent) { + $ext = pathinfo($bgPath, PATHINFO_EXTENSION); + $backgroundBase64 = 'data:image/' . $ext . ';base64,' . base64_encode($bgContent); + } + } + } + + $logoBase64 = null; + $logoPathFinal = null; + + // Logo del club del jugador (no del local) si corresponde: + // jugador → su club actual; aficionado/sin club → club local; fallback → logo OnAPB. + $logoClub = null; + if ($userTipo === 'jugador' && $qr->jugador && $qr->jugador->clubActual) { + $logoClub = $qr->jugador->clubActual; + } elseif ($club) { + $logoClub = $club; + } + + if ($logoClub && $logoClub->imagen) { + $logoInStorage = str_replace(['storage/', 'storage\\'], '', $logoClub->imagen); + $lPath = storage_path('app/public/' . $logoInStorage); + + if (!file_exists($lPath)) { + $lPath = public_path($logoClub->imagen); + } + + if (!file_exists($lPath)) { + $lPath = base_path('../public_html/' . $logoClub->imagen); + } + + if (file_exists($lPath)) { + $logoPathFinal = $lPath; + } + } + + // Si no hay logo, usar logo general + if (!$logoPathFinal) { + if (file_exists(public_path('logo.png'))) { + $logoPathFinal = public_path('logo.png'); + } + } + + if ($logoPathFinal) { + $logoContent = @file_get_contents($logoPathFinal); + if ($logoContent) { + $ext = pathinfo($logoPathFinal, PATHINFO_EXTENSION); + $logoBase64 = 'data:image/' . $ext . ';base64,' . base64_encode($logoContent); + } + } + + $data = [ + 'qr' => $qr, + 'user' => $user, + 'userTipo' => $userTipo, + 'club' => $club, + 'qrImageBase64' => $qrImageBase64, + 'backgroundBase64' => $backgroundBase64, + 'logoBase64' => $logoBase64, + ]; + + $pdf = Pdf::loadView('pdf.qr_ticket', $data) + ->setPaper([0, 0, 320, 500], 'portrait'); + + return $pdf->download("onapb_qr_{$qr->id_qr}.pdf"); + } +} diff --git a/app/Http/Controllers/SeguimientoController.php b/app/Http/Controllers/SeguimientoController.php new file mode 100644 index 0000000..7d7fb98 --- /dev/null +++ b/app/Http/Controllers/SeguimientoController.php @@ -0,0 +1,122 @@ +notifService = $notifService; + } + + private function getUserSession(): ?array + { + if (!session()->has('user_logged_in')) return null; + return ['tipo' => session('user_tipo'), 'id' => session('user_id')]; + } + + /** + * POST /seguimiento/equipo/{id} — Toggle: seguir o dejar de seguir un equipo + */ + public function toggle(int $id) + { + $u = $this->getUserSession(); + if (!$u) { + return response()->json(['ok' => false, 'msg' => 'Debés iniciar sesión para seguir equipos.'], 401); + } + + $equipo = Equipo::find($id); + if (!$equipo) { + return response()->json(['ok' => false, 'msg' => 'Equipo no encontrado.'], 404); + } + + $existing = EquipoSeguimiento::where('id_equipo', $id) + ->where('tipo_usuario', $u['tipo']) + ->where('id_usuario', (string)$u['id']) + ->first(); + + if ($existing) { + $existing->delete(); + return response()->json(['ok' => true, 'siguiendo' => false, 'msg' => 'Dejaste de seguir este equipo.']); + } + + EquipoSeguimiento::create([ + 'id_equipo' => $id, + 'tipo_usuario'=> $u['tipo'], + 'id_usuario' => (string)$u['id'], + 'created_at' => now(), + ]); + + return response()->json(['ok' => true, 'siguiendo' => true, 'msg' => '¡Ahora seguís este equipo!']); + } + + /** + * GET /seguimiento/mis-equipos — Devuelve equipos seguidos y próximos partidos + */ + public function misEquipos() + { + $u = $this->getUserSession(); + if (!$u) return response()->json(['equipos' => []]); + + $seguimientos = EquipoSeguimiento::with(['equipo.club']) + ->where('tipo_usuario', $u['tipo']) + ->where('id_usuario', (string)$u['id']) + ->get(); + + $equiposIds = $seguimientos->pluck('id_equipo'); + + // Próximos partidos de los equipos seguidos + $proximosPartidos = Evento::with(['equipoLocal.club', 'equipoVisitante.club']) + ->where('fecha_evento', '>=', now()->toDateString()) + ->where(function ($q) use ($equiposIds) { + $q->whereIn('id_equipo_local', $equiposIds) + ->orWhereIn('id_equipo_visitante', $equiposIds); + }) + ->orderBy('fecha_evento') + ->orderBy('hora_inicio') + ->limit(10) + ->get(); + + return response()->json([ + 'equipos' => $seguimientos->map(fn($s) => [ + 'id' => $s->id_equipo, + 'nombre' => ($s->equipo->club->nombre ?? 'Equipo') . ' ' . $s->equipo->categoria . ($s->equipo->division ? ' ' . $s->equipo->division : ''), + 'club' => $s->equipo->club->nombre ?? '—', + 'categoria'=> $s->equipo->categoria, + 'division' => $s->equipo->division, + ]), + 'proximos_partidos' => $proximosPartidos->map(fn($e) => [ + 'id' => $e->id_evento, + 'fecha' => $e->fecha_evento->format('d/m/Y'), + 'hora' => $e->hora_inicio ? $e->hora_inicio->format('H:i') : '', + 'local' => $e->equipoLocal->club->nombre ?? '?', + 'visitante'=> $e->equipoVisitante->club->nombre ?? '?', + 'sede' => $e->sede, + ]), + ]); + } + + /** + * GET /seguimiento/estado/{id_equipo} — ¿El usuario sigue este equipo? + */ + public function estado(int $id) + { + $u = $this->getUserSession(); + if (!$u) return response()->json(['siguiendo' => false]); + + $siguiendo = EquipoSeguimiento::where('id_equipo', $id) + ->where('tipo_usuario', $u['tipo']) + ->where('id_usuario', (string)$u['id']) + ->exists(); + + return response()->json(['siguiendo' => $siguiendo]); + } +} diff --git a/app/Http/Controllers/TorneoController.php b/app/Http/Controllers/TorneoController.php new file mode 100644 index 0000000..d96bd0d --- /dev/null +++ b/app/Http/Controllers/TorneoController.php @@ -0,0 +1,85 @@ +query('grupo'); + $torneo = Torneo::with('equipos.club')->findOrFail($id); + + $tournamentService = new \App\Services\TournamentService(); + $stats = $tournamentService->getStandings($id, true); + + $grupos = array_keys($stats); + + if ($selectedGroup && isset($stats[$selectedGroup])) { + $stats = [$selectedGroup => $stats[$selectedGroup]]; + } + + $followedTeamIds = []; + if (session('user_logged_in') && session('user_id')) { + $followedTeamIds = \App\Models\EquipoSeguimiento::where('id_usuario', session('user_id')) + ->where('tipo_usuario', session('user_tipo')) + ->pluck('id_equipo') + ->toArray(); + } + + return view('torneos.standings', compact('torneo', 'stats', 'grupos', 'selectedGroup', 'followedTeamIds')); + } + + public function topScorers(Request $request, $id) + { + $torneo = Torneo::findOrFail($id); + $selectedGroup = $request->query('grupo'); + + $query = \App\Models\EventoJugador::with(['jugador.clubActual']) + ->whereHas('evento', function($q) use ($id) { + $q->where('id_torneo', $id)->whereNotNull('marcador_local'); + }); + + if ($selectedGroup) { + $query->whereHas('evento.equipoLocal.torneos', function($q) use ($id, $selectedGroup) { + $q->where('torneos.id', $id)->where('torneo_equipo.grupo', $selectedGroup); + }); + } + + $scorers = $query->select('id_jugador', DB::raw('SUM(puntos) as total_puntos'), DB::raw('COUNT(id_evento) as partidos_jugados')) + ->groupBy('id_jugador') + ->orderByDesc('total_puntos') + ->take(20)->get(); + + $grupos = DB::table('torneo_equipo')->where('id_torneo', $id)->distinct()->pluck('grupo')->filter(); + + return view('torneos.scorers', compact('torneo', 'scorers', 'grupos', 'selectedGroup')); + } + + public function playoffs(Request $request, $id) + { + $selectedGroup = $request->query('grupo'); + $torneo = Torneo::with('equipos.club')->findOrFail($id); + + // Get groups from pivot + $grupos = DB::table('torneo_equipo')->where('id_torneo', $id)->distinct()->pluck('grupo')->filter(); + + $ts = new \App\Services\TournamentService(); + $bracket = $ts->getPlayoffBrackets($id); + + // Map to simpler keys if needed by view + $bracket = [ + 'cuartos' => collect($bracket[\App\Models\Evento::FASE_CUARTOS] ?? []), + 'semis' => collect($bracket[\App\Models\Evento::FASE_SEMIS] ?? []), + 'final' => collect($bracket[\App\Models\Evento::FASE_FINAL] ?? []), + ]; + + return view('torneos.playoffs', compact('torneo', 'bracket', 'grupos', 'selectedGroup')); + } +} diff --git a/app/Http/Middleware/SecurityHeaders.php b/app/Http/Middleware/SecurityHeaders.php new file mode 100644 index 0000000..440135b --- /dev/null +++ b/app/Http/Middleware/SecurityHeaders.php @@ -0,0 +1,40 @@ +headers->set('X-Frame-Options', 'SAMEORIGIN'); + $response->headers->set('X-Content-Type-Options', 'nosniff'); + $response->headers->set('X-XSS-Protection', '1; mode=block'); + $response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin'); + $response->headers->set( + 'Permissions-Policy', + 'camera=(), microphone=(), geolocation=()' + ); + $response->headers->set( + 'Strict-Transport-Security', + 'max-age=31536000; includeSubDomains; preload' + ); + $response->headers->set( + 'Content-Security-Policy', + "default-src 'self'; " . + "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://challenges.cloudflare.com; " . + "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " . + "font-src 'self' data: https://fonts.gstatic.com; " . + "img-src 'self' data: https:; " . + "connect-src 'self' https://challenges.cloudflare.com; " . + "frame-src 'self' https://challenges.cloudflare.com; " . + "frame-ancestors 'self';" + ); + + return $response; + } +} diff --git a/app/Mail/QrCodeMail.php b/app/Mail/QrCodeMail.php new file mode 100644 index 0000000..d10222a --- /dev/null +++ b/app/Mail/QrCodeMail.php @@ -0,0 +1,29 @@ +user = $user; + $this->evento = $evento; + $this->cantidad = $cantidad; + } + + public function build() + { + return $this->subject('Tus QRs para el evento: ' . $this->evento->nombre_evento) + ->view('emails.qrcodes'); + } +} diff --git a/app/Mail/ResetPasswordMail.php b/app/Mail/ResetPasswordMail.php new file mode 100644 index 0000000..4ca8203 --- /dev/null +++ b/app/Mail/ResetPasswordMail.php @@ -0,0 +1,27 @@ +user = $user; + $this->token = $token; + } + + public function build() + { + return $this->subject('Recuperar contraseña - OnAPB') + ->view('emails.reset_password'); + } +} diff --git a/app/Mail/WelcomeMail.php b/app/Mail/WelcomeMail.php new file mode 100644 index 0000000..f328ca4 --- /dev/null +++ b/app/Mail/WelcomeMail.php @@ -0,0 +1,27 @@ +user = $user; + $this->tipo = $tipo; + } + + public function build() + { + return $this->subject('¡Bienvenido a OnAPB!') + ->view('emails.welcome'); + } +} diff --git a/app/Models/AdminUser.php b/app/Models/AdminUser.php new file mode 100644 index 0000000..0113782 --- /dev/null +++ b/app/Models/AdminUser.php @@ -0,0 +1,36 @@ +belongsTo(Club::class, 'id_club', 'id_club'); + } + + protected $hidden = [ + 'password', + 'reset_token', + ]; + + protected $casts = [ + 'role' => 'integer', + 'reset_expira' => 'datetime', + ]; +} diff --git a/app/Models/Aficionado.php b/app/Models/Aficionado.php new file mode 100644 index 0000000..003a298 --- /dev/null +++ b/app/Models/Aficionado.php @@ -0,0 +1,38 @@ + 'integer', + 'fecha_nacimiento' => 'date', + 'fecha_registro' => 'datetime', + 'reset_expira' => 'datetime', + ]; +} diff --git a/app/Models/AgentThread.php b/app/Models/AgentThread.php new file mode 100644 index 0000000..16b55cc --- /dev/null +++ b/app/Models/AgentThread.php @@ -0,0 +1,42 @@ + 'array', + 'expires_at' => 'datetime', + ]; + + public static function findOrCreateForAdmin(?string $threadId, int $adminId): static + { + if ($threadId) { + $thread = static::where('thread_id', $threadId) + ->where('admin_id', $adminId) + ->first(); + if ($thread) { + return $thread; + } + } + + return static::create([ + 'thread_id' => (string) Str::uuid(), + 'admin_id' => $adminId, + 'messages' => [], + 'expires_at' => now()->addDays(30), + ]); + } +} diff --git a/app/Models/CarouselItem.php b/app/Models/CarouselItem.php new file mode 100644 index 0000000..b2a2f7e --- /dev/null +++ b/app/Models/CarouselItem.php @@ -0,0 +1,25 @@ + 'boolean', + 'orden' => 'integer', + ]; +} diff --git a/app/Models/Categoria.php b/app/Models/Categoria.php new file mode 100644 index 0000000..9d9a672 --- /dev/null +++ b/app/Models/Categoria.php @@ -0,0 +1,19 @@ +id_club)) { + $model->id_club = (int) self::withTrashed()->max('id_club') + 1; + } + }); + } + + protected $casts = [ + 'id_club' => 'integer', + 'es_seleccion' => 'boolean', + ]; + + public function equipos() + { + return $this->hasMany(Equipo::class, 'id_club', 'id_club'); + } + + public function jugadores() + { + return $this->hasMany(Jugador::class, 'id_club_actual', 'id_club'); + } +} diff --git a/app/Models/Configuracion.php b/app/Models/Configuracion.php new file mode 100644 index 0000000..5c77e24 --- /dev/null +++ b/app/Models/Configuracion.php @@ -0,0 +1,45 @@ +first(); + return $config ? $config->valor : $default; + } + + /** + * Establecer o actualizar un valor de configuración. + * + * @param string $clave + * @param mixed $valor + * @param string|null $descripcion + * @return self + */ + public static function set($clave, $valor, $descripcion = null) + { + return self::updateOrCreate( + ['clave' => $clave], + ['valor' => $valor, 'descripcion' => $descripcion] + ); + } +} diff --git a/app/Models/Equipo.php b/app/Models/Equipo.php new file mode 100644 index 0000000..bb0d9c2 --- /dev/null +++ b/app/Models/Equipo.php @@ -0,0 +1,41 @@ + 'integer', + 'id_club' => 'integer', + ]; + + public function club() + { + return $this->belongsTo(Club::class, 'id_club', 'id_club'); + } + + public function jugadores() + { + return $this->belongsToMany(Jugador::class, 'jugador_equipo', 'id_equipo', 'id_jugador') + ->withPivot('fecha_alta'); + } + + public function torneos() + { + return $this->belongsToMany(Torneo::class, 'torneo_equipo', 'id_equipo', 'id_torneo'); + } +} diff --git a/app/Models/EquipoSeguimiento.php b/app/Models/EquipoSeguimiento.php new file mode 100644 index 0000000..79e1dc4 --- /dev/null +++ b/app/Models/EquipoSeguimiento.php @@ -0,0 +1,28 @@ + 'integer', + 'created_at' => 'datetime', + ]; + + public function equipo() + { + return $this->belongsTo(Equipo::class, 'id_equipo', 'id_equipo'); + } +} diff --git a/app/Models/Evento.php b/app/Models/Evento.php new file mode 100644 index 0000000..85c24b0 --- /dev/null +++ b/app/Models/Evento.php @@ -0,0 +1,96 @@ + 'string', + 'id_torneo' => 'integer', + 'id_equipo_local' => 'integer', + 'id_equipo_visitante' => 'integer', + 'marcador_local' => 'integer', + 'marcador_visitante' => 'integer', + 'precio' => 'decimal:2', + 'fase' => 'integer', + 'numero_partido_bracket' => 'integer', + ]; + + public function getFechaEventoAttribute($value) + { + return $value ? Carbon::parse($value) : null; + } + + public function getHoraInicioAttribute($value) + { + return $value ? Carbon::parse($value) : null; + } + + public function getHoraFinAttribute($value) + { + return $value ? Carbon::parse($value) : null; + } + + public function torneo() + { + return $this->belongsTo(Torneo::class, 'id_torneo'); + } + + public function equipoLocal() + { + return $this->belongsTo(Equipo::class, 'id_equipo_local', 'id_equipo'); + } + + public function equipoVisitante() + { + return $this->belongsTo(Equipo::class, 'id_equipo_visitante', 'id_equipo'); + } + + public function pagos() + { + return $this->hasMany(PagoMp::class, 'event_id', 'id_evento'); + } + + public function qrCodes() + { + return $this->hasMany(QrCode::class, 'id_evento', 'id_evento'); + } + + public function jugadoresPuntos() + { + return $this->hasMany(EventoJugador::class, 'id_evento', 'id_evento'); + } +} diff --git a/app/Models/EventoJugador.php b/app/Models/EventoJugador.php new file mode 100644 index 0000000..18dd668 --- /dev/null +++ b/app/Models/EventoJugador.php @@ -0,0 +1,27 @@ +belongsTo(Evento::class, 'id_evento', 'id_evento'); + } + + public function jugador() + { + return $this->belongsTo(Jugador::class, 'id_jugador', 'id_jugador'); + } +} diff --git a/app/Models/Jugador.php b/app/Models/Jugador.php new file mode 100644 index 0000000..8bd86a8 --- /dev/null +++ b/app/Models/Jugador.php @@ -0,0 +1,79 @@ + 'string', + 'fecha_nacimiento' => 'date', + 'edad' => 'integer', + 'id_club_actual' => 'integer', + 'id_club_origen' => 'integer', + 'activo' => 'boolean', + 'reset_expira' => 'datetime', + ]; + + public function getCategoriaCalculadaAttribute() + { + if (!$this->fecha_nacimiento) return 'Sin categoría'; + + // Calculate age for the current year. (Categoría U15 is for players turning 14 and 15 in the current year). + // That means current_year - birth_year + $edadCategoria = date('Y') - $this->fecha_nacimiento->format('Y'); + + $categoria = Categoria::where('edad_min', '<=', $edadCategoria) + ->where('edad_max', '>=', $edadCategoria) + ->first(); + + return $categoria ? $categoria->nombre : 'Sin categoría'; + } + + public function clubActual() + { + return $this->belongsTo(Club::class, 'id_club_actual', 'id_club'); + } + + public function clubOrigen() + { + return $this->belongsTo(Club::class, 'id_club_origen', 'id_club'); + } + + public function equipos() + { + return $this->belongsToMany(Equipo::class, 'jugador_equipo', 'id_jugador', 'id_equipo') + ->withPivot('fecha_alta'); + } +} diff --git a/app/Models/JugadorEquipo.php b/app/Models/JugadorEquipo.php new file mode 100644 index 0000000..2efefa6 --- /dev/null +++ b/app/Models/JugadorEquipo.php @@ -0,0 +1,34 @@ + 'string', + 'id_equipo' => 'integer', + 'fecha_alta' => 'date', + ]; + + public function jugador() + { + return $this->belongsTo(Jugador::class, 'id_jugador', 'id_jugador'); + } + + public function equipo() + { + return $this->belongsTo(Equipo::class, 'id_equipo', 'id_equipo'); + } +} diff --git a/app/Models/Noticia.php b/app/Models/Noticia.php new file mode 100644 index 0000000..f275db4 --- /dev/null +++ b/app/Models/Noticia.php @@ -0,0 +1,26 @@ + 'integer', + 'fecha' => 'datetime', + ]; +} diff --git a/app/Models/Notificacion.php b/app/Models/Notificacion.php new file mode 100644 index 0000000..1dcb753 --- /dev/null +++ b/app/Models/Notificacion.php @@ -0,0 +1,40 @@ + 'boolean', + 'enviada_email' => 'boolean', + 'creada_en' => 'datetime', + ]; + + // ── Scopes ── + public function scopeNoLeidas($query) + { + return $query->where('leida', false); + } + + public function scopeParaUsuario($query, string $tipo, $id) + { + return $query->where('tipo_destinatario', $tipo)->where('id_destinatario', (string)$id); + } +} diff --git a/app/Models/Pase.php b/app/Models/Pase.php new file mode 100644 index 0000000..0f0283e --- /dev/null +++ b/app/Models/Pase.php @@ -0,0 +1,33 @@ +belongsTo(Jugador::class, 'id_jugador', 'id_jugador'); + } + + public function clubOrigen() + { + return $this->belongsTo(Club::class, 'id_club_origen', 'id_club'); + } + + public function clubDestino() + { + return $this->belongsTo(Club::class, 'id_club_destino', 'id_club'); + } +} diff --git a/app/Models/PromoQr.php b/app/Models/PromoQr.php new file mode 100644 index 0000000..e3287a7 --- /dev/null +++ b/app/Models/PromoQr.php @@ -0,0 +1,46 @@ + 'string', + 'id_promo' => 'integer', + 'id_usuario' => 'integer', + 'tipo_usuario' => 'string', + 'generado_en' => 'datetime', + 'usado' => 'boolean', + 'usado_en' => 'datetime', + ]; + + public function promocion() + { + return $this->belongsTo(Promocion::class, 'id_promo', 'id'); + } + + public function usuario() + { + if ($this->tipo_usuario === 'jugador') { + return $this->belongsTo(Jugador::class, 'id_usuario', 'id_jugador'); + } + return $this->belongsTo(Aficionado::class, 'id_usuario', 'id_aficionado'); + } +} diff --git a/app/Models/Promocion.php b/app/Models/Promocion.php new file mode 100644 index 0000000..3896198 --- /dev/null +++ b/app/Models/Promocion.php @@ -0,0 +1,35 @@ + 'integer', + 'lat' => 'decimal:8', + 'lng' => 'decimal:8', + ]; + + public function promoQrs() + { + return $this->hasMany(PromoQr::class, 'id_promo', 'id'); + } +} diff --git a/app/Models/PushSubscription.php b/app/Models/PushSubscription.php new file mode 100644 index 0000000..c137170 --- /dev/null +++ b/app/Models/PushSubscription.php @@ -0,0 +1,24 @@ +where('tipo_usuario', $tipo)->where('id_usuario', $id); + } +} diff --git a/app/Models/QrCode.php b/app/Models/QrCode.php new file mode 100644 index 0000000..484396a --- /dev/null +++ b/app/Models/QrCode.php @@ -0,0 +1,48 @@ + 'string', + 'id_evento' => 'string', + 'id_jugador' => 'string', + 'tipo_qr' => 'string', + 'escaneos_restantes' => 'integer', + 'creado' => 'datetime', + 'id_aficionado' => 'integer', + ]; + + public function evento() + { + return $this->belongsTo(Evento::class, 'id_evento', 'id_evento'); + } + + public function jugador() + { + return $this->belongsTo(Jugador::class, 'id_jugador', 'id_jugador'); + } + + public function aficionado() + { + return $this->belongsTo(Aficionado::class, 'id_aficionado', 'id_aficionado'); + } +} diff --git a/app/Models/Sponsor.php b/app/Models/Sponsor.php new file mode 100644 index 0000000..2fb3743 --- /dev/null +++ b/app/Models/Sponsor.php @@ -0,0 +1,23 @@ + 'boolean', + ]; +} diff --git a/app/Models/Torneo.php b/app/Models/Torneo.php new file mode 100644 index 0000000..6447d9d --- /dev/null +++ b/app/Models/Torneo.php @@ -0,0 +1,33 @@ + 'datetime', + 'fecha_fin' => 'datetime', + ]; + + public function equipos() + { + return $this->belongsToMany(Equipo::class, 'torneo_equipo', 'id_torneo', 'id_equipo')->withPivot('grupo'); + } + + public function eventos() + { + return $this->hasMany(Evento::class, 'id_torneo'); + } +} diff --git a/app/Models/User.php b/app/Models/User.php new file mode 100644 index 0000000..68f3a66 --- /dev/null +++ b/app/Models/User.php @@ -0,0 +1,49 @@ + */ + use HasFactory, Notifiable; + + /** + * The attributes that are mass assignable. + * + * @var list + */ + protected $fillable = [ + 'name', + 'email', + 'password', + ]; + + /** + * The attributes that should be hidden for serialization. + * + * @var list + */ + protected $hidden = [ + 'password', + 'remember_token', + ]; + + /** + * Get the attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'email_verified_at' => 'datetime', + 'password' => 'hashed', + ]; + } +} diff --git a/app/Observers/EventoObserver.php b/app/Observers/EventoObserver.php new file mode 100644 index 0000000..e28c8d3 --- /dev/null +++ b/app/Observers/EventoObserver.php @@ -0,0 +1,142 @@ +notifService = $notifService; + } + + /** + * Al CREAR un partido: notificar a todos los seguidores de ambos equipos. + */ + public function created(Evento $evento): void + { + if (!$evento->id_equipo_local || !$evento->id_equipo_visitante) return; + + $evento->load(['equipoLocal.club', 'equipoVisitante.club']); + + $nombreLocal = $evento->equipoLocal->club->nombre ?? '?'; + $nombreVisitante= $evento->equipoVisitante->club->nombre ?? '?'; + $fechaStr = $evento->fecha_evento ? $evento->fecha_evento->format('d/m/Y') : '—'; + $horaStr = $evento->hora_inicio ? \Carbon\Carbon::parse($evento->hora_inicio)->format('H:i') : ''; + $sedeStr = $evento->sede ? " en {$evento->sede}" : ''; + + $titulo = "🏀 Nuevo Partido Programado"; + $mensaje = "{$nombreLocal} vs {$nombreVisitante} — {$fechaStr}" . ($horaStr ? " a las {$horaStr}" : '') . $sedeStr . '.'; + $url = '/eventos/' . $evento->id_evento; + + $this->notificarSeguidoresDeEquipos( + [$evento->id_equipo_local, $evento->id_equipo_visitante], + 'partido', + $titulo, + $mensaje, + $url + ); + } + + /** + * Al ACTUALIZAR un partido: si cambia el marcador, notificar resultado. + */ + public function updated(Evento $evento): void + { + $dirty = $evento->getDirty(); + + // Solo disparar si se actualizó el marcador + if (!array_key_exists('marcador_local', $dirty) && !array_key_exists('marcador_visitante', $dirty)) { + return; + } + + if ($evento->marcador_local === null || $evento->marcador_visitante === null) return; + + // Validación Horaria (Buenos Aires) + $tz = 'America/Argentina/Buenos_Aires'; + $ahora = \Carbon\Carbon::now($tz); + + // El usuario solicita que se emita cuando fin > real + $fecha = $evento->fecha_evento instanceof \Carbon\Carbon ? $evento->fecha_evento->format('Y-m-d') : substr($evento->fecha_evento, 0, 10); + $horaFin = $evento->hora_fin instanceof \Carbon\Carbon ? $evento->hora_fin->format('H:i:s') : $evento->hora_fin; + $fin = \Carbon\Carbon::createFromFormat('Y-m-d H:i:s', "{$fecha} {$horaFin}", $tz); + + if (!($fin->gt($ahora))) { + return; + } + + $evento->load(['equipoLocal.club', 'equipoVisitante.club']); + + $nombreLocal = $evento->equipoLocal->club->nombre ?? '?'; + $nombreVisitante = $evento->equipoVisitante->club->nombre ?? '?'; + $mloc = $evento->marcador_local; + $mvis = $evento->marcador_visitante; + + if ($mloc > $mvis) { + $resultado = "🏆 Ganó {$nombreLocal}"; + } elseif ($mvis > $mloc) { + $resultado = "🏆 Ganó {$nombreVisitante}"; + } else { + $resultado = "🤝 Empate"; + } + + $titulo = "Resultado: {$nombreLocal} {$mloc} - {$mvis} {$nombreVisitante}"; + $mensaje = "{$resultado}. Partido finalizado."; + $url = '/eventos/' . $evento->id_evento; + + $this->notificarSeguidoresDeEquipos( + [$evento->id_equipo_local, $evento->id_equipo_visitante], + 'resultado', + $titulo, + $mensaje, + $url + ); + } + + /** + * Obtiene todos los seguidores de una lista de equipos y envía notificaciones. + * También incluye a los jugadores de esos equipos automáticamente. + */ + private function notificarSeguidoresDeEquipos(array $idEquipos, string $tipo, string $titulo, string $mensaje, ?string $url): void + { + $idEquipos = array_filter($idEquipos); + if (empty($idEquipos)) return; + + $destinatarios = []; + $yaAgregados = []; + + // Seguidores registrados en equipo_seguimiento + $seguimientos = EquipoSeguimiento::whereIn('id_equipo', $idEquipos)->get(); + foreach ($seguimientos as $s) { + $key = $s->tipo_usuario . ':' . $s->id_usuario; + if (!isset($yaAgregados[$key])) { + $destinatarios[] = ['tipo' => $s->tipo_usuario, 'id' => $s->id_usuario]; + $yaAgregados[$key] = true; + } + } + + // Jugadores que pertenecen a estos equipos (siguen automáticamente) + $jugadores = \DB::table('jugador_equipo') + ->whereIn('id_equipo', $idEquipos) + ->pluck('id_jugador'); + + foreach ($jugadores as $idJ) { + $key = 'jugador:' . $idJ; + if (!isset($yaAgregados[$key])) { + $destinatarios[] = ['tipo' => 'jugador', 'id' => $idJ]; + $yaAgregados[$key] = true; + } + } + + if (!empty($destinatarios)) { + $this->notifService->enviarMasivo($destinatarios, $tipo, $titulo, $mensaje, $url); + } + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php new file mode 100644 index 0000000..f9857ed --- /dev/null +++ b/app/Providers/AppServiceProvider.php @@ -0,0 +1,57 @@ +app->singleton(NotificacionService::class); + } + + public function boot(): void + { + \Illuminate\Pagination\Paginator::useBootstrapFive(); + + // Registrar observer del modelo Evento + Evento::observe(EventoObserver::class); + + view()->composer('layouts.app', function ($view) { + try { + if (\Illuminate\Support\Facades\Schema::hasTable('sponsors')) { + $view->with('footerSponsors', \App\Models\Sponsor::where('activo', true)->orderBy('orden')->get()); + } else { + $view->with('footerSponsors', collect()); + } + + if (\Illuminate\Support\Facades\Schema::hasTable('torneos')) { + $view->with('navTorneos', \App\Models\Torneo::orderByDesc('fecha_inicio')->take(5)->get()); + } else { + $view->with('navTorneos', collect()); + } + + // Badge de notificaciones para el layout + if (session()->has('user_logged_in') && \Illuminate\Support\Facades\Schema::hasTable('notificaciones')) { + $service = app(NotificacionService::class); + $view->with('notifCount', $service->contarNoLeidas(session('user_tipo'), session('user_id'))); + } else { + $view->with('notifCount', 0); + } + + } catch (\Exception $e) { + $view->with('footerSponsors', collect()); + $view->with('navTorneos', collect()); + $view->with('notifCount', 0); + } + }); + } +} + diff --git a/app/Services/FixtureService.php b/app/Services/FixtureService.php new file mode 100644 index 0000000..5e0b581 --- /dev/null +++ b/app/Services/FixtureService.php @@ -0,0 +1,138 @@ +equipos()->get()->values()->toArray(); + $n = count($equipos); + + if ($n < 2) { + throw new \InvalidArgumentException('Se necesitan al menos 2 equipos para generar un fixture.'); + } + + // Si número impar, agregar BYE + $hayBye = false; + if ($n % 2 !== 0) { + $equipos[] = null; // BYE + $n++; + $hayBye = true; + } + + $mitad = $n / 2; + $jornadas = $n - 1; + $partidos = []; + $fechaActual = Carbon::parse($fechaInicio); + + // Algoritmo Round-Robin: fijar el primer equipo, rotar el resto + $lista = range(0, $n - 1); + + for ($jornada = 0; $jornada < $jornadas; $jornada++) { + $jornadaPartidos = []; + + for ($i = 0; $i < $mitad; $i++) { + $localIdx = $lista[$i]; + $visitanteIdx= $lista[$n - 1 - $i]; + + // Si alguno es BYE (null), saltar + if ($equipos[$localIdx] === null || $equipos[$visitanteIdx] === null) continue; + + $local = $equipos[$localIdx]; + $visitante = $equipos[$visitanteIdx]; + + // Alternar localía: en jornadas pares, invertir + if ($jornada % 2 === 1) { + [$local, $visitante] = [$visitante, $local]; + } + + $jornadaPartidos[] = [ + 'id_equipo_local' => $local['id_equipo'], + 'id_equipo_visitante' => $visitante['id_equipo'], + 'fecha_evento' => $fechaActual->format('Y-m-d'), + 'hora_inicio' => '20:00:00', + 'hora_fin' => '22:00:00', + 'sede' => $sedeDefault, + 'id_torneo' => $torneo->id, + 'nombre_evento' => null, // se genera automáticamente + 'precio' => 0, + 'jornada' => $jornada + 1, + ]; + } + + $partidos = array_merge($partidos, $jornadaPartidos); + $fechaActual->addDays($diasEntreJornadas); + + // Rotar la lista (el primer elemento fijo) + $rotatable = array_slice($lista, 1); + array_push($rotatable, array_shift($rotatable)); + $lista = array_merge([$lista[0]], $rotatable); + } + + // Doble rueda: agregar vuelta con localías invertidas + if ($dobleRueda && !empty($partidos)) { + $vuelta = []; + foreach ($partidos as $p) { + $vuelta[] = array_merge($p, [ + 'id_equipo_local' => $p['id_equipo_visitante'], + 'id_equipo_visitante' => $p['id_equipo_local'], + 'fecha_evento' => Carbon::parse($p['fecha_evento'])->addDays($jornadas * $diasEntreJornadas)->format('Y-m-d'), + 'jornada' => $p['jornada'] + $jornadas, + ]); + } + $partidos = array_merge($partidos, $vuelta); + } + + return $partidos; + } + + /** + * Persiste el fixture generado como Evento records en la DB. + */ + public function persistirFixture(array $partidos, Torneo $torneo): int + { + $contador = 0; + foreach ($partidos as $p) { + $local = Equipo::with('club')->find($p['id_equipo_local']); + $visitante = Equipo::with('club')->find($p['id_equipo_visitante']); + + $nombreEvento = ($local->club->nombre ?? '?') . ' vs ' . ($visitante->club->nombre ?? '?'); + + \App\Models\Evento::create([ + 'id_evento' => uniqid('ev_'), + 'nombre_evento' => $nombreEvento, + 'id_equipo_local' => $p['id_equipo_local'], + 'id_equipo_visitante' => $p['id_equipo_visitante'], + 'fecha_evento' => $p['fecha_evento'], + 'hora_inicio' => $p['hora_inicio'], + 'hora_fin' => $p['hora_fin'], + 'sede' => $p['sede'] ?: null, + 'id_torneo' => $torneo->id, + 'precio' => $p['precio'] ?? 0, + ]); + + $contador++; + } + return $contador; + } +} diff --git a/app/Services/GeniusAgentService.php b/app/Services/GeniusAgentService.php new file mode 100644 index 0000000..f079732 --- /dev/null +++ b/app/Services/GeniusAgentService.php @@ -0,0 +1,229 @@ +buildMessages($history, $message); + + try { + $response = Prism::text() + ->using(self::PROVIDER, $this->model()) + ->withSystemPrompt(SystemPromptPublic::get()) + ->withMessages($messages) + ->asText(); + + return $response->text; + } catch (PrismRateLimitedException $e) { + report($e); + return 'Demasiadas consultas en poco tiempo. Por favor, esperá un momento e intentá de nuevo.'; + } catch (Throwable $e) { + report($e); + return 'El agente no responde en este momento. Por favor, intentá de nuevo en unos instantes.'; + } + } + + public function chatAdmin(string $message, AgentThread $thread, bool $isSuperadmin = false): string + { + $messages = $this->buildMessages($thread->messages ?? [], $message); + + try { + $response = Prism::text() + ->using(self::PROVIDER, $this->model()) + ->withSystemPrompt(SystemPromptAdmin::get($isSuperadmin)) + ->withMessages($messages) + ->withTools($this->getAdminTools($isSuperadmin)) + ->withMaxSteps(self::MAX_STEPS) + ->asText(); + + if ($response->text !== '') { + $reply = $response->text; + } else { + Log::warning('Genius admin: empty text from model', [ + 'finishReason' => isset($response->finishReason) + ? (is_object($response->finishReason) ? ($response->finishReason->name ?? get_class($response->finishReason)) : $response->finishReason) + : null, + 'steps_count' => is_countable($response->steps ?? null) ? count($response->steps) : null, + 'tool_calls' => is_countable($response->toolCalls ?? null) ? count($response->toolCalls) : null, + 'tool_results' => is_countable($response->toolResults ?? null) ? count($response->toolResults) : null, + ]); + $reply = $this->fallbackFromToolResults($response); + } + + $updated = array_merge($thread->messages ?? [], [ + ['role' => 'user', 'content' => $message], + ['role' => 'assistant', 'content' => $reply], + ]); + + $thread->update(['messages' => $updated]); + + return $reply; + } catch (PrismRateLimitedException $e) { + report($e); + return 'Demasiadas consultas en poco tiempo. Por favor, esperá un momento e intentá de nuevo.'; + } catch (Throwable $e) { + report($e); + return 'El agente no responde en este momento. Por favor, intentá de nuevo en unos instantes.'; + } + } + + private function fallbackFromToolResults($response): string + { + $collected = []; + + $steps = $response->steps ?? []; + foreach ($steps as $step) { + if (!empty($step->text ?? '')) { + return $step->text; + } + + foreach (($step->toolResults ?? []) as $tr) { + $collected[] = [ + 'tool' => $tr->toolName ?? ($tr->name ?? 'tool'), + 'result' => $tr->result ?? null, + ]; + } + } + + if (empty($collected)) { + foreach (($response->toolResults ?? []) as $tr) { + $collected[] = [ + 'tool' => $tr->toolName ?? ($tr->name ?? 'tool'), + 'result' => $tr->result ?? null, + ]; + } + } + + if (empty($collected)) { + return 'El agente no devolvió una respuesta. Revisá storage/logs/laravel.log para el detalle.'; + } + + $lines = ["Datos obtenidos (la IA no generó un resumen en texto):"]; + foreach ($collected as $item) { + $decoded = is_string($item['result']) ? json_decode($item['result'], true) : $item['result']; + $pretty = is_array($decoded) || is_object($decoded) + ? json_encode($decoded, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) + : (string) $item['result']; + $lines[] = "\n**{$item['tool']}**:\n{$pretty}"; + } + + return implode("\n", $lines); + } + + /** @return array */ + private function buildMessages(array $history, string $newMessage): array + { + $limit = (int) config('services.genius.history_limit', 10); + $trimmed = $limit > 0 ? array_slice($history, -$limit * 2) : $history; + + $messages = []; + + foreach ($trimmed as $entry) { + if (($entry['role'] ?? '') === 'user') { + $messages[] = new UserMessage($entry['content']); + } elseif (($entry['role'] ?? '') === 'assistant') { + $messages[] = new AssistantMessage($entry['content']); + } + } + + $messages[] = new UserMessage($newMessage); + + return $messages; + } + + /** @return array */ + private function getAdminTools(bool $isSuperadmin): array + { + $readOnly = [ + Tool::as('listar_torneos') + ->for('Lista todos los torneos del sistema con sus IDs y fechas. Úsalo cuando el admin mencione un torneo por nombre para obtener el id_torneo.') + ->using(new ListarTorneosTool()), + + Tool::as('listar_equipos') + ->for('Lista los equipos del sistema. Acepta filtros opcionales por torneo (id_torneo) y grupo.') + ->withNumberParameter('id_torneo', 'ID del torneo para filtrar equipos', false) + ->withStringParameter('grupo', 'Nombre del grupo (ej. "A", "B") para filtrar dentro del torneo', false) + ->using(new ListarEquiposTool()), + + Tool::as('listar_eventos') + ->for('Lista los partidos/eventos del sistema. Acepta filtros opcionales por rango de fechas y torneo.') + ->withStringParameter('fecha_desde', 'Fecha de inicio del rango (formato YYYY-MM-DD)', false) + ->withStringParameter('fecha_hasta', 'Fecha de fin del rango (formato YYYY-MM-DD)', false) + ->withNumberParameter('id_torneo', 'ID del torneo para filtrar eventos', false) + ->using(new ListarEventosTool()), + ]; + + if (!$isSuperadmin) { + return $readOnly; + } + + $writeTools = [ + Tool::as('crear_partido') + ->for('Crea un nuevo partido en el sistema. SOLO ejecutar tras confirmación explícita del superadmin.') + ->withNumberParameter('id_equipo_local', 'ID del equipo local') + ->withNumberParameter('id_equipo_visitante', 'ID del equipo visitante') + ->withStringParameter('fecha_evento', 'Fecha del partido (formato YYYY-MM-DD)') + ->withStringParameter('hora_inicio', 'Hora de inicio (formato HH:MM)') + ->withStringParameter('hora_fin', 'Hora de fin (formato HH:MM)') + ->withStringParameter('sede', 'Nombre de la cancha o sede del partido') + ->withNumberParameter('id_torneo', 'ID del torneo al que pertenece el partido') + ->using(new CrearPartidoTool()), + + Tool::as('cargar_puntaje') + ->for('Actualiza el marcador de un partido existente. SOLO ejecutar tras confirmación explícita del superadmin.') + ->withStringParameter('id_evento', 'UUID del partido a actualizar') + ->withNumberParameter('marcador_local', 'Puntos del equipo local') + ->withNumberParameter('marcador_visitante', 'Puntos del equipo visitante') + ->using(new CargarPuntajeTool()), + + Tool::as('redactar_noticia') + ->for('Publica una noticia en el portal OnAPB. SOLO ejecutar tras confirmación explícita del superadmin.') + ->withStringParameter('titulo', 'Título de la noticia') + ->withStringParameter('contenido', 'Cuerpo completo de la noticia en HTML o texto plano') + ->withNumberParameter('id_torneo', 'ID del torneo relacionado (opcional)', false) + ->withStringParameter('categoria', 'Categoría de la noticia (opcional)', false) + ->using(new RedactarNoticiaTool()), + + Tool::as('eliminar_noticia') + ->for('Elimina (rollback) una noticia publicada. Usar cuando el superadmin pida deshacer una creación.') + ->withNumberParameter('id_noticia', 'ID numérico de la noticia a eliminar') + ->using(new EliminarNoticiaTool()), + + Tool::as('eliminar_partido') + ->for('Elimina (soft delete / rollback) un partido creado. Usar cuando el superadmin pida deshacer.') + ->withStringParameter('id_evento', 'UUID del partido a eliminar') + ->using(new EliminarPartidoTool()), + ]; + + return array_merge($readOnly, $writeTools); + } +} diff --git a/app/Services/ImageOptimizer.php b/app/Services/ImageOptimizer.php new file mode 100644 index 0000000..4c69f5f --- /dev/null +++ b/app/Services/ImageOptimizer.php @@ -0,0 +1,106 @@ + ['maxWidth' => 1600, 'quality' => 82], + 'noticias' => ['maxWidth' => 1200, 'quality' => 82], + 'promos' => ['maxWidth' => 1200, 'quality' => 82], + 'clubes' => ['maxWidth' => 512, 'quality' => 85], + 'sponsors' => ['maxWidth' => 600, 'quality' => 85], + 'qr' => ['maxWidth' => 800, 'quality' => 85], + ]; + + private const MIN_RECOMPRESS_BYTES = 100 * 1024; + + /** + * Sube el archivo al disk 'public' y lo optimiza in-place. + * Retorna el path relativo (ej: "carousel/abc123.jpg"). + */ + public function storeAndOptimize(UploadedFile $file, string $folder): string + { + $path = $file->store($folder, 'public'); + + if (!extension_loaded('gd')) { + return $path; + } + + $cfg = self::FOLDERS[$folder] ?? null; + if (!$cfg) { + return $path; + } + + $abs = Storage::disk('public')->path($path); + $this->optimizeFile($abs, $cfg['maxWidth'], $cfg['quality']); + + return $path; + } + + /** + * Optimiza un archivo en disco (resize + recompress). + * Devuelve [origSize, newSize, newW, newH] o null si no se modifico. + */ + public function optimizeFile(string $file, int $maxWidth, int $quality): ?array + { + if (!file_exists($file)) return null; + + $origSize = filesize($file); + $info = @getimagesize($file); + if (!$info) return null; + + [$w, $h] = $info; + $needsResize = $w > $maxWidth; + $needsRecomp = $origSize > self::MIN_RECOMPRESS_BYTES; + if (!$needsResize && !$needsRecomp) return null; + + $ext = strtolower(pathinfo($file, PATHINFO_EXTENSION)); + $img = match ($ext) { + 'jpg', 'jpeg' => @imagecreatefromjpeg($file), + 'png' => @imagecreatefrompng($file), + 'webp' => @imagecreatefromwebp($file), + default => null, + }; + if (!$img) return null; + + $newW = $w; $newH = $h; + if ($w > $maxWidth) { + $newW = $maxWidth; + $newH = (int) round($h * ($maxWidth / $w)); + $resized = imagecreatetruecolor($newW, $newH); + + if (in_array($ext, ['png', 'webp'])) { + imagealphablending($resized, false); + imagesavealpha($resized, true); + $transparent = imagecolorallocatealpha($resized, 0, 0, 0, 127); + imagefilledrectangle($resized, 0, 0, $newW, $newH, $transparent); + } + + imagecopyresampled($resized, $img, 0, 0, 0, 0, $newW, $newH, $w, $h); + $img = $resized; + } + + ob_start(); + match ($ext) { + 'jpg', 'jpeg' => imagejpeg($img, null, $quality), + 'png' => imagepng($img, null, 9), + 'webp' => imagewebp($img, null, $quality), + }; + $bytes = ob_get_clean(); + + if (strlen($bytes) >= $origSize) { + return null; + } + + file_put_contents($file, $bytes); + return [$origSize, strlen($bytes), $newW, $newH]; + } +} diff --git a/app/Services/NotificacionService.php b/app/Services/NotificacionService.php new file mode 100644 index 0000000..985efe1 --- /dev/null +++ b/app/Services/NotificacionService.php @@ -0,0 +1,211 @@ + $tipo_dest, + 'id_destinatario' => (string) $id_dest, + 'tipo' => $tipo, + 'titulo' => $titulo, + 'mensaje' => $mensaje, + 'url_accion' => $url, + 'leida' => false, + 'enviada_email' => false, + 'creada_en' => now(), + ]); + + // Intentar envío Push + $this->enviarPush([['tipo' => $tipo_dest, 'id' => $id_dest]], $titulo, $mensaje, $url); + + return $notif; + } + + /** + * Enviar la misma notificación a múltiples destinatarios. + * $destinatarios = [['tipo' => 'jugador', 'id' => 'XXX'], ...] + */ + public function enviarMasivo(array $destinatarios, string $tipo, string $titulo, string $mensaje, ?string $url = null): int + { + $rows = []; + $ahora = now()->toDateTimeString(); + + foreach ($destinatarios as $dest) { + $rows[] = [ + 'tipo_destinatario' => $dest['tipo'], + 'id_destinatario' => (string) $dest['id'], + 'tipo' => $tipo, + 'titulo' => $titulo, + 'mensaje' => $mensaje, + 'url_accion' => $url, + 'leida' => false, + 'enviada_email' => false, + 'creada_en' => $ahora, + ]; + } + + if (empty($rows)) return 0; + + // Insert en chunks para no sobrecargar + foreach (array_chunk($rows, 500) as $chunk) { + Notificacion::insert($chunk); + } + + // Intentar envío Push masivo + $this->enviarPush($destinatarios, $titulo, $mensaje, $url); + + return count($rows); + } + + /** + * Lógica centralizada para enviar Web Push Notifications + */ + private function enviarPush(array $destinatarios, string $titulo, string $mensaje, ?string $url = null): void + { + $vPublic = env('VAPID_PUBLIC_KEY'); + $vPrivate = env('VAPID_PRIVATE_KEY'); + + if (!$vPublic || !$vPrivate) return; + + // Buscar todas las suscripciones de estos usuarios + $subsFound = PushSubscription::where(function($query) use ($destinatarios) { + foreach ($destinatarios as $d) { + $query->orWhere(function($q) use ($d) { + $q->where('tipo_usuario', $d['tipo']) + ->where('id_usuario', (string)$d['id']); + }); + } + })->get(); + + if ($subsFound->isEmpty()) return; + + try { + $auth = [ + 'VAPID' => [ + 'subject' => env('APP_URL', 'http://localhost'), + 'publicKey' => $vPublic, + 'privateKey' => $vPrivate, + ], + ]; + + $webPush = new WebPush($auth); + $payload = json_encode([ + 'title' => $titulo, + 'body' => $mensaje, + 'url' => $url ?: '/', + ]); + + foreach ($subsFound as $sub) { + $webPush->queueNotification( + Subscription::create([ + 'endpoint' => $sub->endpoint, + 'publicKey' => $sub->p256dh, + 'authToken' => $sub->auth, + ]), + $payload + ); + } + + foreach ($webPush->flush() as $report) { + if (!$report->isSuccess()) { + // Si falló (ej: suscripción expirada), la borramos para no reintentar + if ($report->isSubscriptionExpired()) { + $endpoint = $report->getEndpoint(); + PushSubscription::where('endpoint', $endpoint)->delete(); + } + } + } + } catch (\Exception $e) { + Log::error("Error enviando Web Push: " . $e->getMessage()); + } + } + + /** + * Obtener notificaciones no leídas de un usuario. + */ + public function obtenerNoLeidas(string $tipo, string|int $id): Collection + { + return Notificacion::paraUsuario($tipo, $id) + ->noLeidas() + ->orderByDesc('creada_en') + ->limit(50) + ->get(); + } + + /** + * Obtener todas las notificaciones de un usuario (paginadas). + */ + public function obtenerTodas(string $tipo, string|int $id, int $perPage = 20) + { + return Notificacion::paraUsuario($tipo, $id) + ->orderByDesc('creada_en') + ->paginate($perPage); + } + + /** + * Contar notificaciones no leídas. + */ + public function contarNoLeidas(string $tipo, string|int $id): int + { + return Notificacion::paraUsuario($tipo, $id)->noLeidas()->count(); + } + + /** + * Marcar una notificación como leída (verificando pertenencia). + */ + public function marcarLeida(int $id_notif, string $tipo, string|int $id_dest): bool + { + return (bool) Notificacion::paraUsuario($tipo, $id_dest) + ->where('id', $id_notif) + ->update(['leida' => true]); + } + + /** + * Marcar todas como leídas para un usuario. + */ + public function marcarTodasLeidas(string $tipo, string|int $id_dest): int + { + return Notificacion::paraUsuario($tipo, $id_dest) + ->noLeidas() + ->update(['leida' => true]); + } + + /** + * Eliminar una notificación (verificando pertenencia). + */ + public function eliminar(int $id_notif, string $tipo, string|int $id_dest): bool + { + return (bool) Notificacion::paraUsuario($tipo, $id_dest) + ->where('id', $id_notif) + ->delete(); + } + + /** + * Eliminar todas las notificaciones de un usuario. + */ + public function eliminarTodas(string $tipo, string|int $id_dest): int + { + return Notificacion::paraUsuario($tipo, $id_dest)->delete(); + } +} + diff --git a/app/Services/TournamentService.php b/app/Services/TournamentService.php new file mode 100644 index 0000000..cf42cb9 --- /dev/null +++ b/app/Services/TournamentService.php @@ -0,0 +1,149 @@ +findOrFail($idTorneo); + $stats = []; + + foreach ($torneo->equipos as $equipo) { + $groupName = $equipo->pivot->grupo ?: ($equipo->categoria . ' ' . $equipo->division); + + $stats[$groupName][$equipo->id_equipo] = [ + 'id' => $equipo->id_equipo, + 'nombre' => $equipo->club->nombre ?? 'Equipo', + 'logo' => $equipo->club->imagen ?? null, + 'categoria' => $equipo->categoria, + 'pj' => 0, + 'pg' => 0, + 'pp' => 0, + 'tf' => 0, + 'tc' => 0, + 'pts' => 0, + ]; + } + + $query = Evento::where('id_torneo', $idTorneo) + ->whereNotNull('marcador_local') + ->whereNotNull('marcador_visitante'); + + if ($onlyRegular) { + $query->where('fase', Evento::FASE_REGULAR); + } + + $matches = $query->get(); + + foreach ($matches as $m) { + $localGroup = null; + $visitGroup = null; + + foreach ($stats as $group => $teams) { + if (isset($teams[$m->id_equipo_local])) $localGroup = $group; + if (isset($teams[$m->id_equipo_visitante])) $visitGroup = $group; + } + + if (!$localGroup || !$visitGroup) continue; + + $stats[$localGroup][$m->id_equipo_local]['pj']++; + $stats[$localGroup][$m->id_equipo_local]['tf'] += $m->marcador_local; + $stats[$localGroup][$m->id_equipo_local]['tc'] += $m->marcador_visitante; + + $stats[$visitGroup][$m->id_equipo_visitante]['pj']++; + $stats[$visitGroup][$m->id_equipo_visitante]['tf'] += $m->marcador_visitante; + $stats[$visitGroup][$m->id_equipo_visitante]['tc'] += $m->marcador_local; + + if ($m->marcador_local > $m->marcador_visitante) { + $stats[$localGroup][$m->id_equipo_local]['pg']++; + $stats[$localGroup][$m->id_equipo_local]['pts'] += 2; + $stats[$visitGroup][$m->id_equipo_visitante]['pp']++; + $stats[$visitGroup][$m->id_equipo_visitante]['pts'] += 1; + } elseif ($m->marcador_visitante > $m->marcador_local) { + $stats[$visitGroup][$m->id_equipo_visitante]['pg']++; + $stats[$visitGroup][$m->id_equipo_visitante]['pts'] += 2; + $stats[$localGroup][$m->id_equipo_local]['pp']++; + $stats[$localGroup][$m->id_equipo_local]['pts'] += 1; + } else { + $stats[$localGroup][$m->id_equipo_local]['pts'] += 1; + $stats[$visitGroup][$m->id_equipo_visitante]['pts'] += 1; + } + } + + foreach ($stats as $group => &$teams) { + usort($teams, function($a, $b) { + if ($b['pts'] !== $a['pts']) return $b['pts'] - $a['pts']; + $diffA = $a['tf'] - $a['tc']; + $diffB = $b['tf'] - $b['tc']; + if ($diffB !== $diffA) return $diffB - $diffA; + return $b['tf'] - $a['tf']; + }); + } + + return $stats; + } + + public function getPlayoffBrackets(int $idTorneo): array + { + $playoffs = Evento::where('id_torneo', $idTorneo) + ->where('fase', '>', Evento::FASE_REGULAR) + ->with(['equipoLocal.club', 'equipoVisitante.club']) + ->orderBy('fase') + ->orderBy('numero_partido_bracket') + ->orderBy('fecha_evento') + ->get(); + + $bracket = [ + Evento::FASE_CUARTOS => collect(), + Evento::FASE_SEMIS => collect(), + Evento::FASE_FINAL => collect(), + ]; + + // Agrupar por fase y número de llave (bracket) + $grouped = $playoffs->groupBy(function($item) { + return $item->fase . '-' . $item->numero_partido_bracket; + }); + + foreach ($grouped as $key => $matches) { + $first = $matches->first(); + $fase = $first->fase; + $nro = $first->numero_partido_bracket; + + $winsLocal = 0; + $winsVisit = 0; + $finished = 0; + + foreach ($matches as $m) { + if ($m->marcador_local !== null && $m->marcador_visitante !== null) { + $finished++; + if ($m->marcador_local > $m->marcador_visitante) $winsLocal++; + elseif ($m->marcador_visitante > $m->marcador_local) $winsVisit++; + } + } + + $bracket[$fase][$nro] = [ + 'matches' => $matches, + 'wins_local' => $winsLocal, + 'wins_visitante' => $winsVisit, + 'total_partidos' => $matches->count(), + 'terminados' => $finished, + 'equipo_local' => $first->equipoLocal, + 'equipo_visitante' => $first->equipoVisitante, + ]; + } + + return $bracket; + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php new file mode 100644 index 0000000..8905daa --- /dev/null +++ b/bootstrap/app.php @@ -0,0 +1,72 @@ +withRouting( + web: __DIR__.'/../routes/web.php', + commands: __DIR__.'/../routes/console.php', + health: '/up', + ) + ->withMiddleware(function (Middleware $middleware): void { + $middleware->web(append: [ + \App\Http\Middleware\SecurityHeaders::class, + ]); + }) + ->withExceptions(function (Exceptions $exceptions): void { + // Renderer amigable para errores en /admin/*: convierte excepciones técnicas + // (especialmente SQL) en una redirección back() con un mensaje claro que el + // layout admin muestra como SweetAlert modal. Detalle técnico va al log. + $exceptions->render(function (\Throwable $e, $request) { + if (config('app.debug')) { + return null; // En local/debug mantenemos la pantalla detallada de Laravel. + } + if (!$request->is('admin/*')) { + return null; + } + if ($request->expectsJson() || $request->ajax()) { + return null; + } + + // Excepciones que Laravel ya maneja bien (validación, auth, 404, etc.). + $passthrough = [ + \Illuminate\Validation\ValidationException::class, + \Illuminate\Auth\AuthenticationException::class, + \Illuminate\Auth\Access\AuthorizationException::class, + \Illuminate\Session\TokenMismatchException::class, + \Symfony\Component\HttpKernel\Exception\HttpException::class, + \Symfony\Component\HttpKernel\Exception\NotFoundHttpException::class, + \Illuminate\Database\Eloquent\ModelNotFoundException::class, + ]; + foreach ($passthrough as $cls) { + if ($e instanceof $cls) return null; + } + + if ($e instanceof \Illuminate\Database\QueryException) { + $code = (int) ($e->errorInfo[1] ?? 0); + $msg = match (true) { + $code === 1062 => 'Ya existe un registro con esos datos. Verificá si no fue cargado previamente o si se encuentra en la papelera.', + in_array($code, [1451, 1452], true) => 'Esta operación no se puede completar porque existen datos relacionados (jugadores, equipos o eventos vinculados).', + default => 'Hubo un problema con la base de datos. Intentá nuevamente o contactá al administrador.', + }; + \Log::error('[admin] QueryException', [ + 'code' => $code, + 'message' => $e->getMessage(), + 'url' => $request->fullUrl(), + ]); + return back()->withInput()->with('admin_error_modal', $msg); + } + + \Log::error('[admin] Excepción no manejada', [ + 'exception' => $e->getMessage(), + 'class' => get_class($e), + 'url' => $request->fullUrl(), + ]); + return back()->withInput()->with( + 'admin_error_modal', + 'Ocurrió un error inesperado al procesar la solicitud. Intentá nuevamente o contactá al administrador.' + ); + }); + })->create(); diff --git a/bootstrap/cache/.gitignore b/bootstrap/cache/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/bootstrap/cache/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/bootstrap/providers.php b/bootstrap/providers.php new file mode 100644 index 0000000..fc94ae6 --- /dev/null +++ b/bootstrap/providers.php @@ -0,0 +1,7 @@ + env('APP_NAME', 'Laravel'), + + /* + |-------------------------------------------------------------------------- + | Application Environment + |-------------------------------------------------------------------------- + | + | This value determines the "environment" your application is currently + | running in. This may determine how you prefer to configure various + | services the application utilizes. Set this in your ".env" file. + | + */ + + 'env' => env('APP_ENV', 'production'), + + /* + |-------------------------------------------------------------------------- + | Application Debug Mode + |-------------------------------------------------------------------------- + | + | When your application is in debug mode, detailed error messages with + | stack traces will be shown on every error that occurs within your + | application. If disabled, a simple generic error page is shown. + | + */ + + 'debug' => (bool) env('APP_DEBUG', false), + + /* + |-------------------------------------------------------------------------- + | Application URL + |-------------------------------------------------------------------------- + | + | This URL is used by the console to properly generate URLs when using + | the Artisan command line tool. You should set this to the root of + | the application so that it's available within Artisan commands. + | + */ + + 'url' => env('APP_URL', 'http://localhost'), + + /* + |-------------------------------------------------------------------------- + | Application Timezone + |-------------------------------------------------------------------------- + | + | Here you may specify the default timezone for your application, which + | will be used by the PHP date and date-time functions. The timezone + | is set to "UTC" by default as it is suitable for most use cases. + | + */ + + 'timezone' => env('APP_TIMEZONE', 'UTC'), + + /* + |-------------------------------------------------------------------------- + | Application Locale Configuration + |-------------------------------------------------------------------------- + | + | The application locale determines the default locale that will be used + | by Laravel's translation / localization methods. This option can be + | set to any locale for which you plan to have translation strings. + | + */ + + 'locale' => env('APP_LOCALE', 'es'), + + 'fallback_locale' => env('APP_FALLBACK_LOCALE', 'es'), + + 'faker_locale' => env('APP_FAKER_LOCALE', 'es_AR'), + + /* + |-------------------------------------------------------------------------- + | Encryption Key + |-------------------------------------------------------------------------- + | + | This key is utilized by Laravel's encryption services and should be set + | to a random, 32 character string to ensure that all encrypted values + | are secure. You should do this prior to deploying the application. + | + */ + + 'cipher' => 'AES-256-CBC', + + 'key' => env('APP_KEY'), + + 'previous_keys' => [ + ...array_filter( + explode(',', (string) env('APP_PREVIOUS_KEYS', '')) + ), + ], + + /* + |-------------------------------------------------------------------------- + | Maintenance Mode Driver + |-------------------------------------------------------------------------- + | + | These configuration options determine the driver used to determine and + | manage Laravel's "maintenance mode" status. The "cache" driver will + | allow maintenance mode to be controlled across multiple machines. + | + | Supported drivers: "file", "cache" + | + */ + + 'maintenance' => [ + 'driver' => env('APP_MAINTENANCE_DRIVER', 'file'), + 'store' => env('APP_MAINTENANCE_STORE', 'database'), + ], + +]; diff --git a/config/auth.php b/config/auth.php new file mode 100644 index 0000000..d7568ff --- /dev/null +++ b/config/auth.php @@ -0,0 +1,117 @@ + [ + 'guard' => env('AUTH_GUARD', 'web'), + 'passwords' => env('AUTH_PASSWORD_BROKER', 'users'), + ], + + /* + |-------------------------------------------------------------------------- + | Authentication Guards + |-------------------------------------------------------------------------- + | + | Next, you may define every authentication guard for your application. + | Of course, a great default configuration has been defined for you + | which utilizes session storage plus the Eloquent user provider. + | + | All authentication guards have a user provider, which defines how the + | users are actually retrieved out of your database or other storage + | system used by the application. Typically, Eloquent is utilized. + | + | Supported: "session" + | + */ + + 'guards' => [ + 'web' => [ + 'driver' => 'session', + 'provider' => 'users', + ], + ], + + /* + |-------------------------------------------------------------------------- + | User Providers + |-------------------------------------------------------------------------- + | + | All authentication guards have a user provider, which defines how the + | users are actually retrieved out of your database or other storage + | system used by the application. Typically, Eloquent is utilized. + | + | If you have multiple user tables or models you may configure multiple + | providers to represent the model / table. These providers may then + | be assigned to any extra authentication guards you have defined. + | + | Supported: "database", "eloquent" + | + */ + + 'providers' => [ + 'users' => [ + 'driver' => 'eloquent', + 'model' => env('AUTH_MODEL', User::class), + ], + + // 'users' => [ + // 'driver' => 'database', + // 'table' => 'users', + // ], + ], + + /* + |-------------------------------------------------------------------------- + | Resetting Passwords + |-------------------------------------------------------------------------- + | + | These configuration options specify the behavior of Laravel's password + | reset functionality, including the table utilized for token storage + | and the user provider that is invoked to actually retrieve users. + | + | The expiry time is the number of minutes that each reset token will be + | considered valid. This security feature keeps tokens short-lived so + | they have less time to be guessed. You may change this as needed. + | + | The throttle setting is the number of seconds a user must wait before + | generating more password reset tokens. This prevents the user from + | quickly generating a very large amount of password reset tokens. + | + */ + + 'passwords' => [ + 'users' => [ + 'provider' => 'users', + 'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'), + 'expire' => 60, + 'throttle' => 60, + ], + ], + + /* + |-------------------------------------------------------------------------- + | Password Confirmation Timeout + |-------------------------------------------------------------------------- + | + | Here you may define the number of seconds before a password confirmation + | window expires and users are asked to re-enter their password via the + | confirmation screen. By default, the timeout lasts for three hours. + | + */ + + 'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800), + +]; diff --git a/config/backup.php b/config/backup.php new file mode 100644 index 0000000..b8a7c13 --- /dev/null +++ b/config/backup.php @@ -0,0 +1,387 @@ + [ + /* + * The name of this application. You can use this name to monitor + * the backups. + */ + 'name' => env('APP_NAME', 'laravel-backup'), + + 'source' => [ + 'files' => [ + /* + * The list of directories and files that will be included in the backup. + */ + 'include' => [ + base_path(), + // storage_path(), // Include if you use zero downtime deployments and don't follow symlinks + ], + + /* + * These directories and files will be excluded from the backup. + * + * Directories used by the backup process will automatically be excluded. + */ + 'exclude' => [ + base_path('vendor'), + base_path('node_modules'), + storage_path('framework'), + ], + + /* + * Determines if symlinks should be followed. + */ + 'follow_links' => false, + + /* + * Determines if it should avoid unreadable folders. + */ + 'ignore_unreadable_directories' => false, + + /* + * This path is used to make directories in resulting zip-file relative + * Set to `null` to include complete absolute path + * Example: base_path() + */ + 'relative_path' => null, + ], + + /* + * The names of the connections to the databases that should be backed up + * MySQL, PostgreSQL, SQLite and Mongo databases are supported. + * + * The content of the database dump may be customized for each connection + * by adding a 'dump' key to the connection settings in config/database.php. + * E.g. + * 'mysql' => [ + * ... + * 'dump' => [ + * 'exclude_tables' => [ + * 'table_to_exclude_from_backup', + * 'another_table_to_exclude' + * ] + * ], + * ], + * + * If you are using only InnoDB tables on a MySQL server, you can + * also supply the useSingleTransaction option to avoid table locking. + * + * E.g. + * 'mysql' => [ + * ... + * 'dump' => [ + * 'useSingleTransaction' => true, + * ], + * ], + * + * For a complete list of available customization options, see https://github.com/spatie/db-dumper + */ + 'databases' => [ + env('DB_CONNECTION', 'mysql'), + ], + ], + + /* + * The database dump can be compressed to decrease disk space usage. + * + * Out of the box Laravel-backup supplies + * Spatie\DbDumper\Compressors\GzipCompressor::class. + * + * You can also create custom compressor. More info on that here: + * https://github.com/spatie/db-dumper#using-compression + * + * If you do not want any compressor at all, set it to null. + */ + 'database_dump_compressor' => null, + + /* + * If specified, the database dumped file name will contain a timestamp (e.g.: 'Y-m-d-H-i-s'). + */ + 'database_dump_file_timestamp_format' => null, + + /* + * The base of the dump filename, either 'database' or 'connection' + * + * If 'database' (default), the dumped filename will contain the database name. + * If 'connection', the dumped filename will contain the connection name. + */ + 'database_dump_filename_base' => 'database', + + /* + * The file extension used for the database dump files. + * + * If not specified, the file extension will be .archive for MongoDB and .sql for all other databases + * The file extension should be specified without a leading . + */ + 'database_dump_file_extension' => '', + + 'destination' => [ + /* + * The compression algorithm to be used for creating the zip archive. + * + * If backing up only database, you may choose gzip compression for db dump and no compression at zip. + * + * Some common algorithms are listed below: + * ZipArchive::CM_STORE (no compression at all; set 0 as compression level) + * ZipArchive::CM_DEFAULT + * ZipArchive::CM_DEFLATE + * ZipArchive::CM_BZIP2 + * ZipArchive::CM_XZ + * + * For more check https://www.php.net/manual/zip.constants.php and confirm it's supported by your system. + */ + 'compression_method' => ZipArchive::CM_DEFAULT, + + /* + * The compression level corresponding to the used algorithm; an integer between 0 and 9. + * + * Check supported levels for the chosen algorithm, usually 1 means the fastest and weakest compression, + * while 9 the slowest and strongest one. + * + * Setting of 0 for some algorithms may switch to the strongest compression. + */ + 'compression_level' => 9, + + /* + * The filename prefix used for the backup zip file. + */ + 'filename_prefix' => '', + + /* + * The disk names on which the backups will be stored. + */ + 'disks' => [ + 'local', + ], + + /* + * Determines whether to allow backups to continue when some targets fail instead of failing completely. + */ + 'continue_on_failure' => false, + ], + + /* + * The directory where the temporary files will be stored. + */ + 'temporary_directory' => storage_path('app/backup-temp'), + + /* + * The password to be used for archive encryption. + * Set to `null` to disable encryption. + */ + 'password' => env('BACKUP_ARCHIVE_PASSWORD'), + + /* + * The encryption algorithm to be used for archive encryption. + * Set to 'none' to disable encryption. + * + * Supported: 'none', 'default', 'aes128', 'aes192', 'aes256' + * + * When set to 'default', we'll use AES-256 if available on your system. + */ + 'encryption' => 'default', + + /* + * After creating the zip, verify it can be opened and contains files. + * Recommended for critical backups but adds a small overhead. + */ + 'verify_backup' => false, + + /* + * The number of attempts, in case the backup command encounters an exception + */ + 'tries' => 1, + + /* + * The number of seconds to wait before attempting a new backup if the previous try failed + * Set to `0` for none + */ + 'retry_delay' => 0, + ], + + /* + * You can get notified when specific events occur. Out of the box you can use 'mail' and 'slack'. + * For Slack you need to install laravel/slack-notification-channel. + * + * You can also use your own notification classes, just make sure the class is named after one of + * the `Spatie\Backup\Notifications\Notifications` classes. + */ + 'notifications' => [ + 'notifications' => [ + BackupHasFailedNotification::class => ['mail'], + UnhealthyBackupWasFoundNotification::class => ['mail'], + CleanupHasFailedNotification::class => ['mail'], + BackupWasSuccessfulNotification::class => env('BACKUP_NOTIFY_SUCCESS', false) ? ['mail'] : [], + HealthyBackupWasFoundNotification::class => env('BACKUP_NOTIFY_SUCCESS', false) ? ['mail'] : [], + CleanupWasSuccessfulNotification::class => env('BACKUP_NOTIFY_SUCCESS', false) ? ['mail'] : [], + ], + + /* + * Here you can specify the notifiable to which the notifications should be sent. The default + * notifiable will use the variables specified in this config file. + */ + 'notifiable' => Notifiable::class, + + 'mail' => [ + 'to' => env('BACKUP_NOTIFICATION_EMAIL', env('MAIL_FROM_ADDRESS')), + + 'from' => [ + 'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'), + 'name' => env('MAIL_FROM_NAME', 'Example'), + ], + ], + + 'slack' => [ + 'webhook_url' => '', + + /* + * If this is set to null the default channel of the webhook will be used. + */ + 'channel' => null, + + 'username' => null, + + 'icon' => null, + ], + + 'discord' => [ + 'webhook_url' => '', + + /* + * If this is an empty string, the name field on the webhook will be used. + */ + 'username' => '', + + /* + * If this is an empty string, the avatar on the webhook will be used. + */ + 'avatar_url' => '', + ], + + /* + * A generic webhook channel that POSTs JSON to a URL. + * Useful for Mattermost, Microsoft Teams, or custom integrations. + */ + 'webhook' => [ + 'url' => '', + ], + ], + + /* + * The log channel used for backup activity messages. + * + * Set to a channel name defined in config/logging.php to use that channel. + * Set to false to disable backup logging entirely. + * Set to null to use the default log channel. + */ + 'log_channel' => null, + + /* + * Here you can specify which backups should be monitored. + * If a backup does not meet the specified requirements the + * UnHealthyBackupWasFound event will be fired. + */ + 'monitor_backups' => [ + [ + 'name' => env('APP_NAME', 'laravel-backup'), + 'disks' => ['local'], + 'health_checks' => [ + MaximumAgeInDays::class => 1, + MaximumStorageInMegabytes::class => 5000, + ], + ], + + /* + [ + 'name' => 'name of the second app', + 'disks' => ['local', 's3'], + 'health_checks' => [ + \Spatie\Backup\Tasks\Monitor\HealthChecks\MaximumAgeInDays::class => 1, + \Spatie\Backup\Tasks\Monitor\HealthChecks\MaximumStorageInMegabytes::class => 5000, + ], + ], + */ + ], + + 'cleanup' => [ + /* + * The strategy that will be used to cleanup old backups. The default strategy + * will keep all backups for a certain amount of days. After that period only + * a daily backup will be kept. After that period only weekly backups will + * be kept and so on. + * + * No matter how you configure it the default strategy will never + * delete the newest backup. + */ + 'strategy' => DefaultStrategy::class, + + 'default_strategy' => [ + /* + * Política OnAPB: mantener solo la última semana de backups. + * Resultado esperado en estado estable: máximo ~7 backups + * (uno por día), con tope duro de 1 GB. + */ + + /* + * Días para los que se conservan TODOS los backups del día. + * Con backup:run diario, normalmente solo hay 1 por día, + * así que esto cubre los últimos 3 días por seguridad. + */ + 'keep_all_backups_for_days' => 3, + + /* + * Después de los "all", se conserva solo el backup más reciente + * de cada día durante esta cantidad de días. + * 7 días = última semana de backups disponibles. + */ + 'keep_daily_backups_for_days' => 7, + + /* + * Sin backups semanales históricos (se borran al pasar la semana). + */ + 'keep_weekly_backups_for_weeks' => 0, + + /* + * Sin backups mensuales históricos. + */ + 'keep_monthly_backups_for_months' => 0, + + /* + * Sin backups anuales históricos. + */ + 'keep_yearly_backups_for_years' => 0, + + /* + * Tope duro de espacio: si la carpeta de backups supera 1 GB, + * borrar los más antiguos hasta volver bajo el límite. + * Esto cubre el caso de un backup que crezca mucho de golpe. + */ + 'delete_oldest_backups_when_using_more_megabytes_than' => 1000, + ], + + /* + * The number of attempts, in case the cleanup command encounters an exception + */ + 'tries' => 1, + + /* + * The number of seconds to wait before attempting a new cleanup if the previous try failed + * Set to `0` for none + */ + 'retry_delay' => 0, + ], + +]; diff --git a/config/cache.php b/config/cache.php new file mode 100644 index 0000000..b32aead --- /dev/null +++ b/config/cache.php @@ -0,0 +1,117 @@ + env('CACHE_STORE', 'database'), + + /* + |-------------------------------------------------------------------------- + | Cache Stores + |-------------------------------------------------------------------------- + | + | Here you may define all of the cache "stores" for your application as + | well as their drivers. You may even define multiple stores for the + | same cache driver to group types of items stored in your caches. + | + | Supported drivers: "array", "database", "file", "memcached", + | "redis", "dynamodb", "octane", + | "failover", "null" + | + */ + + 'stores' => [ + + 'array' => [ + 'driver' => 'array', + 'serialize' => false, + ], + + 'database' => [ + 'driver' => 'database', + 'connection' => env('DB_CACHE_CONNECTION'), + 'table' => env('DB_CACHE_TABLE', 'cache'), + 'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'), + 'lock_table' => env('DB_CACHE_LOCK_TABLE'), + ], + + 'file' => [ + 'driver' => 'file', + 'path' => storage_path('framework/cache/data'), + 'lock_path' => storage_path('framework/cache/data'), + ], + + 'memcached' => [ + 'driver' => 'memcached', + 'persistent_id' => env('MEMCACHED_PERSISTENT_ID'), + 'sasl' => [ + env('MEMCACHED_USERNAME'), + env('MEMCACHED_PASSWORD'), + ], + 'options' => [ + // Memcached::OPT_CONNECT_TIMEOUT => 2000, + ], + 'servers' => [ + [ + 'host' => env('MEMCACHED_HOST', '127.0.0.1'), + 'port' => env('MEMCACHED_PORT', 11211), + 'weight' => 100, + ], + ], + ], + + 'redis' => [ + 'driver' => 'redis', + 'connection' => env('REDIS_CACHE_CONNECTION', 'cache'), + 'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'), + ], + + 'dynamodb' => [ + 'driver' => 'dynamodb', + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), + 'table' => env('DYNAMODB_CACHE_TABLE', 'cache'), + 'endpoint' => env('DYNAMODB_ENDPOINT'), + ], + + 'octane' => [ + 'driver' => 'octane', + ], + + 'failover' => [ + 'driver' => 'failover', + 'stores' => [ + 'database', + 'array', + ], + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Cache Key Prefix + |-------------------------------------------------------------------------- + | + | When utilizing the APC, database, memcached, Redis, and DynamoDB cache + | stores, there might be other applications using the same cache. For + | that reason, you may prefix every cache key to avoid collisions. + | + */ + + 'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'), + +]; diff --git a/config/database.php b/config/database.php new file mode 100644 index 0000000..64709ce --- /dev/null +++ b/config/database.php @@ -0,0 +1,184 @@ + env('DB_CONNECTION', 'sqlite'), + + /* + |-------------------------------------------------------------------------- + | Database Connections + |-------------------------------------------------------------------------- + | + | Below are all of the database connections defined for your application. + | An example configuration is provided for each database system which + | is supported by Laravel. You're free to add / remove connections. + | + */ + + 'connections' => [ + + 'sqlite' => [ + 'driver' => 'sqlite', + 'url' => env('DB_URL'), + 'database' => env('DB_DATABASE', database_path('database.sqlite')), + 'prefix' => '', + 'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true), + 'busy_timeout' => null, + 'journal_mode' => null, + 'synchronous' => null, + 'transaction_mode' => 'DEFERRED', + ], + + 'mysql' => [ + 'driver' => 'mysql', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '3306'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'unix_socket' => env('DB_SOCKET', ''), + 'charset' => env('DB_CHARSET', 'utf8mb4'), + 'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'), + 'prefix' => '', + 'prefix_indexes' => true, + 'strict' => true, + 'engine' => null, + 'options' => extension_loaded('pdo_mysql') ? array_filter([ + (PHP_VERSION_ID >= 80500 ? Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'), + ]) : [], + ], + + 'mariadb' => [ + 'driver' => 'mariadb', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '3306'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'unix_socket' => env('DB_SOCKET', ''), + 'charset' => env('DB_CHARSET', 'utf8mb4'), + 'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'), + 'prefix' => '', + 'prefix_indexes' => true, + 'strict' => true, + 'engine' => null, + 'options' => extension_loaded('pdo_mysql') ? array_filter([ + (PHP_VERSION_ID >= 80500 ? Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'), + ]) : [], + ], + + 'pgsql' => [ + 'driver' => 'pgsql', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '5432'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'charset' => env('DB_CHARSET', 'utf8'), + 'prefix' => '', + 'prefix_indexes' => true, + 'search_path' => 'public', + 'sslmode' => env('DB_SSLMODE', 'prefer'), + ], + + 'sqlsrv' => [ + 'driver' => 'sqlsrv', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', 'localhost'), + 'port' => env('DB_PORT', '1433'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'charset' => env('DB_CHARSET', 'utf8'), + 'prefix' => '', + 'prefix_indexes' => true, + // 'encrypt' => env('DB_ENCRYPT', 'yes'), + // 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'), + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Migration Repository Table + |-------------------------------------------------------------------------- + | + | This table keeps track of all the migrations that have already run for + | your application. Using this information, we can determine which of + | the migrations on disk haven't actually been run on the database. + | + */ + + 'migrations' => [ + 'table' => 'migrations', + 'update_date_on_publish' => true, + ], + + /* + |-------------------------------------------------------------------------- + | Redis Databases + |-------------------------------------------------------------------------- + | + | Redis is an open source, fast, and advanced key-value store that also + | provides a richer body of commands than a typical key-value system + | such as Memcached. You may define your connection settings here. + | + */ + + 'redis' => [ + + 'client' => env('REDIS_CLIENT', 'phpredis'), + + 'options' => [ + 'cluster' => env('REDIS_CLUSTER', 'redis'), + 'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'), + 'persistent' => env('REDIS_PERSISTENT', false), + ], + + 'default' => [ + 'url' => env('REDIS_URL'), + 'host' => env('REDIS_HOST', '127.0.0.1'), + 'username' => env('REDIS_USERNAME'), + 'password' => env('REDIS_PASSWORD'), + 'port' => env('REDIS_PORT', '6379'), + 'database' => env('REDIS_DB', '0'), + 'max_retries' => env('REDIS_MAX_RETRIES', 3), + 'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'), + 'backoff_base' => env('REDIS_BACKOFF_BASE', 100), + 'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000), + ], + + 'cache' => [ + 'url' => env('REDIS_URL'), + 'host' => env('REDIS_HOST', '127.0.0.1'), + 'username' => env('REDIS_USERNAME'), + 'password' => env('REDIS_PASSWORD'), + 'port' => env('REDIS_PORT', '6379'), + 'database' => env('REDIS_CACHE_DB', '1'), + 'max_retries' => env('REDIS_MAX_RETRIES', 3), + 'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'), + 'backoff_base' => env('REDIS_BACKOFF_BASE', 100), + 'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000), + ], + + ], + +]; diff --git a/config/filesystems.php b/config/filesystems.php new file mode 100644 index 0000000..545d68d --- /dev/null +++ b/config/filesystems.php @@ -0,0 +1,81 @@ + env('FILESYSTEM_DISK', 'local'), + + /* + |-------------------------------------------------------------------------- + | Filesystem Disks + |-------------------------------------------------------------------------- + | + | Below you may configure as many filesystem disks as necessary, and you + | may even configure multiple disks for the same driver. Examples for + | most supported storage drivers are configured here for reference. + | + | Supported drivers: "local", "ftp", "sftp", "s3" + | + */ + + 'disks' => [ + + 'local' => [ + 'driver' => 'local', + 'root' => storage_path('app/private'), + 'serve' => true, + 'throw' => false, + 'report' => false, + ], + + 'public' => [ + 'driver' => 'local', + // Le decimos que salga de la carpeta laravel y guarde directo en public_html + 'root' => base_path('../public_html/storage'), + 'url' => rtrim(env('APP_URL', 'http://localhost'), '/').'/storage', + 'visibility' => 'public', + 'throw' => false, + 'report' => false, + ], + + 's3' => [ + 'driver' => 's3', + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION'), + 'bucket' => env('AWS_BUCKET'), + 'url' => env('AWS_URL'), + 'endpoint' => env('AWS_ENDPOINT'), + 'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false), + 'throw' => false, + 'report' => false, + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Symbolic Links + |-------------------------------------------------------------------------- + | + | Here you may configure the symbolic links that will be created when the + | `storage:link` Artisan command is executed. The array keys should be + | the locations of the links and the values should be their targets. + | + */ + + 'links' => [ + public_path('storage') => storage_path('app/public'), + ], + +]; diff --git a/config/logging.php b/config/logging.php new file mode 100644 index 0000000..9e998a4 --- /dev/null +++ b/config/logging.php @@ -0,0 +1,132 @@ + env('LOG_CHANNEL', 'stack'), + + /* + |-------------------------------------------------------------------------- + | Deprecations Log Channel + |-------------------------------------------------------------------------- + | + | This option controls the log channel that should be used to log warnings + | regarding deprecated PHP and library features. This allows you to get + | your application ready for upcoming major versions of dependencies. + | + */ + + 'deprecations' => [ + 'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'), + 'trace' => env('LOG_DEPRECATIONS_TRACE', false), + ], + + /* + |-------------------------------------------------------------------------- + | Log Channels + |-------------------------------------------------------------------------- + | + | Here you may configure the log channels for your application. Laravel + | utilizes the Monolog PHP logging library, which includes a variety + | of powerful log handlers and formatters that you're free to use. + | + | Available drivers: "single", "daily", "slack", "syslog", + | "errorlog", "monolog", "custom", "stack" + | + */ + + 'channels' => [ + + 'stack' => [ + 'driver' => 'stack', + 'channels' => explode(',', (string) env('LOG_STACK', 'single')), + 'ignore_exceptions' => false, + ], + + 'single' => [ + 'driver' => 'single', + 'path' => storage_path('logs/laravel.log'), + 'level' => env('LOG_LEVEL', 'debug'), + 'replace_placeholders' => true, + ], + + 'daily' => [ + 'driver' => 'daily', + 'path' => storage_path('logs/laravel.log'), + 'level' => env('LOG_LEVEL', 'debug'), + 'days' => env('LOG_DAILY_DAYS', 14), + 'replace_placeholders' => true, + ], + + 'slack' => [ + 'driver' => 'slack', + 'url' => env('LOG_SLACK_WEBHOOK_URL'), + 'username' => env('LOG_SLACK_USERNAME', 'Laravel Log'), + 'emoji' => env('LOG_SLACK_EMOJI', ':boom:'), + 'level' => env('LOG_LEVEL', 'critical'), + 'replace_placeholders' => true, + ], + + 'papertrail' => [ + 'driver' => 'monolog', + 'level' => env('LOG_LEVEL', 'debug'), + 'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class), + 'handler_with' => [ + 'host' => env('PAPERTRAIL_URL'), + 'port' => env('PAPERTRAIL_PORT'), + 'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'), + ], + 'processors' => [PsrLogMessageProcessor::class], + ], + + 'stderr' => [ + 'driver' => 'monolog', + 'level' => env('LOG_LEVEL', 'debug'), + 'handler' => StreamHandler::class, + 'handler_with' => [ + 'stream' => 'php://stderr', + ], + 'formatter' => env('LOG_STDERR_FORMATTER'), + 'processors' => [PsrLogMessageProcessor::class], + ], + + 'syslog' => [ + 'driver' => 'syslog', + 'level' => env('LOG_LEVEL', 'debug'), + 'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER), + 'replace_placeholders' => true, + ], + + 'errorlog' => [ + 'driver' => 'errorlog', + 'level' => env('LOG_LEVEL', 'debug'), + 'replace_placeholders' => true, + ], + + 'null' => [ + 'driver' => 'monolog', + 'handler' => NullHandler::class, + ], + + 'emergency' => [ + 'path' => storage_path('logs/laravel.log'), + ], + + ], + +]; diff --git a/config/mail.php b/config/mail.php new file mode 100644 index 0000000..e32e88d --- /dev/null +++ b/config/mail.php @@ -0,0 +1,118 @@ + env('MAIL_MAILER', 'log'), + + /* + |-------------------------------------------------------------------------- + | Mailer Configurations + |-------------------------------------------------------------------------- + | + | Here you may configure all of the mailers used by your application plus + | their respective settings. Several examples have been configured for + | you and you are free to add your own as your application requires. + | + | Laravel supports a variety of mail "transport" drivers that can be used + | when delivering an email. You may specify which one you're using for + | your mailers below. You may also add additional mailers if needed. + | + | Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2", + | "postmark", "resend", "log", "array", + | "failover", "roundrobin" + | + */ + + 'mailers' => [ + + 'smtp' => [ + 'transport' => 'smtp', + 'scheme' => env('MAIL_SCHEME'), + 'url' => env('MAIL_URL'), + 'host' => env('MAIL_HOST', '127.0.0.1'), + 'port' => env('MAIL_PORT', 2525), + 'username' => env('MAIL_USERNAME'), + 'password' => env('MAIL_PASSWORD'), + 'timeout' => null, + 'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)), + ], + + 'ses' => [ + 'transport' => 'ses', + ], + + 'postmark' => [ + 'transport' => 'postmark', + // 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'), + // 'client' => [ + // 'timeout' => 5, + // ], + ], + + 'resend' => [ + 'transport' => 'resend', + ], + + 'sendmail' => [ + 'transport' => 'sendmail', + 'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'), + ], + + 'log' => [ + 'transport' => 'log', + 'channel' => env('MAIL_LOG_CHANNEL'), + ], + + 'array' => [ + 'transport' => 'array', + ], + + 'failover' => [ + 'transport' => 'failover', + 'mailers' => [ + 'smtp', + 'log', + ], + 'retry_after' => 60, + ], + + 'roundrobin' => [ + 'transport' => 'roundrobin', + 'mailers' => [ + 'ses', + 'postmark', + ], + 'retry_after' => 60, + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Global "From" Address + |-------------------------------------------------------------------------- + | + | You may wish for all emails sent by your application to be sent from + | the same address. Here you may specify a name and address that is + | used globally for all emails that are sent by your application. + | + */ + + 'from' => [ + 'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'), + 'name' => env('MAIL_FROM_NAME', env('APP_NAME', 'Laravel')), + ], + +]; diff --git a/config/prism.php b/config/prism.php new file mode 100644 index 0000000..99f3ea5 --- /dev/null +++ b/config/prism.php @@ -0,0 +1,73 @@ + [ + // The middleware that will be applied to the Prism Server routes. + 'middleware' => [], + 'enabled' => env('PRISM_SERVER_ENABLED', false), + ], + 'request_timeout' => env('PRISM_REQUEST_TIMEOUT', 30), // The timeout for requests in seconds. + 'providers' => [ + 'openai' => [ + 'url' => env('OPENAI_URL', 'https://api.openai.com/v1'), + 'api_key' => env('OPENAI_API_KEY', ''), + 'organization' => env('OPENAI_ORGANIZATION', null), + 'project' => env('OPENAI_PROJECT', null), + ], + 'anthropic' => [ + 'api_key' => env('ANTHROPIC_API_KEY', ''), + 'version' => env('ANTHROPIC_API_VERSION', '2023-06-01'), + 'url' => env('ANTHROPIC_URL', 'https://api.anthropic.com/v1'), + 'default_thinking_budget' => env('ANTHROPIC_DEFAULT_THINKING_BUDGET', 1024), + // Include beta strings as a comma separated list. + 'anthropic_beta' => env('ANTHROPIC_BETA', null), + ], + 'ollama' => [ + 'url' => env('OLLAMA_URL', 'http://localhost:11434'), + ], + 'mistral' => [ + 'api_key' => env('MISTRAL_API_KEY', ''), + 'url' => env('MISTRAL_URL', 'https://api.mistral.ai/v1'), + ], + 'groq' => [ + 'api_key' => env('GROQ_API_KEY', ''), + 'url' => env('GROQ_URL', 'https://api.groq.com/openai/v1'), + ], + 'xai' => [ + 'api_key' => env('XAI_API_KEY', ''), + 'url' => env('XAI_URL', 'https://api.x.ai/v1'), + ], + 'gemini' => [ + 'api_key' => env('GEMINI_API_KEY', ''), + 'url' => env('GEMINI_URL', 'https://generativelanguage.googleapis.com/v1beta/models'), + ], + 'deepseek' => [ + 'api_key' => env('DEEPSEEK_API_KEY', ''), + 'url' => env('DEEPSEEK_URL', 'https://api.deepseek.com/v1'), + ], + 'elevenlabs' => [ + 'api_key' => env('ELEVENLABS_API_KEY', ''), + 'url' => env('ELEVENLABS_URL', 'https://api.elevenlabs.io/v1/'), + ], + 'voyageai' => [ + 'api_key' => env('VOYAGEAI_API_KEY', ''), + 'url' => env('VOYAGEAI_URL', 'https://api.voyageai.com/v1'), + ], + 'openrouter' => [ + 'api_key' => env('OPENROUTER_API_KEY', ''), + 'url' => env('OPENROUTER_URL', 'https://openrouter.ai/api/v1'), + 'site' => [ + 'http_referer' => env('OPENROUTER_SITE_HTTP_REFERER', null), + 'x_title' => env('OPENROUTER_SITE_X_TITLE', null), + ], + ], + 'perplexity' => [ + 'api_key' => env('PERPLEXITY_API_KEY', ''), + 'url' => env('PERPLEXITY_URL', 'https://api.perplexity.ai'), + ], + 'z' => [ + 'url' => env('Z_URL', 'https://api.z.ai/api/paas/v4'), + 'api_key' => env('Z_API_KEY', ''), + ], + ], +]; diff --git a/config/queue.php b/config/queue.php new file mode 100644 index 0000000..79c2c0a --- /dev/null +++ b/config/queue.php @@ -0,0 +1,129 @@ + env('QUEUE_CONNECTION', 'database'), + + /* + |-------------------------------------------------------------------------- + | Queue Connections + |-------------------------------------------------------------------------- + | + | Here you may configure the connection options for every queue backend + | used by your application. An example configuration is provided for + | each backend supported by Laravel. You're also free to add more. + | + | Drivers: "sync", "database", "beanstalkd", "sqs", "redis", + | "deferred", "background", "failover", "null" + | + */ + + 'connections' => [ + + 'sync' => [ + 'driver' => 'sync', + ], + + 'database' => [ + 'driver' => 'database', + 'connection' => env('DB_QUEUE_CONNECTION'), + 'table' => env('DB_QUEUE_TABLE', 'jobs'), + 'queue' => env('DB_QUEUE', 'default'), + 'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90), + 'after_commit' => false, + ], + + 'beanstalkd' => [ + 'driver' => 'beanstalkd', + 'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'), + 'queue' => env('BEANSTALKD_QUEUE', 'default'), + 'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 90), + 'block_for' => 0, + 'after_commit' => false, + ], + + 'sqs' => [ + 'driver' => 'sqs', + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'), + 'queue' => env('SQS_QUEUE', 'default'), + 'suffix' => env('SQS_SUFFIX'), + 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), + 'after_commit' => false, + ], + + 'redis' => [ + 'driver' => 'redis', + 'connection' => env('REDIS_QUEUE_CONNECTION', 'default'), + 'queue' => env('REDIS_QUEUE', 'default'), + 'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90), + 'block_for' => null, + 'after_commit' => false, + ], + + 'deferred' => [ + 'driver' => 'deferred', + ], + + 'background' => [ + 'driver' => 'background', + ], + + 'failover' => [ + 'driver' => 'failover', + 'connections' => [ + 'database', + 'deferred', + ], + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Job Batching + |-------------------------------------------------------------------------- + | + | The following options configure the database and table that store job + | batching information. These options can be updated to any database + | connection and table which has been defined by your application. + | + */ + + 'batching' => [ + 'database' => env('DB_CONNECTION', 'sqlite'), + 'table' => 'job_batches', + ], + + /* + |-------------------------------------------------------------------------- + | Failed Queue Jobs + |-------------------------------------------------------------------------- + | + | These options configure the behavior of failed queue job logging so you + | can control how and where failed jobs are stored. Laravel ships with + | support for storing failed jobs in a simple file or in a database. + | + | Supported drivers: "database-uuids", "dynamodb", "file", "null" + | + */ + + 'failed' => [ + 'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'), + 'database' => env('DB_CONNECTION', 'sqlite'), + 'table' => 'failed_jobs', + ], + +]; diff --git a/config/services.php b/config/services.php new file mode 100644 index 0000000..fa81871 --- /dev/null +++ b/config/services.php @@ -0,0 +1,50 @@ + [ + 'key' => env('POSTMARK_API_KEY'), + ], + + 'resend' => [ + 'key' => env('RESEND_API_KEY'), + ], + + 'ses' => [ + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), + ], + + 'slack' => [ + 'notifications' => [ + 'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'), + 'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'), + ], + ], + + 'turnstile' => [ + 'site_key' => env('TURNSTILE_SITE_KEY'), + 'secret_key' => env('TURNSTILE_SECRET_KEY'), + ], + + 'genius' => [ + 'model' => env('GENIUS_MODEL', 'gemini-2.5-flash-lite'), + 'history_limit' => (int) env('GENIUS_HISTORY_LIMIT', 10), + 'max_messages_per_session' => (int) env('GENIUS_MAX_MESSAGES_PER_SESSION', 20), + 'session_window_minutes' => (int) env('GENIUS_SESSION_WINDOW_MINUTES', 60), + ], + +]; diff --git a/config/session.php b/config/session.php new file mode 100644 index 0000000..5b541b7 --- /dev/null +++ b/config/session.php @@ -0,0 +1,217 @@ + env('SESSION_DRIVER', 'database'), + + /* + |-------------------------------------------------------------------------- + | Session Lifetime + |-------------------------------------------------------------------------- + | + | Here you may specify the number of minutes that you wish the session + | to be allowed to remain idle before it expires. If you want them + | to expire immediately when the browser is closed then you may + | indicate that via the expire_on_close configuration option. + | + */ + + 'lifetime' => (int) env('SESSION_LIFETIME', 120), + + 'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false), + + /* + |-------------------------------------------------------------------------- + | Session Encryption + |-------------------------------------------------------------------------- + | + | This option allows you to easily specify that all of your session data + | should be encrypted before it's stored. All encryption is performed + | automatically by Laravel and you may use the session like normal. + | + */ + + 'encrypt' => env('SESSION_ENCRYPT', false), + + /* + |-------------------------------------------------------------------------- + | Session File Location + |-------------------------------------------------------------------------- + | + | When utilizing the "file" session driver, the session files are placed + | on disk. The default storage location is defined here; however, you + | are free to provide another location where they should be stored. + | + */ + + 'files' => storage_path('framework/sessions'), + + /* + |-------------------------------------------------------------------------- + | Session Database Connection + |-------------------------------------------------------------------------- + | + | When using the "database" or "redis" session drivers, you may specify a + | connection that should be used to manage these sessions. This should + | correspond to a connection in your database configuration options. + | + */ + + 'connection' => env('SESSION_CONNECTION'), + + /* + |-------------------------------------------------------------------------- + | Session Database Table + |-------------------------------------------------------------------------- + | + | When using the "database" session driver, you may specify the table to + | be used to store sessions. Of course, a sensible default is defined + | for you; however, you're welcome to change this to another table. + | + */ + + 'table' => env('SESSION_TABLE', 'sessions'), + + /* + |-------------------------------------------------------------------------- + | Session Cache Store + |-------------------------------------------------------------------------- + | + | When using one of the framework's cache driven session backends, you may + | define the cache store which should be used to store the session data + | between requests. This must match one of your defined cache stores. + | + | Affects: "dynamodb", "memcached", "redis" + | + */ + + 'store' => env('SESSION_STORE'), + + /* + |-------------------------------------------------------------------------- + | Session Sweeping Lottery + |-------------------------------------------------------------------------- + | + | Some session drivers must manually sweep their storage location to get + | rid of old sessions from storage. Here are the chances that it will + | happen on a given request. By default, the odds are 2 out of 100. + | + */ + + 'lottery' => [2, 100], + + /* + |-------------------------------------------------------------------------- + | Session Cookie Name + |-------------------------------------------------------------------------- + | + | Here you may change the name of the session cookie that is created by + | the framework. Typically, you should not need to change this value + | since doing so does not grant a meaningful security improvement. + | + */ + + 'cookie' => env( + 'SESSION_COOKIE', + Str::slug((string) env('APP_NAME', 'laravel')).'-session' + ), + + /* + |-------------------------------------------------------------------------- + | Session Cookie Path + |-------------------------------------------------------------------------- + | + | The session cookie path determines the path for which the cookie will + | be regarded as available. Typically, this will be the root path of + | your application, but you're free to change this when necessary. + | + */ + + 'path' => env('SESSION_PATH', '/'), + + /* + |-------------------------------------------------------------------------- + | Session Cookie Domain + |-------------------------------------------------------------------------- + | + | This value determines the domain and subdomains the session cookie is + | available to. By default, the cookie will be available to the root + | domain without subdomains. Typically, this shouldn't be changed. + | + */ + + 'domain' => env('SESSION_DOMAIN'), + + /* + |-------------------------------------------------------------------------- + | HTTPS Only Cookies + |-------------------------------------------------------------------------- + | + | By setting this option to true, session cookies will only be sent back + | to the server if the browser has a HTTPS connection. This will keep + | the cookie from being sent to you when it can't be done securely. + | + */ + + 'secure' => env('SESSION_SECURE_COOKIE'), + + /* + |-------------------------------------------------------------------------- + | HTTP Access Only + |-------------------------------------------------------------------------- + | + | Setting this value to true will prevent JavaScript from accessing the + | value of the cookie and the cookie will only be accessible through + | the HTTP protocol. It's unlikely you should disable this option. + | + */ + + 'http_only' => env('SESSION_HTTP_ONLY', true), + + /* + |-------------------------------------------------------------------------- + | Same-Site Cookies + |-------------------------------------------------------------------------- + | + | This option determines how your cookies behave when cross-site requests + | take place, and can be used to mitigate CSRF attacks. By default, we + | will set this value to "lax" to permit secure cross-site requests. + | + | See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value + | + | Supported: "lax", "strict", "none", null + | + */ + + 'same_site' => env('SESSION_SAME_SITE', 'lax'), + + /* + |-------------------------------------------------------------------------- + | Partitioned Cookies + |-------------------------------------------------------------------------- + | + | Setting this value to true will tie the cookie to the top-level site for + | a cross-site context. Partitioned cookies are accepted by the browser + | when flagged "secure" and the Same-Site attribute is set to "none". + | + */ + + 'partitioned' => env('SESSION_PARTITIONED_COOKIE', false), + +]; diff --git a/database/.gitignore b/database/.gitignore new file mode 100644 index 0000000..9b19b93 --- /dev/null +++ b/database/.gitignore @@ -0,0 +1 @@ +*.sqlite* diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php new file mode 100644 index 0000000..c4ceb07 --- /dev/null +++ b/database/factories/UserFactory.php @@ -0,0 +1,45 @@ + + */ +class UserFactory extends Factory +{ + /** + * The current password being used by the factory. + */ + protected static ?string $password; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'name' => fake()->name(), + 'email' => fake()->unique()->safeEmail(), + 'email_verified_at' => now(), + 'password' => static::$password ??= Hash::make('password'), + 'remember_token' => Str::random(10), + ]; + } + + /** + * Indicate that the model's email address should be unverified. + */ + public function unverified(): static + { + return $this->state(fn (array $attributes) => [ + 'email_verified_at' => null, + ]); + } +} diff --git a/database/migrations/0001_01_01_000000_create_users_table.php b/database/migrations/0001_01_01_000000_create_users_table.php new file mode 100644 index 0000000..05fb5d9 --- /dev/null +++ b/database/migrations/0001_01_01_000000_create_users_table.php @@ -0,0 +1,49 @@ +id(); + $table->string('name'); + $table->string('email')->unique(); + $table->timestamp('email_verified_at')->nullable(); + $table->string('password'); + $table->rememberToken(); + $table->timestamps(); + }); + + Schema::create('password_reset_tokens', function (Blueprint $table) { + $table->string('email')->primary(); + $table->string('token'); + $table->timestamp('created_at')->nullable(); + }); + + Schema::create('sessions', function (Blueprint $table) { + $table->string('id')->primary(); + $table->foreignId('user_id')->nullable()->index(); + $table->string('ip_address', 45)->nullable(); + $table->text('user_agent')->nullable(); + $table->longText('payload'); + $table->integer('last_activity')->index(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('users'); + Schema::dropIfExists('password_reset_tokens'); + Schema::dropIfExists('sessions'); + } +}; diff --git a/database/migrations/0001_01_01_000001_create_cache_table.php b/database/migrations/0001_01_01_000001_create_cache_table.php new file mode 100644 index 0000000..ed758bd --- /dev/null +++ b/database/migrations/0001_01_01_000001_create_cache_table.php @@ -0,0 +1,35 @@ +string('key')->primary(); + $table->mediumText('value'); + $table->integer('expiration')->index(); + }); + + Schema::create('cache_locks', function (Blueprint $table) { + $table->string('key')->primary(); + $table->string('owner'); + $table->integer('expiration')->index(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('cache'); + Schema::dropIfExists('cache_locks'); + } +}; diff --git a/database/migrations/0001_01_01_000002_create_jobs_table.php b/database/migrations/0001_01_01_000002_create_jobs_table.php new file mode 100644 index 0000000..967fbf5 --- /dev/null +++ b/database/migrations/0001_01_01_000002_create_jobs_table.php @@ -0,0 +1,59 @@ +id(); + $table->string('queue'); + $table->longText('payload'); + $table->unsignedTinyInteger('attempts'); + $table->unsignedInteger('reserved_at')->nullable(); + $table->unsignedInteger('available_at'); + $table->unsignedInteger('created_at'); + + $table->index(['queue', 'reserved_at', 'available_at']); + }); + + Schema::create('job_batches', function (Blueprint $table) { + $table->string('id')->primary(); + $table->string('name'); + $table->integer('total_jobs'); + $table->integer('pending_jobs'); + $table->integer('failed_jobs'); + $table->longText('failed_job_ids'); + $table->mediumText('options')->nullable(); + $table->integer('cancelled_at')->nullable(); + $table->integer('created_at'); + $table->integer('finished_at')->nullable(); + }); + + Schema::create('failed_jobs', function (Blueprint $table) { + $table->id(); + $table->string('uuid')->unique(); + $table->text('connection'); + $table->text('queue'); + $table->longText('payload'); + $table->longText('exception'); + $table->timestamp('failed_at')->useCurrent(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('jobs'); + Schema::dropIfExists('job_batches'); + Schema::dropIfExists('failed_jobs'); + } +}; diff --git a/database/migrations/2026_03_22_000228_create_carousel_items_table.php b/database/migrations/2026_03_22_000228_create_carousel_items_table.php new file mode 100644 index 0000000..7f3b235 --- /dev/null +++ b/database/migrations/2026_03_22_000228_create_carousel_items_table.php @@ -0,0 +1,71 @@ +id(); + $table->string('titulo')->nullable(); + $table->string('subtitulo')->nullable(); + $table->string('boton_texto')->nullable(); + $table->string('boton_enlace')->nullable(); + $table->string('imagen'); + $table->integer('orden')->default(0); + $table->boolean('activo')->default(true); + $table->timestamps(); + }); + + // Insert default items to preserve the first 3 original items + DB::table('carousel_items')->insert([ + [ + 'titulo' => 'Bienvenido a OnAPB', + 'subtitulo' => 'La nueva forma de vivir el básquet en Paraná', + 'boton_texto' => 'Ver partidos', + 'boton_enlace' => '/eventos', + 'imagen' => 'hero1.jpeg', + 'orden' => 1, + 'activo' => true, + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'titulo' => 'El basquet en tus manos', + 'subtitulo' => 'Seguilo como nunca antes', + 'boton_texto' => 'Unite', + 'boton_enlace' => '/asociate', + 'imagen' => 'hero2.jpg', + 'orden' => 2, + 'activo' => true, + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'titulo' => 'Sumate a la comunidad', + 'subtitulo' => 'Asociate y disfrutá beneficios exclusivos', + 'boton_texto' => 'Lugares', + 'boton_enlace' => '/promos', + 'imagen' => 'hero3.jpg', + 'orden' => 3, + 'activo' => true, + 'created_at' => now(), + 'updated_at' => now(), + ] + ]); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('carousel_items'); + } +}; diff --git a/database/migrations/2026_03_22_011653_add_id_club_to_admin_users_table.php b/database/migrations/2026_03_22_011653_add_id_club_to_admin_users_table.php new file mode 100644 index 0000000..64671fd --- /dev/null +++ b/database/migrations/2026_03_22_011653_add_id_club_to_admin_users_table.php @@ -0,0 +1,41 @@ +integer('id_club')->nullable()->after('password'); + } else { + // Si la columna ya se creó en error en un intento anterior pero mal tipada + $table->integer('id_club')->nullable()->change(); + } + // Agregamos la constrain después de asegurarnos de su tipo + }); + + Schema::table('admin_users', function (Blueprint $table) { + // Intentar dropear llave si existe para evitar error por si acaso no va, + // pero como falló en el fk, mejor tratamos de crearla: + $table->foreign('id_club')->references('id_club')->on('clubes')->onDelete('set null'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('admin_users', function (Blueprint $table) { + $table->dropForeign(['id_club']); + $table->dropColumn('id_club'); + }); + } +}; diff --git a/database/migrations/2026_03_22_011702_create_categorias_table.php b/database/migrations/2026_03_22_011702_create_categorias_table.php new file mode 100644 index 0000000..b98de9c --- /dev/null +++ b/database/migrations/2026_03_22_011702_create_categorias_table.php @@ -0,0 +1,31 @@ +id('id_categoria'); + $table->string('nombre'); + $table->integer('edad_min'); + $table->integer('edad_max'); + $table->string('genero')->nullable(); // M, F, Mixto... + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('categorias'); + } +}; diff --git a/database/migrations/2026_03_22_011710_create_pases_table.php b/database/migrations/2026_03_22_011710_create_pases_table.php new file mode 100644 index 0000000..8061a1d --- /dev/null +++ b/database/migrations/2026_03_22_011710_create_pases_table.php @@ -0,0 +1,37 @@ +id('id_pase'); + $table->char('id_jugador', 36)->nullable(); + $table->integer('id_club_origen')->nullable(); + $table->integer('id_club_destino')->nullable(); + $table->string('estado')->default('Pendiente'); // Pendiente, Aprobado, Rechazado + + // Si la FK en jugadores es de tipo uuid, usamos char(36) + $table->foreign('id_jugador')->references('id_jugador')->on('jugadores')->onDelete('cascade'); + $table->foreign('id_club_origen')->references('id_club')->on('clubes')->onDelete('cascade'); + $table->foreign('id_club_destino')->references('id_club')->on('clubes')->onDelete('cascade'); + + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('pases'); + } +}; diff --git a/database/migrations/2026_03_22_023553_add_limite_qr_to_eventos_table.php b/database/migrations/2026_03_22_023553_add_limite_qr_to_eventos_table.php new file mode 100644 index 0000000..b7b7e03 --- /dev/null +++ b/database/migrations/2026_03_22_023553_add_limite_qr_to_eventos_table.php @@ -0,0 +1,22 @@ +integer('limite_qr_jugador')->default(3)->after('precio'); + }); + } + + public function down(): void + { + Schema::table('eventos', function (Blueprint $table) { + $table->dropColumn('limite_qr_jugador'); + }); + } +}; diff --git a/database/migrations/2026_03_22_023601_add_qr_fields_to_clubes_table.php b/database/migrations/2026_03_22_023601_add_qr_fields_to_clubes_table.php new file mode 100644 index 0000000..a4ebdf5 --- /dev/null +++ b/database/migrations/2026_03_22_023601_add_qr_fields_to_clubes_table.php @@ -0,0 +1,23 @@ +string('qr_background')->nullable(); + $table->string('qr_color_texto')->default('#000000'); + }); + } + + public function down(): void + { + Schema::table('clubes', function (Blueprint $table) { + $table->dropColumn(['qr_background', 'qr_color_texto']); + }); + } +}; diff --git a/database/migrations/2026_03_22_023610_add_es_libre_to_categorias_table.php b/database/migrations/2026_03_22_023610_add_es_libre_to_categorias_table.php new file mode 100644 index 0000000..4ba7750 --- /dev/null +++ b/database/migrations/2026_03_22_023610_add_es_libre_to_categorias_table.php @@ -0,0 +1,22 @@ +boolean('es_libre')->default(false); + }); + } + + public function down(): void + { + Schema::table('categorias', function (Blueprint $table) { + $table->dropColumn('es_libre'); + }); + } +}; diff --git a/database/migrations/2026_03_22_044322_create_configuraciones_table.php b/database/migrations/2026_03_22_044322_create_configuraciones_table.php new file mode 100644 index 0000000..d5bc4cd --- /dev/null +++ b/database/migrations/2026_03_22_044322_create_configuraciones_table.php @@ -0,0 +1,30 @@ +id(); + $table->string('clave')->unique(); + $table->text('valor')->nullable(); + $table->string('descripcion')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('configuraciones'); + } +}; diff --git a/database/migrations/2026_03_22_113000_increase_id_jugador_length.php b/database/migrations/2026_03_22_113000_increase_id_jugador_length.php new file mode 100644 index 0000000..486f208 --- /dev/null +++ b/database/migrations/2026_03_22_113000_increase_id_jugador_length.php @@ -0,0 +1,74 @@ +dropForeign('jugador_equipo_id_jugador_foreign'); + }); + } catch (\Exception $e) {} + + try { + // Drop confirmed name + DB::statement('ALTER TABLE jugador_equipo DROP FOREIGN KEY jugador_equipo_ibfk_1'); + } catch (\Exception $e) {} + + try { + DB::statement('ALTER TABLE qr_codes DROP FOREIGN KEY qr_codes_ibfk_2'); + } catch (\Exception $e) {} + + // 2. Increase the length of id_jugador in the main table + Schema::table('jugadores', function (Blueprint $table) { + $table->string('id_jugador', 20)->change(); + }); + + // 3. Increase the length in referencing tables + Schema::table('pases', function (Blueprint $table) { + $table->string('id_jugador', 20)->change(); + }); + + Schema::table('jugador_equipo', function (Blueprint $table) { + $table->string('id_jugador', 20)->change(); + }); + + Schema::table('qr_codes', function (Blueprint $table) { + $table->string('id_jugador', 20)->nullable()->change(); + }); + + // 4. Restore foreign keys (Laravel will pick names) + Schema::table('pases', function (Blueprint $table) { + // Check if column exists and is not already a FK + try { + $table->foreign('id_jugador')->references('id_jugador')->on('jugadores')->onDelete('cascade'); + } catch (\Exception $e) {} + }); + + Schema::table('jugador_equipo', function (Blueprint $table) { + $table->foreign('id_jugador')->references('id_jugador')->on('jugadores')->onDelete('cascade'); + }); + + Schema::table('qr_codes', function (Blueprint $table) { + $table->foreign('id_jugador')->references('id_jugador')->on('jugadores')->onDelete('set null'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + // No revert needed for length increase + } +}; diff --git a/database/migrations/2026_03_22_114500_add_imagen_to_clubes.php b/database/migrations/2026_03_22_114500_add_imagen_to_clubes.php new file mode 100644 index 0000000..106c8ab --- /dev/null +++ b/database/migrations/2026_03_22_114500_add_imagen_to_clubes.php @@ -0,0 +1,32 @@ +string('imagen')->nullable()->after('nombre'); + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('clubes', function (Blueprint $table) { + if (Schema::hasColumn('clubes', 'imagen')) { + $table->dropColumn('imagen'); + } + }); + } +}; diff --git a/database/migrations/2026_03_22_161609_create_sponsors_table.php b/database/migrations/2026_03_22_161609_create_sponsors_table.php new file mode 100644 index 0000000..b2b5d70 --- /dev/null +++ b/database/migrations/2026_03_22_161609_create_sponsors_table.php @@ -0,0 +1,32 @@ +id('id_sponsor'); + $blueprint->string('nombre', 100); + $blueprint->string('imagen', 255); + $blueprint->string('url', 255)->nullable(); + $blueprint->boolean('activo')->default(true); + $blueprint->integer('orden')->default(0); + $blueprint->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('sponsors'); + } +}; diff --git a/database/migrations/2026_03_24_062014_create_torneos_table.php b/database/migrations/2026_03_24_062014_create_torneos_table.php new file mode 100644 index 0000000..166366a --- /dev/null +++ b/database/migrations/2026_03_24_062014_create_torneos_table.php @@ -0,0 +1,30 @@ +id(); + $table->string('nombre'); + $table->date('fecha_inicio')->nullable(); + $table->date('fecha_fin')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('torneos'); + } +}; diff --git a/database/migrations/2026_03_24_062015_create_torneo_equipo_table.php b/database/migrations/2026_03_24_062015_create_torneo_equipo_table.php new file mode 100644 index 0000000..45b295f --- /dev/null +++ b/database/migrations/2026_03_24_062015_create_torneo_equipo_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignId('id_torneo')->constrained('torneos')->onDelete('cascade'); + $table->unsignedBigInteger('id_equipo'); + $table->foreign('id_equipo')->references('id_equipo')->on('equipos')->onDelete('cascade'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('torneo_equipo'); + } +}; diff --git a/database/migrations/2026_03_24_062016_add_id_torneo_to_eventos_table.php b/database/migrations/2026_03_24_062016_add_id_torneo_to_eventos_table.php new file mode 100644 index 0000000..bc2d7bd --- /dev/null +++ b/database/migrations/2026_03_24_062016_add_id_torneo_to_eventos_table.php @@ -0,0 +1,30 @@ +unsignedBigInteger('id_torneo')->nullable()->after('id_evento'); + $table->foreign('id_torneo')->references('id')->on('torneos')->onDelete('set null'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('eventos', function (Blueprint $table) { + $table->dropForeign(['id_torneo']); + $table->dropColumn('id_torneo'); + }); + } +}; diff --git a/database/migrations/2026_03_24_062511_add_scores_to_eventos_table.php b/database/migrations/2026_03_24_062511_add_scores_to_eventos_table.php new file mode 100644 index 0000000..8a8e89f --- /dev/null +++ b/database/migrations/2026_03_24_062511_add_scores_to_eventos_table.php @@ -0,0 +1,29 @@ +integer('marcador_local')->nullable()->default(0)->after('id_equipo_visitante'); + $col->integer('marcador_visitante')->nullable()->default(0)->after('marcador_local'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('eventos', function (Blueprint $col) { + $col->dropColumn(['marcador_local', 'marcador_visitante']); + }); + } +}; diff --git a/database/migrations/2026_03_24_062734_create_evento_jugador_table.php b/database/migrations/2026_03_24_062734_create_evento_jugador_table.php new file mode 100644 index 0000000..86cae92 --- /dev/null +++ b/database/migrations/2026_03_24_062734_create_evento_jugador_table.php @@ -0,0 +1,37 @@ +id(); + $table->string('id_evento'); + $table->string('id_jugador'); + $table->integer('puntos')->default(0); + $table->integer('faltas')->default(0); + $table->timestamps(); + + $table->foreign('id_evento')->references('id_evento')->on('eventos')->onDelete('cascade'); + $table->foreign('id_jugador')->references('id_jugador')->on('jugadores')->onDelete('cascade'); + + // Un jugador solo puede tener un registro de puntos por evento + $table->unique(['id_evento', 'id_jugador']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('evento_jugador'); + } +}; diff --git a/database/migrations/2026_03_24_065226_add_grupo_to_torneo_equipo_table.php b/database/migrations/2026_03_24_065226_add_grupo_to_torneo_equipo_table.php new file mode 100644 index 0000000..3caaefe --- /dev/null +++ b/database/migrations/2026_03_24_065226_add_grupo_to_torneo_equipo_table.php @@ -0,0 +1,28 @@ +string('grupo')->nullable()->after('id_equipo'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('torneo_equipo', function (Blueprint $table) { + $table->dropColumn('grupo'); + }); + } +}; diff --git a/database/migrations/2026_03_25_181950_add_soft_deletes_to_critical_tables.php b/database/migrations/2026_03_25_181950_add_soft_deletes_to_critical_tables.php new file mode 100644 index 0000000..51e9cd6 --- /dev/null +++ b/database/migrations/2026_03_25_181950_add_soft_deletes_to_critical_tables.php @@ -0,0 +1,52 @@ +softDeletes(); + }); + Schema::table('clubes', function (Blueprint $table) { + $table->softDeletes(); + }); + Schema::table('equipos', function (Blueprint $table) { + $table->softDeletes(); + }); + Schema::table('eventos', function (Blueprint $table) { + $table->softDeletes(); + }); + Schema::table('torneos', function (Blueprint $table) { + $table->softDeletes(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('jugadores', function (Blueprint $table) { + $table->dropSoftDeletes(); + }); + Schema::table('clubes', function (Blueprint $table) { + $table->dropSoftDeletes(); + }); + Schema::table('equipos', function (Blueprint $table) { + $table->dropSoftDeletes(); + }); + Schema::table('eventos', function (Blueprint $table) { + $table->dropSoftDeletes(); + }); + Schema::table('torneos', function (Blueprint $table) { + $table->dropSoftDeletes(); + }); + } +}; diff --git a/database/migrations/2026_03_29_000001_create_notificaciones_table.php b/database/migrations/2026_03_29_000001_create_notificaciones_table.php new file mode 100644 index 0000000..e2f3c17 --- /dev/null +++ b/database/migrations/2026_03_29_000001_create_notificaciones_table.php @@ -0,0 +1,31 @@ +id(); + $table->enum('tipo_destinatario', ['jugador', 'aficionado'])->index(); + $table->string('id_destinatario'); // jugador.id_jugador o aficionado.id_aficionado + $table->enum('tipo', ['partido', 'resultado', 'sistema', 'seguimiento'])->default('sistema'); + $table->string('titulo'); + $table->text('mensaje'); + $table->string('url_accion')->nullable(); + $table->boolean('leida')->default(false); + $table->boolean('enviada_email')->default(false); + $table->timestamp('creada_en')->useCurrent(); + + $table->index(['tipo_destinatario', 'id_destinatario', 'leida']); + }); + } + + public function down(): void + { + Schema::dropIfExists('notificaciones'); + } +}; diff --git a/database/migrations/2026_03_29_000002_create_equipo_seguimiento_table.php b/database/migrations/2026_03_29_000002_create_equipo_seguimiento_table.php new file mode 100644 index 0000000..5e49ea8 --- /dev/null +++ b/database/migrations/2026_03_29_000002_create_equipo_seguimiento_table.php @@ -0,0 +1,27 @@ +id(); + $table->unsignedInteger('id_equipo'); + $table->enum('tipo_usuario', ['jugador', 'aficionado']); + $table->string('id_usuario'); // id_jugador (string) o id_aficionado (int as string) + $table->timestamp('created_at')->useCurrent(); + + $table->unique(['id_equipo', 'tipo_usuario', 'id_usuario'], 'uq_seguimiento'); + $table->index(['tipo_usuario', 'id_usuario']); + }); + } + + public function down(): void + { + Schema::dropIfExists('equipo_seguimiento'); + } +}; diff --git a/database/migrations/2026_03_29_040005_add_phases_to_eventos_table.php b/database/migrations/2026_03_29_040005_add_phases_to_eventos_table.php new file mode 100644 index 0000000..2aa3e4e --- /dev/null +++ b/database/migrations/2026_03_29_040005_add_phases_to_eventos_table.php @@ -0,0 +1,29 @@ +integer('fase')->default(0)->after('id_torneo')->comment('0=Regular, 1=Cuartos, 2=Semis, 3=Final'); + $table->integer('numero_partido_bracket')->nullable()->after('fase'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('eventos', function (Blueprint $table) { + $table->dropColumn(['fase', 'numero_partido_bracket']); + }); + } +}; diff --git a/database/migrations/2026_03_29_091800_fix_qrcodes_and_eventos_schema.php b/database/migrations/2026_03_29_091800_fix_qrcodes_and_eventos_schema.php new file mode 100644 index 0000000..3738648 --- /dev/null +++ b/database/migrations/2026_03_29_091800_fix_qrcodes_and_eventos_schema.php @@ -0,0 +1,44 @@ + VARCHAR) + // Usamos DB::statement por compatibilidad con MariaDB/MySQL sin necesidad de dbal + DB::statement("ALTER TABLE qr_codes MODIFY tipo_qr VARCHAR(50) NOT NULL"); + + // 2. Corregir marcadores en eventos (0 -> NULL) + DB::statement("ALTER TABLE eventos MODIFY marcador_local INT NULL DEFAULT NULL"); + DB::statement("ALTER TABLE eventos MODIFY marcador_visitante INT NULL DEFAULT NULL"); + + // 3. Limpieza de datos: partidos con 0-0 que deberían ser Pendientes (NULL) + // Solo afectamos registros donde ambos son 0 para no romper resultados reales (aunque raros en básquet) + DB::table('eventos') + ->where('marcador_local', 0) + ->where('marcador_visitante', 0) + ->update([ + 'marcador_local' => null, + 'marcador_visitante' => null + ]); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + // Volver a ENUM y Default 0 + DB::statement("ALTER TABLE qr_codes MODIFY tipo_qr ENUM('invitado', 'publico') NOT NULL"); + DB::statement("ALTER TABLE eventos MODIFY marcador_local INT NOT NULL DEFAULT 0"); + DB::statement("ALTER TABLE eventos MODIFY marcador_visitante INT NOT NULL DEFAULT 0"); + } +}; diff --git a/database/migrations/2026_03_31_112246_create_push_subscriptions_table.php b/database/migrations/2026_03_31_112246_create_push_subscriptions_table.php new file mode 100644 index 0000000..e892afb --- /dev/null +++ b/database/migrations/2026_03_31_112246_create_push_subscriptions_table.php @@ -0,0 +1,34 @@ +id(); + $table->string('id_usuario'); // ID del jugador o aficionado + $table->string('tipo_usuario'); // 'jugador' o 'aficionado' + $table->text('endpoint'); // URL de suscripción del navegador + $table->string('p256dh'); // Clave pública del navegador + $table->string('auth'); // Token de autenticación del navegador + $table->timestamps(); + + $table->index(['id_usuario', 'tipo_usuario']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('push_subscriptions'); + } +}; diff --git a/database/migrations/2026_03_31_134500_add_categoria_and_id_torneo_to_noticias_table.php b/database/migrations/2026_03_31_134500_add_categoria_and_id_torneo_to_noticias_table.php new file mode 100644 index 0000000..beb8ec0 --- /dev/null +++ b/database/migrations/2026_03_31_134500_add_categoria_and_id_torneo_to_noticias_table.php @@ -0,0 +1,46 @@ +string('categoria', 50)->nullable()->after('contenido'); + } + + // Añadimos id_torneo si no existe + if (!Schema::hasColumn('noticias', 'id_torneo')) { + $table->unsignedBigInteger('id_torneo')->nullable()->after('categoria'); + // Intentamos añadir la clave foránea solo si la tabla torneos existe (por seguridad) + if (Schema::hasTable('torneos')) { + $table->foreign('id_torneo')->references('id')->on('torneos')->onDelete('set null'); + } + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('noticias', function (Blueprint $table) { + if (Schema::hasColumn('noticias', 'id_torneo')) { + $table->dropForeign(['id_torneo']); + $table->dropColumn('id_torneo'); + } + if (Schema::hasColumn('noticias', 'categoria')) { + $table->dropColumn('categoria'); + } + }); + } +}; diff --git a/database/migrations/2026_04_09_204613_create_agent_threads_table.php b/database/migrations/2026_04_09_204613_create_agent_threads_table.php new file mode 100644 index 0000000..008586e --- /dev/null +++ b/database/migrations/2026_04_09_204613_create_agent_threads_table.php @@ -0,0 +1,31 @@ +id(); + $table->string('thread_id', 36)->unique(); + $table->integer('admin_id'); + $table->json('messages'); + $table->timestamps(); + $table->timestamp('expires_at')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('agent_threads'); + } +}; diff --git a/database/migrations/2026_04_13_000000_add_es_seleccion_to_clubes_table.php b/database/migrations/2026_04_13_000000_add_es_seleccion_to_clubes_table.php new file mode 100644 index 0000000..41cf7ab --- /dev/null +++ b/database/migrations/2026_04_13_000000_add_es_seleccion_to_clubes_table.php @@ -0,0 +1,22 @@ +boolean('es_seleccion')->default(false)->after('nombre'); + }); + } + + public function down(): void + { + Schema::table('clubes', function (Blueprint $table) { + $table->dropColumn('es_seleccion'); + }); + } +}; diff --git a/database/migrations/2026_05_16_120000_add_timestamps_to_clubes_equipos_eventos.php b/database/migrations/2026_05_16_120000_add_timestamps_to_clubes_equipos_eventos.php new file mode 100644 index 0000000..6298886 --- /dev/null +++ b/database/migrations/2026_05_16_120000_add_timestamps_to_clubes_equipos_eventos.php @@ -0,0 +1,44 @@ +getTable(), 'created_at')) { + $table->timestamp('created_at')->nullable(); + } + if (!Schema::hasColumn($table->getTable(), 'updated_at')) { + $table->timestamp('updated_at')->nullable(); + } + }); + } + } + + public function down(): void + { + foreach (['clubes', 'equipos', 'eventos'] as $tabla) { + Schema::table($tabla, function (Blueprint $table) { + if (Schema::hasColumn($table->getTable(), 'updated_at')) { + $table->dropColumn('updated_at'); + } + if (Schema::hasColumn($table->getTable(), 'created_at')) { + $table->dropColumn('created_at'); + } + }); + } + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php new file mode 100644 index 0000000..6b901f8 --- /dev/null +++ b/database/seeders/DatabaseSeeder.php @@ -0,0 +1,25 @@ +create(); + + User::factory()->create([ + 'name' => 'Test User', + 'email' => 'test@example.com', + ]); + } +} diff --git a/docs/superpowers/plans/2026-04-09-genius-agent.md b/docs/superpowers/plans/2026-04-09-genius-agent.md new file mode 100644 index 0000000..78eb978 --- /dev/null +++ b/docs/superpowers/plans/2026-04-09-genius-agent.md @@ -0,0 +1,1610 @@ +# OnAPB Genius Agent — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Agregar un agente de IA conversacional (Prism PHP + Gemini 1.5 Flash) con tools de function-calling para admins y asistente de navegación para usuarios públicos. + +**Architecture:** `GeniusAgentService` orquesta las llamadas a Prism con lista de tools dinámica (vacía para público, 5 tools para admin). Conversaciones de admin persisten en MySQL `agent_threads`; conversaciones públicas viven en sesión PHP. Sin streaming — respuesta JSON completa para compatibilidad con Hostinger. + +**Tech Stack:** PHP 8.2, Laravel 12, `prism-php/prism`, Google Gemini 1.5 Flash, MySQL, Bootstrap 5, vanilla JS. + +--- + +## Mapa de Archivos + +### Creados +| Archivo | Responsabilidad | +|---|---| +| `app/AI/Tools/ListarEquiposTool.php` | Invokable: consulta equipos con filtro torneo/grupo | +| `app/AI/Tools/ListarEventosTool.php` | Invokable: consulta partidos con filtro fecha/torneo | +| `app/AI/Tools/CrearPartidoTool.php` | Invokable: inserta un Evento en BD | +| `app/AI/Tools/CargarPuntajeTool.php` | Invokable: actualiza marcadores de un Evento | +| `app/AI/Tools/RedactarNoticiaTool.php` | Invokable: inserta una Noticia en BD | +| `app/AI/Prompts/SystemPromptAdmin.php` | Retorna string del system prompt para admins | +| `app/AI/Prompts/SystemPromptPublic.php` | Retorna string del system prompt público + manual RAG | +| `app/Services/GeniusAgentService.php` | Orquesta Prism: selecciona modo, ejecuta, persiste historial | +| `app/Http/Controllers/GeniusAgentController.php` | Recibe POST /agent/chat, valida, llama service, retorna JSON | +| `app/Models/AgentThread.php` | Eloquent model para historial admin | +| `app/Console/Commands/PurgeAgentThreads.php` | Artisan command: elimina threads expirados | +| `database/migrations/xxxx_create_agent_threads_table.php` | Tabla agent_threads | +| `resources/views/components/genius-chat.blade.php` | Widget chat bubble (Bootstrap + vanilla JS) | + +### Modificados +| Archivo | Cambio | +|---|---| +| `.env` / `.env.example` | Agregar `GEMINI_API_KEY` | +| `routes/web.php` | `POST /agent/chat` con throttle | +| `routes/console.php` | Schedule daily purge | +| `resources/views/layouts/app.blade.php` | Include del componente chat | + +### Tests +| Archivo | Qué prueba | +|---|---| +| `tests/Unit/AI/Tools/ListarEquiposToolTest.php` | Filtrado por torneo y grupo | +| `tests/Unit/AI/Tools/ListarEventosToolTest.php` | Filtrado por fecha e id_torneo | +| `tests/Unit/AI/Tools/CrearPartidoToolTest.php` | Inserta Evento correctamente | +| `tests/Unit/AI/Tools/CargarPuntajeToolTest.php` | Actualiza marcadores; falla gracefully si no existe | +| `tests/Unit/AI/Tools/RedactarNoticiaToolTest.php` | Inserta Noticia correctamente | +| `tests/Feature/GeniusAgentControllerTest.php` | HTTP layer: validación, throttle, error handling | + +--- + +## Tarea 1: Instalar Prism PHP y configurar Gemini + +**Archivos:** +- Modify: `composer.json` (via composer require) +- Modify: `.env` +- Modify: `.env.example` + +- [ ] **Paso 1: Instalar el paquete** + +```bash +composer require prism-php/prism +``` + +Resultado esperado: `prism-php/prism` aparece en `composer.json` bajo `require`. + +- [ ] **Paso 2: Publicar config de Prism** + +```bash +php artisan vendor:publish --provider="EchoLabs\Prism\PrismServiceProvider" --tag="prism-config" +``` + +Resultado esperado: archivo `config/prism.php` creado. + +- [ ] **Paso 3: Agregar API key en `.env`** + +Agregar al final de `.env`: +```env +GEMINI_API_KEY=tu_api_key_de_google_ai_studio +``` + +Agregar al final de `.env.example`: +```env +GEMINI_API_KEY= +``` + +- [ ] **Paso 4: Verificar que config/prism.php tiene la sección de Gemini** + +Abrir `config/prism.php` y confirmar que existe algo similar a: +```php +'gemini' => [ + 'api_key' => env('GEMINI_API_KEY', ''), +], +``` + +Si no existe, agregar ese bloque dentro de `'providers'`. + +- [ ] **Paso 5: Limpiar config cache** + +```bash +php artisan config:clear +``` + +- [ ] **Paso 6: Commit** + +```bash +git add composer.json composer.lock config/prism.php .env.example +git commit -m "chore: install prism-php/prism and configure Gemini provider" +``` + +--- + +## Tarea 2: Migration y Modelo AgentThread + +**Archivos:** +- Create: `database/migrations/xxxx_create_agent_threads_table.php` +- Create: `app/Models/AgentThread.php` + +- [ ] **Paso 1: Crear la migration** + +```bash +php artisan make:migration create_agent_threads_table +``` + +Abrir el archivo generado y reemplazar el contenido del método `up()`: + +```php +public function up(): void +{ + Schema::create('agent_threads', function (Blueprint $table) { + $table->id(); + $table->string('thread_id', 36)->unique(); + $table->integer('admin_id'); + $table->json('messages'); + $table->timestamps(); + $table->timestamp('expires_at'); + }); +} + +public function down(): void +{ + Schema::dropIfExists('agent_threads'); +} +``` + +- [ ] **Paso 2: Crear el modelo `app/Models/AgentThread.php`** + +```php + 'array', + 'expires_at' => 'datetime', + ]; + + public static function findOrCreateForAdmin(?string $threadId, int $adminId): static + { + if ($threadId) { + $thread = static::where('thread_id', $threadId) + ->where('admin_id', $adminId) + ->first(); + if ($thread) { + return $thread; + } + } + + return static::create([ + 'thread_id' => (string) Str::uuid(), + 'admin_id' => $adminId, + 'messages' => [], + 'expires_at' => now()->addDays(30), + ]); + } +} +``` + +- [ ] **Paso 3: Ejecutar la migration** + +```bash +php artisan migrate +``` + +Resultado esperado: `Migrating: xxxx_create_agent_threads_table` → `Migrated`. + +- [ ] **Paso 4: Verificar el modelo con tinker** + +```bash +php artisan tinker --execute="App\Models\AgentThread::create(['thread_id'=>'test-uuid','admin_id'=>1,'messages'=>[],'expires_at'=>now()->addDays(30)]); echo App\Models\AgentThread::count();" +``` + +Resultado esperado: `1` + +- [ ] **Paso 5: Commit** + +```bash +git add database/migrations/ app/Models/AgentThread.php +git commit -m "feat: add agent_threads migration and AgentThread model" +``` + +--- + +## Tarea 3: Tools de solo lectura — ListarEquipos y ListarEventos + +**Archivos:** +- Create: `app/AI/Tools/ListarEquiposTool.php` +- Create: `app/AI/Tools/ListarEventosTool.php` +- Create: `tests/Unit/AI/Tools/ListarEquiposToolTest.php` +- Create: `tests/Unit/AI/Tools/ListarEventosToolTest.php` + +- [ ] **Paso 1: Crear directorios** + +```bash +mkdir -p app/AI/Tools app/AI/Prompts +mkdir -p tests/Unit/AI/Tools +``` + +- [ ] **Paso 2: Escribir el test de ListarEquiposTool** + +Crear `tests/Unit/AI/Tools/ListarEquiposToolTest.php`: + +```php + 1, 'nombre' => 'Club Test']); + Equipo::create(['id_club' => 1, 'categoria' => 'Primera', 'division' => 'A']); + + $tool = new ListarEquiposTool(); + $result = json_decode($tool(), true); + + $this->assertCount(1, $result); + $this->assertEquals('Primera', $result[0]['categoria']); + } + + public function test_filtra_por_torneo_y_grupo(): void + { + Club::create(['id_club' => 1, 'nombre' => 'Club A']); + Club::create(['id_club' => 2, 'nombre' => 'Club B']); + $eq1 = Equipo::create(['id_club' => 1, 'categoria' => 'Primera', 'division' => 'A']); + $eq2 = Equipo::create(['id_club' => 2, 'categoria' => 'Primera', 'division' => 'A']); + $torneo = Torneo::create(['nombre' => 'Torneo 2025', 'fecha_inicio' => now(), 'fecha_fin' => now()->addMonths(3)]); + + \DB::table('torneo_equipo')->insert([ + ['id_torneo' => $torneo->id, 'id_equipo' => $eq1->id_equipo, 'grupo' => 'A'], + ['id_torneo' => $torneo->id, 'id_equipo' => $eq2->id_equipo, 'grupo' => 'B'], + ]); + + $tool = new ListarEquiposTool(); + $result = json_decode($tool(id_torneo: $torneo->id, grupo: 'A'), true); + + $this->assertCount(1, $result); + $this->assertEquals($eq1->id_equipo, $result[0]['id_equipo']); + } +} +``` + +- [ ] **Paso 3: Ejecutar el test (debe fallar)** + +```bash +php artisan test tests/Unit/AI/Tools/ListarEquiposToolTest.php +``` + +Resultado esperado: `FAIL` — clase `ListarEquiposTool` no existe. + +- [ ] **Paso 4: Implementar `app/AI/Tools/ListarEquiposTool.php`** + +```php +join('torneo_equipo', 'equipos.id_equipo', '=', 'torneo_equipo.id_equipo') + ->where('torneo_equipo.id_torneo', $id_torneo); + + if ($grupo !== null) { + $query->where('torneo_equipo.grupo', $grupo); + } + + $query->select('equipos.id_equipo', 'equipos.categoria', 'equipos.division', 'equipos.id_club'); + } + + $equipos = $query->get()->map(fn($e) => [ + 'id_equipo' => $e->id_equipo, + 'categoria' => $e->categoria, + 'division' => $e->division, + 'club' => $e->club?->nombre, + ]); + + return json_encode($equipos); + } +} +``` + +- [ ] **Paso 5: Ejecutar el test (debe pasar)** + +```bash +php artisan test tests/Unit/AI/Tools/ListarEquiposToolTest.php +``` + +Resultado esperado: `PASS` — 2 tests, 0 fallos. + +- [ ] **Paso 6: Escribir el test de ListarEventosTool** + +Crear `tests/Unit/AI/Tools/ListarEventosToolTest.php`: + +```php + 1], ['nombre' => 'Club A']); + Club::firstOrCreate(['id_club' => 2], ['nombre' => 'Club B']); + $eq1 = Equipo::firstOrCreate(['id_club' => 1, 'categoria' => 'Primera', 'division' => 'A']); + $eq2 = Equipo::firstOrCreate(['id_club' => 2, 'categoria' => 'Primera', 'division' => 'A']); + $torneo = Torneo::firstOrCreate(['nombre' => 'Torneo Test'], ['fecha_inicio' => now(), 'fecha_fin' => now()->addMonths(3)]); + + return Evento::create(array_merge([ + 'id_evento' => (string) Str::uuid(), + 'id_equipo_local' => $eq1->id_equipo, + 'id_equipo_visitante' => $eq2->id_equipo, + 'fecha_evento' => '2025-06-15', + 'hora_inicio' => '20:00:00', + 'hora_fin' => '22:00:00', + 'sede' => 'Estadio Test', + 'id_torneo' => $torneo->id, + 'precio' => 0, + 'fase' => 0, + ], $overrides)); + } + + public function test_retorna_eventos_sin_filtro(): void + { + $this->crearEvento(); + + $tool = new ListarEventosTool(); + $result = json_decode($tool(), true); + + $this->assertCount(1, $result); + $this->assertEquals('2025-06-15', $result[0]['fecha']); + } + + public function test_filtra_por_rango_de_fechas(): void + { + $this->crearEvento(['fecha_evento' => '2025-06-01', 'id_evento' => (string) Str::uuid()]); + $this->crearEvento(['fecha_evento' => '2025-07-01', 'id_evento' => (string) Str::uuid()]); + + $tool = new ListarEventosTool(); + $result = json_decode($tool(fecha_desde: '2025-07-01', fecha_hasta: '2025-07-31'), true); + + $this->assertCount(1, $result); + $this->assertEquals('2025-07-01', $result[0]['fecha']); + } +} +``` + +- [ ] **Paso 7: Ejecutar el test (debe fallar)** + +```bash +php artisan test tests/Unit/AI/Tools/ListarEventosToolTest.php +``` + +Resultado esperado: `FAIL` — clase `ListarEventosTool` no existe. + +- [ ] **Paso 8: Implementar `app/AI/Tools/ListarEventosTool.php`** + +```php +whereNull('deleted_at'); + + if ($fecha_desde) { + $query->whereDate('fecha_evento', '>=', $fecha_desde); + } + if ($fecha_hasta) { + $query->whereDate('fecha_evento', '<=', $fecha_hasta); + } + if ($id_torneo) { + $query->where('id_torneo', $id_torneo); + } + + $eventos = $query->orderBy('fecha_evento')->get()->map(fn($e) => [ + 'id_evento' => $e->id_evento, + 'fecha' => $e->fecha_evento?->format('Y-m-d'), + 'hora' => $e->hora_inicio?->format('H:i'), + 'local' => $e->equipoLocal?->club?->nombre, + 'visitante' => $e->equipoVisitante?->club?->nombre, + 'marcador_local' => $e->marcador_local, + 'marcador_visitante' => $e->marcador_visitante, + 'sede' => $e->sede, + ]); + + return json_encode($eventos); + } +} +``` + +- [ ] **Paso 9: Ejecutar ambos tests** + +```bash +php artisan test tests/Unit/AI/Tools/ListarEquiposToolTest.php tests/Unit/AI/Tools/ListarEventosToolTest.php +``` + +Resultado esperado: `PASS` — 4 tests, 0 fallos. + +- [ ] **Paso 10: Commit** + +```bash +git add app/AI/Tools/ListarEquiposTool.php app/AI/Tools/ListarEventosTool.php \ + tests/Unit/AI/Tools/ListarEquiposToolTest.php tests/Unit/AI/Tools/ListarEventosToolTest.php +git commit -m "feat: add ListarEquipos and ListarEventos read-only tools" +``` + +--- + +## Tarea 4: Tool CrearPartido + +**Archivos:** +- Create: `app/AI/Tools/CrearPartidoTool.php` +- Create: `tests/Unit/AI/Tools/CrearPartidoToolTest.php` + +- [ ] **Paso 1: Escribir el test** + +Crear `tests/Unit/AI/Tools/CrearPartidoToolTest.php`: + +```php + 1, 'nombre' => 'Club A']); + Club::create(['id_club' => 2, 'nombre' => 'Club B']); + $eq1 = Equipo::create(['id_club' => 1, 'categoria' => 'Primera', 'division' => 'A']); + $eq2 = Equipo::create(['id_club' => 2, 'categoria' => 'Primera', 'division' => 'A']); + $torneo = Torneo::create(['nombre' => 'Torneo', 'fecha_inicio' => now(), 'fecha_fin' => now()->addMonths(3)]); + + $tool = new CrearPartidoTool(); + $result = json_decode($tool( + id_equipo_local: $eq1->id_equipo, + id_equipo_visitante: $eq2->id_equipo, + fecha_evento: '2025-08-10', + hora_inicio: '20:00', + hora_fin: '22:00', + sede: 'Estadio Municipal', + id_torneo: $torneo->id + ), true); + + $this->assertTrue($result['success']); + $this->assertDatabaseHas('eventos', [ + 'sede' => 'Estadio Municipal', + 'id_equipo_local' => $eq1->id_equipo, + ]); + } +} +``` + +- [ ] **Paso 2: Ejecutar el test (debe fallar)** + +```bash +php artisan test tests/Unit/AI/Tools/CrearPartidoToolTest.php +``` + +Resultado esperado: `FAIL` + +- [ ] **Paso 3: Implementar `app/AI/Tools/CrearPartidoTool.php`** + +```php + (string) Str::uuid(), + 'id_equipo_local' => $id_equipo_local, + 'id_equipo_visitante' => $id_equipo_visitante, + 'fecha_evento' => $fecha_evento, + 'hora_inicio' => $hora_inicio . ':00', + 'hora_fin' => $hora_fin . ':00', + 'sede' => $sede, + 'id_torneo' => $id_torneo, + 'precio' => $precio ?? 0, + 'fase' => Evento::FASE_REGULAR, + ]); + + return json_encode([ + 'success' => true, + 'id_evento' => $evento->id_evento, + 'mensaje' => "Partido creado correctamente. ID: {$evento->id_evento}", + ]); + } +} +``` + +- [ ] **Paso 4: Ejecutar el test (debe pasar)** + +```bash +php artisan test tests/Unit/AI/Tools/CrearPartidoToolTest.php +``` + +Resultado esperado: `PASS` + +- [ ] **Paso 5: Commit** + +```bash +git add app/AI/Tools/CrearPartidoTool.php tests/Unit/AI/Tools/CrearPartidoToolTest.php +git commit -m "feat: add CrearPartidoTool" +``` + +--- + +## Tarea 5: Tool CargarPuntaje + +**Archivos:** +- Create: `app/AI/Tools/CargarPuntajeTool.php` +- Create: `tests/Unit/AI/Tools/CargarPuntajeToolTest.php` + +- [ ] **Paso 1: Escribir el test** + +Crear `tests/Unit/AI/Tools/CargarPuntajeToolTest.php`: + +```php + 1, 'nombre' => 'Club A']); + Club::create(['id_club' => 2, 'nombre' => 'Club B']); + $eq1 = Equipo::create(['id_club' => 1, 'categoria' => 'Primera', 'division' => 'A']); + $eq2 = Equipo::create(['id_club' => 2, 'categoria' => 'Primera', 'division' => 'A']); + $torneo = Torneo::create(['nombre' => 'Torneo', 'fecha_inicio' => now(), 'fecha_fin' => now()->addMonths(3)]); + + return Evento::create([ + 'id_evento' => (string) Str::uuid(), + 'id_equipo_local' => $eq1->id_equipo, + 'id_equipo_visitante' => $eq2->id_equipo, + 'fecha_evento' => '2025-08-10', + 'hora_inicio' => '20:00:00', + 'hora_fin' => '22:00:00', + 'sede' => 'Estadio', + 'id_torneo' => $torneo->id, + 'precio' => 0, + 'fase' => 0, + ]); + } + + public function test_actualiza_puntaje_correctamente(): void + { + $evento = $this->crearEvento(); + + $tool = new CargarPuntajeTool(); + $result = json_decode($tool( + id_evento: $evento->id_evento, + marcador_local: 85, + marcador_visitante: 72 + ), true); + + $this->assertTrue($result['success']); + $this->assertDatabaseHas('eventos', [ + 'id_evento' => $evento->id_evento, + 'marcador_local' => 85, + 'marcador_visitante' => 72, + ]); + } + + public function test_retorna_error_si_evento_no_existe(): void + { + $tool = new CargarPuntajeTool(); + $result = json_decode($tool( + id_evento: 'uuid-inexistente', + marcador_local: 10, + marcador_visitante: 20 + ), true); + + $this->assertFalse($result['success']); + $this->assertStringContainsString('no encontrado', $result['error']); + } +} +``` + +- [ ] **Paso 2: Ejecutar el test (debe fallar)** + +```bash +php artisan test tests/Unit/AI/Tools/CargarPuntajeToolTest.php +``` + +Resultado esperado: `FAIL` + +- [ ] **Paso 3: Implementar `app/AI/Tools/CargarPuntajeTool.php`** + +```php + false, + 'error' => "Evento '{$id_evento}' no encontrado.", + ]); + } + + $evento->update([ + 'marcador_local' => $marcador_local, + 'marcador_visitante' => $marcador_visitante, + ]); + + return json_encode([ + 'success' => true, + 'mensaje' => "Puntaje cargado: {$marcador_local} - {$marcador_visitante}", + ]); + } +} +``` + +- [ ] **Paso 4: Ejecutar el test (debe pasar)** + +```bash +php artisan test tests/Unit/AI/Tools/CargarPuntajeToolTest.php +``` + +Resultado esperado: `PASS` — 2 tests. + +- [ ] **Paso 5: Commit** + +```bash +git add app/AI/Tools/CargarPuntajeTool.php tests/Unit/AI/Tools/CargarPuntajeToolTest.php +git commit -m "feat: add CargarPuntajeTool" +``` + +--- + +## Tarea 6: Tool RedactarNoticia + +**Archivos:** +- Create: `app/AI/Tools/RedactarNoticiaTool.php` +- Create: `tests/Unit/AI/Tools/RedactarNoticiaToolTest.php` + +- [ ] **Paso 1: Escribir el test** + +Crear `tests/Unit/AI/Tools/RedactarNoticiaToolTest.php`: + +```php +assertTrue($result['success']); + $this->assertDatabaseHas('noticias', ['titulo' => 'Gran partido en la final']); + } + + public function test_crea_noticia_con_torneo_y_categoria(): void + { + $tool = new RedactarNoticiaTool(); + $result = json_decode($tool( + titulo: 'Resumen jornada', + contenido: 'Resumen de la jornada 5.', + id_torneo: 1, + categoria: 'resultados' + ), true); + + $this->assertTrue($result['success']); + $this->assertDatabaseHas('noticias', [ + 'titulo' => 'Resumen jornada', + 'categoria' => 'resultados', + ]); + } +} +``` + +- [ ] **Paso 2: Ejecutar el test (debe fallar)** + +```bash +php artisan test tests/Unit/AI/Tools/RedactarNoticiaToolTest.php +``` + +Resultado esperado: `FAIL` + +- [ ] **Paso 3: Implementar `app/AI/Tools/RedactarNoticiaTool.php`** + +```php + $titulo, + 'contenido' => $contenido, + 'fecha' => now(), + 'id_torneo' => $id_torneo, + 'categoria' => $categoria, + ]); + + return json_encode([ + 'success' => true, + 'id' => $noticia->id, + 'mensaje' => "Noticia creada: {$noticia->titulo}", + ]); + } +} +``` + +- [ ] **Paso 4: Ejecutar el test (debe pasar)** + +```bash +php artisan test tests/Unit/AI/Tools/RedactarNoticiaToolTest.php +``` + +Resultado esperado: `PASS` — 2 tests. + +- [ ] **Paso 5: Ejecutar todos los tests de tools** + +```bash +php artisan test tests/Unit/AI/Tools/ +``` + +Resultado esperado: `PASS` — 9 tests, 0 fallos. + +- [ ] **Paso 6: Commit** + +```bash +git add app/AI/Tools/RedactarNoticiaTool.php tests/Unit/AI/Tools/RedactarNoticiaToolTest.php +git commit -m "feat: add RedactarNoticiaTool — all 5 tools complete" +``` + +--- + +## Tarea 7: System Prompts + +**Archivos:** +- Create: `app/AI/Prompts/SystemPromptAdmin.php` +- Create: `app/AI/Prompts/SystemPromptPublic.php` + +- [ ] **Paso 1: Crear `app/AI/Prompts/SystemPromptAdmin.php`** + +```php +loadManual(); + + return <<chatAdmin($message, $threadId); + } + + return $this->chatPublic($message); + } + + private function chatPublic(string $message): array + { + $history = session('agent_messages', []); + $messages = $this->hydrate($history); + $messages[] = new UserMessage($message); + + $response = Prism::text() + ->using(Provider::Gemini, 'gemini-1.5-flash') + ->withSystemPrompt((new SystemPromptPublic())->build()) + ->withMessages($messages) + ->generate(); + + $reply = $response->text; + + $history[] = ['role' => 'user', 'content' => $message]; + $history[] = ['role' => 'assistant', 'content' => $reply]; + session(['agent_messages' => $history]); + + return ['reply' => $reply]; + } + + private function chatAdmin(string $message, ?string $threadId): array + { + $adminId = (int) session('admin_id', 0); + $thread = AgentThread::findOrCreateForAdmin($threadId, $adminId); + + $messages = $this->hydrate($thread->messages ?? []); + $messages[] = new UserMessage($message); + + $response = Prism::text() + ->using(Provider::Gemini, 'gemini-1.5-flash') + ->withSystemPrompt((new SystemPromptAdmin())->build()) + ->withMessages($messages) + ->withTools($this->buildAdminTools()) + ->generate(); + + $reply = $response->text; + + $stored = $thread->messages ?? []; + $stored[] = ['role' => 'user', 'content' => $message]; + $stored[] = ['role' => 'assistant', 'content' => $reply]; + $thread->messages = $stored; + $thread->save(); + + return ['reply' => $reply, 'thread_id' => $thread->thread_id]; + } + + private function hydrate(array $stored): array + { + return collect($stored)->map(fn($m) => $m['role'] === 'user' + ? new UserMessage($m['content']) + : new AssistantMessage($m['content']) + )->all(); + } + + private function buildAdminTools(): array + { + return [ + Tool::as('listar_equipos') + ->for('Lista los equipos. Filtrá por id_torneo y/o grupo para obtener los IDs correctos antes de crear partidos.') + ->withNumberParameter('id_torneo', 'ID del torneo (opcional)', false) + ->withStringParameter('grupo', 'Nombre del grupo dentro del torneo (opcional)', false) + ->using(new ListarEquiposTool()), + + Tool::as('listar_eventos') + ->for('Lista los partidos. Filtrá por rango de fechas (Y-m-d) o id_torneo para obtener los IDs antes de cargar puntajes.') + ->withStringParameter('fecha_desde', 'Fecha desde en formato Y-m-d (opcional)', false) + ->withStringParameter('fecha_hasta', 'Fecha hasta en formato Y-m-d (opcional)', false) + ->withNumberParameter('id_torneo', 'ID del torneo (opcional)', false) + ->using(new ListarEventosTool()), + + Tool::as('crear_partido') + ->for('Crea un nuevo partido en el sistema. Podés llamar esta tool múltiples veces para crear varios partidos.') + ->withNumberParameter('id_equipo_local', 'ID del equipo local') + ->withNumberParameter('id_equipo_visitante', 'ID del equipo visitante') + ->withStringParameter('fecha_evento', 'Fecha del partido en formato Y-m-d') + ->withStringParameter('hora_inicio', 'Hora de inicio en formato H:i (ej: 20:00)') + ->withStringParameter('hora_fin', 'Hora de fin en formato H:i (ej: 22:00)') + ->withStringParameter('sede', 'Lugar donde se juega el partido') + ->withNumberParameter('id_torneo', 'ID del torneo al que pertenece el partido') + ->withNumberParameter('precio', 'Precio de la entrada en pesos (0 si es gratis)', false) + ->using(new CrearPartidoTool()), + + Tool::as('cargar_puntaje') + ->for('Carga o actualiza el puntaje de un partido existente. Podés llamar esta tool múltiples veces para cargar puntajes de varios partidos.') + ->withStringParameter('id_evento', 'ID del evento (UUID — usá listar_eventos para obtenerlo)') + ->withNumberParameter('marcador_local', 'Puntos del equipo local') + ->withNumberParameter('marcador_visitante', 'Puntos del equipo visitante') + ->using(new CargarPuntajeTool()), + + Tool::as('redactar_noticia') + ->for('Crea una noticia en el sistema. El contenido puede ser HTML o texto plano.') + ->withStringParameter('titulo', 'Título de la noticia') + ->withStringParameter('contenido', 'Contenido completo de la noticia') + ->withNumberParameter('id_torneo', 'ID del torneo relacionado (opcional)', false) + ->withStringParameter('categoria', 'Categoría de la noticia (opcional)', false) + ->using(new RedactarNoticiaTool()), + ]; + } +} +``` + +- [ ] **Paso 2: Verificar que no hay errores de sintaxis** + +```bash +php artisan tinker --execute="new App\Services\GeniusAgentService(); echo 'OK';" +``` + +Resultado esperado: `OK` + +- [ ] **Paso 3: Commit** + +```bash +git add app/Services/GeniusAgentService.php +git commit -m "feat: add GeniusAgentService with public/admin chat modes" +``` + +--- + +## Tarea 9: GeniusAgentController y Route + +**Archivos:** +- Create: `app/Http/Controllers/GeniusAgentController.php` +- Create: `tests/Feature/GeniusAgentControllerTest.php` +- Modify: `routes/web.php` + +- [ ] **Paso 1: Escribir el test del controller** + +Crear `tests/Feature/GeniusAgentControllerTest.php`: + +```php +postJson('/agent/chat', ['message' => '']); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['message']); + } + + public function test_rechaza_mensaje_demasiado_largo(): void + { + $response = $this->postJson('/agent/chat', ['message' => str_repeat('a', 1001)]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['message']); + } + + public function test_usuario_publico_recibe_respuesta(): void + { + $this->mock(GeniusAgentService::class, function ($mock) { + $mock->shouldReceive('chat') + ->once() + ->with('Hola', false, null) + ->andReturn(['reply' => 'Hola desde el agente']); + }); + + $response = $this->postJson('/agent/chat', ['message' => 'Hola']); + + $response->assertStatus(200) + ->assertJson(['reply' => 'Hola desde el agente']); + } + + public function test_admin_recibe_respuesta_con_thread_id(): void + { + $this->mock(GeniusAgentService::class, function ($mock) { + $mock->shouldReceive('chat') + ->once() + ->andReturn(['reply' => 'Listo', 'thread_id' => 'test-uuid']); + }); + + $response = $this->withSession(['admin_logged_in' => true]) + ->postJson('/agent/chat', ['message' => 'Crear partido']); + + $response->assertStatus(200) + ->assertJsonStructure(['reply', 'thread_id']); + } + + public function test_retorna_error_generico_si_el_servicio_falla(): void + { + $this->mock(GeniusAgentService::class, function ($mock) { + $mock->shouldReceive('chat') + ->once() + ->andThrow(new \Exception('API timeout')); + }); + + $response = $this->postJson('/agent/chat', ['message' => 'Hola']); + + $response->assertStatus(500) + ->assertJson(['error' => 'El agente no responde, reintentá en un momento.']); + } +} +``` + +- [ ] **Paso 2: Ejecutar el test (debe fallar)** + +```bash +php artisan test tests/Feature/GeniusAgentControllerTest.php +``` + +Resultado esperado: `FAIL` — ruta no existe. + +- [ ] **Paso 3: Crear el controller `app/Http/Controllers/GeniusAgentController.php`** + +```php +validate([ + 'message' => 'required|string|max:1000', + 'thread_id' => 'nullable|string|max:36', + ]); + + $isAdmin = (bool) session('admin_logged_in'); + + try { + $result = $this->service->chat( + $data['message'], + $isAdmin, + $data['thread_id'] ?? null + ); + + return response()->json($result); + } catch (\Throwable $e) { + \Log::error('GeniusAgent error: ' . $e->getMessage()); + + return response()->json( + ['error' => 'El agente no responde, reintentá en un momento.'], + 500 + ); + } + } +} +``` + +- [ ] **Paso 4: Agregar la ruta en `routes/web.php`** + +Al final de las importaciones en `routes/web.php`, agregar: +```php +use App\Http\Controllers\GeniusAgentController; +``` + +Al final del archivo, agregar: +```php +Route::post('/agent/chat', [GeniusAgentController::class, 'chat']) + ->name('agent.chat') + ->middleware('throttle:20,1'); +``` + +- [ ] **Paso 5: Ejecutar el test (debe pasar)** + +```bash +php artisan test tests/Feature/GeniusAgentControllerTest.php +``` + +Resultado esperado: `PASS` — 4 tests. + +- [ ] **Paso 6: Commit** + +```bash +git add app/Http/Controllers/GeniusAgentController.php \ + tests/Feature/GeniusAgentControllerTest.php \ + routes/web.php +git commit -m "feat: add GeniusAgentController and POST /agent/chat route" +``` + +--- + +## Tarea 10: Comando PurgeAgentThreads + +**Archivos:** +- Create: `app/Console/Commands/PurgeAgentThreads.php` +- Modify: `routes/console.php` + +- [ ] **Paso 1: Crear el command** + +```bash +php artisan make:command PurgeAgentThreads +``` + +Abrir `app/Console/Commands/PurgeAgentThreads.php` y reemplazar con: + +```php +delete(); + $this->info("Eliminados {$count} threads expirados."); + + return self::SUCCESS; + } +} +``` + +- [ ] **Paso 2: Registrar en el schedule (`routes/console.php`)** + +Abrir `routes/console.php` y agregar al final: + +```php +use Illuminate\Support\Facades\Schedule; + +Schedule::command('agent:purge-threads')->daily(); +``` + +Si `Schedule::` ya está importado, omitir el `use`. + +- [ ] **Paso 3: Verificar que el command funciona** + +```bash +php artisan agent:purge-threads +``` + +Resultado esperado: `Eliminados 0 threads expirados.` + +- [ ] **Paso 4: Commit** + +```bash +git add app/Console/Commands/PurgeAgentThreads.php routes/console.php +git commit -m "feat: add agent:purge-threads command with daily schedule" +``` + +--- + +## Tarea 11: Chat Bubble — Frontend y Wiring + +**Archivos:** +- Create: `resources/views/components/genius-chat.blade.php` +- Modify: `resources/views/layouts/app.blade.php` + +- [ ] **Paso 1: Crear `resources/views/components/genius-chat.blade.php`** + +```html +
+ + {{-- Botón flotante --}} + + + {{-- Panel del chat --}} + +
+ + +``` + +- [ ] **Paso 2: Incluir el componente en `resources/views/layouts/app.blade.php`** + +Buscar la línea `` al final del archivo y agregar el include justo antes: + +```html + @include('components.genius-chat') + +``` + +Si ya hay scripts de Bootstrap u otros justo antes de ``, agregar el include después de ellos pero antes de ``. + +- [ ] **Paso 3: Agregar CSRF meta tag si no existe** + +En `resources/views/layouts/app.blade.php`, dentro de ``, verificar que existe: +```html + +``` + +Si no existe, agregarlo debajo de ``. + +- [ ] **Paso 4: Verificar visualmente** + +```bash +php artisan serve +``` + +Abrir `http://localhost:8000` y verificar: +- El botón rojo con icono `bi-stars` aparece en la esquina inferior derecha +- Click abre el panel +- El campo de texto acepta input +- Cerrando y abriendo el panel no pierde mensajes de la sesión + +- [ ] **Paso 5: Commit final** + +```bash +git add resources/views/components/genius-chat.blade.php resources/views/layouts/app.blade.php +git commit -m "feat: add genius-chat bubble component and wire into layout" +``` + +--- + +## Verificación Final + +- [ ] **Ejecutar todos los tests** + +```bash +php artisan test +``` + +Resultado esperado: todos los tests del proyecto pasan (mínimo los 13 nuevos). + +- [ ] **Test de smoke manual (público)** + +1. Abrir el sitio sin loguearse +2. Clickear el botón del agente +3. Escribir: "¿Dónde veo mis QRs?" +4. Verificar que responde en español usando el manual + +- [ ] **Test de smoke manual (admin)** + +1. Loguearse como admin +2. Clickear el botón del agente +3. Escribir: "Listame los equipos del torneo 1" +4. Verificar que la tool se ejecuta y retorna los equipos + +- [ ] **Commit de cierre** + +```bash +git add . +git commit -m "feat: OnAPB Genius Agent — complete implementation" +``` + +--- + +## Comandos de Deploy en Hostinger + +```bash +# 1. Subir archivos y correr en SSH: +composer install --no-dev --optimize-autoloader +php artisan migrate --force +php artisan config:cache +php artisan route:cache +php artisan view:cache + +# 2. Configurar cron en hPanel de Hostinger (una sola vez): +# Reemplazar TU_USUARIO con tu usuario de Hostinger: +# 0 3 * * * cd /home/TU_USUARIO/public_html && php artisan agent:purge-threads >> /dev/null 2>&1 + +# 3. Verificar que la API key está en el .env de producción: +# GEMINI_API_KEY=tu_api_key_aqui +``` diff --git a/docs/superpowers/specs/2026-04-09-genius-agent-design.md b/docs/superpowers/specs/2026-04-09-genius-agent-design.md new file mode 100644 index 0000000..e25721b --- /dev/null +++ b/docs/superpowers/specs/2026-04-09-genius-agent-design.md @@ -0,0 +1,244 @@ +# OnAPB Genius Agent — Spec de Diseño + +**Fecha:** 2026-04-09 +**Rama:** `feature/genius-agent` +**Stack:** Laravel 12, Prism PHP (prism-php/prism), Google Gemini 1.5 Flash, MySQL, Alpine.js + +--- + +## 1. Objetivo + +Implementar un agente de IA conversacional ("OnAPB Genius") integrado en onapb.com con dos modos de operación según el rol del usuario: + +- **Público** (no logueado, aficionado, jugador): asistente de navegación y consultas usando el `MANUAL_USUARIO.md` como contexto RAG simplificado. Sin tools. Sin persistencia entre sesiones. +- **Admin** (SuperAdmin rol=1 / GeneralAdmin rol=2): automatización de tareas mediante function calling (Tools). Historial persistente en MySQL, auto-purgado a los 30 días. + +--- + +## 2. Decisiones de Arquitectura + +| Decisión | Elección | Razón | +|---|---|---| +| SDK de AI | `prism-php/prism` | Soporte probado de Gemini 1.5 Flash + tools. Más estable que `laravel/ai` (nuevo). | +| Modelo | `gemini-1.5-flash` | Velocidad (2–5s), costo bajo, function calling. | +| Streaming | No — JSON completo | Hosting compartido Hostinger con límites PHP desconocidos. Evita problemas de output buffer. | +| Memoria admin | MySQL (`agent_threads`) | Sin Redis. JSON column para mensajes. Purge automático 30 días. | +| Memoria público | Session PHP | Stateless entre sesiones. Simple, sin overhead de BD. | +| Tools | Solo si admin_logged_in | Validado en `GeniusAgentService`, no en Gemini. | + +--- + +## 3. Estructura de Archivos + +``` +app/ +├── AI/ +│ ├── Tools/ +│ │ ├── CrearPartidoTool.php +│ │ ├── CargarPuntajeTool.php +│ │ ├── RedactarNoticiaTool.php +│ │ ├── ListarEquiposTool.php +│ │ └── ListarEventosTool.php +│ └── Prompts/ +│ ├── SystemPromptAdmin.php +│ └── SystemPromptPublic.php +├── Services/ +│ └── GeniusAgentService.php +├── Http/Controllers/ +│ └── GeniusAgentController.php +├── Models/ +│ └── AgentThread.php +└── Console/Commands/ + └── PurgeAgentThreads.php + +database/migrations/ +└── xxxx_create_agent_threads_table.php + +resources/views/components/ +└── genius-chat.blade.php + +routes/web.php +└── POST /agent/chat (throttle: 20/min por IP) +``` + +--- + +## 4. Schema de Base de Datos + +```sql +CREATE TABLE agent_threads ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + thread_id VARCHAR(36) NOT NULL UNIQUE, -- UUID generado en frontend + admin_id INT NOT NULL, -- session('admin_id') + messages JSON NOT NULL, -- array [{role, content}] + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + expires_at TIMESTAMP NOT NULL -- created_at + 30 días +); +``` + +--- + +## 5. Tools Disponibles (Admin) + +Cada tool implementa la interfaz Prism `Tool`. El agent loop permite que Gemini llame la misma tool N veces para operaciones batch (ej. cargar puntajes de todos los partidos de una jornada). + +| Tool | Parámetros | Acción en BD | +|---|---|---| +| `CrearPartidoTool` | `id_equipo_local`, `id_equipo_visitante`, `fecha_evento`, `hora_inicio`, `hora_fin`, `sede`, `id_torneo` | `Evento::create(...)` | +| `CargarPuntajeTool` | `id_evento`, `marcador_local`, `marcador_visitante` | `Evento::find()->update(...)` | +| `RedactarNoticiaTool` | `titulo`, `contenido`, `id_torneo?`, `categoria?` | `Noticia::create(...)` | +| `ListarEquiposTool` | `id_torneo?`, `id_club?`, `grupo?` | `Equipo::query()` con join a pivot `torneo_equipo` filtrando por `grupo` (solo lectura) | +| `ListarEventosTool` | `fecha_desde?`, `fecha_hasta?`, `id_torneo?` | `Evento::query()->get()` (solo lectura) | + +**Nota sobre `grupo`:** No es un modelo separado. Es una columna en la tabla pivot `torneo_equipo` (relación `Torneo::equipos()->withPivot('grupo')`). `ListarEquiposTool` filtra con `wherePivot('grupo', $grupo)`. + +**Agregar una nueva tool en el futuro:** +1. Crear `app/AI/Tools/NuevaTool.php` +2. Registrarla en `GeniusAgentService::getAdminTools()` + +--- + +## 6. Flujo de Datos + +### Usuario público +``` +POST /agent/chat { message } +→ session()->get('agent_messages', []) +→ GeniusAgentService::chatPublic(message, history) + → System prompt: navegación + MANUAL_USUARIO.md completo como contexto (el archivo es pequeño; si crece >50KB usar solo las primeras 200 líneas) + → Prism → Gemini (sin tools) + → Respuesta de texto +→ session()->put('agent_messages', [...]) +→ return JSON { reply } +``` + +### Admin +``` +POST /agent/chat { message, thread_id? } +→ AgentThread::findOrCreate(thread_id, admin_id) +→ GeniusAgentService::chatAdmin(message, thread) + → System prompt: automatización de tareas OnAPB + → Prism → Gemini con tools + → [si Gemini llama tool] → Tool::handle() → resultado + → [Gemini puede llamar N tools] → agent loop + → Respuesta final de texto +→ thread->appendMessages([...]) +→ thread->save() +→ return JSON { reply, thread_id } +``` + +--- + +## 7. Seguridad + +- **Tools bloqueadas a no-admins:** `GeniusAgentService` valida `session('admin_logged_in')` antes de incluir tools. +- **Rate limiting:** `throttle:20,1` en la ruta `/agent/chat`. +- **Validación de input:** `message` requerido, string, máx. 1000 caracteres. +- **Aislamiento de threads:** cada thread valida `admin_id = session('admin_id')`. No hay acceso cruzado entre admins. +- **API key:** solo en `.env` (`GEMINI_API_KEY`). Nunca en código ni en BD. + +--- + +## 8. Manejo de Errores + +| Caso | Comportamiento | +|---|---| +| Gemini timeout / 5xx | `catch RequestException` → JSON `{ error: "El agente no responde, reintentá en un momento." }` | +| Tool falla (ej. evento no existe) | Tool retorna `{ error: "..." }` → Gemini lo incorpora en su respuesta | +| Thread expirado / no encontrado | Se crea un nuevo thread automáticamente | +| API key inválida / quota excedida | Log en Laravel + respuesta genérica (sin exponer detalles de la API) | +| Timeout de Hostinger | `set_time_limit(120)` al inicio del controller action | + +--- + +## 9. Purge de Threads + +Comando `php artisan agent:purge-threads` que elimina `agent_threads` donde `expires_at < NOW()`. + +**Schedule:** Se registra en `routes/console.php` para ejecutarse diariamente. En Hostinger sin queue worker, se puede configurar como cron en hPanel: + +``` +# Reemplazar /home/TU_USUARIO/public_html con la ruta real de tu cuenta Hostinger +0 3 * * * cd /home/TU_USUARIO/public_html && php artisan agent:purge-threads >> /dev/null 2>&1 +``` + +--- + +## 10. Frontend — Chat Bubble + +Componente Blade `genius-chat.blade.php` incluido al final de `resources/views/layouts/app.blade.php`: + +- Botón flotante (bottom-right) con ícono de chat +- Panel slide-up con historial de mensajes de la sesión actual +- Alpine.js para estado local (open/closed, messages, loading spinner) +- Tailwind para estilos (sin dependencias adicionales) +- Rol-aware: el frontend no diferencia, la diferencia la hace el backend + +--- + +## 11. Comandos de Deploy + +### Instalación inicial (local y producción) + +```bash +# 1. Instalar Prism PHP +composer require prism-php/prism + +# 2. Publicar config de Prism (opcional) +php artisan vendor:publish --provider="EchoLabs\Prism\PrismServiceProvider" + +# 3. Agregar en .env +GEMINI_API_KEY=tu_api_key_aqui + +# 4. Ejecutar migration de agent_threads +php artisan migrate + +# 5. Registrar el comando de purge (se hace automáticamente con el código) +# Verificar que está en routes/console.php + +# 6. Limpiar cachés después del deploy +php artisan config:clear +php artisan cache:clear +php artisan view:clear +php artisan route:clear + +# 7. Re-cachear para producción +php artisan config:cache +php artisan route:cache +php artisan view:cache +``` + +### Comandos de mantenimiento + +```bash +# Purge manual de threads expirados +php artisan agent:purge-threads + +# Ver logs del agente +php artisan pail --filter="GeniusAgent" + +# Limpiar todos los threads (emergencia) +php artisan tinker --execute="App\Models\AgentThread::truncate();" +``` + +### Deploy en Hostinger (via SSH o File Manager) + +```bash +# Subir archivos nuevos y correr: +php artisan migrate --force +php artisan config:cache +php artisan route:cache +php artisan view:cache +``` + +--- + +## 12. Configuración `.env` requerida + +```env +GEMINI_API_KEY=tu_api_key_de_google_ai_studio + +# Opcional: ajustar timeout HTTP de Prism +PRISM_HTTP_TIMEOUT=30 +``` diff --git a/docs/superpowers/specs/2026-04-24-pagos-macro-design.md b/docs/superpowers/specs/2026-04-24-pagos-macro-design.md new file mode 100644 index 0000000..3758e8c --- /dev/null +++ b/docs/superpowers/specs/2026-04-24-pagos-macro-design.md @@ -0,0 +1,376 @@ +# Diseño: Sistema de Pagos con Banco Macro + +**Fecha:** 2026-04-24 +**Estado:** Aprobado por el usuario — listo para plan de implementación +**Proyecto:** OnAPB v2 (Laravel / Hostinger Business) + +--- + +## Contexto + +OnAPB v2 es el sistema de gestión de la Asociación Paranaense de Básquet. Se busca implementar un sistema de cobros digitales integrado con Banco Macro (Macro Click de Pago — Botón Integrado), centralizado en la cuenta de la APB. + +- Framework: Laravel (PHP) +- Roles del sistema: super admin (role=1), admin de club (role=2), jugadores/aficionados (usuarios registrados), anónimos +- Existía una integración con MercadoPago que fue eliminada +- Las credenciales del Botón Integrado de Macro fueron solicitadas el 2026-04-23 y están pendientes de recibir +- Contacto Macro: Diego Dallanora — diegodallanora@macro.com.ar — +54 3794 15-0073 + +--- + +## Decisiones de diseño + +| Decisión | Resolución | +|---|---| +| Pasarela de pago | Macro Click de Pago — **Botón Integrado** | +| Quién recauda | **La APB centraliza** todos los pagos (un solo merchant Macro) | +| Entrega tienda | **Retiro físico en sede** (sin envíos) | +| Sanciones | Super admin las carga manualmente sobre un jugador | +| Modelo de datos | **Tabla polimórfica de pagos** (Opción A) | + +## Comisiones Macro (propuesta comercial vigente) + +| Medio | Comisión | Acreditación | +|---|---|---| +| Tarjeta de crédito | 3.05% | 18 días hábiles | +| Tarjeta de crédito en cuotas | 3.05% | 18 días hábiles | +| Tarjeta de débito | 3.00% | 1 día hábil | +| DEBIN | 3.00% | 1 día hábil | + +--- + +## Descomposición en sub-proyectos + +Implementar en este orden (cada uno depende del anterior): + +1. **Motor de pagos** — integración Macro base, modelo de datos, webhook +2. **Cobros institucionales** — inscripciones, multas/sanciones +3. **Tienda online** — catálogo, carrito, checkout + +--- + +## Sección 1 — Modelo de datos + +### Tablas nuevas + +#### `concepto_pagos` — plantillas configurables por el super admin +| Campo | Tipo | Notas | +|---|---|---| +| `id` | PK | | +| `nombre` | string | ej: "Inscripción Anual Jugador 2026" | +| `descripcion` | text | | +| `monto` | decimal(10,2) | | +| `tipo` | enum | `inscripcion_jugador`, `inscripcion_equipo`, `multa`, `tienda` | +| `temporada` | string nullable | ej: "2026" — para conceptos anuales | +| `activo` | boolean | default true | +| timestamps | | | + +#### `pagos` — registro de transacciones (polimórfico) +| Campo | Tipo | Notas | +|---|---|---| +| `id` | PK | | +| `concepto_pago_id` | FK → concepto_pagos | | +| `pagable_type` | string | App\Models\Jugador / Club / OrdenTienda / Sancion | +| `pagable_id` | unsignedBigInt | | +| `monto` | decimal(10,2) | snapshot al crear el pago | +| `estado` | enum | `pendiente`, `pagado`, `fallido`, `cancelado` | +| `macro_transaction_id` | string nullable | ID devuelto por Macro | +| `macro_payload` | json nullable | respuesta raw de Macro | +| `paid_at` | timestamp nullable | | +| `iniciado_por_type` | string nullable | quién inició (jugador, admin_user, null=anónimo) | +| `iniciado_por_id` | unsignedBigInt nullable | | +| timestamps | | | + +#### `sanciones` — registros disciplinarios +| Campo | Tipo | Notas | +|---|---|---| +| `id` | PK | | +| `jugador_id` | FK → jugadores | | +| `id_club` | FK → clubes | club al momento de la sanción | +| `motivo` | string | | +| `descripcion` | text nullable | | +| `fecha_sancion` | date | | +| `admin_id` | FK → admin_users | quién la cargó | +| timestamps | | | + +El pago de la sanción vive en `pagos` con `pagable_type = App\Models\Sancion`. + +#### `productos` — catálogo de la tienda +| Campo | Tipo | | +|---|---|---| +| `id` | PK | | +| `nombre` | string | | +| `descripcion` | text nullable | | +| `precio` | decimal(10,2) | | +| `stock` | unsignedInt | | +| `imagen` | string nullable | | +| `activo` | boolean | default true | +| timestamps | | | + +#### `ordenes_tienda` — órdenes de compra +| Campo | Tipo | Notas | +|---|---|---| +| `id` | PK | | +| `user_id` | FK nullable → users | null si comprador anónimo | +| `nombre_comprador` | string | capturado en checkout | +| `email_comprador` | string | para enviar comprobante | +| `estado` | enum | `pendiente_pago`, `pagado`, `listo_retiro`, `retirado`, `cancelado` | +| timestamps | | | + +#### `orden_items` — líneas de cada orden +| Campo | Tipo | Notas | +|---|---|---| +| `id` | PK | | +| `orden_id` | FK → ordenes_tienda | | +| `producto_id` | FK → productos | | +| `cantidad` | unsignedInt | | +| `precio_unitario` | decimal(10,2) | snapshot al momento de compra | + +### Relaciones Eloquent +``` +ConceptoPago hasMany Pago +Pago morphTo pagable (Jugador | Club | OrdenTienda | Sancion) +Sancion belongsTo Jugador, Club, AdminUser +OrdenTienda hasMany OrdenItem +OrdenItem belongsTo Producto +``` + +--- + +## Sección 2 — Flujo de pago con Macro (Botón Integrado) + +### Flujo estándar (aplica a todos los conceptos de pago) + +``` +Usuario decide pagar un concepto + ↓ +Sistema crea registro en `pagos` (estado = pendiente) + ↓ +GET /checkout/{pago} +Página con botón Macro embebido (monto + referencia interna precargados) + ↓ +Usuario interactúa con el formulario de Macro +(tarjeta crédito / débito / DEBIN) + ↓ + ┌──────────────────┬─────────────────┐ + ↓ ↓ ↓ + Pago exitoso Pago fallido Abandona + ↓ ↓ ↓ + Macro → /pagos/ Macro → /pagos/ Pago queda pendiente + exitoso fallido (expira por cron) + ↓ +POST /api/macro/webhook ← fuente de verdad + ↓ +Valida firma de Macro + ↓ +Actualiza pago: estado = pagado, paid_at = now(), macro_payload = {...} + ↓ +Dispara evento post-pago según pagable_type + ↓ +Envía email de comprobante al pagador +``` + +**Regla crítica:** El webhook es la fuente de verdad, no la redirección. Las páginas de éxito/fallo son solo UX. + +### Eventos post-pago por tipo + +| `pagable_type` | Acción tras confirmar pago | +|---|---| +| `Jugador` (inscripción) | Marcar jugador como inscripto en la temporada | +| `Club` (inscripción equipo) | Marcar equipo/club como habilitado para participar | +| `Sancion` | Marcar sanción como saldada | +| `OrdenTienda` | Estado → `pagado`; notificar al super admin | + +### Rutas nuevas + +``` +GET /checkout/{pago} → página con botón Macro embebido +GET /pagos/exitoso → pantalla de éxito (UX) +GET /pagos/fallido → pantalla de fallo (UX) +POST /api/macro/webhook → receptor del webhook (excluido de CSRF) +GET /tienda → catálogo público +GET /tienda/carrito → carrito de compras +POST /tienda/checkout → genera orden + pago, redirige a /checkout/{pago} +``` + +### Seguridad del webhook + +- Verificar firma de Macro antes de procesar (algoritmo a confirmar con Macro) +- El monto siempre se toma de `pagos.monto`, nunca del payload entrante +- Idempotente: si llega dos veces el mismo `macro_transaction_id`, no procesar dos veces +- Log de cada webhook recibido para auditoría + +--- + +## Sección 3 — Panel de administración + +### Super admin (role=1) — nuevas secciones + +**A. Conceptos de pago** +- Listado con filtros por tipo y estado (activo/inactivo) +- Crear / editar: nombre, descripción, monto, tipo, temporada, activo +- Desactivar un concepto no elimina ni afecta pagos ya realizados + +**B. Sanciones** +- Formulario: buscar jugador → seleccionar club → ingresar motivo, descripción, fecha → asociar concepto de pago tipo `multa` +- Listado con estado de pago (pendiente / saldada); filtros por club, jugador, estado, fecha + +**C. Tienda** +- CRUD de productos: nombre, descripción, precio, stock, imagen, activo +- Listado de órdenes con gestión de estado: + - `pagado` → "Marcar listo para retirar" → email automático al comprador + - `listo_retiro` → "Marcar como retirado" +- Filtros por estado y fecha + +**D. Transacciones / Recaudación** +- Tabla de todos los `pagos` con filtros: estado, tipo de concepto, rango de fechas +- Totales agrupados por tipo de concepto +- Exportar CSV para conciliación con la plataforma de Macro + +### Admin de club (role=2) — nuevas secciones + +**A. Sanciones del club** +- Lista de sanciones de los jugadores de su club +- Estado de cada sanción (pendiente / saldada) +- Botón para pagar una sanción pendiente (el club abona, no el jugador) + +**B. Inscripciones del club** +- Conceptos de inscripción de equipo disponibles para la temporada vigente +- Botón para pagar inscripción de equipo + +--- + +## Sección 4 — Vistas del usuario + +### Jugador registrado (`/panel-usuario`) + +Se agregan dos bloques al panel existente: + +**A. Mis pagos pendientes** +- Lista de cobros asignados al jugador: inscripción anual, sanciones +- Por cada uno: concepto, monto, estado, botón "Pagar ahora" +- Si ya está pagado: fecha + link al comprobante + +**B. Historial de pagos** +- Todos los pagos realizados (inscripciones, sanciones, compras en tienda) +- Filtro por estado y fecha + +### Tienda pública (`/tienda`) + +- Accesible desde la navbar para todos (registrados y anónimos) +- Catálogo: productos activos con stock > 0 +- Carrito en sesión (sin necesidad de cuenta) +- Checkout: formulario con nombre + email → pago con Macro +- Comprobante por email con información de retiro en sede +- Si el usuario tiene cuenta: la orden se vincula a su `user_id` y aparece en su historial + +### Invitación a registrarse (opcional, no obligatorio) +En el checkout anónimo se muestra: *"¿Tenés cuenta? Ingresá para guardar tu historial de compras."* + +--- + +## Sección 5 — Consideraciones técnicas y testing + +### Pendientes a confirmar con Macro al recibir credenciales + +- URL del endpoint para generar transacción / token de pago +- Formato exacto del webhook (campos, firma, algoritmo de verificación — probablemente HMAC-SHA256) +- Si existe **entorno sandbox** para pruebas (solicitarlo explícitamente) +- Si el botón es JS embebido, iframe, o redirect + +### Estructura de clases nuevas en Laravel + +``` +app/ + Services/ + MacroService.php ← toda la comunicación con Macro (stub hasta recibir credenciales) + PagoService.php ← crea pagos, dispara eventos post-pago + Events/ + PagoConfirmado.php + Listeners/ + EnviarComprobantePago.php + MarcarJugadorInscripto.php + MarcarSancionSaldada.php + ActualizarOrdenTienda.php + Jobs/ + CancelarPagosPendientesVencidos.php ← cron diario + Http/Controllers/ + CheckoutController.php + MacroWebhookController.php + TiendaController.php + Admin/ConceptoPagoController.php + Admin/SancionAdminController.php + Admin/ProductoController.php + Admin/OrdenTiendaController.php + Admin/RecaudacionController.php +``` + +### Variables de entorno necesarias + +```env +MACRO_MERCHANT_ID= +MACRO_API_KEY= +MACRO_SECRET= +MACRO_WEBHOOK_SECRET= +MACRO_ENV=sandbox # cambiar a "production" al salir a producción +MACRO_SUCCESS_URL="${APP_URL}/pagos/exitoso" +MACRO_FAILURE_URL="${APP_URL}/pagos/fallido" +MACRO_WEBHOOK_URL="${APP_URL}/api/macro/webhook" +``` + +### Plan de testing en dos etapas + +**Etapa 1 — Sin credenciales Macro (desarrollo inmediato)** +- Tests unitarios del modelo de datos: crear pagos, sanciones, órdenes +- Tests de eventos post-pago con `MacroService` mockeado +- Tests del webhook con payload simulado y firma fake +- Tests de las vistas admin (CRUD de conceptos, productos, sanciones) + +**Etapa 2 — Con Macro sandbox** +- Pruebas end-to-end con tarjeta de prueba en sandbox +- Verificar que el webhook llega y el estado se actualiza +- Verificar idempotencia (enviar webhook dos veces → mismo resultado) +- Verificar email de comprobante + +### Orden de implementación recomendado + +1. Migraciones + modelos (`ConceptoPago`, `Pago`, `Sancion`, `Producto`, `OrdenTienda`, `OrdenItem`) +2. `MacroService` con métodos stub +3. Panel admin: conceptos de pago → sanciones → tienda → reportes/recaudación +4. Panel usuario: pagos pendientes + historial +5. Tienda pública: catálogo + carrito + checkout +6. Webhook receptor + eventos post-pago + job de cancelación +7. Reemplazar stubs con implementación real de Macro al recibir credenciales + +--- + +## Inscripción de jugadores — detalle adicional ✅ + +### Dos tipos de inscripción por temporada + +La temporada abarca Torneo Apertura + Clausura. Hay jugadores que se incorporan a mitad de año y solo deben pagar una inscripción reducida. Por lo tanto, el super admin crea **dos conceptos** por temporada: + +| Concepto | Tipo | Ejemplo precio | Aplica a | +|---|---|---|---| +| Inscripción Anual Completa 2026 | `inscripcion_jugador` | $X | Jugadores desde Apertura | +| Inscripción Reducida 2026 (Clausura) | `inscripcion_jugador` | $Y | Jugadores que ingresan a mitad de año | + +### Generación masiva de deudas + +En el panel admin (super admin), sección **Conceptos de pago**, se agrega una acción: + +**"Generar deuda masiva"** +1. Super admin selecciona el concepto (ej: "Inscripción Anual Completa 2026") +2. Sistema muestra todos los jugadores activos que **no tienen ya ese concepto generado** para esa temporada +3. Super admin puede desmarcar jugadores individuales (ej: los que van a recibir la inscripción reducida) +4. Confirma → sistema crea un registro `pagos` (estado=pendiente) por cada jugador seleccionado +5. Cada jugador ve la deuda en su panel usuario + +Para los jugadores que se incorporan a mitad de año, el admin repite el proceso con el concepto "Inscripción Reducida". + +**Regla de negocio:** Un jugador no puede tener dos pagos pendientes del mismo concepto simultáneamente (se valida antes de generar). + +## Preguntas abiertas + +- ¿Tiene Macro un entorno sandbox para desarrollo? (pendiente confirmar con Diego Dallanora) +- ¿Qué campos exactos envía Macro en el webhook? (se confirma al recibir credenciales)