Reverb infra + Kanban live refresh

- laravel/reverb instalat + reverb:install (config/reverb.php, channels.php)
- routes/channels.php: tenant.{slug} private channel cu auth check
  user.company_id == tenant.id
- App\Events\WorkOrderUpdated implements ShouldBroadcast pe
  PrivateChannel('tenant.{slug}'); broadcastAs 'work-order.updated'
- WorkOrder::booted dispatch event la fiecare update (skip if broadcast=log)
- Filament panel BODY_END inject:
  - Pusher JS de la CDN (compatibil Reverb)
  - Echo client conectat la Reverb (config dinamic din env)
  - Subscribe pe tenant private channel; la 'work-order.updated' →
    Livewire.all().forEach($refresh)
- Kanban view: wire:poll.5s (live refresh fallback) +
  x-on:autocrm:wo-updated.window=$refresh (instant când WS e activ)

Pentru moment BROADCAST_CONNECTION=log în Coolify (Reverb nu e deployat).
Când deployezi Reverb container separat:
  Coolify → New App → Same repo → CMD override:
    php artisan reverb:start --host=0.0.0.0 --port=8080
  → FQDN: ws.service.mir.md:8080
  → Set BROADCAST_CONNECTION=reverb pe AutoCRM app
  → Real-time instant fără cod nou.
