📋

5W Analysis

🎯What

Two-part change: (1) strip the content block from the existing GET /api/v1/courses/{id} modinfo endpoint — all module types now return only metadata + resolved name; and (2) add a dedicated endpoint GET /api/v1/courses/{courseId}/modules/{moduleId} that returns full, type-specific content (page, label, url) on demand with @@PLUGINFILE@@ token resolution.

👤Who

Authenticated students protected by auth:sanctum + moodle.active middleware. The repository layer enforces an additional enrollment check — a student can only fetch modules from courses they are actively enrolled in. Returns 404 (not 403) for non-existent modules, wrong course, or unenrolled access to prevent enumeration.

💡Why

The modinfo endpoint was returning full page/label/url content for every module on every course load — a large, unused payload. API consumers need to lazy-load module content independently, enabling selective caching and reducing initial payload size. The dedicated endpoint follows the same resolver infrastructure already built for pluginfile tokens, just moved to the right abstraction boundary.

⏱️When

The new endpoint is triggered on demand — when a student navigates to a specific module. The request lifecycle begins at the ShowModuleRequest FormRequest (auth + id validation), flows through ModuleController → ModuleService → ModuleRepository, and is formatted by ModuleDetailResource which applies pluginfile resolution synchronously before returning the JSON response.

📍Where

Entirely within app/Modules/Course/. New files: ModuleController, ModuleService, ShowModuleDTO, ShowModuleRequest, ModuleDetailResource, ModuleNotFoundException. Modified: CourseController (resolver injections removed), ModuleResource (content stripped, name only), SectionResource and CourseResource (threading removed). Service binding added in AppServiceProvider.

⚙️How

ModuleRepository::findForStudent() queries course_modules scoped by moduleId, courseId, visible=1, and a whereHas enrollment chain into enrol → user_enrolments. It eager-loads moduleType, page, label, url relations. ModuleDetailResource inspects modname and builds a type-specific content block, resolving @@PLUGINFILE@@ tokens via PluginfileUrlResolverInterface + ContextResolverInterface. Failures are caught silently — raw content is served without token replacement.

🔀

Sequence Diagram

GET /api/v1/courses/{courseId}/modules/{moduleId} — Full request lifecycle
sequenceDiagram autonumber actor Browser participant MW as Middleware
(sanctum + moodle.active) participant Req as ShowModuleRequest participant Ctrl as ModuleController participant Svc as ModuleService participant Repo as ModuleRepository participant DB as MySQL
(Moodle DB) participant CR as ContextResolver participant PFR as PluginfileUrlResolver participant Res as ModuleDetailResource Browser->>MW: GET /api/v1/courses/{courseId}/modules/{moduleId} MW-->>Browser: 401 JSON (if unauthenticated) MW->>Req: Resolve ShowModuleRequest Req->>Req: authorize() → user != null Req->>Req: rules() → validate route params as integers Req->>Ctrl: show(request, courseId, moduleId) Ctrl->>Req: toDTO() → ShowModuleDTO(courseId, moduleId, studentId) Ctrl->>Svc: show(ShowModuleDTO) Svc->>Repo: findForStudent(ShowModuleDTO) Repo->>DB: SELECT course_modules WHERE id=moduleId AND course=courseId AND visible=1
+ whereHas enrol→user_enrolments (student enrolled + active) DB-->>Repo: MoodleCourseModule | null Repo->>DB: Eager-load: moduleType, page, label, url DB-->>Repo: Relations loaded Repo-->>Svc: MoodleCourseModule | null alt Module not found / not enrolled Svc->>Svc: throw ModuleNotFoundException Svc-->>Ctrl: ModuleNotFoundException Ctrl-->>Browser: 404 {success:false, code:3003} else Module found Svc-->>Ctrl: MoodleCourseModule Ctrl->>Res: new ModuleDetailResource(module) Ctrl->>Res: withResolvers(pluginfileResolver, contextResolver) Res->>Res: inspect modname (page | label | url | other) alt modname = page or label (needs pluginfile resolution) Res->>CR: resolve(ContextLevel::Module, cmId) CR->>DB: SELECT context WHERE contextlevel=70 AND instanceid=cmId DB-->>CR: contextId CR-->>Res: contextId Res->>PFR: resolve(html, contextId, component, filearea, itemId) PFR->>PFR: Replace @@PLUGINFILE@@ with portal signed URLs PFR-->>Res: resolved HTML else modname = url Res->>Res: build content block (no pluginfile needed) else unimplemented type Res->>Res: no content block added end Res-->>Browser: 200 {success:true, data:{id, instance, modname, name, url, indent, completion, content?}} end
🌊

Flowchart

Main success path, validation branches, and error paths
flowchart TD A([HTTP Request]) --> B{Authenticated?\nauth:sanctum} B -- No --> Z1[/401 Unauthenticated/] B -- Yes --> C{moodle.active\ncheck} C -- Suspended/deleted --> Z2[/403 Forbidden/] C -- OK --> D[ShowModuleRequest\nauthorize + rules] D --> E{courseId & moduleId\nare integers?} E -- Invalid --> Z3[/422 Unprocessable/] E -- Valid --> F[ModuleController::show\ntoDTO → ShowModuleDTO] F --> G[ModuleService::show\nShowModuleDTO] G --> H[ModuleRepository::findForStudent] H --> I{WHERE id=moduleId\nAND course=courseId\nAND visible=1} I -- Not found --> Z4[/404 code 3003\nModule not found/] I -- Found --> J{Student enrolled\nin course?\nwhereHas enrol→user_enrolments} J -- Not enrolled --> Z4 J -- Enrolled --> K[Eager-load: moduleType\npage, label, url] K --> L{modname?} L -- page --> M[resolvePageContent\nget contextId → replace @@PLUGINFILE@@\nfields: intro,content,display...] L -- label --> N[resolveLabelContent\nget contextId → replace @@PLUGINFILE@@\nfields: intro,introformat,timemodified] L -- url --> O[resolveUrlContent\nfields: externalurl,intro,display...] L -- other type --> P[no content block] M --> Q{contextId resolved?} N --> Q Q -- Yes --> R[Full token replacement] Q -- Error → silent --> S[Raw content served\nno token replacement] R --> T[/200 data with content block/] S --> T O --> U[/200 data with content block/] P --> V[/200 data without content\nmodname,id,instance,url.../] style Z1 fill:#ef4444,color:#fff,stroke:none style Z2 fill:#ef4444,color:#fff,stroke:none style Z3 fill:#ef4444,color:#fff,stroke:none style Z4 fill:#ef4444,color:#fff,stroke:none style T fill:#10b981,color:#fff,stroke:none style U fill:#10b981,color:#fff,stroke:none style V fill:#10b981,color:#fff,stroke:none
📁

