This commit is contained in:
Laucha1312
2026-06-04 15:15:23 -03:00
parent 0841794c50
commit 90c5f85512
167 changed files with 15870 additions and 0 deletions
+72
View File
@@ -0,0 +1,72 @@
<?php
namespace App\AI\Prompts;
class SystemPromptAdmin
{
public static function get(bool $isSuperadmin = false): string
{
$rolLabel = $isSuperadmin ? 'Súper Administrador (OnAPB)' : 'Administrador de Club';
$toolsDisponibles = $isSuperadmin
? <<<TOOLS
### Lectura
- **listar_torneos**: lista todos los torneos con su ID, nombre y fechas. Usalo SIEMPRE que el admin mencione un torneo por nombre, antes de preguntarle el ID.
- **listar_equipos**: lista equipos, con filtros opcionales por torneo y/o grupo.
- **listar_eventos**: lista partidos, con filtros opcionales por rango de fechas y/o torneo.
### Escritura (requieren confirmación explícita)
- **crear_partido**: crea un partido.
- **cargar_puntaje**: actualiza el marcador de un partido existente.
- **redactar_noticia**: publica una noticia.
### Rollback
- **eliminar_noticia**: elimina una noticia por su id.
- **eliminar_partido**: elimina (soft delete) un partido por su UUID.
TOOLS
: <<<TOOLS
### Lectura (solo)
- **listar_torneos**: lista todos los torneos.
- **listar_equipos**: lista equipos (filtrable por torneo/grupo).
- **listar_eventos**: lista partidos (filtrable por fechas/torneo).
TOOLS;
$reglasEscritura = $isSuperadmin
? <<<ESCRITURA
## Reglas de escritura (CRÍTICAS)
- **JAMÁS ejecutes una tool de escritura (crear_partido, cargar_puntaje, redactar_noticia) sin antes MOSTRAR al admin un resumen claro y pedirle confirmación explícita ("¿Confirmás?").**
- Esperá una respuesta afirmativa ("sí", "dale", "confirmo", "ok") antes de ejecutar. Si la respuesta es ambigua, volvé a preguntar.
- Tras ejecutar una tool de escritura, DEVOLVELE al admin el ID del recurso creado/modificado y recordale cómo deshacerlo (por ej: "para revertir, pedime 'eliminá la noticia XX'").
- Para un rollback, usá eliminar_noticia o eliminar_partido con el ID que guardaste en el contexto reciente. Pedí confirmación antes de borrar.
ESCRITURA
: <<<ESCRITURA
## Restricciones de tu rol
- Tenés rol de Administrador de Club: **solo podés consultar datos**. No podés crear partidos, cargar puntajes, redactar noticias ni eliminar nada.
- Si el usuario pide una acción de escritura, explicale amablemente que necesita un Súper Administrador y sugerile que lo contacte.
ESCRITURA;
return <<<PROMPT
Sos OnAPB Genius, el asistente de IA oficial de la Asociación Paranaense de Basquetbol (APB), con sede en Paraná, Entre Ríos, Argentina. OnAPB es la plataforma oficial que la APB usa para gestionar torneos, clubes, equipos, jugadores, partidos y noticias.
## Tu interlocutor
Estás hablando con un **{$rolLabel}**. Adaptá tus acciones a lo que este rol tiene permitido.
## Tools disponibles
{$toolsDisponibles}
{$reglasEscritura}
## Reglas generales
- Respondé siempre en español rioplatense (vos/tenés), de forma concisa y amable.
- **Nunca le pidas al admin un ID técnico si podés averiguarlo vos llamando a una tool de lectura.** Por ejemplo: si dice "el torneo Apertura", primero llamá a listar_torneos y buscá el match por nombre.
- Después de ejecutar cualquier tool, SIEMPRE generá una respuesta en texto claro para el usuario con lo que encontraste o hiciste. Nunca termines el turno solo con la llamada a la tool.
- Si una tool devuelve una lista vacía, decilo explícitamente ("No hay equipos en ese torneo/grupo") en lugar de quedar en silencio.
- Si una tool devuelve un error, citá el mensaje y sugerí cómo resolverlo.
- No inventes datos. Si falta información para ejecutar una tool, preguntá por el dato puntual.
- El deporte es basquetbol. Nunca lo confundas con otro.
## Contexto del sistema
La APB gestiona torneos de basquetbol en Paraná. Hay clubes, equipos (pertenecen a un club, juegan en torneos divididos en grupos A, B, etc.), torneos (con fecha de inicio/fin), partidos (llamados "eventos" en la DB, con UUID como id), marcadores, noticias (con id numérico), jugadores federados y aficionados.
PROMPT;
}
}
+51
View File
@@ -0,0 +1,51 @@
<?php
namespace App\AI\Prompts;
class SystemPromptPublic
{
public static function get(): string
{
$manual = self::loadManual();
return <<<PROMPT
Sos OnAPB Genius, el asistente virtual del portal onapb.com de la Asociación Paranaense de Basquetbol (APB), con sede en Paraná, Entre Ríos, Argentina. OnAPB es la plataforma oficial de la APB para gestionar torneos, equipos, jugadores y partidos.
## Tu rol
Ayudás a los usuarios (aficionados, jugadores, visitantes) a navegar el portal y responder consultas sobre torneos de basquetbol, equipos, partidos y cómo usar el sistema.
## Reglas importantes
- Respondé siempre en español rioplatense (vos/tenés), de forma amable y concisa.
- Basá tus respuestas ÚNICAMENTE en la documentación del portal que se incluye a continuación. Si un dato no está en la documentación, NO lo inventes.
- Si no sabés algo o la información no está documentada, decilo honestamente y sugerí que el usuario contacte directamente a la APB.
- No tenés acceso a datos en tiempo real (partidos en vivo, puntajes actuales, estadísticas). Para eso, indicá al usuario que consulte las secciones correspondientes del portal.
- No podés realizar acciones ni modificar datos: solo informás y guiás.
- El deporte es basquetbol. Nunca lo confundas con otro deporte.
## Documentación del portal
{$manual}
PROMPT;
}
private static function loadManual(): string
{
$path = base_path('misc/MANUAL_USUARIO.md');
if (!file_exists($path)) {
return 'Documentación no disponible.';
}
$content = file_get_contents($path);
// Para el chat público excluimos los capítulos de administradores (cap4, cap5)
// pero conservamos la sección de Preguntas Frecuentes que va al final.
$cap4Pos = strpos($content, '<a name="cap4"></a>');
$faqPos = strpos($content, '## ❓ Preguntas Frecuentes');
if ($cap4Pos !== false && $faqPos !== false && $faqPos > $cap4Pos) {
$content = substr($content, 0, $cap4Pos) . substr($content, $faqPos);
}
return $content;
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace App\AI\Tools;
use App\Models\Evento;
class CargarPuntajeTool
{
public function __invoke(
string $id_evento,
int $marcador_local,
int $marcador_visitante
): string {
$evento = Evento::find($id_evento);
if (!$evento) {
return json_encode(['error' => "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}",
]);
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
namespace App\AI\Tools;
use App\Models\Evento;
use Illuminate\Support\Str;
class CrearPartidoTool
{
public function __invoke(
int $id_equipo_local,
int $id_equipo_visitante,
string $fecha_evento,
string $hora_inicio,
string $hora_fin,
string $sede,
int $id_torneo,
?float $precio = null
): string {
try {
$evento = Evento::create([
'id_evento' => (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()]);
}
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace App\AI\Tools;
use App\Models\Noticia;
class EliminarNoticiaTool
{
public function __invoke(int $id_noticia): string
{
try {
$noticia = Noticia::find($id_noticia);
if (!$noticia) {
return json_encode(['error' => "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()]);
}
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
namespace App\AI\Tools;
use App\Models\Evento;
class EliminarPartidoTool
{
public function __invoke(string $id_evento): string
{
try {
$evento = Evento::find($id_evento);
if (!$evento) {
return json_encode(['error' => "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()]);
}
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace App\AI\Tools;
use App\Models\Equipo;
class ListarEquiposTool
{
public function __invoke(?int $id_torneo = null, ?string $grupo = null): string
{
$query = Equipo::with('club');
if ($id_torneo !== null) {
$query->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);
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
namespace App\AI\Tools;
use App\Models\Evento;
class ListarEventosTool
{
public function __invoke(
?string $fecha_desde = null,
?string $fecha_hasta = null,
?int $id_torneo = null
): string {
$query = Evento::with(['equipoLocal.club', 'equipoVisitante.club'])
->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);
}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace App\AI\Tools;
use App\Models\Torneo;
class ListarTorneosTool
{
public function __invoke(): string
{
$torneos = Torneo::orderByDesc('fecha_inicio')->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);
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace App\AI\Tools;
use App\Models\Noticia;
class RedactarNoticiaTool
{
public function __invoke(
string $titulo,
string $contenido,
?int $id_torneo = null,
?string $categoria = null
): string {
try {
$noticia = Noticia::create([
'titulo' => $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()]);
}
}
}
+51
View File
@@ -0,0 +1,51 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
class CleanupOldEvents extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'app:cleanup-old-events';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Elimina eventos y QRs antiguos según la configuración del sistema.';
/**
* Execute the console command.
*/
public function handle()
{
$dias = \App\Models\Configuracion::get('dias_expiracion_eventos', 30);
$fechaLimite = now()->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.");
}
}
+196
View File
@@ -0,0 +1,196 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
class OptimizeImages extends Command
{
protected $signature = 'optimize:images
{--apply : Aplica cambios. Sin esta flag corre en modo dry-run.}
{--folder= : Solo procesa esta carpeta (carousel|sponsors|qr|promos|noticias|clubes).}
{--no-backup : No crea backup de los originales.}';
protected $description = 'Comprime y redimensiona imagenes en storage/app/public/. Por defecto dry-run.';
private array $config = [
'carousel' => ['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
? '<fg=red>MODO APLICACION</> — los archivos seran reemplazados.'
: '<fg=yellow>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 ? '<fg=green>OK</>' : '<fg=cyan>--</>',
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';
}
}
@@ -0,0 +1,21 @@
<?php
namespace App\Console\Commands;
use App\Models\AgentThread;
use Illuminate\Console\Command;
class PurgeAgentThreads extends Command
{
protected $signature = 'agent:purge-threads';
protected $description = 'Elimina los hilos de conversación del agente que hayan expirado.';
public function handle(): int
{
$deleted = AgentThread::where('expires_at', '<', now())->delete();
$this->info("Se eliminaron {$deleted} hilo(s) expirado(s).");
return self::SUCCESS;
}
}
@@ -0,0 +1,86 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\Models\Evento;
use App\Models\EquipoSeguimiento;
use App\Services\NotificacionService;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
class RecordatorioPartidos extends Command
{
protected $signature = 'notificaciones:recordatorio-partido {--test : Simula sin guardar}';
protected $description = 'Envía recordatorios 48hs antes de cada partido a sus seguidores';
public function handle(NotificacionService $notifService): int
{
$desde = Carbon::now()->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;
}
}
+106
View File
@@ -0,0 +1,106 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\Models\Evento;
use App\Models\Configuracion;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\DB;
use Carbon\Carbon;
class ReporteSemanal extends Command
{
protected $signature = 'reportes:semanal {--dry-run : Solo muestra el contenido sin enviar}';
protected $description = 'Genera y envía el reporte semanal de actividad a los administradores';
public function handle(): int
{
$ahora = Carbon::now();
$semanaAnteriorDesde = $ahora->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;
}
}
@@ -0,0 +1,115 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Models\AdminUser;
use App\Models\Club;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rule;
class AdminUserController extends Controller
{
private function checkSuperAdmin(Request $request)
{
if (!session('admin_logged_in') || session('admin_role') != 1) {
abort(403, 'Acceso denegado. Solo Súper Administradores.');
}
}
public function index(Request $request)
{
$this->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.');
}
}
@@ -0,0 +1,113 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\CarouselItem;
use App\Services\ImageOptimizer;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\File;
class CarouselItemController extends Controller
{
private function checkGeneralAdmin(Request $request): void
{
if (!session('admin_logged_in') || !in_array(session('admin_role'), [1, 2])) {
abort(403, 'Acceso denegado');
}
}
public function index(Request $request)
{
$this->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.');
}
}
@@ -0,0 +1,74 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Models\Categoria;
class CategoriaController extends Controller
{
private function checkSuperAdmin(Request $request)
{
if (!session('admin_logged_in') || session('admin_role') != 1) {
abort(403, 'Acceso denegado. Solo Súper Administradores.');
}
}
public function index(Request $request)
{
$this->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.');
}
}
@@ -0,0 +1,380 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Models\Torneo;
use App\Services\FixtureService;
class FixtureController extends Controller
{
private FixtureService $fixtureService;
public function __construct(FixtureService $fixtureService)
{
$this->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:<br>" . implode("<br>", $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.");
}
}
@@ -0,0 +1,129 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Models\Pase;
use App\Models\Jugador;
use App\Models\Club;
class PaseController extends Controller
{
private function checkGeneralAdmin(Request $request)
{
if (!session('admin_logged_in') || !in_array(session('admin_role'), [1, 2])) {
abort(403, 'Acceso denegado');
}
}
private function checkSuperAdmin(Request $request)
{
if (!session('admin_logged_in') || session('admin_role') != 1) {
abort(403, 'Acceso denegado. Solo Súper Administradores.');
}
}
public function index(Request $request)
{
$this->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.');
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,55 @@
<?php
namespace App\Http\Controllers;
use App\Models\AdminUser;
use Illuminate\Http\Request;
class AdminUserController extends Controller
{
public function index()
{
$users = AdminUser::all();
return response()->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);
}
}
@@ -0,0 +1,67 @@
<?php
namespace App\Http\Controllers;
use App\Models\Aficionado;
use Illuminate\Http\Request;
class AficionadoController extends Controller
{
public function index()
{
$aficionados = Aficionado::all();
return response()->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);
}
}
+411
View File
@@ -0,0 +1,411 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use App\Models\Jugador;
use App\Models\Aficionado;
use App\Models\AdminUser;
use App\Models\Club;
use App\Mail\WelcomeMail;
use App\Mail\ResetPasswordMail;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Http;
class AuthController extends Controller
{
/**
* Valida el token de Cloudflare Turnstile
*/
private function verifyTurnstile($token)
{
if (in_array(config('app.env'), ['local', 'testing']) && $token === '1x00000000000000000000AA') {
return true;
}
if (!$token) return false;
$response = Http::asForm()->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;
}
}
+47
View File
@@ -0,0 +1,47 @@
<?php
namespace App\Http\Controllers;
use App\Models\Club;
use Illuminate\Http\Request;
class ClubController extends Controller
{
public function index()
{
$clubes = Club::with('equipos')->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);
}
}
+8
View File
@@ -0,0 +1,8 @@
<?php
namespace App\Http\Controllers;
abstract class Controller
{
//
}
@@ -0,0 +1,221 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
use Barryvdh\DomPDF\Facade\Pdf;
class DocumentacionController extends Controller
{
public function index()
{
$path = base_path('misc/MANUAL_USUARIO.md');
$content = "No se encontró el manual.";
if (File::exists($path)) {
$md = File::get($path);
// Detectar rol de la sesión
$role = $this->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' => '<a name="cap1"></a>',
'cap2' => '<a name="cap2"></a>',
'cap3' => '<a name="cap3"></a>',
'cap4' => '<a name="cap4"></a>',
'cap5' => '<a name="cap5"></a>',
'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);
}
}
+57
View File
@@ -0,0 +1,57 @@
<?php
namespace App\Http\Controllers;
use App\Models\Equipo;
use Illuminate\Http\Request;
class EquipoController extends Controller
{
public function index()
{
$equipos = Equipo::with('club', 'jugadores')->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);
}
}
+84
View File
@@ -0,0 +1,84 @@
<?php
namespace App\Http\Controllers;
use App\Models\Evento;
use Carbon\Carbon;
use Illuminate\Http\Request;
class EventoController extends Controller
{
public function index(Request $request)
{
$fechaStr = $request->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';
}
}
@@ -0,0 +1,81 @@
<?php
namespace App\Http\Controllers;
use App\Models\AgentThread;
use App\Services\GeniusAgentService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class GeniusAgentController extends Controller
{
public function __construct(private GeniusAgentService $service) {}
public function chat(Request $request): JsonResponse
{
set_time_limit(120);
$request->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]);
}
}
+86
View File
@@ -0,0 +1,86 @@
<?php
namespace App\Http\Controllers;
use App\Models\Noticia;
use App\Models\Torneo;
use App\Models\Evento;
use App\Models\Promocion;
use App\Models\CarouselItem;
use App\Services\TournamentService;
use Carbon\Carbon;
use Illuminate\Http\Request;
class HomeController extends Controller
{
public function index()
{
$eventos = $this->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';
}
}
@@ -0,0 +1,76 @@
<?php
namespace App\Http\Controllers;
use App\Models\Jugador;
use Illuminate\Http\Request;
class JugadorController extends Controller
{
public function index()
{
$jugadores = Jugador::with('clubActual', 'clubOrigen', 'equipos')->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);
}
}
@@ -0,0 +1,73 @@
<?php
namespace App\Http\Controllers;
use App\Models\JugadorEquipo;
use Illuminate\Http\Request;
class JugadorEquipoController extends Controller
{
public function index()
{
$relaciones = JugadorEquipo::with('jugador', 'equipo')->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);
}
}
@@ -0,0 +1,61 @@
<?php
namespace App\Http\Controllers;
use App\Models\Noticia;
use Illuminate\Http\Request;
class NoticiaController extends Controller
{
public function index(Request $request)
{
$noticias = Noticia::orderBy('fecha', 'desc')->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);
}
}
@@ -0,0 +1,143 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Services\NotificacionService;
class NotificacionController extends Controller
{
private NotificacionService $service;
public function __construct(NotificacionService $service)
{
$this->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]);
}
}
+363
View File
@@ -0,0 +1,363 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\DB;
use App\Models\Jugador;
use App\Models\Aficionado;
use App\Models\Evento;
use App\Models\Equipo;
use App\Models\Promocion;
use App\Models\QrCode;
use App\Models\PromoQr;
use App\Mail\QrCodeMail;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Log;
class PanelController extends Controller
{
private \App\Services\NotificacionService $notifService;
public function __construct(\App\Services\NotificacionService $notifService)
{
$this->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'));
}
}
@@ -0,0 +1,52 @@
<?php
namespace App\Http\Controllers;
use App\Models\PromoQr;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
class PromoQrController extends Controller
{
public function index()
{
$promoQrs = PromoQr::with('promocion')->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);
}
}
@@ -0,0 +1,70 @@
<?php
namespace App\Http\Controllers;
use App\Models\Promocion;
use Illuminate\Http\Request;
class PromocionController extends Controller
{
public function index(Request $request)
{
$promociones = Promocion::all();
if ($request->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);
}
}
+57
View File
@@ -0,0 +1,57 @@
<?php
namespace App\Http\Controllers;
use App\Models\QrCode;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
class QrCodeController extends Controller
{
public function index()
{
$qrCodes = QrCode::with('evento', 'jugador', 'aficionado')->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);
}
}
@@ -0,0 +1,129 @@
<?php
namespace App\Http\Controllers;
use App\Models\QrCode;
use Illuminate\Http\Request;
use Barryvdh\DomPDF\Facade\Pdf;
class QrDownloadController extends Controller
{
public function download($id)
{
$qr = QrCode::with(['evento.equipoLocal.club', 'evento.equipoVisitante.club', 'jugador.clubActual', 'aficionado'])
->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");
}
}
@@ -0,0 +1,122 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Models\Equipo;
use App\Models\EquipoSeguimiento;
use App\Models\Evento;
use App\Services\NotificacionService;
class SeguimientoController extends Controller
{
private NotificacionService $notifService;
public function __construct(NotificacionService $notifService)
{
$this->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]);
}
}
+85
View File
@@ -0,0 +1,85 @@
<?php
namespace App\Http\Controllers;
use App\Models\Torneo;
use App\Models\Equipo;
use App\Models\Evento;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class TorneoController extends Controller
{
public function standings(Request $request, $id)
{
$selectedGroup = $request->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'));
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class SecurityHeaders
{
public function handle(Request $request, Closure $next)
{
$response = $next($request);
$response->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;
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
class QrCodeMail extends Mailable
{
use Queueable, SerializesModels;
public $user;
public $evento;
public $cantidad;
public function __construct($user, $evento, $cantidad)
{
$this->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');
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
class ResetPasswordMail extends Mailable
{
use Queueable, SerializesModels;
public $user;
public $token;
public function __construct($user, $token)
{
$this->user = $user;
$this->token = $token;
}
public function build()
{
return $this->subject('Recuperar contraseña - OnAPB')
->view('emails.reset_password');
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
class WelcomeMail extends Mailable
{
use Queueable, SerializesModels;
public $user;
public $tipo;
public function __construct($user, $tipo)
{
$this->user = $user;
$this->tipo = $tipo;
}
public function build()
{
return $this->subject('¡Bienvenido a OnAPB!')
->view('emails.welcome');
}
}
+36
View File
@@ -0,0 +1,36 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class AdminUser extends Model
{
protected $table = 'admin_users';
protected $primaryKey = 'id';
public $timestamps = false;
protected $fillable = [
'username',
'password',
'role',
'id_club',
'reset_token',
'reset_expira',
];
public function club()
{
return $this->belongsTo(Club::class, 'id_club', 'id_club');
}
protected $hidden = [
'password',
'reset_token',
];
protected $casts = [
'role' => 'integer',
'reset_expira' => 'datetime',
];
}
+38
View File
@@ -0,0 +1,38 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Aficionado extends Model
{
protected $table = 'aficionados';
protected $primaryKey = 'id_aficionado';
public $timestamps = false;
protected $fillable = [
'nombre',
'apellido',
'dni',
'fecha_nacimiento',
'email',
'telefono',
'localidad',
'fecha_registro',
'password',
'reset_token',
'reset_expira',
];
protected $hidden = [
'password',
'reset_token',
];
protected $casts = [
'id_aficionado' => 'integer',
'fecha_nacimiento' => 'date',
'fecha_registro' => 'datetime',
'reset_expira' => 'datetime',
];
}
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
class AgentThread extends Model
{
protected $table = 'agent_threads';
protected $fillable = [
'thread_id',
'admin_id',
'messages',
'expires_at',
];
protected $casts = [
'messages' => '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),
]);
}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class CarouselItem extends Model
{
protected $table = 'carousel_items';
protected $fillable = [
'titulo',
'subtitulo',
'boton_texto',
'boton_enlace',
'imagen',
'orden',
'activo',
];
protected $casts = [
'activo' => 'boolean',
'orden' => 'integer',
];
}
+19
View File
@@ -0,0 +1,19 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Categoria extends Model
{
protected $table = 'categorias';
protected $primaryKey = 'id_categoria';
protected $fillable = [
'nombre',
'edad_min',
'edad_max',
'genero',
'es_libre'
];
}
+50
View File
@@ -0,0 +1,50 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class Club extends Model
{
use SoftDeletes;
protected $table = 'clubes';
protected $primaryKey = 'id_club';
public $timestamps = true;
public $incrementing = false;
protected $fillable = [
'id_club',
'nombre',
'es_seleccion',
'estadio_id',
'imagen',
'qr_background',
'qr_color_texto',
];
protected static function boot()
{
parent::boot();
static::creating(function ($model) {
if (empty($model->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');
}
}
+45
View File
@@ -0,0 +1,45 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Configuracion extends Model
{
protected $table = 'configuraciones';
protected $fillable = [
'clave',
'valor',
'descripcion',
];
/**
* Obtener un valor de configuración por su clave.
*
* @param string $clave
* @param mixed $default
* @return mixed
*/
public static function get($clave, $default = null)
{
$config = self::where('clave', $clave)->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]
);
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class Equipo extends Model
{
use SoftDeletes;
protected $table = 'equipos';
protected $primaryKey = 'id_equipo';
public $timestamps = true;
protected $fillable = [
'id_club',
'categoria',
'division',
];
protected $casts = [
'id_equipo' => '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');
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class EquipoSeguimiento extends Model
{
protected $table = 'equipo_seguimiento';
public $timestamps = false;
protected $fillable = [
'id_equipo',
'tipo_usuario',
'id_usuario',
'created_at',
];
protected $casts = [
'id_equipo' => 'integer',
'created_at' => 'datetime',
];
public function equipo()
{
return $this->belongsTo(Equipo::class, 'id_equipo', 'id_equipo');
}
}
+96
View File
@@ -0,0 +1,96 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Carbon\Carbon;
class Evento extends Model
{
use SoftDeletes;
protected $table = 'eventos';
protected $primaryKey = 'id_evento';
public $timestamps = true;
public $incrementing = false;
public const FASE_REGULAR = 0;
public const FASE_CUARTOS = 1;
public const FASE_SEMIS = 2;
public const FASE_FINAL = 3;
protected $fillable = [
'id_evento',
'nombre_evento',
'fecha_evento',
'hora_inicio',
'hora_fin',
'sede',
'id_equipo_local',
'id_equipo_visitante',
'marcador_local',
'marcador_visitante',
'id_torneo',
'precio',
'limite_qr_jugador',
'fase',
'numero_partido_bracket',
];
protected $casts = [
'id_evento' => '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');
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class EventoJugador extends Model
{
protected $table = 'evento_jugador';
protected $fillable = [
'id_evento',
'id_jugador',
'puntos',
'faltas'
];
public function evento()
{
return $this->belongsTo(Evento::class, 'id_evento', 'id_evento');
}
public function jugador()
{
return $this->belongsTo(Jugador::class, 'id_jugador', 'id_jugador');
}
}
+79
View File
@@ -0,0 +1,79 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class Jugador extends Model
{
use SoftDeletes;
protected $table = 'jugadores';
protected $primaryKey = 'id_jugador';
public $timestamps = true;
public $incrementing = false;
protected $fillable = [
'id_jugador',
'documento',
'nombre',
'apellido',
'fecha_nacimiento',
'edad',
'categoria',
'id_club_actual',
'id_club_origen',
'activo',
'email',
'telefono',
'password',
'reset_token',
'reset_expira',
];
protected $hidden = [
'password',
'reset_token',
];
protected $casts = [
'id_jugador' => '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');
}
}
+34
View File
@@ -0,0 +1,34 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class JugadorEquipo extends Model
{
protected $table = 'jugador_equipo';
public $timestamps = false;
public $incrementing = false;
protected $fillable = [
'id_jugador',
'id_equipo',
'fecha_alta',
];
protected $casts = [
'id_jugador' => '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');
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Noticia extends Model
{
protected $table = 'noticias';
protected $primaryKey = 'id';
public $timestamps = false;
protected $fillable = [
'titulo',
'contenido',
'imagen',
'fecha',
'id_torneo',
'categoria',
];
protected $casts = [
'id' => 'integer',
'fecha' => 'datetime',
];
}
+40
View File
@@ -0,0 +1,40 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Notificacion extends Model
{
protected $table = 'notificaciones';
public $timestamps = false;
protected $fillable = [
'tipo_destinatario',
'id_destinatario',
'tipo',
'titulo',
'mensaje',
'url_accion',
'leida',
'enviada_email',
'creada_en',
];
protected $casts = [
'leida' => '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);
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Pase extends Model
{
protected $table = 'pases';
protected $primaryKey = 'id_pase';
protected $fillable = [
'id_jugador',
'id_club_origen',
'id_club_destino',
'estado'
];
public function jugador()
{
return $this->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');
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class PromoQr extends Model
{
protected $table = 'promo_qrs';
protected $primaryKey = 'id_qr';
public $timestamps = false;
public $incrementing = false;
protected $fillable = [
'id_qr',
'id_promo',
'id_usuario',
'tipo_usuario',
'generado_en',
'usado',
'usado_en',
];
protected $casts = [
'id_qr' => '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');
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Promocion extends Model
{
protected $table = 'promociones';
protected $primaryKey = 'id';
public $timestamps = false;
protected $fillable = [
'nombre',
'direccion',
'lat',
'lng',
'contacto',
'descripcion',
'descripcion_lugar',
'categoria',
'imagen',
];
protected $casts = [
'id' => 'integer',
'lat' => 'decimal:8',
'lng' => 'decimal:8',
];
public function promoQrs()
{
return $this->hasMany(PromoQr::class, 'id_promo', 'id');
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class PushSubscription extends Model
{
protected $fillable = [
'id_usuario',
'tipo_usuario',
'endpoint',
'p256dh',
'auth',
];
/**
* Relación polimórfica manual (ya que no usamos el User estándar de Laravel siempre)
*/
public function scopeParaUsuario($query, $tipo, $id)
{
return $query->where('tipo_usuario', $tipo)->where('id_usuario', $id);
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class QrCode extends Model
{
protected $table = 'qr_codes';
protected $primaryKey = 'id_qr';
public $timestamps = false;
public $incrementing = false;
protected $fillable = [
'id_qr',
'id_evento',
'id_jugador',
'tipo_qr',
'escaneos_restantes',
'creado',
'id_aficionado',
];
protected $casts = [
'id_qr' => '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');
}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Sponsor extends Model
{
protected $table = 'sponsors';
protected $primaryKey = 'id_sponsor';
protected $fillable = [
'nombre',
'imagen',
'url',
'activo',
'orden',
];
protected $casts = [
'activo' => 'boolean',
];
}
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class Torneo extends Model
{
use SoftDeletes;
protected $table = 'torneos';
protected $fillable = [
'nombre',
'fecha_inicio',
'fecha_fin',
];
protected $casts = [
'fecha_inicio' => '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');
}
}
+49
View File
@@ -0,0 +1,49 @@
<?php
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
class User extends Authenticatable
{
/** @use HasFactory<UserFactory> */
use HasFactory, Notifiable;
/**
* The attributes that are mass assignable.
*
* @var list<string>
*/
protected $fillable = [
'name',
'email',
'password',
];
/**
* The attributes that should be hidden for serialization.
*
* @var list<string>
*/
protected $hidden = [
'password',
'remember_token',
];
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
}
}
+142
View File
@@ -0,0 +1,142 @@
<?php
namespace App\Observers;
use App\Models\Evento;
use App\Models\EquipoSeguimiento;
use App\Models\Jugador;
use App\Services\NotificacionService;
use Illuminate\Support\Facades\DB;
class EventoObserver
{
private NotificacionService $notifService;
public function __construct(NotificacionService $notifService)
{
$this->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);
}
}
}
+57
View File
@@ -0,0 +1,57 @@
<?php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
use App\Models\Evento;
use App\Observers\EventoObserver;
use App\Services\NotificacionService;
class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
$this->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);
}
});
}
}
+138
View File
@@ -0,0 +1,138 @@
<?php
namespace App\Services;
use App\Models\Torneo;
use App\Models\Equipo;
use Carbon\Carbon;
class FixtureService
{
/**
* Genera un fixture Round-Robin para los equipos de un torneo.
*
* @param Torneo $torneo
* @param string $fechaInicio 'Y-m-d'
* @param int $diasEntreJornadas
* @param string $sedeDefault
* @param bool $dobleRueda Si true, genera ida y vuelta
* @return array Array de arrays con los datos de cada evento a crear
*/
public function generarRoundRobin(
Torneo $torneo,
string $fechaInicio,
int $diasEntreJornadas = 7,
?string $sedeDefault = '',
bool $dobleRueda = false
): array {
$equipos = $torneo->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;
}
}
+229
View File
@@ -0,0 +1,229 @@
<?php
namespace App\Services;
use App\AI\Prompts\SystemPromptAdmin;
use App\AI\Prompts\SystemPromptPublic;
use App\AI\Tools\CargarPuntajeTool;
use App\AI\Tools\CrearPartidoTool;
use App\AI\Tools\EliminarNoticiaTool;
use App\AI\Tools\EliminarPartidoTool;
use App\AI\Tools\ListarEquiposTool;
use App\AI\Tools\ListarEventosTool;
use App\AI\Tools\ListarTorneosTool;
use App\AI\Tools\RedactarNoticiaTool;
use App\Models\AgentThread;
use Illuminate\Support\Facades\Log;
use Prism\Prism\Exceptions\PrismRateLimitedException;
use Prism\Prism\Facades\Prism;
use Prism\Prism\Facades\Tool;
use Prism\Prism\ValueObjects\Messages\AssistantMessage;
use Prism\Prism\ValueObjects\Messages\UserMessage;
use Throwable;
class GeniusAgentService
{
private const PROVIDER = 'gemini';
private const MAX_STEPS = 6;
private function model(): string
{
return config('services.genius.model', 'gemini-2.5-flash-lite');
}
public function chatPublic(string $message, array $history): string
{
$messages = $this->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<int, \Prism\Prism\Contracts\Message> */
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<int, \Prism\Prism\Tool> */
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);
}
}
+106
View File
@@ -0,0 +1,106 @@
<?php
namespace App\Services;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
class ImageOptimizer
{
/**
* Configuracion por carpeta. Una unica fuente de verdad — usada por el
* comando optimize:images y por los uploads de los controllers admin.
*/
public const FOLDERS = [
'carousel' => ['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];
}
}
+211
View File
@@ -0,0 +1,211 @@
<?php
namespace App\Services;
use App\Models\Notificacion;
use App\Models\PushSubscription;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Minishlink\WebPush\WebPush;
use Minishlink\WebPush\Subscription;
class NotificacionService
{
/**
* Enviar una notificación in-app a un usuario (jugador o aficionado).
*/
public function enviar(
string $tipo_dest,
string|int $id_dest,
string $tipo,
string $titulo,
string $mensaje,
?string $url = null
): Notificacion {
$notif = Notificacion::create([
'tipo_destinatario' => $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();
}
}
+149
View File
@@ -0,0 +1,149 @@
<?php
namespace App\Services;
use App\Models\Torneo;
use App\Models\Evento;
use Illuminate\Support\Facades\DB;
class TournamentService
{
/**
* Calcula la tabla de posiciones de un torneo.
*
* @param int $idTorneo
* @param bool $onlyRegular Si true, solo toma partidos de fase regular
* @return array
*/
public function getStandings(int $idTorneo, bool $onlyRegular = true): array
{
$torneo = Torneo::with('equipos.club')->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;
}
}