> ## 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.

# Classroom Chatrooms

> Task 6: a live group chat per classroom (Laravel Reverb + Flutter), what shipped, how it was verified, and the server steps left.

**Code DONE ✅ (20/10/2026):** backend, Flutter web + phones and API docs. Mirror of `LMS_Project/CHATROOMS.md`; the endpoint reference is [Chatrooms API Endpoints](https://theteam-13fc7f49.mintlify.site/api-reference/chatrooms.md).

**Server setup still to do (section 7).** Until Laravel Reverb runs on a server, the chat works by polling every 5 seconds.

| | |
| :- | :- |
| Repos touched | `LMS_Backend`, `LMS_Frontend`, `LMS_Documentation` |
| Key | the **classroom slug**: a chatroom has the slug of the classroom it was made for, but its own tables |
| New packages | Backend: `laravel/reverb` `^1.12` (+ `pusher/pusher-php-server`, Ratchet / ReactPHP helpers). Flutter: **none** (`web_socket_channel`, by the Dart team, was already in the lock file and became a direct dependency) |
| Redis / queue | **Not needed**: Reverb uses Redis only with `REVERB_SCALING_ENABLED=true`; the events are sent during the request, so `QUEUE_CONNECTION=sync` stays |
| Deployment guide | [Chatrooms Setup Guide](https://theteam-13fc7f49.mintlify.site/deployment/chatrooms-setup.md) (every command; same steps as `LMS_Backend/CHATROOMS.md`) |

***

## 0. What you get

| Who | Where | What |
| :- | :- | :- |
| Admin / instructor / content manager | Create classroom, **step 2 "Chatroom"** | Step 1 "Save & next" creates the classroom; step 2 shows the slug, the instructor's e-mail and the learners' e-mails, and **Create chatroom** creates it (`POST /chatrooms`). Step 3 is the HTML content. Skipping step 2 is allowed; Edit classroom offers it again. |
| Admin | Side menu **Chatrooms** → `/admin-chatrooms` | Every chatroom of the company (member or not), with learners, messages, last activity, status. Row actions: View conversation, let / stop learners writing, wipe history. |
| Admin | `/admin-chatrooms/admin-chatroom/:slug` | The whole history + composer + moderation bar, and a details panel (slug, counts, dates, members with e-mails). |
| Admin | Table row menu + details panel → **Back up to the cloud (CSV)** | The whole conversation as a CSV on R2: `files/backups/conversations/{slug}/{adminId}_{Ymd-His}_{random}.csv` since 01/11/2026 (before: `files/conversations/{slug}-{Ymd-His}-{random}.csv`; one file per backup, recorded in `files`), then a dialog with the path and an "Open the file" link. |
| Instructor | Side menu **Chatrooms** → `/instructor-chatrooms` (+ `/instructor-chatroom/:slug`) | Same pages, scoped to their own chatrooms. |
| Student | My Content → Classrooms (`/student-my-content/classrooms`) | Two **centered tabs**: **Chatrooms** (default) and Classrooms (the cards). Web: list beside the conversation (newest opens by itself). Phone: list, then the chat full screen (`/student-my-content/student-chatroom/:slug`). |
| Everyone in the classroom | Classroom page → Chat room tab | The same chatroom (it was UI only before). Staff see **Create chatroom** there when an older classroom has none. |

Chat features: paperclip for **images only** (JPG, PNG, GIF, WEBP, 20 MB each, 4 per message) with a
callout under the composer, **clickable links** (http/https/www, opened in a new tab), day separators,
grouped bubbles with name + role badge (Instructor / Admin), older pages loaded on scroll, full-size
image viewer. Moderators (instructor + admins): **Learners can write** switch (off = only staff can
post, the learner composer becomes a notice, live), **Wipe history** (confirmation dialog, deletes
messages and images for everyone). A staff message sends each learner an **in-app** notification
"New message in chatroom X" (never e-mail; at most one unread per learner at a time).

***

## 1. Decisions taken (and why)

1. **Separate entity, shared slug.** `ClassroomController` is untouched; the apps call
   `POST /chatrooms` after the classroom exists. Members are sent **by e-mail** (unique in the LMS) and
   stored as user ids. A chatroom can later get its own UI without a classroom. *Since 20/10/2026
   (Update 131)* the slug itself is **generated by the backend** from the classroom title on creation
   (like training and quiz slugs) and **editable on the classroom's edit page**; the save then renames
   the chatroom too (`PUT /chatrooms/{old slug}` with `slug`), and an open conversation follows the
   rename live.
2. **Reverb + Pusher protocol, no Flutter SDK.** Reverb 1.12 supports Laravel `^10.47` (we are on
   10.49), so no framework upgrade. The Flutter side is a \~300-line Pusher-protocol client
   (`lib/core/services/realtime/realtime_service.dart`) over `web_socket_channel`, the same code on
   web, Android and iOS (asked and chosen on 20/10/2026).
3. **Polling fallback.** When `GET /api/realtime/config` says `enabled: false` (driver `log`), or the
   socket is down, an open conversation polls `GET /chatrooms/{slug}/messages?after=` every 5 s. After
   every (re)subscription it catches up, so a reconnect never loses messages.
4. **Uploads before sending.** `POST /chatrooms/{slug}/attachments` takes one image (≤ 20 MB per
   request, so the server limit stays per file); the message then carries `attachment_ids`. Only the
   sender's own, unsent uploads of the same chatroom are accepted (the ownership gap of the profElite
   doc is closed). Unsent uploads are deleted daily (`chatrooms:prune-attachments`).
5. **Notification collapse.** `chatroom_members.last_notification_id`: while that notification is
   unread, new staff messages add nothing, so 10 quick messages = 1 notification.
6. **Stepper order** Basic info → Chatroom → Content (chosen on 20/10/2026). On Edit, Next walks the steps
   and the final Save also syncs the chatroom's title and members (`PUT /chatrooms/{slug}`).
7. Content managers may create / sync a chatroom (they create classrooms) and see its details, but
   cannot read the messages or moderate.
8. **Admin backups (added 20/10/2026, after the first delivery).** CSV, UTF-8 with a BOM (Excel shows
   Arabic), columns `message_id, sent_at, sender_id, sender_name, sender_email, sender_role, content,
   attachments`; cells starting with `= + - @` get a `'` (no spreadsheet formula injection). The name
   is `{slug}-{Ymd-His}-{random 8}.csv` instead of the asked `{slug}-{timestamp}.csv`: a slug + time alone would be
   easy to guess, and the file holds private messages and e-mails. To drop the suffix, edit `ChatroomService::backup()`.

***

## 2. Data model (`2026_10_20_000000_create_chatrooms_tables`)

| Table | Columns |
| :- | :- |
| `chatrooms` | `company_id`, `slug` (unique, = classroom slug), `title`, `instructor_id`, `created_by`, `learners_can_post` (default true), `last_message_at` (indexed list sort key), timestamps |
| `chatroom_members` | `chatroom_id`, `user_id` (unique pair), `role` (`instructor` / `learner`), `last_notification_id` |
| `chatroom_messages` | `chatroom_id`, `sender_id` (null on user delete), `content` (≤ 5 000 chars, nullable), timestamps; index `(chatroom_id, id)` for keyset pages |
| `chatroom_attachments` | `chatroom_id`, `message_id` (null until sent), `uploader_id`, `disk` (`r2`), `path` (`files/all-chatrooms/{slug}/…` since 01/11/2026, `chatrooms/{slug}/…` before), `url`, `filename`, `mime_type`, `size_bytes`, `width`, `height` |

```mermaid theme={null}
erDiagram
    companies ||--o{ chatrooms : "owns"
    classrooms |o..o| chatrooms : "same slug, no FK"
    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, null until sent"

    chatrooms {
        bigint id PK
        varchar slug UK
        bigint company_id FK
        bigint instructor_id FK
        boolean learners_can_post
        timestamp last_message_at
    }
    chatroom_members {
        bigint chatroom_id FK
        bigint user_id FK
        varchar role "instructor or learner"
    }
    chatroom_messages {
        bigint id PK
        bigint chatroom_id FK
        bigint sender_id FK
        text content
    }
    chatroom_attachments {
        bigint id PK
        bigint message_id FK
        varchar path "files/all-chatrooms/slug/file on R2"
    }
```

Every column, the neighbour tables and an image's life cycle: [Chatrooms API §2.4](https://theteam-13fc7f49.mintlify.site/api-reference/chatrooms.md#2-4-database-tables).

Roles in a chatroom: **instructor** (`chatrooms.instructor_id`), **learner** (member row),
**admin** (company admin or super-admin, not a member). Instructor + admins post at any time and
moderate; learners post while `learners_can_post`.

***

## 3. API (all in the auth + not-blocked + company-policy group)

| Method | Path | Who |
| :- | :- | :- |
| GET | `/api/realtime/config` | anyone signed in |
| GET | `/api/chatrooms` | scoped: super-admin all, admin company, others their own |
| POST | `/api/chatrooms` | super-admin, admin, content manager, instructor (only as its own instructor) |
| GET | `/api/chatrooms/{slug}` | members, company admins, content managers (404 = none yet) |
| PUT | `/api/chatrooms/{slug}` | instructor (learners only), admins, content managers |
| GET | `/api/chatrooms/{slug}/messages` (`before`, `after`, `limit`) | members + admins |
| POST | `/api/chatrooms/{slug}/messages` (`content`, `attachment_ids`) | who may post (403 when closed to learners) |
| DELETE | `/api/chatrooms/{slug}/messages` | instructor + admins (wipe) |
| POST | `/api/chatrooms/{slug}/attachments` (multipart `image`) | who may post |
| POST | `/api/chatrooms/{slug}/learners-can-post` (`learners_can_post`) | instructor + admins |
| POST | `/api/chatrooms/{slug}/backup` | **admins only** (company admin, super-admin): CSV of the history to R2 `files/backups/conversations/{slug}/` (`files/conversations/` before 01/11/2026) |
| POST | `/api/broadcasting/auth` | signs `private-chatroom.{id}` for members + admins (Sanctum token) |

Events on `private-chatroom.{id}`: `message.sent` (`{message}`, or `{message_id}` when over 8 KB),
`chatroom.updated` (`{chatroom: {id, title, learners_can_post}}`), `chatroom.cleared`.
Full request / response examples: `LMS_Documentation/api-reference/chatrooms.mdx`.

***

## 4. Files

**Backend:** `app/Http/Controllers/ChatroomController.php`, `app/Services/ChatroomService.php`,
`app/Models/Chatroom*.php` (4), `app/Events/Chatroom{MessageSent,Updated,Cleared}.php`,
`app/Console/Commands/PruneChatroomAttachments.php` (+ daily in `app/Console/Kernel.php`),
`routes/api.php`, `routes/channels.php`, `app/Providers/BroadcastServiceProvider.php` (enabled in
`config/app.php`, routes under `/api` with Sanctum), `config/broadcasting.php` (`reverb` connection,
5 s / 3 s timeouts, `client` block), `config/reverb.php` (published, `max_request_size` 100 000),
`.env.example`, `resources/lang/{en,fr,ar}/messages.php`, `tests/Feature/ChatroomTest.php` (20 tests),
`composer.json` / `composer.lock`.

**Flutter:** `lib/core/services/realtime/realtime_service.dart`, `lib/core/data/remote/chatroom.dart`,
`lib/core/data/response_type/chatrooms_response.dart`, `lib/core/providers/chatroom_provider.dart`,
`lib/core/providers/chatroom_conversation_controller.dart`, `lib/ui/widgets/chatroom/*`
(conversation, links, actions), `lib/ui/pages/admin_pages/chatrooms/*` (table + conversation page,
shared by the instructor), `classroom_chatroom_step.dart`, `classroom_chatroom_create_button.dart`,
`lib/ui/pages/student_pages/classroom/student_{classrooms_hub,chatrooms_view,chatroom_mobile_screen}.dart`,
routes / shells / side menus, `assets/icons/appbar_icons/chat.svg`, 59 keys × 3 languages,
`test/chatroom_conversation_test.dart` (6) + `test/chatroom_realtime_test.dart` (8) +
`test/chatroom_backup_test.dart` (3). The UI-only
`classroom_chat_room.dart` and its test are gone. Details: `LMS_Frontend/major_code_updates.md`
Updates 129 and 130 (backup).

***

## 5. How it was verified (20/10/2026)

* Backend: `php artisan test` on SQLite, **147 passed** (20 new, the backup ones included). Pint on the new files.
* Flutter: `flutter analyze`, no new issue; `flutter test`, **864 passed**. The only failure is
  `test/widget_test.dart`, commented out since the initial commit.
* **End to end on a real Reverb server** (local, SQLite backend): a learner subscribed with the Flutter
  `RealtimeService`; the instructor's message reached the socket **26 ms** after the POST, the learner
  got the in-app notification, the sender's own socket was skipped (`X-Socket-ID`), closing the chat
  arrived live and made the learner's POST a 403, an outsider's channel auth was a 403, the wipe arrived
  live.
* Screens rendered to PNG from widget tests (student web + phone, admin table + conversation, stepper
  step 2); a centered moderation bar and a status pill overflow were fixed from them.
* **Not tried yet:** the real web app / phones against a deployed backend (needs §6 and §7).

***

## 6. Deploying the code

```bash theme={null}
# Backend (each server)
composer install --no-dev --optimize-autoloader     # brings laravel/reverb
php artisan migrate --force                         # 2026_10_20_000000_create_chatrooms_tables (new tables only)
php artisan optimize:clear && php artisan optimize

# Flutter (already done locally; on a fresh checkout)
flutter pub get
dart run easy_localization:generate -S "assets/translations" -O "lib/core/translations"
dart run easy_localization:generate -S "assets/translations" -O "lib/core/translations" -o "local_keys.g.dart" -f keys
dart run build_runner build -d
```

The migration only creates tables: nothing existing changes, rollback = `php artisan migrate:rollback --step=1`.
Classrooms created before today have no chatroom: open the classroom (Chat room tab → **Create
chatroom**) or Edit classroom → step 2.

***

## 7. Server setup for real time (Plesk) — TO DO

Without this the chat still works (5-second polling). With it, messages are instant.
**The step-by-step guide with every command is `LMS_Backend/CHATROOMS.md`** (env block, `migrate`,
caches, supervisor / systemd, nginx, PHP limits, cron, checks, troubleshooting). Summary:

1. **Keys** in the backend `.env` (one Reverb app per environment; the key is public, the secret is not):
   ```env theme={null}
   BROADCAST_DRIVER=reverb
   REVERB_APP_ID=<6 digits>
   REVERB_APP_KEY=<20 random lowercase chars>
   REVERB_APP_SECRET=<20 random lowercase chars>
   REVERB_SERVER_HOST=127.0.0.1
   REVERB_SERVER_PORT=8080
   # where Laravel posts events: the local Reverb process
   REVERB_HOST=127.0.0.1
   REVERB_PORT=8080
   REVERB_SCHEME=http
   # what the apps open (GET /api/realtime/config)
   REVERB_CLIENT_HOST=backend-test.theteam.com.tn
   REVERB_CLIENT_PORT=443
   REVERB_CLIENT_SCHEME=https
   ```
   Then `php artisan config:clear` (or `optimize`).
2. **Keep Reverb running**: a supervisor program (or systemd unit) running
   `php artisan reverb:start` in the backend folder as the vhost's user, `autorestart=true`. After each
   deploy: `php artisan reverb:restart`. Plesk's "Scheduled tasks" can't keep a process alive, so this
   needs SSH / root (`apt install supervisor`).
3. **nginx proxy** (Plesk → the backend domain → Apache & nginx Settings → Additional nginx
   directives), so `wss://` goes through the existing certificate. That box is one field for the
   whole vhost: it also holds the upload limit of point 4 and the `/storage/` CORS block of the
   Flutter web app. The two `location` blocks do not conflict. The full box reads:
   ```nginx theme={null}
   # Chat images up to 20 MB (PHP: upload_max_filesize = 25M, post_max_size = 25M)
   client_max_body_size 25m;

   # Local-storage images readable by the Flutter web app (CanvasKit needs CORS on them)
   location ^~ /storage/ {
       add_header Access-Control-Allow-Origin "*" always;
       try_files $uri =404;
   }

   # Chatrooms: wss:// on the site's certificate, forwarded to the local Reverb process
   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;
   }
   ```
   `^~` stops Plesk's pattern-based locations (such as "Serve static files directly by nginx") from
   taking the request first. `X-Forwarded-For` passes the browser's address on, and
   `proxy_send_timeout` gives sends the same 120 s as reads. `8080` is `REVERB_SERVER_PORT`.
   (`/apps/` only matters when Laravel posts through the public host; with `REVERB_HOST=127.0.0.1` it
   doesn't.) Check from any machine:
   ```bash theme={null}
   curl -s -i -N --http1.1 --max-time 5 \
     -H "Connection: Upgrade" -H "Upgrade: websocket" \
     -H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: SGVsbG8sIHdvcmxkIQ==" \
     "https://backend-test.theteam.com.tn/app/<REVERB_APP_KEY>?protocol=7"
   # 101 Switching Protocols + pusher:connection_established = works
   # Laravel's 404 page = the /app/ block is missing · 502 Bad Gateway = Reverb is not running
   ```
4. **Uploads up to 20 MB**: PHP `upload_max_filesize = 25M`, `post_max_size = 25M` (Plesk → PHP
   settings) and nginx `client_max_body_size 25m;`.
5. **Scheduler** (for `chatrooms:prune-attachments` and the existing `notifications:delete-expired`):
   a cron `* * * * * cd <backend> && php artisan schedule:run >> /dev/null 2>&1`.
6. **Check**: `curl -H "Authorization: Bearer <token>" https://backend-test.theteam.com.tn/api/realtime/config`
   answers `enabled: true` with the public host; open a chatroom on two browsers and send a message.

***

## 8. Known limits / later

* A member removed from a chatroom keeps their open socket until they reconnect (REST already answers
  403\). Fix later with a `member.removed` event or by closing their Reverb connection.
* The notification bell isn't pushed live (it refreshes as before). The socket exists now, so a
  `private-App.Models.User.{id}` event could update the badge later.
* No read receipts / unread counters / typing indicator (not asked).
* Archiving a classroom keeps its chatroom (separate entity). A **permanent** delete of the
  classroom deletes its chatroom too since 20/10/2026; there is still no stand-alone chatroom
  delete endpoint.


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