---
feature: Order chat with the provider
slug: chat
surface: client
consumer: flutter
version: 3.0.1
status: ready
brd_version: "1.3"
brd_ids: [CUST-018]
business_rules: [BR-02, BR-15]
decisions: [TD-16, TD-19, TD-25, TD-96, TD-144, TD-145, TD-146, TD-147, TD-150, DEC-14, DEC-27]
schema_tables: [orders, conversations, messages, notifications, notification_preferences]
screens: [customer--chat--*]
related_docs: [orders.md, ../provider/chat.md]
last_updated: 2026-11-02
---

# Customer order chat — Flutter integration

## Version history

| Version | Date | Type | Summary | App action required |
|---------|------|------|---------|---------------------|
| 3.0.1 | 2026-11-02 | PATCH | Released as `ready`: the 3.0.0 contract is final. DEC-14's open part is only the retention period (admin monitoring was decided by TD-150); the error table now lists each `error_code`. No contract change. | None. Build against 3.0.0 as documented. |
| 3.0.0 | 2026-11-01 | Major | `status`, `sender_role` and `type` are `{value,label}` objects in HTTP and socket payloads; sends are rate limited; history of an order with no messages no longer creates a conversation (`conversation.id` is `null`). | Read `.value` of the three fields (labels are localized, never branch on them); handle 429 `RATE_LIMITED` on send; accept `conversation.id: null`. |
| 2.0.0 | 2026-10-29 | Major | Conversation list now paginates; unread follows the recipient app role; recovery and diagnostics clarified. | Read `data.data` and pagination metadata; refetch summaries on events and reconnect. |
| 1.0.0 | 2026-10-29 | Initial | Persisted text chat, private events and explicit unread cursor. | Add order chat and reconnect recovery. |

## Changelog

### 3.0.1 — 2026-11-02

- **Changed (docs):** status `in-progress` → `ready`. The endpoints, socket events and Postman examples were re-verified against the code and tests; nothing in the 3.0.0 contract changed.
- **Clarified:** DEC-14 no longer blocks this feature. Admin access to chats is decided (TD-150: read-only, every read audited, no deletion). Only the general retention period is open, and until it is decided nothing is deleted, so the app never sees a message disappear.
- **Clarified:** §3 now lists the `error_code` of every error the four routes return.
- **Breaking:** No. **App action required:** None.

### 3.0.0 — 2026-11-01

- **Changed (breaking):** conversation `status` (`open`, `read_only`), message `sender_role` (`customer`, `provider`) and message `type` (`text`) were strings and are now `{value,label}` objects (api-surfaces.md). This applies to every chat payload: the list, the history, the send response, the read response, and the `chat.message` and `chat.conversation.updated` socket events. HTTP labels follow the request language; each socket event carries labels in its **recipient's** profile language. Branch on `value`, never on `label`.
- **Changed:** `GET /orders/{order}/conversation` never writes. When the order can chat but has no messages yet it returns `messages: []`, `has_more: false` and a summary with `id: null`, `latest_message: null`, `unread_count: 0`, `status.value: open`; the conversation row is created by the first send. An order that cannot chat and has no conversation still answers 422 `ORDER_CHAT_UNAVAILABLE`.
- **Added:** `POST /orders/{order}/conversation/messages` is throttled per user (`ORDER_CHAT_PER_MINUTE`, default 30) and answers 429 `RATE_LIMITED`; honor `Retry-After`. Postman: `client.conversations.messages.store` › "429 Too many chat messages".
- **App action required:** update the models for the three enum fields, keep the empty-history shape in the chat screen, and back off on 429. Decision: TD-147.

### 2.0.0 — 2026-10-29

`GET /conversations` returns Laravel pagination metadata rather than a bare array. Incoming unread messages are selected by sender role opposite the token app, including an account that owns both customer and provider profiles. Telescope omits chat content from its request, response, model, query and Redis diagnostics. No mobile UI is implemented in this repository.

### 1.0.0 — 2026-10-29

Order-linked text chat supersedes TD-16 for the customer/provider participants. Phone calls and attachments remain outside this contract. General conversation retention and admin monitoring remain open under DEC-14.

## 1. Business summary

`can_chat` is true only while an order is paid and in `confirmed`, `preparing`, `on_the_way`, `arrived` or `in_progress`. Sending stops on completion or cancellation. Existing history remains readable. Opening history does not mark messages read. The customer only sees her own order conversation; identifiers in HTTP and events are UUIDs.

