01 Overview — the 5W

What the change does, when it runs, why it exists, where it lives, and how it works end to end.

🎯 What

Adds a new always-present array data.intro_attachments to GET /api/v1/assignments/{assignmentId}. Each entry describes one file a teacher uploaded into the assignment’s Additional files field, with a signed pluginfile URL the student can download.

  • id, filename, filepath, mimetype, size, url, time_created
  • Empty array (never null) when nothing is attached
  • Sorted by filepath then filename for stable UI ordering

⏱️ When

On every assignment detail request, inside the same synchronous read that already builds submissions, comments, and feedback — one extra query, no queue, no event.

  • Runs in AssignmentService::showFull() after the access guard has passed
  • Short-circuits to an empty collection when the course-module context cannot be resolved (cm_context_id === 0)
  • Not emitted by the assignment list endpoint — a detail-view concern only

💡 Why

Moodle-parity gap. Moodle renders Additional files under the assignment description for students; our portal showed nothing. Teachers who attached a brief, rubric, or template had no way to get it to students through the new portal.

Root cause: AssignmentResource only resolved @@PLUGINFILE@@ tokens against the intro filearea — images embedded inside the description. The separate introattachment area was never queried and had no reference anywhere in the backend or the frontend API services.

📍 Where

  • Assignment module — Controller, Service, Resource
  • MoodleFile module — reused FileRepository::findAllInArea(), unchanged
  • Shared — reused MoodleFileUrl::pluginfileUrl() and MoodleFile read model, unchanged
  • Docsdocs/openapi.yaml, docs/postman_collection.json
  • Teststests/Feature/Assignment/IntroAttachmentsTest.php

No migration, no new table, no new route, no new authorization surface.

⚙️ How

Moodle’s settings form stores Additional files in mdl_files with component = 'mod_assign', filearea = 'introattachment', itemid = 0, in the assignment’s course module context (contextlevel = 70). The feature is a straight read of that tuple, folded into the existing show pipeline:

  • ServiceAssignmentService gains a constructor-injected FileRepositoryInterface; loadIntroAttachments($cmContextId) calls findAllInArea() with named arguments and returns a Collection<int, MoodleFile> into the show bag under intro_attachments.
  • Controller — one line: ->withIntroAttachments($bag['intro_attachments']) in the existing fluent resource chain.
  • ResourcewithIntroAttachments() setter plus buildIntroAttachments(), which sorts, maps each MoodleFile to the FeedbackFileResource shape, and mints a 24-hour temporary signed URL via MoodleFileUrl::pluginfileUrl().
  • Serving — the URL points at the existing /pluginfile/{contextId}/mod_assign/introattachment/0/{path} route. FilePermissionChecker picks its rule from the file’s context level, not its filearea, so a module-context file already routes through checkModuleAccess(): the course module must be visible and the student must hold an active enrolment. Serving already worked — only the listing was missing.
  • Directory rows — Moodle writes a filename = '.' placeholder per directory; findAllInArea() already filters those out. Subdirectories such as /refs/ are folded into the URL path by MoodleFileUrl::buildPath().

02 Sequence Diagram

Full HTTP lifecycle: the detail request that lists the files, followed by the pluginfile request that streams one.

sequenceDiagram
    autonumber
    actor B as Browser (Student)
    participant R as Route /api/v1/assignments/{id}
    participant Rq as ShowAssignmentRequest
    participant C as AssignmentController
    participant S as AssignmentService
    participant G as ActivityAccessGuard
    participant Ctx as ContextResolver
    participant AR as AssignmentRepository
    participant FR as FileRepository
    participant DB as Moodle MySQL (shared)
    participant Res as AssignmentResource
    participant U as MoodleFileUrl
    participant PF as PluginfileController
    participant PC as FilePermissionChecker

    B->>R: GET /api/v1/assignments/{assignmentId}
