Documentación / Arquitectura-Notificaciones-Reverb

Arquitectura-Notificaciones-Reverb

Arquitectura de Notificaciones en Tiempo Real con Laravel Reverb

Índice

  1. FASE 1: Máquinas de Estados (State Machines)
  2. FASE 2: Matriz de Notificaciones
  3. FASE 3: Implementación Técnica de Laravel Reverb
  4. FASE 4: Ejemplo de Uso Real

FASE 1: Máquinas de Estados (State Machines)

1.1 LeaveRequest (Solicitudes de Permiso/Vacaciones)

Flujo Jerárquico Completo:

stateDiagram-v2 [*] --> PENDING: Empleado crea solicitud PENDING --> APPROVED: Jefe Inmediato aprueba PENDING --> REJECTED: Jefe Inmediato rechaza PENDING --> CANCELLED: Empleado/RRHH cancela APPROVED --> IN_PROGRESS: RRHH procesa / Empleado inicia permiso APPROVED --> CANCELLED: Empleado/RRHH cancela IN_PROGRESS --> COMPLETED: Empleado retorna / Permiso finaliza IN_PROGRESS --> CANCELLED: RRHH cancela (emergencia) REJECTED --> [*] CANCELLED --> [*] COMPLETED --> [*] note right of PENDING Estado Inicial end note note right of COMPLETED Estado Final end note

Tabla de Transiciones:

Estado Actual → Estado Destino Acción/Trigger Actor Responsable
PENDING APPROVED approve() Jefe Inmediato
PENDING REJECTED reject() Jefe Inmediato
PENDING CANCELLED cancel() Empleado/RRHH
APPROVED IN_PROGRESS startProgress() Sistema/RRHH
APPROVED CANCELLED cancel() Empleado/RRHH
IN_PROGRESS COMPLETED complete() Sistema/RRHH
IN_PROGRESS CANCELLED cancel() RRHH (emergencia)
REJECTED - (Estado Final) -
CANCELLED - (Estado Final) -
COMPLETED - (Estado Final) -

1.2 PayrollRun (Procesos de Nómina)

Flujo de Auditoría Financiera:

stateDiagram-v2 [*] --> DRAFT: Se crea el proceso de nómina DRAFT --> CALCULATING: Sistema procesa DRAFT --> VOID: Admin anula CALCULATING --> REVIEW: Cálculo completado CALCULATING --> VOID: Admin anula REVIEW --> APPROVED: Gerente RRHH aprueba REVIEW --> DRAFT: Analista devuelve para correcciones REVIEW --> VOID: Admin anula APPROVED --> PROCESSED: Finanzas genera archivos de pago APPROVED --> VOID: Gerente General anula PROCESSED --> PUBLISHED: RRHH publica boletas PROCESSED --> VOID: Gerente General anula PUBLISHED --> [*] VOID --> [*] note right of DRAFT Estado Inicial end note note right of PROCESSED Estado BLOQUEADO Montos inmutables end note note right of PUBLISHED Estado Final Completamente inmutable end note

Tabla de Transiciones:

Estado Actual → Estado Destino Acción/Trigger Actor Responsable
DRAFT CALCULATING startCalculation() Sistema/Analista
DRAFT REVIEW moveToReview() Analista RRHH
DRAFT VOID void() Admin Sistema
CALCULATING REVIEW moveToReview() Sistema
CALCULATING VOID void() Admin Sistema
REVIEW APPROVED approve() Gerente RRHH
REVIEW DRAFT returnToDraft() Analista (correcciones)
REVIEW VOID void() Admin Sistema
APPROVED PROCESSED process() Tesorero/Finanzas
APPROVED VOID void() Gerente General
PROCESSED PUBLISHED publish() RRHH
PROCESSED VOID void() Gerente General
PUBLISHED - (Estado Final - Inmutable) -
VOID - (Estado Final) -

Restricciones de Negocio:

  • Una vez en PROCESSED, los montos NO pueden modificarse.
  • PUBLISHED es completamente inmutable (empleados ya vieron boletas).
  • VOID requiere justificación obligatoria y solo para auditoría.

1.3 TransferRequest (Traslados de Inventario)

Flujo de Doble Validación:

stateDiagram-v2 [*] --> PENDING: Sucursal Origen crea solicitud PENDING --> RELEASED: Jefe Almacén Origen libera PENDING --> REJECTED: Jefe rechaza RELEASED --> APPROVED: Jefe Almacén Destino acepta RELEASED --> REJECTED: Jefe Almacén Destino rechaza APPROVED --> IN_TRANSIT: Almacenero Origen envía IN_TRANSIT --> RECEIVED: Almacenero Destino recibe RECEIVED --> COMPLETED: Jefe Almacén Destino confirma REJECTED --> [*] COMPLETED --> [*] note right of PENDING Estado Inicial end note note right of COMPLETED Movimiento físico ejecutado Estado Final end note

Tabla de Transiciones (Ampliada para Doble Validación):

Para implementar una validación más robusta, se recomienda ampliar el enum:

