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 thedocumentdefault and no content-based backfill,App\Enums\LessonType,POST /lesson/POST /lesson/{id}accepttype, 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 optionalLessonsController.php:174side 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):
Training::quiz()(app/Models/Training.php:145-148) ishasOne(Quiz::class)withoutwhereNull('chapter_id'). If a chapter quiz ever carries atraining_id,$training->quiz(the “final quiz”) can return a chapter quiz. The frontend currently sendstraining_id = nullfor 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.- 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. Export_Import_Chapters_And_Lessons_Controller.php:266-272:importQuiz()callsimportQuizData(), which doesn’t exist. This is dead, broken code. Chapter import silently drops quizzes.LessonsController.php:174(toggle_lesson_validation) useswhere('student_lesson.id', request('user_id')), which should bestudent_id. Not related to these tasks, but noted.- The frontend has
major_code_updates.mdUpdate 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.dartwas deleted and replaced bycourse_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 carriesquizzesplus the deprecatedquiz(= the first), filled byChapter::relationsToArray, sochapters.quizis 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 usedGET /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_optionsused to save an empty slug), plus the migration2026_10_19_000000_backfill_missing_quiz_slugs. Tests:QuizSlugTest. - Frontend:
Chapter.quizzesreplacedChapter.quizoutright (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 deprecatedquizfield (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 theelseif (request('chapter_id')) { … turnQuizToExam … }branch. Keep the final-quiz branch (training_idset,chapter_idnull) 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 validateschapter_idbelongs to the same company/training. - Add validation when
chapter_idis present:exists:chapters,id, the chapter’s training belongs to the caller’scompany_id(tenant isolation), and, iftraining_idis also sent, it must equalchapter.training_id(or normalizetraining_id = nullfor 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 anyturnQuizToExamcall, 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-compatquizfield.
- Make
-
app/Models/Training.phpquiz()(L145-148) →hasOne(Quiz::class)->whereNull('chapter_id')(true final quiz only).- Check
quizzes()(L212-215) usages (TrainingsController.php:362instructor sync) and decide whether it means “final quizzes only” or “all quizzes of the training, chapter ones included”. The latter would beQuiz::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 onchapters.quizzes, add a test for a chapter with 0 lessons + 2 quizzes. After that the frontendGET /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 readingquizon import for old files). - Either fix
importQuiz()→ implement it with the existing quiz import code inExport_Import_Quizzes_Exams_Controllerand loop overquizzes[], 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-27pickstraining_idandchapter_idindependently at random, which creates inconsistent data. Make it achapterQuiz()state (chapter_idset,training_idnull) and afinalQuiz()state (training_idset,chapter_idnull). 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: whenchapter_idis set,position = max(position in chapter) + 1(same asLesson). -
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 /quizandPOST /quiz_with_questions_and_options→ both keepchapter_id, both stay published, noconverted_to_examhistory. 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 →quizzeshas 2 items in id order, andquizequals 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::quiznever returns a chapter quiz, even when the chapter quiz has atraining_id.
- creating 2 quizzes on the same chapter via
- Update
MarketplacePreviewEndpointsTest.php(L179-183, 296-300, 543, 564-588) to also assertchapters.N.quizzes. - Check
CertificationIssuedOnceTestandOwnersEmailsEndpointTeststill pass.
A.2.8 Optional data recovery for already-squashed quizzes
The squashed quizzes are unpublished exams that have aconverted_to_exam history row. A one-off artisan
command (quizzes:restore-squashed-chapter-quizzes --dry-run) could:
- list every quiz whose latest history is
converted_to_examfrom a chapter override (if the history row kept the oldchapter_id; check the history payload first), - print them for review, and with
--applyrestorechapter_id/training_idandis_published. Run it with--dry-runonly 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 customfromJsonthat falls back to[json['quiz']]whenquizzesis 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 sendingquizintoJson(chapter.g.dart:32sendsquiz: nulltoPOST /chaptertoday). - Update
copyWith(L85-87). Rundart run build_runner build -d.
A.4.2 Readers to convert (single quiz → list)
- Mobile dock (
course_player_mobile.dart:527, 642-650) readscurrentTrainingQuiz. 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.mdentry (append with Edit, never rewrite). Mark Update 34 “Case 3” as ✅ superseded.
A.5 Rollout order
- Backend (additive:
quizzesadded,quizkept) + tests → deploy. - Docs.
- Frontend reads
quizzes(withquizfallback) → deploy web + new APK. - Once old APKs are no longer in use (1-2 releases): remove
quizfrom 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 sharedpositionspace 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
lessonscolumns: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,mp4oriframeanywhere inapp/). Videos are YouTube/Drive iframes embedded inside the HTMLcontentby 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
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 MySQLENUM, so new values (pdf,audio,live,quiz_intro…) never need anALTER TABLEon the enum list. - PHP enum:
app/Enums/LessonType.php→enum LessonType: string { case Document = 'document'; case Video = 'video'; }. Cast it inLesson::$casts('type' => LessonType::class) and add it to$fillable. - Naming: use
typewith valuesvideo/document, notvideo_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
videowhen 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 indexlessons_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 staysdocument. Run with a count first and log how many rows changed. -
app/Enums/LessonType.php(new) +Lessonmodel$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
typein every outline payload (wherecontentisn’t sent):Http/Resources/TrainingResource.php:102-106→{id, label, type}.Services/MarketplacePreviewService.php:58→LESSON_OUTLINE_COLUMNSadd'type'.UserProgressAnalyticsController.php:101→ addtypeto theselect.- Full-model responses (
ChaptersController,TrainingsController::show,LessonsController::show/index) get it automatically.
- Import/export:
- Export already dumps all columns, so
typecomes along for free. Export_Import_Chapters_And_Lessons_Controller.php:226-233(JSON import) → maptype(fall back todocumentif missing or invalid).- Bulk image import (L384 R2, L541 local) →
type = document.
- Export already dumps all columns, so
-
database/factories/LessonFactory.php→typerandom between the enum cases. - Tests (
tests/Feature/LessonTypeTest):- create without
type→document; create withvideo→video; create withfoo→ 422; - update without
typekeeps the value; GET /training/{id}and marketplace preview includechapters.*.lessons.*.type.
- create without
- (Side fix, optional)
LessonsController.php:174:student_lesson.id→student_lesson.student_id.
B.5 Documentation — LMS_Documentation
-
api-reference/lessons.mdx: addtypeto the list response (L68-87), create params (L143-147,video|document, optional, defaultdocument), create example (L158-163), show response (L190-200), update params (L220-224), and update example (~L237-240). - Add
typeto lesson objects in:chapters.mdxL81-84,trainings.mdxL280, L489, L553-567,chapters-and-lessons-export-import.mdxL56-60, L222-227 (and note bulk import setsdocument),trainings-export-import.mdxL388-391. - Changelog entry.
B.6 Frontend — LMS_Frontend
-
lib/core/data/remote/lesson.dart: addenum LessonType { @JsonValue('document') document, @JsonValue('video') video }and the field@JsonKey(name: 'type', defaultValue: LessonType.document, unknownEnumValue: LessonType.document) LessonType type;.unknownEnumValuelets an older app version survive future backend values. UpdatecopyWithand runbuild_runner. -
Lesson payloads: add
typewhere lessons are built attraining_provider.dart:958(new training) and L1357-1363 (edit).Lesson.toJson()then sends it toPOST /lessonandPOST /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 inlib/core/utils/fa_compat.dart. -
Use the helper in:
course_content_tree.dart:238(student player, web + mobile drawer);preview_training.dart:1010. Todayplaymeans “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.dartaddLessonScreenL516-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_saveLessonL49-61), if the user hasn’t manually picked a type, pre-selectvideowhenEmbedCode.kindOf(content) == EmbedKind.youtube; - pass the type through
ChapterProvider.addLessonToChapter(...)(chapter_provider.dart:274-302).
- add a 2-option segmented control (Document / Video) under the title, stored in
-
Translations (
assets/translations/{en,fr,ar}.json), new keys:lessonType,lessonTypeDocument,lessonTypeVideo(+ optionalfreePreviewif not present). Re-run botheasy_localization:generatecommands. -
Optional: remove the dead
lessonVideoLink/enterYourLessonVideoLinkkeys and the commented-out code (add_trainings_page_two.dart:715). -
Tests: the student tree renders
circlePlayfor a video lesson andfileLinesfor a document lesson;Lesson.fromJsonwith a missing or unknowntype→document. -
major_code_updates.mdentry (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
typekeep working (defaultdocument, and updates don’t reset it).
Summary checklist (execution order)
- Backend A: remove the chapter squash (3 endpoints) + transaction/validation → model relations (
Chapter::quizzesordered,Training::quizfinal-only) → switch read paths toquizzes+ keep deprecatedquiz→ export/import → factory → tests. - Backend B: migration + backfill +
LessonTypeenum + validation + outline payloads + import + tests. - Docs: quizzes page rule change, chapter/training/export pages → plural; lessons page + lesson objects →
type; changelog. - Frontend A:
Chapter.quizzes(withquizfallback) → convert every reader (list in A.4.2) → tests. - Frontend B:
Lesson.typeenum → icon helper → 4 screens → form toggle + auto-suggest → translations → tests. - Later: remove the deprecated
quizfield; optional quizposition+ reorder; optional squashed-quiz recovery command; optional lesson/quiz interleaving.
Open questions for you
- 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.)
- Do you want Phase 2 quiz ordering (
position+ reorder) now, or is creation order fine? - Should we try to recover the quizzes that were already converted to exams (A.2.8)?
- Any other lesson types to plan for now (
pdf,audio,live)? The string + enum design makes adding them a one-line change.