🔍 5W Overview

📦
What

Parses and evaluates Moodle's availability JSON tree stored in mdl_course_modules.availability against the current student's state, then filters or annotates module responses. Supports six condition types — date, completion, grade, group, grouping, and profile — with full recursive boolean operator logic (&, |, !&, !|) and NOT propagation.

When

Runs synchronously at request time, triggered on every call to the module list endpoint (GET /api/v1/courses/{id}/sections/{id}/modules) and the module show endpoint (GET /api/v1/courses/{id}/modules/{id}). No background jobs. No writes to any table. Pure read-only evaluation.

🎯
Why

Moodle supports conditional access rules per course module. Without this feature the student portal exposes all modules unconditionally, violating instructor configuration. Modules with unmet conditions must be either hidden entirely (preventing information leak) or shown as locked with a human-readable reason — exactly mirroring Moodle's native student course view behaviour.

📁
Where

New service-only module at app/Modules/Availability/ — no routes, controllers, or form requests. Contains Conditions, DTOs, Enums, Contracts, and Services. Integrated into the existing Course module via injection into SectionController and ModuleService. Six new read-only shared models added to app/Shared/Models/.

How

1 — Parsing: AvailabilityService::evaluate() receives the raw JSON string, decodes it with JSON_THROW_ON_ERROR, and delegates to evaluateNode(). 2 — Tree traversal: Each node is either a leaf condition (has type key) or an operator subtree (has op + c keys). Leaf conditions are resolved via ConditionFactory by type string. Unknown types return AvailabilityResult::hidden() as a secure default. 3 — Logic flags: computeLogicFlags(op, not) derives [$innerNot, $andMode] via XOR logic, correctly propagating NOT through nested subtrees. 4 — AND mode: All children must pass; showc[i]=false on any failing child hides the whole module. 5 — OR mode: First passing child wins; show=false on the node hides when all fail. 6 — Results: Three states — available(), locked(reason), hidden(). Hidden modules are excluded from lists and return 404 on direct access (no info leak). Locked modules return 423 with the reason string.

🔄 Sequence Diagram — Module List with Availability

sequenceDiagram autonumber actor Browser participant SC as SectionController participant SR as SectionRepository participant MR as ModuleRepository participant AS as AvailabilityService participant CF as ConditionFactory participant Cond as LeafCondition participant DB as MySQL (Moodle) participant HR as HierarchyModuleResource Browser->>SC: GET /sections/{id}/modules (Bearer token) SC->>SR: findForStudent(sectionId, studentId) SR->>DB: SELECT * FROM mdl_course_sections WHERE id=? DB-->>SR: section row (incl. sequence) SR-->>SC: MoodleCourseSection SC->>MR: listForSection(sectionId, sequence) MR->>DB: SELECT * FROM mdl_course_modules WHERE section=? DB-->>MR: Collection of MoodleCourseModule MR-->>SC: modules loop For each module SC->>AS: evaluate(module.availability, AvailabilityContext) AS->>AS: json_decode with JSON_THROW_ON_ERROR AS->>AS: evaluateNode(node, not=false, ctx) alt Leaf node (has type key) AS->>CF: make(type string) alt Known type CF-->>AS: registered condition implementation AS->>Cond: evaluate(node, not, ctx) Cond->>DB: query grade / group / completion / user data DB-->>Cond: result rows Cond-->>AS: AvailabilityResult else Unknown type CF-->>AS: blocking anonymous condition AS-->>SC: AvailabilityResult::hidden() end else Operator subtree (has op + c keys) AS->>AS: computeLogicFlags(op, not) -> [innerNot, andMode] AS->>AS: evaluateAndMode or evaluateOrMode (recursive) AS-->>SC: AvailabilityResult end alt result.show = false Note over SC: exclude module (hidden) else result.available = false, show = true Note over SC: include with available=false + reason else result.available = true Note over SC: include with available=true end end SC->>HR: collection(visibleModules, availabilityMap) HR-->>SC: JSON with available + available_reason per module SC-->>Browser: 200 { data: { section, modules } }

🔀 Flowchart — Module Show Availability Check

