---
feature: Support desk (support and contact requests)
slug: support
surface: admin
consumer: react
version: 1.2.0
status: ready
brd_version: "1.3"
brd_ids: [ADMIN-026, DRIV-019]
business_rules: [BR-29]
decisions: [DEC-21, TD-162, TD-163, TD-179]
schema_tables: [support_tickets, support_messages, support_reasons, contact_messages, notifications, audit_logs]
related_docs: [../client/support.md, ../provider/support.md, ../delivery/support.md, admins-permissions.md]
last_updated: 2026-11-09
---

# Support desk — React Dashboard Integration Guide

> **Base URL:** `{HOST}/api/v1/admin` · Shared rules (headers, envelope, errors,
> pagination, axios setup, TS types): [API_CONVENTIONS.md](../API_CONVENTIONS.md)

## Version history

Newest first. Never edit an old row.

| Version | Date | Type | Summary | Dashboard action required |
|---------|------|------|---------|---------------------------|
| 1.2.0 | 2026-11-09 | MINOR | Ticket details and every thread message return `has_media`; admin replies and close notes accept `mp4` / `mov` attachments (R05, TD-183). | Show a media indicator; allow video upload in the reply box. |

| 1.1.0 | 2026-11-08 | MINOR | `GET /support-tickets/reasons` and `GET /support-tickets/assignees` pickers (TD-179). Fix: assignment now refuses a deleted, banned, disabled or inactive-role admin. | Fill the reason filter and the assignee select from these endpoints; keep handling `SUPPORT_ASSIGNEE_INVALID`. |
| 1.0.0 | 2026-11-03 | MINOR (additive) | New support desk on `support_tickets` from the customer, provider and driver apps: list with filters, details with thread, reply, status, close, assignment, and a notice to the requester. The `/contact-messages` endpoints are **deprecated but unchanged** (TD-163). | Build the Support desk. Nothing to change in the contact inbox. Run `php artisan rbac:sync-permissions`; roles holding `support.view` / `support.update` gain the new desk routes. |
| 0.1.0 | 2026-10-21 | Initial (base) | Documented `/contact-messages` list, details, reply and delete. | — |

## Changelog

### 1.1.0 — 2026-11-08

- **Added:** `GET /support-tickets/reasons` (route `admin.support-tickets.reasons`) and `GET /support-tickets/assignees` (`admin.support-tickets.assignees`); both are section `support` · `view`, so a supervisor with `support.view` needs no other cell (not `supervisors`, not `users`). Static paths: they never collide with `/support-tickets/{ticket}`. Details in §3.3 and §3.4.
- **Fixed:** `PUT /support-tickets/{ticket}/assignee` only checked `is_active` and the support cell, so it accepted a deleted, banned, admin-disabled or inactive-role admin. It now applies the sign-in rules of the dashboard (`SupportDeskStaff`), the same definition the assignee lookup lists. A request that used to succeed for such an account is now 422 `SUPPORT_ASSIGNEE_INVALID`; clearing (`null`) is unaffected.
- **Breaking:** No (new endpoints; the 422 code already existed).
- **Dashboard action required:** Use the two lookups; keep handling `SUPPORT_ASSIGNEE_INVALID` (eligibility can change after the list loads).
- **BRD / decisions:** ADMIN-026, BR-29, TD-179.

### 1.0.0 — 2026-11-03

- **Added:** `GET /support-tickets`, `GET /support-tickets/{ticket}`, `POST /support-tickets/{ticket}/messages`, `PUT /support-tickets/{ticket}/status`, `PUT /support-tickets/{ticket}/assignee`, `POST /support-tickets/{ticket}/close`; error code `SUPPORT_ASSIGNEE_INVALID`; two notification events (`support_ticket_replied`, `support_ticket_status_changed`).
- **Changed:** the `support` permission section now also maps the `support-tickets` routes: a role with `support.view` / `support.update` gets the desk routes next to the contact-messages ones (`support.update` covers reply, status, assign and close).
- **Deprecated:** `GET /contact-messages`, `GET /contact-messages/{id}`, `POST /contact-messages/{id}/reply`, `DELETE /contact-messages/{id}` and the customer app's `POST /client/contact` — **still working, routes, names, fields and responses unchanged, no app action required** (section 14). The desk replaces them for ADMIN-026.
- **Breaking:** No.
- **Dashboard action required:** build the Support desk; keep the contact inbox as is until the owner retires it.
- **BRD / decisions:** ADMIN-026, DRIV-019, DEC-21, TD-162, TD-163.

---

## 1. Business summary

