Ahora si(?

This commit is contained in:
Laucha1312
2026-06-04 15:01:53 -03:00
parent 47408d49fc
commit 8fc619f9e7
127 changed files with 12952 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;
}
}
+72
View File
@@ -0,0 +1,72 @@
<?php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
$middleware->web(append: [
\App\Http\Middleware\SecurityHeaders::class,
]);
})
->withExceptions(function (Exceptions $exceptions): void {
// Renderer amigable para errores en /admin/*: convierte excepciones técnicas
// (especialmente SQL) en una redirección back() con un mensaje claro que el
// layout admin muestra como SweetAlert modal. Detalle técnico va al log.
$exceptions->render(function (\Throwable $e, $request) {
if (config('app.debug')) {
return null; // En local/debug mantenemos la pantalla detallada de Laravel.
}
if (!$request->is('admin/*')) {
return null;
}
if ($request->expectsJson() || $request->ajax()) {
return null;
}
// Excepciones que Laravel ya maneja bien (validación, auth, 404, etc.).
$passthrough = [
\Illuminate\Validation\ValidationException::class,
\Illuminate\Auth\AuthenticationException::class,
\Illuminate\Auth\Access\AuthorizationException::class,
\Illuminate\Session\TokenMismatchException::class,
\Symfony\Component\HttpKernel\Exception\HttpException::class,
\Symfony\Component\HttpKernel\Exception\NotFoundHttpException::class,
\Illuminate\Database\Eloquent\ModelNotFoundException::class,
];
foreach ($passthrough as $cls) {
if ($e instanceof $cls) return null;
}
if ($e instanceof \Illuminate\Database\QueryException) {
$code = (int) ($e->errorInfo[1] ?? 0);
$msg = match (true) {
$code === 1062 => 'Ya existe un registro con esos datos. Verificá si no fue cargado previamente o si se encuentra en la papelera.',
in_array($code, [1451, 1452], true) => 'Esta operación no se puede completar porque existen datos relacionados (jugadores, equipos o eventos vinculados).',
default => 'Hubo un problema con la base de datos. Intentá nuevamente o contactá al administrador.',
};
\Log::error('[admin] QueryException', [
'code' => $code,
'message' => $e->getMessage(),
'url' => $request->fullUrl(),
]);
return back()->withInput()->with('admin_error_modal', $msg);
}
\Log::error('[admin] Excepción no manejada', [
'exception' => $e->getMessage(),
'class' => get_class($e),
'url' => $request->fullUrl(),
]);
return back()->withInput()->with(
'admin_error_modal',
'Ocurrió un error inesperado al procesar la solicitud. Intentá nuevamente o contactá al administrador.'
);
});
})->create();
+2
View File
@@ -0,0 +1,2 @@
*
!.gitignore
+7
View File
@@ -0,0 +1,7 @@
<?php
use App\Providers\AppServiceProvider;
return [
AppServiceProvider::class,
];
+126
View File
@@ -0,0 +1,126 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Application Name
|--------------------------------------------------------------------------
|
| This value is the name of your application, which will be used when the
| framework needs to place the application's name in a notification or
| other UI elements where an application name needs to be displayed.
|
*/
'name' => env('APP_NAME', 'Laravel'),
/*
|--------------------------------------------------------------------------
| Application Environment
|--------------------------------------------------------------------------
|
| This value determines the "environment" your application is currently
| running in. This may determine how you prefer to configure various
| services the application utilizes. Set this in your ".env" file.
|
*/
'env' => env('APP_ENV', 'production'),
/*
|--------------------------------------------------------------------------
| Application Debug Mode
|--------------------------------------------------------------------------
|
| When your application is in debug mode, detailed error messages with
| stack traces will be shown on every error that occurs within your
| application. If disabled, a simple generic error page is shown.
|
*/
'debug' => (bool) env('APP_DEBUG', false),
/*
|--------------------------------------------------------------------------
| Application URL
|--------------------------------------------------------------------------
|
| This URL is used by the console to properly generate URLs when using
| the Artisan command line tool. You should set this to the root of
| the application so that it's available within Artisan commands.
|
*/
'url' => env('APP_URL', 'http://localhost'),
/*
|--------------------------------------------------------------------------
| Application Timezone
|--------------------------------------------------------------------------
|
| Here you may specify the default timezone for your application, which
| will be used by the PHP date and date-time functions. The timezone
| is set to "UTC" by default as it is suitable for most use cases.
|
*/
'timezone' => env('APP_TIMEZONE', 'UTC'),
/*
|--------------------------------------------------------------------------
| Application Locale Configuration
|--------------------------------------------------------------------------
|
| The application locale determines the default locale that will be used
| by Laravel's translation / localization methods. This option can be
| set to any locale for which you plan to have translation strings.
|
*/
'locale' => env('APP_LOCALE', 'es'),
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'es'),
'faker_locale' => env('APP_FAKER_LOCALE', 'es_AR'),
/*
|--------------------------------------------------------------------------
| Encryption Key
|--------------------------------------------------------------------------
|
| This key is utilized by Laravel's encryption services and should be set
| to a random, 32 character string to ensure that all encrypted values
| are secure. You should do this prior to deploying the application.
|
*/
'cipher' => 'AES-256-CBC',
'key' => env('APP_KEY'),
'previous_keys' => [
...array_filter(
explode(',', (string) env('APP_PREVIOUS_KEYS', ''))
),
],
/*
|--------------------------------------------------------------------------
| Maintenance Mode Driver
|--------------------------------------------------------------------------
|
| These configuration options determine the driver used to determine and
| manage Laravel's "maintenance mode" status. The "cache" driver will
| allow maintenance mode to be controlled across multiple machines.
|
| Supported drivers: "file", "cache"
|
*/
'maintenance' => [
'driver' => env('APP_MAINTENANCE_DRIVER', 'file'),
'store' => env('APP_MAINTENANCE_STORE', 'database'),
],
];
+117
View File
@@ -0,0 +1,117 @@
<?php
use App\Models\User;
return [
/*
|--------------------------------------------------------------------------
| Authentication Defaults
|--------------------------------------------------------------------------
|
| This option defines the default authentication "guard" and password
| reset "broker" for your application. You may change these values
| as required, but they're a perfect start for most applications.
|
*/
'defaults' => [
'guard' => env('AUTH_GUARD', 'web'),
'passwords' => env('AUTH_PASSWORD_BROKER', 'users'),
],
/*
|--------------------------------------------------------------------------
| Authentication Guards
|--------------------------------------------------------------------------
|
| Next, you may define every authentication guard for your application.
| Of course, a great default configuration has been defined for you
| which utilizes session storage plus the Eloquent user provider.
|
| All authentication guards have a user provider, which defines how the
| users are actually retrieved out of your database or other storage
| system used by the application. Typically, Eloquent is utilized.
|
| Supported: "session"
|
*/
'guards' => [
'web' => [
'driver' => 'session',
'provider' => 'users',
],
],
/*
|--------------------------------------------------------------------------
| User Providers
|--------------------------------------------------------------------------
|
| All authentication guards have a user provider, which defines how the
| users are actually retrieved out of your database or other storage
| system used by the application. Typically, Eloquent is utilized.
|
| If you have multiple user tables or models you may configure multiple
| providers to represent the model / table. These providers may then
| be assigned to any extra authentication guards you have defined.
|
| Supported: "database", "eloquent"
|
*/
'providers' => [
'users' => [
'driver' => 'eloquent',
'model' => env('AUTH_MODEL', User::class),
],
// 'users' => [
// 'driver' => 'database',
// 'table' => 'users',
// ],
],
/*
|--------------------------------------------------------------------------
| Resetting Passwords
|--------------------------------------------------------------------------
|
| These configuration options specify the behavior of Laravel's password
| reset functionality, including the table utilized for token storage
| and the user provider that is invoked to actually retrieve users.
|
| The expiry time is the number of minutes that each reset token will be
| considered valid. This security feature keeps tokens short-lived so
| they have less time to be guessed. You may change this as needed.
|
| The throttle setting is the number of seconds a user must wait before
| generating more password reset tokens. This prevents the user from
| quickly generating a very large amount of password reset tokens.
|
*/
'passwords' => [
'users' => [
'provider' => 'users',
'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
'expire' => 60,
'throttle' => 60,
],
],
/*
|--------------------------------------------------------------------------
| Password Confirmation Timeout
|--------------------------------------------------------------------------
|
| Here you may define the number of seconds before a password confirmation
| window expires and users are asked to re-enter their password via the
| confirmation screen. By default, the timeout lasts for three hours.
|
*/
'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),
];
+387
View File
@@ -0,0 +1,387 @@
<?php
use Spatie\Backup\Notifications\Notifiable;
use Spatie\Backup\Notifications\Notifications\BackupHasFailedNotification;
use Spatie\Backup\Notifications\Notifications\BackupWasSuccessfulNotification;
use Spatie\Backup\Notifications\Notifications\CleanupHasFailedNotification;
use Spatie\Backup\Notifications\Notifications\CleanupWasSuccessfulNotification;
use Spatie\Backup\Notifications\Notifications\HealthyBackupWasFoundNotification;
use Spatie\Backup\Notifications\Notifications\UnhealthyBackupWasFoundNotification;
use Spatie\Backup\Tasks\Cleanup\Strategies\DefaultStrategy;
use Spatie\Backup\Tasks\Monitor\HealthChecks\MaximumAgeInDays;
use Spatie\Backup\Tasks\Monitor\HealthChecks\MaximumStorageInMegabytes;
return [
'backup' => [
/*
* The name of this application. You can use this name to monitor
* the backups.
*/
'name' => env('APP_NAME', 'laravel-backup'),
'source' => [
'files' => [
/*
* The list of directories and files that will be included in the backup.
*/
'include' => [
base_path(),
// storage_path(), // Include if you use zero downtime deployments and don't follow symlinks
],
/*
* These directories and files will be excluded from the backup.
*
* Directories used by the backup process will automatically be excluded.
*/
'exclude' => [
base_path('vendor'),
base_path('node_modules'),
storage_path('framework'),
],
/*
* Determines if symlinks should be followed.
*/
'follow_links' => false,
/*
* Determines if it should avoid unreadable folders.
*/
'ignore_unreadable_directories' => false,
/*
* This path is used to make directories in resulting zip-file relative
* Set to `null` to include complete absolute path
* Example: base_path()
*/
'relative_path' => null,
],
/*
* The names of the connections to the databases that should be backed up
* MySQL, PostgreSQL, SQLite and Mongo databases are supported.
*
* The content of the database dump may be customized for each connection
* by adding a 'dump' key to the connection settings in config/database.php.
* E.g.
* 'mysql' => [
* ...
* 'dump' => [
* 'exclude_tables' => [
* 'table_to_exclude_from_backup',
* 'another_table_to_exclude'
* ]
* ],
* ],
*
* If you are using only InnoDB tables on a MySQL server, you can
* also supply the useSingleTransaction option to avoid table locking.
*
* E.g.
* 'mysql' => [
* ...
* 'dump' => [
* 'useSingleTransaction' => true,
* ],
* ],
*
* For a complete list of available customization options, see https://github.com/spatie/db-dumper
*/
'databases' => [
env('DB_CONNECTION', 'mysql'),
],
],
/*
* The database dump can be compressed to decrease disk space usage.
*
* Out of the box Laravel-backup supplies
* Spatie\DbDumper\Compressors\GzipCompressor::class.
*
* You can also create custom compressor. More info on that here:
* https://github.com/spatie/db-dumper#using-compression
*
* If you do not want any compressor at all, set it to null.
*/
'database_dump_compressor' => null,
/*
* If specified, the database dumped file name will contain a timestamp (e.g.: 'Y-m-d-H-i-s').
*/
'database_dump_file_timestamp_format' => null,
/*
* The base of the dump filename, either 'database' or 'connection'
*
* If 'database' (default), the dumped filename will contain the database name.
* If 'connection', the dumped filename will contain the connection name.
*/
'database_dump_filename_base' => 'database',
/*
* The file extension used for the database dump files.
*
* If not specified, the file extension will be .archive for MongoDB and .sql for all other databases
* The file extension should be specified without a leading .
*/
'database_dump_file_extension' => '',
'destination' => [
/*
* The compression algorithm to be used for creating the zip archive.
*
* If backing up only database, you may choose gzip compression for db dump and no compression at zip.
*
* Some common algorithms are listed below:
* ZipArchive::CM_STORE (no compression at all; set 0 as compression level)
* ZipArchive::CM_DEFAULT
* ZipArchive::CM_DEFLATE
* ZipArchive::CM_BZIP2
* ZipArchive::CM_XZ
*
* For more check https://www.php.net/manual/zip.constants.php and confirm it's supported by your system.
*/
'compression_method' => ZipArchive::CM_DEFAULT,
/*
* The compression level corresponding to the used algorithm; an integer between 0 and 9.
*
* Check supported levels for the chosen algorithm, usually 1 means the fastest and weakest compression,
* while 9 the slowest and strongest one.
*
* Setting of 0 for some algorithms may switch to the strongest compression.
*/
'compression_level' => 9,
/*
* The filename prefix used for the backup zip file.
*/
'filename_prefix' => '',
/*
* The disk names on which the backups will be stored.
*/
'disks' => [
'local',
],
/*
* Determines whether to allow backups to continue when some targets fail instead of failing completely.
*/
'continue_on_failure' => false,
],
/*
* The directory where the temporary files will be stored.
*/
'temporary_directory' => storage_path('app/backup-temp'),
/*
* The password to be used for archive encryption.
* Set to `null` to disable encryption.
*/
'password' => env('BACKUP_ARCHIVE_PASSWORD'),
/*
* The encryption algorithm to be used for archive encryption.
* Set to 'none' to disable encryption.
*
* Supported: 'none', 'default', 'aes128', 'aes192', 'aes256'
*
* When set to 'default', we'll use AES-256 if available on your system.
*/
'encryption' => 'default',
/*
* After creating the zip, verify it can be opened and contains files.
* Recommended for critical backups but adds a small overhead.
*/
'verify_backup' => false,
/*
* The number of attempts, in case the backup command encounters an exception
*/
'tries' => 1,
/*
* The number of seconds to wait before attempting a new backup if the previous try failed
* Set to `0` for none
*/
'retry_delay' => 0,
],
/*
* You can get notified when specific events occur. Out of the box you can use 'mail' and 'slack'.
* For Slack you need to install laravel/slack-notification-channel.
*
* You can also use your own notification classes, just make sure the class is named after one of
* the `Spatie\Backup\Notifications\Notifications` classes.
*/
'notifications' => [
'notifications' => [
BackupHasFailedNotification::class => ['mail'],
UnhealthyBackupWasFoundNotification::class => ['mail'],
CleanupHasFailedNotification::class => ['mail'],
BackupWasSuccessfulNotification::class => env('BACKUP_NOTIFY_SUCCESS', false) ? ['mail'] : [],
HealthyBackupWasFoundNotification::class => env('BACKUP_NOTIFY_SUCCESS', false) ? ['mail'] : [],
CleanupWasSuccessfulNotification::class => env('BACKUP_NOTIFY_SUCCESS', false) ? ['mail'] : [],
],
/*
* Here you can specify the notifiable to which the notifications should be sent. The default
* notifiable will use the variables specified in this config file.
*/
'notifiable' => Notifiable::class,
'mail' => [
'to' => env('BACKUP_NOTIFICATION_EMAIL', env('MAIL_FROM_ADDRESS')),
'from' => [
'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'),
'name' => env('MAIL_FROM_NAME', 'Example'),
],
],
'slack' => [
'webhook_url' => '',
/*
* If this is set to null the default channel of the webhook will be used.
*/
'channel' => null,
'username' => null,
'icon' => null,
],
'discord' => [
'webhook_url' => '',
/*
* If this is an empty string, the name field on the webhook will be used.
*/
'username' => '',
/*
* If this is an empty string, the avatar on the webhook will be used.
*/
'avatar_url' => '',
],
/*
* A generic webhook channel that POSTs JSON to a URL.
* Useful for Mattermost, Microsoft Teams, or custom integrations.
*/
'webhook' => [
'url' => '',
],
],
/*
* The log channel used for backup activity messages.
*
* Set to a channel name defined in config/logging.php to use that channel.
* Set to false to disable backup logging entirely.
* Set to null to use the default log channel.
*/
'log_channel' => null,
/*
* Here you can specify which backups should be monitored.
* If a backup does not meet the specified requirements the
* UnHealthyBackupWasFound event will be fired.
*/
'monitor_backups' => [
[
'name' => env('APP_NAME', 'laravel-backup'),
'disks' => ['local'],
'health_checks' => [
MaximumAgeInDays::class => 1,
MaximumStorageInMegabytes::class => 5000,
],
],
/*
[
'name' => 'name of the second app',
'disks' => ['local', 's3'],
'health_checks' => [
\Spatie\Backup\Tasks\Monitor\HealthChecks\MaximumAgeInDays::class => 1,
\Spatie\Backup\Tasks\Monitor\HealthChecks\MaximumStorageInMegabytes::class => 5000,
],
],
*/
],
'cleanup' => [
/*
* The strategy that will be used to cleanup old backups. The default strategy
* will keep all backups for a certain amount of days. After that period only
* a daily backup will be kept. After that period only weekly backups will
* be kept and so on.
*
* No matter how you configure it the default strategy will never
* delete the newest backup.
*/
'strategy' => DefaultStrategy::class,
'default_strategy' => [
/*
* Política OnAPB: mantener solo la última semana de backups.
* Resultado esperado en estado estable: máximo ~7 backups
* (uno por día), con tope duro de 1 GB.
*/
/*
* Días para los que se conservan TODOS los backups del día.
* Con backup:run diario, normalmente solo hay 1 por día,
* así que esto cubre los últimos 3 días por seguridad.
*/
'keep_all_backups_for_days' => 3,
/*
* Después de los "all", se conserva solo el backup más reciente
* de cada día durante esta cantidad de días.
* 7 días = última semana de backups disponibles.
*/
'keep_daily_backups_for_days' => 7,
/*
* Sin backups semanales históricos (se borran al pasar la semana).
*/
'keep_weekly_backups_for_weeks' => 0,
/*
* Sin backups mensuales históricos.
*/
'keep_monthly_backups_for_months' => 0,
/*
* Sin backups anuales históricos.
*/
'keep_yearly_backups_for_years' => 0,
/*
* Tope duro de espacio: si la carpeta de backups supera 1 GB,
* borrar los más antiguos hasta volver bajo el límite.
* Esto cubre el caso de un backup que crezca mucho de golpe.
*/
'delete_oldest_backups_when_using_more_megabytes_than' => 1000,
],
/*
* The number of attempts, in case the cleanup command encounters an exception
*/
'tries' => 1,
/*
* The number of seconds to wait before attempting a new cleanup if the previous try failed
* Set to `0` for none
*/
'retry_delay' => 0,
],
];
+117
View File
@@ -0,0 +1,117 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Default Cache Store
|--------------------------------------------------------------------------
|
| This option controls the default cache store that will be used by the
| framework. This connection is utilized if another isn't explicitly
| specified when running a cache operation inside the application.
|
*/
'default' => env('CACHE_STORE', 'database'),
/*
|--------------------------------------------------------------------------
| Cache Stores
|--------------------------------------------------------------------------
|
| Here you may define all of the cache "stores" for your application as
| well as their drivers. You may even define multiple stores for the
| same cache driver to group types of items stored in your caches.
|
| Supported drivers: "array", "database", "file", "memcached",
| "redis", "dynamodb", "octane",
| "failover", "null"
|
*/
'stores' => [
'array' => [
'driver' => 'array',
'serialize' => false,
],
'database' => [
'driver' => 'database',
'connection' => env('DB_CACHE_CONNECTION'),
'table' => env('DB_CACHE_TABLE', 'cache'),
'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'),
'lock_table' => env('DB_CACHE_LOCK_TABLE'),
],
'file' => [
'driver' => 'file',
'path' => storage_path('framework/cache/data'),
'lock_path' => storage_path('framework/cache/data'),
],
'memcached' => [
'driver' => 'memcached',
'persistent_id' => env('MEMCACHED_PERSISTENT_ID'),
'sasl' => [
env('MEMCACHED_USERNAME'),
env('MEMCACHED_PASSWORD'),
],
'options' => [
// Memcached::OPT_CONNECT_TIMEOUT => 2000,
],
'servers' => [
[
'host' => env('MEMCACHED_HOST', '127.0.0.1'),
'port' => env('MEMCACHED_PORT', 11211),
'weight' => 100,
],
],
],
'redis' => [
'driver' => 'redis',
'connection' => env('REDIS_CACHE_CONNECTION', 'cache'),
'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'),
],
'dynamodb' => [
'driver' => 'dynamodb',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
'table' => env('DYNAMODB_CACHE_TABLE', 'cache'),
'endpoint' => env('DYNAMODB_ENDPOINT'),
],
'octane' => [
'driver' => 'octane',
],
'failover' => [
'driver' => 'failover',
'stores' => [
'database',
'array',
],
],
],
/*
|--------------------------------------------------------------------------
| Cache Key Prefix
|--------------------------------------------------------------------------
|
| When utilizing the APC, database, memcached, Redis, and DynamoDB cache
| stores, there might be other applications using the same cache. For
| that reason, you may prefix every cache key to avoid collisions.
|
*/
'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'),
];
+184
View File
@@ -0,0 +1,184 @@
<?php
use Illuminate\Support\Str;
use Pdo\Mysql;
return [
/*
|--------------------------------------------------------------------------
| Default Database Connection Name
|--------------------------------------------------------------------------
|
| Here you may specify which of the database connections below you wish
| to use as your default connection for database operations. This is
| the connection which will be utilized unless another connection
| is explicitly specified when you execute a query / statement.
|
*/
'default' => env('DB_CONNECTION', 'sqlite'),
/*
|--------------------------------------------------------------------------
| Database Connections
|--------------------------------------------------------------------------
|
| Below are all of the database connections defined for your application.
| An example configuration is provided for each database system which
| is supported by Laravel. You're free to add / remove connections.
|
*/
'connections' => [
'sqlite' => [
'driver' => 'sqlite',
'url' => env('DB_URL'),
'database' => env('DB_DATABASE', database_path('database.sqlite')),
'prefix' => '',
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
'busy_timeout' => null,
'journal_mode' => null,
'synchronous' => null,
'transaction_mode' => 'DEFERRED',
],
'mysql' => [
'driver' => 'mysql',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'unix_socket' => env('DB_SOCKET', ''),
'charset' => env('DB_CHARSET', 'utf8mb4'),
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
'prefix' => '',
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
(PHP_VERSION_ID >= 80500 ? Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
'mariadb' => [
'driver' => 'mariadb',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'unix_socket' => env('DB_SOCKET', ''),
'charset' => env('DB_CHARSET', 'utf8mb4'),
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
'prefix' => '',
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
(PHP_VERSION_ID >= 80500 ? Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
'pgsql' => [
'driver' => 'pgsql',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '5432'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'charset' => env('DB_CHARSET', 'utf8'),
'prefix' => '',
'prefix_indexes' => true,
'search_path' => 'public',
'sslmode' => env('DB_SSLMODE', 'prefer'),
],
'sqlsrv' => [
'driver' => 'sqlsrv',
'url' => env('DB_URL'),
'host' => env('DB_HOST', 'localhost'),
'port' => env('DB_PORT', '1433'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'charset' => env('DB_CHARSET', 'utf8'),
'prefix' => '',
'prefix_indexes' => true,
// 'encrypt' => env('DB_ENCRYPT', 'yes'),
// 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'),
],
],
/*
|--------------------------------------------------------------------------
| Migration Repository Table
|--------------------------------------------------------------------------
|
| This table keeps track of all the migrations that have already run for
| your application. Using this information, we can determine which of
| the migrations on disk haven't actually been run on the database.
|
*/
'migrations' => [
'table' => 'migrations',
'update_date_on_publish' => true,
],
/*
|--------------------------------------------------------------------------
| Redis Databases
|--------------------------------------------------------------------------
|
| Redis is an open source, fast, and advanced key-value store that also
| provides a richer body of commands than a typical key-value system
| such as Memcached. You may define your connection settings here.
|
*/
'redis' => [
'client' => env('REDIS_CLIENT', 'phpredis'),
'options' => [
'cluster' => env('REDIS_CLUSTER', 'redis'),
'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'),
'persistent' => env('REDIS_PERSISTENT', false),
],
'default' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_DB', '0'),
'max_retries' => env('REDIS_MAX_RETRIES', 3),
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
],
'cache' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_CACHE_DB', '1'),
'max_retries' => env('REDIS_MAX_RETRIES', 3),
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
],
],
];
+81
View File
@@ -0,0 +1,81 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Filesystem Disk
|--------------------------------------------------------------------------
|
| Here you may specify the default filesystem disk that should be used
| by the framework. The "local" disk, as well as a variety of cloud
| based disks are available to your application for file storage.
|
*/
'default' => env('FILESYSTEM_DISK', 'local'),
/*
|--------------------------------------------------------------------------
| Filesystem Disks
|--------------------------------------------------------------------------
|
| Below you may configure as many filesystem disks as necessary, and you
| may even configure multiple disks for the same driver. Examples for
| most supported storage drivers are configured here for reference.
|
| Supported drivers: "local", "ftp", "sftp", "s3"
|
*/
'disks' => [
'local' => [
'driver' => 'local',
'root' => storage_path('app/private'),
'serve' => true,
'throw' => false,
'report' => false,
],
'public' => [
'driver' => 'local',
// Le decimos que salga de la carpeta laravel y guarde directo en public_html
'root' => base_path('../public_html/storage'),
'url' => rtrim(env('APP_URL', 'http://localhost'), '/').'/storage',
'visibility' => 'public',
'throw' => false,
'report' => false,
],
's3' => [
'driver' => 's3',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION'),
'bucket' => env('AWS_BUCKET'),
'url' => env('AWS_URL'),
'endpoint' => env('AWS_ENDPOINT'),
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
'throw' => false,
'report' => false,
],
],
/*
|--------------------------------------------------------------------------
| Symbolic Links
|--------------------------------------------------------------------------
|
| Here you may configure the symbolic links that will be created when the
| `storage:link` Artisan command is executed. The array keys should be
| the locations of the links and the values should be their targets.
|
*/
'links' => [
public_path('storage') => storage_path('app/public'),
],
];
+132
View File
@@ -0,0 +1,132 @@
<?php
use Monolog\Handler\NullHandler;
use Monolog\Handler\StreamHandler;
use Monolog\Handler\SyslogUdpHandler;
use Monolog\Processor\PsrLogMessageProcessor;
return [
/*
|--------------------------------------------------------------------------
| Default Log Channel
|--------------------------------------------------------------------------
|
| This option defines the default log channel that is utilized to write
| messages to your logs. The value provided here should match one of
| the channels present in the list of "channels" configured below.
|
*/
'default' => env('LOG_CHANNEL', 'stack'),
/*
|--------------------------------------------------------------------------
| Deprecations Log Channel
|--------------------------------------------------------------------------
|
| This option controls the log channel that should be used to log warnings
| regarding deprecated PHP and library features. This allows you to get
| your application ready for upcoming major versions of dependencies.
|
*/
'deprecations' => [
'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'),
'trace' => env('LOG_DEPRECATIONS_TRACE', false),
],
/*
|--------------------------------------------------------------------------
| Log Channels
|--------------------------------------------------------------------------
|
| Here you may configure the log channels for your application. Laravel
| utilizes the Monolog PHP logging library, which includes a variety
| of powerful log handlers and formatters that you're free to use.
|
| Available drivers: "single", "daily", "slack", "syslog",
| "errorlog", "monolog", "custom", "stack"
|
*/
'channels' => [
'stack' => [
'driver' => 'stack',
'channels' => explode(',', (string) env('LOG_STACK', 'single')),
'ignore_exceptions' => false,
],
'single' => [
'driver' => 'single',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'replace_placeholders' => true,
],
'daily' => [
'driver' => 'daily',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'days' => env('LOG_DAILY_DAYS', 14),
'replace_placeholders' => true,
],
'slack' => [
'driver' => 'slack',
'url' => env('LOG_SLACK_WEBHOOK_URL'),
'username' => env('LOG_SLACK_USERNAME', 'Laravel Log'),
'emoji' => env('LOG_SLACK_EMOJI', ':boom:'),
'level' => env('LOG_LEVEL', 'critical'),
'replace_placeholders' => true,
],
'papertrail' => [
'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'),
'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class),
'handler_with' => [
'host' => env('PAPERTRAIL_URL'),
'port' => env('PAPERTRAIL_PORT'),
'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'),
],
'processors' => [PsrLogMessageProcessor::class],
],
'stderr' => [
'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'),
'handler' => StreamHandler::class,
'handler_with' => [
'stream' => 'php://stderr',
],
'formatter' => env('LOG_STDERR_FORMATTER'),
'processors' => [PsrLogMessageProcessor::class],
],
'syslog' => [
'driver' => 'syslog',
'level' => env('LOG_LEVEL', 'debug'),
'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER),
'replace_placeholders' => true,
],
'errorlog' => [
'driver' => 'errorlog',
'level' => env('LOG_LEVEL', 'debug'),
'replace_placeholders' => true,
],
'null' => [
'driver' => 'monolog',
'handler' => NullHandler::class,
],
'emergency' => [
'path' => storage_path('logs/laravel.log'),
],
],
];
+118
View File
@@ -0,0 +1,118 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Mailer
|--------------------------------------------------------------------------
|
| This option controls the default mailer that is used to send all email
| messages unless another mailer is explicitly specified when sending
| the message. All additional mailers can be configured within the
| "mailers" array. Examples of each type of mailer are provided.
|
*/
'default' => env('MAIL_MAILER', 'log'),
/*
|--------------------------------------------------------------------------
| Mailer Configurations
|--------------------------------------------------------------------------
|
| Here you may configure all of the mailers used by your application plus
| their respective settings. Several examples have been configured for
| you and you are free to add your own as your application requires.
|
| Laravel supports a variety of mail "transport" drivers that can be used
| when delivering an email. You may specify which one you're using for
| your mailers below. You may also add additional mailers if needed.
|
| Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2",
| "postmark", "resend", "log", "array",
| "failover", "roundrobin"
|
*/
'mailers' => [
'smtp' => [
'transport' => 'smtp',
'scheme' => env('MAIL_SCHEME'),
'url' => env('MAIL_URL'),
'host' => env('MAIL_HOST', '127.0.0.1'),
'port' => env('MAIL_PORT', 2525),
'username' => env('MAIL_USERNAME'),
'password' => env('MAIL_PASSWORD'),
'timeout' => null,
'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)),
],
'ses' => [
'transport' => 'ses',
],
'postmark' => [
'transport' => 'postmark',
// 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'),
// 'client' => [
// 'timeout' => 5,
// ],
],
'resend' => [
'transport' => 'resend',
],
'sendmail' => [
'transport' => 'sendmail',
'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'),
],
'log' => [
'transport' => 'log',
'channel' => env('MAIL_LOG_CHANNEL'),
],
'array' => [
'transport' => 'array',
],
'failover' => [
'transport' => 'failover',
'mailers' => [
'smtp',
'log',
],
'retry_after' => 60,
],
'roundrobin' => [
'transport' => 'roundrobin',
'mailers' => [
'ses',
'postmark',
],
'retry_after' => 60,
],
],
/*
|--------------------------------------------------------------------------
| Global "From" Address
|--------------------------------------------------------------------------
|
| You may wish for all emails sent by your application to be sent from
| the same address. Here you may specify a name and address that is
| used globally for all emails that are sent by your application.
|
*/
'from' => [
'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'),
'name' => env('MAIL_FROM_NAME', env('APP_NAME', 'Laravel')),
],
];
+73
View File
@@ -0,0 +1,73 @@
<?php
return [
'prism_server' => [
// The middleware that will be applied to the Prism Server routes.
'middleware' => [],
'enabled' => env('PRISM_SERVER_ENABLED', false),
],
'request_timeout' => env('PRISM_REQUEST_TIMEOUT', 30), // The timeout for requests in seconds.
'providers' => [
'openai' => [
'url' => env('OPENAI_URL', 'https://api.openai.com/v1'),
'api_key' => env('OPENAI_API_KEY', ''),
'organization' => env('OPENAI_ORGANIZATION', null),
'project' => env('OPENAI_PROJECT', null),
],
'anthropic' => [
'api_key' => env('ANTHROPIC_API_KEY', ''),
'version' => env('ANTHROPIC_API_VERSION', '2023-06-01'),
'url' => env('ANTHROPIC_URL', 'https://api.anthropic.com/v1'),
'default_thinking_budget' => env('ANTHROPIC_DEFAULT_THINKING_BUDGET', 1024),
// Include beta strings as a comma separated list.
'anthropic_beta' => env('ANTHROPIC_BETA', null),
],
'ollama' => [
'url' => env('OLLAMA_URL', 'http://localhost:11434'),
],
'mistral' => [
'api_key' => env('MISTRAL_API_KEY', ''),
'url' => env('MISTRAL_URL', 'https://api.mistral.ai/v1'),
],
'groq' => [
'api_key' => env('GROQ_API_KEY', ''),
'url' => env('GROQ_URL', 'https://api.groq.com/openai/v1'),
],
'xai' => [
'api_key' => env('XAI_API_KEY', ''),
'url' => env('XAI_URL', 'https://api.x.ai/v1'),
],
'gemini' => [
'api_key' => env('GEMINI_API_KEY', ''),
'url' => env('GEMINI_URL', 'https://generativelanguage.googleapis.com/v1beta/models'),
],
'deepseek' => [
'api_key' => env('DEEPSEEK_API_KEY', ''),
'url' => env('DEEPSEEK_URL', 'https://api.deepseek.com/v1'),
],
'elevenlabs' => [
'api_key' => env('ELEVENLABS_API_KEY', ''),
'url' => env('ELEVENLABS_URL', 'https://api.elevenlabs.io/v1/'),
],
'voyageai' => [
'api_key' => env('VOYAGEAI_API_KEY', ''),
'url' => env('VOYAGEAI_URL', 'https://api.voyageai.com/v1'),
],
'openrouter' => [
'api_key' => env('OPENROUTER_API_KEY', ''),
'url' => env('OPENROUTER_URL', 'https://openrouter.ai/api/v1'),
'site' => [
'http_referer' => env('OPENROUTER_SITE_HTTP_REFERER', null),
'x_title' => env('OPENROUTER_SITE_X_TITLE', null),
],
],
'perplexity' => [
'api_key' => env('PERPLEXITY_API_KEY', ''),
'url' => env('PERPLEXITY_URL', 'https://api.perplexity.ai'),
],
'z' => [
'url' => env('Z_URL', 'https://api.z.ai/api/paas/v4'),
'api_key' => env('Z_API_KEY', ''),
],
],
];
+129
View File
@@ -0,0 +1,129 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Queue Connection Name
|--------------------------------------------------------------------------
|
| Laravel's queue supports a variety of backends via a single, unified
| API, giving you convenient access to each backend using identical
| syntax for each. The default queue connection is defined below.
|
*/
'default' => env('QUEUE_CONNECTION', 'database'),
/*
|--------------------------------------------------------------------------
| Queue Connections
|--------------------------------------------------------------------------
|
| Here you may configure the connection options for every queue backend
| used by your application. An example configuration is provided for
| each backend supported by Laravel. You're also free to add more.
|
| Drivers: "sync", "database", "beanstalkd", "sqs", "redis",
| "deferred", "background", "failover", "null"
|
*/
'connections' => [
'sync' => [
'driver' => 'sync',
],
'database' => [
'driver' => 'database',
'connection' => env('DB_QUEUE_CONNECTION'),
'table' => env('DB_QUEUE_TABLE', 'jobs'),
'queue' => env('DB_QUEUE', 'default'),
'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90),
'after_commit' => false,
],
'beanstalkd' => [
'driver' => 'beanstalkd',
'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'),
'queue' => env('BEANSTALKD_QUEUE', 'default'),
'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 90),
'block_for' => 0,
'after_commit' => false,
],
'sqs' => [
'driver' => 'sqs',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'),
'queue' => env('SQS_QUEUE', 'default'),
'suffix' => env('SQS_SUFFIX'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
'after_commit' => false,
],
'redis' => [
'driver' => 'redis',
'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
'queue' => env('REDIS_QUEUE', 'default'),
'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90),
'block_for' => null,
'after_commit' => false,
],
'deferred' => [
'driver' => 'deferred',
],
'background' => [
'driver' => 'background',
],
'failover' => [
'driver' => 'failover',
'connections' => [
'database',
'deferred',
],
],
],
/*
|--------------------------------------------------------------------------
| Job Batching
|--------------------------------------------------------------------------
|
| The following options configure the database and table that store job
| batching information. These options can be updated to any database
| connection and table which has been defined by your application.
|
*/
'batching' => [
'database' => env('DB_CONNECTION', 'sqlite'),
'table' => 'job_batches',
],
/*
|--------------------------------------------------------------------------
| Failed Queue Jobs
|--------------------------------------------------------------------------
|
| These options configure the behavior of failed queue job logging so you
| can control how and where failed jobs are stored. Laravel ships with
| support for storing failed jobs in a simple file or in a database.
|
| Supported drivers: "database-uuids", "dynamodb", "file", "null"
|
*/
'failed' => [
'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'),
'database' => env('DB_CONNECTION', 'sqlite'),
'table' => 'failed_jobs',
],
];
+50
View File
@@ -0,0 +1,50 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Third Party Services
|--------------------------------------------------------------------------
|
| This file is for storing the credentials for third party services such
| as Mailgun, Postmark, AWS and more. This file provides the de facto
| location for this type of information, allowing packages to have
| a conventional file to locate the various service credentials.
|
*/
'postmark' => [
'key' => env('POSTMARK_API_KEY'),
],
'resend' => [
'key' => env('RESEND_API_KEY'),
],
'ses' => [
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
],
'slack' => [
'notifications' => [
'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'),
'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'),
],
],
'turnstile' => [
'site_key' => env('TURNSTILE_SITE_KEY'),
'secret_key' => env('TURNSTILE_SECRET_KEY'),
],
'genius' => [
'model' => env('GENIUS_MODEL', 'gemini-2.5-flash-lite'),
'history_limit' => (int) env('GENIUS_HISTORY_LIMIT', 10),
'max_messages_per_session' => (int) env('GENIUS_MAX_MESSAGES_PER_SESSION', 20),
'session_window_minutes' => (int) env('GENIUS_SESSION_WINDOW_MINUTES', 60),
],
];
+217
View File
@@ -0,0 +1,217 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Default Session Driver
|--------------------------------------------------------------------------
|
| This option determines the default session driver that is utilized for
| incoming requests. Laravel supports a variety of storage options to
| persist session data. Database storage is a great default choice.
|
| Supported: "file", "cookie", "database", "memcached",
| "redis", "dynamodb", "array"
|
*/
'driver' => env('SESSION_DRIVER', 'database'),
/*
|--------------------------------------------------------------------------
| Session Lifetime
|--------------------------------------------------------------------------
|
| Here you may specify the number of minutes that you wish the session
| to be allowed to remain idle before it expires. If you want them
| to expire immediately when the browser is closed then you may
| indicate that via the expire_on_close configuration option.
|
*/
'lifetime' => (int) env('SESSION_LIFETIME', 120),
'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false),
/*
|--------------------------------------------------------------------------
| Session Encryption
|--------------------------------------------------------------------------
|
| This option allows you to easily specify that all of your session data
| should be encrypted before it's stored. All encryption is performed
| automatically by Laravel and you may use the session like normal.
|
*/
'encrypt' => env('SESSION_ENCRYPT', false),
/*
|--------------------------------------------------------------------------
| Session File Location
|--------------------------------------------------------------------------
|
| When utilizing the "file" session driver, the session files are placed
| on disk. The default storage location is defined here; however, you
| are free to provide another location where they should be stored.
|
*/
'files' => storage_path('framework/sessions'),
/*
|--------------------------------------------------------------------------
| Session Database Connection
|--------------------------------------------------------------------------
|
| When using the "database" or "redis" session drivers, you may specify a
| connection that should be used to manage these sessions. This should
| correspond to a connection in your database configuration options.
|
*/
'connection' => env('SESSION_CONNECTION'),
/*
|--------------------------------------------------------------------------
| Session Database Table
|--------------------------------------------------------------------------
|
| When using the "database" session driver, you may specify the table to
| be used to store sessions. Of course, a sensible default is defined
| for you; however, you're welcome to change this to another table.
|
*/
'table' => env('SESSION_TABLE', 'sessions'),
/*
|--------------------------------------------------------------------------
| Session Cache Store
|--------------------------------------------------------------------------
|
| When using one of the framework's cache driven session backends, you may
| define the cache store which should be used to store the session data
| between requests. This must match one of your defined cache stores.
|
| Affects: "dynamodb", "memcached", "redis"
|
*/
'store' => env('SESSION_STORE'),
/*
|--------------------------------------------------------------------------
| Session Sweeping Lottery
|--------------------------------------------------------------------------
|
| Some session drivers must manually sweep their storage location to get
| rid of old sessions from storage. Here are the chances that it will
| happen on a given request. By default, the odds are 2 out of 100.
|
*/
'lottery' => [2, 100],
/*
|--------------------------------------------------------------------------
| Session Cookie Name
|--------------------------------------------------------------------------
|
| Here you may change the name of the session cookie that is created by
| the framework. Typically, you should not need to change this value
| since doing so does not grant a meaningful security improvement.
|
*/
'cookie' => env(
'SESSION_COOKIE',
Str::slug((string) env('APP_NAME', 'laravel')).'-session'
),
/*
|--------------------------------------------------------------------------
| Session Cookie Path
|--------------------------------------------------------------------------
|
| The session cookie path determines the path for which the cookie will
| be regarded as available. Typically, this will be the root path of
| your application, but you're free to change this when necessary.
|
*/
'path' => env('SESSION_PATH', '/'),
/*
|--------------------------------------------------------------------------
| Session Cookie Domain
|--------------------------------------------------------------------------
|
| This value determines the domain and subdomains the session cookie is
| available to. By default, the cookie will be available to the root
| domain without subdomains. Typically, this shouldn't be changed.
|
*/
'domain' => env('SESSION_DOMAIN'),
/*
|--------------------------------------------------------------------------
| HTTPS Only Cookies
|--------------------------------------------------------------------------
|
| By setting this option to true, session cookies will only be sent back
| to the server if the browser has a HTTPS connection. This will keep
| the cookie from being sent to you when it can't be done securely.
|
*/
'secure' => env('SESSION_SECURE_COOKIE'),
/*
|--------------------------------------------------------------------------
| HTTP Access Only
|--------------------------------------------------------------------------
|
| Setting this value to true will prevent JavaScript from accessing the
| value of the cookie and the cookie will only be accessible through
| the HTTP protocol. It's unlikely you should disable this option.
|
*/
'http_only' => env('SESSION_HTTP_ONLY', true),
/*
|--------------------------------------------------------------------------
| Same-Site Cookies
|--------------------------------------------------------------------------
|
| This option determines how your cookies behave when cross-site requests
| take place, and can be used to mitigate CSRF attacks. By default, we
| will set this value to "lax" to permit secure cross-site requests.
|
| See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value
|
| Supported: "lax", "strict", "none", null
|
*/
'same_site' => env('SESSION_SAME_SITE', 'lax'),
/*
|--------------------------------------------------------------------------
| Partitioned Cookies
|--------------------------------------------------------------------------
|
| Setting this value to true will tie the cookie to the top-level site for
| a cross-site context. Partitioned cookies are accepted by the browser
| when flagged "secure" and the Same-Site attribute is set to "none".
|
*/
'partitioned' => env('SESSION_PARTITIONED_COOKIE', false),
];
+1
View File
@@ -0,0 +1 @@
*.sqlite*
+45
View File
@@ -0,0 +1,45 @@
<?php
namespace Database\Factories;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
/**
* @extends Factory<User>
*/
class UserFactory extends Factory
{
/**
* The current password being used by the factory.
*/
protected static ?string $password;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'name' => fake()->name(),
'email' => fake()->unique()->safeEmail(),
'email_verified_at' => now(),
'password' => static::$password ??= Hash::make('password'),
'remember_token' => Str::random(10),
];
}
/**
* Indicate that the model's email address should be unverified.
*/
public function unverified(): static
{
return $this->state(fn (array $attributes) => [
'email_verified_at' => null,
]);
}
}
@@ -0,0 +1,49 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
$table->rememberToken();
$table->timestamps();
});
Schema::create('password_reset_tokens', function (Blueprint $table) {
$table->string('email')->primary();
$table->string('token');
$table->timestamp('created_at')->nullable();
});
Schema::create('sessions', function (Blueprint $table) {
$table->string('id')->primary();
$table->foreignId('user_id')->nullable()->index();
$table->string('ip_address', 45)->nullable();
$table->text('user_agent')->nullable();
$table->longText('payload');
$table->integer('last_activity')->index();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('users');
Schema::dropIfExists('password_reset_tokens');
Schema::dropIfExists('sessions');
}
};
@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('cache', function (Blueprint $table) {
$table->string('key')->primary();
$table->mediumText('value');
$table->integer('expiration')->index();
});
Schema::create('cache_locks', function (Blueprint $table) {
$table->string('key')->primary();
$table->string('owner');
$table->integer('expiration')->index();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('cache');
Schema::dropIfExists('cache_locks');
}
};
@@ -0,0 +1,59 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('jobs', function (Blueprint $table) {
$table->id();
$table->string('queue');
$table->longText('payload');
$table->unsignedTinyInteger('attempts');
$table->unsignedInteger('reserved_at')->nullable();
$table->unsignedInteger('available_at');
$table->unsignedInteger('created_at');
$table->index(['queue', 'reserved_at', 'available_at']);
});
Schema::create('job_batches', function (Blueprint $table) {
$table->string('id')->primary();
$table->string('name');
$table->integer('total_jobs');
$table->integer('pending_jobs');
$table->integer('failed_jobs');
$table->longText('failed_job_ids');
$table->mediumText('options')->nullable();
$table->integer('cancelled_at')->nullable();
$table->integer('created_at');
$table->integer('finished_at')->nullable();
});
Schema::create('failed_jobs', function (Blueprint $table) {
$table->id();
$table->string('uuid')->unique();
$table->text('connection');
$table->text('queue');
$table->longText('payload');
$table->longText('exception');
$table->timestamp('failed_at')->useCurrent();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('jobs');
Schema::dropIfExists('job_batches');
Schema::dropIfExists('failed_jobs');
}
};
@@ -0,0 +1,71 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('carousel_items', function (Blueprint $table) {
$table->id();
$table->string('titulo')->nullable();
$table->string('subtitulo')->nullable();
$table->string('boton_texto')->nullable();
$table->string('boton_enlace')->nullable();
$table->string('imagen');
$table->integer('orden')->default(0);
$table->boolean('activo')->default(true);
$table->timestamps();
});
// Insert default items to preserve the first 3 original items
DB::table('carousel_items')->insert([
[
'titulo' => 'Bienvenido a OnAPB',
'subtitulo' => 'La nueva forma de vivir el básquet en Paraná',
'boton_texto' => 'Ver partidos',
'boton_enlace' => '/eventos',
'imagen' => 'hero1.jpeg',
'orden' => 1,
'activo' => true,
'created_at' => now(),
'updated_at' => now(),
],
[
'titulo' => 'El basquet en tus manos',
'subtitulo' => 'Seguilo como nunca antes',
'boton_texto' => 'Unite',
'boton_enlace' => '/asociate',
'imagen' => 'hero2.jpg',
'orden' => 2,
'activo' => true,
'created_at' => now(),
'updated_at' => now(),
],
[
'titulo' => 'Sumate a la comunidad',
'subtitulo' => 'Asociate y disfrutá beneficios exclusivos',
'boton_texto' => 'Lugares',
'boton_enlace' => '/promos',
'imagen' => 'hero3.jpg',
'orden' => 3,
'activo' => true,
'created_at' => now(),
'updated_at' => now(),
]
]);
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('carousel_items');
}
};
@@ -0,0 +1,41 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('admin_users', function (Blueprint $table) {
if (!Schema::hasColumn('admin_users', 'id_club')) {
$table->integer('id_club')->nullable()->after('password');
} else {
// Si la columna ya se creó en error en un intento anterior pero mal tipada
$table->integer('id_club')->nullable()->change();
}
// Agregamos la constrain después de asegurarnos de su tipo
});
Schema::table('admin_users', function (Blueprint $table) {
// Intentar dropear llave si existe para evitar error por si acaso no va,
// pero como falló en el fk, mejor tratamos de crearla:
$table->foreign('id_club')->references('id_club')->on('clubes')->onDelete('set null');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('admin_users', function (Blueprint $table) {
$table->dropForeign(['id_club']);
$table->dropColumn('id_club');
});
}
};
@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('categorias', function (Blueprint $table) {
$table->id('id_categoria');
$table->string('nombre');
$table->integer('edad_min');
$table->integer('edad_max');
$table->string('genero')->nullable(); // M, F, Mixto...
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('categorias');
}
};
@@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('pases', function (Blueprint $table) {
$table->id('id_pase');
$table->char('id_jugador', 36)->nullable();
$table->integer('id_club_origen')->nullable();
$table->integer('id_club_destino')->nullable();
$table->string('estado')->default('Pendiente'); // Pendiente, Aprobado, Rechazado
// Si la FK en jugadores es de tipo uuid, usamos char(36)
$table->foreign('id_jugador')->references('id_jugador')->on('jugadores')->onDelete('cascade');
$table->foreign('id_club_origen')->references('id_club')->on('clubes')->onDelete('cascade');
$table->foreign('id_club_destino')->references('id_club')->on('clubes')->onDelete('cascade');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('pases');
}
};

Some files were not shown because too many files have changed in this diff Show More