Arquitectura de Notificaciones en Tiempo Real con Laravel Reverb
Índice
- FASE 1: Máquinas de Estados (State Machines)
- FASE 2: Matriz de Notificaciones
- FASE 3: Implementación Técnica de Laravel Reverb
- 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
- Solo notificar cuando requiere acción inmediata o es un evento crítico.
- Canales de entrega diferenciados: Real-time (toast) vs Database (histórico) vs Mail (externo).
- 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.
- 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.
- 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