> ## Documentation Index
> Fetch the complete documentation index at: https://theteam-13fc7f49.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Chatrooms API Endpoints

> A classroom's group chat: create it by e-mail, list and read conversations, send text and images, moderate, and receive messages live over Laravel Reverb.

##### *General - Chatrooms API Documentation and Postman Guide.*

This page covers the endpoints of `ChatroomController` (rules and JSON built by `app/Services/ChatroomService.php`). A **chatroom** is the group chat of a classroom: it has the classroom's **slug**, but it is its own entity with its own tables, so `ClassroomController` never creates or edits it (it only deletes it along with a permanently deleted classroom, section 5). The apps create it right after the classroom (classroom stepper, step 2).

All endpoints run behind `auth:sanctum`, the "user is not blocked" check, and the per-company policy middleware. Real-time events go through **Laravel Reverb** (Pusher protocol); when no WebSocket server is configured the apps poll the messages endpoint instead.

***

## Table of Contents

1. **Base URL & Headers**
2. **Who Can Do What**
   * **2.1 Roles in a Chatroom**
   * **2.2 The Chatroom Object**
   * **2.3 The Message Object**
   * **2.4 Database Tables**
3. **Endpoints**
   * **3.1 List Chatrooms**
   * **3.2 Create a Chatroom**
   * **3.3 Show a Chatroom**
   * **3.4 Update a Chatroom (title, members, slug)**
   * **3.5 List Messages**
   * **3.6 Upload an Image**
   * **3.7 Send a Message**
   * **3.8 Open / Close the Chat to Learners**
   * **3.9 Wipe the History**
   * **3.10 Real-time Configuration**
   * **3.11 Back Up a Conversation to R2 (admins)**
   * **3.12 Delete a Chatroom (admins)**
4. **Real Time (Laravel Reverb)**
   * **4.1 Channel Authorization**
   * **4.2 Events**
   * **4.3 Server Setup**
5. **Notifications and Clean-up**
6. **Postman Setup**

***

## 1. Base URL & Headers

```text theme={null}
Base URL: {{base_url}}/api
```

```text theme={null}
Accept: application/json
Authorization: Bearer {{token}}
Content-Type: application/json
```

`{slug}` is the classroom slug. A slug may contain `(`, `)`, `[`, `]`, `,`, `:` or `=`: URL-encode it in the path (`chatrooms/cloud%5B2026%5D`).

***

## 2. Who Can Do What

### 2.1 Roles in a Chatroom

| Role (`viewer.role`) | Who | Read | Post | Moderate (open / close, wipe) |
| :- | :- | :- | :- | :- |
| `instructor` | `chatrooms.instructor_id` | ✅ | ✅ always | ✅ |
| `admin` | An admin of the chatroom's company, or a super-admin (not a member) | ✅ every chatroom of the company | ✅ always | ✅ (+ CSV backup 3.11, delete 3.12) |
| `learner` | A learner member | ✅ | ✅ while `learners_can_post` is `true` | ❌ |
| `null` | A content manager of the company | Details only (3.3), no messages | ❌ | ❌ |

Anyone else gets `403`. Creating a chatroom (3.2) is open to super-admins, admins, content managers and instructors (an instructor only for a chatroom they lead). Members always belong to the chatroom's company.

### 2.2 The Chatroom Object