flowchart TD A([GET /courses/:id/modules/:moduleId]) --> B[ShowModuleRequest\nvalidation + auth] B --> C{Module exists\nfor student?} C -- No --> D([404 Module not found\ncode 3003]) C -- Yes --> F[resolveSequence\nsectionId to int array] F --> G[Build AvailabilityContext\nstudentId, courseId, moduleId, sequence] G --> H[AvailabilityService::evaluate\navailability JSON string] H --> I{JSON null or empty?} I -- Yes --> AVAIL I -- No --> J[evaluateNode tree\nrecursive descent] J --> K{Node type?} K -- date --> K1[DateCondition\nCarbon::now vs timestamp] K -- completion --> K2[CompletionCondition\nquery mdl_course_modules_completion] K -- grade --> K3[GradeCondition\nquery mdl_grade_grades + items] K -- group --> K4[GroupCondition\nquery mdl_groups_members] K -- grouping --> K5[GroupingCondition\njoin groups_members + groupings_groups] K -- profile --> K6[ProfileCondition\nMoodleUser or user_info_data] K -- unknown --> K7([hidden result\nsecure default]) K1 & K2 & K3 & K4 & K5 & K6 --> L{Operator mode?} L -- AND mode --> M{All children pass?} M -- Yes --> AVAIL([AvailabilityResult::available]) M -- "No: any showc=false or result.show=false" --> HIDD([AvailabilityResult::hidden]) M -- No: all showc=true --> LOCK([AvailabilityResult::locked\nreasons joined with semicolon]) L -- OR mode --> Q{Any child passes?} Q -- Yes --> AVAIL Q -- "No: node show=false" --> HIDD Q -- "No: node show=true" --> LOCK2([AvailabilityResult::locked\nreasons joined with or]) AVAIL --> S1[completionService\nrecordViewIfRequired] S1 --> OK([200 ModuleDetailResource\navailable true, available_reason null]) LOCK & LOCK2 --> EX([throw ModuleNotAvailableException\nreason string]) EX --> R423([423 This module is not yet available\ncode 3010]) HIDD & K7 --> R404([throw ModuleNotFoundException\n404 no info leak, code 3003]) style D fill:#3b1f1f,stroke:#ef4444,color:#fca5a5 style R404 fill:#3b1f1f,stroke:#ef4444,color:#fca5a5 style R423 fill:#3b2000,stroke:#f59e0b,color:#fde68a style OK fill:#1a2e1a,stroke:#10b981,color:#6ee7b7 style AVAIL fill:#1a2e1a,stroke:#10b981,color:#6ee7b7 style K7 fill:#3b1f1f,stroke:#ef4444,color:#fca5a5 style HIDD fill:#3b1f1f,stroke:#ef4444,color:#fca5a5

📄 Files Changed