Customers, providers and drivers send support requests from their apps (a reason, text, optional files; a customer or provider may link an order, a driver may link her own trip). The desk lists them with the sender's app, name and phone, the reason, priority, status and submission date. Opening one shows the user, the reason, the text, attachments, the linked order or trip, and the whole thread.

An admin can reply, set the request back to **under review**, close it, and assign it to a colleague. The requester gets an in-app notice and a push for every reply and every status change (ADMIN-026). A closed request is final: the requester's follow-up is refused and she opens a new one.

Statuses (DEC-21): `under_review` → `replied` → `closed`. `replied` is reached **only** by sending a reply, so a request never reads "replied" with nothing sent; an admin who answered by phone replies with a short note.

## 2. Pages and permissions

| Page / button | Route name | Section | Action |
|---------------|------------|---------|--------|
| Requests list | `admin.support-tickets.index` | `support` | view |
| Request details | `admin.support-tickets.show` | `support` | view |
| Reply | `admin.support-tickets.reply` | `support` | update |
| Change status | `admin.support-tickets.status` | `support` | update |
| Close | `admin.support-tickets.close` | `support` | update |
| Assign | `admin.support-tickets.assign` | `support` | update |
| Reason filter options (1.1.0) | `admin.support-tickets.reasons` | `support` | view |
| Assignee select (1.1.0) | `admin.support-tickets.assignees` | `support` | view |

Sidebar entry: **Support → Requests**; the existing **Support → Contact messages** entry keeps working (section 14). A supervisor with only `support.view` sees the list and details; the write buttons answer `403`.

## 3. List / table

`GET /api/v1/admin/support-tickets` — paginated under `data.tickets` (or `paginate=false`, a plain array capped at 500 rows). Default order: newest first.

| Column | Field | Sortable (`sort[...]`) | Filter (`filters[...]`) |
|--------|-------|------------------------|-------------------------|
| Code | `code` | — | `search` (partial) |
| App | `app` `{value,label}` | — | `filters[app]` = `customer` / `provider` / `driver` |
| User | `user.full_name`, `user.phone_code` + `user.phone` | — | `search` (name or phone) |
| Reason | `reason.label` | — | `filters[reason_id]` (uuid) |
| Priority | `priority` | `sort[priority]` | `filters[priority]` = `normal` / `urgent` |
| Status | `status` | `sort[status]` | `filters[status]` = `under_review` / `replied` / `closed` |
| Assignee | `assignee` (`null` if none) | — | `filters[assigned_admin_id]` (user uuid) |
| Linked | `order` / `trip` (`{id, code}` or `null`) | — | — |
| Submitted | `created_at` | `sort[created_at]` | `filters[created_from]`, `filters[created_to]` (`YYYY-MM-DD`) |

Query: `page`, `per_page` (1–100, default 15), `paginate`, `search`, `filters[...]`, `sort[...]`. An unknown filter or sort key is a `422`. The example keeps two rows.

```json
{
  "status": "success",
  "message": "Data retrieved successfully",
  "data": {
    "tickets": [
      {
        "id": "01a1a0fc-791c-73b9-96d8-cdd45e13950d",
        "code": "SUP-pumq84676414",
        "app": {
          "value": "provider",
          "label": "Provider"
        },
        "user": {
          "id": "01a1a0fc-7917-72f6-9083-3fe7eef3702f",
          "full_name": "Raina Deckow",
          "phone_code": "966",
          "phone": "517037700"
        },
        "reason": {
          "id": "01a1a0fc-791a-730d-add9-96dcf52fb299",
          "label": "Sample-tcly36865324"
        },
        "priority": {
          "value": "normal",
          "label": "Normal"
        },
        "status": {
          "value": "replied",
          "label": "Replied"
        },
        "assignee": {
          "id": "01a1a0fc-7906-72e7-9de3-332c60f0daf0",
          "full_name": "Jaylin Hegmann"
        },
        "order": null,
        "trip": null,
        "created_at": "2026-11-03T10:37:43+00:00",
        "closed_at": null
      },
      {
        "id": "01a1a0fc-7916-71bd-80a9-dd52908869b2",
        "code": "SUP-ydsg11119117",
        "app": {
          "value": "driver",
          "label": "Driver"
        },
        "user": {
          "id": "01a1a0fc-7911-70cc-a847-2670789683fc",
          "full_name": "Odessa Reichel",
          "phone_code": "966",
          "phone": "551408749"
        },
        "reason": {
          "id": "01a1a0fc-7914-70f8-acc5-e8e8bc80c3f7",
          "label": "Sample-frie52416836"
        },
        "priority": {
          "value": "urgent",
          "label": "Urgent"
        },
        "status": {
          "value": "under_review",
          "label": "Under review"
        },
        "assignee": null,
        "order": null,
        "trip": null,
        "created_at": "2026-11-03T10:37:43+00:00",
        "closed_at": null
      }
    ]
  }
}
```

