Study Plan — Feature Report

Generated: 2026-04-25 Branch: StudyPlan Endpoint: GET /api/v1/study-plan Suite: 633 / 633 green
✔ Delivered Read-Only Endpoint auth:sanctum + moodle.active 17 Feature Tests 13 Unit Tests No Migrations
1Endpoint
12Module Files
5Shared Models
30Tests
7DB Tables Read
0Migrations

Overview (5W)

📋
What

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).

When

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.

💡
Why

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.

📁
Where
  • app/Modules/StudyPlan/ — self-contained module
  • app/Shared/Models/MoodleFlexi* — subscription read models
  • app/Shared/Models/MoodleStudyPlan* — plan & semester models
  • app/Modules/StudyPlan/routes.php — module route
  • tests/Feature/StudyPlan/ — 17 integration tests
  • tests/Unit/Modules/StudyPlan/ — 13 unit tests
⚙️
How — End-to-End Flow

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

sequenceDiagram autonumber participant Browser participant Sanctum as auth:sanctum participant MoodleMW as moodle.active participant Req as ShowStudyPlanRequest participant Ctrl as StudyPlanController participant Svc as StudyPlanService participant Repo as StudyPlanRepository participant DB as MySQL (Moodle DB) participant Res as StudyPlanResource Browser->>Sanctum: GET /api/v1/study-plan (Bearer token) alt Token invalid Sanctum-->>Browser: 401 Unauthenticated end Sanctum->>MoodleMW: Token valid → pass user alt Account suspended / deleted MoodleMW-->>Browser: 403 Forbidden end MoodleMW->>Req: Account active → resolve FormRequest Req->>Req: authorize() — user != null Req->>Ctrl: toDTO() → ShowStudyPlanDTO(studentId) Ctrl->>Svc: getForStudent(dto) Svc->>Repo: findActiveSubscription(studentId) Repo->>DB: SELECT * FROM local_flexiplan_subscription
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

flowchart TD A([GET /api/v1/study-plan]) --> B{auth:sanctum} B -- invalid token --> E401([401 Unauthenticated]) B -- valid --> C{moodle.active} C -- suspended/deleted --> E403([403 Forbidden]) C -- active --> D[ShowStudyPlanRequest\nauthorize + toDTO] D --> F[StudyPlanService\ngetForStudent] F --> G[findActiveSubscription\nstudentId] G -- no row --> EN1([404 · code 5002\nNo active subscription]) G -- found --> H[getSubscriptionCourses\nsubscriptionId] H --> I{findStudentPlan\nsubscription.id} I -- student plan exists --> J["resolvePlan → [plan, isDefault=false]"] I -- no student plan --> K[adjustTimestamp\nmonth < 6 → Jan 15 of year\nelse unchanged] K --> L{findDefaultPlan\ntimestart > adjusted} L -- not found --> EN2([404 · code 5001\nNo study plan found]) L -- found --> M["resolvePlan → [plan, isDefault=true]"] J --> N[loadDefaultSemesters\nfor teacher_pct] M --> O[skip defaultSemesters\nteacher_pct = null] N --> P[getPlanSemesters\nORDER BY semester ASC] O --> P P --> Q[collectModules\nfor each semester × course pair] Q --> R[getSectionForSemester\nparse sequence column as IDs] R -- no section or empty sequence --> S1[empty modules for this pair] R -- sequence found --> T[getModulesByIds\nfilter: completion≠0\ndeletioninprogress=0\ntype NOT IN attendance,label] T --> U[getCompletionStates\nbatch query keyed by coursemoduleid] S1 --> V[buildResponse] U --> V V --> W{semester.weeks > 6?\nlong semester} W -- yes --> X[separateModules\nextract Revision + Final Exam] W -- no --> Y[all modules → main chunks\nstudyWeeks = weeks] X --> Z[chunkIntoWeeks\nperWeek = ceil total / studyWeeks\nstudyWeeks = weeks − 2\nappend revision week\nappend exam week] Y --> ZA[chunkIntoWeeks\nperWeek = ceil total / weeks] Z --> AA[formatWeeks\nis_completed per module] ZA --> AA AA --> BB[countCompleted\nexclude modules named Revision] BB --> CC[progressRate\nclamp 0–1.0 time-based rate] CC --> DD{isDefault?} DD -- yes --> EE[teacher_pct = null] DD -- no --> FF[calculateTeacherPct\nusing defaultSemester timing] EE --> GG[StudyPlanResource\nformatTimestamp → ISO 8601] FF --> GG GG --> OK([200 OK\n{ success, message, data }]) style E401 fill:#7f1d1d,stroke:#ef4444,color:#fecaca style E403 fill:#7f1d1d,stroke:#ef4444,color:#fecaca style EN1 fill:#7f1d1d,stroke:#ef4444,color:#fecaca style EN2 fill:#7f1d1d,stroke:#ef4444,color:#fecaca style OK fill:#064e3b,stroke:#10b981,color:#a7f3d0 style A fill:#1e1b4b,stroke:#818cf8,color:#c7d2fe

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

Architecture
  • 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.
  • StudyPlanRepositoryInterface bound in AppServiceProvider; 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.
Coding Style
  • declare(strict_types=1) on every PHP file in the feature.
  • All classes are final; DTO is final readonly with 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 raw array_map or array_filter.
  • Carbon used for all timestamp operations; Str:: facade for string operations — no raw str_contains.
  • Explicit return types on every method; every parameter typed; no mixed type anywhere.
  • Named arguments used for clarity: strict: true in type check, Carbon::create(...) named params.
  • PHPDoc on every class and public method with @throws on all raising methods; skipped @param/@return where signature is self-evident.
  • Laravel Pint — zero violations confirmed.
Security
  • auth:sanctum + moodle.active on 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.
Testing
  • TDD workflow: failing tests written before implementation code for each behaviour.
  • 17 feature tests use RefreshDatabase and build their own Moodle-compatible schema in setUp() via Schema::create — tests hit a real SQLite database.
  • 13 unit tests cover pure calculation methods using PHPUnit MockObject for 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/else in test bodies.
  • Tests are independent — each sets up its own data with helper methods and tears it down via RefreshDatabase.
  • Http::fake() and Queue::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.
Moodle Integration
  • 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 $table to the bare Moodle table name; DB_TABLE_PREFIX env 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).
Git Workflow & Documentation
  • Feature developed on dedicated branch StudyPlan, branched from main.
  • Conventional Commit format: feat(study-plan): … scope.
  • Pre-commit hooks passed: Pint (zero violations) + PHPUnit suite (633/633 green).
  • docs/openapi.yaml updated with full endpoint spec + 5 new schema components.
  • docs/postman_collection.json updated 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 in plans/study-plan/tasks.md.
  • PHPDoc on every class and public method — inline documentation requirement met.