Overview (5W)

📦 What

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.

When

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.

🎯 Why

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.

📍 Where

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.

How

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

sequenceDiagram autonumber participant B as Browser participant MW as Middleware
(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)

flowchart TD A([POST /navigate
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
flowchart LR subgraph jumpto ["jumpto Resolution — resolveJumpto()"] direction TB A1["answer.jumpto"] --> B1{"tryFrom(jumpto)"} B1 -- "NextPage (0)" --> C1["return page.nextpageid\n(0 = end of lesson)"] B1 -- "EndOfLesson (-1)" --> D1["return NavigationResult::endOfLesson()"] B1 -- "null (positive int)" --> E1["return NavigationResult::nextPage(jumpto)"] end subgraph walk ["buildOrderedPageList() — Linked-List Walk"] direction TB A2["Find root: prevpageid = 0"] --> B2["current = root"] B2 --> C2{"current.nextpageid\n!= 0?"} C2 -- Yes --> D2["push current\ncurrent = map[nextpageid]"] D2 --> C2 C2 -- No --> E2["push last page\n(nextpageid = 0)"] E2 --> F2["Filter CLUSTER (40)\nEND_OF_CLUSTER (50)"] end

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

🏛 Architecture
  • 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 readonly with 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
🔒 Security
  • 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
✏️ Coding Style
  • 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
🧪 Testing
  • 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