Postman: `admin.support-tickets.index` › "200 Support Tickets List"

```json
{
  "status": "fail",
  "message": "The selected filters.app is invalid.",
  "data": {
    "errors": {
      "filters.app": [
        "The selected filters.app is invalid."
      ]
    }
  }
}
```

Postman: `admin.support-tickets.index` › "422 Support Tickets List Invalid App"

## 3.3 Reason lookup — `GET /api/v1/admin/support-tickets/reasons` (1.1.0, TD-179)

Options for the list's `filters[reason_id]` and any reason select. Reads `support_reasons` with their translations; the admin never calls an app route.

| Query | Type | Notes |
|-------|------|-------|
| `search` | string ≤ 100 | Partial match on the label in **any** language (LIKE-escaped). |
| `filters[audience]` | `customer` \| `provider` \| `driver` | Reasons offered in that app. Omit for all apps. |
| `filters[is_active]` | boolean (`1`/`0`) | `1` = still pickable by users, `0` = retired. **Omitted = both**, so a historical request linked to a retired reason can always be selected; mark retired rows with `is_active: false`. |
| `page`, `per_page` (1–100, default 15) | int | Paginated under `data.reasons` with `links` and `meta`. |
| `paginate=false` | | Direct `data` array, at most 500 rows. |

Order: `audience`, `sort_order`, id. Row: `id` (reason uuid, the value `filters[reason_id]` takes), `audience {value,label}` (the app: reasons are per app, so show the audience next to the label), `label` (request locale), `is_urgent`, `is_active`, `sort_order`. An unknown filter key, bad enum or `per_page` over 100 is 422. 403 without `support.view`.

```json
{
  "status": "success",
  "message": "Data retrieved successfully",
  "data": {
    "reasons": [
      {"id": "01a1bb7b-f168-7120-841c-22bbe3470530", "audience": {"value": "customer", "label": "Customer"}, "label": "Payment issue", "is_urgent": true, "is_active": true, "sort_order": 1},
      {"id": "01a1bb7b-f16b-7132-b81b-afd81aecf942", "audience": {"value": "customer", "label": "Customer"}, "label": "Old complaint", "is_urgent": false, "is_active": false, "sort_order": 2}
    ],
    "links": {"first": "…/support-tickets/reasons?page=1", "last": "…?page=1", "prev": null, "next": null},
    "meta": {"current_page": 1, "per_page": 15, "total": 3, "last_page": 1}
  }
}
```

(`meta` is abridged here; it is the standard paginator.) Postman: `admin.support-tickets.reasons` › "200 Support Reasons Lookup" · "200 Support Reasons Lookup Unpaginated" · "422 Support Reasons Lookup Invalid".

## 3.4 Assignee lookup — `GET /api/v1/admin/support-tickets/assignees` (1.1.0, TD-179)

Everyone `PUT …/assignee` accepts, and nobody else: **super admins and supervisors** who can sign in today (not deleted, banned, admin-disabled, disabled, or on an inactive role) and can open the desk (a super admin always; a supervisor needs `admin.support-tickets.index`, i.e. `support.view`, through her role or direct grants). `GET /supervisors` stays supervisor-only and is not needed for this select. The list and the assignment share one class (`SupportDeskStaff`), so they cannot drift.

| Query | Type | Notes |
|-------|------|-------|
| `search` | string ≤ 100 | Partial match on name or email. |
| `page`, `per_page`, `paginate=false` | | As above; rows under `data.assignees`. |

Row: `id` (**user uuid**, send it as `assignee_id`), `full_name`, `email`, `type {value: super_admin|supervisor, label}`. No role, permission or phone data. Order: name. The current assignee of an old ticket may no longer be listed (she left or was disabled): show `ticket.assignee` from the ticket itself, and treat the list as the choices for a **new** assignment.

```json
{"id": "01a1bb7b-f958-71bf-bf44-ad0b247c1054", "full_name": "Amal Desk", "email": "amal@example.com", "type": {"value": "supervisor", "label": "Supervisor"}}
```

Postman: `admin.support-tickets.assignees` › "200 Support Assignees Lookup" · "200 Support Assignees Lookup Page" · "200 Support Assignees Lookup Unpaginated" · "422 Support Assignees Lookup Invalid".