Files Changed

File Path Layer Change Description
app/Modules/Course/Controllers/ModuleController.php Controller NEW Handles GET /api/v1/courses/{courseId}/modules/{moduleId}. Delegates to ModuleService, catches ModuleNotFoundException, and wraps the result in ModuleDetailResource with resolver injection.
app/Modules/Course/Controllers/CourseController.php Controller MODIFIED Removed PluginfileUrlResolverInterface and ContextResolverInterface constructor injections and withModuleContentResolvers() call. Now thin — passes only withStructure() and withEnrollment() flags.
app/Modules/Course/Services/ModuleService.php Service NEW Business logic for single-module retrieval. Calls ModuleRepository::findForStudent() and throws ModuleNotFoundException when the result is null.
app/Modules/Course/Repositories/ModuleRepository.php Repository MODIFIED Added findForStudent(ShowModuleDTO) — queries by moduleId + courseId + visible=1 with a whereHas enrollment chain. Eager-loads moduleType, page, label, url relations.
app/Modules/Course/Repositories/ModuleRepositoryInterface.php Repository MODIFIED Added findForStudent(ShowModuleDTO): ?MoodleCourseModule contract method with full PHPDoc describing enrollment enforcement and eager-load contract.
app/Modules/Course/DTOs/ShowModuleDTO.php DTO NEW final readonly DTO carrying courseId, moduleId, and studentId across the controller → service → repository boundary.
app/Modules/Course/Exceptions/ModuleNotFoundException.php Exception NEW Domain exception thrown when a module cannot be found or the student is not enrolled. Carries CourseErrorCode::ModuleNotFound (code 3003) as a typed property.
app/Modules/Course/Requests/ShowModuleRequest.php Request NEW FormRequest for the new endpoint. Authorizes authenticated users, validates courseId and moduleId as integers from route parameters, and provides toDTO() factory.
app/Modules/Course/Resources/ModuleDetailResource.php Resource NEW API resource for a single module with full content. Inspects modname and builds a type-specific content block (page, label, url), applying @@PLUGINFILE@@ token resolution via injected resolvers. Silent failure on context lookup errors.
app/Modules/Course/Resources/ModuleResource.php Resource MODIFIED Stripped all content resolver methods (resolvePageContent, resolveLabelContent, resolveUrlContent, withContentResolvers). Now resolves name only from plugin relations for page/label/url. Added PHPDoc noting content was moved to the dedicated endpoint.
app/Modules/Course/Resources/SectionResource.php Resource MODIFIED Removed withModuleContentResolvers() threading to ModuleResource. Retained withPluginfileResolution() for section summary tokens only. buildModules() now passes withContext() only.
app/Modules/Course/Resources/CourseResource.php Resource MODIFIED Removed withModuleContentResolvers() call on SectionResource in toStructureArray(). CourseController no longer injects resolver dependencies for module content.
app/Modules/Course/routes.php Routing MODIFIED Added Route::get('/{courseId}/modules/{moduleId}', [ModuleController::class, 'show']) named api.v1.courses.modules.show under the auth:sanctum + moodle.active middleware group.
app/Providers/AppServiceProvider.php Provider MODIFIED Bound ModuleRepositoryInterface → ModuleRepository and registered ModuleService with constructor injection of ModuleRepositoryInterface.
tests/Feature/Course/ModuleContentEndpointTest.php Test NEW 8 feature tests covering: 401 unauthenticated, 404 module not found, 404 wrong course, 404 not enrolled, 200 page module, 200 label module, 200 url module, 200 unimplemented type (no content). Uses inline schema creation and RefreshDatabase.
tests/Feature/Course/CourseModinfoTest.php Test MODIFIED Added assertion that modules have no content key in the modinfo shape. Asserts name is still resolved for page type. Existing tests for modinfo structure, section/module ordering, and URL construction were retained.
tests/Feature/Course/PageModuleContentTest.php Test DELETED Superseded by ModuleContentEndpointTest. Page content tests migrated to the new consolidated test class.
tests/Feature/Course/LabelModuleContentTest.php Test DELETED Superseded by ModuleContentEndpointTest. Label content tests migrated to the new consolidated test class.
tests/Feature/Course/UrlModuleContentTest.php → ModuleContentEndpointTest.php Test RENAMED Renamed and expanded from the URL-specific test to the unified ModuleContentEndpointTest covering all module types and error scenarios.
docs/openapi.yaml Docs MODIFIED Appended GET /api/v1/courses/{courseId}/modules/{moduleId} spec; updated modinfo module shape to remove content block.
docs/postman_collection.json Docs MODIFIED Added new request with examples for page/label/url/unimplemented types; updated modinfo examples to reflect the stripped content block.
📐

Rules Applied