Estado Actual → Estado Destino Acción/Trigger Actor Responsable
PENDING RELEASED release() Jefe Almacén Origen
PENDING REJECTED reject() Jefe Almacén Origen/Destino
RELEASED APPROVED approve() Jefe Almacén Destino
RELEASED REJECTED reject() Jefe Almacén Destino
APPROVED IN_TRANSIT startTransit() Almacenero Origen
IN_TRANSIT RECEIVED markReceived() Almacenero Destino
RECEIVED COMPLETED complete() Jefe Almacén Destino
COMPLETED - (Estado Final) -
REJECTED - (Estado Final) -

FASE 2: Matriz de Notificaciones

Principios de Diseño

  1. Solo notificar cuando requiere acción inmediata o es un evento crítico.
  2. Canales de entrega diferenciados: Real-time (toast) vs Database (histórico) vs Mail (externo).
  3. Targeted Broadcasting: Las notificaciones se dirigen a usuarios específicos basados en su relación con el registro (ej. dueño del contrato) o sus permisos/roles regionales.
  4. Roles Administrativos: Los roles Super-Admin y Admin SIEMPRE reciben todas las notificaciones de forma automática a través de la herencia de BaseRealtimeEvent.
  5. BaseRealtimeEvent: Todos los eventos de broadcast extienden esta clase, la cual maneja la resolución de canales privados y la inclusión automática de administradores.

2.1 Notificaciones Transaccionales - Solicitudes de Permiso

Evento Disparador (Trigger) Receptor (Target) Canales Mensaje
Solicitud de Permiso Creada LeaveRequestStatusChanged (PENDIENTE) Aprobadores RRHH + Admins broadcast, database "Nueva solicitud de permiso de {empleado} requiere aprobación"
Solicitud de Permiso Aprobada LeaveRequestStatusChanged (APROBADO) Empleado + Admins broadcast, database, mail "La solicitud de permiso de {empleado} ha sido aprobada"
Solicitud de Permiso Rechazada LeaveRequestStatusChanged (RECHAZADO) Empleado + Admins broadcast, database, mail "La solicitud de permiso de {empleado} ha sido rechazada"

2.2 Notificaciones Transaccionales - Nómina (PayrollRun)

Evento Disparador (Trigger) Receptor (Target) Canales Mensaje
Nómina en Cálculo PayrollRunStatusChanged (CALCULANDO) Usuarios con Permiso Nómina + Admins broadcast, database "El proceso de nómina de {periodo} está siendo calculado"
Nómina Lista para Revisión PayrollRunStatusChanged (REVIEW) Revisores Nómina + Admins broadcast, database "Proceso de nómina {periodo} listo para revisión"
Nómina Aprobada PayrollRunStatusChanged (APPROVED) Revisores Nómina + Admins broadcast, database "Nómina {periodo} aprobada, proceder con dispersión"
Nómina Procesada PayrollRunStatusChanged (PROCESSED) Usuarios con Permiso Nómina + Admins broadcast, database "El proceso de nómina de {periodo} ha sido procesado"
Nómina Publicada PayrollRunStatusChanged (PUBLISHED) Usuarios con Permiso Nómina + Admins database, mail "Las boletas de pago de {periodo} han sido publicadas"
Nómina Anulada PayrollRunStatusChanged (VOID) Usuarios con Permiso Nómina + Admins broadcast, database "El proceso de nómina de {periodo} ha sido anulado"

2.3 Notificaciones Transaccionales - Períodos de Nómina (PayrollPeriod)

Evento Disparador (Trigger) Receptor (Target) Canales Mensaje
Período Listo PayrollPeriodReady Procesadores Nómina + Admins broadcast, database "El período {código} está listo para procesar nóminas"
Cambio de Estado Período PayrollPeriodStatusChanged RRHH + Admins broadcast, database "El estado del período de nómina ha cambiado a {estado}"
Período Cerrado PayrollPeriodClosed Creador + RRHH + Admins broadcast, database "El período {código} ha sido cerrado"

2.4 Notificaciones Transaccionales - Configuración de Nómina (PayrollSetting)

Evento Disparador (Trigger) Receptor (Target) Canales Mensaje
Configuración Enviada a Aprobación PayrollSettingSubmitted Aprobadores RRHH + Admins broadcast, database "{usuario} ha enviado configuración de nómina para {sucursal} a aprobación"
Configuración de Nómina Aprobada PayrollSettingApproved Creador + RRHH + Admins broadcast, database, mail "Configuración de nómina para {sucursal} ha sido aprobada"
Configuración de Nómina Rechazada PayrollSettingRejected Creador + Enviador + Admins broadcast, database, mail "Configuración de nómina para {sucursal} rechazada: {motivo}"
Cambio de Estado en Configuración PayrollSettingStatusChanged RRHH + Admins broadcast, database "El estado de la configuración de nómina ha cambiado a {estado}"

2.5 Notificaciones Transaccionales - Traslados (Logística)

Evento Disparador (Trigger) Receptor (Target) Canales Mensaje
Traslado Aprobado TransferRequest::status → APPROVED Solicitante + Almacén Origen + Super-Admin + Admin broadcast, database "Traslado {ref} aprobado, preparar envío"
Traslado Completado TransferRequest::status → COMPLETED Solicitante + Logística + Super-Admin + Admin broadcast, database "Traslado {ref} completado exitosamente"

