I05 — Lesson Logic (Read/Nav)
Generated 2026-04-17 · Flexi Student Portal v3 Backend · Branch: module-impl
Overview (5W)
Exposes Moodle lesson activities to the student portal via 4 REST endpoints: retrieve lesson metadata with resume state, list all pages in linked-list traversal order, view a single page, and resolve the next page from a student's answer selection. Answers are filtered server-side to hide grade, score, and response fields on question pages. CLUSTER and END_OF_CLUSTER pages are excluded from all API responses.
Triggered by authenticated student HTTP requests under /api/v1/courses/{courseId}/lessons/{lessonId}. The lesson timer write occurs on the first load of a lesson with a time requirement (timelimit > 0 or completiontimespent > 0) — subsequent loads are idempotent. Branch tracking writes occur each time a student selects a branch on a BRANCH_TABLE page via the navigate endpoint.
Moodle lesson pages form a doubly-linked list with conditional branching. The jump destination (lesson_answers.jumpto) is stored as a special value: 0 = next page, −1 = end of lesson, positive integer = specific page ID. The frontend cannot compute this resolution without access to the Moodle database. Without this server-side navigation layer the student cannot traverse a lesson. Moodle also requires lesson_timer and lesson_branch records to evaluate time-based completion criteria and forward events correctly.
Lives inside the Course module (app/Modules/Course/) with a new LessonController, two services (LessonService, LessonNavService), four repository pairs, four DTOs, three events, five API resources, two enums, and four custom exceptions. Six read-only / write-enabled Eloquent models for Moodle tables are added to app/Shared/Models/. Bindings are registered in AppServiceProvider.
A thin controller delegates to the service layer via typed DTOs (created by FormRequest). LessonService orchestrates lesson retrieval, resume-page detection (comparing timestamps from lesson_attempts and lesson_branch), and idempotent timer creation. LessonNavService contains the pure jumpto resolution algorithm (resolveJumpto()) and the linked-list walker (buildOrderedPageList()) — both methods are unit-testable in isolation with no DB dependency. Repositories encapsulate all DB access. Three domain events extend BaseEvent and are forwarded to Moodle asynchronously via ForwardEventToMoodle (queued listener). Answer fields (grade, score, response) are stripped in the Resource layer based on LessonPageType enum.
Sequence Diagram — Full HTTP Lifecycle
(auth:sanctum
moodle.active) participant C as LessonController participant FR as FormRequest participant SVC as LessonService /
LessonNavService participant REPO as Repositories participant DB as Moodle DB
(shared MySQL) participant EV as Event Bus participant Q as Queue
(ForwardToMoodle) Note over B,Q: GET /courses/{cId}/lessons/{lId} — Show Lesson B->>MW: GET /lessons/{lessonId} MW->>MW: Verify Sanctum token + moodle.active MW->>C: show(ShowLessonRequest, courseId, lessonId) C->>FR: authorize() — user authenticated? FR-->>C: ShowLessonDTO{courseId, lessonId, studentId} C->>SVC: LessonService::show(dto) SVC->>REPO: LessonRepository::findForStudent(dto) REPO->>DB: SELECT lesson JOIN course_modules JOIN user_enrolments WHERE active DB-->>REPO: MoodleLesson | null REPO-->>SVC: MoodleLesson (or throws LessonNotEnrolledException) SVC->>REPO: LessonRepository::findResumePageForStudent(lessonId, userId) REPO->>DB: SELECT MAX(timeseen) from lesson_attempts + lesson_branch for current retry DB-->>REPO: ?resumePageId SVC->>REPO: LessonTimerRepository::startIfNotRunning(lessonId, userId, retry) REPO->>DB: SELECT id FROM lesson_timer WHERE lessonid+userid+retry alt Timer does not exist AND timelimit/completiontimespent > 0 DB-->>REPO: no row REPO->>DB: INSERT lesson_timer (starttime=now, lessontime=0) DB-->>REPO: MoodleLessonTimer (fresh) REPO-->>SVC: timer SVC->>EV: Event::dispatch(LessonStarted{userId, courseId, lessonId, cmId}) EV->>Q: ForwardEventToMoodle (queued async) else Timer already exists DB-->>REPO: existing row REPO-->>SVC: null (no-op) end SVC-->>C: array{lesson, resumePageId, timerStartedAt, currentRetry} C-->>B: 200 JSON — LessonResource with student state Note over B,Q: GET /courses/{cId}/lessons/{lId}/pages — List Pages B->>C: pages(ListLessonPagesRequest, courseId, lessonId) C->>FR: toDTO() → ListLessonPagesDTO C->>SVC: LessonService::listPages(dto) SVC->>REPO: LessonRepository::findForStudent(dto) REPO->>DB: enrollment check DB-->>REPO: MoodleLesson SVC->>REPO: LessonPageRepository::listOrdered(lessonId) REPO->>DB: SELECT * FROM lesson_pages WHERE lessonid ORDER by linked-list walk DB-->>REPO: Collection of MoodleLessonPage (with answers eager-loaded) SVC->>SVC: LessonNavService::buildOrderedPageList(pages) — walk prevpageid=0 chain SVC-->>C: ordered Collection C-->>B: 200 JSON — LessonPageCollectionResource (CLUSTER/END_OF_CLUSTER filtered) Note over B,Q: GET /courses/{cId}/lessons/{lId}/pages/{pId} — Show Page B->>C: page(ShowLessonPageRequest, courseId, lessonId, pageId) C->>SVC: LessonService::showPage(dto) SVC->>REPO: LessonRepository::findForStudent(dto) + LessonPageRepository::findForLesson(pageId, lessonId) REPO->>DB: SELECT lesson_pages WHERE id AND lessonid WITH answers DB-->>REPO: MoodleLessonPage | null SVC->>EV: Event::dispatch(LessonPageViewed{userId, courseId, lessonId, pageId, cmId, pageTypeLabel}) EV->>Q: ForwardEventToMoodle (queued async) SVC-->>C: MoodleLessonPage C-->>B: 200 JSON — LessonPageResource (grade/score/response hidden for question pages) Note over B,Q: POST /courses/{cId}/lessons/{lId}/pages/{pId}/navigate — Navigate B->>C: navigate(NavigateLessonPageRequest, courseId, lessonId, pageId) body={answer_id} C->>FR: rules() — answer_id required, integer, exists lesson_answers WHERE pageid=pageId FR-->>C: NavigateLessonPageDTO{courseId, lessonId, pageId, answerId, studentId} C->>SVC: LessonNavService::navigate(dto) SVC->>REPO: LessonRepository::findForStudent(dto) REPO->>DB: enrollment check SVC->>REPO: LessonPageRepository::findForLesson(pageId, lessonId) DB-->>REPO: MoodleLessonPage SVC->>SVC: resolveJumpto(answer, page) — pure algorithm, no DB Note right of SVC: jumpto=0 → page.nextpageid
jumpto=-1 → end of lesson
jumpto>0 → specific page ID alt Page is BRANCH_TABLE (qtype=20) SVC->>REPO: LessonBranchRepository::track(lessonId, userId, pageId, nextPageId, retry) REPO->>DB: UPSERT lesson_branch (timeseen=now, nextpageid) DB-->>REPO: MoodleLessonBranch SVC->>EV: Event::dispatch(LessonBranchVisited{userId, courseId, lessonId, pageId, cmId, nextPageId}) EV->>Q: ForwardEventToMoodle (queued async) end SVC-->>C: NavigationResult{nextPageId, isEndOfLesson} C-->>B: 200 JSON — NavigationResultResource{next_page_id, is_end_of_lesson, feedback:null} Note over Q: All queued listeners run async — do NOT block HTTP response
Flowchart — Navigate Endpoint (Main Logic)
answer_id in body]) --> B{auth:sanctum
token valid?} B -- No --> B1[401 Unauthenticated]:::err B -- Yes --> C[NavigateLessonPageRequest
validate answer_id] C --> D{answer_id present
and integer?} D -- No --> D1[422 Validation Error
answer_id required]:::err D -- Yes --> E{answer exists in
lesson_answers WHERE
pageid = pageId?} E -- No --> E1[422 Validation Error
answer does not belong to page]:::err E -- Yes --> F[LessonNavService::navigate] F --> G[LessonRepository
findForStudent] G --> H{Lesson found AND
student enrolled
in course?} H -- No --> H1[404 LessonNotFoundException
code 3005]:::err H -- Yes --> I[LessonPageRepository
findForLesson pageId+lessonId] I --> J{Page belongs
to lesson?} J -- No --> J1[404 LessonPageNotFoundException
code 3007]:::err J -- Yes --> K[resolveJumpto
pure algorithm] K --> L{jumpto value?} L -- "jumpto = 0" --> M[next_page_id =
page.nextpageid] M --> N{nextpageid = 0?} N -- Yes --> O[is_end_of_lesson = true
next_page_id = null]:::eol N -- No --> P[is_end_of_lesson = false
next_page_id = nextpageid]:::ok L -- "jumpto = -1" --> O L -- "jumpto > 0" --> Q2[next_page_id = jumpto
specific page ID]:::ok O --> R{Page type
= BRANCH_TABLE
qtype 20?} P --> R Q2 --> R R -- Yes --> S[LessonBranchRepository
track — UPSERT lesson_branch] S --> T[Event::dispatch
LessonBranchVisited
queued async] T --> U[NavigationResultResource
next_page_id, is_end_of_lesson
feedback=null] R -- No --> U U --> V([200 OK JSON Response]):::success classDef err fill:#3b0f0f,stroke:#ef4444,color:#fca5a5 classDef success fill:#052e16,stroke:#10b981,color:#86efac classDef eol fill:#1c1917,stroke:#f59e0b,color:#fcd34d classDef ok fill:#0c1a3b,stroke:#60a5fa,color:#93c5fd
Files Changed
| File Path | Layer | Description |
|---|---|---|
| app/Modules/Course/Controllers/LessonController.php | Controller | 4 thin action methods: show, pages, page, navigate. Delegates to services, transforms exceptions to API responses. |
| app/Modules/Course/Services/LessonService.php | Service | show() retrieves lesson + resume page + starts timer. listPages() returns ordered pages. showPage() fetches single page and dispatches LessonPageViewed. |
| app/Modules/Course/Services/LessonNavService.php | Service | navigate() resolves next page, tracks branch, dispatches LessonBranchVisited. resolveJumpto() and buildOrderedPageList() are pure algorithms with no DB dependency. |
| app/Modules/Course/Repositories/LessonRepositoryInterface.php | Repository | Contract for findForStudent, findResumePageForStudent, findCmId. |
| app/Modules/Course/Repositories/LessonRepository.php | Repository | Enrollment guard via course_modules → enrol → user_enrolments JOIN. Resume page by comparing MAX(timeseen) from lesson_attempts and lesson_branch. |
| app/Modules/Course/Repositories/LessonPageRepositoryInterface.php | Repository | Contract for listOrdered and findForLesson. |
| app/Modules/Course/Repositories/LessonPageRepository.php | Repository | listOrdered fetches all pages with eager-loaded answers. findForLesson validates page ownership via lessonid column — prevents page ID enumeration across lessons. |
| app/Modules/Course/Repositories/LessonTimerRepositoryInterface.php | Repository | Contract for startIfNotRunning. |
| app/Modules/Course/Repositories/LessonTimerRepository.php | Repository | Idempotent INSERT to lesson_timer — checks for existing record before writing. Returns null when no timer is needed (lesson has no time requirement). |
| app/Modules/Course/Repositories/LessonBranchRepositoryInterface.php | Repository | Contract for track. |
| app/Modules/Course/Repositories/LessonBranchRepository.php | Repository | UPSERT to lesson_branch on each BRANCH_TABLE navigation. Updates timeseen and nextpageid on re-visit. |
| app/Modules/Course/DTOs/ShowLessonDTO.php | DTO | final readonly — courseId, lessonId, studentId. |
| app/Modules/Course/DTOs/ListLessonPagesDTO.php | DTO | final readonly — courseId, lessonId, studentId. |
| app/Modules/Course/DTOs/ShowLessonPageDTO.php | DTO | final readonly — courseId, lessonId, pageId, studentId. |
| app/Modules/Course/DTOs/NavigateLessonPageDTO.php | DTO | final readonly — courseId, lessonId, pageId, answerId, studentId. |
| app/Modules/Course/DTOs/NavigationResult.php | DTO | final readonly — nextPageId (?int), isEndOfLesson (bool). Named constructors: endOfLesson(), nextPage(int). |
| app/Modules/Course/Events/LessonStarted.php | Event | Extends BaseEvent. Fired when lesson_timer is first created. Maps to mod_lesson\event\lesson_started. |
| app/Modules/Course/Events/LessonPageViewed.php | Event | Extends BaseEvent. Fired on GET single page. Maps to mod_lesson\event\lesson_page_viewed with pagetype label in other. |
| app/Modules/Course/Events/LessonBranchVisited.php | Event | Extends BaseEvent. Fired after lesson_branch write. Maps to mod_lesson\event\lesson_page_viewed with pagetype=branch_table and nextpageid in other. |
| app/Modules/Course/Requests/ShowLessonRequest.php | Request | authorize() requires authenticated user. rules() validates courseId and lessonId as integers. toDTO() → ShowLessonDTO. |
| app/Modules/Course/Requests/ListLessonPagesRequest.php | Request | authorize() + rules() for courseId and lessonId. toDTO() → ListLessonPagesDTO. |
| app/Modules/Course/Requests/ShowLessonPageRequest.php | Request | Validates courseId, lessonId, pageId. toDTO() → ShowLessonPageDTO. |
| app/Modules/Course/Requests/NavigateLessonPageRequest.php | Request | answer_id required, integer, Rule::exists('lesson_answers','id')->where('pageid', pageId). Prevents cross-page answer injection. toDTO() → NavigateLessonPageDTO. |
| app/Modules/Course/Resources/LessonResource.php | Resource | Extends ApiResource. Serializes lesson metadata + nested student state (current_retry, timer_started_at, resume_page_id). withStudentState() fluent setter. |
| app/Modules/Course/Resources/LessonPageResource.php | Resource | Serializes single page with answers via LessonAnswerResource. qtype_label derived from LessonPageType::label(). |
| app/Modules/Course/Resources/LessonPageCollectionResource.php | Resource | Filters out CLUSTER (40) and END_OF_CLUSTER (50) pages before mapping to LessonPageResource. |
| app/Modules/Course/Resources/LessonAnswerResource.php | Resource | forPageType() sets context. Hides grade, score, response, responseformat for question pages (isQuestion()=true). Exposes jumpto only for BRANCH_TABLE answers. |
| app/Modules/Course/Resources/NavigationResultResource.php | Resource | Extends ApiResource. Returns {next_page_id, is_end_of_lesson, feedback: null}. feedback always null in I05 scope. |
| app/Modules/Course/Enums/LessonPageType.php | Enum | int-backed enum with 10 cases (ShortAnswer=0 … EndOfCluster=50). Methods: label(), isQuestion(), isNavigation(), isHidden(). |
| app/Modules/Course/Enums/LessonJumpTo.php | Enum | int-backed enum: NextPage=0, EndOfLesson=-1. Positive values represent page IDs and are handled by tryFrom() returning null. |
| app/Modules/Course/Enums/CourseErrorCode.php | Enum | Modified — added cases 3005–3009: LessonNotFound, NotEnrolled, LessonPageNotFound, LessonTimedOut, LessonAnswerNotFound. |
| app/Modules/Course/Exceptions/LessonNotFoundException.php | Exception | Maps to error code 3005 / HTTP 404. |
| app/Modules/Course/Exceptions/LessonNotEnrolledException.php | Exception | Maps to error code 3006 / HTTP 403. Student not enrolled in the course. |
| app/Modules/Course/Exceptions/LessonPageNotFoundException.php | Exception | Maps to error code 3007 / HTTP 404. Page does not belong to the lesson. |
| app/Modules/Course/Exceptions/LessonAnswerNotFoundException.php | Exception | Maps to error code 3009 / HTTP 404. Answer does not belong to the page. |
| app/Modules/Course/routes.php | Routes | 4 new routes added under /api/v1/courses/{courseId}/lessons. Navigate route has throttle:30,1 middleware. All require auth:sanctum + moodle.active. |
| app/Providers/AppServiceProvider.php | Provider | Binds 4 repository interfaces to concrete classes. Registers LessonService and LessonNavService as singletons. Maps 3 events to ForwardEventToMoodle listener. |
| app/Shared/Models/MoodleLesson.php | Model | Read-only. Table: lesson. Relations: pages(), timers(), course(). Casts: retake, practice, modattempts, completionendreached as bool. |
| app/Shared/Models/MoodleLessonPage.php | Model | Read-only. Table: lesson_pages. qtype cast to LessonPageType enum. Relations: lesson(), answers(). |
| app/Shared/Models/MoodleLessonAnswer.php | Model | Read-only. Table: lesson_answers. grade/score/response hidden from API. Relations: page(), lesson(). |
| app/Shared/Models/MoodleLessonAttempt.php | Model | Read-only. Table: lesson_attempts. Used in I05 only to determine resume page via MAX(timeseen). correct cast to bool. |
| app/Shared/Models/MoodleLessonBranch.php | Model | Write-enabled ($readOnly = false). Table: lesson_branch. Fillable: lessonid, userid, pageid, retry, flag, timeseen, nextpageid. |
| app/Shared/Models/MoodleLessonTimer.php | Model | Write-enabled ($readOnly = false). Table: lesson_timer. Fillable: lessonid, userid, starttime, lessontime, completed, timemodifiedoffline. completed cast to bool. |
| tests/Feature/Course/LessonShowTest.php | Test | 11 feature tests for GET /lessons/{id}. Covers: enrollment guard, resume page, timer creation, idempotency, event dispatch, 401/404 cases. |
| tests/Feature/Course/LessonPagesTest.php | Test | 10 feature tests for GET /lessons/{id}/pages. Covers: linked-list ordering, CLUSTER filtering, embedded answers, answer field hiding, jumpto exposure. |
| tests/Feature/Course/LessonPageShowTest.php | Test | 10 feature tests for GET /lessons/{id}/pages/{pid}. Covers: page ownership, answer filtering, LessonPageViewed event dispatch, cross-page answer isolation. |
| tests/Feature/Course/LessonNavigateTest.php | Test | 14 feature tests for POST /navigate. Covers: all jumpto cases, branch write, event dispatch, 401/422/404 cases, end-of-lesson resolution. |
| tests/Unit/Services/LessonNavServiceTest.php | Test | 10 unit tests for jumpto resolution and linked-list walker. Pure algorithm tests — no DB. Covers all 3 jumpto cases, edge cases, NavigationResult factories. |
| tests/Unit/Services/LessonPageTypeTest.php | Test | 7 unit tests for LessonPageType enum. Covers isQuestion(), isNavigation(), isHidden(), label(), tryFrom() casting from integers. |
Rules Applied
- No layer skipping — every request flows Controller → FormRequest → Service → Repository → Model
- Controllers are thin (5–15 lines per method) — all business logic in services
- DTOs are
final readonlywith constructor-promoted typed properties - Repository interfaces bound via service provider — services depend on contracts, not concretes
- Domain events dispatched by service layer, not controllers or repositories
- ForwardEventToMoodle listener runs on queue — event forwarding never blocks HTTP response
- Read-only Moodle models guard against writes via MoodleModel base class ($readOnly)
- Write-enabled models (MoodleLessonBranch, MoodleLessonTimer) explicitly documented with justification
- All routes under /api/v1/ with versioned prefix
- Response envelope: {success, message, data, errors, code} on all endpoints
- HTTP 404 for not found, 403 for forbidden, 422 for validation, 401 for unauthenticated
- Enrollment guard on every repository call — unenrolled students never see lesson content
- Answer ownership validated in FormRequest via Rule::exists with pageid constraint — prevents cross-page answer injection
- Page ownership validated in LessonPageRepository::findForLesson by lessonid column — prevents page ID enumeration across lessons
- grade, score, response, responseformat stripped in Resource layer for question pages (isQuestion() = true)
- jumpto and jumpto_page_title exposed only for BRANCH_TABLE pages — never for gradable questions
- correct boolean from lesson_attempts never returned in any API response
- Navigate endpoint uses throttle:30,1 (stricter than default 60/min) — write-capable endpoint protected
- All input validated in dedicated FormRequest classes — no inline validation in controllers or services
- No raw SQL — all queries via Eloquent with parameter binding
- PHP 8.3 enums used for LessonPageType (int-backed, 10 cases) and LessonJumpTo — no magic integer constants
- match expressions used throughout (jumpto resolution, label() method, isQuestion/isNavigation)
- All classes final by default — LessonController, LessonService, LessonNavService, all DTOs, all Repositories
- Constructor promotion on all DTOs and NavigationResult
- Strict comparison (===) throughout service and repository layers
- No raw PHP array functions — Collection::map, Collection::filter used
- Carbon used for all timestamp handling — no raw date() or DateTime
- Custom exception classes per domain (4 new exceptions) — never catch \Exception
- PHPDoc on every class and public method with @throws for exceptions that can be raised
- Named arguments used for 3+ parameter calls
- Single quotes for plain strings, double quotes only for interpolation
- TDD workflow followed — failing test written before implementation for each behavior
- All test methods named test_it_* in snake_case describing the behavior and expected outcome
- AAA pattern (Arrange → Act → Assert) in every test method
- Feature tests use RefreshDatabase trait and hit the actual test database
- Event::fake() used in all event dispatch assertions — never mocking Eloquent
- Pure algorithm methods (resolveJumpto, buildOrderedPageList) covered by unit tests without DB
- Moodle REST calls mocked via Http::fake() in all feature tests
- Each scenario has its own dedicated test method — no if/else in tests
- 48+ test cases covering happy path, validation failures, auth failures, not found cases, and edge cases
- 100% event dispatch coverage — all 3 events tested for correct dispatch conditions
- Tests mirror app structure: app/Modules/Course → tests/Feature/Course, app/Services → tests/Unit/Services