# Flutter Chat API

Use this when wiring the mobile app to **Supergame Chat Manager**. The app talks to Laravel over HTTPS. Realtime listen uses the **Supabase anon key** only. The app must **not** write to Postgres or Storage directly.

Interactive spec (local): `http://127.0.0.1:8000/api/docs`

## Base URL

```
{APP_URL}/api
```

Local default: `http://127.0.0.1:8000/api`

All chat routes are under `/api/chat/...`. Rate limit: **60 requests / minute** per IP + secret.

## Auth headers (every request)

| Header | Value |
| --- | --- |
| `X-Secret-Key` | Shared `SECRET_KEY` from Chat Manager (same key for all domains) |
| `X-Chat-Domain` | Active hostname, e.g. `sarswati.test` |
| `Accept` | `application/json` |

Bearer token is also accepted as the secret (`Authorization: Bearer {SECRET_KEY}`). Prefer `X-Secret-Key`.

`X-Chat-Domain` can be replaced with body/query `domain`, but the header is the default.

Inactive or unknown domain → `404` `{ "message": "Invalid domain." }`  
Wrong/missing secret → `401` `{ "message": "Invalid secret key." }`

## Integration flow

1. Call **GET `/chat/config`** once per session. Cache `supabase.url`, `supabase.anon_key`, `supabase.storage_bucket`.
2. Call **GET `/chat/quick-replies?type=`** when the player taps Deposit, Withdrawal, Game Rate, or Other. Use the returned `answer` as the canned text.
3. Call **GET `/chat/conversations?user_id=`** to resolve the existing `conversation.id`. If `404`, call **POST `/chat/conversations`** with `user_id` and `user_name` to create it. On first create, use the returned `message` (welcome) immediately — do not call GET messages just for that.
4. Call **GET `/chat/conversations/{id}/messages?user_id=`** to load history (existing threads).
5. Subscribe to Supabase `postgres_changes` on `messages` for that `conversation_id` (INSERT + UPDATE).
6. Send with **POST `/chat/conversations/{id}/messages`**. Do not insert rows from the app.
7. Tick receipts:
   - **`delivered`** when a chat-manager message arrives (realtime INSERT / history load).
   - **`seen`** when the player opens the thread.

One conversation per `(user_id, domain_id)`. GET never creates or reopens. POST creates a thread if missing and reopens if closed. Archived chats stay archived (`"status": "deleted"`).

## Endpoints

### GET `/api/chat/config`

Bootstrap domain + realtime credentials. Never returns the service role key.

```http
GET /api/chat/config
X-Secret-Key: {SECRET_KEY}
X-Chat-Domain: sarswati.test
```

```json
{
  "domain": {
    "id": 1,
    "domain": "sarswati.test",
    "domain_name": "Sarswati"
  },
  "supabase": {
    "url": "http://127.0.0.1:54321",
    "anon_key": "...",
    "storage_bucket": "chat-media"
  }
}
```

### GET `/api/chat/quick-replies`

Returns the canned answer for this domain. Query: `type` (required). `200`.

`type` is one of: `deposit`, `withdrawal`, `game_rate`, `other`.

```http
GET /api/chat/quick-replies?type=deposit
X-Secret-Key: {SECRET_KEY}
X-Chat-Domain: sarswati.test
```

```json
{
  "type": "deposit",
  "answer": "💰 Minimum Deposit ₹99"
}
```

Unknown/inactive domain → `404` `{ "message": "Invalid domain." }`  
Missing or invalid `type` → `422`  
No saved answer for that type → `404` `{ "message": "Quick reply not found." }`

### GET `/api/chat/conversations`

Returns the existing thread for this user in this domain. Query: `user_id` (required). `200`. Does **not** create or reopen.

```http
GET /api/chat/conversations?user_id=player-1003
X-Secret-Key: {SECRET_KEY}
X-Chat-Domain: sarswati.test
```

```json
{
  "conversation": {
    "id": 12,
    "user_id": "player-1003",
    "user_name": "Neha Kapoor",
    "domain_id": 1,
    "status": "open"
  }
}
```

`id` is a **bigint**, not a UUID.

No thread yet → `404` `{ "message": "Conversation not found." }`  
Closed chats are returned with `"status": "closed"`.  
Archived chats (`deleted_at` set) are returned with `"status": "deleted"`. GET does not restore them.

### POST `/api/chat/conversations`

Create or reopen the one-to-one thread. `201`.

```json
{
  "user_id": "player-1003",
  "user_name": "Neha Kapoor"
}
```

`user_id` required (string, max 255). `user_name` optional.

Response matches GET, plus `message`: the domain welcome when this request created the thread. Existing/reopened threads return `"message": null`.

```json
{
  "conversation": {
    "id": 12,
    "user_id": "player-1003",
    "user_name": "Neha Kapoor",
    "domain_id": 1,
    "status": "open"
  },
  "message": {
    "id": 101,
    "conversation_id": 12,
    "sender_id": "system",
    "sender_type": "chat_manager",
    "message_type": "text",
    "body": "Welcome to Sarswati chat support. How can we help you?",
    "media_path": null,
    "media_url": null,
    "status": "sent",
    "reply_to_id": null,
    "reply_to": null,
    "created_at": "2026-09-15T12:00:00+00:00"
  }
}
```

