# Real-Time Architecture: Reverb Websockets + GPS Return-Label Sync

*Developer synopsis — 2026-08-02 (updated same day: the system is now FULLY LIVE end-to-end). Companion doc: `dev-documents/websockets-event-map.md` (the full writer census and event catalog this design came from).*

**Status: deployed and verified in production.** The Apache `wss://` proxy is installed; the /orders page connects, authenticates the private channel, and receives events; ~34 broadcast emit sites are wired across Central; and a live test (server-side note insert → note rendered on an open /orders page with zero refresh) passed.

---

## 1. What changed

### A. Central now runs a websocket server (Laravel Reverb)

Central (`central.go-parts.com`) has a **Laravel Reverb** daemon — our own self-hosted websocket server, no third-party service:

| | |
|---|---|
| Daemon | `php artisan reverb:start --host=127.0.0.1 --port=6001` |
| Runs as | `centralgoparts` (same user as php-fpm and `schedule:run`) |
| Kept alive by | `/home/centralgoparts/bin/reverb-keepalive.sh` — centralgoparts crontab, every minute + `@reboot` |
| Logs | `/home/centralgoparts/logs/reverb.log` |
| Config | `config/reverb.php`, credentials in `.env` (`REVERB_APP_*`), `BROADCAST_DRIVER=reverb` |
| Browser path | `wss://central.go-parts.com/app/{key}` via Apache proxy (`mod_proxy_wstunnel` → 127.0.0.1:6001) |

**Why:** the /orders page (and others) currently keep fresh by polling half a dozen endpoints on timers, per open tab, forever. Reverb lets the server *push* a tiny event the moment data changes; the browser updates just the affected row. Faster UX, far less server load.

### B. The broadcast pattern (this is the part you'll reuse)

Server side, one helper is the entry point for everything:

```php
// After ANY write that changes what the /orders page shows:
\App\Support\OrdersBroadcast::changed($orderId, ['returns', 'notes']);
// scope values: status, items, tracking, notes, assignments, returns, costs, autofulfill
```

- It fires `App\Events\OrderDataChanged` on the private channel **`orders`** (auth: any logged-in staff user — `routes/channels.php`).
- It is **fail-safe**: if Reverb is down it logs a warning and the write proceeds. Always use the helper, never `event(...)` directly from a write path.
- `OrderDataChanged` implements **`ShouldBroadcastNow`** (synchronous) deliberately: this app has `QUEUE_CONNECTION=database` with **no queue worker**, so a normal queued `ShouldBroadcast` event would sit in the jobs table forever and never send. **Every broadcast event in this app must use `ShouldBroadcastNow`** until we run a real queue worker.

Client side (Echo is already bundled with Filament):

```js
Echo.private('orders')
    .listen('.OrderDataChanged', (e) => {
        // e = { order_id, external_order_id, scope: ['returns', ...] }
        // If that order's row is on screen: re-fetch just it via the existing
        // POST /api/orders/batch-items (85ms warm) and patch the row with the
        // same updater functions the pollers already call.
    });
```

Non-Laravel writers (the Magento storefront, legacy scripts, invoice-logger crons) can't call the helper — they get a tiny authenticated HTTP ping endpoint on Central that emits the event for them. The GPS return-label webhook below is the first working example of an external app driving a broadcast.

### Where the emits live today (~34 sites, all deployed)

- **Observer choke points (cover every Eloquent write automatically):**
  - `OrderObserver::updated` → `['status']` when `order_status_id` changed — covers `updateStatusBasedOnItems()`, `checkAndUpdateOrderStatus()`, webhooks, fulfillment pages, background commands.
  - `OrderItemObserver::updated` → scope derived from dirty columns (`can_auto_fulfill`→autofulfill, `tracking`/`carrier`→tracking, anything→items) — covers supplier changes, autofulfill toggles, Meyer sync, process-batch, item edits.
  - `OrderNoteObserver::created` → `['notes']` — covers every `OrderNote/Notes::create` (routes, email controller, webhooks, background commands).
- **Manual emits at raw-`DB::table()` sites** (observers never fire there — this is the trap to remember): the assignment routes + `DepartmentController` + `USAutoMonitoring`, the notes/status/tracking/returns/autofulfill routes in `routes/web.php`, `ItemActionsController` cost methods, `ItemReturnModal`, the LTL/Hazmat/ShipDev webhook controllers, and the GPS return-label controller.
- **Rule of thumb when adding a write:** if it's Eloquent on Order/OrderItem/OrderNote you get the broadcast free; if it's `DB::table()`, you must call `OrdersBroadcast::changed()` yourself.

### The client (live on /orders)