2.6 Alertas de Sistema (Events)

Evento Disparador (Trigger) Receptor (Target) Canales Mensaje
Contrato por Vencer / Vencido ContractExpiringAlert RRHH + Jefe + Admins broadcast, database, mail "El contrato de {empleado} vence el {fecha}"
Saldo Vacacional Negativo RecalculateVacationBalanceJob RRHH + Admins broadcast, database "Alerta: {empleado} tiene saldo vacacional negativo"

FASE 3: Implementación Técnica de Laravel Reverb

3.1 Instalación y Configuración del Backend

Paso 1: Instalar Broadcasting

php artisan install:broadcasting

Esto instalará:

  • Laravel Reverb
  • Laravel Echo
  • Pusher JS (compatible con Reverb)
  • Configurará config/reverb.php

Paso 2: Configuración de .env (Producción)

# ===========================================
# BROADCASTING - LARAVEL REVERB
# ===========================================
BROADCAST_CONNECTION=reverb

# Credenciales de la aplicación Reverb
REVERB_APP_ID=erp-rrhh-app
REVERB_APP_KEY=your-secure-app-key-here
REVERB_APP_SECRET=your-secure-app-secret-here

# Configuración del servidor Reverb (interno)
REVERB_SERVER_HOST=0.0.0.0
REVERB_SERVER_PORT=8080

# Configuración pública (lo que ve el cliente)
REVERB_HOST=ws.tudominio.com
REVERB_PORT=443
REVERB_SCHEME=https

# Variables para Vite (cliente)
VITE_REVERB_APP_KEY="${REVERB_APP_KEY}"
VITE_REVERB_HOST="${REVERB_HOST}"
VITE_REVERB_PORT="${REVERB_PORT}"
VITE_REVERB_SCHEME="${REVERB_SCHEME}"

Paso 3: Configuración de config/reverb.php

<?php

return [
    'default' => env('REVERB_SERVER', 'reverb'),

    'servers' => [
        'reverb' => [
            'host' => env('REVERB_SERVER_HOST', '0.0.0.0'),
            'port' => env('REVERB_SERVER_PORT', 8080),
            'hostname' => env('REVERB_HOST'),
            'options' => [
                'tls' => [],
            ],
            'max_request_size' => env('REVERB_MAX_REQUEST_SIZE', 10_000),
            'scaling' => [
                'enabled' => env('REVERB_SCALING_ENABLED', false),
                'channel' => env('REVERB_SCALING_CHANNEL', 'reverb'),
                'server' => [
                    'url' => env('REDIS_URL'),
                    'host' => env('REDIS_HOST', '127.0.0.1'),
                    'port' => env('REDIS_PORT', '6379'),
                    'username' => env('REDIS_USERNAME'),
                    'password' => env('REDIS_PASSWORD'),
                    'database' => env('REDIS_DB', '0'),
                ],
            ],
            'pulse_ingest_interval' => env('REVERB_PULSE_INGEST_INTERVAL', 15),
            'telescope_ingest_interval' => env('REVERB_TELESCOPE_INGEST_INTERVAL', 15),
        ],
    ],

    'apps' => [
        'provider' => 'config',
        'apps' => [
            [
                'key' => env('REVERB_APP_KEY'),
                'secret' => env('REVERB_APP_SECRET'),
                'app_id' => env('REVERB_APP_ID'),
                'options' => [
                    'host' => env('REVERB_HOST'),
                    'port' => env('REVERB_PORT', 443),
                    'scheme' => env('REVERB_SCHEME', 'https'),
                    'useTLS' => env('REVERB_SCHEME', 'https') === 'https',
                ],
                'allowed_origins' => [
                    env('APP_URL'),
                ],
                'ping_interval' => env('REVERB_APP_PING_INTERVAL', 60),
                'activity_timeout' => env('REVERB_APP_ACTIVITY_TIMEOUT', 30),
                'max_message_size' => env('REVERB_APP_MAX_MESSAGE_SIZE', 10_000),
            ],
        ],
    ],
];

3.2 Autorización de Canales (routes/channels.php)

<?php

use App\Models\User;
use Illuminate\Support\Facades\Broadcast;

/*
|--------------------------------------------------------------------------
| Broadcast Channels
|--------------------------------------------------------------------------
|
| Canales privados para notificaciones targeted del ERP.
| CRÍTICO: Cada usuario solo puede escuchar su propio canal.
|
*/

/**
 * Canal privado del usuario autenticado.
 * Formato: App.Models.User.{id}
 *
 * Este canal se usa para todas las notificaciones personales:
 * - Aprobaciones de solicitudes
 * - Alertas de sistema
 * - Actualizaciones de estado
 */
Broadcast::channel('App.Models.User.{id}', function (User $user, int $id) {
    // CRÍTICO: El usuario solo puede acceder a su propio canal
    return $user->id === $id;
});