Authorization: Bearer <sanctum> R->>Rq: resolve + auth:sanctum + throttle Rq->>Rq: rules() (path-only) → toDTO() Rq-->>C: ShowAssignmentDTO{assignmentId, studentId} C->>S: showFull($dto) S->>AR: findById(assignmentId) AR->>DB: SELECT ... FROM assign WHERE id = ? DB-->>AR: assign row AR-->>S: MoodleAssign S->>G: assertAccessible(cm, studentId) G->>DB: module visible? active enrolment? DB-->>G: ok G-->>S: granted S->>Ctx: resolve cm context (contextlevel = 70) Ctx->>DB: SELECT id FROM context WHERE contextlevel=70 AND instanceid=? DB-->>Ctx: cmContextId Ctx-->>S: cmContextId Note over S,FR: loadIntroAttachments() — new in this feature alt cmContextId > 0 S->>FR: findAllInArea(cmContextId, 'mod_assign', 'introattachment', 0) FR->>DB: SELECT * FROM files WHERE contextid=? AND component=?
AND filearea=? AND itemid=? AND filename != '.' DB-->>FR: file rows (placeholders excluded) FR-->>S: Collection<MoodleFile> else cmContextId == 0 S-->>S: collect() (empty) end S-->>C: bag[... , intro_attachments] C->>Res: withIntroAttachments($bag['intro_attachments']) C->>Res: withPluginfileResolution(...) + withFilters(...) Res->>Res: buildIntroAttachments() — sort by filepath, filename loop each MoodleFile Res->>U: pluginfileUrl($file) U-->>Res: signed /pluginfile/... (24h expiry) end Res-->>C: data.intro_attachments[] C-->>B: 200 { success, data: { ..., intro_attachments: [...] } } Note over B,PC: Later — student clicks a file B->>PF: GET /pluginfile/{cmContextId}/mod_assign/introattachment/0/brief.pdf
?expires=&signature= PF->>PF: validate HMAC signature PF->>PC: check(file, studentId) PC->>DB: context level = 70 → checkModuleAccess()
module visible + active enrolment DB-->>PC: ok PC-->>PF: allowed PF-->>B: 200 file stream (Content-Type: application/pdf)

03 Flowchart

Success path, branches, and every error status the endpoint (and the follow-up download) can return.

