# Admin Conversations: React Dashboard Integration Guide

How to wire the React admin dashboard to the support-conversation oversight
endpoints at `routes/api/v1/admin.php` (`admin.conversations.*`,
[ConversationController](../../app/Http/Controllers/Api/V1/Admin/ConversationController.php)).
This covers the REST surface (list/show/reply/close/delete) and the
Socket.IO realtime channel that delivers new customer messages without
polling.

For the polymorphic order-linking behind the `entity` field, see
[support-chat-order-linking.md](support-chat-order-linking.md). For JWT
mechanics (login, token shape, TTL), see [AUTH.md](../api/AUTH.md). For a
prior, Flutter-side implementation of the exact same presence/realtime
protocol used below, see [FLUTTER_CHAT_PRESENCE.md](../FLUTTER_CHAT_PRESENCE.md)
— this guide adapts it to `socket.io-client`.

> **Source of truth note:** [docs/api/05-admin.md](../api/05-admin.md) §12 has
> live-captured examples for these endpoints, but they were captured
> 2026-08-20 — three days *before* the `entity_type`/`entity_id` columns were
> added (2026-08-23 migration). Their captured JSON does not show the
> `entity` field. This guide reflects the current
> [ConversationResource](../../app/Http/Resources/Admin/Conversation/ConversationResource.php)
> source, which does include it. Re-verify against a live response before
> shipping if that matters for your table columns.

---

## 1. Auth & permissions

All routes sit behind `auth:api` + `user:admin,super_admin` + `active` +
`rbac.auto` (`routes/api/v1/admin.php:64`). Send the JWT from login as a
bearer token on every request:

```
Authorization: Bearer <token>
Accept: application/json
Accept-Language: en   // or "ar" — drives __() strings in `message`/`status.label`
```