/**
 * Canal para supervisores de un área específica.
 * Formato: supervisors.area.{areaId}
 *
 * Usado para notificar a todos los supervisores de un área
 * sobre solicitudes pendientes de sus subordinados.
 */
Broadcast::channel('supervisors.area.{areaId}', function (User $user, int $areaId) {
    // Verificar si el usuario es supervisor del área
    if (!$user->employee) {
        return false;
    }

    return $user->employee->activeContract?->area_id === $areaId
        && $user->hasRole(['supervisor', 'jefe-area', 'gerente']);
});

/**
 * Canal para el equipo de RRHH.
 * Formato: team.rrhh
 *
 * Usado para notificaciones globales del módulo de RRHH.
 */
Broadcast::channel('team.rrhh', function (User $user) {
    return $user->hasAnyPermission([
        'view.recursos-humanos.empleados',
        'manage.recursos-humanos.nomina',
        'approve.recursos-humanos.solicitudes',
    ]);
});

/**
 * Canal para almaceneros de una sucursal.
 * Formato: warehouse.branch.{branchId}
 */
Broadcast::channel('warehouse.branch.{branchId}', function (User $user, int $branchId) {
    if (!$user->employee) {
        return false;
    }

    return $user->employee->activeContract?->branch_id === $branchId
        && $user->hasAnyPermission([
            'view.logistica.almacen',
            'manage.logistica.traslados',
        ]);
});

3.3 Integración en Filament (Frontend)

Paso 1: Crear config/filament.php

<?php

return [
    /*
    |--------------------------------------------------------------------------
    | Broadcasting / Echo
    |--------------------------------------------------------------------------
    |
    | Configuración de Laravel Echo para notificaciones en tiempo real.
    | Se integra con Laravel Reverb (WebSockets nativos).
    |
    */
    'broadcasting' => [
        'echo' => [
            'broadcaster' => 'reverb',
            'key' => env('VITE_REVERB_APP_KEY'),
            'cluster' => env('VITE_REVERB_APP_CLUSTER', 'mt1'),
            'wsHost' => env('VITE_REVERB_HOST', '127.0.0.1'),
            'wsPort' => env('VITE_REVERB_PORT', 8080),
            'wssPort' => env('VITE_REVERB_PORT', 8080),
            'forceTLS' => env('VITE_REVERB_SCHEME', 'http') === 'https',
            'enabledTransports' => ['ws', 'wss'],
            'authEndpoint' => '/broadcasting/auth',
            'disableStats' => true,
            'encrypted' => true,
        ],
    ],
];

Paso 2: Actualizar resources/js/bootstrap.js (o echo.js)

import axios from "axios";
import Echo from "laravel-echo";
import Pusher from "pusher-js";

// Configurar axios globalmente
window.axios = axios;
window.axios.defaults.headers.common["X-Requested-With"] = "XMLHttpRequest";

// Exponer Pusher para Laravel Echo (Reverb es compatible con el protocolo Pusher)
window.Pusher = Pusher;

/**
 * Laravel Echo con Reverb
 *
 * Configuración para WebSockets nativos con Laravel Reverb.
 * Compatible con canales privados y de presencia.
 */
window.Echo = new Echo({
  broadcaster: "reverb",
  key: import.meta.env.VITE_REVERB_APP_KEY,
  wsHost: import.meta.env.VITE_REVERB_HOST,
  wsPort: import.meta.env.VITE_REVERB_PORT ?? 8080,
  wssPort: import.meta.env.VITE_REVERB_PORT ?? 8080,
  forceTLS: (import.meta.env.VITE_REVERB_SCHEME ?? "http") === "https",
  enabledTransports: ["ws", "wss"],
  authEndpoint: "/broadcasting/auth",
  auth: {
    headers: {
      "X-CSRF-TOKEN": document
        .querySelector('meta[name="csrf-token"]')
        ?.getAttribute("content"),
    },
  },
});

/**
 * Helper para suscribirse al canal privado del usuario actual.
 *
 * @param {number} userId - ID del usuario autenticado
 * @returns {Object} - Instancia del canal privado
 */
window.subscribeToUserChannel = function (userId) {
  return window.Echo.private(`App.Models.User.${userId}`);
};

// Debug en desarrollo
if (import.meta.env.DEV) {
  window.Echo.connector.pusher.connection.bind("state_change", (states) => {
    console.log("[Reverb] Estado:", states.current);
  });

  window.Echo.connector.pusher.connection.bind("error", (error) => {
    console.error("[Reverb] Error:", error);
  });
}

Paso 3: Actualizar vite.config.js

import { defineConfig } from "vite";
import laravel from "laravel-vite-plugin";

export default defineConfig({
  plugins: [
    laravel({
      input: [
        "resources/css/app.css",
        "resources/js/app.js",
        "resources/js/bootstrap.js", // Asegurar que esté incluido
        "resources/css/filament/admin/theme.css",
      ],
      refresh: true,
    }),
  ],
  // Configuración para desarrollo con Reverb
  server: {
    hmr: {
      host: "localhost",
    },
  },
});

3.4 Arquitectura de Eventos y Notificaciones

Evento Base (Abstract)

<?php

