Assignment “Additional files” — introattachment exposure
Exposes the teacher-attached files from Moodle’s mod_assign settings form
as data.intro_attachments on the assignment detail endpoint.
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
filepaththenfilenamefor 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()andMoodleFileread model, unchanged - Docs —
docs/openapi.yaml,docs/postman_collection.json - Tests —
tests/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:
- Service —
AssignmentServicegains a constructor-injectedFileRepositoryInterface;loadIntroAttachments($cmContextId)callsfindAllInArea()with named arguments and returns aCollection<int, MoodleFile>into the show bag underintro_attachments. - Controller — one line:
->withIntroAttachments($bag['intro_attachments'])in the existing fluent resource chain. - Resource —
withIntroAttachments()setter plusbuildIntroAttachments(), which sorts, maps eachMoodleFileto theFeedbackFileResourceshape, and mints a 24-hour temporary signed URL viaMoodleFileUrl::pluginfileUrl(). - Serving — the URL points at the existing
/pluginfile/{contextId}/mod_assign/introattachment/0/{path}route.FilePermissionCheckerpicks its rule from the file’s context level, not its filearea, so a module-context file already routes throughcheckModuleAccess(): 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 byMoodleFileUrl::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 Path | Layer | Description |
|---|---|---|
| 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 Path | Layer | Role 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 onFileRepository— modules stay decoupled. - Response envelope preserved. The new key lands inside
data;success/message/errors/codeshape 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_filesis read through theMoodleFileread model; no write, no migration, no schema change. - No table prefix in code. The query targets
files;DB_TABLE_PREFIXis 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 embeddedintroimages and from other assignments.
security Authorization & access
- No endpoint is public by default. The route stays behind
auth:sanctumand the existing throttle. - Students see only their own data. Listing is gated by the same
ActivityAccessGuardthat 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
temporarySignedRoutelinks (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()— nousort, noarray_map. - First-class arrow fns with typed
MoodleFileparameters 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 andbuildIntroAttachments()declareslist<array<string, mixed>>. - Strict comparison + explicit casts on every serialized field;
mimetypefalls back to'application/octet-stream'rather than emittingnull. - Imports, not FQCNs.
MoodleFileandMoodleFileUrlwere 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 = nullbefore 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/// Assertcomments and no branching inside tests. - Specific assertions.
assertStatus(200),assertJsonCount(), andassertJsonPath()per field — notassertOk(). - Real database.
RefreshDatabasewith the Moodle-mirroring schema; thefilestable is seeded throughDB::table(). - Laravel fakes for externals.
Queue::fake()andHttp::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) anddocs/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-attachments—feature/<module>/<description>, branched frommain. - One logical change per branch, tests in the same change as the code they cover.
- Plan artifacts written to
plans/assignment-intro-attachments/asplan.md+tasks.mdbefore delivery. - Small PR. 153 insertions, 0 deletions — well under the 400-line ceiling.
06 Verification
What was run, and what is explicitly still open.
| Check | Result | Detail |
|---|---|---|
| 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. |