File Path Layer Description
app/Modules/Availability/Contracts/AvailabilityConditionInterface.php Contract Interface for all six condition evaluators. Declares evaluate(array, bool, AvailabilityContext): AvailabilityResult.
app/Modules/Availability/DTOs/AvailabilityResult.php DTO Immutable result value object. Three static factories: available(), locked(reason), hidden(). Fields: available, show, reason.
app/Modules/Availability/DTOs/AvailabilityContext.php DTO Per-request context DTO: studentId, courseId, courseModuleId, sectionSequence. Used by completion condition to resolve cm=-1 OPTION_PREVIOUS.
app/Modules/Availability/Enums/AvailabilityOperator.php Enum String-backed enum for the four Moodle logical operators: And='&', Or='|', NotAnd='!&', NotOr='!|'.
app/Modules/Availability/Enums/CompletionExpectation.php Enum Int-backed enum: Incomplete=0, Complete=1, CompletePassed=2, CompleteFailed=3. Maps Moodle completion states.
app/Modules/Availability/Enums/ProfileOperator.php Enum String-backed enum: Contains, DoesNotContain, IsEqualTo, StartsWith, EndsWith, IsEmpty, IsNotEmpty.
app/Modules/Availability/Conditions/DateCondition.php Condition Time-only evaluation using Carbon::now()->timestamp. Supports >= (from) and < (until) directions. No DB queries. Formats reason dates with Carbon.
app/Modules/Availability/Conditions/CompletionCondition.php Condition Queries MoodleCourseModuleCompletion. Resolves cm=-1 (OPTION_PREVIOUS) from section sequence. e=1 (Complete) accepts PASS or FAIL completions.
app/Modules/Availability/Conditions/GradeCondition.php Condition Queries MoodleGradeGrade + MoodleGradeItem. Computes percentage: ((finalgrade - rawgrademin) * 100) / (rawgrademax - rawgrademin). Missing grade row = unavailable.
app/Modules/Availability/Conditions/GroupCondition.php Condition id=0 checks any group in the course; id>0 checks a specific group. Queries MoodleGroupMember with optional join on MoodleGroup for course filter.
app/Modules/Availability/Conditions/GroupingCondition.php Condition Joins mdl_groups_members with mdl_groupings_groups to check if student belongs to any group within the specified grouping.
app/Modules/Availability/Conditions/ProfileCondition.php Condition Handles standard fields (from MoodleUser) and custom fields (from MoodleUserInfoField + MoodleUserInfoData). Applies all 7 ProfileOperator comparisons via Str:: helpers.
app/Modules/Availability/Services/ConditionFactory.php Service Resolves condition implementations by type string. Falls back to an anonymous blocking condition returning hidden() for unknown types (secure default).
app/Modules/Availability/Services/AvailabilityService.php Service Core tree evaluator. Decodes JSON, calls evaluateNode() recursively. Implements AND/OR mode with NOT propagation via computeLogicFlags(). Handles showc (per-child) and show (OR node) visibility flags.
app/Shared/Models/MoodleGradeItem.php Model Read-only model for mdl_grade_items. Casts grademax, grademin as float. gradeGrades(): HasMany.
app/Shared/Models/MoodleGradeGrade.php Model Read-only model for mdl_grade_grades. Casts finalgrade, rawgrademin, rawgrademax as float. gradeItem(): BelongsTo.
app/Shared/Models/MoodleGroup.php Model Read-only model for mdl_groups. members(): HasMany -> MoodleGroupMember.
app/Shared/Models/MoodleGroupMember.php Model Read-only model for mdl_groups_members. group(): BelongsTo -> MoodleGroup.
app/Shared/Models/MoodleGrouping.php Model Read-only model for mdl_groupings. groupingGroups(): HasMany -> MoodleGroupingGroup.
app/Shared/Models/MoodleGroupingGroup.php Model Read-only pivot model for mdl_groupings_groups. grouping(): BelongsTo, group(): BelongsTo.
app/Modules/Course/Exceptions/ModuleNotAvailableException.php Exception Thrown when a module is locked (available=false, show=true). Carries the human-readable reason. errorCode = CourseErrorCode::ModuleNotAvailable → HTTP 423.
app/Modules/Course/Enums/CourseErrorCode.php Enum Added ModuleNotAvailable = 3010. (Note: plan specified 3004 but the enum already had that value; actual code is 3010.)
app/Modules/Course/Services/ModuleService.php Service Injected AvailabilityService + SectionRepositoryInterface. After module load, resolves section sequence, builds AvailabilityContext, evaluates availability. Throws ModuleNotFoundException (hidden) or ModuleNotAvailableException (locked).
app/Modules/Course/Controllers/SectionController.php Controller Injected AvailabilityService. Added applyAvailability() private method that evaluates each module, returns filtered visible collection + availability map keyed by module ID.
app/Modules/Course/Controllers/ModuleController.php Controller Added catch for ModuleNotAvailableException returning ApiResource::error(..., 423). Hidden modules already return 404 via the existing ModuleNotFoundException catch.
app/Modules/Course/Resources/HierarchyModuleResource.php Resource Added available and available_reason fields sourced from the availability map. Locked modules expose the reason; available modules expose null reason.
app/Modules/Course/Resources/ModuleDetailResource.php Resource Added available: true and available_reason: null (always — by the time the resource is returned, access is confirmed).
app/Modules/Course/Repositories/SectionRepositoryInterface.php Contract Added getSequenceForSection(int $sectionId): ?string contract method for ModuleService to obtain the section sequence string.
app/Modules/Course/Repositories/SectionRepository.php Repository Implemented getSequenceForSection(): MoodleCourseSection::where('id', $sectionId)->value('sequence').
app/Providers/AppServiceProvider.php Provider Singleton binding for ConditionFactory with all 6 conditions injected by type string key. Singleton binding for AvailabilityService.
tests/Unit/Availability/AvailabilityServiceTest.php Test 12 unit tests for tree evaluator: null/empty JSON, AND/OR modes, showc/show flags, NOT operators (!&, !|), nested subtrees, unknown type blocking.
tests/Unit/Availability/Conditions/DateConditionTest.php Test 6 unit tests: from/until directions, NOT flag, reason string format with formatted date.
tests/Unit/Availability/Conditions/CompletionConditionTest.php Test 7 DB tests: COMPLETE/PASS/FAIL states, OPTION_PREVIOUS resolution (cm=-1), NOT flag.
tests/Unit/Availability/Conditions/GradeConditionTest.php Test 7 DB tests: min/max thresholds, missing grade row, percentage calculation, NOT flag, reason with item name.
tests/Unit/Availability/Conditions/GroupConditionTest.php Test 5 DB tests: specific group, any group (id=0), NOT flag.
tests/Unit/Availability/Conditions/GroupingConditionTest.php Test 3 DB tests: membership via join, NOT flag.
tests/Unit/Availability/Conditions/ProfileConditionTest.php Test 7 DB tests: standard fields, custom fields, all 7 operators, NOT flag.
tests/Feature/Course/SectionTest.php Test 4 feature tests added: hidden module excluded from list, locked module with available_reason, available module, no-JSON treated as available.
tests/Feature/Course/ModuleAvailabilityTest.php Test New feature test file: 423 for locked module (with reason), 404 for hidden module (no info leak), 200 when conditions met.
docs/openapi.yaml Docs Added available + available_reason fields to module schemas. Added 423 response to module show endpoint. Added error code 3010.
docs/postman_collection.json Docs Updated module response examples with available and available_reason. Added 423 example response to module show request.