namespace App\Events;

use App\Models\User;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Collection;

/**
 * Clase base abstracta para eventos de broadcast en tiempo real.
 */
abstract class BaseRealtimeEvent implements ShouldBroadcastNow
{
    use Dispatchable, InteractsWithSockets, SerializesModels;

    protected array $targetUserIds = [];
    protected array $payload;
    protected bool $includeAdmins = true;
    protected array $adminRoles = ['Super-Admin', 'Admin'];

    public function __construct(int|array $targetUserIds, array $payload = [], bool $includeAdmins = true)
    {
        $this->targetUserIds = is_array($targetUserIds) ? $targetUserIds : [$targetUserIds];
        $this->payload = $payload;
        $this->includeAdmins = $includeAdmins;
    }

    public function broadcastOn(): array
    {
        $allTargetIds = collect($this->targetUserIds);

        if ($this->includeAdmins) {
            $adminIds = User::role($this->adminRoles)->whereNull('deleted_at')->pluck('id');
            $allTargetIds = $allTargetIds->merge($adminIds);
        }

        return $allTargetIds->filter()->unique()->values()->map(
            fn (int $userId) => new PrivateChannel("App.Models.User.{$userId}")
        )->toArray();
    }

    public function broadcastWith(): array
    {
        return array_merge($this->payload, [
            'timestamp' => now()->toIso8601String(),
            'event_type' => class_basename($this),
        ]);
    }

    public function broadcastAs(): string
    {
        return class_basename($this);
    }
}

Evento Concreto: LeaveRequestApproved

<?php

namespace App\Events\HumanResources;

use App\Events\BaseRealtimeEvent;
use App\Models\LeaveRequest;
use App\Models\User;

class LeaveRequestApproved extends BaseRealtimeEvent
{
    public LeaveRequest $leaveRequest;

    public function __construct(LeaveRequest $leaveRequest)
    {
        // Obtener el usuario asociado al empleado
        $targetUserId = $leaveRequest->employee->user_id;

        // Datos para la notificación visual
        $payload = [
            'type' => 'success',
            'title' => 'Solicitud Aprobada',
            'message' => "Su solicitud de permiso del {$leaveRequest->start_date->format('d/m/Y')} al {$leaveRequest->end_date->format('d/m/Y')} ha sido aprobada.",
            'icon' => 'heroicon-o-check-circle',
            'action' => [
                'label' => 'Ver Solicitud',
                'url' => route('filament.admin.resources.leave-requests.view', $leaveRequest),
            ],
            'leave_request' => [
                'id' => $leaveRequest->id,
                'code' => $leaveRequest->request_code,
                'start_date' => $leaveRequest->start_date->toDateString(),
                'end_date' => $leaveRequest->end_date->toDateString(),
                'approved_by' => $leaveRequest->approvedBy?->name,
            ],
        ];

        parent::__construct($targetUserId, $payload);

        $this->leaveRequest = $leaveRequest;
    }
}

3.5 Listener Global en Filament (Render Hook)

Componente Livewire: RealtimeNotificationListener

<?php

namespace App\Livewire;

use Filament\Notifications\Notification;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\On;
use Livewire\Component;

class RealtimeNotificationListener extends Component
{
    public ?int $userId = null;

    public function mount(): void
    {
        $this->userId = Auth::id();
    }

    /**
     * Escucha eventos de Echo y los convierte en notificaciones de Filament.
     * Este método es llamado desde JavaScript vía Livewire.
     */
    #[On('echo-notification')]
    public function handleEchoNotification(array $data): void
    {
        $notification = Notification::make()
            ->title($data['title'] ?? 'Notificación')
            ->body($data['message'] ?? '')
            ->icon($data['icon'] ?? 'heroicon-o-bell');

        // Aplicar tipo de notificación
        match ($data['type'] ?? 'info') {
            'success' => $notification->success(),
            'warning' => $notification->warning(),
            'danger' => $notification->danger(),
            default => $notification->info(),
        };

        // Agregar acción si existe
        if (!empty($data['action']['url'])) {
            $notification->actions([
                \Filament\Notifications\Actions\Action::make('view')
                    ->label($data['action']['label'] ?? 'Ver')
                    ->url($data['action']['url'])
                    ->openUrlInNewTab(false),
            ]);
        }

        $notification->send();
    }