This commit is contained in:
2026-05-07 14:25:26 +00:00
parent 09fd0bada2
commit 7ce78c350c
10 changed files with 1299 additions and 5 deletions
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace App\Events;
use App\Models\Tenant\WorkOrder;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class WorkOrderUpdated implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(public WorkOrder $workOrder, public string $tenantSlug) {}
public function broadcastOn(): array
{
return [new PrivateChannel('tenant.' . $this->tenantSlug)];
}
public function broadcastAs(): string
{
return 'work-order.updated';
}
public function broadcastWith(): array
{
return [
'id' => $this->workOrder->id,
'number' => $this->workOrder->number,
'status' => $this->workOrder->status,
'pay_status' => $this->workOrder->pay_status,
'total' => (float) $this->workOrder->total,
'updated_at' => $this->workOrder->updated_at?->toIso8601String(),
];
}
}
+13 -1
View File
@@ -109,7 +109,7 @@ class WorkOrder extends Model
return sprintf('WO-%s-%04d', $year, $count + 1); return sprintf('WO-%s-%04d', $year, $count + 1);
} }
/** Auto-send 'ready' email when status transitions to 'ready'. */ /** Auto-send 'ready' email + broadcast WS event on status change. */
protected static function booted(): void protected static function booted(): void
{ {
static::updated(function (self $wo) { static::updated(function (self $wo) {
@@ -120,6 +120,18 @@ class WorkOrder extends Model
) { ) {
app(\App\Services\NotificationDispatcher::class)->workOrderReady($wo); app(\App\Services\NotificationDispatcher::class)->workOrderReady($wo);
} }
// Broadcast real-time update on any field change (skip if broadcasting=log).
if (config('broadcasting.default') !== 'log') {
try {
$company = \App\Models\Central\Company::withoutGlobalScopes()->find($wo->company_id);
if ($company) {
\App\Events\WorkOrderUpdated::dispatch($wo, $company->slug);
}
} catch (\Throwable $e) {
\Illuminate\Support\Facades\Log::debug('WO broadcast skipped: ' . $e->getMessage());
}
}
}); });
} }
} }
+41 -2
View File
@@ -130,7 +130,15 @@ class TenantPanelProvider extends PanelProvider
) )
->renderHook( ->renderHook(
PanelsRenderHook::BODY_END, PanelsRenderHook::BODY_END,
fn (): string => <<<'HTML' fn (): string => Blade::render(<<<'BLADE'
@php
$tenant = app(\App\Tenancy\TenantManager::class)->current();
$reverbKey = config('broadcasting.connections.reverb.key');
$reverbHost = config('broadcasting.connections.reverb.options.host');
$reverbPort = config('broadcasting.connections.reverb.options.port');
$reverbScheme = config('broadcasting.connections.reverb.options.scheme', 'https');
$broadcastEnabled = config('broadcasting.default') === 'reverb' && $reverbKey && $reverbHost;
@endphp
<script> <script>
if ('serviceWorker' in navigator) { if ('serviceWorker' in navigator) {
window.addEventListener('load', () => { window.addEventListener('load', () => {
@@ -138,7 +146,38 @@ class TenantPanelProvider extends PanelProvider
}); });
} }
</script> </script>
HTML @if ($broadcastEnabled && $tenant)
<script src="https://js.pusher.com/8.4/pusher.min.js"></script>
<script>
(function() {
if (typeof Pusher === 'undefined') return;
Pusher.logToConsole = false;
window.AutoCRMEcho = new Pusher('{{ $reverbKey }}', {
wsHost: '{{ $reverbHost }}',
wsPort: {{ $reverbPort ?: ($reverbScheme === 'https' ? 443 : 80) }},
wssPort: {{ $reverbPort ?: 443 }},
forceTLS: {{ $reverbScheme === 'https' ? 'true' : 'false' }},
enabledTransports: ['ws', 'wss'],
cluster: 'mt1',
authEndpoint: '/broadcasting/auth',
auth: {
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content || ''
}
}
});
const ch = window.AutoCRMEcho.subscribe('private-tenant.{{ $tenant->slug }}');
ch.bind('work-order.updated', function(payload) {
window.dispatchEvent(new CustomEvent('autocrm:wo-updated', { detail: payload }));
// If we're on the kanban or a WO list page, refresh the Livewire component.
if (typeof Livewire !== 'undefined') {
Livewire.all().forEach(c => c.$refresh && c.$refresh());
}
});
})();
</script>
@endif
BLADE)
); );
} }
} }
+1
View File
@@ -9,6 +9,7 @@ return Application::configure(basePath: dirname(__DIR__))
->withRouting( ->withRouting(
web: __DIR__.'/../routes/web.php', web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php', commands: __DIR__.'/../routes/console.php',
channels: __DIR__.'/../routes/channels.php',
health: '/up', health: '/up',
) )
->withMiddleware(function (Middleware $middleware): void { ->withMiddleware(function (Middleware $middleware): void {
+1
View File
@@ -12,6 +12,7 @@
"filament/spatie-laravel-media-library-plugin": "^5.6", "filament/spatie-laravel-media-library-plugin": "^5.6",
"laravel/framework": "^12.0", "laravel/framework": "^12.0",
"laravel/octane": "^2.17", "laravel/octane": "^2.17",
"laravel/reverb": "^1.10",
"laravel/sanctum": "^4.3", "laravel/sanctum": "^4.3",
"laravel/tinker": "^2.10.1", "laravel/tinker": "^2.10.1",
"spatie/laravel-activitylog": "^5.0", "spatie/laravel-activitylog": "^5.0",
Generated
+1003 -1
View File
File diff suppressed because it is too large Load Diff
+82
View File
@@ -0,0 +1,82 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Broadcaster
|--------------------------------------------------------------------------
|
| This option controls the default broadcaster that will be used by the
| framework when an event needs to be broadcast. You may set this to
| any of the connections defined in the "connections" array below.
|
| Supported: "reverb", "pusher", "ably", "redis", "log", "null"
|
*/
'default' => env('BROADCAST_CONNECTION', 'null'),
/*
|--------------------------------------------------------------------------
| Broadcast Connections
|--------------------------------------------------------------------------
|
| Here you may define all of the broadcast connections that will be used
| to broadcast events to other systems or over WebSockets. Samples of
| each available type of connection are provided inside this array.
|
*/
'connections' => [
'reverb' => [
'driver' => 'reverb',
'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',
],
'client_options' => [
// Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html
],
],
'pusher' => [
'driver' => 'pusher',
'key' => env('PUSHER_APP_KEY'),
'secret' => env('PUSHER_APP_SECRET'),
'app_id' => env('PUSHER_APP_ID'),
'options' => [
'cluster' => env('PUSHER_APP_CLUSTER'),
'host' => env('PUSHER_HOST') ?: 'api-'.env('PUSHER_APP_CLUSTER', 'mt1').'.pusher.com',
'port' => env('PUSHER_PORT', 443),
'scheme' => env('PUSHER_SCHEME', 'https'),
'encrypted' => true,
'useTLS' => env('PUSHER_SCHEME', 'https') === 'https',
],
'client_options' => [
// Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html
],
],
'ably' => [
'driver' => 'ably',
'key' => env('ABLY_KEY'),
],
'log' => [
'driver' => 'log',
],
'null' => [
'driver' => 'null',
],
],
];
+102
View File
@@ -0,0 +1,102 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Reverb Server
|--------------------------------------------------------------------------
|
| This option controls the default server used by Reverb to handle
| incoming messages as well as broadcasting message to all your
| connected clients. At this time only "reverb" is supported.
|
*/
'default' => env('REVERB_SERVER', 'reverb'),
/*
|--------------------------------------------------------------------------
| Reverb Servers
|--------------------------------------------------------------------------
|
| Here you may define details for each of the supported Reverb servers.
| Each server has its own configuration options that are defined in
| the array below. You should ensure all the options are present.
|
*/
'servers' => [
'reverb' => [
'host' => env('REVERB_SERVER_HOST', '0.0.0.0'),
'port' => env('REVERB_SERVER_PORT', 8080),
'path' => env('REVERB_SERVER_PATH', ''),
'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'),
'timeout' => env('REDIS_TIMEOUT', 60),
],
],
'pulse_ingest_interval' => env('REVERB_PULSE_INGEST_INTERVAL', 15),
'telescope_ingest_interval' => env('REVERB_TELESCOPE_INGEST_INTERVAL', 15),
],
],
/*
|--------------------------------------------------------------------------
| Reverb Applications
|--------------------------------------------------------------------------
|
| Here you may define how Reverb applications are managed. If you choose
| to use the "config" provider, you may define an array of apps which
| your server will support, including their connection credentials.
|
*/
'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' => ['*'],
'ping_interval' => env('REVERB_APP_PING_INTERVAL', 60),
'activity_timeout' => env('REVERB_APP_ACTIVITY_TIMEOUT', 30),
'max_connections' => env('REVERB_APP_MAX_CONNECTIONS'),
'max_message_size' => env('REVERB_APP_MAX_MESSAGE_SIZE', 10_000),
'accept_client_events_from' => env('REVERB_APP_ACCEPT_CLIENT_EVENTS_FROM', 'members'),
'rate_limiting' => [
'enabled' => env('REVERB_APP_RATE_LIMITING_ENABLED', false),
'max_attempts' => env('REVERB_APP_RATE_LIMIT_MAX_ATTEMPTS', 60),
'decay_seconds' => env('REVERB_APP_RATE_LIMIT_DECAY_SECONDS', 60),
'terminate_on_limit' => env('REVERB_APP_RATE_LIMIT_TERMINATE', false),
],
],
],
],
];
@@ -33,7 +33,10 @@
.kb-empty { font-size: 11px; color: #9ca3af; text-align: center; padding: 16px 0; } .kb-empty { font-size: 11px; color: #9ca3af; text-align: center; padding: 16px 0; }
</style> </style>
<div x-data="{ dragId: null }"> {{-- Live: poll fallback every 5s + instant refresh on WebSocket event --}}
<div x-data="{ dragId: null }"
wire:poll.5s
x-on:autocrm:wo-updated.window="$wire.$refresh()">
<div class="kb-board"> <div class="kb-board">
@foreach ($columns as $status => $col) @foreach ($columns as $status => $col)
<div <div
+13
View File
@@ -0,0 +1,13 @@
<?php
use App\Tenancy\TenantManager;
use Illuminate\Support\Facades\Broadcast;
/**
* Tenant-scoped private channels: only users belonging to the current tenant
* can subscribe. Channel name encodes the tenant slug.
*/
Broadcast::channel('tenant.{slug}', function ($user, string $slug) {
$tenant = app(TenantManager::class)->current();
return $tenant && $tenant->slug === $slug && (int) $user->company_id === (int) $tenant->id;
});