## 4. Details view

`GET /api/v1/admin/support-tickets/{ticket}` — `ticket` is the uuid; an integer id or unknown uuid is a `404`.

```json
{
  "status": "success",
  "message": "Data retrieved successfully",
  "data": {
    "id": "01a1a0fc-7f2a-709f-8d0b-92b5da8093b5",
    "code": "SUP-wbna66980001",
    "app": {
      "value": "driver",
      "label": "Driver"
    },
    "user": {
      "id": "01a1a0fc-7f09-7039-b0a5-07c9186b888a",
      "full_name": "Miss Fanny Cormier Jr.",
      "phone_code": "966",
      "phone": "519450577"
    },
    "reason": {
      "id": "01a1a0fc-7f28-7277-8130-d6c7759ecf1f",
      "label": "Sample-regd59092116"
    },
    "priority": {
      "value": "normal",
      "label": "Normal"
    },
    "status": {
      "value": "under_review",
      "label": "Under review"
    },
    "assignee": null,
    "order": null,
    "trip": {
      "id": "01a1a0fc-7f11-7104-aaa3-45281d1afe5d",
      "code": "TRP-meoa25270776",
      "order": {
        "id": "01a1a0fc-7f11-7104-aaa3-45281d3ef6e8",
        "code": "JSH-faea07038063"
      }
    },
    "created_at": "2026-11-03T10:37:44+00:00",
    "closed_at": null,
    "body": "The pickup was cancelled on site",
    "attachments": [
      "http://localhost/storage/support/f53c932b-4228-40aa-a41e-67c7d7024240.pdf"
    ],
    "allowed_statuses": [
      {
        "value": "closed",
        "label": "Closed"
      }
    ],
    "can_reply": true,
    "updated_at": "2026-11-03T10:37:44+00:00",
    "messages": [
      {
        "id": "01a1a0fc-7f33-710a-99ef-8c064327f61c",
        "sender": {
          "value": "user",
          "label": "Requester",
          "name": "Miss Fanny Cormier Jr."
        },
        "body": "First",
        "attachments": [
          "https://ui-avatars.com/api/?name=N&background=f3f4f6&color=9ca3af&size=200"
        ],
        "created_at": "2026-11-03T10:37:44+00:00"
      },
      {
        "id": "01a1a0fc-7f34-7223-a087-f8d4bc2e77c0",
        "sender": {
          "value": "admin",
          "label": "Support team",
          "name": "Alessandro Huels Sr."
        },
        "body": "Second",
        "attachments": [
          "https://ui-avatars.com/api/?name=N&background=f3f4f6&color=9ca3af&size=200"
        ],
        "created_at": "2026-11-03T10:37:44+00:00"
      }
    ]
  }
}
```

Postman: `admin.support-tickets.show` › "200 Support Ticket Details"

- `user.id` is the user's uuid. `trip.order` is the order of the linked trip (a driver's request links a trip, never an order).
- `messages` are in order; `sender.value` is `user` or `admin`; `sender.name` is the admin's name on the desk (the requester's apps only ever see "Support team").
- `allowed_statuses` lists the moves the status endpoint accepts now; `can_reply` is `false` once closed. Drive the buttons from these, not from your own copy of the map.

```json
{
  "status": "fail",
  "message": "Resource not found",
  "data": null
}
```

Postman: `admin.support-tickets.show` › "404 Support Ticket Not Found"

## 5. Forms (create / edit)

N/A — requests are created by the apps. The desk's writes are the actions below.

## 6. Actions (non-CRUD)

### 6.1 Reply

`POST /api/v1/admin/support-tickets/{ticket}/messages` — `201`. Saves the message, sets the request to `replied` and notifies the requester, in one transaction.

| Field | Type | Required | Rules | Description |
|-------|------|----------|-------|-------------|
| `body` | string | yes | 2–4000 | The answer. |
| `attachments` | array | no | ≤ 5 media hashes (image, video or document) | Upload first through the media endpoint. |

```json
{
  "status": "success",
  "message": "Your reply was sent.",
  "data": {
    "id": "01a1a0fc-842b-72c3-870d-10c3db8d21bc",
    "sender": {
      "value": "admin",
      "label": "Support team"
    },
    "body": "We are on it",
    "attachments": [
      "http://localhost/storage/support/e7b915b9-ad86-4e48-a592-bcf016a6fec1.pdf"
    ],
    "created_at": "2026-11-03T10:37:45+00:00"
  }
}
```

