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

# R2 Storage Paths — where every upload goes

> Every file the apps send to Cloudflare R2: the page it comes from, the endpoint, the object key the backend writes (one folder per training, classroom, quiz or chatroom slug under files/, built by the backend since 01/11/2026), the owner ids the Flutter app sends, and the backend function behind each key.

Written on 29/10/2026 from the audit of 25/10/2026, updated 01/11/2026 when the backend took over the
layout. The maintained twin of this page is `LMS_Frontend/R2_storage_paths.md`. Keywords for search:
R2, bucket, object key, `files/`, `all-lessons`, `all-classrooms`, `all-quizzes`, `all-chatrooms`,
`training_id`, `lesson_id`, `classroom_id`, `quiz_id`, `upload_file`, `import_to_r2`,
`R2StoragePathService`.

**Goal.** Files saved in Cloudflare R2 are organised into folders named after the **slug** of the
training, exam / quiz, classroom or chatroom they belong to, all under `files/`.

**Status on 01/11/2026: done, end to end.**

* ✅ **Backend.** The layout lives in one place, `app/Services/R2StoragePathService.php`, and every key is
  built there. `POST /upload_file` (type `3`) reads the owner the app names (`training_id` + `lesson_id`
  for a saved lesson, `classroom_id`, or `quiz_id`), loads it, checks it belongs to the request's company
  and writes the key itself. `POST /lessons/import_to_r2` writes `files/all-lessons/{training-slug}/bulk/`
  from `training_id`. Chat images, chat backups and instructor logos moved to their folders. A `folder`
  or `folder_name` string sent by a client is ignored. Guarded by `tests/Feature/R2StoragePathsTest.php`
  and `tests/Feature/ChatroomTest.php`.
* ✅ **Frontend (Update 184).** `R2UploadTarget` sends ids, never a folder string; the import modal shows
  the folder the backend answers.
* Existing objects keep their old keys (nothing is moved): every URL is stored in `files.url`, in the
  lesson / classroom HTML or in `chatroom_attachments.path`.

## At a glance

