Study Plan — Feature Report
Overview (5W)
A read-only GET /api/v1/study-plan endpoint that returns a student's complete
personalised academic study plan. The response nests semesters → enrolled courses → weekly module
chunks, and includes per-course progress metrics: completed_pct, late_pct,
and — only for students on a custom plan — teacher_pct (where the teacher's default
schedule places the student at this moment in time).
Triggered on every authenticated GET /api/v1/study-plan. The request passes through
auth:sanctum (token validation) and moodle.active (account-status check)
before reaching the controller. All data is assembled on-the-fly within one synchronous HTTP request
cycle — no caching, no queued jobs, no async steps involved.
Students on Flexi subscription plans need a single, structured view of what to study each week, how far along they are, and how their pace compares to the teacher's default schedule. This endpoint replaces a fragmented Moodle UI with a purpose-built API that the student portal frontend renders directly, enabling a richer and more personalised academic roadmap.
app/Modules/StudyPlan/— self-contained moduleapp/Shared/Models/MoodleFlexi*— subscription read modelsapp/Shared/Models/MoodleStudyPlan*— plan & semester modelsapp/Modules/StudyPlan/routes.php— module routetests/Feature/StudyPlan/— 17 integration teststests/Unit/Modules/StudyPlan/— 13 unit tests
Step 1 — Subscription: findActiveSubscription queries
local_flexiplan_subscription with a CASE-based ORDER BY that implements priority:
active (1) → pending (30) → disabled (31) → inactive (0) → cancelled/expired/refunded.
Most-recent timestart breaks ties.
Step 2 — Plan resolution: The service checks for a student-specific plan
(local_studyplans WHERE subscriptionid = sub.id). If none, it applies the
Jan 15 adjustment — if timestart.month < 6, anchor becomes Jan 15
of that year — then finds the next default plan (subscriptionid = 0, timestart > adjusted).
Step 3 — Module collection (nested loop): For each semester × course pair,
the repository reads course_sections WHERE course = ? AND section = semesterNum,
parses the comma-separated sequence column as module IDs, then loads those
course_modules filtered by completion != 0,
deletioninprogress = 0, and type NOT IN (attendance, label).
Plugin relations (assign, quiz, page, url) are eager-loaded for name resolution.
Step 4 — Chunking: Long semesters (weeks > 6) separate Revision and
Final Exam modules into dedicated end-weeks. Regular modules are chunked via
ceil(count / studyWeeks) where studyWeeks = weeks − 2 for long
semesters. Short semesters keep all modules in uniform chunks.
Step 5 — Progress metrics: A time-based rate
clamp(0, (now − start) / (finish − start), 1.0) drives due_modules
and late_pct. teacher_pct applies the same formula against the
default plan's semester timing, shown only when the student has a custom plan.
Revision modules are excluded from completed_modules counts.
Sequence Diagram
Full HTTP Request Lifecycle — GET /api/v1/study-plan
WHERE userid=? ORDER BY CASE status … DESC timestart DB-->>Repo: subscription row (or null) alt No subscription row Repo-->>Svc: throws ActiveSubscriptionNotFoundException Svc-->>Ctrl: exception caught Ctrl-->>Browser: 404 { code: 5002 } end Repo-->>Svc: MoodleFlexiPlanSubscription Svc->>Repo: getSubscriptionCourses(subscriptionId) Repo->>DB: SELECT subs_lines + course
WHERE subscriptionid=? AND status=1 AND courseid IS NOT NULL DB-->>Repo: rows eager-loaded with courses Repo-->>Svc: Collection[MoodleFlexiPlanSubsLine] Svc->>Svc: resolvePlan(subscription) Svc->>Repo: findStudentPlan(subscriptionId) Repo->>DB: SELECT * FROM local_studyplans WHERE subscriptionid=? DB-->>Repo: plan row or null alt Student plan found Repo-->>Svc: MoodleStudyPlan [isDefault=false] else No student plan Svc->>Svc: adjustTimestamp(timestart)
[Jan 15 rule: month < 6] Svc->>Repo: findDefaultPlan(adjustedTs) Repo->>DB: SELECT * FROM local_studyplans
WHERE subscriptionid=0 AND timestart > ?
ORDER BY timestart LIMIT 1 DB-->>Repo: default plan or null alt No default plan Repo-->>Svc: null Svc-->>Ctrl: throws StudyPlanNotFoundException Ctrl-->>Browser: 404 { code: 5001 } end Repo-->>Svc: MoodleStudyPlan [isDefault=true] end Svc->>Repo: getPlanSemesters(planId) Repo->>DB: SELECT * FROM local_studyplan_semesters
WHERE studyplanid=? ORDER BY semester ASC DB-->>Repo: semester rows Repo-->>Svc: Collection[MoodleStudyPlanSemester] loop For each semester × enrolled course Svc->>Repo: getSectionForSemester(courseId, semesterNum) Repo->>DB: SELECT * FROM course_sections
WHERE course=? AND section=? DB-->>Repo: MoodleCourseSection (or null) Svc->>Repo: getModulesByIds(parsedIds from sequence) Repo->>DB: SELECT cm.* FROM course_modules
WHERE id IN (?)
AND completion != 0
AND deletioninprogress = 0
AND module.name NOT IN ('attendance','label')
WITH moduleType, assign, quiz, page, url DB-->>Repo: Collection[MoodleCourseModule] Repo-->>Svc: modules end Svc->>Repo: getCompletionStates(studentId, allModuleIds[]) Repo->>DB: SELECT * FROM course_modules_completion
WHERE userid=? AND coursemoduleid IN (?) DB-->>Repo: completion rows Repo-->>Svc: Collection keyed by coursemoduleid Svc->>Svc: buildResponse()
separateModules → chunkIntoWeeks → formatWeeks
countCompleted → progressRate → calculateTeacherPct Svc-->>Ctrl: array[id, is_default, semesters[...]] Ctrl->>Res: new StudyPlanResource(planArray) Res->>Res: formatTimestamp — Unix → ISO 8601 (Carbon) Res-->>Browser: 200 { success, message, data: { id, is_default, semesters… } }
Flowchart
Main Success Path, Validation Branches & Error Paths
Files Changed
| File Path | Layer | Description |
|---|---|---|
| app/Shared/Models/MoodleFlexiChild.php | Model | Read-only model for local_flexichild. Links a Moodle user to their Flexi student child record. Added as part of the subscription data-model expansion. |
| app/Shared/Models/MoodleFlexiPlanSubscription.php | Model | Read-only model for local_flexiplan_subscription. Holds the student's plan subscription with status and timestart casts. Has-many lines() relationship to MoodleFlexiPlanSubsLine. |
| app/Shared/Models/MoodleFlexiPlanSubsLine.php | Model | Read-only model for local_flexiplan_subs_lines. Represents one course enrolled under a subscription. BelongsTo MoodleFlexiPlanSubscription and MoodleCourse. |
| app/Shared/Models/MoodleStudyPlan.php | Model | Read-only model for local_studyplans. A subscriptionid = 0 row is the default/fallback plan shared across students. Has-many semesters(). |
| app/Shared/Models/MoodleStudyPlanSemester.php | Model | Read-only model for local_studyplan_semesters. Defines one quadmester: timestart, weeks, ignoreweeks (used to derive finish date). BelongsTo MoodleStudyPlan. |
| app/Modules/StudyPlan/Controllers/StudyPlanController.php | Controller | Thin controller (under 15 lines) for GET /api/v1/study-plan. Delegates entirely to StudyPlanService, catches ActiveSubscriptionNotFoundException and StudyPlanNotFoundException and maps them to structured 404 responses via ApiResource::error(). |
| app/Modules/StudyPlan/Requests/ShowStudyPlanRequest.php | Request | FormRequest that authorises the authenticated user and provides toDTO() → ShowStudyPlanDTO. Student ID is always sourced from $this->user()->id, never from user input. |
| app/Modules/StudyPlan/Services/StudyPlanService.php | Service | Orchestrates the complete 8-step build: subscription lookup → course loading → plan resolution (Jan 15 adjustment) → default-semester pre-load for teacher_pct → semester collection → module collection loop → completion batch fetch → response assembly. Houses all pure calculation helpers: adjustTimestamp, calculateStudyWeeks, calculateFinish, progressRate, separateModules, chunkIntoWeeks, formatWeeks, countCompleted, calculateTeacherPct. |
| app/Modules/StudyPlan/Repositories/StudyPlanRepositoryInterface.php | Repository | Contract defining all 7 database query methods with full PHPDoc: findActiveSubscription, getSubscriptionCourses, findStudentPlan, findDefaultPlan, getPlanSemesters, getSectionForSemester, getModulesByIds, getCompletionStates. |
| app/Modules/StudyPlan/Repositories/StudyPlanRepository.php | Repository | Concrete Eloquent implementation. findActiveSubscription uses a CASE-based ORDER BY compatible with both MySQL and SQLite. getModulesByIds eager-loads all plugin relations (assign, quiz, page, url) for name resolution in the service layer. |
| app/Modules/StudyPlan/DTOs/ShowStudyPlanDTO.php | DTO | final readonly DTO with constructor-promoted int $studentId. Carries the authenticated user's ID from the FormRequest into the service. |
| app/Modules/StudyPlan/Resources/StudyPlanResource.php | Resource | Extends ApiResource. Converts all Unix timestamps (subscription_start, per-semester time_start and finish) to ISO 8601 strings using Carbon. All other fields passed through as-is. |
| app/Modules/StudyPlan/Enums/StudyPlanErrorCode.php | Enum | Integer-backed enum: PlanNotFound = 5001, SubscriptionNotFound = 5002. Used as the code field in 404 error envelopes. |
| app/Modules/StudyPlan/Enums/SubscriptionStatus.php | Enum | Integer-backed enum mirroring Moodle's local_flexiplan_subscription.status values: Active=1, Pending=30, Disabled=31, Inactive=0, Cancelled=100, Expired=101, Refunded=102. |
| app/Modules/StudyPlan/Exceptions/StudyPlanNotFoundException.php | Exception | Thrown when neither a student-specific nor a default study plan is found. Carries StudyPlanErrorCode::PlanNotFound (5001) as a readonly property. Maps to HTTP 404. |
| app/Modules/StudyPlan/Exceptions/ActiveSubscriptionNotFoundException.php | Exception | Thrown when no subscription row exists for the authenticated student. Carries StudyPlanErrorCode::SubscriptionNotFound (5002) as a readonly property. Maps to HTTP 404. |
| app/Modules/StudyPlan/routes.php | Route | Module-local route file. Registers GET /v1/study-plan under auth:sanctum + moodle.active middleware with route name api.v1.study-plan.show. |
| tests/Feature/StudyPlan/StudyPlanTest.php | Test | 17 feature tests. Builds the required Moodle-compatible schema in setUp() using Schema::create. Covers: 401 unauthenticated, 404 no subscription (code 5002), 404 no plan (code 5001), correct response envelope, default plan fallback, student-specific plan priority, semester ordering, weekly chunking (4 quizzes → 4 weeks), revision week extraction for long semesters, exam week extraction, short-semester pass-through, completion flag per module, revision exclusion from completed count, attendance/label exclusion, teacher_pct null for default plan, teacher_pct present for custom plan, Jan 15 adjustment for pre-June subscriptions. |
| tests/Unit/Modules/StudyPlan/StudyPlanServiceTest.php | Test | 13 unit tests for pure calculation helpers. Uses PHPUnit MockObject for the repository interface. Covers: adjustTimestamp (June boundary, pre-June Jan 15 anchor), calculateStudyWeeks (long vs short), calculateFinish (with and without ignoreweeks), progressRate (midpoint, capped at 1.0, floored at 0.0, zero-duration guard), resolvePlan (student plan returned, fallback to default, throws when nothing found). |
| app/Providers/AppServiceProvider.php | Config | Modified to bind StudyPlanRepositoryInterface → StudyPlanRepository in the service container and to load the StudyPlan module's routes.php. |
| routes/api.php | Route | Updated to include the StudyPlan module route file, ensuring the endpoint is registered under the global API prefix and middleware stack. |
| docs/openapi.yaml | Docs | Appended the GET /api/v1/study-plan path definition with 200, 401, and 404 responses. Added 5 new reusable schemas: StudyPlanResponse, SemesterItem, CourseItem, WeekItem, ModuleItem. |
| docs/postman_collection.json | Docs | Added a StudyPlan folder with a Get Study Plan request (Bearer token, Accept: application/json header), example 200/404 response bodies, and a test script validating the success + data.semesters envelope shape. |
Rules Applied
- Full layer chain enforced: Route → Middleware → Controller → FormRequest → Service → Repository → Model — no layer skipping.
- Controller is under 15 lines; all business logic lives exclusively in
StudyPlanService. - Module self-contained under
app/Modules/StudyPlan/with its own controllers, services, repositories, DTOs, enums, exceptions, and routes. - Moodle tables accessed only via dedicated read-only models in
app/Shared/Models/; no raw DB queries from service or controller. StudyPlanRepositoryInterfacebound inAppServiceProvider; service depends on the contract, not the concrete class (DI / IoC).- Standard success envelope:
{ success, message, data }; error envelope:{ error: { code, message } }. - All routes under
/api/v1/; URI uses kebab-case (/study-plan); route name uses dot-notation (api.v1.study-plan.show). - Read-only endpoint — no events dispatched, no listeners required.
declare(strict_types=1)on every PHP file in the feature.- All classes are
final; DTO isfinal readonlywith constructor promotion. - PHP 8.3 backed enums for error codes and subscription status — no string/int magic constants.
- Readonly property on exception classes (
$errorCode) — set once in constructor, immutable thereafter. - Laravel Collection API used throughout (
Collection::map,Collection::filter,Collection::chunk,Collection::keyBy) — no rawarray_maporarray_filter. Carbonused for all timestamp operations;Str::facade for string operations — no rawstr_contains.- Explicit return types on every method; every parameter typed; no
mixedtype anywhere. - Named arguments used for clarity:
strict: truein type check,Carbon::create(...)named params. - PHPDoc on every class and public method with
@throwson all raising methods; skipped@param/@returnwhere signature is self-evident. - Laravel Pint — zero violations confirmed.
auth:sanctum+moodle.activeon every route — no public endpoints.- Student ID always sourced from
$request->user()->id(authenticated identity), never from user-supplied input — prevents accessing another student's plan. - All DB queries via Eloquent with bound parameters; the CASE ORDER BY uses literal integer values only — no user input interpolated.
- Read-only Moodle models (extending
MoodleModel) prevent accidental writes to Moodle tables. - No sensitive fields in responses (no password hashes, no Moodle admin data, no internal system IDs of other users).
- Stack traces and SQL errors never returned — only the structured
{ code, message }error envelope on 404.
- TDD workflow: failing tests written before implementation code for each behaviour.
- 17 feature tests use
RefreshDatabaseand build their own Moodle-compatible schema insetUp()viaSchema::create— tests hit a real SQLite database. - 13 unit tests cover pure calculation methods using PHPUnit
MockObjectfor the repository — no real DB needed. - All test methods named
test_it_*in snake_case, describing the expected behaviour (not the method under test). - AAA (Arrange → Act → Assert) pattern in every test; no
if/elsein test bodies. - Tests are independent — each sets up its own data with helper methods and tears it down via
RefreshDatabase. Http::fake()andQueue::fake()isolate from external services in feature tests.- Repository mocked only in unit tests (testing business logic in isolation); feature tests rely on real Eloquent queries.
- Full suite 633 / 633 green before feature delivery.
- Zero Moodle tables altered, dropped, or written to — purely read access.
- Zero migrations created — feature reads from existing Moodle tables.
- All 5 new shared models set
$tableto the bare Moodle table name;DB_TABLE_PREFIXenv var applies automatically — no manual prefix in code. - No Moodle REST event forwarding needed (read-only operation — no student-triggered domain events).
- Module filtering respects Moodle's soft-delete flag (
deletioninprogress = 0) and completion tracking flag (completion != 0). - Subscription priority ordering mirrors Moodle's own status semantics (active preferred over pending over disabled).
- Feature developed on dedicated branch
StudyPlan, branched frommain. - Conventional Commit format:
feat(study-plan): …scope. - Pre-commit hooks passed: Pint (zero violations) + PHPUnit suite (633/633 green).
docs/openapi.yamlupdated with full endpoint spec + 5 new schema components.docs/postman_collection.jsonupdated with StudyPlan folder, Bearer auth, example responses for all documented status codes, and test script.- Feature plan written to
plans/study-plan/plan.md; tasks tracked and all checked inplans/study-plan/tasks.md. - PHPDoc on every class and public method — inline documentation requirement met.