Postman: `admin.support-tickets.reply` › "201 Support Ticket Reply"

| Case | Response |
|------|----------|
| Empty or too short text | `422` `VALIDATION_FAILED` — Postman: `admin.support-tickets.reply` › "422 Support Ticket Reply Empty" |
| Request is closed | `409` `SUPPORT_TICKET_CLOSED` — Postman: `admin.support-tickets.reply` › "409 Support Ticket Reply Closed" |

### 6.2 Change status

`PUT /api/v1/admin/support-tickets/{ticket}/status` — body `status`: `under_review` or `closed` only. Returns the full details payload.

| From | May move to |
|------|-------------|
| `under_review` | `closed` |
| `replied` | `under_review`, `closed` |
| `closed` | — (final) |

```json
{
  "status": "success",
  "message": "The request status was updated.",
  "data": {
    "id": "01a1a0fc-8886-73d7-bfee-b25d35ca2bbb",
    "code": "SUP-yndi10631634",
    "app": {
      "value": "provider",
      "label": "Provider"
    },
    "user": {
      "id": "01a1a0fc-8881-719a-b087-048c58c3b5fc",
      "full_name": "Yasmin Strosin",
      "phone_code": "966",
      "phone": "504528504"
    },
    "reason": {
      "id": "01a1a0fc-8884-7372-b9f8-bce838f576a8",
      "label": "Sample-uzul39793766"
    },
    "priority": {
      "value": "normal",
      "label": "Normal"
    },
    "status": {
      "value": "under_review",
      "label": "Under review"
    },
    "assignee": null,
    "order": null,
    "trip": null,
    "created_at": "2026-11-03T10:37:47+00:00",
    "closed_at": null,
    "body": "Sed consectetur aut provident nemo. Odio perferendis quod mollitia atque. Unde excepturi omnis qui assumenda quod tempora explicabo. Ut dolores possimus occaecati.",
    "attachments": [
      "https://ui-avatars.com/api/?name=N&background=f3f4f6&color=9ca3af&size=200"
    ],
    "allowed_statuses": [
      {
        "value": "closed",
        "label": "Closed"
      }
    ],
    "can_reply": true,
    "updated_at": "2026-11-03T10:37:47+00:00",
    "messages": []
  }
}
```

Postman: `admin.support-tickets.status` › "200 Support Ticket Status Under Review"

| Case | Response |
|------|----------|
| `status` = `replied`, or any other value | `422` `VALIDATION_FAILED` — Postman: `admin.support-tickets.status` › "422 Support Ticket Status Replied Invalid" |
| A move the table does not allow (including to the same status, or out of `closed`) | `409` `INVALID_STATE_TRANSITION` — Postman: `admin.support-tickets.status` › "409 Support Ticket Status Invalid Transition" |

### 6.3 Close

`POST /api/v1/admin/support-tickets/{ticket}/close` — optional `body` (2–4000) and `attachments` (≤ 5). With a `body` the last message and the closing happen in one transaction and the requester gets **one** notice (status changed), not two. Stamps `closed_at`. Same `409` as 6.2 when already closed.

```json
{
  "status": "success",
  "message": "The request status was updated.",
  "data": {
    "id": "01a1a0fc-8e09-70e4-8098-99478f922c3f",
    "code": "SUP-mnow60610289",
    "app": {
      "value": "customer",
      "label": "Customer"
    },
    "user": {
      "id": "01a1a0fc-8e03-72db-8e98-4d4783145b3f",
      "full_name": "Valentin Labadie DDS",
      "phone_code": "966",
      "phone": "552897601"
    },
    "reason": {
      "id": "01a1a0fc-8e06-70de-99fa-3350b1dd6143",
      "label": "Sample-gxlk86560568"
    },
    "priority": {
      "value": "normal",
      "label": "Normal"
    },
    "status": {
      "value": "closed",
      "label": "Closed"
    },
    "assignee": null,
    "order": null,
    "trip": null,
    "created_at": "2026-11-03T10:37:48+00:00",
    "closed_at": "2026-11-03T10:37:48+00:00",
    "body": "Aut officiis totam reprehenderit eum corrupti quod. Veniam itaque dolores repellat eum vel dolores. Itaque est omnis nesciunt esse. Nisi nesciunt et ut sit voluptatem facilis voluptatem.",
    "attachments": [
      "https://ui-avatars.com/api/?name=N&background=f3f4f6&color=9ca3af&size=200"
    ],
    "allowed_statuses": [],
    "can_reply": false,
    "updated_at": "2026-11-03T10:37:48+00:00",
    "messages": [
      {
        "id": "01a1a0fc-8e10-711a-acb9-65b5007cdb65",
        "sender": {
          "value": "admin",
          "label": "Support team",
          "name": "Prof. Myrna Cassin DDS"
        },
        "body": "Resolved, thank you",
        "attachments": [
          "https://ui-avatars.com/api/?name=N&background=f3f4f6&color=9ca3af&size=200"
        ],
        "created_at": "2026-11-03T10:37:48+00:00"
      }
    ]
  }
}
```