| Field | Type | Description |
| :- | :- | :- |
| `id` | integer | Chatroom id (used in the WebSocket channel name). |
| `slug` | string | The classroom slug. |
| `title` | string | The classroom title when it was created or last synced. |
| `company_id` | integer | Tenant. |
| `instructor_id` | integer or null | The instructor. |
| `learners_can_post` | boolean | `false` = only the instructor and the admins can post. |
| `instructor` | object or null | The [classroom person object](https://theteam-13fc7f49.mintlify.site/api-reference/classrooms.md#2-2-the-person-object); `email` for staff callers only. |
| `learners_count` | integer | Learner members. |
| `messages_count` | integer | Messages in the history. |
| `last_message_at` | string or null | Date of the newest message (`Y-m-d H:i:s`). Lists sort on it, newest first. |
| `last_message` | object or null | `id`, `sender_id`, `sender_name`, `content` (140 characters at most, `null` for an image-only message), `attachments_count`, `created_at`. |
| `created_at` / `updated_at` | string | `Y-m-d H:i:s`. |
| `viewer` | object | The caller's `role`, `can_post`, `can_moderate`. |
| `members` | array | **3.3 only.** Person objects with a `role` (`instructor` first, then learners by name). |

### 2.3 The Message Object

| Field | Type | Description |
| :- | :- | :- |
| `id` | integer | Message id (keyset pagination). |
| `chatroom_id` | integer | |
| `content` | string or null | Up to 5 000 characters; `null` for an image-only message. |
| `sender` | object or null | `id`, `first_name`, `last_name`, `fullname`, `image` (`id`, `url`) and `role` (`instructor`, `learner`, `admin`, or `null` for someone who left). Never the e-mail. `null` when the account was deleted. |
| `attachments` | array | Images: `id`, `url`, `filename`, `mime_type`, `size_bytes`, `width`, `height`. |
| `created_at` | string | `Y-m-d H:i:s` (Africa/Tunis). |

### 2.4 Database Tables

Four tables, created by the migration `2026_10_20_000000_create_chatrooms_tables`. Solid lines are foreign keys; dotted lines are links the code keeps without a foreign key.

```mermaid theme={null}
erDiagram
    companies ||--o{ chatrooms : "owns"
    users |o--o{ chatrooms : "instructor_id, created_by"
    chatrooms ||--o{ chatroom_members : "members"
    users ||--o{ chatroom_members : "user_id"
    chatrooms ||--o{ chatroom_messages : "history"
    users |o--o{ chatroom_messages : "sender_id"
    chatrooms ||--o{ chatroom_attachments : "images"
    chatroom_messages |o--o{ chatroom_attachments : "message_id"
    users |o--o{ chatroom_attachments : "uploader_id"
    classrooms |o..o| chatrooms : "same slug, no FK"
    notifications |o..o{ chatroom_members : "last_notification_id, no FK"

    chatrooms {
        bigint id PK
        bigint company_id FK "cascade on delete"
        varchar slug UK "the classroom slug"
        varchar title
        bigint instructor_id FK "nullable, set null on delete"
        bigint created_by FK "nullable, set null on delete"
        boolean learners_can_post "default true"
        timestamp last_message_at "nullable, indexed, list sort key"
        timestamp created_at
        timestamp updated_at
    }

    chatroom_members {
        bigint id PK
        bigint chatroom_id FK "unique with user_id, cascade"
        bigint user_id FK "indexed, cascade"
        varchar role "instructor or learner"
        bigint last_notification_id "nullable, latest chat notification"
        timestamp created_at
        timestamp updated_at
    }

    chatroom_messages {
        bigint id PK "keyset pages, index chatroom_id + id"
        bigint chatroom_id FK "cascade"
        bigint sender_id FK "nullable, set null on delete"
        text content "nullable, 5000 chars max"
        timestamp created_at
        timestamp updated_at
    }

    chatroom_attachments {
        bigint id PK
        bigint chatroom_id FK "cascade"
        bigint message_id FK "null until the message is sent"
        bigint uploader_id FK "nullable, set null on delete"
        varchar disk "r2"
        varchar path "files/all-chatrooms/slug/file"
        text url "public R2 address"
        varchar filename "the uploader file name"
        varchar mime_type "jpeg, png, gif or webp"
        bigint size_bytes "20 MB max"
        int width
        int height
        timestamp created_at
        timestamp updated_at
    }

    classrooms {
        bigint id PK
        bigint company_id FK
        varchar classroom_slug UK
        varchar classroom_title
        bigint instructor_id FK
    }

    users {
        bigint id PK
        bigint company_id FK
        bigint role_id FK
        text email "members are matched on it"
    }

    companies {
        bigint id PK
    }

    notifications {
        bigint id PK
        bigint student_id
        boolean notif_read_status
    }
```

What each link means in practice:

* **`chatrooms.slug` = `classrooms.classroom_slug`.** Both columns are unique on their own and there is no foreign key between them: the chatroom is a separate entity. The apps pass the classroom's slug on create and send the new one after a classroom rename (3.4). A generated classroom slug skips the slugs already held by a chatroom, and permanently deleting a classroom deletes its chatroom. An admin can also delete a chatroom on its own (3.12): the classroom stays, and its slug is free for a new chatroom.
* **Members.** One `chatroom_members` row per person, `role` = `instructor` or `learner`; `chatrooms.instructor_id` always names the same user as the `instructor` row. Company admins and super-admins read every chatroom of their company without a row.
* **Deleting a user** keeps their messages (`sender_id` becomes `null`, shown as a deleted account) and removes their member rows.
* **`last_notification_id`** points at the member's latest unread "new message" notification, so a burst of messages makes one notification (section 5).
* **Admin CSV backups** (3.11) are recorded in the `files` table like any other R2 file, without a link to the chatroom, so they stay when the chatroom is deleted (3.12).

An image's rows over its life:

```mermaid theme={null}
flowchart LR
    A["POST .../attachments<br/>one image, 20 MB max"] --> B["chatroom_attachments row<br/>message_id = null<br/>file on R2 files/all-chatrooms/slug/"]
    B -->|"sent with attachment_ids (3.7)"| C["message_id = the new message<br/>shown in the history"]
    B -->|"never sent, after 24 h"| D["chatrooms:prune-attachments<br/>row and file deleted"]
    C -->|"wipe (3.9), chatroom deleted (3.12)<br/>or classroom deleted"| E["rows and R2 files deleted"]
```

***

## 3. Endpoints

## 3.1 List Chatrooms

`GET /chatrooms`

The admin table (`/admin-chatrooms`), the instructor table (`/instructor-chatrooms`) and the student's Chatrooms tab. Scoped by role: a super-admin sees every chatroom (optionally one company), an admin every chatroom of their company (member or not), anyone else the chatrooms they lead or belong to.

**Query parameters**

| Name | Type | Required | Description |
| :- | :- | :- | :- |
| `search` | string | No | Title, slug, or the instructor's first or last name (and e-mail for staff). |
| `sort_by` | string | No | `last_message_at` (default), `created_at`, `title`, `messages_count`, `learners_count`. Chatrooms without messages come last when sorting on `last_message_at`. |
| `sort_order` | string | No | `asc` or `desc` (default). |
| `page` | integer | No | Default `1`. |
| `per_page` | integer | No | 1–100, default `10`. |
| `company_id` | integer | No | Super-admins only: one company. |

**Example request**

```text theme={null}
GET {{base_url}}/api/chatrooms?page=1&per_page=10
Accept: application/json
Authorization: Bearer {{token}}
```

**Responses**

> [tab] 200
>
> ✅ 200 OK (admin caller)
>
> ```json theme={null}
> {
>   "current_page": 1,
>   "data": [
>     {
>       "id": 12,
>       "slug": "cloud-101",
>       "title": "Cloud 101",
>       "company_id": 1,
>       "instructor_id": 40,
>       "learners_can_post": true,
>       "instructor": {
>         "id": 40,
>         "first_name": "Ines",
>         "last_name": "Trabelsi",
>         "fullname": "Ines Trabelsi",
>         "image_id": null,
>         "image": null,
>         "created_at": "2026-09-01 10:12:00",
>         "email": "ines.trabelsi@example.com"
>       },
>       "learners_count": 18,
>       "messages_count": 6,
>       "last_message_at": "2026-10-20 09:41:05",
>       "last_message": {
>         "id": 311,
>         "sender_id": 40,
>         "sender_name": "Ines Trabelsi",
>         "content": "Slides are on https://example.com/cloud101",
>         "attachments_count": 0,
>         "created_at": "2026-10-20 09:41:05"
>       },
>       "created_at": "2026-10-08 14:00:00",
>       "updated_at": "2026-10-20 09:41:05",
>       "viewer": { "role": "admin", "can_post": true, "can_moderate": true }
>     }
>   ],
>   "first_page_url": "{{base_url}}/api/chatrooms?page=1",
>   "from": 1,
>   "last_page": 1,
>   "last_page_url": "{{base_url}}/api/chatrooms?page=1",
>   "links": [],
>   "next_page_url": null,
>   "path": "{{base_url}}/api/chatrooms",
>   "per_page": 10,
>   "prev_page_url": null,
>   "to": 1,
>   "total": 1
> }
> ```

> [tab] 401
>
> ⚠️ 401 Unauthenticated
>
> ```json theme={null}
> { "message": "Unauthenticated." }
> ```

> [tab] 422
>
> ⚠️ 422 Validation Error
>
> ```json theme={null}
> {
>   "message": "The selected sort by is invalid.",
>   "errors": { "sort_by": ["The selected sort by is invalid."] }
> }
> ```

***

## 3.2 Create a Chatroom

`POST /chatrooms`

Called by the classroom stepper (step 2, "Create chatroom") once the classroom exists. Members are given **by e-mail** and resolved to users of the caller's company (super-admins may pass `company_id`). E-mails are trimmed and lower-cased, duplicates are ignored, and the instructor's e-mail in `student_emails` is dropped.

**Body parameters**

| Name | Type | Required | Description |
| :- | :- | :- | :- |
| `slug` | string | No | The classroom slug (the apps always send it, as the backend generated it on `POST /classroom/create`). Lowercased; same rule as the classroom slug (`a-z`, `0-9`, `( ) - , . _ = [ ] :`). **Already held by a chatroom of your company that you may edit:** that chatroom is adopted (see the `200` response). **Omitted or empty (since 20/10/2026):** generated from `title` (`"Cloud 101"` → `cloud-101`, then `-2`, `-3`… until unique among chatrooms), like training slugs. |
| `title` | string | Yes | 1–255 characters (the classroom title). |
| `instructor_email` | string | Yes | A non-student user of the company. An instructor caller must give their own e-mail. |
| `student_emails` | array of strings | No | Learners' e-mails, users of the company. Unknown e-mails are refused (422). |
| `learners_can_post` | boolean | No | Default `true`. |
| `company_id` | integer | No | Super-admins only. |

**Example request**

```text theme={null}
POST {{base_url}}/api/chatrooms
Accept: application/json
Authorization: Bearer {{token}}
Content-Type: application/json
```

```json theme={null}
{
  "slug": "cloud-101",
  "title": "Cloud 101",
  "instructor_email": "ines.trabelsi@example.com",
  "student_emails": ["sara.gharbi@example.com", "amine.bensalah@example.com"]
}
```

**Responses**

> [tab] 201
>
> ✅ 201 Created: the chatroom as in 3.3 (with `members`).
>
> ```json theme={null}
> {
>   "id": 12,
>   "slug": "cloud-101",
>   "title": "Cloud 101",
>   "company_id": 1,
>   "instructor_id": 40,
>   "learners_can_post": true,
>   "instructor": { "id": 40, "first_name": "Ines", "last_name": "Trabelsi", "fullname": "Ines Trabelsi", "image_id": null, "image": null, "created_at": "2026-09-01 10:12:00", "email": "ines.trabelsi@example.com" },
>   "learners_count": 2,
>   "messages_count": 0,
>   "last_message_at": null,
>   "last_message": null,
>   "created_at": "2026-10-20 09:30:00",
>   "updated_at": "2026-10-20 09:30:00",
>   "viewer": { "role": "admin", "can_post": true, "can_moderate": true },
>   "members": [
>     { "id": 40, "first_name": "Ines", "last_name": "Trabelsi", "fullname": "Ines Trabelsi", "image_id": null, "image": null, "created_at": "2026-09-01 10:12:00", "email": "ines.trabelsi@example.com", "role": "instructor" },
>     { "id": 52, "first_name": "Amine", "last_name": "Ben Salah", "fullname": "Amine Ben Salah", "image_id": null, "image": null, "created_at": "2026-09-03 08:00:00", "email": "amine.bensalah@example.com", "role": "learner" },
>     { "id": 51, "first_name": "Sara", "last_name": "Gharbi", "fullname": "Sara Gharbi", "image_id": null, "image": null, "created_at": "2026-09-03 08:00:00", "email": "sara.gharbi@example.com", "role": "learner" }
>   ]
> }
> ```

> [tab] 200
>
> ✅ 200 OK (since 20/10/2026 PM): the `slug` is already held by a chatroom **of your company that you may edit**. That chatroom *is* the classroom's (left behind by a deleted classroom with the same title, or written by an earlier create whose answer never arrived), so it is **adopted**: it takes the request's `title`, `instructor_email`, `student_emails` and `learners_can_post` (omitted = kept), exactly like `PUT /chatrooms/{slug}`, and is answered with the same body as the `201` plus `adopted: true`. Its messages and images stay (a moderator can wipe them). A `chatroom.updated` event is broadcast.
>
> ```json theme={null}
> {
>   "id": 12,
>   "slug": "cloud-101",
>   "title": "Cloud 101",
>   "adopted": true,
>   "...": "the chatroom as in 3.3 (with `members`)"
> }
> ```

> [tab] 403
>
> ⚠️ 403 Forbidden (a student, or an instructor for someone else)
>
> ```json theme={null}
> { "message": "An instructor can only manage a chatroom they lead." }
> ```

> [tab] 422
>
> ⚠️ 422 Validation Error
>
> ```json theme={null}
> {
>   "message": "These e-mails do not belong to a user of your company: someone@other.example",
>   "errors": { "student_emails": ["These e-mails do not belong to a user of your company: someone@other.example"] }
> }
> ```
>
> Other cases: `slug` invalid, `instructor_email` of a student or of another company (`"The instructor must be a staff member of your company."`), and a `slug` held by a chatroom of **another company**, or by one the caller may not edit (an instructor and a chatroom led by someone else), whose message says what to do:
>
> ```json theme={null}
> {
>   "message": "A chatroom with the slug “cloud-101” already exists in another company, or you may not edit it. Change the classroom slug and try again.",
>   "errors": { "slug": ["A chatroom with the slug “cloud-101” already exists in another company, or you may not edit it. Change the classroom slug and try again."] }
> }
> ```
>
> `chatrooms.slug` is unique across every company, so this is the only case left where the classroom's own slug cannot be used; the apps show the message with "change the classroom slug on step 1" and hide the Create button. A slug held by a chatroom of the caller's own company is adopted instead (the `200` above).

> [tab] 500
>
> ❌ 500 Server Error
>
> ```json theme={null}
> { "message": "Something went wrong with the chatroom. Please try again." }
> ```

***

## 3.3 Show a Chatroom

`GET /chatrooms/{slug}`

The chatroom (2.2) with its `members`. A `404` means the classroom has no chatroom yet; the stepper reads it that way. Members, company admins and content managers may call it.

> [tab] 200
>
> ✅ 200 OK: same body as the 201 of 3.2. A student caller gets no `email` in `instructor` or `members`, and `viewer` `{ "role": "learner", "can_post": true, "can_moderate": false }`.

> [tab] 403
>
> ⚠️ 403 Forbidden
>
> ```json theme={null}
> { "message": "You don't have access to this chatroom." }
> ```

> [tab] 404
>
> ⚠️ 404 Not Found
>
> ```json theme={null}
> { "message": "Chatroom not found." }
> ```

***

## 3.4 Update a Chatroom (title, members, slug)

`PUT /chatrooms/{slug}`

Sent by the apps after a classroom is saved, so the chatroom keeps the classroom's title, members and slug. An omitted key is kept; `student_emails` **replaces** the learners (`[]` removes them all). The chatroom's instructor may change the learners (and the title / slug) but not hand the chatroom to another instructor (`403`); admins and content managers may change everything. A removed member loses access at once (REST `403`).

**Body parameters**

| Name | Type | Required | Description |
| :- | :- | :- | :- |
| `slug` | string | No | **The slug is editable here (since 20/10/2026).** Lowercased, same rule as 3.2, unique among the other chatrooms; sent by the apps with the classroom's new slug after a classroom rename, to `{slug}` = the old one. Cannot be empty when sent. The answer and `chatroom.updated` (4.2) carry the new slug; images already uploaded keep their `chatrooms/{old slug}/` addresses. |
| `title` | string | No | 1–255 characters. |
| `instructor_email` | string | No | Same rule as 3.2. |
| `student_emails` | array of strings | No | Same rule as 3.2; a full replacement. |

```json theme={null}
{
  "slug": "cloud-101-spring",
  "title": "Cloud 101 (spring)",
  "instructor_email": "ines.trabelsi@example.com",
  "student_emails": ["sara.gharbi@example.com"]
}
```

> [tab] 200
>
> ✅ 200 OK: the chatroom as in 3.3. Broadcasts `chatroom.updated` (4.2).

> [tab] 403
>
> ⚠️ 403 Forbidden
>
> ```json theme={null}
> { "message": "An instructor can only manage a chatroom they lead." }
> ```

> [tab] 422
>
> ⚠️ 422 Validation Error: same cases as 3.2, plus a `slug` that is empty, invalid or already used by another chatroom.
>
> ```json theme={null}
> {
>   "message": "The slug has already been taken.",
>   "errors": { "slug": ["The slug has already been taken."] }
> }
> ```

***

## 3.5 List Messages

`GET /chatrooms/{slug}/messages`

Keyset pagination, **oldest first** in the response. With no parameter: the newest page. `before={id}`: the page before that message (scrolling up). `after={id}`: everything after it, oldest first (catching up after a reconnect, or polling when no WebSocket is open; `after=0` = from the start). Members and company admins only.

**Query parameters**

| Name | Type | Required | Description |
| :- | :- | :- | :- |
| `before` | integer | No | Messages with a smaller id. |
| `after` | integer | No | Messages with a bigger id (`before` is then ignored). |
| `limit` | integer | No | 1–100, default `50`. |

**Example request**

```text theme={null}
GET {{base_url}}/api/chatrooms/cloud-101/messages?before=311
Accept: application/json
Authorization: Bearer {{token}}
```

> [tab] 200
>
> ✅ 200 OK
>
> ```json theme={null}
> {
>   "data": [
>     {
>       "id": 309,
>       "chatroom_id": 12,
>       "content": "Here is my diagram for exercise 2",
>       "sender": {
>         "id": 51,
>         "first_name": "Sara",
>         "last_name": "Gharbi",
>         "fullname": "Sara Gharbi",
>         "image": null,
>         "role": "learner"
>       },
>       "attachments": [
>         {
>           "id": 77,
>           "url": "https://pub-xxxx.r2.dev/chatrooms/cloud-101/51_1760950000_k3j9x0a1bc.jpg",
>           "filename": "diagram.jpg",
>           "mime_type": "image/jpeg",
>           "size_bytes": 384512,
>           "width": 1600,
>           "height": 1000
>         }
>       ],
>       "created_at": "2026-10-20 09:12:44"
>     },
>     {
>       "id": 310,
>       "chatroom_id": 12,
>       "content": "Great work Sara",
>       "sender": { "id": 40, "first_name": "Ines", "last_name": "Trabelsi", "fullname": "Ines Trabelsi", "image": null, "role": "instructor" },
>       "attachments": [],
>       "created_at": "2026-10-20 09:20:03"
>     }
>   ],
>   "has_more": true
> }
> ```

> [tab] 403
>
> ⚠️ 403 Forbidden
>
> ```json theme={null}
> { "message": "You don't have access to this chatroom." }
> ```

> [tab] 404
>
> ⚠️ 404 Not Found
>
> ```json theme={null}
> { "message": "Chatroom not found." }
> ```

***

## 3.6 Upload an Image

`POST /chatrooms/{slug}/attachments`

The paperclip: **one** image per request, sent later with a message (3.7). Only **JPG, PNG, GIF and WEBP** up to **20 MB** (checked on the file's content, not its name; no SVG). Stored on Cloudflare R2 under `files/all-chatrooms/{slug}/` (since 01/11/2026, `app/Services/R2StoragePathService.php`; older images stay under `chatrooms/{slug}/`). Requires the right to post (a learner gets `403` while the chat is closed to learners). An upload that is never sent is deleted after a day (section 5).

**Body:** `multipart/form-data` with the field `image`.

```text theme={null}
POST {{base_url}}/api/chatrooms/cloud-101/attachments
Accept: application/json
Authorization: Bearer {{token}}
Content-Type: multipart/form-data

image=@diagram.jpg
```

> [tab] 201
>
> ✅ 201 Created
>
> ```json theme={null}
> {
>   "id": 77,
>   "url": "https://pub-xxxx.r2.dev/chatrooms/cloud-101/51_1760950000_k3j9x0a1bc.jpg",
>   "filename": "diagram.jpg",
>   "mime_type": "image/jpeg",
>   "size_bytes": 384512,
>   "width": 1600,
>   "height": 1000
> }
> ```

> [tab] 403
>
> ⚠️ 403 Forbidden (chat closed to learners)
>
> ```json theme={null}
> { "message": "Only the instructor and the admins can write in this chatroom right now." }
> ```

> [tab] 422
>
> ⚠️ 422 Validation Error
>
> ```json theme={null}
> {
>   "message": "The image field must be a file of type: jpg, jpeg, png, gif, webp.",
>   "errors": { "image": ["The image field must be a file of type: jpg, jpeg, png, gif, webp."] }
> }
> ```
>
> Also when the image is over 20 MB (`max` rule).

> [tab] 500
>
> ❌ 500 Server Error (R2 unreachable)
>
> ```json theme={null}
> { "message": "Unable to upload the file(s). Please contact your platform administrator." }
> ```

> [!NOTE]
>
> The server must accept 20 MB request bodies: PHP `upload_max_filesize` / `post_max_size` and nginx `client_max_body_size` (4.3).

***

## 3.7 Send a Message

`POST /chatrooms/{slug}/messages`

Text and / or up to **4** images uploaded with 3.6. At least one of the two is required. The images must be the sender's own, of this chatroom, and not sent yet. On success the message is broadcast (`message.sent`, 4.2) to everyone else (send the `X-Socket-ID` header so the sender's own socket is skipped), and a message from the instructor or an admin notifies the learners (section 5). A failed broadcast never fails the request.

**Headers:** optional `X-Socket-ID: {{socket_id}}` (from the WebSocket connection).

**Body parameters**

| Name | Type | Required | Description |
| :- | :- | :- | :- |
| `content` | string | Without images | Up to 5 000 characters, trimmed. Links stay plain text (the apps make them clickable). |
| `attachment_ids` | array of integers | Without content | Up to 4 ids from 3.6, distinct. |

```json theme={null}
{ "content": "Slides are on https://example.com/cloud101", "attachment_ids": [77] }
```

> [tab] 201
>
> ✅ 201 Created: the message (2.3).
>
> ```json theme={null}
> {
>   "id": 311,
>   "chatroom_id": 12,
>   "content": "Slides are on https://example.com/cloud101",
>   "sender": { "id": 40, "first_name": "Ines", "last_name": "Trabelsi", "fullname": "Ines Trabelsi", "image": null, "role": "instructor" },
>   "attachments": [
>     { "id": 77, "url": "https://pub-xxxx.r2.dev/chatrooms/cloud-101/40_1760950700_a8b7c6d5e4.png", "filename": "slide-1.png", "mime_type": "image/png", "size_bytes": 201334, "width": 1280, "height": 720 }
>   ],
>   "created_at": "2026-10-20 09:41:05"
> }
> ```

> [tab] 403
>
> ⚠️ 403 Forbidden
>
> ```json theme={null}
> { "message": "Only the instructor and the admins can write in this chatroom right now." }
> ```
>
> Or `"You don't have access to this chatroom."` for a non-member.

> [tab] 422
>
> ⚠️ 422 Validation Error
>
> ```json theme={null}
> {
>   "message": "Write a message or attach an image.",
>   "errors": { "content": ["Write a message or attach an image."] }
> }
> ```
>
> Or, for `attachment_ids`: `"One of the images is not yours, belongs to another chatroom or was already sent."`

***

## 3.8 Open / Close the Chat to Learners

`POST /chatrooms/{slug}/learners-can-post`

Instructor and admins. `false` = only the instructor and the admins can post (learners still read). Broadcast as `chatroom.updated`, so learners' composers lock without a reload.

```json theme={null}
{ "learners_can_post": false }
```

> [tab] 200
>
> ✅ 200 OK: the chatroom as in 3.3, `"learners_can_post": false`.

> [tab] 403
>
> ⚠️ 403 Forbidden (a learner)
>
> ```json theme={null}
> { "message": "You don't have access to this chatroom." }
> ```

> [tab] 422
>
> ⚠️ 422 Validation Error
>
> ```json theme={null}
> {
>   "message": "The learners can post field is required.",
>   "errors": { "learners_can_post": ["The learners can post field is required."] }
> }
> ```

***

## 3.9 Wipe the History

`DELETE /chatrooms/{slug}/messages`

Instructor and admins. Deletes **every message and every image** of the chatroom (the R2 objects too), for everyone. Broadcast as `chatroom.cleared`.

This is a hard delete: there is no archive and no undo. The apps ask for confirmation first.

> [tab] 200
>
> ✅ 200 OK
>
> ```json theme={null}
> { "message": "The chat history was deleted.", "deleted_messages": 42, "deleted_attachments": 3 }
> ```

> [tab] 403
>
> ⚠️ 403 Forbidden
>
> ```json theme={null}
> { "message": "You don't have access to this chatroom." }
> ```

***

## 3.10 Real-time Configuration

`GET /realtime/config`

Where the apps open their WebSocket. `enabled: false` (broadcast driver `log` / `null`, or missing keys) makes them poll 3.5 with `after` every 5 seconds instead. The `key` is the public Reverb app key; the secret is never returned.

> [tab] 200
>
> ✅ 200 OK (Reverb configured)
>
> ```json theme={null}
> {
>   "enabled": true,
>   "driver": "reverb",
>   "key": "a1b2c3d4e5f6g7h8i9j0",
>   "host": "backend-test.example.com",
>   "port": 443,
>   "scheme": "https",
>   "auth_endpoint": "/api/broadcasting/auth"
> }
> ```
>
> Without a WebSocket server: `{ "enabled": false }`.

***

## 3.11 Back Up a Conversation to R2 (admins)

`POST /chatrooms/{slug}/backup`

**Admins of the chatroom's company and super-admins only** (instructors and learners get `403`). Writes the whole history, oldest first, to a CSV file on Cloudflare R2 and records it as a `files` row (type 3, so it shows with the other R2 files). Each call makes a new file; nothing is deleted.

**Where:** `files/backups/conversations/{slug}/{adminId}_{Ymd-His}_{random}.csv`, for example `files/backups/conversations/cloud-101/5_20261020-101500_ab12cd34.csv` (Tunis time; `adminId` = the admin running the backup; before 01/11/2026: `files/conversations/{slug}-{Ymd-His}-{random}.csv`). The 8 random characters keep the file's address from being guessed; share the `url` only with people allowed to read the conversation.

**File:** UTF-8 with a byte-order mark (Excel shows Arabic and accents), comma-separated, RFC 4180 quoting, one row per message:

| Column | Content |
| :- | :- |
| `message_id` | Message id |
| `sent_at` | `Y-m-d H:i:s` |
| `sender_id`, `sender_name`, `sender_email` | Who wrote it (empty when the account was deleted) |
| `sender_role` | `instructor`, `learner`, `admin`, or empty for someone who left |
| `content` | The text (empty for an image-only message) |
| `attachments` | The image URLs, separated by `\|` |

A cell that starts with `=`, `+`, `-` or `@` gets a leading `'`, so a spreadsheet never runs it as a formula.

**Example request**

```text theme={null}
POST {{base_url}}/api/chatrooms/cloud-101/backup
Accept: application/json
Authorization: Bearer {{token}}
```

> [tab] 201
>
> ✅ 201 Created
>
> ```json theme={null}
> {
>   "message": "The conversation was backed up to the cloud.",
>   "file_id": 912,
>   "filename": "5_20261020-101500_ab12cd34.csv",
>   "path": "files/backups/conversations/cloud-101/5_20261020-101500_ab12cd34.csv",
>   "url": "https://pub-xxxx.r2.dev/files/backups/conversations/cloud-101/5_20261020-101500_ab12cd34.csv",
>   "messages_count": 42,
>   "saved_at": "2026-10-20 10:15:00"
> }
> ```

> [tab] 403
>
> ⚠️ 403 Forbidden (not an admin of the chatroom's company)
>
> ```json theme={null}
> { "message": "You don't have access to this chatroom." }
> ```

> [tab] 404
>
> ⚠️ 404 Not Found
>
> ```json theme={null}
> { "message": "Chatroom not found." }
> ```

> [tab] 422
>
> ⚠️ 422 Nothing to back up
>
> ```json theme={null}
> {
>   "message": "There is no message to back up.",
>   "errors": { "chatroom": ["There is no message to back up."] }
> }
> ```

> [tab] 500
>
> ❌ 500 Server Error (R2 unreachable)
>
> ```json theme={null}
> { "message": "Unable to upload the file(s). Please contact your platform administrator." }
> ```

In the apps: **Back up to the cloud (CSV)** in the Chatrooms table's row menu and in the conversation page's details panel (admins only), then a dialog with the path and an "Open the file" link.

***

## 3.12 Delete a Chatroom (admins)

`DELETE /chatrooms/{slug}`

**Admins of the chatroom's company and super-admins only** (since 04/11/2026). The instructor and the learners get `403`: they can wipe the history (3.9), not delete the chatroom. Deletes the chatroom with its members, every message and every image (the R2 objects too), like permanently deleting its classroom does (section 5). Broadcast as `chatroom.deleted` (4.2).

What stays:

* **The classroom.** Its slug is free again, so 3.2 can create a new chatroom for it.
* **The CSV backups** of 3.11 (`files/backups/conversations/{slug}/`): back the conversation up first if you want to keep it.
* **The "new message" notifications** the learners already received.

This is a hard delete: there is no archive and no undo. The apps ask the admin to type "delete" first.

**Example request**

```text theme={null}
DELETE {{base_url}}/api/chatrooms/cloud-101
Accept: application/json
Authorization: Bearer {{token}}
```

> [tab] 200
>
> ✅ 200 OK
>
> ```json theme={null}
> { "message": "The chatroom was deleted." }
> ```

> [tab] 403
>
> ⚠️ 403 Forbidden (not an admin of the chatroom's company)
>
> ```json theme={null}
> { "message": "You don't have access to this chatroom." }
> ```

> [tab] 404
>
> ⚠️ 404 Not Found (no chatroom with that slug, or already deleted)
>
> ```json theme={null}
> { "message": "Chatroom not found." }
> ```

> [tab] 500
>
> ❌ 500 Server Error
>
> ```json theme={null}
> { "message": "Something went wrong with the chatroom. Please try again." }
> ```

In the apps: **Delete chatroom** in the Chatrooms table's row menu and in the conversation page's details panel (admins only). A `404` also takes the row out of the table, since the chatroom is gone either way. A backend older than 04/11/2026 has no such route and answers `405`; the apps then say the server cannot delete chatrooms yet, and nothing is deleted.

***

## 4. Real Time (Laravel Reverb)

The apps connect to `wss://{host}:{port}/app/{key}?protocol=7` (Pusher protocol v7), subscribe to `private-chatroom.{id}` (the chatroom **id**: slugs may hold characters channel names reject), and answer the server's pings. The Flutter client is `lib/core/services/realtime/realtime_service.dart` (`web_socket_channel`, same code on web, Android and iOS); it reconnects with backoff and catches up with 3.5 `after` after every (re)subscription.

### 4.1 Channel Authorization

`POST /broadcasting/auth` (Sanctum bearer token, not-blocked check)

Signs a private-channel subscription for members of the chatroom (instructor, learners) and the company's admins / super-admins.

**Body:** `socket_id` (from `pusher:connection_established`) and `channel_name`.

```json theme={null}
{ "socket_id": "123456.7890123", "channel_name": "private-chatroom.12" }
```

> [tab] 200
>
> ✅ 200 OK
>
> ```json theme={null}
> { "auth": "a1b2c3d4e5f6g7h8i9j0:4f9c2d…" }
> ```

> [tab] 403
>
> ⚠️ 403 Forbidden: the caller is not a member (or admin) of that chatroom, so the socket can't subscribe.

### 4.2 Events

All on `private-chatroom.{id}`; `data` is the JSON below.

| Event | When | `data` |
| :- | :- | :- |
| `message.sent` | 3.7 | `{ "message": <message, 2.3> }`, or `{ "message_id": 311 }` when the message would exceed 8 KB (the apps then fetch it with 3.5 `after`). Not sent to the sender's own socket (`X-Socket-ID`). |
| `chatroom.updated` | 3.4, 3.8 | `{ "chatroom": { "id": 12, "slug": "cloud-101", "title": "Cloud 101", "learners_can_post": false } }` — `slug` is the current slug after 3.4 (an open conversation switches to it when it changed) and `null` after 3.8. |
| `chatroom.cleared` | 3.9 | `{ "chatroom_id": 12 }` |
| `chatroom.deleted` | 3.12 | `{ "chatroom_id": 12 }` — sent after the delete, so only the sockets already subscribed get it (the channel no longer authorizes anyone). An open conversation closes. |

Events are sent at once (`ShouldBroadcastNow`, no queue needed). Laravel reaches Reverb with a 3 s connect / 5 s total timeout; if Reverb is down the request still succeeds and the error is only reported.

### 4.3 Server Setup

The step-by-step guide with every command is [Chatrooms Setup Guide](https://theteam-13fc7f49.mintlify.site/deployment/chatrooms-setup.md) (the backend repository's `CHATROOMS.md` holds the same steps). No Redis and no queue worker are needed: Reverb only uses Redis when several Reverb processes share events (`REVERB_SCALING_ENABLED`), and the chat events are sent during the request. In short:

1. **Environment** (backend `.env`, one Reverb app per environment):

   ```env theme={null}
   BROADCAST_DRIVER=reverb
   REVERB_APP_ID=123456
   REVERB_APP_KEY=a1b2c3d4e5f6g7h8i9j0
   REVERB_APP_SECRET=<secret>
   REVERB_SERVER_HOST=127.0.0.1
   REVERB_SERVER_PORT=8080
   REVERB_HOST=127.0.0.1
   REVERB_PORT=8080
   REVERB_SCHEME=http
   REVERB_CLIENT_HOST=backend-test.example.com
   REVERB_CLIENT_PORT=443
   REVERB_CLIENT_SCHEME=https
   ```

   `REVERB_HOST` / `PORT` / `SCHEME` are where Laravel posts events (the local Reverb process); `REVERB_CLIENT_*` are what 3.10 tells the apps to open (the public host behind nginx). Then `php artisan optimize`.

2. **Keep `php artisan reverb:start` running** under supervisor or systemd, as the site's user, restarted automatically; run `php artisan reverb:restart` after each deploy.

3. **Proxy the WebSocket through nginx** (Plesk: the backend domain → Apache & nginx Settings → Additional nginx directives). The box is one field for the whole vhost, so the `/app/` block goes next to the upload limit of point 4 and the existing `/storage/` CORS block; they do not conflict. The full box:

   ```nginx theme={null}
   client_max_body_size 25m;

   location ^~ /storage/ {
       add_header Access-Control-Allow-Origin "*" always;
       try_files $uri =404;
   }

   location ^~ /app/ {
       proxy_pass http://127.0.0.1:8080;
       proxy_http_version 1.1;
       proxy_set_header Upgrade $http_upgrade;
       proxy_set_header Connection "Upgrade";
       proxy_set_header Host $host;
       proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
       proxy_read_timeout 120s;
       proxy_send_timeout 120s;
   }
   ```

   `^~` keeps Plesk's pattern-based locations from taking the request first. The check (`101 Switching Protocols` = working, Laravel's `404` = block missing, `502` = Reverb not running) is in [Chatrooms server setup](https://theteam-13fc7f49.mintlify.site/deployment/chatrooms-setup.md).

4. **Allow 20 MB uploads**: PHP `upload_max_filesize = 25M`, `post_max_size = 25M`, nginx `client_max_body_size 25m;`.

5. **Run the scheduler** every minute (`php artisan schedule:run`) for the daily clean-up of section 5.

6. Check: 3.10 answers `enabled: true`, and a message sent in one browser shows up at once in another.

***

## 5. Notifications and Clean-up

* **"New message in chatroom X"**: a message from the instructor or an admin creates an **in-app** notification (type 8, Announcement; never an e-mail) for each learner, in the sender's language. A learner whose previous chatroom notification is still unread gets no new one, so a burst of messages is one notification. They show in the usual notification list ([Notifications](https://theteam-13fc7f49.mintlify.site/api-reference/notifications.md)).
* **Unsent images**: `php artisan chatrooms:prune-attachments` (daily, `app/Console/Kernel.php`) deletes uploads that were never sent with a message after 24 hours (`--hours=` to change), file included.
* **Classroom life cycle**: archiving a classroom does not touch its chatroom. Permanently deleting a classroom (`DELETE /classroom/{id}/force_delete`) deletes its chatroom too since 20/10/2026: members, messages and images. The other way round, deleting a chatroom (3.12) never touches its classroom.

***

## 6. Postman Setup

**Environment variables**

| Variable | Example |
| :- | :- |
| `base_url` | `http://127.0.0.1:8020` |
| `token` | A Sanctum token from `POST /api/auth/login` |

**Collection headers**

```text theme={null}
Accept: application/json
Authorization: Bearer {{token}}
```

Bodies are raw JSON, except 3.6 (form-data, `image` as a file).

**Smoke-test flow**

1. As an admin, create a classroom, then its chatroom with two learners' e-mails (3.2): `201`, `learners_count: 2`.
2. As a learner, list (3.1): one chatroom, `viewer.role: "learner"`, no `email` anywhere.
3. As the instructor, send a message (3.7); as the learner, read it (3.5) and see the "New message in chatroom …" notification.
4. As the learner, upload an image (3.6) and send it with a text (3.7).
5. As the instructor, close the chat (3.8 `false`); as the learner, sending answers `403`.
6. As the admin, back the conversation up (3.11): `201`, a `files/backups/conversations/cloud-101/….csv` path; open the `url`.
7. As the admin, wipe the history (3.9): `deleted_messages: 3`; 3.5 answers an empty `data`.
8. As the instructor, delete the chatroom (3.12): `403`. As the admin: `200`; 3.3 now answers `404`, and 3.2 with the same slug creates a new chatroom (`201`).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.