`rbac.auto` derives the required permission from the route name
(`admin.conversations.X` → `conversations.<action>`) — see
[RbacAutoCheck](../../app/Http/Middleware/RbacAutoCheck.php) /
[PermissionResolver](../../app/Infrastructure/Auth/PermissionResolver.php).
Gate dashboard UI (nav item, action buttons) on these, sourced from
`permissions` on the login response (see AUTH.md's `LoginResource` shape):

| Action | Route name | Required permission |
|---|---|---|
| List | `admin.conversations.index` | `conversations.index` |
| Show | `admin.conversations.show` | `conversations.view` |
| Reply | `admin.conversations.reply` | `conversations.reply` |
| Close | `admin.conversations.close` | `conversations.close` |
| Delete | `admin.conversations.destroy` | `conversations.delete` |

A 403 from any of these comes back with an `X-Missing-Permission` response
header naming the slug — useful for a generic "you lack permission: X" toast
instead of hardcoding messages per screen.

## 2. Response envelope

Every response (success or error) is the same shape — never destructure
`data` blindly without checking `status` first:

```ts
interface ApiEnvelope<T> {
  status: 'success' | 'fail' | 'error';
  message: string;
  data: T | null;
}
```

`fail` = client error (validation, 404, 403, 401). `error` = server-side
(500, DB failure). Both are non-2xx HTTP statuses, so an axios/fetch
`try/catch` on HTTP status is sufficient — you don't need to branch on
`status` separately in normal flow, only for parsing the error banner text.

**Validation errors carry no field-level map.** `ValidationException` is
rendered as `{ status: 'fail', message: e.getMessage(), data: null }`
(`bootstrap/app.php`) — there is no `errors: { field: [...] }` object. The
`message` is a human sentence built from the form request's `attributes()`
label (e.g. reply's `body` field is labelled "Reply", so a missing body
returns `"The Reply field is required."`). If you need per-field inline
errors in the compose box, you must derive them client-side (e.g. checking
length before submit) — the API won't hand you a structured error to map
onto a specific input.

## 3. List conversations — `GET /admin/conversations`

```
GET /api/v1/admin/conversations?page=1&per_page=15&search=&sort[created_at]=desc&filters[status]=open
```

Backed by `BaseCRUDService::index()` — always paginated unless
`paginate=false` is sent explicitly (then capped at 500 rows, no
`links`/`meta`).

**Query params:**

| Param | Notes |
|---|---|
| `page` | 1-based |
| `per_page` / `limit` | either name works, capped at 100 (`BaseCRUDService::$maxPerPage`) |
| `search` | case-insensitive `LIKE` across `Conversation::$searchableFields` = `subject`, `entity_type`, `entity_id`, `status` (matches literal enum value/FQCN, not labels) |
| `sort[<field>]` | `asc`/`desc`, restricted to the same `$searchableFields` list |
| `filters[status]` | `open` or `closed` (raw enum value, not the label — see [ConversationStatus](../../app/Enums/ConversationStatus.php)) |
| `filters[entity_type]` | `order` (alias, resolved server-side to the FQCN via `Conversation::ENTITY_MAP` — [scopeEntityType](../../app/Models/Conversation.php)) |
| `filters[entity_id]` | numeric order id, used with `entity_type` to find every conversation about one order |
| `paginate=false` | opt out of pagination, `?paginate=0` also works |

> ⚠️ `docs/api/05-admin.md` documents `filters[is_active]` for this
> endpoint — that's stale, copy-pasted from another entity's doc block.
> `Conversation` has no `is_active` column; use `filters[status]` instead.
> `ApplyFilters` silently no-ops on any field not in `$searchableFields`, so
> sending `is_active` won't error, it just won't filter anything.

**Response** (paginated; wrapped under the table name):

```json
{
  "status": "success",
  "message": "Data retrieved successfully",
  "data": {
    "conversations": [
      {
        "id": 1,
        "customer": { "id": 3, "name": "Davin Gibson", "email": "rbednar@example.org" },
        "subject": "Need help with my order",
        "entity": { "type": "order", "id": 42 },
        "status": "Open",
        "last_message": "Hello, when will my technician arrive?",
        "created_at": "2026-08-20 14:19:16",
        "closed_at": null
      }
    ],
    "links": { "first": "...", "last": "...", "prev": null, "next": null },
    "meta": {
      "current_page": 1, "from": 1, "last_page": 1, "per_page": 15,
      "to": 1, "total": 1, "path": "...", "links": [ /* Laravel pagination links */ ]
    }
  }
}
```

Notes on the row shape:
- `status` is the **label** (`"Open"`/`"Closed"`, locale-aware via
  `ConversationStatus::label()`), not the raw enum value — don't use it for
  conditional logic, use it only for display. If you need the raw value for
  UI state (e.g. picking a badge color), derive it from whether `closed_at`
  is null instead of string-matching the label.
- `entity` is `null` for general support conversations, or
  `{ "type": "order", "id": <int> }` when linked to an order — `type` is
  always the alias, never the raw `App\Models\Order` class string.
- `last_message` is `null` for a conversation whose opening message hasn't
  loaded (shouldn't normally happen — every conversation is created with an
  initial message — but code defensively for it).

```ts
interface ConversationListItem {
  id: number;
  customer: { id: number; name: string; email: string } | null;
  subject: string | null;
  entity: { type: 'order'; id: number } | null;
  status: string;       // label, display-only
  last_message: string | null;
  created_at: string;   // "YYYY-MM-DD HH:mm:ss", server (app) timezone
  closed_at: string | null;
}
```

## 4. Show conversation — `GET /admin/conversations/:id`

Eager-loads the full message thread (`messages.sender`) plus `customer`.
404s via `ModelNotFoundException` → `{ status: 'fail', message: 'Resource not found' }`
if the id doesn't exist — there's no ownership scoping on the admin side
(any admin can open any conversation).

```json
{
  "status": "success",
  "message": "Data retrieved successfully",
  "data": {
    "id": 1,
    "customer": { "id": 3, "name": "Davin Gibson", "email": "rbednar@example.org" },
    "subject": "Need help with my order",
    "entity": { "type": "order", "id": 42 },
    "status": "Open",
    "created_at": "2026-08-20 14:19:16",
    "closed_at": null,
    "messages": [
      {
        "id": 1,
        "sender": { "id": 3, "name": "Davin Gibson" },
        "sender_type": "customer",
        "body": "Hello, when will my technician arrive?",
        "sent_at": "2026-08-20 14:19:16"
      }
    ]
  }
}
```

`sender_type` is `"customer"` or `"staff"` — use it to align message
bubbles left/right, not `sender.id` (an admin viewing another admin's
reply still has `sender_type: "staff"`).

```ts
interface ConversationMessage {
  id: number;
  sender: { id: number; name: string } | null;
  sender_type: 'customer' | 'staff';
  body: string;
  sent_at: string;
}

interface ConversationDetails extends Omit<ConversationListItem, 'last_message'> {
  messages: ConversationMessage[];
}
```

## 5. Reply — `POST /admin/conversations/:id/reply`

```json
// request
{ "body": "A technician will arrive between 2-4 PM today." }
```

Validation: `required|string|max:2000`. Returns **201**, payload is the
created `Message` (not the whole conversation — merge it into local thread
state yourself rather than refetching `show`):

```json
{
  "status": "success",
  "message": "Reply sent successfully",
  "data": {
    "id": 2,
    "sender": { "id": 1, "name": "Stewart Cummings" },
    "sender_type": "staff",
    "body": "A technician will arrive between 2-4 PM today.",
    "sent_at": "2026-08-22 19:34:51"
  }
}
```

This call also fires a realtime push to the customer
(`CustomerMessageNotification` → Redis → Socket.IO) — you don't need to do
anything extra for the customer's app to see it live; it's a side effect of
this endpoint, not something the dashboard triggers separately.

## 6. Close — `POST /admin/conversations/:id/close`

No request body required. Idempotent — closing an already-closed
conversation returns 200 with the same result, not an error, so you don't
need to guard the button on current status. Returns the updated
conversation (list-row shape, not the full thread):

```json
{
  "status": "success",
  "message": "Conversation closed successfully",
  "data": {
    "id": 1,
    "customer": { "id": 3, "name": "Davin Gibson", "email": "rbednar@example.org" },
    "subject": "Need help with my order",
    "entity": { "type": "order", "id": 42 },
    "status": "Closed",
    "created_at": "2026-08-20 14:19:16",
    "closed_at": "2026-08-20 14:19:16"
  }
}
```

There is no "reopen" endpoint — closing is one-directional through this
API surface.

## 7. Delete — `DELETE /admin/conversations/:id`

Hard delete, runs through the model's delete pipeline
(`BaseCRUDService::destroy()`). `{ status: 'success', message: 'Data removed successfully', data: null }`.
No confirmation/undo at the API layer — gate this behind a confirm dialog in
the UI, since the message thread goes with it.

## 8. Error responses (all endpoints)

| Status | When | Body |
|---|---|---|
| 401 | missing/expired/blacklisted JWT | `{status:'fail', message:'Unauthenticated, you have to login first'}` |
| 403 | valid token, wrong role or missing RBAC permission | `{status:'fail', message:'You do not have permission: <slug>'}`, header `X-Missing-Permission` |
| 404 | conversation id doesn't exist | `{status:'fail', message:'Resource not found'}` |
| 422 | reply `body` missing/too long | `{status:'fail', message:'The Reply field is required.'}` (or similar — see §2 on why there's no field map) |
| 429 | rate-limited | `{status:'fail', message:'Too many requests, please try again after a while!'}` |

## 9. Realtime: new customer messages via Socket.IO

REST is authoritative for history; Socket.IO delivers live push so the
inbox doesn't need polling. This is the **same sibling service and wire
protocol** the mobile apps use — see
[FLUTTER_CHAT_PRESENCE.md](../FLUTTER_CHAT_PRESENCE.md) for the
Dart version this section mirrors. Get the actual `socketUrl` from
whoever deploys `src/subscribers/index.js` (not in this repo) — same
question as for that Flutter doc.

### Connect once per signed-in admin session

```ts
import { io, Socket } from 'socket.io-client';

function createSocket(socketUrl: string, jwt: string): Socket {
  return io(socketUrl, {
    transports: ['websocket'],
    autoConnect: false,
    auth: { token: jwt },
    reconnection: true,
  });
}
```

The server reads the admin's id from the verified JWT and joins them to a
`user:{id}` room automatically — never emit a `join_room` event or send a
`user_id` yourself; the server rejects attempts to join another user's room.
Keep one shared socket for the whole dashboard session, not one per
conversation screen.

### Listening for new messages

Event names encode the notification type, conversation id, and the
**admin's own** user id — subscribe per open conversation, not globally,
and remove the listener on unmount:

```ts
function conversationEventName(conversationId: number, adminUserId: number) {
  return `VN-support_message:${conversationId}:${adminUserId}`;
}

useEffect(() => {
  const event = conversationEventName(conversationId, currentAdminId);
  const handler = (payload: SocketMessagePayload) => {
    // payload.message: { id, message_type, message, created_at, read_at, is_deleted, sender: {id, full_name, image} }
    appendMessageDeduped(conversationId, {
      id: payload.message.id,
      body: payload.message.message,      // note: field is "message", not "body", over the socket
      sender_type: 'customer',
      sent_at: payload.message.created_at,
      sender: { id: payload.message.sender.id, name: payload.message.sender.full_name },
    });
  };

  socket.on(event, handler);
  return () => socket.off(event, handler);
}, [conversationId, currentAdminId]);
```

Dedupe by `message.id` — a REST refetch (e.g. after reconnect) and the
socket push can both deliver the same message. The socket payload's field
names differ slightly from the REST `MessageResource` shape (`message` vs
`body`, `full_name` vs `name` on sender) because it's built from
[CustomerMessageNotification](../../app/Notifications/General/SupportMessageNotification.php)'s
`toDatabase()`/`toArray()`, not the REST resource — map it explicitly rather
than assuming the two are interchangeable.

An admin only receives `support_message` while *not* actively viewing that
exact conversation (see next section) — while inside it, still subscribe to
this event as the live-append mechanism, since presence there only
suppresses the redundant `database`/FCM notification rows, not the
Socket.IO push itself.

### Presence (suppresses duplicate notification badges)

Call `conversation:enter` when an admin opens a conversation's detail pane
and `conversation:leave` when they close it, with a heartbeat while it's
open (presence expires server-side after 60s of silence). This prevents the
notification bell/inbox badge from also firing for messages the admin is
already looking at in realtime.

```ts
class ChatPresence {
  private heartbeat: ReturnType<typeof setInterval> | null = null;

  constructor(private socket: Socket, private conversationId: number) {}

  enter() {
    this.socket.emit(
      'conversation:enter',
      { conversation_id: this.conversationId },
      (ack: { ok: boolean; error?: string }) => {
        if (!ack?.ok) console.warn('Chat presence rejected:', ack?.error);
      },
    );
    this.heartbeat = setInterval(() => {
      this.socket.emit('conversation:heartbeat', { conversation_id: this.conversationId });
    }, 20_000);
  }

  leave() {
    if (this.heartbeat) clearInterval(this.heartbeat);
    this.socket.emit('conversation:leave', { conversation_id: this.conversationId });
  }
}

// in the conversation detail component:
useEffect(() => {
  const presence = new ChatPresence(socket, conversationId);
  presence.enter();
  return () => presence.leave();
}, [conversationId]);
```

Re-enter on `socket.on('connect', ...)` too — a reconnect drops server-side
presence state even if your React component never unmounted.

### After reconnect

Refetch `GET /admin/conversations/:id` — delivery over the socket is
fire-and-forget, so a message sent while disconnected is otherwise lost to
the live view (though it's always safely in the DB and shows up on next
`show`/`index`).

## 10. Suggested API client shape

```ts
async function apiRequest<T>(path: string, init?: RequestInit): Promise<T> {
  const res = await fetch(`${API_BASE}/api/v1/admin${path}`, {
    ...init,
    headers: {
      'Content-Type': 'application/json',
      Accept: 'application/json',
      'Accept-Language': locale,
      Authorization: `Bearer ${token}`,
      ...init?.headers,
    },
  });
  const body: ApiEnvelope<T> = await res.json();
  if (!res.ok || body.status !== 'success') {
    throw new ApiError(body.message, res.status, res.headers.get('X-Missing-Permission'));
  }
  return body.data as T;
}

export const conversationsApi = {
  list: (params: URLSearchParams) =>
    apiRequest<PaginatedEnvelope<'conversations', ConversationListItem>>(`/conversations?${params}`),
  show: (id: number) => apiRequest<ConversationDetails>(`/conversations/${id}`),
  reply: (id: number, body: string) =>
    apiRequest<ConversationMessage>(`/conversations/${id}/reply`, {
      method: 'POST',
      body: JSON.stringify({ body }),
    }),
  close: (id: number) => apiRequest<ConversationListItem>(`/conversations/${id}/close`, { method: 'POST' }),
  destroy: (id: number) => apiRequest<null>(`/conversations/${id}`, { method: 'DELETE' }),
};
```

`PaginatedEnvelope<K, T>` is whatever generic pagination wrapper the rest of
the dashboard already uses for other `filters[]`-driven admin lists (every
other admin index endpoint wraps under its own table name the same way —
mirror the existing pattern rather than inventing a new one for this
entity).

## 11. Open items to confirm before shipping

- **Socket.IO URL/port and auth error shape** — not in this repo; get from
  whoever owns `src/subscribers/index.js`.
- **`entity` field** — re-capture a live `index`/`show` response before
  relying on it in a table column; the only committed capture predates the
  column (§ note at top).
