📋 5W Analysis

🔍

What

A read-only Calendar module that exposes Moodle calendar events to authenticated students via two REST endpoints. Supports paginated listing with optional date-range filters and single-event detail retrieval.

When

Triggered on every authenticated HTTP request to GET /api/v1/calendar/events or GET /api/v1/calendar/events/{id}. Events are filtered at query-time: only visible = 1 records are returned. Optional start_date and end_date parameters further narrow results by timestart. Results are ordered by timesort ascending.

💡

Why

Students need a single unified view of upcoming deadlines, course events, and personal reminders without logging into Moodle. This module bridges Moodle's event data to the new student portal, respecting Moodle's visibility and enrolment rules without duplicating or altering any Moodle table.

📍

Where

  • Module: app/Modules/Calendar/
  • Shared model: app/Shared/Models/MoodleCalendarEvent.php
  • Moodle source table: mdl_event (read-only)
  • Routes: registered via app/Modules/Calendar/routes.php
  • Tests: tests/Feature/Calendar/ and tests/Unit/Services/
⚙️

How

The request flows through the standard layered architecture: Route → Middleware (auth:sanctum + moodle.active) → EventController → ListEventsRequest / ShowEventDTO → CalendarService → EventRepository → MoodleCalendarEvent.


The core mechanism is a multi-branch OR scope inside EventRepository::applyStudentScope() that determines which events the authenticated student can see:

  • User eventseventtype = 'user' AND userid = $studentId
  • Site eventseventtype = 'site' (visible to all)
  • Course / module eventseventtype IN ('course','due','open','close') AND courseid IN (enrolled courses) via a subquery on user_enrolments JOIN enrol
  • Category eventseventtype = 'category' AND categoryid IN (categories of enrolled courses)
  • Group eventseventtype = 'group' AND groupid IN (groups the student belongs to)

Table aliases (ue, e, c, gm) are used in all subqueries to remain DB_TABLE_PREFIX–agnostic. The CalendarEventResource normalises Moodle's Unix timestamp integers to ISO 8601 strings and converts Moodle sentinel values (zero / empty string) to null.

↔️ Sequence Diagram

sequenceDiagram autonumber participant Browser participant Route as Route/Middleware participant Req as ListEventsRequest participant Ctrl as EventController participant Svc as CalendarService participant Repo as EventRepository participant DB as Moodle DB (mdl_event) Browser->>Route: GET /api/v1/calendar/events?start_date=...&per_page=15 Route->>Route: auth:sanctum — verify Sanctum token Route->>Route: moodle.active — check student is active in Moodle Route->>Req: Resolve ListEventsRequest Req->>Req: authorize() — Auth::check() Req->>Req: rules() — validate start_date, end_date, per_page alt Validation fails Req-->>Browser: 422 Unprocessable Entity end Req->>Req: toDTO() — build ListEventsDTO(studentId, startDate, endDate, perPage) Req->>Ctrl: index(ListEventsRequest $request) Ctrl->>Svc: list(ListEventsDTO) Svc->>Repo: listForStudent(ListEventsDTO) Repo->>DB: SELECT * FROM event WHERE visible=1 AND () AND timestart>=... ORDER BY timesort LIMIT 15 Note over Repo,DB: Student scope: personal OR site OR enrolled-course (sub-SELECT on user_enrolments JOIN enrol) OR category OR group DB-->>Repo: LengthAwarePaginator Repo-->>Svc: LengthAwarePaginator Svc-->>Ctrl: LengthAwarePaginator Ctrl->>Ctrl: CalendarEventResource::collection(paginator) Ctrl-->>Browser: 200 JSON {success, message, data[], meta{page,total}} Browser->>Route: GET /api/v1/calendar/events/{id} Route->>Route: auth:sanctum + moodle.active Route->>Ctrl: show(Request, int $id) Ctrl->>Svc: show(ShowEventDTO(eventId, studentId)) Svc->>Repo: findForStudent(eventId, studentId) Repo->>DB: SELECT * FROM event WHERE visible=1 AND id=? AND () LIMIT 1 alt Not found / not accessible DB-->>Repo: ModelNotFoundException Repo-->>Svc: throw CalendarEventNotFoundException Svc-->>Ctrl: CalendarEventNotFoundException Ctrl-->>Browser: 404 {success:false, code:4001} else Found DB-->>Repo: MoodleCalendarEvent Repo-->>Svc: MoodleCalendarEvent Svc-->>Ctrl: MoodleCalendarEvent Ctrl->>Ctrl: new CalendarEventResource(event) Ctrl-->>Browser: 200 JSON {success, message, data{...}} end

🔀 Request Flowchart

