Completion Tracking
1 5W Overview
Tracks student module completion state in Moodle's course_modules_completion table.
Supports two modes: manual — student POSTs a state value (0/1/2/3) via API;
and automatic view — a completion record is written silently when a student
fetches a module that has completionview=1.
The existing GET module endpoint is extended to return completionstate in its response.
POST — triggers during a student-initiated HTTP request to
POST /api/v1/courses/{courseId}/modules/{moduleId}/completion.
Runs after auth middleware (auth:sanctum + moodle.active), FormRequest
validation, and access control in ModuleService::show().
Auto-view — fires as a side-effect inside ModuleService::show()
on every successful GET /api/v1/courses/{courseId}/modules/{moduleId} call when the
module has completionview=1.
PM task I01 — surface per-module completion state to students and propagate
state changes back to Moodle so that course-progress, badges, and grade-book integrations
continue to function correctly. Moodle's own event system expects
\core\event\course_module_completion_updated to fire on every state change;
this feature queues that event via ForwardEventToMoodle on the
moodle-events queue.
Primary home: app/Modules/Course/ — controller, service, repository,
request, resource, DTO, event, exception, and enum live here.
Shared infrastructure: app/Shared/Models/MoodleCourseModuleCompletion.php
(the only write-enabled Moodle model in this feature).
Modified neighbours: MoodleCourseModule (new completionRecords() relation),
ModuleService, ModuleController, ModuleDetailResource,
CourseRepository, and AppServiceProvider.
Manual completion (POST):
MarkModuleCompleteRequest validates that state is a valid
CompletionState enum value. CompletionController::store() delegates
to ModuleService::show() for access control, then calls
CompletionService::markComplete(). The service checks completion=1
(throws CompletionNotAllowedException → 422 if not), then calls
CompletionRepository::upsert() which constructs the model manually and calls
save() — bypassing updateOrCreate() to preserve enum casting.
After the write, ModuleCompletionUpdated is dispatched; the
ForwardEventToMoodle listener processes it asynchronously on the
moodle-events queue.
Auto-view (GET side-effect):
ModuleService::show() calls CompletionService::recordViewIfRequired()
after every successful module retrieval. The method silently skips when
completion=0, completionview≠1, or viewed is already 1.
Any Throwable is caught and logged as a warning — it never blocks the GET response.
When a view is recorded, ModuleCompletionUpdated is also dispatched.
GET response extension:
ModuleController calls CompletionService::getForUser() and passes
the result to ModuleDetailResource::withCompletion(). The resource renders
completionstate as the enum integer value, or null when no record exists.
2 Sequence Diagram
(auth:sanctum + moodle.active) participant CC as CompletionController participant MRQ as MarkModuleCompleteRequest participant MS as ModuleService participant MR as ModuleRepository participant CS as CompletionService participant CR as CompletionRepository participant DB as MySQL
(course_modules_completion) participant EV as Event Bus participant Q as Queue
(moodle-events) participant MO as Moodle Plugin
(REST) B->>MW: POST /completion { state: 1 } MW->>CC: Authenticated student request CC->>MRQ: validate { state: enum(0-3) } MRQ-->>CC: MarkModuleCompleteDTO CC->>MS: show(ShowModuleDTO) MS->>MR: findForStudent(dto) MR->>DB: SELECT course_modules WHERE id=X AND enrolled DB-->>MR: MoodleCourseModule MR-->>MS: MoodleCourseModule MS->>CS: recordViewIfRequired(module, userId) Note over CS: completionview check — silent skip or write MS-->>CC: MoodleCourseModule CC->>CS: markComplete(dto, module) CS->>CS: check module.completion === 1 alt completion ≠ 1 CS-->>CC: throws CompletionNotAllowedException CC-->>B: 422 { code: 3004 } else completion === 1 CS->>CR: upsert(moduleId, userId, state) CR->>DB: SELECT existing record DB-->>CR: null or existing CR->>DB: INSERT / UPDATE (manual save()) DB-->>CR: MoodleCourseModuleCompletion CR-->>CS: [record, isNew] CS->>EV: dispatch(ModuleCompletionUpdated) CS-->>CC: MoodleCourseModuleCompletion CC-->>B: 200 CompletionStateResource EV->>Q: ForwardEventToMoodle (queued) Q->>MO: POST event payload end
3 Flowchart
4 Files Changed
| File Path | Layer | Description |
|---|---|---|
| app/Modules/Course/Enums/CompletionState.php | Enum | Defines the 4 Moodle completion states (0=Incomplete, 1=Complete, 2=CompletePass, 3=CompleteFail) with an isPassing() helper using match. |
| app/Shared/Models/MoodleCourseModuleCompletion.php | Model | Write-enabled Eloquent model for course_modules_completion. The only approved write-target Moodle model in this feature; $readOnly=false. Casts completionstate to the CompletionState enum. |
| app/Modules/Course/DTOs/MarkModuleCompleteDTO.php | DTO | final readonly DTO carrying courseId, moduleId, studentId, and CompletionState between request and service layers. |
| app/Modules/Course/Events/ModuleCompletionUpdated.php | Event | Domain event extending BaseEvent. Maps to Moodle's \core\event\course_module_completion_updated. Carries courseModuleId and CompletionState; sets other with both values for Moodle compatibility. |
| app/Modules/Course/Exceptions/CompletionNotAllowedException.php | Exception | Thrown by CompletionService when completion≠1. Carries CourseErrorCode::CompletionNotAllowed (3004) for structured API error responses. |
| app/Modules/Course/Repositories/CompletionRepositoryInterface.php | Interface | Contract defining findForUser, upsert, and recordView operations. Bound in AppServiceProvider to CompletionRepository. |
| app/Modules/Course/Repositories/CompletionRepository.php | Repository | Implements manual model construction + save() for both upsert and recordView. Deliberately avoids updateOrCreate() to preserve CompletionState enum casting. Skips write in recordView when viewed is already 1. |
| app/Modules/Course/Services/CompletionService.php | Service | Orchestrates manual completion (markComplete), view-based auto-completion (recordViewIfRequired), and record retrieval (getForUser). All event dispatches live here. recordViewIfRequired wraps writes in try/catch(\Throwable) to prevent blocking GET responses. |
| app/Modules/Course/Requests/MarkModuleCompleteRequest.php | Request | Validates that state is an integer and a valid CompletionState enum value using Illuminate\Validation\Rules\Enum. Builds MarkModuleCompleteDTO from validated data and route parameters. |
| app/Modules/Course/Resources/CompletionStateResource.php | Resource | Formats MoodleCourseModuleCompletion for the POST response envelope. Returns completionstate as the raw integer value of the enum. |
| app/Modules/Course/Controllers/CompletionController.php | Controller | Thin controller for POST /completion. Re-uses ModuleService::show() for access control, delegates to CompletionService::markComplete(), and maps exceptions to HTTP status codes (404/422). |
| database/factories/MoodleCourseModuleCompletionFactory.php | Factory | Eloquent factory for MoodleCourseModuleCompletion used in unit and feature tests. Provides default state values with CompletionState::Incomplete. |
| tests/Unit/Services/CompletionServiceTest.php | Test | 9 unit tests for CompletionService covering markComplete (happy path, throws on completion≠1), recordViewIfRequired (skip conditions, first view, repeated view, DB error resilience), and getForUser. |
| tests/Feature/Course/CompletionTest.php | Test | 13 feature tests for the full HTTP lifecycle: 401 unauthenticated, 404 not-found/not-enrolled, 422 invalid-state/disabled/automatic completion, create record, update record, toggle to incomplete, event dispatched, GET with null state, GET with existing state, auto-view recording. |
| app/Shared/Models/MoodleCourseModule.php | Model | Added completionRecords(): HasMany relationship to MoodleCourseModuleCompletion. Added completionview integer cast to existing $casts array. |
| app/Modules/Course/Enums/CourseErrorCode.php | Enum | Added CompletionNotAllowed = 3004 case to the error code enum used for structured API error responses. |
| app/Modules/Course/Services/ModuleService.php | Service | Injected CompletionService via constructor. Added call to recordViewIfRequired(module, studentId) after successful module retrieval in show(). |
| app/Modules/Course/Controllers/ModuleController.php | Controller | Injected CompletionService. After loading the module, calls getForUser() and passes the completion record to ModuleDetailResource::withCompletion(). |
| app/Modules/Course/Resources/ModuleDetailResource.php | Resource | Added withCompletion(?MoodleCourseModuleCompletion $record): static fluent setter. Added completionstate field to toArray(), rendered as null when no record exists. |
| app/Modules/Course/Repositories/CourseRepository.php | Repository | Refactored getProgressMap() to query MoodleCourseModuleCompletion model via Eloquent instead of a raw query, aligning with the new model structure. |
| app/Modules/Course/routes.php | Route | Added Route::post('/{courseId}/modules/{moduleId}/completion', [CompletionController::class, 'store']) inside the existing auth:sanctum + moodle.active middleware group. |
| app/Providers/AppServiceProvider.php | Provider | Registered CompletionRepositoryInterface → CompletionRepository binding and singleton binding for CompletionService in the service container. |
| tests/Feature/Course/ModuleContentEndpointTest.php | Test | Added course_modules_completion table schema to the test setup (setUp()) to prevent "table not found" failures when recordViewIfRequired fires during existing module tests. |
5 Rules Applied
- Strict layer flow: Route → Middleware → Controller → FormRequest → Service → Repository → Model.
- Controller is thin (≤15 lines per method); all logic lives in
CompletionService. MoodleCourseModuleCompletionplaced inapp/Shared/Models/as a dedicated Moodle-table model. Only this feature's approved write target has$readOnly=false; all other Moodle models remain read-only.- Event dispatched by the Service layer via
Event::dispatch(); no event dispatch in the controller or repository. ForwardEventToMoodleruns on themoodle-eventsqueue (async) — event forwarding never blocks HTTP response.- DTOs (
MarkModuleCompleteDTO) arefinal readonlywith constructor-promoted typed properties. - Interface (
CompletionRepositoryInterface) bound inAppServiceProvider; service depends on the contract, not the concrete class. - API versioned under
/api/v1/; resource named with plural nouns and kebab-case.
- All input validation in
MarkModuleCompleteRequest— no inline validation in the controller or service. statevalidated withIlluminate\Validation\Rules\Enum(CompletionState::class)— no raw integer comparison accepted from the client.- Every endpoint protected by
auth:sanctum+moodle.activemiddleware — not public by default. - Access control delegated to
ModuleService::show()(enrollment check) — students cannot mark completion for modules they cannot access. - No raw SQL — all DB access via Eloquent with typed bindings.
- Sensitive data not exposed; error responses use structured
code/messagewithout stack traces. - Rate limiting inherited from the
throttlemiddleware applied globally toapiroutes.
- PHP 8.3 Enum (
CompletionState) for all completion state values — no raw integer constants. - PHP 8.3 readonly properties on the DTO and event constructor arguments.
matchexpression used inCompletionState::isPassing()— noswitch.- All classes are
finalby default. - Every class and public method has a concise PHPDoc block with
@throwswhere applicable. - Laravel-first:
Carbon::now()->getTimestamp()instead oftime();Event::dispatch()facade;Log::warning(). - Named arguments used in
Event::dispatch(new ModuleCompletionUpdated(userId: ..., objectId: ..., ...))for clarity. - Constructor promotion in the DTO, event, service, and controller classes.
- No magic numbers —
completionview === 1documented with a comment mapping toCOMPLETION_TRACKING_*constants.
- TDD workflow followed — tests written before implementation for each behaviour.
- AAA (Arrange → Act → Assert) pattern in all 22 tests.
- All test methods prefixed with
test_it_and named to describe the expected behaviour. - Feature tests use
RefreshDatabasetrait and hit the real (in-memory) database — no mocking of Eloquent. - External Moodle HTTP calls mocked with
Http::fake(); queue faked withQueue::fake(). - Event dispatch asserted with
Event::fake()+Event::assertDispatched()with closure checking all relevant properties. - 100% coverage of event dispatching paths: manual completion and auto-view both verified.
- Existing test suite (
ModuleContentEndpointTest) extended with completion table schema to maintain test independence.