| | |
| :- | :- |
| Bucket | `laravelbucket` (the `r2` disk, see [File Management & Storage](https://theteam-13fc7f49.mintlify.site/api-reference/file-management-and-storage-api.md)) |
| Public URLs | built from `R2_URL`, or `R2_CUSTOM_DOMAIN` when set (`r2.the-team.tn`) |
| Who builds the key | **Always the backend** (`R2StoragePathService`): the folder from the owner's slug, the name `{userId}_{timestamp}_{random8}.{ext}` |
| What the apps send | whose file it is, by id: `training_id` (+ `lesson_id` for a saved lesson), `classroom_id` or `quiz_id` |
| Where the layout lives | backend `app/Services/R2StoragePathService.php` (the truth); app `lib/core/utils/resources/r2_storage_paths.dart` (`R2UploadTarget`, and folder helpers that mirror the layout for display), guarded by `test/r2_storage_paths_test.dart` |
| How it is checked | `tests/Feature/R2StoragePathsTest.php` (11 tests: every owner, the company check, the two-owner and lesson-outside-the-training refusals, the import folder, the logo folder) and `ChatroomTest` for the chat images and backups, all on an in-memory database with a fake R2 disk |

The routes below are the admin ones. The Instructor and Content Manager roles open the same screens
under their own routes.

***

## 1. The layout

```text theme={null}
files/
├── all-lessons/
│   └── {training-slug}/
│       ├── {lessonId}_{userId}_{timestamp}_{random8}.png     ← lesson editor: image
│       ├── {lessonId}_{userId}_{timestamp}_{random8}.pdf     ← lesson editor: PDF
│       └── bulk/
│           └── {dd-mm-yyyy-HH-ii}-{file-name-slug}.{ext}     ← "Import lessons from images"
├── all-classrooms/
│   └── {classroom-slug}/
│       └── {userId}_{timestamp}_{random8}.{ext}              ← classroom content editor
├── all-quizzes/
│   └── {exam-slug or quiz-slug}/
│       └── {userId}_{timestamp}_{random8}.{ext}              ← question images
├── all-chatrooms/
│   └── {chatroom-slug}/
│       └── {userId}_{timestamp}_{random10}.{ext}             ← images attached to messages
├── backups/
│   └── conversations/
│       └── {chatroom-slug}/
│           └── {userId}_{Ymd-His}_{random8}.csv              ← chatroom backup (userId = the admin)
├── instructor_bg_items/
│   └── {userId}_{timestamp}_{originalName}                   ← instructor certificate logos
└── {userId}_{timestamp}_{random8}.{ext}                      ← upload_file that names no owner (as before)
```

A lesson that is not saved yet has no id, so its files carry no `{lessonId}_` prefix:
`files/all-lessons/{training-slug}/{userId}_{timestamp}_{random8}.{ext}`.

One folder name is made by `R2StoragePathService::segment()`: lower case, anything but a letter, a digit,
`_` or `-` becomes a dash (so a classroom slug such as `pmp-class(1)` gives `pmp-class-1`, and `..` can
never climb), dashes trimmed, and `training-{id}` / `classroom-{id}` / `quiz-{id}` / `chatroom-{id}`
when nothing is left. The app's `r2Segment()` applies the same rule for what it displays.

***

## 2. Uploads that go to R2

### 2.1 Where each upload comes from

| # | Page (route) | Upload action | API call |
| :- | :- | :- | :- |
| 1 | Lesson content editor: `/admin-trainings/admin-add-lesson-content` | HTML editor → **Upload image** | `POST /upload_file` (type `3`) |
| 2 | same page | HTML editor → **Upload PDF** | `POST /upload_file` (type `3`) |
| 3 | Classroom content: `/admin-classrooms/admin-create-classroom`, `/admin-classrooms/admin-update-classroom/:id` | HTML editor → image / PDF | `POST /upload_file` (type `3`) |
| 4 | Quiz / exam editor: `/admin-quizzes/admin-create-quiz`, `/admin-quizzes/admin-update-quiz/:quiz_id` | Question image (uploaded on submit, step 4) | `POST /upload_file` (type `3`) |
| 5 | Trainings list `/admin-trainings` (row menu → Import lessons bulk), and `/admin-trainings/update-trainings/:id` step 2 → Import | Import lessons from images | `POST /lessons/import_to_r2` |
| 6 | Chatroom: `/admin-chatrooms/admin-chatroom/:slug` (students: `/student-chatroom/:slug` on phones) | Attach image to a message | `POST /chatrooms/{slug}/attachments` |
| 7 | same chatroom page | Backup conversation | `POST /chatrooms/{slug}/backup` |
| 8 | Instructor side: `/instructor-public-profile/edit-certificates` | Certificate logo | `POST /users/{id}/background-items` |

### 2.2 The key written now, and before

| # | Since 01/11/2026 | Before (old objects keep these keys) |
| :- | :- | :- |
| 1 | `files/all-lessons/{training-slug}/{lessonId}_{userId}_{timestamp}_{random8}.png` | `files/{userId}_{timestamp}_{random8}.png` |
| 2 | `files/all-lessons/{training-slug}/{lessonId}_{userId}_{timestamp}_{random8}.pdf` | `files/{userId}_{timestamp}_{random8}.pdf` |
| 3 | `files/all-classrooms/{classroom-slug}/{userId}_{timestamp}_{random8}.{ext}` | `files/{userId}_{timestamp}_{random8}.{ext}` |
| 4 | `files/all-quizzes/{exam-slug or quiz-slug}/{userId}_{timestamp}_{random8}.{ext}` | `files/{userId}_{timestamp}_{random8}.{ext}` |
| 5 | `files/all-lessons/{training-slug}/bulk/{dd-mm-yyyy-HH-ii}-{file-name-slug}.{ext}` | `files/{folder_name slug}/{dd-mm-yyyy-HH-ii}-{file-name-slug}.{ext}` |
| 6 | `files/all-chatrooms/{chatroom-slug}/{userId}_{timestamp}_{random10}.{ext}` | `chatrooms/{chatroom-slug}/{userId}_{timestamp}_{random10}.{ext}` (outside `files/`) |
| 7 | `files/backups/conversations/{chatroom-slug}/{userId}_{Ymd-His}_{random8}.csv` | `files/conversations/{chatroom-slug}-{Ymd-His}-{random8}.csv` |
| 8 | `files/instructor_bg_items/{userId}_{timestamp}_{originalName}` | `files/instructor_background_items/{userId}_{timestamp}_{uniqid}_{originalName}` |

> [!NOTE]
>
> `POST /r2_upload_file` and `POST /r2_bulk_upload` (`FilesRController`) still write flat to `files/`,
> but the apps never call them. Rows 3 and 4 were requested with a `.pdf` extension; they are written
> with `{ext}` here because the classroom editor uploads images **and** PDFs, and question images are
> images.

### 2.3 What the Flutter app sends (Update 184)

| # | Multipart fields added to the request | Status |
| :- | :- | :- |
| 1, 2 | `training_id={trainingId}` and `lesson_id={lessonId}` (saved lessons only) | ✅ sent · ✅ read |
| 3 | `classroom_id={classroomId}` | ✅ sent · ✅ read |
| 4 | `quiz_id={quizId}` (the quiz exists since step 3 of the editor creates it as a draft, Update 174) | ✅ sent · ✅ read |
| 5 | `training_id` (as always), plus the legacy `folder_name={training-slug}` for a backend older than 01/11/2026 | ✅ `training_id` read · `folder_name` ignored |
| 6, 7, 8 | nothing: these folders are built by the backend alone. The app shows the `url` / `path` the backend answers | ✅ backend-only |

The app never sends a folder. A `folder` field reaching the backend is ignored (not validated, never read).

### 2.4 Code behind each row

| # | Frontend (`LMS_Frontend/lib/…`) | Backend (`LMS_Backend/app/…`) |
| :- | :- | :- |
| 1, 2 | `ui/pages/admin_pages/trainings/lessons/add_lessons_content.dart` (`AddLessonContent.lessonUploadTarget`: the training id from `TrainingProvider.training`, the lesson id from `ChapterProvider.chapterIndex` / `lessonIndex`) → `ui/widgets/rich_text_editor/html_content_editor.dart` (`uploadTarget`) → `core/providers/htmleditor_services.dart` (`uploadBytes` / `uploadFile`) | `Http/Controllers/FilesController.php` → `upload_file` → `Services/R2StoragePathService.php` (`uploadDestination`, `lessonsFolder`, `lessonOf`) |
| 3 | `ui/pages/admin_pages/classrooms/widget/classroom_content_widget.dart` (`R2UploadTarget.forClassroom(classroom.id)`), same editor and service | `FilesController@upload_file` → `R2StoragePathService::classroomsFolder` |
| 4 | `core/providers/quiz_provider.dart` (`saveQuizDraft` creates the draft on step 3; `questionImageUploadTarget` = `forQuiz(quiz?.id)`, `uploadFile(target:)`, called from `submitCreateQuiz` on step 4) | `FilesController@upload_file` → `R2StoragePathService::quizzesFolder` |
| 5 | `ui/widgets/training/import_lessons/lessons_importer_widget.dart` (the locked storage folder field, the success dialog) → `core/providers/lessons_importer_provider.dart` (`training_id`, legacy `folder_name`; reads the answered `folder` into `LessonsImportResult.folder`) | `Http/Controllers/Export_Import_Chapters_And_Lessons_Controller.php` → `import_lessons_to_r2` → `R2StoragePathService::lessonsBulkFolder` |
| 6 | `core/providers/chatroom_conversation_controller.dart` → `ApiClient.uploadChatroomAttachment` | `Services/ChatroomService.php` → `storeAttachment` → `R2StoragePathService::chatroomsFolder` |
| 7 | `ui/widgets/chatroom/chatroom_actions.dart` → `ChatroomProvider.backupChatroom` | `Services/ChatroomService.php` → `backup` → `R2StoragePathService::conversationBackupsFolder` |
| 8 | `core/providers/instructor_profile_provider.dart` (`addOrUpdateInstructorProfileData`) | `Http/Controllers/UserBackgroundItemsController.php` → `storeLogoFile` → `R2StoragePathService::INSTRUCTOR_BACKGROUND_ITEMS` |

***

## 3. The contract: the owner ids the app sends

`POST /upload_file` (type `3`) carries **whose file it is**, as multipart fields next to `type=3`,
`company_id` and `file`. The backend loads the owner and builds the folder.

| Field | Type | Sent by | Rule on the backend |
| :- | :- | :- | :- |
| `training_id` | integer | rows 1–2 | The training the lesson belongs to. Must exist and belong to `company_id` (a super-admin may name any company's training), else `422` on `training_id`. Folder `files/all-lessons/{training-slug}`. |
| `lesson_id` | integer | rows 1–2, saved lessons only | Only with `training_id`, and the lesson must be in one of that training's chapters, else `422` on `lesson_id`. The backend puts `{lesson_id}_` in front of the file name. An unsaved lesson sends no `lesson_id`. |
| `classroom_id` | integer | row 3 | Same company rule, `422` on `classroom_id`. Folder `files/all-classrooms/{classroom-slug}`. |
| `quiz_id` | integer | row 4 | Same company rule, `422` on `quiz_id`. Folder `files/all-quizzes/{quiz-slug}`. |

* **One owner at most.** `training_id`, `classroom_id` and `quiz_id` together answer `422` on the second
  one (`messages.r2_one_owner_only`). **No owner at all:** the file lands flat in `files/`, as before.
* The answer carries `file_id`, `url` and `path` (the key). `files.file_name` holds the full key of a
  type-`3` upload.
* A validation error of `upload_file` answers `422` (`{ "message", "errors" }`) since 01/11/2026; it used
  to answer `500`.

**Example request (lesson editor, saved lesson 318 of training 7, `pmp-foundations`)**

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

company_id  = 1
type        = 3
url         =
file        = (binary) governance-model.png
training_id = 7
lesson_id   = 318
```

> [tab] 200
>
> ✅ 200 OK
>
> ```json theme={null}
> {
>   "file_id": 912,
>   "url": "https://r2.the-team.tn/files/all-lessons/pmp-foundations/318_42_1793289600_aZ3kP9qL.png",
>   "path": "files/all-lessons/pmp-foundations/318_42_1793289600_aZ3kP9qL.png",
>   "message": "File uploaded successfully."
> }
> ```

> [tab] 422
>
> ⚠️ 422 Validation Error (the training is another company's, or does not exist)
>
> ```json theme={null}
> {
>   "message": "The training does not exist or does not belong to your company.",
>   "errors": { "training_id": ["The training does not exist or does not belong to your company."] }
> }
> ```

**Example request (import, training 7)**

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

training_id   = 7
company_id    = 1
chapter_label = Governance
folder_name   = pmp-foundations      (legacy, ignored since 01/11/2026)
files[]       = (binary) 01-intro.png
files[]       = (binary) 02-governance-model.png
```

The answer carries `folder` (`files/all-lessons/pmp-foundations/bulk`) next to `chapter` and `lessons`;
the app shows it in its success dialog. See
[Chapters & Lessons Export / Import](https://theteam-13fc7f49.mintlify.site/api-reference/chapters-and-lessons-export-import.md).

> [accordion] Row 4: the quiz exists before its images
>
> Since Update 174, step 3 of the quiz / exam editor ("Save & continue") creates a new quiz as an
> unpublished draft (`POST /quiz`, no slug sent: the backend generates it) and the questions step is
> only reachable through that button. So `quiz_id` is known on create as on update, and the images land
> under the backend's final slug. The draft is published (or not) by the final submit, which goes through
> the update endpoint.

> [accordion] No owner, no folder
>
> A request that names no owner (a provider with no saved training / classroom / quiz yet, or another
> client) lands flat in `files/`, the behaviour of before. In practice the training draft is created on
> step 1 of the training editor, the classroom on step 1 of its stepper and the quiz on step 3, so the
> owner exists by the time the content editors upload.

> [accordion] Existing files keep their keys
>
> Nothing has to be moved. Every URL is stored in `files.url`, inside the lesson / classroom HTML or in
> `chatroom_attachments.path`, so older files keep working from their old keys. Only new uploads follow
> the layout.

***

## 4. How the files are named

`{random8}` is 8 random characters made by Laravel's `Str::random(8)`
(`R2StoragePathService::uploadFileName`, used by `FilesController@upload_file`, type `3`).

* **Allowed characters:** letters and digits only (`A–Z`, `a–z`, `0–9`), 62 in total, upper and
  lower case mixed.
* No `-`, `_`, `+`, `/` or `=`: Laravel removes those from the random bytes it encodes, so the result
  is always safe in a URL or a file name.
* **Why it is there:** the R2 bucket is public. With 62⁸ (about 218 trillion) possible values, nobody
  can guess a file's URL from the user id and the timestamp alone.

**Examples of full keys** (user 42, lesson 318, timestamp `1793289600`):

```text theme={null}
files/all-lessons/pmp-foundations/318_42_1793289600_aZ3kP9qL.pdf
files/all-lessons/pmp-foundations/318_42_1793289600_7GhT2mXw.png
files/all-lessons/pmp-foundations/bulk/29-10-2026-17-05-02-governance-model.png
files/all-classrooms/pmp-class-1/42_1793289600_Qe8RbN0v.jpg
files/all-quizzes/pmp-mock-exam/42_1793289600_X9w2LmZa.webp
```

Two rows lowercase their random part, so they only use `a–z` and `0–9` (`Str::lower(Str::random(n))`
in `ChatroomService`):

| Row | Key since 01/11/2026 | Before |
| :- | :- | :- |
| 6, chatroom image (`{random10}`) | `files/all-chatrooms/pmp-class-1/42_1793289600_q7m2x9kd4a.jpg` | `chatrooms/pmp-class-1/42_1793289600_q7m2x9kd4a.jpg` |
| 7, chatroom backup | `files/backups/conversations/pmp-class-1/42_20261029-161502_k3x9pq2m.csv` | `files/conversations/pmp-class-1-20261029-161502-k3x9pq2m.csv` |

***

## 5. What the backend does

`app/Services/R2StoragePathService.php` is the one place that knows the layout. Add a method there for
a new kind of upload; never hard-code a bucket folder in a controller, and never trust a folder string
from a client.

| Method / constant | Gives |
| :- | :- |
| `lessonsFolder(Training)` | `files/all-lessons/{segment(training.slug)}` |
| `lessonsBulkFolder(Training)` | `files/all-lessons/{segment(training.slug)}/bulk` |
| `classroomsFolder(Classroom)` | `files/all-classrooms/{segment(classroom_slug)}` |
| `quizzesFolder(Quiz)` | `files/all-quizzes/{segment(quiz.slug)}` |
| `chatroomsFolder(Chatroom)` | `files/all-chatrooms/{segment(chatroom.slug)}` |
| `conversationBackupsFolder(Chatroom)` | `files/backups/conversations/{segment(chatroom.slug)}` |
| `INSTRUCTOR_BACKGROUND_ITEMS` | `files/instructor_bg_items` |
| `segment(slug, fallback)` | lower case, `[^a-z0-9_-]+` → `-`, dashes trimmed; the fallback (`training-{id}`, …) when nothing is left |
| `uploadFileName(userId, ext, prefix)` | `{prefix}{userId}_{timestamp}_{random8}.{ext}` |
| `uploadDestination(input, companyId, user)` | the folder and the `{lessonId}_` prefix of one `upload_file` from its ids (`trainingFor` / `classroomFor` / `quizFor` / `lessonOf`, each a `422` on its field when it fails) |

**1. FilesController\@upload\_file, type 3 (rows 1 to 4)**

Validates `training_id`, `lesson_id`, `classroom_id`, `quiz_id` (nullable integers), asks the service
for the destination, names the file, `putFileAs` on the `r2` disk (a `false` answer from the disk is
a `500`), stores the full key in `files.file_name` and answers `file_id`, `url`, `path`. See
[File Management & Storage](https://theteam-13fc7f49.mintlify.site/api-reference/file-management-and-storage-api.md) §1.

**2. Export\_Import\_Chapters\_And\_Lessons\_Controller\@import\_lessons\_to\_r2 (row 5)**

Loads the training through the service (another company's → `422`), writes every image to
`files/all-lessons/{training-slug}/bulk/` and answers `folder`. `folder_name` is no longer read.

**3. ChatroomService::storeAttachment and ::backup (rows 6 and 7)**

`files/all-chatrooms/{slug}/` for the images; `files/backups/conversations/{slug}/{adminId}_{Ymd-His}_{random8}.csv`
for the backups (`BACKUP_FOLDER` now points at the service's constant). See
[Chatrooms](https://theteam-13fc7f49.mintlify.site/api-reference/chatrooms.md) §3.8 and §3.11.

**4. UserBackgroundItemsController::storeLogoFile (row 8)**

`files/instructor_bg_items/{userId}_{timestamp}_{originalName}`. The `UserBackgroundItem.image`
payload is unchanged. See [Instructor Dashboard](https://theteam-13fc7f49.mintlify.site/api-reference/instructor-dashboard.md) §20.

The error texts are `messages.r2_*` (`resources/lang/{en,fr,ar}/messages.php`). Run the guard with
`DB_CONNECTION=sqlite DB_DATABASE=:memory: php artisan test --filter='R2StoragePaths|ChatroomTest'`.

***

## 6. Where the apps show a bucket path

* **Import lessons modal** (row 5): the "Storage folder" field shows the destination
  (`files/all-lessons/{slug}/bulk/`, locked: the training slug is not editable since Update 175), and the
  success dialog adds the `folder` the backend answered.
* **Chatroom backup** (row 7): the "Backup saved" dialog shows the `path` the backend answers.
* Nowhere else. The admin storage usage page (`/admin-config-storage-usage`) lists the folders the
  backend returns (**List R2 Folders**, endpoint #12 of the file management page: the first level under
  `files/`, so `all-lessons`, `all-classrooms`, `all-quizzes`, `all-chatrooms`, `backups`,
  `instructor_bg_items` and `(root)`).

***

## 7. Uploads that do NOT go to R2 (local server disk)

These are saved under `storage/app/public/files/` on the backend server and served from
`/storage/files/...`:

| Page (route) | Upload | API call |
| :- | :- | :- |
| `/admin-trainings/create-trainings`, `/admin-trainings/update-trainings/:id` (step 1) | Training cover image, badge logo, intro video file. A YouTube link (type `2`) stores no file. | `POST /upload_file` (type `1` / type `2`) |
| `/admin-users` (create-user dialog), `/admin-settings-profile` | User photo | `POST /upload_file` (type `1`) |
| `/admin-config-theme` | Platform logo, login-page image | `POST /platform/theme` |
| `/admin-config-certification` | Certificate company logo and badge | `POST /certification/update/config` |
| `/admin-config-landing` | Landing page, category and partner images | `POST /landing-page/update`, `/landing-page/feat-category`, `/landing-page/partner-companies` |

The local-disk uploads (training covers, avatars, theme and landing images) would have to move to R2
before folders on the bucket apply to them at all. Serving them to the Flutter web app needs the
`/storage/` CORS header described on the file management page.

***

## 8. Other things the audit found

* The HTML editor, quiz and training uploads hard-code `company_id: 1` in the app, so every `files`
  row is tagged company 1, whatever the user's real company is (frontend known issue 7). The owner check
  of `upload_file` compares the owner's company to that `company_id`.
* Local type-`1` uploads are named `{userId}{originalName}` with no timestamp: if the same user uploads
  a second `cover.png`, it overwrites the first one.
* One configuration finding about the backend repository is tracked privately in the workspace notes.


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