## 2. Data model

One `conversations` row per order. `messages` stores text, sender role, UUID and the opposite role's `read_at`; `notifications` stores a generic recipient alert. An account with both profiles has separate customer and provider unread views. No message deletion schedule is configured because DEC-14 is open.

## 3. Endpoints

All routes require a customer app JWT and an active account. Base path: `/api/v1/client`.

| Route | Method and path | Request | Success | Errors |
|-------|-----------------|---------|---------|--------|
| `client.conversations.index` | `GET /conversations` | Optional `page` integer ≥ 1 (default 1); `per_page` integer 1–100 (default 15) | 200 paginated summaries in `data.data` | 401/403, 422 invalid page/size |
| `client.conversations.show` | `GET /orders/{order}/conversation` | Optional `before` message UUID, 50 older messages | 200 history and summary; an order with no messages yet returns an empty history and `conversation.id: null` | 404 foreign order/cursor, 422 malformed cursor or unavailable unopened order |
| `client.conversations.messages.store` | `POST /orders/{order}/conversation/messages` | Required `body`: text, max 4000 characters | 201 persisted message | 404 foreign order, 422 invalid body or chat unavailable, 429 too many sends |
| `client.conversations.read` | `POST /orders/{order}/conversation/read` | Required `through_message_id`: UUID of latest message actually displayed | 200 summary with updated unread count | 404 foreign message/order, 422 malformed UUID |

| HTTP | `error_code` | When | App action |
|------|--------------|------|------------|
| 401 | `UNAUTHENTICATED` | Missing or expired token | Refresh the token or log in again. |
| 403 | `PERMISSION_DENIED` | Token issued by another app | Use the customer app token. |
| 404 | `ENTITY_NOT_FOUND` | Another customer's order, unknown order, or a `before` / `through_message_id` UUID outside this conversation | Close the chat screen. |
| 422 | — (`data.errors`) | Bad `page` / `per_page`, malformed UUID, empty or too-long `body` | Show the field error from `data.errors`. |
| 422 | `ORDER_CHAT_UNAVAILABLE` | Send on an order that is not paid or no longer in `confirmed` … `in_progress`; history of such an order that never had a message | Switch the screen to read-only (or hide chat) and refetch the order's `can_chat`. |
| 429 | `RATE_LIMITED` | More than `ORDER_CHAT_PER_MINUTE` (default 30) sends a minute | Keep the draft, wait `Retry-After`. |

List order is newest persisted message ID first, then conversation ID descending. Conversations without messages come last, newest conversation first among those. Each item has `{id,order_id,status,latest_message,unread_count}`; `status` is `{value,label}` with `value` `open` or `read_only`, and a message is `{id,sender_role,type,body,created_at}` with `sender_role` `{value: customer|provider, label}` and `type` `{value: text, label}`. The standard Laravel pagination object also supplies `current_page`, `per_page`, `total`, `last_page`, `from`, `to`, page URLs and `links`. Follow `next_page_url` until null. Public item and message identifiers remain UUIDs; page numbers are pagination controls, not record IDs. The `422` page-size example is Postman `client.conversations.index` › "422 Conversation page size exceeds maximum".

Postman `client.conversations.index` › "200 List customer conversations" captures this response:

```json
{
    "status": "success",
    "message": "",
    "data": {
        "current_page": 1,
        "data": [
            {
                "id": "01a19619-1639-720f-90ef-038fd272c2c7",
                "order_id": "01a19619-0ff7-73d9-90e5-52260adae3b0",
                "status": {
                    "value": "open",
                    "label": "Open"
                },
                "latest_message": {
                    "id": "01a19619-163a-7279-97bc-89f3b14e0392",
                    "sender_role": {
                        "value": "customer",
                        "label": "Customer"
                    },
                    "type": {
                        "value": "text",
                        "label": "Text"
                    },
                    "body": "I will arrive shortly.",
                    "created_at": "2026-10-25T07:00:00+00:00"
                },
                "unread_count": 0
            }
        ],
        "first_page_url": "http://localhost/api/v1/client/conversations?page=1",
        "from": 1,
        "last_page": 1,
        "last_page_url": "http://localhost/api/v1/client/conversations?page=1",
        "links": [
            {
                "url": null,
                "label": "&laquo; Previous",
                "page": null,
                "active": false
            },
            {
                "url": "http://localhost/api/v1/client/conversations?page=1",
                "label": "1",
                "page": 1,
                "active": true
            },
            {
                "url": null,
                "label": "Next &raquo;",
                "page": null,
                "active": false
            }
        ],
        "next_page_url": null,
        "path": "http://localhost/api/v1/client/conversations",
        "per_page": 15,
        "prev_page_url": null,
        "to": 1,
        "total": 1
    }
}
```

