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

# Quiz in Chapters — Multiple Quizzes & Lesson Types

> Several quizzes per chapter (the 2nd quiz used to silently turn the 1st into an exam) and a video / document lesson type for icon variants — both done. Root cause, design decisions, backend, docs and frontend checklists, rollout and acceptance criteria.

Mirror of **`LMS_Project/QUIZ_IN_CHAPTERS.md`** (written 14/10/2026). Repos touched: `LMS_Backend` →
`LMS_Documentation` → `LMS_Frontend` (in that order). All paths below are relative to each repo root.

**Status: both tasks are done ✅ (Task B 16/10/2026, Task A 19/10/2026).**

Two tasks:

* **Task A** ✅: allow **multiple quizzes per chapter** (the 2nd quiz used to "squash" the 1st). Shipped in the backend, the docs ([Quizzes & Exams](https://theteam-13fc7f49.mintlify.site/api-reference/quizzes-and-exams-endpoints.md), [Chapters](https://theteam-13fc7f49.mintlify.site/api-reference/chapters.md), [Trainings](https://theteam-13fc7f49.mintlify.site/api-reference/trainings.md)) and the Flutter app, together with backend-generated quiz / exam slugs. See [Changelog](https://theteam-13fc7f49.mintlify.site/changelog/changelog.md) · Update 123.
* **Task B** ✅: `lessons.type` (`document` | `video`) shipped in the backend (migration with the `document` default and no content-based backfill, `App\Enums\LessonType`, `POST /lesson` / `POST /lesson/{id}` accept `type`, outline payloads carry it), in the docs ([Lessons](https://theteam-13fc7f49.mintlify.site/api-reference/lessons.md)) and in the Flutter app (`Lesson.type`, one icon helper used by the player tree, the preview, the learner analytics and the remade training editor, which pre-selects *Video* when the content embeds a YouTube player). The optional `LessonsController.php:174` side fix (`student_lesson.student_id`) was applied too. See [Changelog](https://theteam-13fc7f49.mintlify.site/changelog/changelog.md) · Update 120.

***

## 0. Root-cause findings (why the 1st quiz "disappears")

**The database is NOT the problem.** `quizzes.chapter_id` is a plain nullable FK with no unique index
(`database/migrations/2024_03_29_043304_quizzes.php:20-24`), and no validation rule makes `chapter_id`
unique. Many quizzes per chapter is already possible at the DB level.

**The squash is explicit controller logic.** In `app/Http/Controllers/QuizzesController.php` three
endpoints look for an existing quiz on the same chapter and call `turnQuizToExam()` on it:

| Endpoint | Route | Lines |
| :- | :- | :- |
| `store` | `POST /quiz` | 172-192 |
| `update` | `POST /quiz/{id}` | 308-328 (uses `where('id','<>',$id)`) |
| `create_quiz_with_questions_and_options` | `POST /quiz_with_questions_and_options` | 480-499 |

```php theme={null}
} elseif (request('chapter_id')) {
    // This is a chapter quiz. Check if one already exists for this chapter.
    $existingQuiz = Quiz::where('chapter_id', request('chapter_id'))->first();
    if ($existingQuiz) {
        Log::info("Overriding existing chapter quiz ...");
        $this->turnQuizToExam($existingQuiz);
    }
}
```

`turnQuizToExam()` (L401-427) **does not delete** the old quiz. It sets `training_id = null`,
`chapter_id = null` and `is_published = false`, and writes a `converted_to_exam` history row. So the
"lost" quizzes still exist as **unpublished standalone exams**, and they can be recovered (see A.2.8).

**Second layer of the problem: read paths only return ONE quiz.** `Chapter` has both
`quiz()` (`hasOne`, `app/Models/Chapter.php:39-42`) and `quizzes()` (`hasMany`, `:58-61`). Most
endpoints eager-load the **singular** `chapters.quiz`, and the Flutter `Chapter` model only has
`Quiz? quiz`. Even after the controller fix, the UI would still show only one quiz until both layers change.

**Other things the audit turned up (worth fixing at the same time):**

1. `Training::quiz()` (`app/Models/Training.php:145-148`) is `hasOne(Quiz::class)` **without**
   `whereNull('chapter_id')`. If a chapter quiz ever carries a `training_id`, `$training->quiz` (the
   "final quiz") can return a chapter quiz. The frontend currently sends `training_id = null` for
   chapter quizzes (`quiz_provider.dart:1790-1803`), but seeded data (`QuizFactory.php:26-27`) and
   API clients other than the Flutter app can set both. `Training::quizzes()` (L212-215) has the same problem.
2. The squash runs **before** the instructor-mismatch 422 (`QuizzesController.php:227`) and outside a DB
   transaction, so a request that fails still converts the old quiz.
3. `Export_Import_Chapters_And_Lessons_Controller.php:266-272`: `importQuiz()` calls `importQuizData()`,
   which doesn't exist. This is dead, broken code. Chapter import silently drops quizzes.
4. `LessonsController.php:174` (`toggle_lesson_validation`) uses `where('student_lesson.id', request('user_id'))`,
   which should be `student_id`. Not related to these tasks, but noted.
5. The frontend has `major_code_updates.md` **Update 34 → "Case 3 (multiple quizzes per chapter)"** (L3101-3118).
   It is an earlier plan for the same change, still marked not done. Its file list is out of date
   (`chapter_lesson_sidebar_menu.dart` was deleted and replaced by `course_content_tree.dart` +
   `course_player_actions.dart`). This plan supersedes it.

***

## TASK A — Multiple quizzes per chapter ✅ Done (19/10/2026)

**What shipped (19/10/2026)**, and where it differs from the plan below:

* **Backend:** the chapter squash is gone from the 3 endpoints; the ownership, instructor and revenue checks run before any write, in one transaction; a chapter quiz is stored with `training_id = null`. Every chapter payload carries `quizzes` plus the deprecated `quiz` (= the first), filled by `Chapter::relationsToArray`, so `chapters.quiz` is never eager-loaded. **Students get published chapter quizzes only, staff also get drafts** (`GET /training/{id}`, `GET /chapter(s)`, the login payload). Chapter quizzes now follow the training's instructor too. Tests: `ChapterMultipleQuizzesTest`.
* **Lessonless chapters (A.2.3):** `GET /training/{id}` never dropped them. The quiz students missed was a **draft** (the editor creates quizzes unpublished), and the Update 34 backfill used `GET /chapter/{id}`, which also returned drafts. The backfill is deleted: a published quiz shows, lessons or not.
* **Added to the task:** quiz / exam slugs are generated by the backend (optional on the 4 create / update endpoints; `POST /quiz_with_questions_and_options` used to save an empty slug), plus the migration `2026_10_19_000000_backfill_missing_quiz_slugs`. Tests: `QuizSlugTest`.
* **Frontend:** `Chapter.quizzes` replaced `Chapter.quiz` outright (no temporary getter); the course player walks one sequence (lessons, then the chapter's quizzes, then the next chapter, then the final quiz); the training editor's step 2 curriculum lists the quizzes in the theme's secondary color with the circle-question icon. Tests: `test/chapter_quizzes_test.dart`.
* **Open questions answered:** a chapter is done only when **all** its quizzes are passed; quizzes stay in creation order; no recovery of already-squashed quizzes.
* **Not done:** `total_quizzes_count` (it never existed), Phase 2 ordering (A.2.6), squashed-quiz recovery (A.2.8), importing the quizzes of a chapter file, and removing the deprecated `quiz` field (A.5 step 4, after 1–2 releases).

### A.1 Design decisions (suggested)

| Question | Recommendation | Why |
| :- | :- | :- |
| Remove the "one quiz per chapter" rule? | **Yes**, for chapter quizzes. | This is the bug. |
| Keep "one **final** quiz per training"? | **Keep it for now** (the `whereNull('chapter_id')` branch still calls `turnQuizToExam`). | Out of scope; can be relaxed later with the same pattern. |
| JSON shape | Add **`quizzes: Quiz[]`** on every chapter object. Keep **`quiz`** (= first quiz or `null`) as a **deprecated** field for one or two releases. | Android APKs and cached web builds already in production read `chapter.quiz`. Removing it would break them. |
| Order of quizzes inside a chapter | **Phase 1:** `orderBy('id')` (creation order). **Phase 2 (optional):** add `quizzes.position` + `POST /quizzes/reorder` scoped by chapter, mirroring lessons. | No position column exists today. Creation order is predictable and needs no migration. |
| Interleave quizzes between lessons (L1, Q1, L2, Q2…)? | **Out of scope.** Quizzes render **after** the chapter's lessons, as today. | That needs a shared ordering space (a `chapter_items` table or a common `position`). It is a bigger refactor, see A.7. |
| Which quizzes do students see? | Published only (`isPublished()`), as today's `chapters.quiz` eager load already does. | Same behaviour as now. |

### A.2 Backend — `LMS_Backend`

#### A.2.1 Stop the squash — `app/Http/Controllers/QuizzesController.php`

* [x] `store` (L172-192): delete the `elseif (request('chapter_id')) { … turnQuizToExam … }` branch.
  Keep the final-quiz branch (`training_id` set, `chapter_id` null) unchanged.
* [x] `update` (L308-328): same removal.
* [x] `create_quiz_with_questions_and_options` (L480-499): same removal. Also update the "one per chapter" comment (L491).
* [x] `update_quiz_with_questions_and_options` (L630-700): no override logic exists there. Check that it
  still validates `chapter_id` belongs to the same company/training.
* [x] Add validation when `chapter_id` is present: `exists:chapters,id`, the chapter's training belongs to the
  caller's `company_id` (tenant isolation), and, if `training_id` is also sent, it must equal
  `chapter.training_id` (or normalize `training_id = null` for chapter quizzes; pick one and document it).
* [x] Wrap the remaining final-quiz override plus create in `DB::transaction()` and move the instructor
  check (L227) **before** any `turnQuizToExam` call, so a failed request never mutates data.

#### A.2.2 Models

* [x] `app/Models/Chapter.php`
  * Make `quizzes()` the main relation: `hasMany(Quiz::class, 'chapter_id')->orderBy('id')`.
  * Keep `quiz()` but mark it `@deprecated`, and make it deterministic:
    `hasOne(Quiz::class)->oldestOfMany()` (first created). Used only for the backward-compat `quiz` field.
* [x] `app/Models/Training.php`
  * `quiz()` (L145-148) → `hasOne(Quiz::class)->whereNull('chapter_id')` (true final quiz only).
  * Check `quizzes()` (L212-215) usages (`TrainingsController.php:362` instructor sync) and decide whether
    it means "final quizzes only" or "all quizzes of the training, chapter ones included". The latter
    would be `Quiz::where('training_id',$id)->orWhereIn('chapter_id', $chapterIds)`. Name it clearly.

#### A.2.3 Read paths: switch `chapters.quiz` → `chapters.quizzes`

For each location, eager-load `quizzes` (with the same constraints that `quiz` had) and emit **both**
`quizzes` (array) and the deprecated `quiz` (= `quizzes->first()`). Doing that in one place (a helper or
`ChapterResource`) avoids 10 copies of the same logic.

| File:line | Today | Change |
| :- | :- | :- |
| `ChaptersController.php:28` (index) | `with('quiz.quiz_questions…')` | `with('quizzes.quiz_questions…')` |
| `ChaptersController.php:104` (show) | `with('quiz.quiz_questions…')` | `with('quizzes.quiz_questions…')` |
| `TrainingsController.php:202-207` (show) | `'chapters.quiz' => isPublished()->with(…)` | `'chapters.quizzes' => isPublished()->with(…)` |
| `Http/Resources/TrainingResource.php:108-114` | emits `quizzes` (L108) **and** `quiz` (L112) | keep `quizzes`, derive `quiz` from it |
| `Http/Resources/UserResource.php:124-127, 156-159` | loads both | drop the singular eager load, derive |
| `AuthController.php:91-96` (cart) | loads both | same |
| `UserDataController.php:59-62` | loads both | same |
| `Services/MarketplacePreviewService.php:192` | `'chapters.quiz' => without('instructor')->isPublished()->withCount('quiz_questions')` | `'chapters.quizzes' => …same…` |
| `Services/MarketplacePreviewService.php:365` | `'quiz' => $this->quizOutline(...)` | add `'quizzes' => $chapter->quizzes->map(fn($q) => $this->quizOutline($q))`, keep `quiz` |
| `Export_Import_Chapters_And_Lessons_Controller.php:44, 91` | `quiz.quiz_questions…` | `quizzes.quiz_questions…` (export every quiz) |
| `Export_Import_Trainings_Controller.php:473` (backup ZIP) | `quiz.quiz_questions…` | `quizzes.quiz_questions…` |

Already multi-quiz safe, **no change needed**: `UserProgressAnalyticsController` (L116-121, 150-167, 255-258),
`Quizzes_Exams_Progress_Tracking_Controller` (L285-297), `GamificationService` (L49-60),
`CertificationsController` (no completion check on chapter quizzes), `UsersController::trainings_by_student` (L344-345).

* [x] Check the **lessonless-chapter bug** from frontend Update 34: `GET /training/{id}` drops the chapter quiz
  when the chapter has no lessons. Once on `chapters.quizzes`, add a test for a chapter with 0 lessons + 2 quizzes.
  After that the frontend `GET /chapter/{id}` backfill (`training_quiz_provider.dart:255-267`) can be removed.
* [ ] `total_quizzes_count` (training): make sure it counts **all** chapter quizzes plus the final quiz.

#### A.2.4 Import / export

* [x] Chapter export JSON: `quiz` → `quizzes: []` (keep reading `quiz` on import for old files).
* [x] Either fix `importQuiz()` → implement it with the existing quiz import code in
  `Export_Import_Quizzes_Exams_Controller` and loop over `quizzes[]`, **or** delete the dead method and
  document that chapter import skips quizzes. (Recommended: delete now, implement as a separate task.)

#### A.2.5 Seeder / factory

* [x] `database/factories/QuizFactory.php:26-27` picks `training_id` and `chapter_id` independently at random, which
  creates inconsistent data. Make it a `chapterQuiz()` state (`chapter_id` set, `training_id` null) and a
  `finalQuiz()` state (`training_id` set, `chapter_id` null). Seed 0-3 quizzes per chapter.

#### A.2.6 Optional Phase 2 — quiz ordering inside a chapter

* [ ] Migration `add_position_to_quizzes_table`: `unsignedBigInteger('position')->default(0)` + index `(chapter_id, position)`.
* [ ] Backfill: per chapter, `position = ROW_NUMBER() OVER (PARTITION BY chapter_id ORDER BY id)`.
* [ ] `Quiz::creating`: when `chapter_id` is set, `position = max(position in chapter) + 1` (same as `Lesson`).
* [ ] `POST /quizzes/reorder` (`[{id, position}]`), with tenant and chapter ownership checks.
* [ ] `Chapter::quizzes()` → `orderBy('position')->orderBy('id')`.

#### A.2.7 Tests (`tests/Feature/`, using `tests/Support/CreatesLmsFixtures.php`)

* [x] **New** `ChapterMultipleQuizzesTest`:
  * creating 2 quizzes on the same chapter via `POST /quiz` and `POST /quiz_with_questions_and_options` → both keep `chapter_id`, both stay published, no `converted_to_exam` history.
  * `POST /quiz/{id}` moving quiz B into chapter X where quiz A lives → both are on X.
  * `GET /chapter/{id}`, `GET /chapters`, `GET /training/{id}`, marketplace preview → `quizzes` has 2 items in id order, and `quiz` equals the first.
  * chapter with **0 lessons** + 2 quizzes → still returned.
  * final-quiz rule still holds: a 2nd final quiz on a training still converts the 1st (unchanged behaviour).
  * `Training::quiz` never returns a chapter quiz, even when the chapter quiz has a `training_id`.
* [x] Update `MarketplacePreviewEndpointsTest.php` (L179-183, 296-300, 543, 564-588) to also assert `chapters.N.quizzes`.
* [x] Check `CertificationIssuedOnceTest` and `OwnersEmailsEndpointTest` still pass.

#### A.2.8 Optional data recovery for already-squashed quizzes

The squashed quizzes are unpublished exams that have a `converted_to_exam` history row. A one-off artisan
command (`quizzes:restore-squashed-chapter-quizzes --dry-run`) could:

1. list every quiz whose latest history is `converted_to_exam` from a chapter override (if the history row
   kept the old `chapter_id`; check the history payload first),
2. print them for review, and with `--apply` restore `chapter_id`/`training_id` and `is_published`.
   Run it with `--dry-run` only and review the list manually. Some of them may have since been sold as real exams.

### A.3 Documentation — `LMS_Documentation`

| Page | Change |
| :- | :- |
| `api-reference/quizzes-and-exams-endpoints.mdx` | **Main page to change.** Remove the "one quiz per chapter / collision → `turnQuizToExam`" rule (L52, L67, L104-112, L617-618, L1106-1108, L1325). Keep the final-quiz rule. Update Example B (L563-575) and the `chapter_id` / `training_id` param notes (L520-521). |
| `api-reference/chapters.mdx` | L6, L45, L200 wording; examples L87-150 and L222-250 → `quizzes: [...]` + deprecated `quiz`. |
| `api-reference/trainings.mdx` | L232 wording; `chapters[]` examples (L276-282, L489, L556, L569) → add `quizzes`. Leave the training final `quiz` (L291) alone. |
| `api-reference/chapters-and-lessons-export-import.mdx` | L6, L64-95, L194/210/489 → plural. |
| `api-reference/trainings-export-import.mdx` | L341, L368, L396-420 → plural. |
| `changelog/changelog.mdx` | New entry. Don't rewrite the historical L1779-1783, add a note referencing it. |
| `clean-up/backend.mdx` L253-255, `changelog/suggested-changes.mdx` L47 | Mark as resolved. |

Follow the docs CLAUDE.md: update each edited page's TOC, and bump its version if the title has one.

### A.4 Frontend — `LMS_Frontend`

#### A.4.1 Model

* [x] `lib/core/data/remote/chapter.dart`: add
  `@JsonKey(name: 'quizzes', defaultValue: <Quiz>[], includeToJson: false) List<Quiz> quizzes;`.
  Use a custom `fromJson` that falls back to `[json['quiz']]` when `quizzes` is missing, so the app keeps
  working against a backend that isn't deployed yet.
* [x] Keep `Quiz? get quiz => quizzes.isEmpty ? null : quizzes.first;` as a temporary getter during migration,
  then remove it once every reader is converted. Stop sending `quiz` in `toJson`
  (`chapter.g.dart:32` sends `quiz: null` to `POST /chapter` today).
* [x] Update `copyWith` (L85-87). Run `dart run build_runner build -d`.

#### A.4.2 Readers to convert (single quiz → list)

| File | What changes |
| :- | :- |
| `ui/pages/student_pages/training/training/course_content_tree.dart` L180-212 | `itemCount = lessons.length + quizzes.length`, one `_QuizRow` per quiz, `chapterDone` = all lessons + **all** quizzes passed. |
| `…/training/course_player_actions.dart` L65-66 | "Next" after the last lesson → 1st chapter quiz, then the next quiz, then the next chapter. |
| `core/providers/student_training_provider.dart` L355-363, 401-443, 487-488 | Navigation by index over a flat chapter sequence `[lessons…, quizzes…]`. Track the current quiz by **id** instead of "the chapter's quiz". `canNextCheck` / `previousLesson` iterate the list. |
| `core/providers/training_quiz_provider.dart` L255-267 | Backfill `quizzes`. Delete it once the backend lessonless-chapter fix is live. |
| `core/utils/resources/training_progress_helper.dart` L31-67 | Counts become `sum(chapter.quizzes.length)`; per-chapter total/validated go from 0/1 to N. |
| `ui/pages/student_pages/dashboard/widget/continue_learning_card.dart` L138-159 | Build the quiz list from every chapter quiz. |
| `ui/pages/admin_pages/trainings/preview_training.dart` L889, 953, 965, 2190-2196 | Loop `_QuizRow` per quiz; `_countQuizzes` sums lists. |
| `ui/pages/admin_pages/trainings/add_trainings_page_two.dart` L329-362, 740-741 | Show **one read-only row per quiz**; delete-chapter warning shows `quizzes.length`. |
| `ui/pages/student_pages/profile/widget/training_analytics_listtile.dart` L185-187, 258-268, 328-388 | Loop chapter quizzes. |
| `ui/widgets/quiz/single_chapter_select_search_widget.dart` | Optional: show a "N quizzes" badge next to each chapter. **Do not** block chapters that already have quizzes. |
| `ui/widgets/training/training_content_widget.dart` | Dead code. Delete it, or leave it alone. |

* [x] Mobile dock (`course_player_mobile.dart:527, 642-650`) reads `currentTrainingQuiz`. Check it after the provider change.
* [x] Update tests: `test/course_content_tree_numbers_test.dart`, `test/continue_learning_card_test.dart`,
  `test/dashboard_enrolled_cards_parity_test.dart`. Add a case with 2 quizzes in one chapter and one with 0 lessons + 2 quizzes.
* [x] Add a `major_code_updates.md` entry (append with Edit, never rewrite). Mark Update 34 "Case 3" as ✅ superseded.

### A.5 Rollout order

1. Backend (additive: `quizzes` added, `quiz` kept) + tests → deploy.
2. Docs.
3. Frontend reads `quizzes` (with `quiz` fallback) → deploy web + new APK.
4. Once old APKs are no longer in use (1-2 releases): remove `quiz` from chapter payloads, `Chapter::quiz()`, and the Flutter getter.

### A.6 Acceptance criteria

* An instructor creates Quiz 1 and Quiz 2 on Chapter X → both are visible in the editor, preview, student player and analytics.
* The student must pass both (if completion rules require quizzes) for the chapter to show as done.
* No quiz is ever silently converted to an exam by a chapter collision.
* The training's final quiz behaves exactly as before.

### A.7 Out of scope / later

* Interleaving quizzes between lessons. This needs either a polymorphic `chapter_items (chapter_id, item_type, item_id, position)`
  table or a shared `position` space for lessons and quizzes, plus a combined reorder endpoint.
* Multiple final quizzes per training.

***

## TASK B — Lesson type (`video` / `document`) for icon variants ✅ Done (16/10/2026)

### B.1 Current state

* `lessons` columns: `id, chapter_id, label, position, content (longText HTML), timestamps, archived_at, deleted_at`.
  No migration has ever altered it.
* The backend has **no** notion of lesson type (no `video`, `youtube`, `vimeo`, `mp4` or `iframe` anywhere in `app/`).
  Videos are YouTube/Drive iframes embedded **inside the HTML `content`** by the frontend editor
  (`lib/ui/widgets/rich_text_editor/html_editor_dialogs.dart`: `EmbedKind { pdf, googleCalendar, youtube, other }`, `EmbedCode.kindOf(html)`).
* Frontend lesson icons today:

| Screen | File:line | Icon |
| :- | :- | :- |
| Student player tree | `course_content_tree.dart:238` | `FontAwesomeIcons.fileLines` |
| Preview / marketplace | `preview_training.dart:1010` | `isPreview ? play : file` |
| Instructor builder | `add_trainings_page_two.dart:208-251` | none (drag handle only) |
| Admin learner analytics | `training_analytics_listtile.dart:372` | `fileLines` |

### B.2 Options compared

| Option | How | Pros | Cons |
| :- | :- | :- | :- |
| 1. Frontend only: derive from `content` | Run `EmbedCode.kindOf(content)` on every render | No backend change | `content` isn't sent in most lists (`TrainingResource` sends `{id,label}`, preview sends only outline columns). Parses HTML on every render. Can't override. |
| 2. Backend computed accessor | `$appends = ['type']` from parsing `content` | No migration | Same parsing cost on every query, and needs `content` loaded. Not editable. |
| **3. Stored `type` column (recommended)** | `lessons.type` string, set by the instructor, auto-suggested by the editor | Cheap to read, shows in outline payloads, filterable, overridable, extendable | Needs a migration + backfill + form field |

### B.3 Recommended design

* **Column:** `lessons.type` → `string('type', 20)->default('document')->after('label')` with an index.
  Use a **string + PHP backed enum**, not a MySQL `ENUM`, so new values (`pdf`, `audio`, `live`, `quiz_intro`…)
  never need an `ALTER TABLE` on the enum list.
* **PHP enum:** `app/Enums/LessonType.php` → `enum LessonType: string { case Document = 'document'; case Video = 'video'; }`.
  Cast it in `Lesson::$casts` (`'type' => LessonType::class`) and add it to `$fillable`.
* **Naming:** use `type` with values `video` / `document`, not `video_icon` / `document_icon`. The column
  describes the **content kind**; the icon is a frontend presentation choice derived from it.
* **Default:** `document`. Every existing and imported lesson stays valid without client changes.
* **Who sets it:** the instructor, through a toggle in the lesson form. The frontend **pre-selects** `video`
  when the editor content contains a YouTube or video iframe (`EmbedCode.kindOf == youtube`). The instructor can override it.
* **Optional future:** `duration_seconds` (nullable) for video lessons ("12 min" next to the icon).
  Not part of this task.

### B.4 Backend — `LMS_Backend`

* [ ] Migration `2026_10_xx_000000_add_type_to_lessons_table.php`: add the column (above) and index `lessons_type_index`.
  `down()` drops it.
* [ ] Backfill in the same migration (or a separate artisan command, safer on prod):
  `UPDATE lessons SET type='video' WHERE content REGEXP 'youtube\\.com/embed|youtu\\.be|player\\.vimeo\\.com|<video'`.
  Everything else stays `document`. Run with a count first and log how many rows changed.
* [ ] `app/Enums/LessonType.php` (new) + `Lesson` model `$fillable` / `$casts`.
* [ ] `LessonsController`:
  * `store` (L73-77): `'type' => ['sometimes', Rule::enum(LessonType::class)]` (default from the DB).
  * `update` (L115-119): same rule, `sometimes`. Old clients that don't send it keep the current value.
* [ ] Include `type` in every **outline** payload (where `content` isn't sent):
  * `Http/Resources/TrainingResource.php:102-106` → `{id, label, type}`.
  * `Services/MarketplacePreviewService.php:58` → `LESSON_OUTLINE_COLUMNS` add `'type'`.
  * `UserProgressAnalyticsController.php:101` → add `type` to the `select`.
  * Full-model responses (`ChaptersController`, `TrainingsController::show`, `LessonsController::show/index`)
    get it automatically.
* [ ] Import/export:
  * Export already dumps all columns, so `type` comes along for free.
  * `Export_Import_Chapters_And_Lessons_Controller.php:226-233` (JSON import) → map `type` (fall back to `document` if missing or invalid).
  * Bulk image import (L384 R2, L541 local) → `type = document`.
* [ ] `database/factories/LessonFactory.php` → `type` random between the enum cases.
* [ ] Tests (`tests/Feature/LessonTypeTest`):
  * create without `type` → `document`; create with `video` → `video`; create with `foo` → 422;
  * update without `type` keeps the value;
  * `GET /training/{id}` and marketplace preview include `chapters.*.lessons.*.type`.
* [ ] (Side fix, optional) `LessonsController.php:174`: `student_lesson.id` → `student_lesson.student_id`.

### B.5 Documentation — `LMS_Documentation`

* [ ] `api-reference/lessons.mdx`: add `type` to the list response (L68-87), create params (L143-147, `video|document`, optional, default `document`), create example (L158-163), show response (L190-200), update params (L220-224), and update example (\~L237-240).
* [ ] Add `type` to lesson objects in: `chapters.mdx` L81-84, `trainings.mdx` L280, L489, L553-567,
  `chapters-and-lessons-export-import.mdx` L56-60, L222-227 (and note bulk import sets `document`), `trainings-export-import.mdx` L388-391.
* [ ] Changelog entry.

### B.6 Frontend — `LMS_Frontend`

* [ ] `lib/core/data/remote/lesson.dart`: add
  `enum LessonType { @JsonValue('document') document, @JsonValue('video') video }` and the field
  `@JsonKey(name: 'type', defaultValue: LessonType.document, unknownEnumValue: LessonType.document) LessonType type;`.
  `unknownEnumValue` lets an older app version survive future backend values. Update `copyWith` and run `build_runner`.
* [ ] Lesson payloads: add `type` where lessons are built at `training_provider.dart:958` (new training)
  and L1357-1363 (edit). `Lesson.toJson()` then sends it to `POST /lesson` and `POST /lesson/{id}`.
* [ ] **One icon helper** so every screen stays consistent, e.g. `lib/core/utils/resources/lesson_type_icon.dart`:

  ```dart theme={null}
  IconData lessonTypeIcon(LessonType t) => switch (t) {
    LessonType.video    => FontAwesomeIcons.circlePlay,   // already in fa_compat.dart:48
    LessonType.document => FontAwesomeIcons.fileLines,    // fa_compat.dart:74
  };
  ```

  No new package or asset is needed: both icons already exist in `lib/core/utils/fa_compat.dart`.
* [ ] Use the helper in:
  * `course_content_tree.dart:238` (student player, web + mobile drawer);
  * `preview_training.dart:1010`. Today `play` means "free preview", which would clash with the video icon.
    Use the type icon there and show free preview as a separate small "Preview" chip or label;
  * `add_trainings_page_two.dart:208-251` (instructor builder: add the icon after the drag handle);
  * `training_analytics_listtile.dart:372`.
* [ ] Lesson form (`add_trainings_page_two.dart` `addLessonScreen` L516-613 + `LessonProvider`):
  * add a 2-option segmented control (Document / Video) under the title, stored in `LessonProvider.lessonType`;
  * after returning from the content editor (`add_lessons_content.dart` `_saveLesson` L49-61), if the user
    hasn't manually picked a type, pre-select `video` when `EmbedCode.kindOf(content) == EmbedKind.youtube`;
  * pass the type through `ChapterProvider.addLessonToChapter(...)` (`chapter_provider.dart:274-302`).
* [ ] Translations (`assets/translations/{en,fr,ar}.json`), new keys: `lessonType`, `lessonTypeDocument`,
  `lessonTypeVideo` (+ optional `freePreview` if not present). Re-run both `easy_localization:generate` commands.
* [ ] Optional: remove the dead `lessonVideoLink` / `enterYourLessonVideoLink` keys and the commented-out code (`add_trainings_page_two.dart:715`).
* [ ] Tests: the student tree renders `circlePlay` for a video lesson and `fileLines` for a document lesson; `Lesson.fromJson` with a missing or unknown `type` → `document`.
* [ ] `major_code_updates.md` entry (append only).

### B.7 Acceptance criteria

* Existing lessons show the document icon until an author switches them to Video (decided on 18/10/2026: no content-based backfill, the type is manual).
* An instructor can switch a lesson between Video and Document; the choice persists and shows in the student player, preview and analytics.
* Old app versions that don't send `type` keep working (default `document`, and updates don't reset it).

***

## Summary checklist (execution order)

1. **Backend A**: remove the chapter squash (3 endpoints) + transaction/validation → model relations (`Chapter::quizzes` ordered, `Training::quiz` final-only) → switch read paths to `quizzes` + keep deprecated `quiz` → export/import → factory → tests.
2. **Backend B**: migration + backfill + `LessonType` enum + validation + outline payloads + import + tests.
3. **Docs**: quizzes page rule change, chapter/training/export pages → plural; lessons page + lesson objects → `type`; changelog.
4. **Frontend A**: `Chapter.quizzes` (with `quiz` fallback) → convert every reader (list in A.4.2) → tests.
5. **Frontend B**: `Lesson.type` enum → icon helper → 4 screens → form toggle + auto-suggest → translations → tests.
6. **Later**: remove the deprecated `quiz` field; optional quiz `position` + reorder; optional squashed-quiz recovery command; optional lesson/quiz interleaving.

## Open questions for you

1. Should chapter completion (the ✓ on a chapter in the player) require passing **all** its quizzes, or only lessons + any one quiz? (The plan assumes **all**.)
2. Do you want Phase 2 quiz ordering (`position` + reorder) now, or is creation order fine?
3. Should we try to **recover** the quizzes that were already converted to exams (A.2.8)?
4. Any other lesson types to plan for now (`pdf`, `audio`, `live`)? The string + enum design makes adding them a one-line change.


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