The realtime subscriber is the last block of `public/js/central-app.js`: raw WebSocket to `wss://<host>/app/<key>` (key exposed as `window.__reverbKey` by the AppServiceProvider hook), session-auth via `/broadcasting/auth`, subscribe `private-orders`, auto-reconnect with backoff. On `OrderDataChanged` it coalesces bursts per order (300ms), skips off-screen rows, and calls the **existing** updater functions per scope — `fetchOrderItems()` for item-ish scopes, `pollForStatusUpdates()` for status badges, `refreshOrderNotes()` for notes (this function was a husk that fetched and rendered nothing; repaired as part of this work), `pollNewAssignments()` for assignments. Add `?perflog=1` to see `[REALTIME]` lines for every event handled.

The legacy polls still run as the reconciliation safety net; once the events have soaked, their intervals should be lengthened (not removed).

### C. THE RULE going forward

> **Do not add new polling loops (`setInterval` + fetch) for data freshness. Any feature that needs live updates must broadcast a Reverb event at the write site and subscribe via Echo on the client.** The event catalog and the choke-point map (which write sites need instrumentation for each poll we retire) live in `websockets-event-map.md`. If your data source is outside Central's Laravel app, use the HTTP-ping pattern.

The existing polls are being retired one at a time (assignments first, then status/items, then notes/counts/autofulfill), each running in parallel with its events until proven, with one slow reconciliation poll kept forever as a dropped-connection safety net.

---

## 2. GPS return labels now sync back to Central (live today)

**The gap:** buying a **return label** in Go-Parts Ship recorded it only in GPS's own database. Central never learned — the /orders return badges and the return-tracking-compliance report only knew about returns typed in manually. (Outbound labels already synced via `SHIP_NOTIFY`; returns just… didn't.)

**The flow now:**

```
GPS: return label purchased
  ReturnShipmentService::createReturnFromRate() / ::createReturnFromOriginal()
  → after the DB transaction commits →
  WebhookDispatcher::dispatchReturnLabelNotify($shipment)      [never blocks the label]
  → POST https://central.go-parts.com/api/gps/return-label     [RETURN_LABEL_NOTIFY config, shared skey]
     payload: order_number, sku (when unambiguous), tracking_number, carrier,
              rma_number, return_reason, label_cost, outbound_tracking_number

Central: ReturnLabelWebhookController@handle
  1. matches the order by external_order_id, resolves the item by
     sku → outbound-tracking → single-item-order (in that order)
  2. attaches tracking to the item's most recent OPEN return entry
     (no tracking yet), or auto-creates one:
     type=refund, return_status=label_sent, comment carries reason + RMA
  3. writes an order note (author "GPS Sync") — always, even if the item
     couldn't be auto-matched, so the info is never lost
  4. OrdersBroadcast::changed(order, ['returns','notes'])  ← Reverb push
```

Implementation points, if you need to touch it:
- GPS side: `shipdev/app/Services/ShipstationApi/WebhookDispatcher.php` (`dispatchReturnLabelNotify`), called from both return paths in `shipdev/app/Services/ReturnShipmentService.php`. Config rows: `webhook_configurations` table, `event_type=RETURN_LABEL_NOTIFY` — one per warehouse (67 seeded, mirroring SHIP_NOTIFY credentials).
- Central side: `app/Http/Controllers/Api/ReturnLabelWebhookController.php`, route `POST /api/gps/return-label` (CSRF-exempt, shared-secret `skey` — same auth as the LTL/hazmat webhooks).
- Also relevant: Central has a dormant `RETURN_CREDIT_NOTIFY` handler (marks return credits issued) that GPS never sends — a natural follow-up to build on this same pattern.

---

## 3. Integrating GPS itself with Reverb (messaging, live updates)

GPS is the obvious next consumer — its yard↔ops **order message threads**, order status boards, and shipment lists all poll or require refreshes today. Two integration shapes, both supported by the daemon we already run:

1. **GPS as its own Reverb app (recommended for GPS-internal realtime, e.g. messaging).** Reverb serves multiple apps from one daemon: add a second entry to `config/reverb.php`'s `apps` array (own key/secret for the shipdev Laravel app), point GPS's `.env` `BROADCAST_DRIVER=reverb` + `REVERB_*` at `127.0.0.1:6001` (same box), and add a wss proxy include for `ship.go-parts.com`. Then GPS broadcasts `MessagePosted`, `OrderStatusChanged`, etc. on its own private channels with its own channel auth — completely isolated from Central's events. Same `ShouldBroadcastNow` rule applies unless GPS runs a queue worker.
2. **Cross-app events via HTTP ping (already the pattern for return labels).** When a GPS action must update *Central's* UI (or vice versa), keep the existing webhook direction and let the receiving app broadcast on its own channel — exactly what `RETURN_LABEL_NOTIFY` does. Don't have one app publish directly into the other's Reverb app: the receiving app owns its data writes and its events.

Rule of thumb: **the app that owns the database write owns the broadcast.**