For example, Postman `client.conversations.messages.store` › "201 Send text message" records this real response:

```json
{
    "status": "success",
    "message": "",
    "data": {
        "id": "01a19619-163a-7279-97bc-89f3b14e0392",
        "sender_role": {
            "value": "customer",
            "label": "Customer"
        },
        "type": {
            "value": "text",
            "label": "Text"
        },
        "body": "I will arrive shortly.",
        "created_at": "2026-10-25T07:00:00+00:00",
        "order_id": "01a19619-0ff7-73d9-90e5-52260adae3b0",
        "conversation_id": "01a19619-1639-720f-90ef-038fd272c2c7"
    }
}
```

Postman `client.conversations.show` › "200 Order without messages returns an empty history" captures the empty-history response:

```json
{
    "status": "success",
    "message": "",
    "data": {
        "conversation": {
            "id": null,
            "order_id": "01a19619-1cdf-728e-b6c2-1134044277e7",
            "status": {
                "value": "open",
                "label": "Open"
            },
            "latest_message": null,
            "unread_count": 0
        },
        "messages": [],
        "has_more": false
    }
}
```

Other captured responses: `client.conversations.index` › "200 List customer conversations"; `client.conversations.show` › "200 Load customer conversation history"; `client.conversations.read` › "200 Customer marks displayed messages read"; validation and unavailable cases in the same routes.

## 4. Events and notifications

The recipient and sender's customer/provider app rooms each receive `chat.message` after commit, carrying exactly the persisted message fields above (enum fields as `{value,label}`, labels in the recipient's language). Both receive `chat.conversation.updated` containing `{id,order_id,status,latest_message,unread_count}`; the count is computed for the target app role, even if the account IDs are equal. A read action updates only the reader's app sessions. The peer receives no read receipt. The recipient gets a generic `chat_message` inbox entry and preference-aware FCM push with order/conversation/message UUIDs and no message text. Telescope retains chat request and query metadata but redacts message-bearing diagnostic content; app responses and authorized events still include text.

## 5. Consumer lifecycle

1. Register `chat.message` and `chat.conversation.updated` listeners before connecting the customer socket. Keep messages keyed by UUID so a repeated event or an HTTP page never creates a duplicate.
2. On connect or reconnect, fetch `/conversations` pages needed for the visible list and the open order's newest history page. For each older history page, send `before` equal to the **first** message UUID in the preceding page. Continue until the last locally known message appears or `has_more` is false. Merge by UUID; let the server's page order determine display order.
3. Give each list/history fetch a local generation number. If a chat event arrives during a fetch, keep its message in the UUID map, invalidate the in-flight response, and fetch again. Apply a response only when its generation is still current. This prevents an older response from replacing a newer message or unread count.
4. Treat `chat.conversation.updated` as a prompt to refresh authoritative list/summary state. It can update the visible row immediately, but a delayed event must never overwrite a more recent HTTP result. Refetch on the event and use generation checks; a new event during that fetch starts another refresh. No server revision field is required for this invalidation model.
5. Call `/conversation/read` only with the latest message UUID **actually displayed**; the server marks incoming messages through that cursor. History fetches do not clear unread. The response and subsequent summary event update this app's devices only; the other participant sees no receipt.
6. On screen close, increment the generation and ignore outstanding screen-specific responses. Keep the socket if another feature uses it. Resume the fetch procedure when the chat screen reopens.

## 6. Acceptance and open decisions

AC-CUST-018: unpaid/unconfirmed and foreign orders cannot send; paid confirmed participants can (`OrderChatTest`).

- **DEC-14 (partly resolved):** admin monitoring is decided (TD-150, read-only and audited, [admin/orders](../admin/orders.md)). The general retention period is open; no deletion runs until it is decided.
- **DEC-27 (open):** customer ↔ provider phone calls. Do not show the call buttons from the screens.
- **Out of scope (TD-144):** attachments, voice notes, editing and deleting messages.
- The mobile UI is built in the Flutter repository, not here. The backend contract above is final for this version.