    public function render(): string
    {
        return <<<'HTML'
        <div
            x-data="{
                userId: @js($userId),
                channel: null,

                init() {
                    if (!this.userId || !window.Echo) {
                        console.warn('[RealtimeNotifications] Usuario no autenticado o Echo no disponible');
                        return;
                    }

                    this.subscribeToChannel();
                },

                subscribeToChannel() {
                    const channelName = `App.Models.User.${this.userId}`;

                    this.channel = window.Echo.private(channelName);

                    // Escuchar todos los eventos de notificación
                    this.channel.listen('.LeaveRequestApproved', (e) => this.handleNotification(e));
                    this.channel.listen('.LeaveRequestRejected', (e) => this.handleNotification(e));
                    this.channel.listen('.LeaveRequestCreated', (e) => this.handleNotification(e));
                    this.channel.listen('.PayrollRunStatusChanged', (e) => this.handleNotification(e));
                    this.channel.listen('.TransferRequestUpdated', (e) => this.handleNotification(e));
                    this.channel.listen('.ContractExpiringAlert', (e) => this.handleNotification(e));

                    // Evento genérico para cualquier notificación
                    this.channel.listen('.notification', (e) => this.handleNotification(e));

                    console.log('[RealtimeNotifications] Suscrito al canal:', channelName);
                },

                handleNotification(event) {
                    console.log('[RealtimeNotifications] Evento recibido:', event);

                    // Enviar al componente Livewire para mostrar la notificación
                    $wire.dispatch('echo-notification', { data: event });

                    // Reproducir sonido de notificación (opcional)
                    this.playNotificationSound();
                },

                playNotificationSound() {
                    const audio = new Audio('/sounds/notification.mp3');
                    audio.volume = 0.5;
                    audio.play().catch(() => {
                        // Silenciar error si el navegador bloquea autoplay
                    });
                }
            }"
            class="hidden"
        ></div>
        HTML;
    }
}

Registrar Componente en AdminPanelProvider

<?php

namespace App\Providers\Filament;

use App\Livewire\RealtimeNotificationListener;
use Filament\Http\Middleware\Authenticate;
use Filament\Http\Middleware\DisableBladeIconComponents;
use Filament\Http\Middleware\DispatchServingFilamentEvent;
use Filament\Navigation\NavigationGroup;
use Filament\Pages;
use Filament\Panel;
use Filament\PanelProvider;
use Filament\Support\Colors\Color;
use Filament\Widgets;
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
use Illuminate\Cookie\Middleware\EncryptCookies;
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
use Illuminate\Routing\Middleware\SubstituteBindings;
use Illuminate\Session\Middleware\AuthenticateSession;
use Illuminate\Session\Middleware\StartSession;
use Illuminate\View\Middleware\ShareErrorsFromSession;
use Livewire\Livewire;

class AdminPanelProvider extends PanelProvider
{
    public function panel(Panel $panel): Panel
    {
        return $panel
            ->default()
            ->id('admin')
            ->path('admin')
            ->login()
            // ... otras configuraciones

            // Agregar componente de notificaciones en tiempo real
            ->renderHook(
                'panels::body.end',
                fn () => view('components.realtime-notification-listener')
            )

            // Configuración de broadcasting para Filament
            ->broadcasting(config('filament.broadcasting.echo'));
    }

    public function boot(): void
    {
        // Registrar el componente Livewire
        Livewire::component('realtime-notification-listener', RealtimeNotificationListener::class);
    }
}

Vista del Componente (resources/views/components/realtime-notification-listener.blade.php)

@auth
    <livewire:realtime-notification-listener />
@endauth

FASE 4: Ejemplo de Uso Real

Action: ApproveLeaveRequestAction

<?php

namespace App\Actions\HumanResources;

use App\Events\HumanResources\LeaveRequestApproved;
use App\Models\LeaveRequest;
use App\Models\User;
use App\Support\Enums\HumanResources\LeaveRequestStatus;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;

class ApproveLeaveRequestAction
{
    /**
     * Aprueba una solicitud de permiso.
     *
     * Flujo:
     * 1. Valida que el usuario tenga permisos
     * 2. Valida la transición de estado
     * 3. Actualiza el registro
     * 4. Dispara evento de broadcast al solicitante
     *
     * @throws ValidationException
     */
    public function execute(LeaveRequest $leaveRequest, ?string $notes = null): LeaveRequest
    {
        // 1. Validar permisos del usuario actual
        $this->authorizeUser();

        // 2. Validar transición de estado (usando la máquina de estados)
        $this->validateTransition($leaveRequest);

        // 3. Ejecutar la actualización en transacción
        return DB::transaction(function () use ($leaveRequest, $notes) {
            $leaveRequest->fill([
                'status' => LeaveRequestStatus::APPROVED,
                'approved_by' => Auth::id(),
                'approved_at' => now(),
                'approval_notes' => $notes,
                // Limpiar campos de rechazo si existían
                'rejected_by' => null,
                'rejected_at' => null,
            ]);

            $leaveRequest->save();

            // 4. Disparar evento de broadcast al solicitante
            $this->broadcastToRequester($leaveRequest);

            // 5. Notificar al equipo de RRHH (si aplica)
            $this->notifyHrTeam($leaveRequest);

            return $leaveRequest->fresh();
        });
    }

    /**
     * Valida que el usuario actual tenga permisos para aprobar.
     */
    protected function authorizeUser(): void
    {
        $user = Auth::user();

        if (!$user) {
            throw ValidationException::withMessages([
                'auth' => ['Debe iniciar sesión para realizar esta acción.'],
            ]);
        }

        // Verificar permisos usando el sistema de permisos existente
        if (!$user->hasAnyPermission([
            'approve.recursos-humanos.solicitudes-permiso',
            'manage.recursos-humanos.solicitudes',
        ])) {
            throw ValidationException::withMessages([
                'auth' => ['No tiene permisos para aprobar solicitudes de permiso.'],
            ]);
        }
    }