📏 Rules Applied

🏗 Architecture

  • New Availability module is fully self-contained — service layer only, no routes or controllers — following the modular architecture mandate.
  • No layer skipping: SectionController (HTTP layer) calls AvailabilityService (service layer); ModuleService orchestrates across services within the same layer.
  • All Moodle tables accessed via dedicated read-only Eloquent models extending MoodleReadModel. No writes to any Moodle table.
  • DTOs (AvailabilityContext, AvailabilityResult) used to pass structured data across layer boundaries.
  • Constructor injection throughout — no app() or resolve() calls in services or condition classes.
  • ConditionFactory and AvailabilityService bound as singletons in AppServiceProvider.
  • Controllers remain thin — all evaluation logic delegated to AvailabilityService; controller only handles exception-to-response mapping.
  • API errors use established ApiResource::error() envelope format with HTTP 423 for locked, 404 for hidden.

🔒 Security

  • Hidden modules return 404 on direct access — identical to "not found", preventing enumeration of restricted content.
  • Unknown condition types default to hidden() — fail-closed posture; never assume available when we cannot interpret a condition.
  • All DB access for availability goes through read-only Eloquent models — no risk of accidental writes.
  • Locked module 423 response exposes only the human-readable reason string — no internal IDs, DB details, or stack traces.
  • JSON decoded with JSON_THROW_ON_ERROR — malformed JSON throws a catchable exception rather than silently producing wrong availability state.
  • No raw SQL — all queries use Eloquent query builder with parameter binding.

💻 Coding Style

  • All new classes are final. DTOs are final readonly with constructor-promoted typed properties.
  • Enums used for all fixed value sets: AvailabilityOperator, CompletionExpectation, ProfileOperator, CourseErrorCode. No magic strings or integers.
  • Carbon used for all time operations in DateCondition — not raw time() or DateTime.
  • Str::contains, Str::startsWith, Str::endsWith used in ProfileCondition — Laravel helpers over raw PHP string functions.
  • Collection::filter() and Collection::values() used in applyAvailability() — not array_filter.
  • Every class and public method has a PHPDoc block. @throws declared on all throwing methods.
  • Explicit return types on all methods. Typed properties on all classes. Strict comparison (===, !==) throughout.
  • Named arguments used for clarity on multi-parameter constructors (e.g. AvailabilityContext, AvailabilityResult).

🧪 Testing

  • TDD workflow followed: failing tests written per condition type before implementation.
  • All tests follow AAA (Arrange → Act → Assert) and are named with test_it_ prefix describing expected behaviour.
  • Unit tests for AvailabilityService cover all 4 operators, all showc/show combinations, nested subtrees, and unknown type blocking (12 test methods).
  • One test class per condition (6 files): happy path, failure path, NOT flag, reason string.
  • DB-touching unit tests use RefreshDatabase trait and hit the real test database (no Eloquent mocking).
  • Feature tests verify full HTTP request cycle: correct status codes (200, 404, 423), envelope shape, and available/available_reason field presence.
  • Tests are independent — each seeds its own data; no shared mutable state across test methods.
  • Controllers not directly unit tested — covered by feature tests per testing rules.