flowchart TD A([HTTP Request]) --> B{Sanctum Token\nValid?} B -- No --> B1[401 Unauthenticated] B -- Yes --> C{moodle.active\nCheck?} C -- Suspended/Deleted --> C1[403 Forbidden] C -- Active --> D{Endpoint?} D -- GET /events --> E[ListEventsRequest\nvalidation] E --> E1{start_date valid\ndate string?} E1 -- No --> E2[422 Validation Error] E1 -- Yes --> E3{end_date ≥ start_date?} E3 -- No --> E4[422 Validation Error\nend_date must be after start_date] E3 -- Yes --> E5{per_page 1–100?} E5 -- No --> E6[422 Validation Error] E5 -- Yes --> E7[Build ListEventsDTO] E7 --> F[CalendarService::list] F --> G[EventRepository::listForStudent] G --> H[Query mdl_event\nWHERE visible=1\nAND student scope\nAND date filters\nORDER BY timesort\nPAGINATE] H --> I[LengthAwarePaginator] I --> J[CalendarEventResource::collection\nnormalise timestamps to ISO 8601\nzero/empty → null] J --> K[200 OK\npaginated list + meta] D -- GET /events/id --> L[EventController::show] L --> M[Build ShowEventDTO\neventId + studentId from auth] M --> N[CalendarService::show] N --> O[EventRepository::findForStudent] O --> P{Row found\nAND student scope\nmatches?} P -- No --> Q[CalendarEventNotFoundException\ncode 4001] Q --> R[404 Not Found\ncode 4001] P -- Yes --> S[MoodleCalendarEvent] S --> T[CalendarEventResource\nnormalise] T --> U[200 OK\nsingle event detail] style B1 fill:#ef444430,stroke:#ef4444,color:#fca5a5 style C1 fill:#ef444430,stroke:#ef4444,color:#fca5a5 style E2 fill:#f59e0b30,stroke:#f59e0b,color:#fde68a style E4 fill:#f59e0b30,stroke:#f59e0b,color:#fde68a style E6 fill:#f59e0b30,stroke:#f59e0b,color:#fde68a style R fill:#ef444430,stroke:#ef4444,color:#fca5a5 style K fill:#10b98130,stroke:#10b981,color:#6ee7b7 style U fill:#10b98130,stroke:#10b981,color:#6ee7b7

📁 Files Changed

File Path Layer Description
app/Shared/Models/MoodleCalendarEvent.php Model Read-only Eloquent model for Moodle's event table. Extends MoodleModel (write-protected), casts Unix timestamps, defines course and user BelongsTo relationships.
app/Modules/Calendar/Enums/CalendarEventType.php Enum PHP 8.1 string-backed enum mapping all eight Moodle event type values (user, site, course, group, category, due, open, close) used in query scoping.
app/Modules/Calendar/Enums/CalendarErrorCode.php Enum Integer-backed enum for Calendar module error codes. Defines NotFound = 4001 returned in the API envelope's code field on 404 responses.
app/Modules/Calendar/Exceptions/CalendarEventNotFoundException.php Exception Domain exception thrown when an event is not found or not accessible by the student. Carries a CalendarErrorCode readonly property, maps to HTTP 404.
app/Modules/Calendar/DTOs/ListEventsDTO.php DTO final readonly DTO carrying validated list query parameters (studentId, optional Carbon start/end dates, perPage) from the FormRequest to the service layer.
app/Modules/Calendar/DTOs/ShowEventDTO.php DTO final readonly DTO carrying eventId and studentId for single-event detail lookups. Constructed inline in the controller from the route parameter and authenticated user.
app/Modules/Calendar/Requests/ListEventsRequest.php Request FormRequest validating start_date, end_date (must be ≥ start_date), and per_page (1–100). Provides toDTO() that builds a ListEventsDTO using Carbon for date parsing.
app/Modules/Calendar/Repositories/EventRepositoryInterface.php Repository Contract defining listForStudent(ListEventsDTO) and findForStudent(int, int). Bound in AppServiceProvider — service depends on this interface, not the concrete class.
app/Modules/Calendar/Repositories/EventRepository.php Repository Queries MoodleCalendarEvent with a multi-branch OR scope (applyStudentScope) covering personal, site, course/module, category, and group events. Uses table aliases in subqueries to be prefix-agnostic. Translates ModelNotFoundException to CalendarEventNotFoundException.
app/Modules/Calendar/Services/CalendarService.php Service Business logic orchestrator. Accepts ListEventsDTO or ShowEventDTO and delegates to EventRepositoryInterface. Thin by design — all query complexity lives in the repository.
app/Modules/Calendar/Resources/CalendarEventResource.php Resource Extends ApiResource. Maps Moodle model fields to camelCase API keys, converts Unix timestamps to ISO 8601 via Carbon, and normalises Moodle sentinel values (0, empty string) to null.
app/Modules/Calendar/Controllers/EventController.php Controller Thin controller (≤15 lines per method). index delegates to service via ListEventsRequest::toDTO(). show builds ShowEventDTO inline, catches CalendarEventNotFoundException, returns 404 with error code.
app/Modules/Calendar/routes.php Route Registers GET /v1/calendar/events and GET /v1/calendar/events/{id} under auth:sanctum + moodle.active middleware. Named routes follow api.v1.calendar.* convention.
app/Providers/AppServiceProvider.php Provider Binds EventRepositoryInterfaceEventRepository in the service container so CalendarService receives the concrete implementation via constructor injection.
routes/api.php Route Loads the Calendar module's routes.php into the main API router, registering endpoints under the /api prefix.
tests/Feature/Calendar/CalendarEventTest.php Test 18 feature tests covering both endpoints: 401 on unauthenticated, envelope structure, site/personal/course/module event visibility, invisible event exclusion, date-range filtering, pagination, resource shape, 404 for inaccessible events, and Moodle sentinel value normalisation.
tests/Unit/Services/CalendarServiceTest.php Test 3 unit tests for CalendarService: verifies list() delegates to repository, show() returns the correct model, and show() re-throws CalendarEventNotFoundException from the repository.
docs/openapi.yaml Docs Calendar endpoints appended to the project OpenAPI 3.0 spec: both GET paths with query parameters, response schemas, and error codes documented.
docs/postman_collection.json Docs Calendar folder added to Postman collection with requests for both endpoints, example responses for 200/404/422/401, and test scripts validating envelope shape.