    /**
     * Valida que la transición de estado sea válida.
     */
    protected function validateTransition(LeaveRequest $leaveRequest): void
    {
        $currentStatus = $leaveRequest->status;

        // Usar el método canMoveTo() del enum
        if (!$currentStatus->canMoveTo(LeaveRequestStatus::APPROVED)) {
            throw ValidationException::withMessages([
                'status' => [
                    "No se puede aprobar una solicitud en estado '{$currentStatus->label()}'. " .
                    "Solo las solicitudes pendientes pueden ser aprobadas."
                ],
            ]);
        }

        // Validaciones adicionales de negocio
        $this->validateBusinessRules($leaveRequest);
    }

    /**
     * Validaciones de reglas de negocio antes de aprobar.
     */
    protected function validateBusinessRules(LeaveRequest $leaveRequest): void
    {
        // Verificar que el empleado tenga contrato activo
        if (!$leaveRequest->contract?->is_active) {
            throw ValidationException::withMessages([
                'contract' => ['El empleado no tiene un contrato activo.'],
            ]);
        }

        // Verificar que el tipo de permiso esté activo
        if (!$leaveRequest->leaveType?->is_active) {
            throw ValidationException::withMessages([
                'leave_type' => ['El tipo de permiso ya no está disponible.'],
            ]);
        }

        // Verificar saldo de vacaciones si aplica
        if ($leaveRequest->leaveType?->category === \App\Support\Enums\HumanResources\LeaveTypeCategory::VACATION) {
            $this->validateVacationBalance($leaveRequest);
        }
    }

    /**
     * Valida el saldo de vacaciones disponible.
     */
    protected function validateVacationBalance(LeaveRequest $leaveRequest): void
    {
        // Implementar lógica de validación de saldo
        // Por ahora, solo una validación básica
        $balance = $leaveRequest->employee->vacationBalance ?? 0;

        if ($balance < $leaveRequest->work_days) {
            throw ValidationException::withMessages([
                'balance' => [
                    "El empleado no tiene suficiente saldo de vacaciones. " .
                    "Disponible: {$balance} días, Solicitado: {$leaveRequest->work_days} días."
                ],
            ]);
        }
    }

    /**
     * Dispara el evento de broadcast al usuario solicitante.
     */
    protected function broadcastToRequester(LeaveRequest $leaveRequest): void
    {
        // Verificar que el empleado tenga un usuario asociado
        if (!$leaveRequest->employee?->user_id) {
            // Log warning pero no fallar
            \Log::warning("LeaveRequest {$leaveRequest->id} aprobada pero el empleado no tiene usuario asociado.");
            return;
        }

        // Disparar el evento - ShouldBroadcastNow lo envía inmediatamente
        event(new LeaveRequestApproved($leaveRequest));
    }

    /**
     * Notifica al equipo de RRHH sobre la aprobación.
     */
    protected function notifyHrTeam(LeaveRequest $leaveRequest): void
    {
        // Obtener usuarios con rol de RRHH
        $hrUsers = User::role(['rrhh', 'gerente-rrhh'])->get();

        foreach ($hrUsers as $hrUser) {
            // No notificar al mismo usuario que aprobó
            if ($hrUser->id === Auth::id()) {
                continue;
            }

            // Usar notificación de base de datos para histórico
            $hrUser->notify(new \App\Notifications\LeaveRequestApprovedNotification($leaveRequest));
        }
    }
}

Uso del Action en un Filament Resource

<?php

// En LeaveRequestResource.php - Tabla Actions

use App\Actions\HumanResources\ApproveLeaveRequestAction;
use Filament\Notifications\Notification;
use Filament\Tables\Actions\Action;

Action::make('approve')
    ->label('Aprobar')
    ->icon('heroicon-o-check-circle')
    ->color('success')
    ->requiresConfirmation()
    ->modalHeading('Aprobar Solicitud de Permiso')
    ->modalDescription(fn (LeaveRequest $record) =>
        "¿Está seguro de aprobar la solicitud de {$record->employee->full_name}?"
    )
    ->modalSubmitActionLabel('Sí, Aprobar')
    ->form([
        \Filament\Forms\Components\Textarea::make('approval_notes')
            ->label('Notas de Aprobación')
            ->placeholder('Comentarios opcionales sobre la aprobación...')
            ->maxLength(500),
    ])
    ->visible(fn (LeaveRequest $record) =>
        $record->status === LeaveRequestStatus::PENDING &&
        auth()->user()->can('approve.recursos-humanos.solicitudes-permiso')
    )
    ->action(function (LeaveRequest $record, array $data) {
        try {
            $action = new ApproveLeaveRequestAction();
            $action->execute($record, $data['approval_notes'] ?? null);

            Notification::make()
                ->title('Solicitud Aprobada')
                ->body("La solicitud de permiso ha sido aprobada exitosamente.")
                ->success()
                ->send();

        } catch (ValidationException $e) {
            foreach ($e->errors() as $messages) {
                foreach ($messages as $message) {
                    Notification::make()
                        ->title('Error de Validación')
                        ->body($message)
                        ->danger()
                        ->persistent()
                        ->send();
                }
            }
        } catch (\Exception $e) {
            Notification::make()
                ->title('Error')
                ->body($e->getMessage())
                ->danger()
                ->persistent()
                ->send();
        }
    }),