Postman: `admin.support-tickets.close` › "200 Support Ticket Close With Message" · "422 Support Ticket Close Body Too Short"

### 6.4 Assign

`PUT /api/v1/admin/support-tickets/{ticket}/assignee` — body `assignee_id` is **required to be present**: an admin's user uuid, or `null` to clear. The assignee must be an active super admin (able to sign in: not deleted, banned, admin-disabled or on an inactive role), or an active supervisor whose role grants `support.view`; anyone else (a customer's uuid included) is `422` `SUPPORT_ASSIGNEE_INVALID`. Populate your picker from the supervisors list and expect the refusal.

```json
{
  "status": "success",
  "message": "The request assignment was updated.",
  "data": {
    "id": "01a1a0fc-923e-70d3-b174-9e78cb13fbce",
    "code": "SUP-dvfq09606450",
    "app": {
      "value": "customer",
      "label": "Customer"
    },
    "user": {
      "id": "01a1a0fc-9238-70d6-8ad6-addc8209b1b0",
      "full_name": "Mr. Malachi Legros",
      "phone_code": "966",
      "phone": "548192267"
    },
    "reason": {
      "id": "01a1a0fc-923b-7013-a0f6-e34f851a61b6",
      "label": "Sample-kxgo71941977"
    },
    "priority": {
      "value": "normal",
      "label": "Normal"
    },
    "status": {
      "value": "under_review",
      "label": "Under review"
    },
    "assignee": {
      "id": "01a1a0fc-924c-7382-afab-2a8e23788c53",
      "full_name": "Alessia Becker"
    },
    "order": null,
    "trip": null,
    "created_at": "2026-11-03T10:37:49+00:00",
    "closed_at": null,
    "body": "Suscipit excepturi dolor corporis dolorem accusamus pariatur dolorum. Architecto voluptates eum explicabo odio. Eveniet temporibus aut aliquid voluptatem.",
    "attachments": [
      "https://ui-avatars.com/api/?name=N&background=f3f4f6&color=9ca3af&size=200"
    ],
    "allowed_statuses": [
      {
        "value": "closed",
        "label": "Closed"
      }
    ],
    "can_reply": true,
    "updated_at": "2026-11-03T10:37:49+00:00",
    "messages": []
  }
}
```

Postman: `admin.support-tickets.assign` › "200 Support Ticket Assign" · "200 Support Ticket Assign Clear" · "422 Support Ticket Assign Invalid Assignee"

```json
{
  "status": "fail",
  "message": "The chosen admin cannot handle support requests.",
  "data": {
    "error_code": "SUPPORT_ASSIGNEE_INVALID",
    "correlation_id": "38c87886-21c1-417d-8d40-6edc03316142"
  }
}
```

## 7. Statuses and enums

| Enum | Values |
|------|--------|
| `status` | `under_review`, `replied`, `closed` |
| `priority` | `normal`, `urgent` (an urgent reason makes the request urgent) |
| `app` | `customer`, `provider`, `driver` |
| `messages[].sender.value` | `user`, `admin` |

## Data model

`support_tickets` (code `SUP-1000`+, `app`, `reason_id`, `order_id` / `trip_id`, `priority`, `status`, `assigned_admin_id`, `closed_at`), `support_messages` (sender `user` / `admin`, body, attachments), `support_reasons` (+ translations). The `contact_messages` (deprecated inbox, section 14) is unchanged.

## 8. TypeScript types

```ts
type TicketStatus = 'under_review' | 'replied' | 'closed';
interface EnumObject<T extends string> { value: T; label: string }
interface SupportTicketRow {
  id: string; code: string;
  app: EnumObject<'customer' | 'provider' | 'driver'>;
  user: { id: string | null; full_name: string | null; phone_code: string | null; phone: string | null };
  reason: { id: string | null; label: string | null };
  priority: EnumObject<'normal' | 'urgent'>;
  status: EnumObject<TicketStatus>;
  assignee: { id: string; full_name: string } | null;
  order: { id: string; code: string } | null;
  trip: { id: string; code: string } | null;
  created_at: string; closed_at: string | null;
}
interface SupportTicketDetails extends Omit<SupportTicketRow, 'trip'> {
  body: string; attachments: string[]; can_reply: boolean; updated_at: string;
  trip: { id: string; code: string; order: { id: string; code: string } | null } | null;
  allowed_statuses: EnumObject<TicketStatus>[];
  messages: { id: string; sender: EnumObject<'user' | 'admin'> & { name: string | null }; body: string; attachments: string[]; created_at: string }[];
}
```

