Skip to main content
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, Chapters, Trainings) and the Flutter app, together with backend-generated quiz / exam slugs. See Changelog · 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) 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 · 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:
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)

A.2 Backend — LMS_Backend

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

  • store (L172-192): delete the elseif (request('chapter_id')) { … turnQuizToExam … } branch. Keep the final-quiz branch (training_id set, chapter_id null) unchanged.
  • update (L308-328): same removal.
  • create_quiz_with_questions_and_options (L480-499): same removal. Also update the “one per chapter” comment (L491).
  • 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.
  • 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).
  • 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

  • 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.
  • 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. 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).
  • 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

  • Chapter export JSON: quiz → quizzes: [] (keep reading quiz on import for old files).
  • 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

  • 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)

  • 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.
  • Update MarketplacePreviewEndpointsTest.php (L179-183, 296-300, 543, 564-588) to also assert chapters.N.quizzes.
  • 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

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

  • 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.
  • 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).
  • Update copyWith (L85-87). Run dart run build_runner build -d.

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

  • Mobile dock (course_player_mobile.dart:527, 642-650) reads currentTrainingQuiz. Check it after the provider change.
  • 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.
  • 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:

B.2 Options compared

  • 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:
    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.