flowchart TD
    A["GET /api/v1/assignments/{assignmentId}"] --> B{"auth:sanctum
token valid?"} B -- no --> B1["401 Unauthenticated"]:::err B -- yes --> T{"throttle
60 req/min?"} T -- exceeded --> T1["429 Too Many Requests"]:::err T -- ok --> C["ShowAssignmentRequest → ShowAssignmentDTO"] C --> D{"assignment exists?"} D -- no --> D1["404 ASSIGNMENT_NOT_FOUND
AssignmentNotFoundException"]:::err D -- yes --> E{"module visible AND
student actively enrolled?"} E -- no --> E1["403 MODULE_NOT_AVAILABLE
ModuleNotAvailableException"]:::err E -- yes --> F{"plugin payload valid?"} F -- no --> F1["422 INVALID_PLUGIN_PAYLOAD"]:::err F -- yes --> G["resolve course-module context
(contextlevel = 70)"] G --> H{"cmContextId > 0?"} H -- no --> I["intro_attachments = []
(graceful degrade)"]:::warn H -- yes --> J["FileRepository::findAllInArea
contextid, mod_assign, introattachment, 0
filename != '.'"] J --> K{"rows found?"} K -- no --> I K -- yes --> L["sort by filepath, then filename"] L --> M["map → id, filename, filepath,
mimetype ?? application/octet-stream,
size, time_created"] M --> N["MoodleFileUrl::pluginfileUrl()
24h temporary signed URL"] N --> O["intro_attachments[]"] I --> P["AssignmentResource::toArray()"] O --> P P --> Q["200 OK
{ data: { ..., intro_attachments } }"]:::ok Q --> R["Student clicks a file URL"] R --> S{"signature valid
and not expired?"} S -- no --> S1["403 Invalid / expired signature"]:::err S -- yes --> U{"FilePermissionChecker
context level 70 → checkModuleAccess"} U -- "module hidden or
enrolment inactive" --> U1["403 FILE_ACCESS_DENIED"]:::err U -- allowed --> V{"file record exists?"} V -- no --> V1["404 File not found"]:::err V -- yes --> W["200 file stream
(?forcedownload=1 to force download)"]:::ok classDef ok fill:#10b98122,stroke:#10b981,color:#d1fae5; classDef err fill:#ef444422,stroke:#ef4444,color:#fee2e2; classDef warn fill:#f59e0b22,stroke:#f59e0b,color:#fef3c7;

04 Files Changed

3 application files modified, 1 test file added, 2 documentation artifacts updated — 153 insertions, 0 deletions. No file was created under app/: the feature is entirely additive on top of existing infrastructure.

File PathLayerDescription
app/Modules/Assignment/Controllers/AssignmentController.php Controller Single line added to show(): ->withIntroAttachments($bag['intro_attachments']) in the existing fluent resource chain. Controller stays thin — no logic, no query.
app/Modules/Assignment/Services/AssignmentService.php Service Injects FileRepositoryInterface via constructor promotion. New private loadIntroAttachments(int $cmContextId): Collection queries the introattachment area with named arguments, returning an empty collection when the context is unresolvable. Adds intro_attachments to the showFull() bag and its array-shape docblock.
app/Modules/Assignment/Resources/AssignmentResource.php Resource New Collection<int, MoodleFile> $introAttachments property initialised to collect() in the constructor; withIntroAttachments() setter; buildIntroAttachments() sorts by filepath/filename, maps to the file-row shape, and mints signed URLs via MoodleFileUrl::pluginfileUrl(). Emits the intro_attachments key from toArray().
tests/Feature/Assignment/IntroAttachmentsTest.php Test New. 5 feature tests written red-first: happy path (fields + pluginfile URL shape), empty area, filename = '.' placeholder exclusion, filearea/context isolation (embedded intro image and another assignment’s attachment both excluded), and subdirectory paths (/refs/notes.pdf). Includes the inline Moodle schema helper with the files table and seeds via DB::table().
docs/openapi.yaml Docs Adds the intro_attachments array to the AssignmentDetail schema and a new AssignmentIntroAttachment component schema describing all seven item fields.
docs/postman_collection.json Docs Updates the “Show Assignment” request with two 200 examples (with and without attachments) and extends its test script to assert the array is present and each item’s url is a /pluginfile/.../introattachment/0/ URL.

Reused unchanged — the reason this change is 153 lines and not 500:

File PathLayerRole in this feature
app/Modules/MoodleFile/Repositories/FileRepository.php Repository findAllInArea() already existed and already filtered filename != '.' — the exact query this feature needed, with no modification.
app/Modules/MoodleFile/Repositories/FileRepositoryInterface.php Repository The contract the service depends on; already bound in the container, so injection required no provider change.
app/Shared/Support/MoodleFileUrl.php Shared pluginfileUrl() builds the 24-hour URL::temporarySignedRoute() and folds filepath into the path, which is what makes subdirectory attachments work for free.
app/Shared/Models/MoodleFile.php Model Read-only Eloquent model over Moodle’s files table — the collection element type.
app/Modules/MoodleFile/Services/FilePermissionChecker.php Service Resolves the access rule from context level, so module-context files already route through checkModuleAccess() — module visible + active enrolment. No new authorization code was written.

05 Rules Applied

Specific project rules this change exercised, and where each one shows up in the code.

architecture Layers & modules

  • No layer skipping. Route → FormRequest → Controller → Service → Repository → Model. The controller never touches MoodleFile; the resource never queries.
  • Thin controller. show() grew by exactly one chained setter call — no branching, no data shaping.
  • Business logic in the service. The “which filearea, which context, what if it’s unresolvable” decision lives in loadIntroAttachments().
  • Cross-module reuse via contract. The Assignment service depends on FileRepositoryInterface, not on FileRepository — modules stay decoupled.
  • Response envelope preserved. The new key lands inside data; success/message/errors/code shape is untouched.
  • Nested resource naming. No new route was invented — the existing /api/v1/assignments/{assignmentId} detail resource carries the data.

architecture Shared Moodle database

  • Read-only access to Moodle tables. mdl_files is read through the MoodleFile read model; no write, no migration, no schema change.
  • No table prefix in code. The query targets files; DB_TABLE_PREFIX is applied by Laravel.
  • No new tables. The feature is pure Moodle-parity read — nothing was added under fxs_*.
  • Explicit tuple matching. The four-column exact match (contextid, component, filearea, itemid) is what isolates this assignment’s attachments from embedded intro images and from other assignments.

security Authorization & access

  • No endpoint is public by default. The route stays behind auth:sanctum and the existing throttle.
  • Students see only their own data. Listing is gated by the same ActivityAccessGuard that already protects the detail endpoint (module visible + active enrolment).
  • No new authorization surface. Download is gated by FilePermissionChecker::checkModuleAccess(), reached through context level rather than filearea — deliberately not special-cased.
  • Never trust client IDs. The context ID is resolved server-side from the assignment; it is never accepted from the request.
  • Signed, expiring URLs. Files are served through HMAC-signed temporarySignedRoute links (24h), never as raw filesystem paths.
  • No SQL injection surface. Query Builder / Eloquent with bound parameters throughout; no DB::raw, no interpolation.

coding-style PHP 8.3 & Laravel-first

  • Constructor promotion + readonly. private readonly FileRepositoryInterface $fileRepository.
  • Named arguments. findAllInArea(contextId:, component:, filearea:, itemId:) — four positional args would have been unreadable.
  • Collections over raw PHP. sortBy() / map() / values() / all() — no usort, no array_map.
  • First-class arrow fns with typed MoodleFile parameters in the sort comparators and mapper.
  • Explicit types everywhere. Every new method has a declared return type; the property carries a Collection<int, MoodleFile> generic docblock and buildIntroAttachments() declares list<array<string, mixed>>.
  • Strict comparison + explicit casts on every serialized field; mimetype falls back to 'application/octet-stream' rather than emitting null.
  • Imports, not FQCNs. MoodleFile and MoodleFileUrl were imported rather than written inline — the PHPDoc was corrected to use the short name.
  • PHPDoc on every new public method, explaining why (Moodle’s storage tuple) rather than restating the signature.

testing TDD & coverage

  • Red before green. All 5 tests were written first and confirmed failing with data.intro_attachments = null before any implementation line existed.
  • Naming convention. Every method is test_it_<behaviour> and reads as a sentence, e.g. test_it_excludes_moodle_directory_placeholder_records.
  • AAA structure with explicit // Arrange / // Act / // Assert comments and no branching inside tests.
  • Specific assertions. assertStatus(200), assertJsonCount(), and assertJsonPath() per field — not assertOk().
  • Real database. RefreshDatabase with the Moodle-mirroring schema; the files table is seeded through DB::table().
  • Laravel fakes for externals. Queue::fake() and Http::fake() keep the Moodle event forwarder out of the test path.
  • Edge cases covered: empty area, directory placeholders, filearea isolation, context isolation, subdirectory paths.
  • Test independence. Each test builds its own course, enrolment, assignment, and context; unique-suffixed usernames avoid collisions.

docs & git-workflow Delivery

  • Both API artifacts updated. docs/openapi.yaml (schema + component) and docs/postman_collection.json (two examples + assertions) — required for every endpoint change.
  • Postman test script validates the envelope and the new array, matching the collection’s existing convention.
  • Branch naming. feature/assignment/intro-attachmentsfeature/<module>/<description>, branched from main.
  • One logical change per branch, tests in the same change as the code they cover.
  • Plan artifacts written to plans/assignment-intro-attachments/ as plan.md + tasks.md before delivery.
  • Small PR. 153 insertions, 0 deletions — well under the 400-line ceiling.

06 Verification

What was run, and what is explicitly still open.

CheckResultDetail
Feature tests Pass php artisan test --filter=IntroAttachmentsTest — 5 passed, 18 assertions.
Full suite regression No regressions Base branch: 113 failed / 1573 passed. This branch: 113 failed / 1578 passed. The +5 delta is exactly the new tests; the 113 failures are pre-existing and identical on both branches.
Laravel Pint Pre-existing debt ./vendor/bin/pint --test reports the same fixer set on the touched files as the base branch. This branch introduced zero new style violations; the existing debt in AssignmentService.php was left untouched to keep the diff scoped.
Live Moodle verification Not performed Verifying against a real assignment with teacher-uploaded Additional files needs live DB access and has not been done. Behaviour is proven only against the mirrored test schema.
Frontend rendering Out of scope Displaying data.intro_attachments under the assignment description in flexi-student-v3-front is deliberately not part of this change — backend contract only.
Open items before this is student-visible: the frontend must render the array, and the endpoint should be smoke-tested against a live Moodle assignment that actually has Additional files attached.