## 9. Business rules enforced by the backend

- BR-29: a supervisor reaches only the cells granted; the route name is the permission.
- The request row is locked before every write, so a reply racing a close either lands first or is refused (`SUPPORT_TICKET_CLOSED`).
- `closed` is final for the desk; the requester's follow-up on a closed request is refused (409) in the apps (TD-162).
- Reply, status, close and assignment each write an audit entry (`support.ticket.replied|status|assigned`).
- The requester's notice is written in the same transaction as the change, in her locale (Arabic fallback), in her own app's inbox.
- The base contact form and `/contact-messages` are deprecated but untouched (TD-163); their data is not merged into tickets.

## 10. Notifications triggered

| Event key | When | Recipient | `data` |
|-----------|------|-----------|--------|
| `support_ticket_replied` | Reply sent (6.1) | The requester, in her app (`customer`, `provider` or `driver`) | `ticket_id` (uuid), `code`, `status` |
| `support_ticket_status_changed` | Status set to `under_review` or `closed` (6.2, 6.3) | The requester | `ticket_id`, `code`, `status` = `under_review` \| `closed` |

Each is an inbox row plus FCM push (TD-141). Closing with a message sends only the second event.

## 11. Integration checklist

- [ ] Add the Support desk next to the Contact inbox; leave the contact-messages code as is.
- [ ] Hide Reply and Close when `can_reply` is `false`; show only `allowed_statuses` in the status menu.
- [ ] Offer "Reply with a short note" instead of a "mark replied" button.
- [ ] Handle `409` `SUPPORT_TICKET_CLOSED` / `INVALID_STATE_TRANSITION` and `422` `SUPPORT_ASSIGNEE_INVALID`.
- [ ] After deploy run `php artisan rbac:sync-permissions`; existing roles with `support.*` cells gain the desk routes.

## 12. Acceptance scenarios

AC-ADMIN-026 (list with user type, name, phone, reason, date and status; details with user, reason, text, attachments and linked order or trip; reply; change status; close; the user is notified). Tests: `tests/Feature/Api/V1/Admin/SupportDeskTest.php`.

## 13. Open decisions and known limitations

- Notification titles and bodies are in the language files for now; WP-4 turns the two events into editable templates.
- No unassign endpoint other than `assignee_id: null`; no assignment notification.
- Contact messages and tickets are separate inboxes; the dashboard shows both until the owner retires the contact form.
- A reply does not reopen a `closed` request; the requester opens a new one (TD-162).

---

## 14. Deprecated — contact messages (still working, no app action required)

> **Deprecated, unchanged (TD-163).** `GET/POST/DELETE /contact-messages…` and the customer app's `POST /client/contact` keep their routes, route names (`admin.contact-messages.*`, `client.contact`), request fields and response shapes. The dashboard and the customer app already use them; nothing needs to change. New work should use the Support desk above. Section `support`: `index`/`show` → view, `reply` → update, `destroy` → delete. The reply is stored on the message only; the sender is not notified.

### 14.2 Pages and permissions

| Page / component | Purpose | Endpoint(s) | RBAC permission (route name) |
|------------------|---------|-------------|------------------------------|
| ContactInboxPage | Table of messages | `GET /contact-messages` | `admin.contact-messages.index` |
| ContactMessagePage | Read one (marks it read) | `GET /contact-messages/{id}` | `admin.contact-messages.show` |
| ReplyForm | Save a reply | `POST /contact-messages/{id}/reply` | `admin.contact-messages.reply` |
| DeleteMessageDialog | Delete | `DELETE /contact-messages/{id}` | `admin.contact-messages.destroy` |

Section `support` (TD-27): `index`/`show` → view, `reply` → update,
`destroy` → delete. Sidebar entry: **Support → Contact messages**.

### 14.3 List / table

`GET /api/v1/admin/contact-messages` — list key `contact_messages`. Default
order: newest first.