Comandos de Producción

Iniciar Reverb

# Desarrollo
php artisan reverb:start --debug

# Producción (con Supervisor)
php artisan reverb:start --host=0.0.0.0 --port=8080

Configuración de Supervisor (producción)

[program:reverb]
command=php /var/www/erp/artisan reverb:start --host=0.0.0.0 --port=8080
directory=/var/www/erp
user=www-data
autostart=true
autorestart=true
redirect_stderr=true
stdout_logfile=/var/log/reverb.log
stopwaitsecs=10
minfds=10000

Configuración de Nginx (Proxy WSS)

server {
    listen 443 ssl http2;
    server_name ws.tudominio.com;

    ssl_certificate /etc/letsencrypt/live/tudominio.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/tudominio.com/privkey.pem;

    location / {
        proxy_http_version 1.1;
        proxy_set_header Host $http_host;
        proxy_set_header Scheme $scheme;
        proxy_set_header SERVER_PORT $server_port;
        proxy_set_header REMOTE_ADDR $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "Upgrade";

        proxy_pass http://127.0.0.1:8080;

        # Timeouts para conexiones WebSocket
        proxy_read_timeout 86400s;
        proxy_send_timeout 86400s;
    }
}


FASE 5: Catálogo de Eventos Implementados (RRHH)

A continuación se listan los eventos de broadcast actualmente operativos en el módulo de Recursos Humanos. Todos heredan de BaseRealtimeEvent.

5.1 Gestión de Solicitudes (LeaveRequest)

Clase de Evento Propósito Destinatarios Principales
LeaveRequestStatusChanged Notifica cualquier cambio de estado en una solicitud. Empleado + Roles con permiso de aprobación
LeaveRequestCreated Notifica la creación de una nueva solicitud. Roles con permiso de aprobación
LeaveRequestApproved Notifica que la solicitud fue aprobada. Empleado
LeaveRequestRejected Notifica que la solicitud fue rechazada. Empleado

5.2 Procesos de Nómina (PayrollRun)

Clase de Evento Propósito Destinatarios Principales
PayrollRunStatusChanged Notifica cambios en el ciclo de vida de la nómina (Cálculo, Revisión, Aprobación, etc). Usuarios con permiso view.recursos-humanos.corridas-nomina

5.3 Períodos de Nómina (PayrollPeriod)

Clase de Evento Propósito Destinatarios Principales
PayrollPeriodStatusChanged Notifica cambios de estado del período fiscal. Equipo de RRHH
PayrollPeriodReady Indica que el período está listo para iniciar procesos de nómina. Procesadores de Nómina
PayrollPeriodClosed Indica el cierre definitivo del período. Equipo de RRHH

5.4 Configuración de Nómina (PayrollSetting)

Clase de Evento Propósito Destinatarios Principales
PayrollSettingSubmitted Notifica que se ha enviado una configuración para aprobación. Aprobadores de RRHH
PayrollSettingApproved Notifica la aprobación de la configuración. Creador / RRHH
PayrollSettingRejected Notifica el rechazo con el motivo correspondiente. Creador / RRHH
PayrollSettingStatusChanged Cambio de estado general de la configuración. RRHH

5.5 Alertas de Contratos

Clase de Evento Propósito Destinatarios Principales
ContractExpiringAlert Alerta sobre contratos próximos a vencer o ya vencidos. RRHH + Jefe Inmediato (+ Legal en casos críticos)

Resumen de Archivos Implementados

Archivo Propósito
app/Events/BaseRealtimeEvent.php Clase base abstracta para todos los eventos de broadcast.
app/Events/HumanResources/*.php Colección de eventos específicos del módulo de RRHH.
app/Notifications/HumanResources/*.php Notificaciones por base de datos/mail (asincrónicas).
app/Livewire/RealtimeNotificationListener.php Componente global que escucha los WebSockets en el frontend.
routes/channels.php Lógica de autorización para canales privados por usuario.

Flujo Completo de Ejemplo

1. Empleado → Crea LeaveRequest → Estado: PENDING
                ↓
2. Sistema → Dispara LeaveRequestCreated → Jefe recibe notificación
                ↓
3. Jefe → Ejecuta ApproveLeaveRequestAction
                ↓
4. Action → Valida transición (PENDING → APPROVED) ✓
                ↓
5. Action → Actualiza BD → Estado: APPROVED
                ↓
6. Action → event(new LeaveRequestApproved($leaveRequest))
                ↓
7. Reverb → Envía mensaje a canal App.Models.User.{employee.user_id}
                ↓
8. Echo (Frontend) → Recibe evento en canal privado
                ↓
9. Alpine.js → Ejecuta handleNotification()
                ↓
10. Livewire → $wire.dispatch('echo-notification', event)
                ↓
11. Filament Notification → Toast visual para el empleado