📏 Rules Applied

Architecture

  • Strict layer separation: Route → Middleware → Controller → FormRequest → Service → Repository → Model (no layer skipping)
  • Controller methods are 5–15 lines; all logic delegated to CalendarService
  • Repository pattern with an interface (EventRepositoryInterface) bound in AppServiceProvider
  • Module is self-contained under app/Modules/Calendar/ with its own Controllers, Services, Repositories, DTOs, Enums, Exceptions, Resources, and routes
  • Shared model (MoodleCalendarEvent) lives in app/Shared/Models/ as a read-only Moodle integration model
  • API versioned under /api/v1/; kebab-case URIs; plural nouns (/events)
  • Standard response envelope: { success, message, data, meta, errors, code }

Shared Database

  • MoodleCalendarEvent extends MoodleModel — write operations throw exceptions (read-only guard enforced at model level)
  • Table name set to bare 'event'; DB_TABLE_PREFIX applied automatically by Laravel
  • No migrations — the mdl_event table is Moodle-owned and never altered
  • Subqueries use table aliases (ue, e, c, gm) to remain prefix-agnostic
  • No mass-assignment on Moodle-sourced data; explicit column selection via Eloquent

Coding Style

  • PHP 8.3 features: enum for event types and error codes, final readonly for DTOs, constructor promotion throughout, match-style typed casts
  • All classes are final by default
  • All properties and parameters have explicit types; no mixed
  • Strict comparison (===, !==) for sentinel value checks in Resource
  • Laravel helpers: Carbon::parse(), Carbon::createFromTimestamp(), $request->filled(), $request->integer(), Builder::when(), Builder::tap()
  • PHPDoc on every class and public method with @throws declared where applicable
  • Named arguments used where clarity requires (e.g., eventId:, studentId:)

Security

  • All endpoints protected by auth:sanctum + moodle.active middleware — no public access
  • All input validation in ListEventsRequest FormRequest — no inline controller validation
  • Student data isolation enforced at query level: the scope ensures a student can only ever see their own personal events, site events, and events from enrolled courses
  • Foreign-key IDs validated via subquery existence checks (not client-trusted)
  • No raw SQL; all queries use Eloquent query builder with parameter binding
  • 404 response on inaccessible events (not 403) to prevent event-ID enumeration

Testing

  • TDD workflow followed: tests written before implementation (Red → Green → Refactor)
  • Feature tests use RefreshDatabase with real SQLite schemas mirroring Moodle tables
  • AAA pattern (Arrange / Act / Assert) in every test method
  • All test methods prefixed test_it_ with descriptive names stating expected behavior
  • No mocking of Eloquent or Laravel internals; Http::fake() and Queue::fake() for external concerns
  • 100% happy-path + all validation branches + all auth failures covered
  • Unit tests isolate CalendarService using mocked EventRepositoryInterface

API Design

  • HTTP 200 for successful GET, 404 for not found, 422 for validation errors, 401 for unauthenticated
  • Pagination metadata in meta envelope field (current_page, per_page, total)
  • Error responses carry machine-readable code field (e.g., 4001) for client-side handling
  • Route names follow api.v1.calendar.events.* dot-notation convention
  • Both endpoints documented in docs/openapi.yaml and docs/postman_collection.json