# Venus Realtime Chat — Complete Architecture & Rebuild Guide

> **What this file is.** Two things in one document:
>
> 1. **Part A–G** — an exact description of how realtime chat works in this repo today (Laravel + Redis Pub/Sub + Node/Socket.IO), with file references you can click.
> 2. **Part H–J** — a self-contained *blueprint* you can hand to an AI agent (or a new developer) to reproduce this exact system in another project and have it work end to end.
>
> Every claim below was read out of the source in this repo. Where the current code has a bug or a gap, it is called out explicitly in **Part F — Known Issues** rather than glossed over.

---

## Table of Contents

- [Part A — The 30-second summary](#part-a--the-30-second-summary)
- [Part B — Components map](#part-b--components-map)
- [Part C — The end-to-end flow, step by step](#part-c--the-end-to-end-flow-step-by-step)
- [Part D — The Redis Pub/Sub contract](#part-d--the-redis-pubsub-contract)
- [Part E — The Socket.IO layer](#part-e--the-socketio-layer)
- [Part F — Known issues in the current implementation](#part-f--known-issues-in-the-current-implementation)
- [Part G — Frontend integration guide](#part-g--frontend-integration-guide)
- [Part H — Environment, running, deploying](#part-h--environment-running-deploying)
- [Part I — Debugging cookbook](#part-i--debugging-cookbook)
- [Part J — Rebuild blueprint (give this to an agent)](#part-j--rebuild-blueprint-give-this-to-an-agent)

---

## Part A — The 30-second summary

**Chat messages are NOT sent over the socket. They are sent over HTTP.**

This is the single most important thing to understand, and the most common source of confusion.

```
   ┌──────────┐  1. POST /send-message (HTTPS)   ┌─────────────┐
   │  Client  │ ───────────────────────────────► │   Laravel   │
   │ (mobile/ │                                   │  (REST API) │
   │   web)   │ ◄─────────────────────────────── │   + MySQL   │
   └──────────┘  2. 200 OK { message: {...} }    └──────┬──────┘
        ▲                                                │
        │                                                │ 3. PUBLISH
        │                                                ▼
        │                                        ┌───────────────┐
        │                                        │     Redis     │
        │                                        │  Pub/Sub bus  │
        │                                        └───────┬───────┘
        │                                                │ 4. message
        │  6. socket.on('VN-...')                        ▼
        │                                        ┌───────────────┐
        └────────────────────────────────────────│   Node.js     │
           5. io.emit(event, payload)            │  Socket.IO    │
              over WSS                           └───────────────┘
```

1. The **sender** calls a normal Laravel REST endpoint to persist the message.
2. Laravel stores it in MySQL and returns it in the HTTP response — so the **sender** sees their own message immediately from the HTTP reply, *not* from the socket.
3. An Eloquent observer fires an event → a queued listener → a Laravel Notification, whose custom `RedisNotificationChannel` **publishes a JSON string to a Redis channel**.
4. The Node service holds a persistent `SUBSCRIBE` on that Redis channel.
5. Node parses the JSON and re-emits it over Socket.IO.
6. The **receiver's** open socket picks it up and renders the incoming message.

**The socket is a one-way, read-only push pipe.** Node → browser only. Nothing a client emits over the socket currently reaches Laravel (see [Part F.1](#f1-the-node--laravel-direction-is-a-dead-end)).

**Why this design?** Laravel (PHP-FPM) cannot hold long-lived WebSocket connections — a PHP process dies at the end of each request. Node can hold tens of thousands of open sockets cheaply. Redis Pub/Sub is the message bus that lets a short-lived PHP request hand a payload to the long-lived Node process. It is the same idea as Laravel Echo Server / Soketi, just hand-rolled.

---

## Part B — Components map

### B.1 The Node service — `NodeJs/`

Seven files, 339 lines total. That is the entire realtime tier.

| File | Lines | Responsibility |
|---|---|---|
| [src/server.js](src/server.js) | 2 | Entry point. Loads `.env`, requires `app.js`. |
| [src/app.js](src/app.js) | 13 | Instantiates `Application`, calls `initialize()` then `start()`, exits on failure. |
| [src/core/application.js](src/core/application.js) | 95 | Wires everything: HTTPS server, Socket.IO, two Redis clients, handlers, `/health`. |
| [src/sockets/index.js](src/sockets/index.js) | 74 | Inbound socket events (`join_room`, `leave_room`, `message`, `custom_event`) → publishes to Redis. |
| [src/subscribers/index.js](src/subscribers/index.js) | 35 | Redis `SUBSCRIBE` → `io.emit()`. **This is the chat delivery path.** |
| [src/config/index.js](src/config/index.js) | 13 | Redis URL + the two channel names. |
| [src/utils/logger.js](src/utils/logger.js) | 107 | Winston logger with daily-rotating files and `logger.socket.*` / `logger.redis.*` helpers. |

> ⚠️ **There is no `package.json` inside `NodeJs/`.** Dependencies live in the **root** [package.json](../package.json) (`socket.io`, `redis`, `express`, `winston`, `winston-daily-rotate-file`, `dotenv`, `cors`, `helmet`). The Node service therefore resolves its modules from the Laravel project root's `node_modules/`, and must be started from the project root. See [Part H.3](#h3-running-the-node-service).

### B.2 The Laravel side — chat domain

| Concern | File |
|---|---|
| Chat model (morph target) | [app/Models/Chat.php](../app/Models/Chat.php) |
| Message model + observer boot | [app/Models/Message.php](../app/Models/Message.php) |
| Fires the event on insert | [app/Observers/MessageObserver.php](../app/Observers/MessageObserver.php) |
| Event | [app/Events/Chats/MessageCreated.php](../app/Events/Chats/MessageCreated.php) |
| Queued listener (routes by chat type) | [app/Listeners/Chats/MessageCreatedListener.php](../app/Listeners/Chats/MessageCreatedListener.php) |
| Event→listener binding | [app/Providers/EventServiceProvider.php:94-99](../app/Providers/EventServiceProvider.php#L94-L99) |
| Persists the message | [app/Services/General/MessagingService.php](../app/Services/General/MessagingService.php) |
| Loads a chat + marks read | [app/Services/General/ChatService.php](../app/Services/General/ChatService.php) |
| **Publishes to Redis** | [app/Services/Utilities/NotificationChannels/RedisNotificationChannel.php](../app/Services/Utilities/NotificationChannels/RedisNotificationChannel.php) |
| Decides which channels fire | [app/Traits/NotificationChannelResolver.php](../app/Traits/NotificationChannelResolver.php) |
| Notification (tech-support path) | [app/Notifications/General/NewMessageNotification.php](../app/Notifications/General/NewMessageNotification.php) |
| Notification (order-chat path) | [app/Notifications/General/User/GeneralNotification.php](../app/Notifications/General/User/GeneralNotification.php) |
| Payload shapers | [app/Http/helpers.php:245-414](../app/Http/helpers.php#L245-L414) |
| Order-chat endpoints | [app/Http/Controllers/Api/App/Shared/Chats/OrderChatController.php](../app/Http/Controllers/Api/App/Shared/Chats/OrderChatController.php) |
| Access control | [app/Http/Middleware/OrderChatMiddleware.php](../app/Http/Middleware/OrderChatMiddleware.php) |
| Validation | [app/Http/Requests/Api/Shared/Chat/SendMessageRequest.php](../app/Http/Requests/Api/Shared/Chat/SendMessageRequest.php) |

### B.3 Database schema

**`chats`** — [migration](../database/migrations/2024_12_04_101918_create_chats_table.php)

| Column | Type | Notes |
|---|---|---|
| `id` | bigint PK | |
| `user_id` | FK users | the chat **creator** |
| `receiver_id` | FK users | the **other** party |
| `chatable_id` / `chatable_type` | nullable morph | e.g. `Order` for order chats; `NULL` for tech-support |
| `type` | string | `technical_support` \| `customer_technician` |
| `status` | string | `pending` \| `in_progress` \| `closed` \| `completed` |
| timestamps, soft deletes | | |

**`messages`** — [migration](../database/migrations/2024_12_04_102448_create_messages_table.php)

| Column | Type | Notes |
|---|---|---|
| `id` | bigint PK | |
| `sender_id` | FK users | |
| `messageable_id` / `messageable_type` | morph (**not nullable**) | `Chat` or `CommunityChat` |
| `message` | text | plain text, **or** an attachment path when `message_type` is image/video |
| `message_type` | string | `text` \| `image` \| `video` |
| `read_at` | timestamp null | |
| `deleted_by_sender_at` / `deleted_by_receiver_at` | timestamp null | per-side soft delete |

> Note the commented-out `chat_id` column in both models — the schema was migrated from a direct FK to a **polymorphic** `messageable`. `Message::chat()` is commented out; use `Message::messageable()`. `MessageDeletedListener` still calls `$event->message->chat` and would fatal if ever dispatched — see [F.6](#f6-messagedeletedlistener-references-a-relation-that-no-longer-exists).

### B.4 Chat flavours

| Flavour | `chats.type` | `chatable` | Endpoint prefix |
|---|---|---|---|
| Order chat (client ↔ technician) | `customer_technician` | `Order` | `orders/{order}/chat` |
| Technical support | `technical_support` | `NULL` | `technical-support/chat` |
| Technicians community | *(separate model)* | `CommunityChat` | `community-chat` |

---

## Part C — The end-to-end flow, step by step

Traced for an **order chat** message. Tech-support is identical except for which helper builds the payload.

### Step 1 — Client opens the chat

```http
GET /api/orders/{order}/chat
Authorization: Bearer <jwt>
```

→ [`OrderChatController::chat()`](../app/Http/Controllers/Api/App/Shared/Chats/OrderChatController.php#L22-L34)

```php
$chat = $order->chat()->firstOrCreate([
    'chatable_id'   => $order->id,
    'chatable_type' => Order::class,
], [
    'user_id'       => $order->user_id,
    'receiver_id'   => $order->technician_id,
    'type'          => Chat::TYPE_CUSTOMER_TECHNICIAN,
]);
return $this->chatService->show($chat, ChatDetailsResource::class);
```

- `firstOrCreate` means the chat row is **lazily created on first open** — there is no separate "create chat" call.
- The `order_chat` middleware ([OrderChatMiddleware](../app/Http/Middleware/OrderChatMiddleware.php)) rejects anyone who is neither `order.user_id` nor `order.technician_id` with **403**.
- [`ChatService::show()`](../app/Services/General/ChatService.php#L38-L47) eager-loads messages **and marks every message not sent by me as read** in the same call:
  ```php
  $model->messages()->where('sender_id', '!=', auth('api')->id())->update(['read_at' => now()]);
  ```
  ⚠️ Side effect: opening a chat marks the *entire* history read, and no "read" event is pushed to the other party — so **read receipts do not update in realtime**.
- Response carries `messages` **paginated, newest-first** (`latest()->paginate()`), so a client must reverse the array to render oldest→newest.

**The client should open its socket at this point** (or at app boot) — see [Part G](#part-g--frontend-integration-guide).

### Step 2 — Client sends a message

```http
POST /api/orders/{order}/chat/{chat}/send-message
Authorization: Bearer <jwt>
Content-Type: application/json

{ "message_type": "text", "message": "Hello 👋" }
```

Validated by [SendMessageRequest](../app/Http/Requests/Api/Shared/Chat/SendMessageRequest.php):
- `message_type` ∈ `text | image | video` (required)
- `message` required; when type is `image`/`video` it must pass the `AttachmentExists` rule (i.e. you **upload first via the attachments endpoint, then send the returned path as `message`**).

### Step 3 — Laravel persists it

[`MessagingService::save()`](../app/Services/General/MessagingService.php#L27-L64), inside a DB transaction:

```php
$message = $chat->messages()->create($request->validated() + ['sender_id' => auth('api')->id()]);
if ($chat->messages()->count() === 1) {
    $chat->update(['status' => Chat::STATUS_IN_PROGRESS]);
}
DB::commit();
return json(MessageResource::make($message), __('Message sent successfully'));
```

The HTTP response is the sender's own copy of the message. **The sender does not get their message back over the socket** (and must not wait for it).

### Step 4 — Observer → Event → Listener

[`Message::boot()`](../app/Models/Message.php#L16-L20) registers [`MessageObserver`](../app/Observers/MessageObserver.php), whose `created()` hook fires `MessageCreated`.

> ⚠️ Registering the observer inside `boot()` rather than a service provider means it fires for **any** `Message` insert anywhere — including seeders and tinker.

[`MessageCreatedListener`](../app/Listeners/Chats/MessageCreatedListener.php) `implements ShouldQueue` — **it runs on the queue worker, not in the web request.** If no worker is running (and `QUEUE_CONNECTION` is not `sync`), messages save fine but **nothing is ever pushed to the socket**. This is the #1 cause of "chat works but realtime is dead".

It routes by chat type:

```php
if ($messageable_type === Chat::class && $messageable->type === Chat::TYPE_CUSTOMER_TECHNICIAN) {
    $this->sendOrderChatMessageNotifications($event->message);   // → GeneralNotification
}
if ($messageable_type === Chat::class && $messageable->type === Chat::TYPE_TECHNICAL_SUPPORT) {
    sendChatMessageNotification($event->message);                // → NewMessageNotification
}
```

The whole body is wrapped in `try/catch` that only logs — **a failure here is silent from the client's perspective.**

### Step 5 — Building the recipient list

**Order chat** — [`SendOrderNotifications::sendOrderChatMessageNotifications()`](../app/Traits/SendOrderNotifications.php#L30-L42):
```php
$notifiable = collect(getNotifiableSystemAdmins());
$notifiable->push($message->receiver());
```

**Tech support** — [`sendChatMessageNotification()`](../app/Http/helpers.php#L394-L414):
```php
$notifiable = collect(getNotifiableSystemAdmins())
    ->push($message->receiver())
    ->unique(fn($user) => $user->id)
    ->reject(fn($user) => $user->id === $message->sender->id)   // don't notify yourself
    ->values();
```

⚠️ **The order-chat branch has neither `unique()` nor the self-`reject()`.** If the sender is an admin, they get notified about their own message, possibly twice.

[`Message::receiver()`](../app/Models/Message.php#L52-L61) resolves "the other party" with a query, not a relation:
```php
User::query()->where('id', '!=', $this->sender_id)
    ->where(fn($q) => $q->where('id', $this->messageable->user_id)
                        ->orWhere('id', $this->messageable->receiver_id))->first();
```

[`getNotifiableSystemAdmins()`](../app/Http/helpers.php#L186-L192) returns all admins + supervisors (by `user_type` or by role name match). **Every admin is notified about every chat message in the system** — fine at small scale, a fan-out problem at large scale.

### Step 6 — Notification channel resolution

Each recipient gets `$user->notify(new GeneralNotification($data))` (or `NewMessageNotification`). Both use [`NotificationChannelResolver`](../app/Traits/NotificationChannelResolver.php):

```php
$channels = ['database'];                    // always stored in the notifications table
$channels[] = RedisNotificationChannel::class;   // always → realtime
if ($this->isOnlyRedisPrivilegedUser($notifiable, $type)) return $channels;  // admins stop here
if ($notifiable->allow_notifications) $channels[] = FCMNotificationChannel::class;  // push
```

So each recipient produces: a DB row, a **Redis publish**, and (for non-admins who allow it) an FCM push.

> `GeneralNotification implements ShouldQueue`; `NewMessageNotification` **does not**. So order-chat notifications get queued a second time, while tech-support notifications send inline within the already-queued listener. Both work; the timing differs.

### Step 7 — The publish

[`RedisNotificationChannel::send()`](../app/Services/Utilities/NotificationChannels/RedisNotificationChannel.php#L9-L19) — **this is the hand-off point between PHP and Node**:

```php
$redis = app('redis');
$redis->publish(
    config('app.name') . '_laravel_to_node',
    json_encode($this->dataTransformer($notification->data ?? [], $user, $notification->actor ?? null))
);

if (in_array($user->user_type, [UserType::SUPER_ADMIN->value, UserType::SUPERVISOR->value])) {
    $redis->publish(config('app.name') . '_laravel_to_node',
        json_encode($this->adminNotificationDataTransformer($notification, $user)));
}
```

Admins receive **two** publishes: the per-chat event *and* a generic `VN-admin-notifications:{id}` event that drives the dashboard notification bell.

### Step 8 — Node receives and fans out

[`RedisSubscriber::initialize()`](src/subscribers/index.js#L11-L18) subscribed at boot; `handleLaravelMessage` does:

```js
const parsed = JSON.parse(message);
this.io.emit(`${parsed.event}`, parsed.payload);
```

That is the whole delivery mechanism: **the JSON's `event` field becomes the Socket.IO event name, and its `payload` field becomes the argument.**

⚠️ `io.emit()` broadcasts to **every connected socket**, not to a room. See [F.2](#f2-every-message-is-broadcast-to-every-connected-client).

### Step 9 — The client renders

The client registered `socket.on('VN-customer_technician:{orderId}:{myUserId}', cb)` and appends `payload` to the message list.

---

## Part D — The Redis Pub/Sub contract

This is the interface between the two services. **Get this exactly right and everything works; get one character wrong and nothing arrives, silently.**

### D.1 Channel names

Both sides build the channel name from the app name:

| Side | Code | Result |
|---|---|---|
| Laravel | `config('app.name') . '_laravel_to_node'` | `Venus_laravel_to_node` |
| Node | [`${process.env.APP_NAME}_laravel_to_node`](src/config/index.js#L5) | `Venus_laravel_to_node` |

🔴 **`APP_NAME` must be byte-identical in both `.env` files**, including case and spaces. `APP_NAME=Venus` in Laravel and `APP_NAME=venus` in Node = total silence, with no error anywhere. This is the single most common misconfiguration.

| Channel | Publisher | Subscriber | Status |
|---|---|---|---|
| `{APP_NAME}_laravel_to_node` | Laravel | Node | ✅ **This carries all chat traffic.** |
| `{APP_NAME}_node_to_laravel` | Node | *(nobody)* | ❌ Dead — see [F.1](#f1-the-node--laravel-direction-is-a-dead-end). |

> ⚠️ **Redis key prefix caveat.** [config/database.php:128](../config/database.php#L128) sets a Redis `prefix` (`{app_name}_database_`). Whether the client library applies that prefix to `PUBLISH` channel names depends on `REDIS_CLIENT` (`phpredis` vs `predis`) and version. **Always verify empirically** with `redis-cli PSUBSCRIBE '*'` (see [Part I.1](#i1-is-laravel-actually-publishing)) and read the *actual* channel name off the wire before assuming.

### D.2 Payload envelope — Laravel → Node

Exactly two keys. Anything else is ignored by Node.

```json
{
  "event":   "<socket.io event name>",
  "payload": { }
}
```

### D.3 Event name grammar

Built in [`RedisNotificationChannel::dataTransformer()`](../app/Services/Utilities/NotificationChannels/RedisNotificationChannel.php#L29-L42):

```php
"VN-{$source_data['notification_type']}:{$source_data['notify_id']}:{$user->id}"
```

```
VN-<notification_type>:<notify_id>:<recipient_user_id>
```

| Part | Meaning |
|---|---|
| `VN-` | Fixed prefix (Venus Notification). |
| `notification_type` | The chat/notification kind. |
| `notify_id` | The **scope id** — what the event is *about*. Differs per flavour, see below. |
| `recipient_user_id` | The user this copy is for. **One publish per recipient**, so N recipients = N publishes with N different event names. |

#### Concrete event names

| Flavour | `notification_type` | `notify_id` source | Example event name |
|---|---|---|---|
| **Order chat** | `customer_technician` | `$message->messageable->chatable->id` → **the ORDER id** | `VN-customer_technician:812:45` |
| **Technical support** | `technical_support` | `$message->messageable_id` → **the CHAT id** | `VN-technical_support:37:45` |
| **Community** | `community` | community chat id | `VN-community:9:45` |
| **Admin bell** | *(fixed)* | — | `VN-admin-notifications:45` |

🔴 **The trap:** order chat is keyed by **order id**, tech support by **chat id**. A frontend that uses `chat.id` for both will silently never receive order-chat messages. Use the id you routed with:
- order chat → the `{order}` route param
- tech support → the `{chat}` route param

### D.4 Payload shape — a chat message

```php
'payload' => isset($source_data['meta']) ? $source_data['meta']['message'] : [ ... ]
```

`meta` is set by all chat transformers via [`transformNotificationMetaData()`](../app/Http/helpers.php#L374-L391), so for chat the payload is **the message object itself**:

```json
{
  "id": 4471,
  "message_type": "text",
  "message": "Hello 👋",
  "created_at": "2026-08-03 15:22:41",
  "read_at": null,
  "is_deleted": false,
  "sender": {
    "id": 12,
    "full_name": "Ahmed Ghoneim",
    "image": "https://api.example.com/storage/users/12/avatar.png"
  }
}
```

Field notes:
- `created_at` — `Y-m-d H:i:s`, **server timezone, no offset**. Convert client-side deliberately.
- `read_at` — human string (`"5 minutes ago"`) when set, `null` otherwise. **Not** a timestamp. Treat as display-only.
- `is_deleted` — `deleted_by_sender_at || deleted_by_receiver_at`, so it is `true` if *either* side deleted.
- `sender.image` — built as `asset('storage/' . $message->sender->image)`. ⚠️ Elsewhere the codebase reads avatars from `$user->library->image` ([RedisNotificationChannel:60](../app/Services/Utilities/NotificationChannels/RedisNotificationChannel.php#L60)); if `users.image` is unused you get a URL pointing at `storage/` — always guard with a fallback avatar on the client.

Note the shape **differs from `MessageResource`** returned by HTTP: HTTP gives `sender` as a full `SimpleUserResource`, the socket gives a hand-built 3-field object. **Normalize both into one client-side model** or your UI will break on whichever path you didn't test.

### D.5 Payload shape — admin notification bell

From [`adminNotificationDataTransformer()`](../app/Services/Utilities/NotificationChannels/RedisNotificationChannel.php#L44-L64), event `VN-admin-notifications:{user_id}`:

```json
{
  "message": "A new message has been sent in the technical support chat",
  "id": "9f8c...uuid",
  "type": "technical_support",
  "data": { },
  "read_at": null,
  "created_at": null,
  "notifiable": { "id": 3, "name": "Admin", "image": null }
}
```

⚠️ `message` is resolved with `auth('api')->user()->locale` — but this runs **on the queue worker where there is no authenticated user**, so it falls back to `app()->getLocale()`. `id`, `read_at` and `created_at` read off a notification object that has not been persisted yet at publish time and are usually `null`.

### D.6 Envelope — Node → Laravel (currently unused)

[`SocketHandler.forwardToLaravel()`](src/sockets/index.js#L49-L67) publishes a **different** shape:

```json
{ "event": "message", "data": { }, "socketId": "abc123", "timestamp": 1754230000000 }
```

Four keys (`data`, not `payload`; plus `socketId`/`timestamp`). No Laravel code subscribes to this channel — see [F.1](#f1-the-node--laravel-direction-is-a-dead-end).

---

## Part E — The Socket.IO layer

### E.1 Server bootstrap — [core/application.js](src/core/application.js)

```js
const sslOptions = {
    key:  fs.readFileSync(process.env.SSL_KEY_PATH),
    cert: fs.readFileSync(process.env.SSL_CERT_PATH),
    ca:   process.env.SSL_CA_PATH ? fs.readFileSync(process.env.SSL_CA_PATH) : undefined,
    minVersion: 'TLSv1.2'
};
this.httpServer = https.createServer(sslOptions, this.app);
this.io = require('socket.io')(this.httpServer, this.getSocketConfig());
```

🔴 **The server is HTTPS-only and terminates TLS itself.** `SSL_KEY_PATH` and `SSL_CERT_PATH` are read **in the constructor with `readFileSync`, before any try/catch** — if either is missing or unreadable by the Node user, the process throws and dies instantly at startup. Clients must connect with `wss://` / `https://`, never `ws://`.

Socket.IO config ([getSocketConfig()](src/core/application.js#L26-L37)):

```js
{
  cors: {
    methods: ['GET','PATCH','POST','PUT','OPTIONS'],
    origin: process.env.CLIENT_URL || '*',
    credentials: true
  },
  transports: ['websocket', 'polling'],
  pingInterval: 10000,   // server pings every 10s
  pingTimeout: 5000      // client must pong within 5s or is dropped
}
```

- ⚠️ `origin: '*'` **together with** `credentials: true` is rejected by browsers. Set `CLIENT_URL` explicitly for any browser client.
- `pingTimeout: 5000` is aggressive. Mobile clients on flaky networks will be disconnected often — your client **must** handle reconnect (Socket.IO does this automatically; your *app state* must too).

### E.2 Startup sequence

[`initialize()`](src/core/application.js#L39-L44) then [`start()`](src/core/application.js#L88-L92):

1. `setupRedis()` — creates `redisClient`, then `redisPublisher = redisClient.duplicate()`, connects both.
   *(Two connections are required: a Redis connection in subscriber mode cannot issue `PUBLISH`.)*
2. `setupSocketHandlers()` — `new SocketHandler(io, redisPublisher).initialize()`
3. `setupSubscribers()` — `new RedisSubscriber(redisClient, io).initialize()`
4. `setupHealthCheck()` — `GET /health`
5. `httpServer.listen(process.env.NODE_PORT)`

If Redis fails to connect, `initialize()` rethrows → [app.js](src/app.js#L8-L11) logs and `process.exit(1)`. **The service refuses to start without Redis** — that's intentional and good.

Note ⚠️: `redisClient` is used for **both** `SUBSCRIBE` (in the subscriber) and as the source of `duplicate()`. The `error` handler is attached only to `redisClient`, **not** to `redisPublisher` — an error on the publisher connection surfaces as an unhandled event.

### E.3 Health check

```http
GET https://<host>:<NODE_PORT>/health
→ 200 {"status":"ok","connections": 137}
```

`connections` is `io.engine.clientsCount`. Use it for load balancer probes and to confirm clients actually connected.

### E.4 Inbound socket events — [sockets/index.js](src/sockets/index.js)

| Event | Payload | Handler |
|---|---|---|
| `join_room` | `room` (string) | `socket.join(room)` + console log |
| `leave_room` | `room` (string) | `socket.leave(room)` + console log |
| `message` | any | `forwardToLaravel('message', data, socket.id)` |
| `custom_event` | any | `forwardToLaravel('custom_event', data, socket.id)` |
| `disconnect` | reason | logs |

**None of these affect chat delivery.** Rooms are joined but never emitted to. `message` is published to a channel nobody reads. `this.rooms = new Map()` at [sockets/index.js:9](src/sockets/index.js#L9) is never used.

**Do not send chat messages over the socket.** Use the HTTP endpoint.

### E.5 What is NOT here

- ❌ **No authentication or authorization.** No `io.use()` middleware, no JWT check, no handshake validation. Anyone who can reach the port can connect and listen.
- ❌ **No rooms/namespaces in the delivery path** — `io.emit()` only.
- ❌ **No Redis adapter** (`@socket.io/redis-adapter`) — so **you cannot run more than one Node instance** unless every instance subscribes to the same Redis channel (which they do here — meaning each instance independently emits to *its own* clients; this actually works for this fan-out design, but `join_room` state is per-instance and sticky sessions are required for `polling` transport).
- ❌ **No acks, no delivery guarantees, no replay.** A client offline when the publish happens **never receives that event** — it must re-fetch history over HTTP on reconnect.
- ❌ **No typing indicators, presence, or read receipts.**

### E.6 Dead code: `routes/channels.php`

[routes/channels.php](../routes/channels.php) defines Laravel Broadcasting channels named `ma7ta_mobile-*` — leftover from a different project. `BROADCAST_DRIVER` defaults to `null`, and **the Node service does not use Laravel Broadcasting at all**. Ignore this file; do not model new work on it.

---

## Part F — Known issues in the current implementation

Read this section before changing anything. These are real defects found in the source, ordered by impact.

### F.1 The Node → Laravel direction is a dead end

`SocketHandler.forwardToLaravel()` publishes to `{APP_NAME}_node_to_laravel`, but **no PHP code anywhere subscribes to it** (verified: only [NodeJs/src/config/index.js](src/config/index.js) and [RedisNotificationChannel.php](../app/Services/Utilities/NotificationChannels/RedisNotificationChannel.php) mention these channel names). Anything a client emits over the socket vanishes.

**Fix if you need it:** add an artisan long-running subscriber:
```php
// app/Console/Commands/SubscribeToNode.php
Redis::subscribe([config('app.name').'_node_to_laravel'], function ($message) {
    $data = json_decode($message, true);
    // dispatch a job based on $data['event']
});
```
and run it under PM2/Supervisor. **Never** trust `$data['socketId']` as an identity — the socket layer is unauthenticated.

### F.2 Every message is broadcast to every connected client

[subscribers/index.js:25](src/subscribers/index.js#L25) uses `this.io.emit(...)`, which sends to **all** sockets. Privacy is achieved only by the *event name* containing the recipient's user id — clients "don't listen" to other people's events. But **any client can trivially `socket.onAny()` and read every chat message and notification in the system.**

**Fix:** authenticate the socket, put each user in a room named by their id, and emit to that room:
```js
// sockets: after auth middleware sets socket.data.userId
socket.join(`user:${socket.data.userId}`);

// subscriber: derive the recipient from the event name suffix
const userId = parsed.event.split(':').pop();
this.io.to(`user:${userId}`).emit(parsed.event, parsed.payload);
```
This is backward-compatible with existing clients (same event name, same payload) — see [Part J.6](#j6-hardening-checklist).

### F.3 The socket server is completely unauthenticated

No handshake token check. Anyone who reaches `NODE_PORT` can connect. Combined with F.2, this is a full data-leak path. Fix together with F.2 via a JWT handshake — [Part J.6](#j6-hardening-checklist).

### F.4 Order-chat recipients are not de-duplicated and include the sender

[`sendOrderChatMessageNotifications()`](../app/Traits/SendOrderNotifications.php#L30-L42) lacks the `->unique()` and `->reject(sender)` that the tech-support helper has. An admin who sends an order-chat message notifies themselves; an admin who is also the receiver is notified twice.

**Fix:** mirror the tech-support version:
```php
$notifiable = collect(getNotifiableSystemAdmins())
    ->push($message->receiver())
    ->unique(fn($u) => $u->id)
    ->reject(fn($u) => $u->id === $message->sender_id)
    ->values();
```

### F.5 Realtime silently dies without a queue worker

`MessageCreatedListener implements ShouldQueue`. If `QUEUE_CONNECTION` is a real driver and no worker is running, messages persist and the API returns 200, but **nothing is published**. Run the worker ([queue.yml](../queue.yml) has a PM2 config: `php artisan queue:work --tries=5 --sleep=1`). Also remember to `php artisan queue:restart` after every deploy — workers cache code.

### F.6 `MessageDeletedListener` references a relation that no longer exists

[MessageDeletedListener:23](../app/Listeners/Chats/MessageDeletedListener.php#L23) calls `$event->message->chat->type`, but `Message::chat()` is **commented out** ([Message.php:39-44](../app/Models/Message.php#L39-L44)) since the move to `messageable`. It would throw. In practice `MessageDeleted` is never dispatched — [MessageObserver](../app/Observers/MessageObserver.php) only implements `created()`, not `deleted()`. So **message deletion produces no realtime event at all**: the other party keeps seeing a deleted message until they refetch.

**Fix:** add `deleted()` to the observer and change the listener to `$event->message->messageable->type`.

### F.7 Read receipts are not realtime

`ChatService::show()` bulk-marks messages read but publishes nothing. The sender's UI never learns their message was read without a manual refetch.

### F.8 Error swallowing

Both `MessageCreatedListener` and the notification helpers wrap everything in `try/catch` that only logs. A Redis outage or malformed payload is invisible to the client — the message appears sent. Check `storage/logs/` and `logs/` (Node's Winston output).

### F.9 Duplicate/missing infrastructure niceties

- No `package.json` in `NodeJs/` (deps come from the project root).
- `cors` and `helmet` are installed but **never used** in the Express app.
- `this.rooms = new Map()` is dead state.
- `console.log` is used alongside Winston in [sockets/index.js](src/sockets/index.js#L30) — those lines bypass the log files.
- Node's `SOCKET_PORT` in [config/index.js](src/config/index.js#L10) is **not** the port used — `start()` uses `process.env.NODE_PORT` directly.

---

## Part G — Frontend integration guide

### G.1 The mental model for a client developer

| Action | Transport |
|---|---|
| Load chat + history | **HTTP** `GET /api/orders/{order}/chat` |
| Send a message | **HTTP** `POST .../send-message` → render from the response |
| Delete a message | **HTTP** `DELETE .../delete-message/{message}` |
| **Receive someone else's message** | **Socket** `socket.on('VN-<type>:<scopeId>:<myUserId>')` |
| Admin notification bell | **Socket** `socket.on('VN-admin-notifications:<myUserId>')` |

You always need **both**. The socket alone can never load history, and HTTP alone can never push.

### G.2 Install

```bash
npm install socket.io-client
```
Client major version must match the server's Socket.IO major version (**v4** — see [package.json](../package.json)).

### G.3 Vanilla JS / TypeScript

```js
import { io } from 'socket.io-client';

// NOTE: https:// — the server terminates TLS itself. ws:// will NOT work.
const socket = io('https://api.example.com:3001', {
  transports: ['websocket', 'polling'],  // websocket first, polling fallback
  reconnection: true,
  reconnectionAttempts: Infinity,
  reconnectionDelay: 1000,
  reconnectionDelayMax: 5000,
  timeout: 20000,
  // withCredentials: true,   // only if CLIENT_URL is set to your exact origin
});

socket.on('connect',    () => console.log('connected', socket.id));
socket.on('disconnect', (reason) => console.log('disconnected:', reason));
socket.on('connect_error', (err) => console.error('connect_error:', err.message));
```

#### Build the event name

```js
// Order chat  → scope id is the ORDER id
const orderChatEvent = (orderId, myUserId) =>
  `VN-customer_technician:${orderId}:${myUserId}`;

// Technical support → scope id is the CHAT id
const supportChatEvent = (chatId, myUserId) =>
  `VN-technical_support:${chatId}:${myUserId}`;

// Admin bell
const adminBellEvent = (myUserId) => `VN-admin-notifications:${myUserId}`;
```

#### Subscribe / unsubscribe with the chat screen lifecycle

```js
function openOrderChat(orderId, myUserId, onMessage) {
  const event = orderChatEvent(orderId, myUserId);

  const handler = (payload) => {
    // payload = { id, message_type, message, created_at, read_at, is_deleted, sender:{id,full_name,image} }
    onMessage(normalize(payload));
  };

  socket.on(event, handler);
  return () => socket.off(event, handler);   // ← call this on screen close
}
```

🔴 **Always keep a reference to the handler and call `socket.off(event, handler)` when the screen unmounts.** Re-subscribing without unsubscribing is the classic "messages appear 2×, then 3×, then 4×" bug.

#### Normalize the two payload shapes

HTTP `MessageResource` and the socket payload differ (see [D.4](#d4-payload-shape--a-chat-message)). Funnel both through one function:

```js
function normalize(m) {
  return {
    id:        m.id,
    type:      m.message_type,
    body:      m.message,
    createdAt: m.created_at,                       // 'Y-m-d H:i:s', server TZ, no offset
    isRead:    m.read_at != null,                  // read_at is a HUMAN STRING, not a date
    isDeleted: !!m.is_deleted,
    sender: {
      id:     m.sender?.id,
      name:   m.sender?.full_name ?? m.sender?.name ?? 'Unknown',
      avatar: m.sender?.image || DEFAULT_AVATAR,   // may be a broken URL — always fall back
    },
  };
}
```

#### De-duplicate

The sender renders their own message from the HTTP response; admins may receive duplicate publishes ([F.4](#f4-order-chat-recipients-are-not-de-duplicated-and-include-the-sender)). Always dedupe by `id`:

```js
setMessages(prev => prev.some(x => x.id === msg.id) ? prev : [...prev, msg]);
```

#### Refetch on reconnect

Nothing is replayed after a disconnect ([E.5](#e5-what-is-not-here)). Anything published while you were offline is **gone forever**.

```js
socket.on('connect', () => {
  if (currentOrderId) refetchChatHistory(currentOrderId);   // close the gap
});
```

### G.4 React hook (drop-in)

```jsx
import { useEffect, useRef, useState, useCallback } from 'react';
import { io } from 'socket.io-client';

const SOCKET_URL = import.meta.env.VITE_SOCKET_URL;   // https://api.example.com:3001
const API_URL    = import.meta.env.VITE_API_URL;      // https://api.example.com/api

export function useOrderChat({ orderId, chatId, myUserId, token }) {
  const [messages, setMessages]   = useState([]);
  const [connected, setConnected] = useState(false);
  const socketRef = useRef(null);

  const fetchHistory = useCallback(async () => {
    const res  = await fetch(`${API_URL}/orders/${orderId}/chat`, {
      headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
    });
    const json = await res.json();
    // messages come back paginated + newest-first → reverse for chronological render
    const list = (json?.data?.messages?.data ?? []).map(normalize).reverse();
    setMessages(list);
  }, [orderId, token]);

  useEffect(() => { fetchHistory(); }, [fetchHistory]);

  useEffect(() => {
    const socket = io(SOCKET_URL, {
      transports: ['websocket', 'polling'],
      reconnection: true,
      reconnectionAttempts: Infinity,
    });
    socketRef.current = socket;

    const event = `VN-customer_technician:${orderId}:${myUserId}`;

    const onMessage = (payload) => {
      const msg = normalize(payload);
      setMessages(prev => prev.some(m => m.id === msg.id) ? prev : [...prev, msg]);
    };

    socket.on('connect',    () => { setConnected(true); fetchHistory(); });
    socket.on('disconnect', () => setConnected(false));
    socket.on(event, onMessage);

    return () => {
      socket.off(event, onMessage);
      socket.disconnect();
    };
  }, [orderId, myUserId, fetchHistory]);

  const send = useCallback(async (text, message_type = 'text') => {
    const res = await fetch(`${API_URL}/orders/${orderId}/chat/${chatId}/send-message`, {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${token}`,
        'Content-Type': 'application/json',
        Accept: 'application/json',
      },
      body: JSON.stringify({ message_type, message: text }),
    });
    const json = await res.json();
    const msg  = normalize(json.data);           // sender's own copy comes from HTTP
    setMessages(prev => prev.some(m => m.id === msg.id) ? prev : [...prev, msg]);
    return msg;
  }, [orderId, chatId, token]);

  return { messages, connected, send, refetch: fetchHistory };
}
```

### G.5 Flutter / Dart

```dart
import 'package:socket_io_client/socket_io_client.dart' as IO;

final socket = IO.io('https://api.example.com:3001',
  IO.OptionBuilder()
    .setTransports(['websocket'])
    .enableReconnection()
    .setReconnectionDelay(1000)
    .build(),
);

socket.onConnect((_) {
  debugPrint('connected');
  refetchHistory();                     // close the offline gap
});

final event = 'VN-customer_technician:$orderId:$myUserId';
socket.on(event, (data) {
  final msg = Message.fromJson(Map<String, dynamic>.from(data));
  if (!messages.any((m) => m.id == msg.id)) messages.add(msg);
});

// on dispose:
socket.off(event);
socket.dispose();
```

⚠️ If the server uses a self-signed certificate, Flutter/Android will refuse the TLS handshake. Use a real (e.g. Let's Encrypt) certificate.

### G.6 Sending images and video

`message_type` of `image`/`video` requires `message` to be an **existing attachment path**, enforced by the `AttachmentExists` rule ([SendMessageRequest](../app/Http/Requests/Api/Shared/Chat/SendMessageRequest.php#L20-L26)). So:

1. `POST` the file to the project's attachment/upload endpoint → returns a stored path.
2. `POST .../send-message` with `{ "message_type": "image", "message": "<that path>" }`.

Do **not** try to send base64 or a raw file to `send-message`.

---

## Part H — Environment, running, deploying

### H.1 Laravel `.env`

```dotenv
APP_NAME=Venus                 # 🔴 MUST match Node's APP_NAME byte-for-byte
APP_URL=https://api.example.com

QUEUE_CONNECTION=redis         # 🔴 must not be 'sync' in production; a worker MUST run
BROADCAST_DRIVER=null          # Laravel Broadcasting is NOT used by this design

REDIS_CLIENT=phpredis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
REDIS_DB=0

JWT_SECRET=<secret>
```

### H.2 Node `.env` (loaded by [src/server.js](src/server.js))

```dotenv
APP_NAME=Venus                 # 🔴 identical to Laravel's APP_NAME
NODE_PORT=3001                 # the port actually used by start()

REDIS_URL=redis://127.0.0.1:6379
# with a password: redis://:yourpassword@127.0.0.1:6379/0

CLIENT_URL=https://app.example.com   # 🔴 set this; '*' + credentials:true breaks browsers

SSL_KEY_PATH=/etc/letsencrypt/live/api.example.com/privkey.pem
SSL_CERT_PATH=/etc/letsencrypt/live/api.example.com/fullchain.pem
SSL_CA_PATH=                   # optional

LOG_LEVEL=info                 # 'debug' to see every publish/subscribe
```

Full variable reference:

| Variable | Used at | Required | Effect if wrong |
|---|---|---|---|
| `APP_NAME` | [config/index.js:5](src/config/index.js#L5) | ✅ | Channel mismatch → **total silence, no error** |
| `NODE_PORT` | [application.js:89](src/core/application.js#L89) | ✅ | `listen(undefined)` → random port |
| `REDIS_URL` | [config/index.js:3](src/config/index.js#L3) | ✅ | Startup fails, `exit(1)` |
| `SSL_KEY_PATH` | [application.js:15](src/core/application.js#L15) | ✅ | **Throws in constructor, process dies** |
| `SSL_CERT_PATH` | [application.js:16](src/core/application.js#L16) | ✅ | Same |
| `SSL_CA_PATH` | [application.js:17](src/core/application.js#L17) | ➖ | — |
| `CLIENT_URL` | [application.js:30](src/core/application.js#L30) | ⚠️ | Defaults `'*'`; browsers block with `credentials:true` |
| `LOG_LEVEL` | [logger.js:39](src/utils/logger.js#L39) | ➖ | Defaults `info` |
| `SOCKET_PORT` | [config/index.js:10](src/config/index.js#L10) | ❌ | **Read but never used** — ignore it |

### H.3 Running the Node service

Dependencies live in the **project root**, not in `NodeJs/`:

```bash
cd /path/to/venus
npm install                       # installs socket.io, redis, express, winston, ...
node NodeJs/src/server.js
```

Under PM2:

```bash
pm2 start NodeJs/src/server.js --name venus-socket --cwd /path/to/venus
pm2 save
```

Winston writes to `logs/error-YYYY-MM-DD.log` and `logs/combined-YYYY-MM-DD.log`, **relative to the working directory** — so always set `--cwd`.

### H.4 Running the queue worker (mandatory)

Without this, realtime is dead ([F.5](#f5-realtime-silently-dies-without-a-queue-worker)). [queue.yml](../queue.yml) is ready for PM2:

```bash
pm2 start queue.yml
# equivalent to: php artisan queue:work --tries=5 --sleep=1
```

After every deploy: `php artisan queue:restart`.

### H.5 Full startup checklist

```bash
redis-cli ping                             # → PONG
php artisan migrate                        # chats, messages, notifications tables
pm2 start queue.yml                        # queue worker
pm2 start NodeJs/src/server.js --name venus-socket --cwd $(pwd)
curl -k https://localhost:3001/health      # → {"status":"ok","connections":0}
```

### H.6 Nginx in front (recommended)

Terminating TLS at Nginx and proxying to a plain-HTTP Node process is simpler than Node reading certs. That requires changing `https.createServer` → `http.createServer` in [application.js:21](src/core/application.js#L21). If you keep Node's own TLS, proxy with:

```nginx
location /socket.io/ {
    proxy_pass         https://127.0.0.1:3001;
    proxy_http_version 1.1;
    proxy_set_header   Upgrade $http_upgrade;      # required for WebSocket
    proxy_set_header   Connection "upgrade";       # required for WebSocket
    proxy_set_header   Host $host;
    proxy_set_header   X-Real-IP $remote_addr;
    proxy_read_timeout 86400;                      # don't kill idle sockets
}
```

The `Upgrade`/`Connection` headers are **mandatory** — without them the WebSocket transport fails and clients silently fall back to slow long-polling.

---

## Part I — Debugging cookbook

Work through these **in order**. Each step isolates one hop in the chain.

### I.1 Is Laravel actually publishing?

Watch every Redis channel while you send a message:

```bash
redis-cli PSUBSCRIBE '*'
```

Then `POST .../send-message`. You should see something like:

```
pmessage | * | Venus_laravel_to_node | {"event":"VN-customer_technician:812:45","payload":{...}}
```

- **Nothing appears** → the problem is upstream: queue worker not running ([F.5](#f5-realtime-silently-dies-without-a-queue-worker)), listener threw (check `storage/logs/laravel.log`), or the recipient list was empty.
- **It appears but the channel name is not what you expected** → that is the [Redis prefix caveat](#d1-channel-names). Make Node's channel match what you actually see.

### I.2 Is Node subscribed to the same channel?

```bash
LOG_LEVEL=debug node NodeJs/src/server.js
```

At boot you should see `Redis subscribed to Venus_laravel_to_node` ([logger.redis.subscribe](src/utils/logger.js#L99-L101)). **Compare that string character-by-character** with the channel from step I.1.

### I.3 Publish a fake message by hand

Bypass Laravel entirely:

```bash
redis-cli PUBLISH Venus_laravel_to_node \
  '{"event":"VN-customer_technician:812:45","payload":{"id":1,"message":"test","message_type":"text","created_at":"2026-08-03 12:00:00","read_at":null,"is_deleted":false,"sender":{"id":12,"full_name":"Tester","image":null}}}'
```

If your client receives this, the Node↔client hop is fine and the bug is in Laravel. If not, the bug is in Node or the client.

### I.4 Listen to *everything* from a browser console

Point a page at your socket URL and run:

```js
const s = io('https://api.example.com:3001', { transports: ['websocket'] });
s.onAny((event, ...args) => console.log('EVENT:', event, args));
```

`onAny` shows the exact event names arriving. Compare them to the name your app subscribes to — a mismatch here (wrong `notify_id`, wrong user id) is the most common client-side bug. *(That this works at all is [F.2](#f2-every-message-is-broadcast-to-every-connected-client).)*

### I.5 Check connection count

```bash
curl -k https://localhost:3001/health
```

`connections: 0` while your app claims to be connected means the socket never actually established — check TLS (self-signed cert?), CORS (`CLIENT_URL`), the port, and firewall rules.

### I.6 Symptom → cause table

| Symptom | Most likely cause |
|---|---|
| Message saves (200 OK) but nothing realtime | Queue worker not running — [F.5](#f5-realtime-silently-dies-without-a-queue-worker) |
| Nothing on `PSUBSCRIBE '*'` | Listener threw and was swallowed — check `storage/logs/laravel.log` |
| Redis shows the publish, client gets nothing | `APP_NAME` mismatch — [D.1](#d1-channel-names) |
| Node connects, client never connects | TLS (`https://` not `ws://`), self-signed cert, or CORS `CLIENT_URL` |
| Node dies instantly at boot, no log | `SSL_KEY_PATH`/`SSL_CERT_PATH` missing or unreadable — [E.1](#e1-server-bootstrap--coreapplicationjs) |
| Node exits with code 1 after a moment | Redis unreachable — check `REDIS_URL`/password |
| Tech-support chat works, order chat doesn't | Using chat id where the event needs the **order** id — [D.3](#d3-event-name-grammar) |
| Messages appear 2×, 3×, 4× | Handler re-registered without `socket.off()` — [G.3](#g3-vanilla-js--typescript) |
| Works on desktop, flaky on mobile | `pingTimeout: 5000` + no refetch-on-reconnect — [G.3](#g3-vanilla-js--typescript) |
| Browser CORS error despite `origin:'*'` | `'*'` + `credentials:true` is illegal — set `CLIENT_URL` |
| Deleted message stays visible for the other party | No deletion event is emitted — [F.6](#f6-messagedeletedlistener-references-a-relation-that-no-longer-exists) |
| Everything breaks after deploy | `php artisan queue:restart` not run — workers cache old code |

---

## Part J — Rebuild blueprint (give this to an agent)

> **Prompt to hand an agent:**
> *"Implement realtime chat in this project following `CHAT_ARCHITECTURE.md` Part J exactly. Build every numbered deliverable in J.2–J.5, then verify against the acceptance tests in J.7. Apply the hardening in J.6. Do not use Laravel Broadcasting, Pusher, or Laravel Echo — this is a hand-rolled Redis Pub/Sub → Socket.IO bridge."*

### J.1 The invariants — get these right or nothing works

1. **Messages travel over HTTP, not over the socket.** The socket is a one-way server→client push channel.
2. **The sender renders their own message from the HTTP response**, never from the socket.
3. **Redis Pub/Sub is the only bridge** between PHP and Node.
4. **The Redis channel name must be byte-identical on both sides.** Derive it from one shared env var.
5. **The envelope is exactly `{"event": "...", "payload": {...}}`.** Node does `io.emit(parsed.event, parsed.payload)` and nothing else.
6. **The event name encodes the recipient**, so there is one publish per recipient.
7. **Two Redis connections in Node** — a subscriber cannot publish.
8. **A queue worker must run** if the publishing listener is queued.
9. **Pub/Sub is fire-and-forget.** Offline clients miss events; clients must refetch history on reconnect.

### J.2 Deliverable 1 — Database

```php
// chats
$table->id();
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete();      // creator
$table->foreignId('receiver_id')->constrained('users')->cascadeOnDelete();  // other party
$table->nullableMorphs('chatable');            // Order, Ticket, ... or NULL
$table->string('type')->default('technical_support');
$table->string('status')->default('pending');
$table->timestamps();
$table->softDeletes();

// messages
$table->id();
$table->foreignId('sender_id')->constrained('users')->cascadeOnDelete();
$table->morphs('messageable');                 // Chat / CommunityChat
$table->text('message');
$table->string('message_type');                // text | image | video
$table->timestamp('read_at')->nullable();
$table->timestamps();
$table->timestamp('deleted_by_sender_at')->nullable();
$table->timestamp('deleted_by_receiver_at')->nullable();
$table->index(['messageable_type', 'messageable_id', 'created_at']);   // ← add this
```

Also run `php artisan notifications:table && php artisan migrate` — the `database` notification channel is always on.

### J.3 Deliverable 2 — Laravel publish pipeline

**a) Observer** (register in `EventServiceProvider::boot()`, not in `Model::boot()`):
```php
public function created(Message $message): void { event(new MessageCreated($message)); }
public function deleted(Message $message): void { event(new MessageDeleted($message)); }
```

**b) Listener** — `implements ShouldQueue`, routes by `messageable->type`, builds the recipient list with **`unique()` + `reject(sender)`**, calls `$user->notify(...)`.

**c) Payload transformer** — one function producing the canonical message shape ([D.4](#d4-payload-shape--a-chat-message)). **Reuse the same resource class as the HTTP endpoint** so both transports emit identical JSON — this removes the normalization tax described in [G.3](#g3-vanilla-js--typescript).

**d) The Redis channel** — the critical piece:

```php
namespace App\Services\Utilities\NotificationChannels;

class RedisNotificationChannel
{
    public function send($user, $notification): void
    {
        $data = $notification->data ?? [];
        if (empty($data)) return;

        $envelope = [
            'event'   => sprintf('VN-%s:%s:%s',
                $data['notification_type'], $data['notify_id'], $user->id),
            'payload' => $data['meta']['message'] ?? $data,
        ];

        app('redis')->publish(
            config('app.name') . '_laravel_to_node',
            json_encode($envelope, JSON_UNESCAPED_UNICODE)   // ← keeps Arabic/emoji readable
        );
    }
}
```

**e) Channel resolver** — `['database', RedisNotificationChannel::class]` plus FCM when the user allows push.

### J.4 Deliverable 3 — HTTP endpoints

| Method | Path | Purpose |
|---|---|---|
| `GET` | `/api/orders/{order}/chat` | `firstOrCreate` the chat, return details + paginated history, mark read |
| `POST` | `/api/orders/{order}/chat/{chat}/send-message` | Validate, persist, return the message |
| `DELETE` | `/api/orders/{order}/chat/{chat}/delete-message/{message}` | Sender-only delete |

Guard all three with a middleware that verifies the caller is a party to the chat (403 otherwise) — model it on [OrderChatMiddleware](../app/Http/Middleware/OrderChatMiddleware.php).

Validation:
```php
'message_type' => ['required', 'string', Rule::in(['text','image','video'])],
'message'      => ['required', 'string'],   // an attachment path when type is image/video
```

### J.5 Deliverable 4 — The Node service

**Structure** (mirror this repo, but add a real `package.json` inside the service directory):

```
socket-service/
├── package.json          ← add this; do NOT rely on a parent node_modules
├── .env
└── src/
    ├── server.js         require('dotenv').config(); require('./app');
    ├── app.js            new Application().initialize().then(start).catch(exit(1))
    ├── config/index.js   redis url + channel names from APP_NAME
    ├── core/application.js
    ├── sockets/index.js
    ├── subscribers/index.js
    └── utils/logger.js
```

`package.json`:
```json
{
  "name": "venus-socket-service",
  "version": "1.0.0",
  "main": "src/server.js",
  "scripts": { "start": "node src/server.js", "dev": "LOG_LEVEL=debug node src/server.js" },
  "dependencies": {
    "@socket.io/redis-adapter": "^8.3.0",
    "dotenv": "^16.4.7",
    "express": "^4.21.2",
    "jsonwebtoken": "^9.0.2",
    "redis": "^4.7.0",
    "socket.io": "^4.8.1",
    "winston": "^3.17.0",
    "winston-daily-rotate-file": "^5.0.0"
  }
}
```

**`src/config/index.js`**
```js
module.exports = {
    redis: {
        url: process.env.REDIS_URL || 'redis://localhost:6379',
        channels: {
            laravelToNode: `${process.env.APP_NAME}_laravel_to_node`,
            nodeToLaravel: `${process.env.APP_NAME}_node_to_laravel`,
        },
    },
    port: process.env.NODE_PORT || 3001,
    clientUrl: process.env.CLIENT_URL || '*',
    jwtSecret: process.env.JWT_SECRET,
};
```

**`src/core/application.js`** — key points: two Redis connections, error handlers on **both**, auth middleware before handlers.

```js
async setupRedis() {
    this.redisClient    = redis.createClient({ url: config.redis.url });
    this.redisPublisher = this.redisClient.duplicate();     // subscriber can't publish

    this.redisClient.on('error',    (e) => logger.redis.error(e));
    this.redisPublisher.on('error', (e) => logger.redis.error(e));   // ← don't forget this one

    await Promise.all([this.redisClient.connect(), this.redisPublisher.connect()]);
    logger.info('Redis connected');
}
```

**`src/subscribers/index.js`** — the delivery path, room-scoped:

```js
class RedisSubscriber {
    constructor(redisClient, io) {
        this.redis = redisClient;
        this.io = io;
        this.channel = config.redis.channels.laravelToNode;
    }

    initialize() {
        this.redis.subscribe(this.channel, (message) => this.handle(message));
        logger.redis.subscribe(this.channel);
    }

    handle(raw) {
        try {
            const { event, payload } = JSON.parse(raw);
            if (!event) return logger.warn('Envelope missing "event"', { raw });

            // Event grammar: VN-<type>:<scopeId>:<recipientUserId>
            const userId = String(event).split(':').pop();

            if (/^\d+$/.test(userId)) {
                this.io.to(`user:${userId}`).emit(event, payload);   // ← targeted, not broadcast
            } else {
                this.io.emit(event, payload);                       // fallback for global events
            }
            logger.debug('Emitted', { event, userId });
        } catch (err) {
            logger.error('Bad Redis message', { error: err.stack, raw });
        }
    }
}
```

**`src/sockets/index.js`** — auth middleware + per-user room:

```js
const jwt = require('jsonwebtoken');

initialize() {
    // 1. Authenticate every handshake
    this.io.use((socket, next) => {
        const token = socket.handshake.auth?.token
                   || socket.handshake.headers?.authorization?.replace('Bearer ', '');
        if (!token) return next(new Error('unauthorized'));
        try {
            const decoded = jwt.verify(token, config.jwtSecret);
            socket.data.userId = decoded.sub;      // Laravel JWT puts the user id in `sub`
            next();
        } catch {
            next(new Error('unauthorized'));
        }
    });

    // 2. Put each socket in its own user room
    this.io.on('connection', (socket) => {
        socket.join(`user:${socket.data.userId}`);
        logger.socket.connection(socket.id, socket.handshake.address);
        socket.on('disconnect', (r) => logger.socket.disconnection(socket.id, r));
    });
}
```

### J.6 Hardening checklist

Apply all of these — they close the defects listed in [Part F](#part-f--known-issues-in-the-current-implementation) while keeping the wire format 100% backward-compatible:

- [ ] **JWT handshake auth** (`io.use`) — closes [F.3](#f3-the-socket-server-is-completely-unauthenticated)
- [ ] **Per-user rooms + `io.to(...)`** instead of `io.emit` — closes [F.2](#f2-every-message-is-broadcast-to-every-connected-client). Event name and payload are unchanged, so existing clients keep working.
- [ ] **`unique()` + `reject(sender)`** on the order-chat recipient list — closes [F.4](#f4-order-chat-recipients-are-not-de-duplicated-and-include-the-sender)
- [ ] **`deleted()` observer hook** + fix the listener to use `messageable` — closes [F.6](#f6-messagedeletedlistener-references-a-relation-that-no-longer-exists)
- [ ] **Publish a `read` event** when `ChatService::show()` marks messages read — closes [F.7](#f7-read-receipts-are-not-realtime)
- [ ] **Error handler on the publisher connection too** — [E.2](#e2-startup-sequence)
- [ ] **`@socket.io/redis-adapter`** if you run more than one Node instance and use rooms
- [ ] **`JSON_UNESCAPED_UNICODE`** on `json_encode` — keeps Arabic/emoji readable in logs
- [ ] **Set `CLIENT_URL`** to a real origin — never `'*'` with `credentials: true`
- [ ] **Index `messages(messageable_type, messageable_id, created_at)`** — history queries scan otherwise
- [ ] **Raise `pingTimeout`** to ~20000 for mobile clients
- [ ] **Remove unused deps/dead state** (`cors`, `helmet`, `this.rooms`, `SOCKET_PORT`) or actually wire them up
- [ ] **Replace `console.log` with the Winston logger** in `sockets/index.js` so nothing bypasses the log files

### J.7 Acceptance tests — "works 100%" means all of these pass

Run in order. Each isolates one hop.

| # | Test | Expected |
|---|---|---|
| 1 | `redis-cli ping` | `PONG` |
| 2 | Start Node | Logs `Redis connected` + `Redis subscribed to <APP_NAME>_laravel_to_node` |
| 3 | `curl -k https://localhost:3001/health` | `{"status":"ok","connections":0}` |
| 4 | Connect a client with a valid JWT | `/health` shows `connections: 1` |
| 5 | Connect **without** a token | Rejected with `connect_error: unauthorized` |
| 6 | `redis-cli PSUBSCRIBE '*'`, then POST a message | Envelope appears on `<APP_NAME>_laravel_to_node` with `event` + `payload` |
| 7 | Same POST, receiver's client open | Receiver's `socket.on('VN-...:<theirId>')` fires within ~1s |
| 8 | Same POST, **sender's** client open | Sender does **not** receive a socket event; their message came from the HTTP 200 |
| 9 | A **third** user's client open with `onAny()` | Sees **nothing** (proves rooms work — this fails on the unhardened version) |
| 10 | Kill the queue worker, POST a message | Message saves; **no** publish. Restart worker → the backlog drains and events fire |
| 11 | Disconnect the receiver, POST, reconnect | Missed message appears via refetch-on-reconnect, **not** via replay |
| 12 | POST 3 messages rapidly | Exactly 3 events; **no duplicates** (verifies `unique()` + client dedupe) |
| 13 | Send an Arabic message + emoji | Renders correctly end to end (`JSON_UNESCAPED_UNICODE` + UTF-8 DB collation) |
| 14 | Non-party user hits `GET .../chat` | **403** |
| 15 | Delete a message | Other party's UI updates in realtime (requires the [F.6](#f6-messagedeletedlistener-references-a-relation-that-no-longer-exists) fix) |
| 16 | Restart Node while clients are connected | Clients auto-reconnect and refetch; no duplicated handlers |
| 17 | Send an `image` message with a bogus path | **422** from `AttachmentExists` |
| 18 | Two Node instances behind a load balancer | A message reaches the receiver regardless of which instance they're on |

### J.8 Migration path if you keep the current codebase

You do **not** need to rewrite. In priority order:

1. **Add JWT auth + per-user rooms** (J.6, first two boxes). ~30 lines in Node. Fixes the two security defects. **Zero client changes required.**
2. **Fix the order-chat recipient list** (3 lines in `SendOrderNotifications`).
3. **Add a `package.json` to `NodeJs/`** so the service is self-contained.
4. **Add the `deleted()` observer hook** for realtime deletions.
5. **Add read-receipt publishing** in `ChatService::show()`.
6. **Add the composite index** on `messages`.

Steps 1–2 are the high-value ones; do them first.

---

## Quick reference card

```
CHANNEL   {APP_NAME}_laravel_to_node        # Laravel → Node (all chat traffic)
ENVELOPE  { "event": "...", "payload": {...} }
DELIVERY  io.emit(parsed.event, parsed.payload)

EVENT NAME GRAMMAR
  VN-<notification_type>:<notify_id>:<recipient_user_id>

  order chat      VN-customer_technician:<ORDER id>:<user id>
  tech support    VN-technical_support:<CHAT id>:<user id>
  community       VN-community:<community chat id>:<user id>
  admin bell      VN-admin-notifications:<user id>

PAYLOAD (chat)
  { id, message_type, message, created_at, read_at, is_deleted,
    sender: { id, full_name, image } }

SEND     POST   /api/orders/{order}/chat/{chat}/send-message   { message_type, message }
LOAD     GET    /api/orders/{order}/chat
DELETE   DELETE /api/orders/{order}/chat/{chat}/delete-message/{message}
RECEIVE  socket.on('VN-customer_technician:<orderId>:<myUserId>', cb)

MUST BE RUNNING   redis-server · php artisan queue:work · node NodeJs/src/server.js
MUST MATCH        APP_NAME in both .env files (byte-for-byte)
MUST USE          https:// / wss://  (the Node server terminates TLS itself)
```