Do **not** call GET messages just to show the welcome. Use GET messages for chat history. If `message` is null, there was no new welcome on this start.

POST creates the thread if missing and reopens if closed. Archived chats stay archived and return `"status": "deleted"` with `"message": null`.

### GET `/api/chat/conversations/{id}/messages`

Latest **200** messages, oldest first. Query: `user_id` (required). Soft-deleted messages are omitted.

```json
{
  "conversation": { "id": 12, "user_id": "player-1003", "user_name": "Neha Kapoor", "domain_id": 1, "status": "open" },
  "messages": [
    {
      "id": 101,
      "conversation_id": 12,
      "sender_id": "player-1003",
      "sender_type": "user",
      "message_type": "text",
      "body": "Bonus did not credit.",
      "media_path": null,
      "media_url": null,
      "status": "sent",
      "created_at": "2026-09-14T12:00:00+00:00"
    }
  ]
}
```

Wrong `user_id` or other domain’s conversation → `404` `{ "message": "Conversation not found." }`

### POST `/api/chat/conversations/{id}/messages`

Send as the Flutter user (`sender_type` is always `user`). `201`.

**Text (JSON):**

```json
{
  "user_id": "player-1003",
  "body": "Bonus did not credit."
}
```

**Media (`multipart/form-data`):** fields `user_id`, optional `body`, file field `media`.

At least one of `body` or `media` is required. `body` max 5000 chars.

| Type | MIME | Max |
| --- | --- | --- |
| image | jpeg, png, gif, webp | 5 MB |
| audio | mpeg, mp4, wav, webm, ogg, aac | 15 MB |
| video | mp4, webm, quicktime | 50 MB |

Response:

```json
{
  "message": {
    "id": 102,
    "conversation_id": 12,
    "sender_id": "player-1003",
    "sender_type": "user",
    "message_type": "text",
    "body": "Bonus did not credit.",
    "media_path": null,
    "media_url": null,
    "status": "sent",
    "created_at": "2026-09-14T12:01:00+00:00"
  }
}
```

Text field in DB/API is **`body`**, not `message`.

### PATCH `/api/chat/conversations/{id}/receipts`

Marks **chat-manager** messages only. Player messages are marked seen by the inbox when a manager opens the thread.

```json
{
  "user_id": "player-1003",
  "status": "delivered"
}
```

`status` is `delivered` or `seen`.

- `delivered` — manager messages still `sent` → `delivered` + `delivered_at`
- `seen` — remaining `sent` → `delivered`, then `delivered` → `seen` + `seen_at`

Response: `{ "ok": true }`

Ticks: `sent` = 1 tick, `delivered` = 2 ticks, `seen` = 2 blue ticks.

## Realtime (listen only)

Create the client with config `supabase.url` + `supabase.anon_key`. Subscribe to `public.messages` for this conversation:

```dart
supabase
  .channel('chat-messages-$conversationId')
  .onPostgresChanges(
    event: PostgresChangeEvent.insert,
    schema: 'public',
    table: 'messages',
    filter: PostgresChangeFilter(
      type: PostgresChangeFilterType.eq,
      column: 'conversation_id',
      value: conversationId,
    ),
    callback: (payload) { /* append bubble */ },
  )
  .onPostgresChanges(
    event: PostgresChangeEvent.update,
    schema: 'public',
    table: 'messages',
    filter: PostgresChangeFilter(
      type: PostgresChangeFilterType.eq,
      column: 'conversation_id',
      value: conversationId,
    ),
    callback: (payload) { /* receipts or delete */ },
  )
  .subscribe();
```

Realtime payloads are **raw table rows**:

- Use `body` (not `message`)
- `sender_type`: `user` | `chat_manager`
- `message_type`: `text` | `image` | `audio` | `video`
- `status`: `sent` | `delivered` | `seen`
- IDs are integers
- No `media_url` — build it:

```
{supabase.url}/storage/v1/object/public/{storage_bucket}/{media_path}
```

If `deleted_at` is set on UPDATE, remove that bubble. Chat managers can delete messages (including the player’s). History GET already hides them.

Anon key is **read/listen**. Do not use it to INSERT/UPDATE `messages` or `conversations`, and do not use a service role key in the app.

## Errors

All `/api/*` errors are a single field:

```json
{ "message": "Conversation not found." }
```

No `exception`, `file`, or `trace`.

| Status | Typical `message` |
| --- | --- |
| 401 | `Invalid secret key.` |
| 404 | `Invalid domain.` / `Conversation not found.` / `Not found.` |
| 422 | First validation error, e.g. `The user id field is required.` |
| 429 | Too many requests |

Unknown conversation id → `Not found.`  
Known id but wrong `user_id` / domain → `Conversation not found.`

## Do not

- Insert or update `conversations` / `messages` from Flutter
- Use UUID conversation/message ids (old schema)
- Send `{ "message": "..." }` — the field is `body`
- Set `sender_id` to `admin` — use the player `user_id`; server sets `sender_type=user`
- Ship `SUPABASE_SERVICE_ROLE_KEY` in the app
- Call chat APIs for an inactive domain (Chat Manager will 404)