| Column | Field | Sortable (`sort[...]`) | Filter (`filters[...]`) | Notes |
|--------|-------|------------------------|-------------------------|-------|
| Name | `name` | yes | `filters[name]` | |
| Email | `email` | yes | `filters[email]` | |
| Phone | `phone` | yes | `filters[phone]` | May be `null` |
| Subject | `subject` | yes | `filters[subject]` | |
| Read | `is_read` | no | no | Bold the row when `false` |
| Replied | `replied_at` | no | no | `null` = not replied |
| Received | `created_at` | no (default order) | no | `Y-m-d H:i:s` (TD-03) |

`search` matches name, email, phone, subject, message. There is no filter for
read / replied.

```json
{
  "status": "success",
  "message": "Data retrieved successfully",
  "data": {
    "contact_messages": [
      {
        "id": "01a15dd9-483e-71b3-817a-556755f6c7a0",
        "name": "Noura",
        "email": "noura@example.com",
        "phone": "0512345678",
        "subject": "Booking question",
        "is_read": false,
        "replied_at": null,
        "created_at": "2026-10-21 09:44:43"
      }
    ],
    "links": {
      "first": "http://localhost/api/v1/admin/contact-messages?page=1",
      "last": "http://localhost/api/v1/admin/contact-messages?page=1",
      "prev": null,
      "next": null
    },
    "meta": {
      "current_page": 1,
      "from": 1,
      "last_page": 1,
      "path": "http://localhost/api/v1/admin/contact-messages",
      "per_page": 15,
      "to": 1,
      "total": 1
    }
  }
}
```

### 14.4 Details view

`GET /api/v1/admin/contact-messages/{id}` — also sets `is_read = true` (the
answer already shows `true`).

```json
{
  "status": "success",
  "message": "Data retrieved successfully",
  "data": {
    "id": "01a15dd9-483e-71b3-817a-556755f6c7a0",
    "name": "Noura",
    "email": "noura@example.com",
    "phone": "0512345678",
    "subject": "Booking question",
    "is_read": true,
    "replied_at": null,
    "created_at": "2026-10-21 09:44:43",
    "message": "Can I book for my sister too?",
    "reply": null,
    "replied_by": null,
    "updated_at": "2026-10-21 09:44:43"
  }
}
```

After a reply:

```json
{
  "status": "success",
  "message": "Data retrieved successfully",
  "data": {
    "id": "01a15dd9-483e-71b3-817a-556755f6c7a0",
    "name": "Noura",
    "email": "noura@example.com",
    "phone": "0512345678",
    "subject": "Booking question",
    "is_read": true,
    "replied_at": "2026-10-21 09:44:43",
    "created_at": "2026-10-21 09:44:43",
    "message": "Can I book for my sister too?",
    "reply": "Yes — use group booking.",
    "replied_by": {
      "id": "01a15dd9-480f-701b-a6bd-3822a1a7f490",
      "full_name": "Joshen Admin",
      "email": "admin@joshen.test"
    },
    "updated_at": "2026-10-21 09:44:43"
  }
}
```

| Field | Type | Nullable | Description |
|-------|------|----------|-------------|
| `message` | string | no | Sender's text |
| `reply` | string | yes | Admin reply |
| `replied_by` | `{id, full_name, email}` | yes | Admin who replied |
| `replied_at` | string | yes | `Y-m-d H:i:s` |

### 14.5 Forms (create / edit)

#### 5.1 Reply — `POST /api/v1/admin/contact-messages/{id}/reply`

| Field | Input type | Required | Validation |
|-------|-----------|----------|-----------|
| `reply` | textarea | yes | string, max 5000 |

A second reply **overwrites** the first (one reply per message).

```json
{ "reply": "Yes — use group booking." }
```

```json
{
  "status": "success",
  "message": "Reply sent successfully",
  "data": null
}
```

```json
{
  "status": "fail",
  "message": "The Reply field is required.",
  "data": null
}
```

Reload the details after saving: the answer has no data.

### 14.6 Actions (non-CRUD)

#### 6.1 Delete

`DELETE /api/v1/admin/contact-messages/{id}` · Permission: `admin.contact-messages.destroy` — permanent.

```json
{
  "status": "success",
  "message": "Data removed successfully",
  "data": null
}
```

#### Errors (all endpoints in this doc)

| HTTP | `error_code` | When | Dashboard behavior |
|------|--------------|------|--------------------|
| 401 | `UNAUTHENTICATED` | Missing or expired token | Refresh, then retry |
| 403 | `PERMISSION_DENIED` | Missing route permission | Hide/disable the action |
| 404 | — | Unknown or integer id | Toast, reload |
| 422 | — | Empty or too long reply (TD-02) | Show `message` |
