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

# # Plan - Instant Chat (Taken from another laravel application [profelite] )

> [!CAUTION]
>
> **This instant chat plan was taken from another application called "profelite", not from the current Strived LMS: it shows how they built it, so we can take inspiration from it.**\
> \
> The documentation below is unrelated the LMS\_Backend, LMS\_Frontend projects & has nothing to do with the Backend or the database structure of Strived LMS / E-Learning Application; It has nothing to do with the "[https://theteam-13fc7f49.mintlify.site/](https://theteam-13fc7f49.mintlify.site/index.md)" documentation as well; \
> \
> This documentation, System Architecture, Data Model, Eloquent Model,  & models below was taken from another project using the similar backend technologies like Laravel.\
> \
> This Document was taken from the documentation of another project called profElite, this project had some similarity to mine in terms of roles like : admin, instructor and student. the profElite Project had a ReactJs Frontend, so it's different to what I work with in the frontend. \\

# Chat & Messaging System — Full Technical Documentation

## Table of Contents

1. [Overview & Scope](#1-overview--scope)
2. [Technology Stack](#2-technology-stack)
3. [System Architecture](#3-system-architecture)
4. [Data Model](#4-data-model)
   * 4.1 [Entity-Relationship Diagram](#41-entity-relationship-diagram)
   * 4.2 [Migrations (verbatim schema)](#42-migrations-verbatim-schema)
   * 4.3 [Index coverage & the one missing index](#43-index-coverage--the-one-missing-index)
5. [Eloquent Models](#5-eloquent-models)
6. [API Resources (response shapes)](#6-api-resources-response-shapes)
7. [Complete Route Map](#7-complete-route-map)
8. [Controller Reference — Participant Endpoints](#8-controller-reference--participant-endpoints)
9. [Controller Reference — Admin Endpoints](#9-controller-reference--admin-endpoints)
10. [Reusable & Private Helper Functions](#10-reusable--private-helper-functions)
11. [Real-Time Layer — `MessageSent` + Channel Authorization](#11-real-time-layer--messagesent--channel-authorization)
12. [File Sharing — CloudFlare R2 Attachment Pipeline](#12-file-sharing--cloudflare-r2-attachment-pipeline)
13. [The Admin Role — Oversight, Exports & History Wipe](#13-the-admin-role--oversight-exports--history-wipe)
14. [Moderation & Reporting](#14-moderation--reporting)
15. [Performance — Measured Response Times](#15-performance--measured-response-times)
16. [Security Model](#16-security-model)
17. [Error Contract](#17-error-contract)
18. [Configuration & Environment Variables](#18-configuration--environment-variables)
19. [Tests](#19-tests)
20. [Porting Checklist — Duplicating This System](#20-porting-checklist--duplicating-this-system)
21. [Known Gaps & Deliberate Decisions](#21-known-gaps--deliberate-decisions)
22. [File Inventory](#22-file-inventory)

***

## 1. Overview & Scope

The messaging system is a **1-to-1 threaded chat between a student and an instructor**, with a read-only **admin oversight layer** on top. There are no group chats, no channels, and no admin-participant composer — an admin can *see*, *export* and *destroy* history, never write into it.

**Core identity rule:** a conversation is a **unique `(student_id, instructor_id)` pair**, enforced by a composite `UNIQUE` index. `POST /conversations` is therefore idempotent (`firstOrCreate`) — calling it twice for the same pair returns the same thread, never a duplicate.

**Per-participant state.** Unread counters and the archived flag are **columns on the conversation, one per side** (`student_unread`/`instructor_unread`, `student_archived`/`instructor_archived`). There is no pivot/participant table. The same thread can be archived by the student while staying active for the instructor, and each side reads its own counter. The API never exposes both — `ConversationResource` collapses them to a caller-scoped `unread` / `archived` based on `$request->user()`.

**What the system does:**

| Capability | Where |
| - | - |
| Start / reuse a thread | `ConversationController@store` |
| Keyset-paginated history (50/page) | `ConversationController@messages` |
| Send text + attachments | `ConversationController@sendMessage` |
| Per-side read receipts & unread counters | `markRead` + `messages.read_at` |
| Per-side archive | `archive` |
| Report a thread to moderation | `report` → `MessageReport` |
| Live push (server side wired) | `App\Events\MessageSent` on `conversation.{id}` |
| Image/document sharing | Cloudflare R2 via `POST /api/r2_upload_file` |
| Admin thread browser + search | `adminIndex` / `adminShow` |
| Admin Excel exports (platform-wide + per thread) | `adminXlsx` / `adminConversationXlsx` |
| Admin R2 cloud archive of a thread | `adminConversationBackup` |
| **Admin permanent history wipe** | `adminWipeMessages` |
| Admin moderation queue | `ModerationController` |

**One controller owns the whole chat surface:** `app/Http/Controllers/ConversationController.php` (\~790 lines, 13 public methods + 4 private helpers). Reports are handed off to `ModerationController`; uploads to `FilesRController`.

***

## 2. Technology Stack

| Layer | Technology | Version / Detail |
| - | - | - |
| Framework | **Laravel** | `^13.8` (PHP `^8.3`; the Docker image runs PHP **8.4** FPM) |
| Auth | **Laravel Sanctum** | `^4.0` — personal-access **bearer tokens**. No JWT, no sessions, no 2FA |
| Database | **MySQL** | SQLite is **not** used in this project (`phpunit.xml` points at a `profelite_test` MySQL DB) |
| ORM | Eloquent | No factories anywhere — migrations + seeders only |
| Transport | JSON over REST | `routes/api.php`, prefix `/api` |
| Serialization | Laravel API Resources | `JsonResource::withoutWrapping()` is **ON** → single resources *and* non-paginated collections are **unwrapped** |
| Real-time | **Laravel broadcasting** (`ShouldBroadcast`) | Event `MessageSent`, private channel `conversation.{id}`. `BROADCAST_CONNECTION=log` by default; **Reverb-ready** (`REVERB_*` env block exists) |
| File storage | **Cloudflare R2** via `league/flysystem-aws-s3-v3` `^3.0` | `r2` disk, S3 driver, `region=auto`, path-style endpoint |
| Excel export | **PhpOffice\PhpSpreadsheet** `^5.8` | Needs PHP `zip` + `xml` + `gd` extensions |
| Locale | French (`APP_LOCALE=fr`, fallback `en`) | User-visible API strings are French |
| Formatter | Laravel Pint | `./vendor/bin/pint` before committing |
| Tests | PHPUnit `^12.5` | `tests/Feature/ChatAttachmentFolderTest.php` |

**Deliberately absent:** Gates/Policies (roles are integer checks in the controller), soft-deletes on messages (the wipe is a hard delete by design), queues for message delivery (sending is synchronous), notifications on inbound messages, and a WebSocket client in the frontend (see §21).

***

## 3. System Architecture

```mermaid placement="top-right" theme={null}
flowchart TB
    subgraph Client["Client (React SPA)"]
        UI["Messages.tsx / instructor/Messages.tsx<br/>admin/Conversations.tsx + ConversationDetail.tsx"]
        HOOKS["hooks/api/messaging.ts<br/>messagingAdmin.ts<br/>messagingFiles.ts"]
    end

    subgraph Laravel["Laravel API (/api)"]
        MW["Middleware:<br/>auth:sanctum + not_banned"]
        CC["ConversationController<br/>(13 endpoints)"]
        FRC["FilesRController<br/>r2_upload_file"]
        MOD["ModerationController"]
        RES["ConversationResource<br/>MessageResource"]
        EV["Event: MessageSent<br/>(ShouldBroadcast)"]
    end

    subgraph Data["Persistence"]
        DB[("MySQL<br/>conversations · messages<br/>message_attachments · message_reports · files")]
        R2[("Cloudflare R2<br/>files/&lt;conv-ref&gt;/…<br/>conversations_backups/…")]
    end

    subgraph RT["Broadcast transport"]
        LOG["log driver (default)"]
        REVERB["Laravel Reverb<br/>(optional, not yet enabled)"]
    end

    UI --> HOOKS
    HOOKS -->|"Bearer token"| MW
    MW --> CC
    MW --> FRC
    MW --> MOD
    CC --> RES
    CC --> DB
    CC -->|"attachment bytes on wipe<br/>+ .xlsx backups"| R2
    FRC -->|"PUT object"| R2
    CC --> EV
    EV --> LOG
    EV -.->|"BROADCAST_CONNECTION=reverb"| REVERB
    REVERB -.->|"private channel<br/>conversation.{id}"| UI
    MOD --> DB
```

**Request lifecycle for every chat endpoint:**

```mermaid theme={null}
sequenceDiagram
    participant C as Client
    participant S as Sanctum (auth:sanctum)
    participant B as EnsureUserIsNotBanned
    participant Ct as ConversationController
    participant DB as MySQL

    C->>S: Authorization: Bearer {token}
    S->>DB: SELECT personal_access_tokens + users
    alt invalid token
        S-->>C: 401
    end
    S->>B: $request->user()
    alt user.status == banned
        B-->>C: 403 {error:true, message:"...banned..."}
    end
    B->>Ct: dispatch
    Ct->>DB: Conversation::findOrFail($id)
    alt not found
        Ct-->>C: 404 {message:"Not found."}
    end
    Ct->>Ct: $conversation->hasParticipant($me->id)
    alt not a participant (or not admin on admin routes)
        Ct-->>C: 403 {message:"Forbidden."}
    end
    Ct->>DB: business logic (inside DB::transaction for writes)
    Ct-->>C: 200 / 201 + Resource
```

***

## 4. Data Model

### 4.1 Entity-Relationship Diagram

```mermaid placement="top-right" theme={null}
erDiagram
    USERS ||--o{ CONVERSATIONS : "as student_id"
    USERS ||--o{ CONVERSATIONS : "as instructor_id"
    USERS ||--o{ MESSAGES : "sender_id"
    CONVERSATIONS ||--o{ MESSAGES : "conversation_id (cascade)"
    MESSAGES ||--o{ MESSAGE_ATTACHMENTS : "message_id (cascade)"
    FILES ||--o| MESSAGE_ATTACHMENTS : "file_id (nullOnDelete)"
    CONVERSATIONS ||--o{ MESSAGE_REPORTS : "conversation_id (cascade)"
    MESSAGES ||--o| MESSAGE_REPORTS : "message_id (nullOnDelete)"
    USERS ||--o{ MESSAGE_REPORTS : "reported_by_id / author_id"

    CONVERSATIONS {
        bigint id PK
        string reference UK "conv-xxxxxxxxxxxx"
        bigint student_id FK
        bigint instructor_id FK
        string subject "nullable"
        string meet_url "nullable, no writer today"
        text last_message "denormalized preview"
        timestamp last_message_at "sort key"
        uint student_unread
        uint instructor_unread
        bool student_archived
        bool instructor_archived
        timestamps created_at_updated_at
    }
    MESSAGES {
        bigint id PK
        bigint conversation_id FK
        bigint sender_id FK
        text content "nullable"
        timestamp read_at "nullable = unread"
        string kind "nullable, e.g. booking"
        json booking "nullable payload"
        timestamps created_at_updated_at
    }
    MESSAGE_ATTACHMENTS {
        bigint id PK
        bigint message_id FK
        bigint file_id FK "nullable"
        string url "denormalized public URL"
        string filename
        string mime_type "nullable, extension-guessed"
        ubigint size_bytes "default 0"
        timestamps created_at_updated_at
    }
    FILES {
        bigint id PK
        bigint company_id FK
        bigint user_id FK
        tinyint type "1=local, 2=R2"
        string file_name "R2 object key"
        string url
        softDeletes deleted_at
    }
    MESSAGE_REPORTS {
        bigint id PK
        bigint conversation_id FK
        bigint message_id FK "nullable"
        bigint reported_by_id FK
        bigint author_id FK "nullable"
        string reason "enum-by-validation"
        text note "nullable"
        string status "en attente|résolu|rejeté"
    }
```

**Two denormalizations are intentional** and are what make the conversation list a single cheap query:

1. `conversations.last_message` + `last_message_at` — the thread preview, written on every send. The list endpoint never touches `messages`.
2. `message_attachments.url` / `filename` / `mime_type` / `size_bytes` — copied off the `File` row at link time, so rendering a thread needs no join to `files` and no per-file URL round-trip. The `file_id` FK is `nullOnDelete`, so an attachment row survives (with its URL) if the `File` row goes.

### 4.2 Migrations (verbatim schema)

`database/migrations/2026_07_01_000013_create_conversations_table.php`:

```php theme={null}
Schema::create('conversations', function (Blueprint $table) {
    $table->id();
    $table->foreignId('student_id')->constrained('users')->cascadeOnDelete();
    $table->foreignId('instructor_id')->constrained('users')->cascadeOnDelete();
    $table->string('subject')->nullable();
    $table->string('meet_url')->nullable();
    $table->text('last_message')->nullable();
    $table->timestamp('last_message_at')->nullable();
    $table->unsignedInteger('student_unread')->default(0);
    $table->unsignedInteger('instructor_unread')->default(0);
    $table->boolean('student_archived')->default(false);
    $table->boolean('instructor_archived')->default(false);
    $table->timestamps();

    $table->unique(['student_id', 'instructor_id']);   // one thread per pair
});
```

`2026_08_06_000002_add_reference_to_conversations_table.php` adds the R2 folder key and **backfills existing rows**:

```php theme={null}
Schema::table('conversations', function (Blueprint $table) {
    $table->string('reference', 40)->nullable()->unique()->after('id');
});

Conversation::whereNull('reference')->get()->each(function (Conversation $conversation) {
    $conversation->reference = Conversation::generateReference();
    $conversation->saveQuietly();          // saveQuietly: don't re-fire model events
});
```

`2026_07_01_000014_create_messages_table.php` creates **two** tables:

```php theme={null}
Schema::create('messages', function (Blueprint $table) {
    $table->id();
    $table->foreignId('conversation_id')->constrained()->cascadeOnDelete();
    $table->foreignId('sender_id')->constrained('users')->cascadeOnDelete();
    $table->text('content')->nullable();
    $table->timestamp('read_at')->nullable();
    $table->string('kind')->nullable();   // e.g. "booking" system event
    $table->json('booking')->nullable();  // booking event payload
    $table->timestamps();
});

Schema::create('message_attachments', function (Blueprint $table) {
    $table->id();
    $table->foreignId('message_id')->constrained()->cascadeOnDelete();
    $table->foreignId('file_id')->nullable()->constrained('files')->nullOnDelete();
    $table->string('url');
    $table->string('filename');
    $table->string('mime_type')->nullable();
    $table->unsignedBigInteger('size_bytes')->default(0);
    $table->timestamps();
});
```

`2026_07_01_000015_create_message_reports_table.php`:

```php theme={null}
Schema::create('message_reports', function (Blueprint $table) {
    $table->id();
    $table->foreignId('conversation_id')->constrained()->cascadeOnDelete();
    $table->foreignId('message_id')->nullable()->constrained()->nullOnDelete();
    $table->foreignId('reported_by_id')->constrained('users')->cascadeOnDelete();
    $table->foreignId('author_id')->nullable()->constrained('users')->nullOnDelete();
    $table->string('reason');   // Harcèlement | Contenu inapproprié | Spam | ...
    $table->text('note')->nullable();
    $table->string('status')->default('en attente'); // en attente | résolu | rejeté
    $table->timestamps();
});
```

`2026_07_01_000002_create_files_table.php` (shared with blog/avatars, not chat-specific):

```php theme={null}
Schema::create('files', function (Blueprint $table) {
    $table->id();
    $table->foreignId('company_id')->constrained()->cascadeOnDelete();
    $table->foreignId('user_id')->constrained()->cascadeOnDelete();
    $table->unsignedTinyInteger('type');   // 1 = local, 2 = R2
    $table->string('file_name');           // for R2 this is the OBJECT KEY
    $table->string('url');
    $table->timestamps();
    $table->softDeletes();
});
```

### 4.3 Index coverage & the one missing index

| Query | Index used | Status |
| - | - | - |
| `firstOrCreate(student_id, instructor_id)` | `UNIQUE(student_id, instructor_id)` | ✅ covered |
| `WHERE instructor_id = ?` (list) | FK index on `instructor_id` | ✅ covered |
| `WHERE student_id = ?` (list) | leading column of the composite unique | ✅ covered |
| `WHERE conversation_id = ? ORDER BY id DESC` (history) | FK index on `conversation_id` + PK | ✅ covered |
| `WHERE reference = ?` | `UNIQUE(reference)` | ✅ covered |
| **`ORDER BY last_message_at DESC`** (both list endpoints + both exports) | **none** | ⚠️ **no index** |

> ⚠️ **Port this with an index.** Every listing in the system sorts on `last_message_at` (`index`, `adminIndex`, `adminXlsx`) and that column is **unindexed**. For a per-user list the row count is small enough that MySQL filesorts a handful of rows, so it is invisible today — but `GET /admin/conversations` sorts the **whole table** before paginating, so it degrades linearly with total conversation count. In a new project add `$table->index('last_message_at')` (or a composite `['instructor_id', 'last_message_at']` / `['student_id', 'last_message_at']`) from the start.

***

## 5. Eloquent Models

All four models `use App\Models\Concerns\SerializesDates`, which forces **every** timestamp to serialize as `Y-m-d H:i:s` instead of ISO-8601 with microseconds:

```php theme={null}
trait SerializesDates
{
    protected function serializeDate(DateTimeInterface $date)
    {
        return $date->format('Y-m-d H:i:s');
    }
}
```

### `App\Models\Conversation`

```php theme={null}
protected $fillable = [
    'reference', 'student_id', 'instructor_id', 'subject', 'meet_url',
    'last_message', 'last_message_at',
    'student_unread', 'instructor_unread',
    'student_archived', 'instructor_archived',
];

protected $casts = [
    'last_message_at'     => 'datetime',
    'student_unread'      => 'integer',
    'instructor_unread'   => 'integer',
    'student_archived'    => 'boolean',
    'instructor_archived' => 'boolean',
];
```

| Member | Kind | Purpose |
| - | - | - |
| `booted()` | model hook | On `creating`, assigns `reference` if empty — **every conversation always has one** |
| `generateReference()` | `public static` | `'conv-' . strtolower(Str::random(12))`, re-rolled in a `do/while` until unique |
| `student()` / `instructor()` | `belongsTo(User)` | Explicit FKs `student_id` / `instructor_id` |
| `messages()` | `hasMany(Message)` | |
| `reports()` | `hasMany(MessageReport)` | |
| **`hasParticipant(int $userId): bool`** | **the authorization primitive** | `(int) $this->student_id === $userId \|\| (int) $this->instructor_id === $userId` |

`hasParticipant()` is the single access-control predicate for the whole subsystem — it is called by **5 controller methods, the broadcast channel callback, and the R2 upload endpoint**. Port this one method faithfully.

```php theme={null}
public static function generateReference(): string
{
    do {
        $reference = 'conv-'.strtolower(Str::random(12));
    } while (self::where('reference', $reference)->exists());

    return $reference;
}
```

### `App\Models\Message`

```php theme={null}
protected $fillable = ['conversation_id', 'sender_id', 'content', 'read_at', 'kind', 'booking'];
protected $casts    = ['read_at' => 'datetime', 'booking' => 'array'];

public function conversation() { return $this->belongsTo(Conversation::class); }
public function sender()       { return $this->belongsTo(User::class, 'sender_id'); }
public function attachments()  { return $this->hasMany(MessageAttachment::class); }
```

`read_at IS NULL` **is** the unread state — there is no boolean. The resource exposes it as `read: bool`.

### `App\Models\MessageAttachment`

```php theme={null}
protected $fillable = ['message_id', 'file_id', 'url', 'filename', 'mime_type', 'size_bytes'];
protected $casts    = ['size_bytes' => 'integer'];

public function message() { return $this->belongsTo(Message::class); }
public function file()    { return $this->belongsTo(File::class); }
```

### `App\Models\MessageReport`

```php theme={null}
public const STATUS_PENDING  = 'en attente';
public const STATUS_RESOLVED = 'résolu';
public const STATUS_REJECTED = 'rejeté';

protected $fillable = ['conversation_id', 'message_id', 'reported_by_id', 'author_id',
                       'reason', 'note', 'status'];
```

Relations: `conversation()`, `message()`, `reportedBy()` (`reported_by_id`), `author()` (`author_id`).

### `App\Models\File` (shared, chat-relevant parts)

```php theme={null}
public const TYPE_INTERNAL = 1;   // local server disk
public const TYPE_R2       = 2;   // Cloudflare R2

use SoftDeletes;

protected static function booted(): void
{
    // Deleting a File row removes the matching LOCAL file from disk.
    // R2 objects are NOT touched here — callers must delete them explicitly.
    static::deleting(function (File $file) {
        if ((int) $file->type === self::TYPE_INTERNAL) {
            $path = 'public/files/'.$file->file_name;
            if (Storage::disk('local')->exists($path)) {
                Storage::disk('local')->delete($path);
                Log::info("Deleted local file from disk: {$path}");
            }
        }
    });
}

public function isR2(): bool    { return (int) $this->type === self::TYPE_R2; }
public function isLocal(): bool { return (int) $this->type === self::TYPE_INTERNAL; }
```

> ⚠️ **The `deleting` hook does not clean R2.** This is the single most important gotcha in the file layer: deleting a `File` row whose `type = 2` leaves the object in the bucket forever unless the caller deletes it. Both `FilesRController@r2_delete_file` and `ConversationController@adminWipeMessages` do so explicitly. If you port this, either replicate the explicit deletes or extend the hook.

### Role constants (`App\Models\Role`)

```php theme={null}
Role::STUDENT = 1;   Role::INSTRUCTOR = 2;   Role::ADMIN = 3;
```

Checked via `$user->isStudent()` / `isInstructor()` / `isAdmin()` — **manual checks, no Gates or Policies anywhere in this subsystem.**

***

## 6. API Resources (response shapes)

> **Critical envelope rule:** `AppServiceProvider::boot()` calls `JsonResource::withoutWrapping()`. Single resources **and non-paginated collections** are therefore returned **unwrapped** — `GET /conversations` and `GET /conversations/{id}/messages` return **plain JSON arrays, not `{"data": [...]}`**. Only hand-built paginated payloads (`adminIndex`) carry a `data`/`meta` envelope, because the controller writes it by hand.
>
> *(Verified by executing the endpoints; `api_messaging.md` §3/§5 shows a `{"data": …}` wrapper for these two and is wrong on that point — `src/hooks/api/messaging.ts` in the frontend has it right.)*

### `App\Http\Resources\ConversationResource`

**Caller-aware** — it resolves `counterpart` / `unread` / `archived` from `$request->user()`:

```php theme={null}
public function toArray(Request $request): array
{
    $me = $request->user();
    $isInstructorSide = $me && (int) $me->id === (int) $this->instructor_id;

    $counterpart = $isInstructorSide ? $this->student : $this->instructor;
    $unread      = $isInstructorSide ? $this->instructor_unread : $this->student_unread;
    $archived    = $isInstructorSide ? $this->instructor_archived : $this->student_archived;

    return [
        'id' => $this->id,
        'reference' => $this->reference,
        'student_id' => $this->student_id,
        'instructor_id' => $this->instructor_id,
        'subject' => $this->subject,
        'meet_url' => $this->meet_url,
        'last_message' => $this->last_message,
        'last_message_at' => optional($this->last_message_at)->format('Y-m-d H:i:s'),
        'unread' => (int) $unread,
        'archived' => (bool) $archived,
        'counterpart' => $counterpart ? [
            'id' => $counterpart->id,
            'name' => $counterpart->fullname,
            'avatar' => $counterpart->image?->url,
            'managed_by_parent' => (bool) $counterpart->managed_by_parent,
            'parent_name' => $counterpart->parent_name,
            'default_meet_url' => $isInstructorSide ? null : $counterpart->default_meet_url,
        ] : null,
        'messages' => MessageResource::collection($this->whenLoaded('messages')),
    ];
}
```

Real response (one thread, student caller — **plain array**, no wrapper):

```json theme={null}
[
  {
    "id": 1,
    "reference": "conv-ar3ry68dgmqq",
    "student_id": 1,
    "instructor_id": 2,
    "subject": "Aide en mathématiques",
    "meet_url": null,
    "last_message": "Bonjour, à demain !",
    "last_message_at": "2026-06-30 14:05:00",
    "unread": 2,
    "archived": false,
    "counterpart": {
      "id": 2,
      "name": "Sarah Martin",
      "avatar": "https://pub-xxxx.r2.dev/files/2_1750000000_avatar.png",
      "managed_by_parent": false,
      "parent_name": null,
      "default_meet_url": "https://meet.google.com/abc-defg-hij"
    }
  }
]
```

> **Privacy detail worth copying:** `counterpart.default_meet_url` is exposed **only to the student side** (`$isInstructorSide ? null : …`). The column exists on every user but is only meaningful for instructors, so a student's own Meet room must never leak to their instructor.

### `App\Http\Resources\MessageResource`

```php theme={null}
return [
    'id' => $this->id,
    'conversation_id' => $this->conversation_id,
    'sender_id' => $this->sender_id,
    'content' => $this->content,
    'read' => ! is_null($this->read_at),
    'kind' => $this->kind,
    'booking' => $this->booking,
    'attachments' => $this->whenLoaded('attachments', fn () => $this->attachments->map(fn ($a) => [
        'id' => $a->id,
        'url' => $a->url,
        'filename' => $a->filename,
        'mime_type' => $a->mime_type,
        'size_bytes' => $a->size_bytes,
    ])),
    'timestamp' => optional($this->created_at)->format('Y-m-d H:i:s'),
];
```

Real response (plain array):

```json theme={null}
[
  {
    "id": 1,
    "conversation_id": 1,
    "sender_id": 1,
    "content": "Voici le corrigé",
    "read": false,
    "kind": null,
    "booking": null,
    "attachments": [
      {
        "id": 31,
        "url": "https://pub-xxxx.r2.dev/files/conv-ar3ry68dgmqq/1_1759000000_corrige.pdf",
        "filename": "1_1759000000_corrige.pdf",
        "mime_type": "application/pdf",
        "size_bytes": 0
      }
    ],
    "timestamp": "2026-10-14 23:35:11"
  }
]
```

> `attachments` is **conditional on eager-loading** (`whenLoaded`). Every endpoint that returns messages calls `->with('attachments')`, so the key is always present in practice — but if you add a new endpoint and forget the eager-load, the key silently disappears from the payload instead of N+1-ing. Keep that; it is the safer failure mode.

### `App\Http\Resources\MessageReportResource`

```php theme={null}
return [
    'id' => $this->id,
    'conversation_id' => $this->conversation_id,
    'message_id' => $this->message_id,
    'reason' => $this->reason,
    'note' => $this->note,
    'status' => $this->status,
    'reported_by' => $this->whenLoaded('reportedBy', fn () => [
        'id' => ..., 'name' => ..., 'email' => ...,
    ]),
    'author' => $this->whenLoaded('author', fn () => $this->author ? [...] : null),
    'date' => optional($this->created_at)->format('Y-m-d H:i:s'),
];
```

***

## 7. Complete Route Map

All 13 chat routes live inside **one middleware group** in `routes/api.php`:

```php theme={null}
Route::middleware(['auth:sanctum', 'not_banned'])->group(function () {

    // ---- Messaging & conversations ----
    Route::get('/conversations',                  [ConversationController::class, 'index']);
    Route::post('/conversations',                 [ConversationController::class, 'store']);
    Route::get('/conversations/{id}/messages',    [ConversationController::class, 'messages']);
    Route::post('/conversations/{id}/messages',   [ConversationController::class, 'sendMessage']);
    Route::post('/conversations/{id}/read',       [ConversationController::class, 'markRead']);
    Route::post('/conversations/{id}/archive',    [ConversationController::class, 'archive']);
    Route::post('/conversations/{id}/report',     [ConversationController::class, 'report']);

    Route::get('/admin/conversations',                      [ConversationController::class, 'adminIndex']);
    Route::get('/admin/conversations.xlsx',                 [ConversationController::class, 'adminXlsx']);
    Route::get('/admin/conversations/{id}',                 [ConversationController::class, 'adminShow']);
    Route::get('/admin/conversations/{id}/export.xlsx',     [ConversationController::class, 'adminConversationXlsx']);
    Route::post('/admin/conversations/{id}/backup.xlsx',    [ConversationController::class, 'adminConversationBackup']);
    Route::delete('/admin/conversations/{id}/messages',     [ConversationController::class, 'adminWipeMessages']);
});
```

> **Route order matters.** `/admin/conversations.xlsx` is declared **before** `/admin/conversations/{id}` on purpose. Laravel matches in declaration order; the literal `.xlsx` route must win, or `conversations.xlsx` would be swallowed as an `{id}`. Preserve this ordering when porting.

**Full endpoint table:**

| # | Method | Path | Controller method | Auth | Success |
| :- | :- | :- | :- | :- | :- |
| 1 | `GET` | `/api/conversations` | `index` | participant (student \| instructor) | `200` plain array |
| 2 | `POST` | `/api/conversations` | `store` | student \| instructor | `200` object (idempotent) |
| 3 | `GET` | `/api/conversations/{id}/messages` | `messages` | **participant only** | `200` plain array (≤50) |
| 4 | `POST` | `/api/conversations/{id}/messages` | `sendMessage` | **participant only** | `201` object |
| 5 | `POST` | `/api/conversations/{id}/read` | `markRead` | **participant only** | `200 {message:"OK"}` |
| 6 | `POST` | `/api/conversations/{id}/archive` | `archive` | **participant only** | `200 {message, archived}` |
| 7 | `POST` | `/api/conversations/{id}/report` | `report` | **participant only** | `201 {message}` |
| 8 | `GET` | `/api/admin/conversations` | `adminIndex` | **admin only** | `200 {data, meta}` (paginated 20) |
| 9 | `GET` | `/api/admin/conversations.xlsx` | `adminXlsx` | **admin only** | `200` streamed `.xlsx` |
| 10 | `GET` | `/api/admin/conversations/{id}` | `adminShow` | **admin only** | `200` object + full thread |
| 11 | `GET` | `/api/admin/conversations/{id}/export.xlsx` | `adminConversationXlsx` | **admin only** | `200` streamed `.xlsx` |
| 12 | `POST` | `/api/admin/conversations/{id}/backup.xlsx` | `adminConversationBackup` | **admin only** | `200 {message, path, url, saved_at}` |
| 13 | `DELETE` | `/api/admin/conversations/{id}/messages` | `adminWipeMessages` | **admin only** | `200 {message, deleted_messages}` |

**Supporting routes** (same middleware group, different controllers):

| Method | Path | Method | Role in chat |
| :- | :- | :- | :- |
| `POST` | `/api/r2_upload_file` | `FilesRController@r2_upload_file` | **the attachment upload** — accepts `conversation_id` |
| `GET` | `/api/file_r2/{id}/url` | `FilesRController@r2_file_url` | cheap URL + existence check |
| `DELETE` | `/api/r2_delete_file/{id}` | `FilesRController@r2_delete_file` | hard delete object + row |
| `GET` | `/api/admin/reports` | `ModerationController@index` | moderation queue |
| `GET` | `/api/admin/reports/pending-count` | `ModerationController@pendingCount` | sidebar badge |
| `GET` | `/api/admin/reports/{id}` | `ModerationController@show` | report + thread context |
| `POST` | `/api/admin/reports/{id}/resolve` | `ModerationController@resolve` | → `résolu` |
| `POST` | `/api/admin/reports/{id}/reject` | `ModerationController@reject` | → `rejeté` |
| `POST` | `/api/admin/reports/{id}/reopen` | `ModerationController@reopen` | → `en attente` |

Broadcast channel auth is registered via `bootstrap/app.php` → `channels: routes/channels.php`.

***

## 8. Controller Reference — Participant Endpoints

Every method follows the same house pattern:

```php theme={null}
public function x($id, Request $request)
{
    try {
        /* guard → validate → work → Resource */
    } catch (ValidationException $e) {
        return response()->json(['error' => true,
            'message' => collect($e->errors())->flatten()->first()], 422);
    } catch (ModelNotFoundException $e) {
        return response()->json(['message' => 'Not found.'], 404);
    } catch (\Throwable $e) {
        report($e);
        return $this->serverError();          // generic French 500, NEVER $e->getMessage()
    }
}
```

### 8.1 `index(Request $request)` — `GET /conversations`

Lists the caller's own threads, newest activity first.

```php theme={null}
$query = Conversation::query()
    ->with(['student.image', 'instructor.image'])     // avatars, 2 extra queries total
    ->orderByDesc('last_message_at');

$includeArchived = (int) $request->query('archived', 0) === 1;

if ($me->isInstructor()) {
    $query->where('instructor_id', $me->id);
    if (! $includeArchived) { $query->where('instructor_archived', false); }
} else {
    $query->where('student_id', $me->id);
    if (! $includeArchived) { $query->where('student_archived', false); }
}

return ConversationResource::collection($query->get());
```

* **Role branch, not a scope**: instructor → `instructor_id`, *everything else* (student **and admin**) → `student_id`. An admin hitting this endpoint sees threads where they are the student (normally none) — admins use `/admin/conversations` instead.
* **Not paginated** — `->get()` returns every thread. Fine at current scale; see §15.
* `?archived=1` includes the caller's archived threads. The frontend actually calls it with `archived=1` always and filters client-side.

| Query param | Type | Default | Effect |
| - | - | - | - |
| `archived` | int | `0` | `1` → include the caller's archived threads |

### 8.2 `store(Request $request)` — `POST /conversations`

Idempotent thread creation.

```php theme={null}
if ($me->isInstructor()) {
    $data = $request->validate([
        'student_id' => 'required|integer|exists:users,id',
        'subject'    => 'nullable|string|max:255',
    ]);
    $studentId = (int) $data['student_id'];
    $instructorId = (int) $me->id;
} else {
    $data = $request->validate([
        'instructor_id' => 'required|integer|exists:users,id',
        'subject'       => 'nullable|string|max:255',
    ]);
    $studentId = (int) $me->id;
    $instructorId = (int) $data['instructor_id'];
}

$conversation = Conversation::firstOrCreate(
    ['student_id' => $studentId, 'instructor_id' => $instructorId],
    ['subject' => $data['subject'] ?? null]
);

$conversation->load(['student.image', 'instructor.image']);
return ConversationResource::make($conversation);
```

**The validated field depends on the caller's role** — a student sends `instructor_id`, an instructor sends `student_id`. The caller never supplies their own side, so nobody can open a thread *as* someone else.

> ⚠️ **Two integrity gaps to fix when porting** (present in this implementation):
>
> 1. `exists:users,id` does **not** check the counterpart's **role**. A student can pass another *student's* id and create a student↔student thread stored in the `instructor_id` column. Add `Rule::exists('users','id')->where('role_id', Role::INSTRUCTOR)` (and the mirror for the instructor branch).
> 2. Nothing stops `student_id === instructor_id` (a self-thread). Add an explicit guard.
>
> Both are cheap validation rules; the `UNIQUE(student_id, instructor_id)` index does not catch either.

Returns `200`, not `201` — even on first creation, because `firstOrCreate` makes the call idempotent.

### 8.3 `messages($id, Request $request)` — `GET /conversations/{id}/messages`

**Keyset (cursor) pagination, 50 per page, fixed server-side.**

```php theme={null}
$conversation = Conversation::findOrFail($id);

if (! $conversation->hasParticipant((int) $me->id)) {
    return response()->json(['message' => 'Forbidden.'], 403);
}

$query = $conversation->messages()
    ->with('attachments')
    ->orderByDesc('id');

if ($request->filled('before')) {
    $query->where('id', '<', (int) $request->query('before'));
}

// Fetch latest N descending, then present ascending (oldest → newest).
$messages = $query->limit(50)->get()->sortBy('id')->values();

return MessageResource::collection($messages);
```

**Why keyset and not `?page=`:** new inbound messages shift offset-based page boundaries, so a user scrolling up would see duplicates or holes. `id <` is stable. The trade-off is you can only page **backwards** — there is no forward cursor, because "forward" is what the broadcast/refetch delivers.

The `->get()->sortBy('id')` re-sort happens **in PHP on 50 rows** (not in SQL) so that the DB can still use the descending index scan with `LIMIT 50`. Sorting ascending in SQL would mean scanning from the start of the thread.

| Query param | Type | Effect |
| - | - | - |
| `before` | int | Return up to 50 messages with `id <` this value (the older page) |

Page size `50` is **hardcoded**, mirrored in the frontend as `MESSAGES_PAGE_SIZE = 50`.

### 8.4 `sendMessage($id, Request $request)` — `POST /conversations/{id}/messages`

The heart of the system. Guard → validate → **one transaction** (message + attachments + conversation metadata + unread bump) → broadcast → `201`.

```php theme={null}
$conversation = Conversation::findOrFail($id);
if (! $conversation->hasParticipant((int) $me->id)) {
    return response()->json(['message' => 'Forbidden.'], 403);
}

$data = $request->validate([
    'content'          => 'nullable|string|max:5000',
    'attachment_ids'   => 'nullable|array',
    'attachment_ids.*' => 'integer',
]);

$message = DB::transaction(function () use ($conversation, $me, $data) {
    $message = Message::create([
        'conversation_id' => $conversation->id,
        'sender_id'       => $me->id,
        'content'         => $data['content'] ?? null,
    ]);

    $attachmentIds = $data['attachment_ids'] ?? [];
    foreach ($attachmentIds as $fileId) {
        $file = File::find($fileId);
        if (! $file) { continue; }           // unknown ids are SILENTLY skipped

        MessageAttachment::create([
            'message_id' => $message->id,
            'file_id'    => $file->id,
            'url'        => $file->url,
            'filename'   => basename((string) $file->file_name),
            'mime_type'  => $this->guessMimeType($file->file_name),
            'size_bytes' => 0,
        ]);
    }

    // Conversation metadata + unread counter for the OTHER party.
    $conversation->last_message = $data['content'] ?? '[pièce jointe]';
    $conversation->last_message_at = now();

    if ((int) $me->id === (int) $conversation->student_id) {
        $conversation->instructor_unread = (int) $conversation->instructor_unread + 1;
    } else {
        $conversation->student_unread = (int) $conversation->student_unread + 1;
    }
    $conversation->save();

    return $message;
});

broadcast(new MessageSent($message->load('sender', 'attachments')))->toOthers();

return MessageResource::make($message->load('attachments'))
    ->response()
    ->setStatusCode(201);
```

Behaviours to carry over:

| Behaviour | Detail |
| - | - |
| `content` max | **5000 chars**, `nullable` |
| Attachment-only message | Allowed; `last_message` becomes the literal `'[pièce jointe]'` |
| Unknown `attachment_ids` | **Silently skipped** (`continue`) — no `422`, no partial failure |
| Unread bump | **Recipient's** counter only, `+1`, inside the transaction |
| `size_bytes` | Always written as **`0`** — the byte size isn't known at link time (see §21) |
| `mime_type` | Extension-guessed by `guessMimeType()` (§10.4) |
| `filename` | `basename($file->file_name)` → for R2 this is the *generated* key's basename (e.g. `5_1759000000_devoir.pdf`), **not** the original upload name |
| Broadcast | **After** the transaction commits, `->toOthers()` so the sender's own socket is skipped |
| Broadcast failure | **Not** caught separately — it falls into the outer `\Throwable` → `500`, *after* the message was already committed. Consider `report()`-ing it and still returning `201` when porting |

> ⚠️ **No ownership check on `attachment_ids`.** Any `File` id that exists gets linked, regardless of who uploaded it or whether it belongs to this conversation. A participant could enumerate ids and attach another user's file (its public URL is then copied into the attachment row). The upload endpoint *does* check conversation membership (§12), but the link step does not re-check. When porting, add `->where('user_id', $me->id)` (or verify the key's `files/{reference}/` prefix) to the `File::find()`.

> ⚠️ **No validation that at least one of `content` / `attachment_ids` is present.** `POST` with an empty body creates an empty message (`content: null`, no attachments) and still bumps the unread counter. Add `'content' => 'required_without:attachment_ids'`.

### 8.5 `markRead($id, Request $request)` — `POST /conversations/{id}/read`

```php theme={null}
DB::transaction(function () use ($conversation, $me) {
    if ((int) $me->id === (int) $conversation->student_id) {
        $conversation->student_unread = 0;
    } else {
        $conversation->instructor_unread = 0;
    }
    $conversation->save();

    // Mark the OTHER party's messages as read.
    $conversation->messages()
        ->where('sender_id', '!=', $me->id)
        ->whereNull('read_at')
        ->update(['read_at' => now()]);
});

return response()->json(['message' => 'OK']);
```

Two effects, one transaction: zero **the caller's** counter, and stamp `read_at = now()` on the **counterpart's** unread messages (a single bulk `UPDATE`, not a loop). `whereNull('read_at')` keeps it idempotent and avoids rewriting already-read rows — a repeat call is a no-op and costs one `UPDATE` matching zero rows.

### 8.6 `archive($id, Request $request)` — `POST /conversations/{id}/archive`

A **toggle**, not a setter — it flips the caller's own flag and returns the resulting state:

```php theme={null}
if ((int) $me->id === (int) $conversation->student_id) {
    $conversation->student_archived = ! $conversation->student_archived;
    $archived = (bool) $conversation->student_archived;
} else {
    $conversation->instructor_archived = ! $conversation->instructor_archived;
    $archived = (bool) $conversation->instructor_archived;
}
$conversation->save();

return response()->json(['message' => 'OK', 'archived' => $archived]);
```

Response: `{"message":"OK","archived":true}`. Note this is **not** idempotent — a retried request un-archives. An explicit `{archived: bool}` body would be safer in a new implementation.

### 8.7 `report($id, Request $request)` — `POST /conversations/{id}/report`

Files a `MessageReport` against the **other participant**, always in status `en attente`.

```php theme={null}
private const REPORT_REASONS = [
    'Harcèlement',
    'Contenu inapproprié',
    'Spam',
    'Coordonnées personnelles',
    'Arnaque / paiement hors plateforme',
    'Autre',
];

$data = $request->validate([
    'reason'     => ['required', 'string', 'in:'.implode(',', self::REPORT_REASONS)],
    'note'       => 'nullable|string',
    'message_id' => 'nullable|integer',
]);

// The reported author is the other participant.
$authorId = (int) $me->id === (int) $conversation->student_id
    ? $conversation->instructor_id
    : $conversation->student_id;

MessageReport::create([
    'conversation_id' => $conversation->id,
    'message_id'      => $data['message_id'] ?? null,
    'reported_by_id'  => $me->id,
    'author_id'       => $authorId,
    'reason'          => $data['reason'],
    'note'            => $data['note'] ?? null,
    'status'          => MessageReport::STATUS_PENDING,
]);

return response()->json(['message' => 'Signalement envoyé.'], 201);
```

* **`author_id` is derived, never supplied** — you cannot report yourself or a third party.
* The report targets the **whole conversation**; `message_id` is an optional pointer and is `nullable|integer` only (**not** `exists:messages,id`, and **not** checked to belong to this thread).
* Reasons are a **controller constant**, not a DB enum — the column is a plain `string`. Keep the constant as the single source of truth and mirror it in the client's reason picker.
* No rate limiting: a user can file unlimited reports on the same thread. Consider `throttle:` or a per-(reporter, conversation) uniqueness rule.

***

## 9. Controller Reference — Admin Endpoints

Each of the six admin methods opens with the **same inline guard** — there is no middleware, no Gate:

```php theme={null}
$me = $request->user();
if (! $me->isAdmin()) {
    return response()->json(['message' => 'Forbidden.'], 403);
}
```

> Note the guard is repeated 6× rather than extracted. `ModerationController` does extract it (`denyUnlessAdmin()`, §14) — that is the better pattern to carry forward, or better still an `EnsureUserIsAdmin` middleware applied to an `/admin` route group.

### 9.1 `adminIndex(Request $request)` — `GET /admin/conversations`

Paginated oversight list (**20/page**) with both participants' **contact info**.

```php theme={null}
$query = Conversation::query()
    ->with(['student.image', 'instructor.image'])
    ->orderByDesc('last_message_at');

if ($request->filled('student_id'))    { $query->where('student_id', (int) $request->query('student_id')); }
if ($request->filled('instructor_id')) { $query->where('instructor_id', (int) $request->query('instructor_id')); }

if ($request->filled('q')) {
    $term = '%'.$request->query('q').'%';
    $query->where(function ($q) use ($term) {
        $q->whereHas('student', fn ($s) => $s
            ->where('first_name', 'like', $term)
            ->orWhere('last_name', 'like', $term)
            ->orWhere('email', 'like', $term))
          ->orWhereHas('instructor', fn ($s) => $s
            ->where('first_name', 'like', $term)
            ->orWhere('last_name', 'like', $term)
            ->orWhere('email', 'like', $term));
    });
}

$page = $query->paginate(20);
```

The response is **hand-built**, so unlike the participant endpoints it **does** carry a wrapper:

```json theme={null}
{
  "data": [
    {
      "id": 12,
      "reference": "conv-ar3ry68dgmqq",
      "subject": "Aide en mathématiques",
      "last_message": "Bonjour, à demain !",
      "last_message_at": "2026-06-30 14:05:00",
      "student": {
        "id": 5, "name": "Léo Dupont", "email": "leo@blondmail.com",
        "phone": "+33600000000", "default_meet_url": null,
        "managed_by_parent": true, "parent_name": "Claire Dupont"
      },
      "instructor": {
        "id": 9, "name": "Sarah Martin", "email": "sarah@fivermail.com",
        "phone": "+33611111111",
        "default_meet_url": "https://meet.google.com/abc-defg-hij",
        "managed_by_parent": false, "parent_name": null
      }
    }
  ],
  "meta": { "current_page": 1, "last_page": 1, "per_page": 20, "total": 1 }
}
```

| Query param | Type | Effect |
| - | - | - |
| `q` | string | `LIKE %q%` on either participant's `first_name`, `last_name` or `email` |
| `student_id` | int | exact filter |
| `instructor_id` | int | exact filter |

> The `q` search is a leading-wildcard `LIKE` across two `whereHas` subqueries — **no index can serve it**. It is a full scan of `users` per conversation candidate. Acceptable for an admin screen; replace with a `FULLTEXT` index or a denormalized search column if the table grows.

### 9.2 `adminShow($id, Request $request)` — `GET /admin/conversations/{id}`

The **complete** thread (no pagination, no limit) plus contact cards.

```php theme={null}
$conversation = Conversation::with(['student.image', 'instructor.image'])->findOrFail($id);

$messages = $conversation->messages()
    ->with('attachments')
    ->orderBy('id')
    ->get();                       // ← ALL messages, unbounded

return response()->json([
    'id' => $conversation->id,
    'reference' => $conversation->reference,
    'subject' => $conversation->subject,
    'meet_url' => $conversation->meet_url,
    'last_message_at' => optional($conversation->last_message_at)->format('Y-m-d H:i:s'),
    'student' => $this->adminParticipant($conversation->student),
    'instructor' => $this->adminParticipant($conversation->instructor),
    'messages' => MessageResource::collection($messages),
]);
```

`messages` serializes as a **plain array** (`withoutWrapping`).

> ⚠️ **This is the heaviest read in the subsystem** and the only unbounded one: a 500-message thread measured **\~15 ms and 135 KB** of JSON (§15). It grows linearly and forever. In a new project, paginate it the same way the participant endpoint does, or cap it (`ModerationController@show` already caps its own thread preview at 50).

### 9.3 `adminXlsx(Request $request)` — `GET /admin/conversations.xlsx`

Platform-wide Excel summary, **one row per conversation**, streamed.

```php theme={null}
$spreadsheet = new Spreadsheet;
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle('Conversations');

$headers = ['id', 'student', 'instructor', 'subject', 'last_message_at', 'messages_count'];
$sheet->fromArray($headers, null, 'A1');
$sheet->getStyle('A1:F1')->getFont()->setBold(true);

$row = 2;
Conversation::with(['student', 'instructor'])
    ->withCount('messages')                       // one aggregated subquery, no N+1
    ->orderByDesc('last_message_at')
    ->chunk(200, function ($conversations) use ($sheet, &$row) {   // memory-bounded
        foreach ($conversations as $c) {
            $sheet->fromArray([
                $c->id, $c->student?->fullname, $c->instructor?->fullname, $c->subject,
                optional($c->last_message_at)->format('Y-m-d H:i:s'), $c->messages_count,
            ], null, 'A'.$row);
            $row++;
        }
    });

foreach (range('A', 'F') as $col) { $sheet->getColumnDimension($col)->setAutoSize(true); }

$filename = 'conversations_'.now()->format('Ymd_His').'.xlsx';

return response()->streamDownload(function () use ($spreadsheet) {
    $writer = new Xlsx($spreadsheet);
    $writer->save('php://output');
    $spreadsheet->disconnectWorksheets();          // frees PhpSpreadsheet's cyclic refs
}, $filename, [
    'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
]);
```

Three details worth copying: `withCount('messages')` (avoids N+1 on the counts), `chunk(200)` (bounded DB memory), and `disconnectWorksheets()` (PhpSpreadsheet holds circular references; without this call a large export leaks until the request ends).

> This **replaced** a removed `GET /admin/conversations.csv` (`adminCsv`). Don't reintroduce the CSV variant here — only the finance module keeps both formats.

Filename: `conversations_YYYYMMDD_HHMMSS.xlsx`.

### 9.4 `adminConversationXlsx($id, Request $request)` — `GET /admin/conversations/{id}/export.xlsx`

One thread, **one row per message**, streamed. This is the export the admin UI runs **before** a wipe.

```php theme={null}
$conversation = $this->loadConversationForExport($id);         // shared loader  (§10.1)
$spreadsheet  = $this->buildConversationSpreadsheet($conversation);  // shared builder (§10.2)

$studentEmail = $conversation->student?->email ?? '—';

// '@' becomes '_at_', then any remaining non-safe character collapses to '_'; '.' and '-' are kept.
$slug = fn (?string $s) => preg_replace('/[^A-Za-z0-9._-]+/', '_', str_replace('@', '_at_', $s ?? 'inconnu'));

$filename = 'conversation-'.$conversation->id
    .'_'.$slug($studentEmail)
    .'_'.now()->format('Y-m-d')
    .'.xlsx';
```

→ `conversation-12_leo_at_blondmail.com_2026-10-15.xlsx`. The `404` here is **French** (`'Conversation introuvable.'`), unlike the participant endpoints' `'Not found.'`.

### 9.5 `adminConversationBackup($id, Request $request)` — `POST /admin/conversations/{id}/backup.xlsx`

Same workbook as §9.4, but **pushed to R2** instead of the browser.

```php theme={null}
$tmp = null;
try {
    $conversation = $this->loadConversationForExport($id);
    $spreadsheet  = $this->buildConversationSpreadsheet($conversation);

    // Write the workbook to a temp file, then push its bytes to R2.
    $tmp = tempnam(sys_get_temp_dir(), 'conv_backup_').'.xlsx';
    $writer = new Xlsx($spreadsheet);
    $writer->save($tmp);
    $spreadsheet->disconnectWorksheets();

    $path = 'conversations_backups/conversation-'.$conversation->id.'.xlsx';
    $disk = Storage::disk('r2');
    $disk->put($path, file_get_contents($tmp), [
        'ContentType' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
    ]);

    return response()->json([
        'message'  => 'Historique enregistré dans le cloud.',
        'path'     => $path,
        'url'      => $disk->url($path),
        'saved_at' => now()->format('Y-m-d H:i:s'),
    ]);
} catch (ModelNotFoundException $e) {
    return response()->json(['message' => 'Conversation introuvable.'], 404);
} catch (\Throwable $e) {
    report($e);
    return $this->serverError();
} finally {
    if ($tmp && is_file($tmp)) { @unlink($tmp); }     // temp file always cleaned
}
```

**The object key is deterministic** — `conversations_backups/conversation-{id}.xlsx`. Re-saving the same thread **overwrites** ("squashes") the previous backup rather than piling up dated copies. That is a product decision: one current archive per thread, no storage creep. Say so in any UI copy.

> ⚠️ The `r2` disk is configured `'throw' => false`, so `$disk->put()` returns `false` on failure instead of throwing — this method therefore **reports success even if the upload silently failed**. When porting, check the return value: `if (! $disk->put(...)) { throw new RuntimeException(...); }`. (`'throw' => false` is deliberate for the shared disk so a failed chat upload doesn't `500`; the admin R2 health check clones the config with `throw => true` for exactly this reason.)

### 9.6 `adminWipeMessages($id, Request $request)` — `DELETE /admin/conversations/{id}/messages`

See §13.3 — documented in full with the admin-role flow.

***

## 10. Reusable & Private Helper Functions

Five shared pieces carry the subsystem. Port these first; the endpoints are thin on top of them.

### 10.1 `loadConversationForExport($id): Conversation` — *private*

The single eager-load shape both exporters need:

```php theme={null}
private function loadConversationForExport($id): Conversation
{
    return Conversation::with([
        'student',
        'instructor',
        'messages' => fn ($q) => $q->orderBy('created_at')->with('attachments.file'),
    ])->findOrFail($id);
}
```

Note the nested `attachments.file` — the export prints the live `File::url`, not the denormalized `message_attachments.url`, so a re-hosted file exports with its current URL.

### 10.2 `buildConversationSpreadsheet(Conversation $c): Spreadsheet` — *private*

**Shared by `adminConversationXlsx` (§9.4) and `adminConversationBackup` (§9.5)**, so the downloaded file and the archived one are byte-for-byte identical. Change the layout in one place.

```php theme={null}
private function buildConversationSpreadsheet(Conversation $conversation): Spreadsheet
{
    $spreadsheet = new Spreadsheet;
    $sheet = $spreadsheet->getActiveSheet();
    $sheet->setTitle('Conversation '.$conversation->id);

    $studentEmail    = $conversation->student?->email ?? '—';
    $instructorEmail = $conversation->instructor?->email ?? '—';

    // Title block: both participant emails + the export date, above the data grid.
    $sheet->setCellValue('A1', 'Export de la conversation #'.$conversation->id);
    $sheet->mergeCells('A1:J1');
    $sheet->getStyle('A1')->getFont()->setBold(true)->setSize(14);

    $sheet->setCellValue('A2', 'Élève : '.$studentEmail);          $sheet->mergeCells('A2:J2');
    $sheet->setCellValue('A3', 'Instructeur : '.$instructorEmail); $sheet->mergeCells('A3:J3');
    $sheet->setCellValue('A4', 'Date d’export : '.now()->format('Y-m-d H:i'));
    $sheet->mergeCells('A4:J4');
    $sheet->getStyle('A2:A4')->getFont()->setBold(true);

    $headerRow = 6;
    $headers = ['Conversation', 'Sujet', 'Élève', 'Instructeur', 'Expéditeur', 'Rôle',
                'Date', 'Heure', 'Message', 'Pièces jointes'];
    $sheet->fromArray($headers, null, 'A'.$headerRow);
    $sheet->getStyle('A'.$headerRow.':J'.$headerRow)->getFont()->setBold(true);

    $studentId = $conversation->student?->id;
    $row = $headerRow + 1;

    foreach ($conversation->messages as $m) {
        $isStudent = $studentId !== null && (int) $m->sender_id === (int) $studentId;
        $sender = $isStudent ? $conversation->student : $conversation->instructor;
        $attachments = $m->attachments->map(fn ($a) => $a->file?->url)->filter()->implode(' ');

        $sheet->fromArray([
            $conversation->id, $conversation->subject, $studentEmail, $instructorEmail,
            $sender?->email, $isStudent ? 'Élève' : 'Instructeur',
            optional($m->created_at)->format('Y-m-d'),
            optional($m->created_at)->format('H:i'),
            $m->content, $attachments,
        ], null, 'A'.$row);
        $row++;
    }

    foreach (range('A', 'J') as $col) { $sheet->getColumnDimension($col)->setAutoSize(true); }

    return $spreadsheet;
}
```

Layout: rows 1–4 title block (merged `A:J`), row 5 blank, **row 6 header**, rows 7+ one per message oldest → newest. Attachment URLs are space-joined in column J. **Sender role is derived** (`sender_id === student_id` → `Élève`, else `Instructeur`) rather than read from the user's `role_id`, so an exported thread stays correct even if a user's role later changes.

### 10.3 `adminParticipant(?User $user): ?array` — *private*

The admin contact card, shared by `adminIndex` and `adminShow`:

```php theme={null}
private function adminParticipant(?User $user): ?array
{
    if (! $user) { return null; }

    return [
        'id' => $user->id,
        'name' => $user->fullname,
        'email' => $user->email,
        'phone' => $user->phone_number,
        'default_meet_url' => $user->default_meet_url,
        'managed_by_parent' => (bool) $user->managed_by_parent,
        'parent_name' => $user->parent_name,
    ];
}
```

**Admins see contact info both participants never see about each other** — email, phone, Meet room, and the parent's name for a minor's managed account. This is the deliberate asymmetry of the oversight role. Null-safe, so a cascade-deleted user renders as `null` instead of crashing the list.

### 10.4 `guessMimeType(?string $fileName): ?string` — *private*

Extension → MIME lookup, used when linking an attachment (the byte stream isn't available at link time, only the stored `File` row):

```php theme={null}
private function guessMimeType(?string $fileName): ?string
{
    if (! $fileName) { return null; }

    $ext = strtolower(pathinfo($fileName, PATHINFO_EXTENSION));

    return match ($ext) {
        'jpg', 'jpeg' => 'image/jpeg',
        'png'  => 'image/png',
        'gif'  => 'image/gif',
        'webp' => 'image/webp',
        'svg'  => 'image/svg+xml',
        'pdf'  => 'application/pdf',
        'mp4'  => 'video/mp4',
        'webm' => 'video/webm',
        'mov'  => 'video/quicktime',
        'mp3'  => 'audio/mpeg',
        'doc'  => 'application/msword',
        'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
        'xls'  => 'application/vnd.ms-excel',
        'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
        'zip'  => 'application/zip',
        'txt'  => 'text/plain',
        default => null,
    };
}
```

Returns `null` for anything unlisted — the client then falls back to a generic file icon. Note it still maps the **video** types even though the upload endpoint rejects videos; harmless, but a reminder that this map is a *display* helper, never a security control.

### 10.5 `Controller::serverError(string $message = …): JsonResponse` — base class

```php theme={null}
protected function serverError(string $message = 'Une erreur est survenue. Veuillez réessayer plus tard.'): JsonResponse
{
    return response()->json(['message' => $message], 500);
}
```

Every `catch (\Throwable)` in the subsystem is `report($e)` + `return $this->serverError()`. **`$e->getMessage()` is never returned** — driver text leaks the schema, the SQL, the DB name and host. The real exception goes to `storage/logs/laravel.log` (or `php artisan pail`).

### 10.6 `Conversation::hasParticipant(int $userId): bool` — the authorization primitive

Already shown in §5. Listed again here because it is **the** reusable function of this subsystem: called by `messages`, `sendMessage`, `markRead`, `archive`, `report`, the `conversation.{id}` channel callback, and `FilesRController@r2_upload_file`. Seven call sites, one definition.

***

## 11. Real-Time Layer — `MessageSent` + Channel Authorization

### The event

`app/Events/MessageSent.php` — the only broadcast event in the subsystem:

```php theme={null}
class MessageSent implements ShouldBroadcast
{
    use Dispatchable, InteractsWithSockets, SerializesModels;

    public function __construct(public Message $message) {}

    /** Only participants of the conversation receive the push. */
    public function broadcastOn(): array
    {
        return [new PrivateChannel('conversation.'.$this->message->conversation_id)];
    }

    public function broadcastAs(): string
    {
        return 'MessageSent';          // stable client-side event name
    }

    public function broadcastWith(): array
    {
        $this->message->loadMissing('sender', 'attachments');

        return [
            'id' => $this->message->id,
            'conversation_id' => $this->message->conversation_id,
            'sender_id' => $this->message->sender_id,
            'content' => $this->message->content,
            'kind' => $this->message->kind,
            'booking' => $this->message->booking,
            'sender' => [
                'id' => $this->message->sender?->id,
                'name' => $this->message->sender?->fullname,
                'avatar' => $this->message->sender?->image?->url,
            ],
            'attachments' => $this->message->attachments->map(fn ($a) => [
                'url' => $a->url,
                'filename' => $a->filename,
                'mime_type' => $a->mime_type,
                'size_bytes' => $a->size_bytes,
            ]),
            'created_at' => $this->message->created_at?->format('Y-m-d H:i:s'),
        ];
    }
}
```

**Design choices to preserve:**

* `broadcastAs()` pins the client-side event name to `MessageSent` instead of the fully-qualified class name — so the frontend listener doesn't break if the PHP namespace moves.
* `broadcastWith()` is **self-sufficient**: it carries the sender block (name + avatar) *and* full attachment metadata, so a subscribed client renders the incoming bubble **without a follow-up REST call**. That is what makes push delivery actually fast rather than "push then fetch".
* `loadMissing()` (not `load()`) — the sender already eager-loads in the controller (`->load('sender','attachments')`), so this is a safety net that costs nothing when primed.
* The payload shape is **deliberately different from `MessageResource`** (it has `sender`/`created_at` where the resource has `read`/`timestamp`). If you port this, either unify them or keep both mappings explicit on the client — do not assume the socket payload equals the REST payload.
* `ShouldBroadcast` (not `ShouldBroadcastNow`) → the event goes through the **queue** (`QUEUE_CONNECTION=database`), so delivery latency depends on a running worker.

### Channel authorization

`routes/channels.php` — registered through `bootstrap/app.php` (`channels: routes/channels.php`):

```php theme={null}
Broadcast::channel('conversation.{conversationId}', function ($user, $conversationId) {
    $conversation = Conversation::find($conversationId);

    return $conversation && $conversation->hasParticipant((int) $user->id);
});
```

Same predicate as the REST guards — **no third party, not even an admin, can subscribe to a thread's live channel.** Admin oversight is REST-only, by design.

### Current transport state

```mermaid theme={null}
flowchart LR
    A["sendMessage()<br/>DB::transaction commits"] --> B["broadcast(new MessageSent)<br/>->toOthers()"]
    B --> C{"BROADCAST_CONNECTION"}
    C -->|"log (current default)"| D["written to<br/>storage/logs/laravel.log<br/>⇒ no client delivery"]
    C -->|"reverb (ready, not enabled)"| E["Reverb WebSocket server"]
    E --> F["channel auth:<br/>hasParticipant()"]
    F --> G["Client receives 'MessageSent'<br/>renders bubble inline"]
    D -.->|"fallback in production today"| H["Client refetches<br/>on window focus"]
```

* `.env.example` ships **`BROADCAST_CONNECTION=log`** — the payload is written to the log, **not delivered**. The REST contract is unaffected.
* The `REVERB_*` env block exists (`REVERB_APP_ID/KEY/SECRET/HOST/PORT/SCHEME`), so the server side is Reverb-ready; flipping the connection is the only backend change needed.
* **The frontend has no WebSocket client.** There is no `laravel-echo` / `pusher-js` dependency; the chat page refreshes on window focus instead:
  ```ts theme={null}
  // profelite_frontend/src/pages/Messages.tsx
  // No websockets (BROADCAST_CONNECTION=log): refresh when the tab regains focus.
  useEffect(() => {
    const onFocus = () => {
      void refetchConversations();
      if (selectedConv !== null) void refetchMessages();
    };
    window.addEventListener('focus', onFocus);
    return () => window.removeEventListener('focus', onFocus);
  }, [refetchConversations, refetchMessages, selectedConv]);
  ```
  There is **no polling interval** — a message from the other party appears when the tab regains focus or the user re-selects the thread. See §15 for what this means for perceived latency.

### Enabling real-time when porting

1. `composer require laravel/reverb` and publish its config.
2. Set `BROADCAST_CONNECTION=reverb` + the `REVERB_*` values; run `php artisan reverb:start`.
3. Run a queue worker (`php artisan queue:work`) — `ShouldBroadcast` is queued. Or switch the event to `ShouldBroadcastNow` to publish synchronously inside the request.
4. Expose the broadcasting auth route. Laravel 13 does **not** register `/broadcasting/auth` automatically when only `api` routing is configured — add `->withBroadcasting(...)` in `bootstrap/app.php` with `['middleware' => ['auth:sanctum']]`, since this API has no session cookie.
5. Frontend: `npm i laravel-echo pusher-js`, configure Echo with the bearer token in `authEndpoint`/`auth.headers`, then `echo.private('conversation.' + id).listen('.MessageSent', cb)` — note the **leading dot**, which is required because `broadcastAs()` sets a custom name.

***

## 12. File Sharing — Cloudflare R2 Attachment Pipeline

Attachments **never travel over the socket and never pass through the message endpoint as bytes**. The client uploads to R2 first, then references the returned file ids.

### 12.1 The two-step flow

```mermaid theme={null}
sequenceDiagram
    autonumber
    participant U as User (composer)
    participant FE as React client
    participant API as Laravel API
    participant R2 as Cloudflare R2
    participant DB as MySQL

    U->>FE: picks file(s) + types text
    FE->>FE: client guard: size ≤ 40 MB<br/>isVideoAttachment() → reject
    FE->>API: POST /api/r2_upload_file<br/>(multipart: company_id, type=2,<br/>file, conversation_id)
    API->>API: validate + video guard (MIME & extension)
    API->>DB: Conversation::find(conversation_id)
    API->>API: hasParticipant() || isAdmin()  → else 403
    API->>API: objectKey() = files/{conv-ref}/{userId}_{time}_{safeName}
    API->>R2: put(key, bytes, 'public')
    API->>DB: INSERT files (type=2, file_name=key, url)
    API-->>FE: 200 { file_id, url, custom_domain_url? }

    FE->>API: POST /api/conversations/{id}/messages<br/>{ content, attachment_ids:[file_id] }
    API->>DB: BEGIN
    API->>DB: INSERT messages
    API->>DB: SELECT files WHERE id IN (...)
    API->>DB: INSERT message_attachments (url, filename,<br/>mime_type guessed, size_bytes = 0)
    API->>DB: UPDATE conversations (last_message, last_message_at,<br/>recipient unread + 1)
    API->>DB: COMMIT
    API->>API: broadcast(MessageSent)->toOthers()
    API-->>FE: 201 MessageResource (attachment URLs included)
    FE->>R2: <img src="{url}"> — direct, no API round-trip
```

### 12.2 `FilesRController@r2_upload_file` — `POST /api/r2_upload_file`

```php theme={null}
$data = $request->validate([
    'company_id' => 'required|integer',
    'type'       => 'required|in:2',
    'file'       => 'required|file',
    // Optional: when this upload is a chat attachment, group it in R2 under the
    // conversation's folder (files/<conversation-reference>/…).
    'conversation_id' => 'nullable|integer|exists:conversations,id',
]);

// Images and documents only — videos are not allowed as attachments.
$uploaded = $request->file('file');
$ext = strtolower($uploaded->getClientOriginalExtension());
$videoExts = ['mp4','webm','mov','avi','mkv','flv','wmv','m4v','3gp','mpeg','mpg','ogv'];
if (str_starts_with((string) $uploaded->getMimeType(), 'video/') || in_array($ext, $videoExts, true)) {
    return response()->json(['message' => 'Les vidéos ne sont pas autorisées.'], 422);
}

$userId = $request->user()->id;

// Resolve the conversation folder — only for a participant (or an admin),
// so nobody can drop files into another thread's folder.
$folder = null;
if (! empty($data['conversation_id'])) {
    $conversation = Conversation::find($data['conversation_id']);
    if (! $conversation
        || ! ($request->user()->isAdmin() || $conversation->hasParticipant((int) $userId))) {
        return response()->json(['message' => 'Action non autorisée sur cette conversation.'], 403);
    }
    $folder = $conversation->reference;
}

$key = $this->objectKey($uploaded, $userId, $folder);
Storage::disk('r2')->put($key, file_get_contents($uploaded->getRealPath()), 'public');

$url = $this->r2Url($key);

$file = File::create([
    'company_id' => $data['company_id'],
    'user_id'    => $userId,
    'type'       => File::TYPE_R2,
    'file_name'  => $key,          // the R2 object key
    'url'        => $url,
]);

$payload = ['file_id' => $file->id, 'url' => $url, 'message' => 'File uploaded successfully to R2.'];
if ($custom = $this->customDomainUrl($key)) { $payload['custom_domain_url'] = $custom; }

return response()->json($payload);
```

**Response `200`:**

```json theme={null}
{
  "file_id": 31,
  "url": "https://pub-xxxx.r2.dev/files/conv-ar3ry68dgmqq/5_1759000000_devoir.pdf",
  "message": "File uploaded successfully to R2.",
  "custom_domain_url": "https://cdn.profelite.com/files/conv-ar3ry68dgmqq/5_1759000000_devoir.pdf"
}
```

### 12.3 Object key layout

```php theme={null}
private function objectKey($uploaded, int $userId, ?string $folder = null): string
{
    $name = $userId.'_'.time().'_'.$this->sanitizeFileName($uploaded->getClientOriginalName());

    // Chat attachments are grouped under their conversation's folder.
    return $folder ? 'files/'.$folder.'/'.$name : 'files/'.$name;
}

private function sanitizeFileName(string $name): string
{
    $name = str_replace(' ', '-', trim($name));
    return preg_replace('/[^A-Za-z0-9._-]/', '', $name);
}

private function r2Url(string $key): string
{
    return rtrim((string) env('R2_URL'), '/').'/'.$key;
}

private function customDomainUrl(string $key): ?string
{
    $domain = env('R2_CUSTOM_DOMAIN');
    if ($domain === null || $domain === '') { return null; }
    return rtrim((string) $domain, '/').'/'.$key;
}
```

| Bucket prefix | Contents | Written by |
| - | - | - |
| `files/{conv-reference}/` | **chat attachments**, one folder per thread | `r2_upload_file` with `conversation_id` |
| `files/` (root) | non-chat uploads (blog covers, avatars) | `r2_upload_file` without `conversation_id`, `r2_bulk_upload` |
| `conversations_backups/` | `conversation-{id}.xlsx` thread archives | `adminConversationBackup` |
| `health-checks/` | throwaway objects from the admin R2 probe | `StorageDiagnosticsController@r2Health` |

**Key naming:** `{userId}_{unixTime}_{sanitizedOriginalName}`. The `userId_time` prefix makes keys effectively collision-free; `sanitizeFileName()` strips spaces → `-` and drops anything outside `[A-Za-z0-9._-]`. Side effect: `message_attachments.filename` is `basename($key)`, i.e. the **prefixed** name, so the UI displays `5_1759000000_devoir.pdf` rather than `devoir.pdf` (see §21).

### 12.4 Limits & the video rule

| Rule | Value | Enforced where |
| - | - | - |
| Max attachment size | **40 MB** | **Client only** (`MAX_ATTACHMENT_BYTES` in `messagingFiles.ts`) — the backend has `required\|file` with **no `max:` rule**; the real server ceiling is PHP's `upload_max_filesize`/`post_max_size` |
| Videos | **rejected** `422 "Les vidéos ne sont pas autorisées."` | **Both** — client `isVideoAttachment()`, server MIME prefix `video/` **and** extension allowlist |
| Accepted types | images + documents | Client `ATTACHMENT_ACCEPT = 'image/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.csv,.zip'` (a picker hint only) |
| Content-type allowlist | **none server-side** | ⚠️ anything non-video passes — see below |

> ⚠️ **The video guard lives on `r2_upload_file` only.** `POST /upload_file` (with `type = 2`) and `POST /r2_bulk_upload` reach the same bucket **without** it. Chat uploads must keep using `/api/r2_upload_file`. Don't "unify" the three endpoints without moving the check.

> ⚠️ **When porting, add a server-side size cap and a positive MIME allowlist.** The current design is a *deny-list* (block videos) with the size limit enforced only in the browser — both are trivially bypassable with curl. `'file' => 'required|file|max:40960|mimes:jpg,jpeg,png,gif,webp,pdf,doc,docx,xls,xlsx,ppt,pptx,txt,csv,zip'` would close both holes. Also note `file_get_contents($uploaded->getRealPath())` reads the **whole file into PHP memory** before the `put()` — with a 40 MB cap that's 40 MB of RSS per concurrent upload; `Storage::disk('r2')->putFileAs(...)` streams instead.

### 12.5 Retrieval

Two flavors exist per storage backend; **prefer the URL form**:

| Endpoint | Behaviour |
| - | - |
| `GET /api/file_r2/{id}/url` | Returns the stored public URL + an `exists` flag. **No bytes move.** |
| `GET /api/file_r2/{id}/base64` | Reads the object and inlines it into JSON (\~33% larger, full read into memory). Only for clients that cannot fetch the URL directly. |

In practice the chat needs **neither**: `MessageResource.attachments[].url` and the broadcast payload already carry the public URL, so the client renders `<img src>` / a download link straight from R2.

### 12.6 CORS — three owners, only one of them is the backend

A recurring "the image won't load" class of bug. **Which layer answers the request decides who sets the CORS headers:**

| Request | Headers come from | Configured in |
| - | - | - |
| `/api/...` JSON | **Laravel** | `config/cors.php`, origins via `CORS_ALLOWED_ORIGINS`. `supports_credentials` is **`false` on purpose** — auth is a bearer token, no cookies cross origins, and that is exactly what makes a `*` origin legal |
| R2 public URLs (`R2_URL` / `R2_CUSTOM_DOMAIN`) | **Cloudflare** | the **R2 dashboard** → bucket → Settings → CORS Policy. **Nothing in the repo affects this** |
| `/storage/files/...` (local, `type = 1`) | **the web server** | nginx `add_header`, scoped to `/storage/` — these bypass PHP entirely and ship no `Access-Control-Allow-Origin` at all |

A plain `<img src>` is never subject to CORS; only a programmatic `fetch()` of the bytes is — which is why "it opens in a new tab but breaks in the app" is the classic symptom. `POST /admin/storage/r2/test` **cannot** catch a CORS problem: it runs server-side, where CORS does not apply.

***

## 13. The Admin Role — Oversight, Exports & History Wipe

### 13.1 What the admin role is, and is not

`Role::ADMIN = 3`, checked with `$user->isAdmin()` inline in each of the six admin methods.

| Admin **can** | Admin **cannot** |
| - | - |
| List/search **every** conversation on the platform | Send a message (no admin composer exists) |
| Read **every** message of any thread | Subscribe to a thread's live channel (`hasParticipant()` excludes them) |
| See both participants' **email, phone, Meet room, parent name** | Appear in `GET /conversations` as a participant |
| Export all threads, or one thread, to Excel | Edit or delete an **individual** message (only the all-or-nothing wipe exists) |
| Archive one thread's history to R2 | Delete the conversation **row** itself |
| **Permanently wipe** a thread's entire history | Undo a wipe |
| Upload into any conversation's R2 folder (`isAdmin()` bypass in `r2_upload_file`) | Resolve their own reports anonymously — the moderation queue records `reported_by_id` |

The oversight read path is deliberately **REST-only and pull-based**: an admin opening a thread is a request, not a subscription, so there is no silent live surveillance channel.

### 13.2 The archive-before-destroy workflow

```mermaid theme={null}
flowchart TD
    A["Admin → /admin/conversations<br/>GET adminIndex (paginated 20, q search)"] --> B["Click a row<br/>→ /admin/conversations/:id"]
    B --> C["GET adminShow<br/>full thread, read-only, no composer"]
    C --> D{"Keep the history?"}
    D -->|"Download a copy"| E["GET /admin/conversations/{id}/export.xlsx<br/>adminConversationXlsx<br/>→ streamed .xlsx (1 row/message)"]
    D -->|"Archive to the cloud"| F["POST /admin/conversations/{id}/backup.xlsx<br/>adminConversationBackup<br/>→ R2 conversations_backups/conversation-{id}.xlsx<br/>(deterministic key: OVERWRITES the previous backup)"]
    E --> G["Confirm modal:<br/>« Vider l'historique »<br/>warns: permanent, for BOTH participants"]
    F --> G
    D -->|"No copy needed"| G
    G --> H["DELETE /admin/conversations/{id}/messages<br/>adminWipeMessages"]
    H --> I["R2 objects deleted (best effort)<br/>File rows force-deleted"]
    I --> J["message_attachments deleted<br/>messages deleted<br/>summary fields reset"]
    J --> K["Conversation ROW KEPT<br/>participants keep an empty thread"]

    style H fill:#c62828,color:#fff
    style K fill:#2e7d32,color:#fff
```

Both exports share `loadConversationForExport()` + `buildConversationSpreadsheet()` (§10.1–10.2), so the downloaded file and the R2 archive are **byte-for-byte the same workbook**.

The admin UI (`pages/admin/ConversationDetail.tsx`) fetches the `.xlsx` endpoints through an authenticated **blob** fetch (`downloadXlsx()`), not an `<a href>` — a plain link cannot carry the bearer token.

### 13.3 `adminWipeMessages` — the permanent history wipe

**`DELETE /api/admin/conversations/{id}/messages`** — a **hard delete**. No soft-delete, no recycle bin, no undo.

```php theme={null}
/**
 * Admin: permanently wipe a conversation's message history (hard delete —
 * no soft-delete). Removes every message, its attachment rows and the
 * attachment files themselves (R2 objects / local disk files included),
 * then resets the thread's summary fields. The conversation row is kept.
 */
public function adminWipeMessages($id, Request $request)
{
    try {
        $me = $request->user();
        if (! $me->isAdmin()) {
            return response()->json(['message' => 'Forbidden.'], 403);
        }

        $conversation = Conversation::findOrFail($id);

        $attachments = MessageAttachment::whereIn('message_id', $conversation->messages()->select('id'))
            ->with('file')
            ->get();

        // Remove the stored bytes first — best effort for R2 (the object
        // may already be gone); the File model hook cleans the local disk.
        foreach ($attachments as $attachment) {
            $file = $attachment->file;
            if (! $file) { continue; }

            if ($file->isR2()) {
                try {
                    Storage::disk('r2')->delete($file->file_name);
                } catch (\Throwable $e) {
                    report($e);          // swallowed: a missing object must not block the wipe
                }
            }

            $file->forceDelete();        // forceDelete: bypass SoftDeletes, really drop the row
        }

        $deleted = 0;

        DB::transaction(function () use ($conversation, &$deleted) {
            MessageAttachment::whereIn('message_id', $conversation->messages()->select('id'))->delete();
            $deleted = $conversation->messages()->delete();

            $conversation->forceFill([
                'last_message'      => null,
                'last_message_at'   => null,
                'student_unread'    => 0,
                'instructor_unread' => 0,
            ])->save();
        });

        return response()->json([
            'message' => 'Conversation history permanently deleted.',
            'deleted_messages' => $deleted,
        ]);
    } catch (ModelNotFoundException $e) {
        return response()->json(['message' => 'Not found.'], 404);
    } catch (\Throwable $e) {
        report($e);
        return $this->serverError();
    }
}
```

**Response `200`:**

```json theme={null}
{
  "message": "Conversation history permanently deleted.",
  "deleted_messages": 7
}
```

**Deletion order, and why it is that order:**

| Step | Action | Rationale |
| :- | :- | :- |
| 1 | Collect attachments via `whereIn('message_id', $conversation->messages()->select('id'))` | A **subquery**, not a loaded id array — scales to any thread size |
| 2 | For each R2 file: `Storage::disk('r2')->delete($file->file_name)` in its own try/catch | **Bytes first.** If this ran after the rows were gone, a crash mid-way would orphan objects in the bucket with no way left to find their keys |
| 3 | `$file->forceDelete()` | `File` uses `SoftDeletes`; a plain `delete()` would only set `deleted_at` and leave the row. `forceDelete()` also fires the model hook that removes a **local** disk file |
| 4 | `MessageAttachment::…->delete()` **inside** the transaction | Explicit, even though `cascadeOnDelete` would handle it — the code does not rely on the FK |
| 5 | `$conversation->messages()->delete()` → returns the **row count** | That count is the `deleted_messages` in the response |
| 6 | `forceFill([...])->save()` | Resets `last_message`, `last_message_at` and **both** unread counters so the empty thread doesn't show a stale preview or badge. `forceFill` bypasses `$fillable` for the mass assignment |
| 7 | **The `conversations` row is kept** | Both participants keep an empty, working thread — and the `reference` (hence the R2 folder) survives |

> ⚠️ **Steps 2–3 run *outside* the transaction, and that is unavoidable** — R2 is not transactional. If the request dies between step 3 and step 5, the files are gone while the `messages` rows remain, leaving attachment rows pointing at deleted `File`s (`file_id` is `nullOnDelete`, so they survive with their denormalized `url` pointing at a 404). It fails in the "bytes gone, metadata left" direction, which is the safer one for a *delete* operation, but it is not atomic. Note this in any port.

> ⚠️ **Step 2–3 is an N+1 by construction:** one `DELETE` HTTP call to R2 **plus** one SQL `DELETE` per attachment. A thread with 200 attachments makes 200 round-trips to Cloudflare in a single synchronous request. The measured 1.6 ms for a 500-message wipe (§15) had **zero** attachments — with attachments, R2 network latency dominates completely (\~50–200 ms each). **Queue this** when porting, or at minimum batch the key deletes (`Storage::disk('r2')->delete([...keys])` accepts an array and issues one multi-delete).

**Safety rails that exist (all client-side):** the admin UI shows a `WipeConfirmModal` whose copy states the deletion is permanent **for both participants (élève et instructeur)** and irreversible, the button is disabled on already-empty threads, and the export/backup actions sit next to it. **The backend has no confirmation token, no dry-run, and no audit record of who wiped what** — see §21.

### 13.4 Related admin tooling

| Endpoint | Controller | Chat relevance |
| - | - | - |
| `POST /api/admin/storage/r2/test` | `StorageDiagnosticsController@r2Health` | Real round-trip on the bucket (write → HEAD → read-back → public URL → delete under `health-checks/`, cleaned in a `finally`). Proves attachment storage works. **A failure is still a `200`** with `ok: false` |
| `GET /api/r2_storage_stats` | `FilesRController@r2_storage_stats` | Bucket size/counts/growth projection. Currently **unused by the frontend** |
| `GET /api/list_r2_folders` | `FilesRController@list_r2_folders` | Lists `files/` sub-folders with a file count each — i.e. per-conversation attachment counts |

***

## 14. Moderation & Reporting

`ModerationController` consumes the `message_reports` rows that `ConversationController@report` writes. **Reports flag the whole conversation**, not a single message (the optional `message_id` is only a pointer).

### Status machine

```mermaid theme={null}
stateDiagram-v2
    [*] --> en_attente : POST /conversations/{id}/report
    en_attente --> résolu : POST /admin/reports/{id}/resolve
    en_attente --> rejeté : POST /admin/reports/{id}/reject
    résolu --> en_attente : POST /admin/reports/{id}/reopen
    rejeté --> en_attente : POST /admin/reports/{id}/reopen
    résolu --> rejeté : reject
    rejeté --> résolu : resolve
```

All transitions go through **one shared private helper** — the pattern to copy:

```php theme={null}
public function resolve($id, Request $request) {
    return $this->setStatus($id, $request, MessageReport::STATUS_RESOLVED, 'Signalement résolu.');
}
public function reject($id, Request $request) {
    return $this->setStatus($id, $request, MessageReport::STATUS_REJECTED, 'Signalement rejeté.');
}
public function reopen($id, Request $request) {
    return $this->setStatus($id, $request, MessageReport::STATUS_PENDING, 'Signalement rouvert.');
}

private function setStatus($id, Request $request, string $status, string $message)
{
    if ($deny = $this->denyUnlessAdmin($request)) { return $deny; }

    try {
        $report = MessageReport::findOrFail($id);
        $report->status = $status;
        $report->save();

        return response()->json(['message' => $message]);
    } catch (ModelNotFoundException $e) {
        return response()->json(['message' => 'Not found.'], 404);
    } catch (\Throwable $e) {
        report($e);
        return $this->serverError();
    }
}
```

Note there is **no transition validation** — any status can go to any status, and `reopen` on an already-pending report is a no-op write.

### The extracted admin guard (better than `ConversationController`'s inline copies)

```php theme={null}
private function denyUnlessAdmin(Request $request)
{
    if (! $request->user()->isAdmin()) {
        return response()->json(['message' => 'Forbidden.'], 403);
    }

    return null;
}

// usage:  if ($deny = $this->denyUnlessAdmin($request)) { return $deny; }
```

### `index` — `GET /admin/reports`

Paginated (20) with filters `status`, `reason`, `q` (`note` / `reason` / either participant's name or email), **plus stat-card counts in the same response**:

```php theme={null}
return response()->json([
    'counts' => [
        'total'    => MessageReport::count(),
        'pending'  => MessageReport::where('status', MessageReport::STATUS_PENDING)->count(),
        'resolved' => MessageReport::where('status', MessageReport::STATUS_RESOLVED)->count(),
        'rejected' => MessageReport::where('status', MessageReport::STATUS_REJECTED)->count(),
    ],
    'reports' => MessageReportResource::collection($reports),
]);
```

Four extra `COUNT(*)` queries per page load — cheap now, worth a single `GROUP BY status` later.

### `show` — `GET /admin/reports/{id}`

The report plus conversation context, with the thread preview **capped at 50 messages** (`->orderBy('id')->limit(50)`) — the *oldest* 50, deliberately, since a report is usually about how a thread started. Contrast with `adminShow` (§9.2), which is uncapped.

### `pendingCount` — `GET /admin/reports/pending-count`

One `COUNT(*)`, powers the admin sidebar's live badge.

***

## 15. Performance — Measured Response Times

### 15.1 How these numbers were produced

Measured by instrumenting each endpoint through Laravel's HTTP test kernel with `DB::listen()` for query counts and `microtime(true)` around the dispatch, on this dataset:

* **31 conversations** (1 benchmarked + 30 others on the same instructor)
* **500 messages** in the benchmarked thread, 120-char bodies, alternating senders
* **0 attachments** (so attachment-path costs are called out separately)
* Each endpoint warmed once, then measured; destructive endpoints measured **cold** (no warm-up)
* PHP **8.4.6** CLI, macOS, **SQLite in-memory** (MySQL was not running locally)

**Read these as the application's own cost — PHP, Eloquent hydration, resource serialization — not as production wall-clock.** Four things are excluded and must be added for a real-world figure:

| Excluded | Typical addition |
| - | - |
| **Sanctum token resolution** — `actingAs()` bypasses it | **+2 queries** (`personal_access_tokens` + `users`) on *every* authenticated request |
| MySQL over TCP instead of in-memory SQLite | ≈ **+0.2–1 ms per query** on localhost; more across a network/container boundary |
| HTTP server, TLS, JSON transfer | a few ms, plus payload ÷ bandwidth |
| Client↔server RTT | 10–100+ ms depending on geography |

A realistic production estimate is roughly **(measured value) + (queries + 2) × per-query latency + network RTT**. For the chat endpoints that lands in the **low tens of milliseconds** server-side.

### 15.2 Measured results

| Endpoint | Status | Time | Queries | Payload |
| :- | :-: | -: | -: | -: |
| `GET /conversations` (student, 1 thread) | 200 | **0.9 ms** | 3 | 321 B |
| `GET /conversations` (instructor, 31 threads) | 200 | **2.7 ms** | 3 | 9.9 KB |
| `GET /conversations/{id}/messages` (latest 50 of 500) | 200 | **2.0 ms** | 3 | 13.5 KB |
| `GET /conversations/{id}/messages?before=300` | 200 | **2.2 ms** | 3 | 13.5 KB |
| `POST /conversations/{id}/messages` (text only) | 201 | **1.7 ms** | **9** | 153 B |
| `POST /conversations/{id}/read` (cold, 250 unread) | 200 | **0.9 ms** | 3 | 16 B |
| `POST /conversations/{id}/read` (already read) | 200 | **0.5 ms** | 2 | 16 B |
| `POST /conversations/{id}/archive` | 200 | **0.6 ms** | 2 | 32 B |
| `GET /admin/conversations` (page of 20 / 31) | 200 | **1.9 ms** | 4 | 8.5 KB |
| **`GET /admin/conversations/{id}`** (full 500-msg thread) | 200 | **15.5 ms** | 5 | **135 KB** |
| **`GET /admin/conversations/{id}/export.xlsx`** (500 rows) | 200 | **74.7 ms** | 5 | streamed |
| `GET /admin/conversations.xlsx` (31 rows) | 200 | **4.4 ms** | 3 | streamed |
| `DELETE /admin/conversations/{id}/messages` (500 msgs, 0 attachments) | 200 | **1.6 ms** | 5 | 78 B |

### 15.3 Reading the numbers

**The query counts are the portable fact** — they hold on any database, and they are flat:

* **Every read is 3–5 queries, constant in data size.** No N+1 anywhere on the read paths: `->with(['student.image','instructor.image'])` is 2 extra queries for 1 or 1000 rows; `->with('attachments')` is 1 more; `withCount('messages')` is an aggregated subquery, not a loop.
* **`POST message` costs 9 queries** — the most of any chat endpoint. Breakdown: `SELECT` conversation → `INSERT` message → `UPDATE` conversation → then the broadcast's `load('sender','attachments')` + `sender->image` + the response's `load('attachments')`. Roughly **4 of the 9 queries exist only to build the broadcast payload** — and with `BROADCAST_CONNECTION=log` they are spent on a log line nobody reads. Switching to Reverb makes them useful; switching the event off would make the send noticeably leaner.
* **`markRead` is cheap and self-limiting**: one bulk `UPDATE … WHERE read_at IS NULL`, so 250 unread messages cost the same single statement as 1, and a repeat call drops to 2 queries matching 0 rows.
* **`archive` is 2 queries** — `SELECT` + `UPDATE`.
* **Keyset pagination is flat**: `?before=300` costs exactly what the first page costs (2.0 vs 2.2 ms), which is the whole point of `WHERE id < ?` over `OFFSET`. Paging to message 10 000 will be the same.

**The three slow spots, and why:**

1. **`GET /admin/conversations/{id}` — 15.5 ms / 135 KB for 500 messages.** Only 5 queries, so this is **not** a query problem: it is hydrating 500 Eloquent models and serializing 500 resources. Cost is **O(messages in thread), unbounded**. A 5 000-message thread would be \~150 ms and \~1.3 MB. This is the endpoint to paginate first in any port.
2. **`GET /admin/conversations/{id}/export.xlsx` — 74.7 ms for 500 rows.** \~**0.15 ms per message**, nearly all of it inside PhpSpreadsheet (`fromArray` + `setAutoSize(true)` on 10 columns, which measures every cell's rendered width). A 5 000-message thread → **\~700 ms**; 20 000 → several seconds and real memory pressure. Streaming only helps the transfer, not the build: the whole workbook is assembled in memory first. If threads can get long, queue the export or drop `setAutoSize`.
3. **`POST /admin/conversations/{id}/backup.xlsx`** — not separately timed because it is dominated by network, but it is **the export build (above) + a temp-file write + an R2 `PUT` of the whole workbook**, all synchronous. Budget the export time **plus** R2 upload latency.

**Where the wipe's real cost hides.** 1.6 ms for 500 messages looks free — because that thread had **no attachments**. With attachments the loop in §13.3 does one R2 `DELETE` HTTP request **and** one SQL `DELETE` per file:

```text theme={null}
wipe time ≈ 1.6 ms + N_attachments × (R2 round-trip ≈ 50–200 ms + 1 query)
```

A 200-attachment thread is therefore a **10–40 second synchronous request** — well past PHP's default `max_execution_time`. Batch the key deletes and/or queue the job.

**Perceived chat latency is not an API problem.** Server-side the send is \~2 ms, but `BROADCAST_CONNECTION=log` means nothing is pushed and the frontend has no WebSocket client and **no polling interval** — it refetches only on `window.focus`. So the *other* participant sees a new message when they refocus the tab, not in real time:

| Transport | Delivery latency to the recipient |
| - | - |
| **Current (`log` + focus refetch)** | **Unbounded** — until the recipient refocuses the tab or reselects the thread |
| Add client polling (e.g. 5 s) | ≤ 5 s, at the cost of 1 request per interval per open client |
| Reverb + Echo (code is ready) | **Sub-100 ms** — one WebSocket frame, no refetch, payload already complete |

The `MessageSent` payload is deliberately self-sufficient (§11), so enabling Reverb adds **zero** extra API calls per delivered message.

### 15.4 Scaling notes for a port

| Concern | Status today | Fix |
| - | - | - |
| `ORDER BY last_message_at` unindexed | works at small scale; full sort on `adminIndex` | `index('last_message_at')`, or composites with each participant column |
| `GET /conversations` not paginated | `->get()` returns every thread | paginate, or cap + "load more" |
| `GET /admin/conversations/{id}` unbounded | 15 ms / 135 KB at 500 msgs; linear | keyset-paginate like the participant endpoint |
| `.xlsx` build is synchronous | 75 ms @ 500 rows, \~700 ms @ 5 000 | queue + notify, or drop `setAutoSize` |
| Wipe is N+1 on R2 deletes | 1 HTTP call per attachment, synchronous | `delete([...keys])` batch + queue |
| 4 of 9 send queries feed a logged broadcast | wasted while `BROADCAST_CONNECTION=log` | enable Reverb (makes them useful) |
| `q` search is a leading-wildcard `LIKE` × 2 subqueries | unindexable | FULLTEXT or a denormalized search column |
| Attachment upload buffers the whole file in PHP memory | 40 MB RSS per concurrent upload | `putFileAs()` to stream |

***

## 16. Security Model

### Layered access control

```mermaid theme={null}
flowchart TD
    R["Request to /api/conversations/*"] --> L1["Layer 1 — auth:sanctum<br/>valid bearer token? else 401"]
    L1 --> L2["Layer 2 — not_banned middleware<br/>user.status == 'banned'? → 403"]
    L2 --> L3{"Layer 3 — route class"}
    L3 -->|"participant route"| P["Conversation::hasParticipant(me.id)<br/>else 403 'Forbidden.'"]
    L3 -->|"admin route"| A["$me->isAdmin()<br/>else 403 'Forbidden.'"]
    P --> L4["Layer 4 — resource scoping<br/>ConversationResource hides the<br/>other side's unread/archived;<br/>default_meet_url student-only"]
    A --> L5["Layer 4' — adminParticipant()<br/>adds email / phone / meet / parent_name"]
```

**Layer 2** (`app/Http/Middleware/EnsureUserIsNotBanned.php`, aliased in `bootstrap/app.php`):

```php theme={null}
public function handle(Request $request, Closure $next): Response
{
    $user = $request->user();

    if ($user && $user->isBanned()) {
        return response()->json([
            'error' => true,
            'message' => 'Your account has been banned. Please contact support.',
        ], 403);
    }

    return $next($request);
}
```

A banned user's **existing token keeps working at the Sanctum layer** — this middleware is what actually locks them out of chat. Any port must apply it to the chat group, not just to login.

### What is guarded well

* **Thread access** — one predicate (`hasParticipant`), seven call sites, including the broadcast channel and the upload endpoint. No role can read a thread it is not in (admins go through separate routes).
* **`store` cannot be spoofed** — the caller's own side is taken from `$me->id`, never from the body.
* **`report` cannot be aimed** — `author_id` is derived from the thread, not supplied.
* **Upload folder isolation** — `r2_upload_file` verifies `hasParticipant() || isAdmin()` before using a conversation's `reference` as the R2 folder, so nobody can write into another thread's folder. Covered by a test (§19).
* **No driver detail in errors** — `serverError()` + `report($e)`; `$e->getMessage()` never reaches a client on these routes.
* **Counterpart privacy** — `default_meet_url` is student-only; participants never get each other's email/phone (only admins do).

### What to harden when porting

| # | Gap | Fix |
| :- | :- | :- |
| 1 | **`attachment_ids` ownership is never checked** (§8.4) — any existing `File` id gets linked and its URL copied into the thread | `File::where('id',$fileId)->where('user_id',$me->id)->first()`, or verify the key's `files/{reference}/` prefix |
| 2 | **No role check on the counterpart** in `store` (§8.2) — a student↔student thread can be created | `Rule::exists('users','id')->where('role_id', Role::INSTRUCTOR)` + reject `student_id === instructor_id` |
| 3 | **Upload size cap is client-side only** (§12.4) | add `max:40960` to the `file` rule |
| 4 | **MIME deny-list, not allow-list** (§12.4) — only videos are blocked; an `.html`/`.svg` served from a public bucket is a stored-XSS vector on the R2 origin | `mimes:…` allow-list; serve attachments with `Content-Disposition: attachment` |
| 5 | **No empty-message guard** (§8.4) | `'content' => 'required_without:attachment_ids'` |
| 6 | **No rate limiting on send or report** | `throttle:` on the send route; per-(reporter, conversation) uniqueness on reports |
| 7 | **The wipe has no audit trail** — nothing records *which admin* wiped *which thread* and *when* | log it, or write an `admin_actions` row before deleting |
| 8 | **Admin guard duplicated 6×** inline | extract an `EnsureUserIsAdmin` middleware on an `/admin` route group |
| 9 | **`attachment_ids.*` is only `integer`** | add `exists:files,id` so unknown ids `422` instead of silently vanishing |
| 10 | **`message_id` on a report isn't verified to belong to the thread** | `Rule::exists('messages','id')->where('conversation_id', $id)` |

***

## 17. Error Contract

| Status | Body | When |
| :- | :- | :- |
| `401` | Laravel default | Missing / invalid bearer token (`auth:sanctum`) |
| `403` | `{"error": true, "message": "Your account has been banned. Please contact support."}` | `not_banned` middleware |
| `403` | `{"message": "Forbidden."}` | Not a participant (participant routes) / not an admin (admin routes) |
| `403` | `{"message": "Action non autorisée sur cette conversation."}` | `r2_upload_file` with a `conversation_id` the caller isn't in |
| `404` | `{"message": "Not found."}` | Unknown conversation — participant routes, `adminShow`, `adminWipeMessages` |
| `404` | `{"message": "Conversation introuvable."}` | Unknown conversation — the two **export** endpoints (French, inconsistent with the above) |
| `422` | `{"error": true, "message": "<first validation error>"}` | `store`, `sendMessage`, `report` — note the `error: true` key and that **only the first** message is returned |
| `422` | `{"message": "Les vidéos ne sont pas autorisées."}` | Video upload attempt (no `error` key — different shape) |
| `500` | `{"message": "Une erreur est survenue. Veuillez réessayer plus tard."}` | Any `\Throwable`; real cause in `storage/logs/laravel.log` via `report($e)` |

The `422` shape comes from the house pattern:

```php theme={null}
catch (ValidationException $e) {
    return response()->json([
        'error' => true,
        'message' => collect($e->errors())->flatten()->first(),
    ], 422);
}
```

> Three inconsistencies to normalize in a port: the `404` message language differs between export and non-export endpoints; `422` bodies sometimes carry `error: true` and sometimes don't; and the flatten-to-first-message approach discards per-field errors, so a client cannot highlight the offending input. Returning Laravel's standard `{message, errors:{field:[…]}}` is friendlier — but note this API's **French validation strings are part of the contract**: the frontend's `authErrorMessage` substring-matches them.

***

## 18. Configuration & Environment Variables

```ini theme={null}
# ---- Broadcasting (real-time) ----
BROADCAST_CONNECTION=log          # 'reverb' to actually deliver MessageSent
QUEUE_CONNECTION=database         # ShouldBroadcast is queued → a worker must run

# Reverb (used only when BROADCAST_CONNECTION=reverb)
REVERB_APP_ID=local-app
REVERB_APP_KEY=local-key
REVERB_APP_SECRET=local-secret
REVERB_HOST="localhost"
REVERB_PORT=8080
REVERB_SCHEME=http

# ---- File storage ----
FILESYSTEM_DISK=local             # default disk; chat attachments explicitly use 'r2'
R2_ACCESS_KEY_ID=
R2_SECRET_ACCESS_KEY=
R2_DEFAULT_REGION=auto
R2_BUCKET=profelite-bucket
R2_ENDPOINT=
R2_USE_PATH_STYLE_ENDPOINT=true
R2_URL=                           # public bucket URL — used to BUILD every stored file URL
R2_CUSTOM_DOMAIN=                 # optional; adds custom_domain_url to upload responses
R2_FREE_TIER_STORAGE_GB=10
R2_WARN_THRESHOLD=0.8

# ---- Locale (API answers in French) ----
APP_LOCALE=fr
APP_FALLBACK_LOCALE=en
```

The `r2` disk (`config/filesystems.php`):

```php theme={null}
'r2' => [
    'driver' => 's3',
    'key' => env('R2_ACCESS_KEY_ID'),
    'secret' => env('R2_SECRET_ACCESS_KEY'),
    'region' => env('R2_DEFAULT_REGION', 'auto'),
    'bucket' => env('R2_BUCKET'),
    'endpoint' => env('R2_ENDPOINT'),
    'use_path_style_endpoint' => env('R2_USE_PATH_STYLE_ENDPOINT', false),
    'url' => env('R2_URL'),
    'throw' => false,                    // ← see the warning in §9.5
    'options' => ['http' => ['connect_timeout' => 60, 'timeout' => 500]],
],
```

> ⚠️ **`R2_URL` must be set** — `r2Url()` builds every stored URL as `rtrim(env('R2_URL'),'/').'/'.$key`. With it empty, uploads succeed and every stored `url` is a broken `/files/...` path. Also note `r2_upload_file` reads `env()` **directly** rather than `config()`: after `php artisan config:cache` (which the production entrypoint runs) `env()` returns `null`. Route these through `config/` when porting.

> ⚠️ **`throw => false` is deliberate** on the shared disk so a failed chat upload doesn't `500` — but it means `put()`/`delete()` return `false` instead of throwing. Check return values (§9.5).

PHP extensions required at runtime: **`zip`, `xml`, `gd`** (PhpSpreadsheet). Missing them breaks the exports **at runtime, not at build time**.

***

## 19. Tests

`tests/Feature/ChatAttachmentFolderTest.php` is the one chat-specific test suite. It covers the R2 folder isolation rule:

| Test | Asserts |
| - | - |
| `test_conversation_gets_a_reference_on_creation` | `reference` is non-null and starts with `conv-` |
| `test_participant_upload_is_grouped_under_the_conversation_folder` | key starts with `files/{reference}/` and `Storage::disk('r2')->assertExists()` |
| `test_upload_without_a_conversation_stays_at_the_root` | key is `files/…` with no `/conv-` segment |
| `test_a_non_participant_cannot_upload_into_the_conversation_folder` | outsider gets `403` |

Setup pattern worth reusing (no factories exist in this project — seed explicitly):

```php theme={null}
Role::forceCreate(['id' => Role::STUDENT, 'label' => 'Student']);
Role::forceCreate(['id' => Role::INSTRUCTOR, 'label' => 'Instructor']);
Role::forceCreate(['id' => Role::ADMIN, 'label' => 'Admin']);
Company::forceCreate(['id' => Company::DEFAULT_ID, 'label' => 'ProfElite', 'currency' => 'EUR']);

Storage::fake('r2');          // no real bucket calls
// then: $this->actingAs($user, 'sanctum')->postJson(...)
```

**Not covered — worth adding in a port:** the send/read/archive/report happy paths, participant `403`s, the admin `403`s, the `before=` cursor, and above all **the wipe** (that message rows, attachment rows, `File` rows and R2 objects all disappear and the conversation row survives with reset counters).

***

## 20. Porting Checklist — Duplicating This System

Build in this order; each step is independently testable.

### Phase 1 — schema

* [ ] `conversations` + `UNIQUE(student_id, instructor_id)` + **`index('last_message_at')`** (§4.3)
* [ ] `reference` column, `string(40)`, `unique`, nullable (populated by the model hook)
* [ ] `messages` (`content` nullable, `read_at` nullable, `kind` string, `booking` json)
* [ ] `message_attachments` (`file_id` nullable `nullOnDelete`, denormalized `url`/`filename`/`mime_type`/`size_bytes`)
* [ ] `message_reports`
* [ ] a `files` table with a `type` discriminator (local vs object storage) + `SoftDeletes`

### Phase 2 — models

* [ ] `SerializesDates` trait (`Y-m-d H:i:s` on every timestamp)
* [ ] `Conversation` with `booted()` → `generateReference()`, and **`hasParticipant()`**
* [ ] `Message`, `MessageAttachment`, `MessageReport` (+ status constants)
* [ ] `File` with `TYPE_*` constants, `isR2()`/`isLocal()`, and the local-disk `deleting` hook — **plus a decision on whether object-storage cleanup belongs in the hook or stays explicit** (§5)

### Phase 3 — auth & guards

* [ ] Token auth (Sanctum or equivalent) on the whole chat group
* [ ] A ban/status middleware equivalent to `not_banned`
* [ ] Role constants + `isStudent()`/`isInstructor()`/`isAdmin()`
* [ ] **Extract an admin middleware** instead of 6 inline `isAdmin()` checks (§16 #8)

### Phase 4 — participant endpoints

* [ ] `index` (role branch, `?archived`, eager-load avatars) — **paginate it this time**
* [ ] `store` (`firstOrCreate`, role-dependent validated field, **+ counterpart role check + self-thread guard**)
* [ ] `messages` (keyset `before`, `limit 50`, desc fetch → asc present)
* [ ] `sendMessage` (one transaction: message + attachments + preview + recipient unread; broadcast after commit; **+ attachment ownership check + `required_without` guard**)
* [ ] `markRead` (zero own counter + bulk `UPDATE … WHERE read_at IS NULL`)
* [ ] `archive` (toggle — or make it an explicit setter)
* [ ] `report` (reason constant, derived `author_id`)

### Phase 5 — resources

* [ ] Disable resource wrapping (`JsonResource::withoutWrapping()`) **or** keep wrapping consistently — just don't end up with a mix like §6 documents
* [ ] Caller-aware `ConversationResource` (collapse per-side columns to `unread`/`archived`/`counterpart`)
* [ ] `MessageResource` with `whenLoaded('attachments')`
* [ ] `MessageReportResource`

### Phase 6 — real-time

* [ ] `MessageSent implements ShouldBroadcast`, `PrivateChannel("conversation.{id}")`, `broadcastAs()`, self-sufficient `broadcastWith()`
* [ ] Channel auth callback reusing `hasParticipant()`
* [ ] Decide the transport **up front**: Reverb + Echo, or an explicit polling interval. "Refetch on focus" (§11) is the current fallback and it means unbounded delivery latency

### Phase 7 — object storage

* [ ] S3-compatible disk (R2/S3/MinIO), `region=auto`, path-style for R2
* [ ] Upload endpoint with: **participant check on the folder**, type guard, **server-side size cap**, **positive MIME allow-list**, streaming `putFileAs`
* [ ] Key layout `files/{conversation-reference}/{userId}_{time}_{safeName}`
* [ ] URL-returning retrieval endpoint (skip base64 unless a client truly needs it)
* [ ] **Bucket CORS policy in the provider dashboard** — not in the app (§12.6)

### Phase 8 — admin surface

* [ ] `adminIndex` (paginate, filters, `adminParticipant()` contact card)
* [ ] `adminShow` — **paginate it** (§9.2)
* [ ] Platform-wide export: `withCount` + `chunk(200)` + `disconnectWorksheets()`
* [ ] Per-thread export + cloud backup sharing **one** `buildConversationSpreadsheet()` builder
* [ ] Deterministic backup key (one archive per thread, overwrite on re-save)
* [ ] **The wipe**: bytes first → `forceDelete()` files → rows in a transaction → reset summary fields → keep the conversation row. **Batch the object deletes, queue it, and log who did it**
* [ ] Moderation queue with the shared `setStatus()` helper

### Phase 9 — hardening & verification

* [ ] Work through the 10 items in §16
* [ ] Tests for the wipe, the participant `403`s, and the `before=` cursor
* [ ] Re-run the §15 benchmark on the real DB and compare query counts (they should match: 3/3/9/2–3/4/5)

***

## 21. Known Gaps & Deliberate Decisions

### Deliberate — do not "fix" these

| Decision | Why |
| - | - |
| **Conversation row survives a wipe** | Participants keep a working (empty) thread; the `reference`, hence the R2 folder, stays stable |
| **Hard delete, no soft-delete, on the wipe** | The feature *is* permanent erasure (a GDPR-style request); a soft-delete would defeat it |
| **No admin composer** | Oversight is read-only by design; an admin writing into a student↔instructor thread would corrupt the record |
| **Admins can't subscribe to the live channel** | `hasParticipant()` excludes them — no silent live surveillance |
| **Deterministic backup key** (overwrite, not versioned) | One current archive per thread; no storage creep |
| **`'throw' => false` on the shared `r2` disk** | A broken bucket must not `500` a chat upload. (The admin health check clones the config with `throw => true`.) |
| **Keyset pagination, backwards only** | Stable boundaries while new messages arrive; "forward" is the broadcast's job |
| **Exports are Excel, not CSV** | `GET /admin/conversations.csv` was removed on purpose |
| **`counterpart.default_meet_url` is student-only** | The column exists on every role but is only meaningful for instructors |
| **French user-facing strings** | Part of the API contract — the frontend substring-matches some of them |

### Gaps — carry these into the port as TODOs

| Gap | Detail |
| - | - |
| **`size_bytes` is always `0`** | `sendMessage` hardcodes it; the byte size isn't known at link time. Fix: return `getSize()` from the upload endpoint and pass it through, or `Storage::disk('r2')->size($key)` at link time |
| **`filename` shows the generated key** | `basename($file->file_name)` → `5_1759000000_devoir.pdf`, not `devoir.pdf`. Fix: store the original name on the `File` row and copy that |
| **`kind` / `booking` are never written** | The columns, the model cast, the resource fields, the broadcast payload and the frontend `BookingMessageCard` all exist — but **no endpoint inserts a `kind:"booking"` message**. `POST /bookings` creates the `Session` + an instructor notification only, so the student's "Réservation confirmée" card is **local/optimistic** (gone on refetch; the instructor never sees it). Fix: have the booking controller persist a `kind:"booking"` message and broadcast it |
| **`conversations.meet_url` has no writer** | The column and the resource field exist; nothing sets it. Not to be confused with `counterpart.default_meet_url` (the instructor's permanent room, on `users`) |
| **No notification on an inbound message** | `NotificationService` exists and is used elsewhere; messaging doesn't call it. A user with the tab closed learns nothing |
| **`readme_laravel_chat_solution.md` is referenced but absent** | `api_messaging.md` §1 points at it for the Reverb setup; the file is not in the repo. §11 of this document replaces it |
| **`api_messaging.md` §3/§5 show a `{"data": …}` wrapper** | Wrong — those endpoints return plain arrays (§6, verified by execution). The frontend hook comments are correct |
| **No audit trail on the wipe** | Nothing records which admin wiped which thread |
| **No typing indicators, presence, reactions, edit/delete-own-message, or per-message delete** | None of these exist; `read_at` + the unread counters are the whole state model |
| **No queue use in the chat path** | Sending, exporting and wiping are all synchronous (§15.3) |

***

## 22. File Inventory

Everything the chat & messaging system touches, in dependency order.

### Backend — `profelite_backend/`

| Path | Role | Size |
| - | - | - |
| `routes/api.php` (lines 143–156) | the 13 chat routes inside the `auth:sanctum` + `not_banned` group | — |
| `routes/channels.php` | `conversation.{id}` private-channel authorization | 1 callback |
| `bootstrap/app.php` | registers `channels:`, aliases `not_banned`, JSON error rendering for `api/*` | — |
| **`app/Http/Controllers/ConversationController.php`** | **the whole chat surface** — 13 public + 4 private methods | \~790 lines |
| `app/Http/Controllers/FilesRController.php` | R2 upload/url/delete/bulk/stats + `objectKey()`, `r2Url()`, `sanitizeFileName()` | \~450 lines |
| `app/Http/Controllers/ModerationController.php` | report queue, `denyUnlessAdmin()`, `setStatus()` | \~190 lines |
| `app/Http/Controllers/Controller.php` | base class — `serverError()` | 25 lines |
| `app/Http/Controllers/StorageDiagnosticsController.php` | `POST /admin/storage/r2/test` — proves attachment storage works | — |
| `app/Http/Middleware/EnsureUserIsNotBanned.php` | the `not_banned` guard | 25 lines |
| `app/Events/MessageSent.php` | the broadcast event | 60 lines |
| `app/Models/Conversation.php` | + `generateReference()`, **`hasParticipant()`** | 85 lines |
| `app/Models/Message.php` | | 40 lines |
| `app/Models/MessageAttachment.php` | | 30 lines |
| `app/Models/MessageReport.php` | + status constants | 50 lines |
| `app/Models/File.php` | `TYPE_*`, `isR2()`, local-disk `deleting` hook | 65 lines |
| `app/Models/Concerns/SerializesDates.php` | `Y-m-d H:i:s` on every timestamp | 15 lines |
| `app/Http/Resources/ConversationResource.php` | caller-aware thread shape | 55 lines |
| `app/Http/Resources/MessageResource.php` | message shape | 30 lines |
| `app/Http/Resources/MessageReportResource.php` | report shape | 35 lines |
| `app/Providers/AppServiceProvider.php` (line 26) | `JsonResource::withoutWrapping()` | — |
| `config/filesystems.php` (the `r2` disk) | S3 driver against Cloudflare R2 | — |
| `config/cors.php` | CORS for `/api/*` only (§12.6) | — |
| `database/migrations/2026_07_01_000013_create_conversations_table.php` | | — |
| `database/migrations/2026_07_01_000014_create_messages_table.php` | creates `messages` **and** `message_attachments` | — |
| `database/migrations/2026_07_01_000015_create_message_reports_table.php` | | — |
| `database/migrations/2026_08_06_000002_add_reference_to_conversations_table.php` | + backfill | — |
| `database/migrations/2026_07_01_000002_create_files_table.php` | shared file table | — |
| `tests/Feature/ChatAttachmentFolderTest.php` | R2 folder isolation (4 tests) | — |

### Frontend — `profelite_frontend/` (consumers, for contract reference)

| Path | Role |
| - | - |
| `src/hooks/api/messaging.ts` | participant endpoints; documents the **plain-array** shapes; `MESSAGES_PAGE_SIZE = 50` |
| `src/hooks/api/messagingAdmin.ts` | admin endpoints + `downloadXlsx()` authenticated blob fetch |
| `src/hooks/api/messagingFiles.ts` | `uploadChatAttachment()`, `MAX_ATTACHMENT_BYTES`, `ATTACHMENT_ACCEPT`, `isVideoAttachment()` |
| `src/hooks/api/adminModeration.ts` | report queue types/calls |
| `src/pages/Messages.tsx` | student thread UI; the `window.focus` refetch (§11) |
| `src/pages/instructor/Messages.tsx` | instructor thread UI |
| `src/pages/admin/Conversations.tsx` | admin list + Excel export |
| `src/pages/admin/ConversationDetail.tsx` | admin thread, export/backup/`WipeConfirmModal` |
| `src/pages/admin/Moderation.tsx` | moderation queue UI |
| `src/components/ReportMessageModal.tsx` | reason picker (mirrors `REPORT_REASONS`) |
| `src/components/BookingMessageCard.tsx` | renders `kind: "booking"` messages — **nothing produces them yet** (§21) |
| `src/components/admin/conversationParts.tsx` | `ManagedByParentBadge`, `Avatar`, `RolePill`, `MeetIconButton`, `ContactCard` |

### Related documentation

| Path | Covers |
| - | - |
| `API_Documentation/api_messaging.md` | lighter per-endpoint contract reference (note the envelope error, §21) |
| `API_Documentation/api_files.md` | the full file/R2 contract, §8 upload, §16 the R2 health check, "R2 Bucket CORS" |
| `API_Documentation/api_moderation.md` | the moderation endpoints in contract form |
| `API_Documentation/google_meet_links.md` | `users.default_meet_url` semantics |
| `readme_suggestions.md` | running log of observations (booking-message gap, `CACHE_STORE` throttle trap, …) |


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