---

## 4. Operational notes

- **Health check:** `curl -s http://127.0.0.1:6001/` (connection refused = down; keepalive restarts it within a minute). `ss -tlnp | grep 6001` shows the owner — should be `centralgoparts`.
- **Restart after config/env changes:** kill the `reverb:start` process; the keepalive relaunches it with fresh config.
- **Scaling:** one daemon handles thousands of connections; our staff count is trivial. If we ever cluster, Reverb supports Redis pub/sub between nodes.
- **Debugging events:** `php artisan reverb:start --debug` in a foreground shell prints every connection/message. For page-load performance issues on /orders, `?perflog=1` still prints the [PERFLOG] timeline in the console.
- **Reconciliation:** even fully migrated, one slow poll (3–5 min) stays as the safety net. Note when writing reconcilers: storefront-created orders historically had NULL `date_updated` (fixed 2026-08-02 in Magento's Saleshook, but old rows remain).

---

## 5. GPS (Go-Parts Ship) — upgraded 2026-08-02

GPS now runs on the same Reverb daemon as its **own app** (second entry in Central's `config/reverb.php`; own key/secret). No new proxy was needed: GPS browsers connect through the existing `wss://central.go-parts.com/app/{gpsKey}` (Reverb routes by app key) while private-channel auth stays on `ship.go-parts.com/broadcasting/auth` with the GPS session.

**Channels (yard isolation):** `private-gps.user.{id}` (a yard hears only its own orders) and `private-gps.staff` (internal users hear everything; auth = `!isExternalAccount()` — keep `is_external_account` flags on yard main accounts correct or those logins hear every yard). Every event broadcasts to both the order's yard channel and staff. **Sub-user scoping (fixed 2026-08-02):** orders hang off the yard's MAIN account while yard people log in as `sub-user` rows, so channel auth also admits a sub-user onto their parent account's channel (`created_by`) and the client subscribes to both — mirrors the `getTargetUserId()` scoping used across the app. Without this, yard sub-users never received any event.

**Event:** single `GpsDataChanged {type, ...}` via the fail-safe `App\Support\GpsBroadcast::push()` (ShouldBroadcastNow — GPS also has no queue worker). Wired types:
- `message` — from `OrderMessageService::postMessage()` (the single choke point for yard↔ops messaging, after the transaction commits) with order/thread/author/preview.
- `order_received` — from the ShipStation-API `createOrder` when Central pushes a new order in.
- `tracking` — from `ShipmentService::updateTracking()` on tracking-status movement.

**Client:** injected on every Filament page via a render hook in GPS's `AppServiceProvider` — connects, subscribes (user channel + parent-account channel for sub-users, staff channel for internal users, staff-side dedupe), and surfaces events as a bottom-right toast + title flash, plus two in-place updaters (2026-08-02): an open order's **message-thread panel live-refreshes** (Livewire `$refresh` located via `data-rt-order` on the component root; this replaced the panel's old 10s `wire:poll`, and the toast is suppressed while that thread is on screen), and the **Messages nav badge re-counts live** via `GET /api/rt/messages-badge` (debounced; handles both the user panel's `.gp-rd-pill` and stock Filament `.fi-badge` markup). The **Messages / All Messages list pages live-refresh the same way** (2026-08-02, marker `data-rt-messages-list`, walks up to the Livewire root) — their 15s `wire:poll` is removed, as is the user panel's `databaseNotifications()` + 30s bell poller (never functional: 0 rows ever written; re-add Reverb-driven if a bell is ever wanted). Verified live: 35s idle on /messages produced zero polling requests, and a real staff `postMessage` made the new thread appear in the open list within ~2s. Reconnect catch-up refreshes badge, panels, and lists. Same viewport rule as Central: an event never shifts page layout mid-read.

**GPS traps for developers:** same two as Central — `ShouldBroadcastNow` mandatory, and raw `DB::table()` writes need explicit `GpsBroadcast::push()` calls. Note `pusher/pusher-php-server` is now a GPS composer dependency (the reverb driver requires it; its absence briefly 500'd the app during rollout — fixed).

**Verified end-to-end (2026-08-02):** the real production triggers (`postMessage`, `updateTracking`) were exercised against a logged-in yard sub-user session — toast, live thread refresh, and badge update all confirmed. `order_received`'s emit site remains synthetically tested only (its one-line emit sits in ShipStation `createOrder`).

**Operational trap:** the Reverb daemon reads `config/reverb.php` once at boot — an app added without a restart does not exist to Reverb and clients get `pusher:error 4001` (this silently killed GPS realtime on launch day). After any Reverb config change: `php artisan reverb:restart` in the Central app; keepalive relaunches within a minute and browser clients auto-reconnect.

**Not yet wired in GPS (candidates for next pass):** label/void events, LTL/hazmat status changes.
