Assignments API Reference
Complete API contracts for Assignments, including routes, auth, DTOs, responses, errors, examples, and integration notes.
Assignments - API Reference
Audience: Frontend engineers (the admin panel), mobile/portal engineers, backend engineers, QA.
Scope: Admin, teacher-portal, student-portal, guardian-portal, and file-download surfaces owned by the Assignments module. 76 routes total (GET /api/subjects/{publicId}/assignments and POST /api/subjects/{publicId}/assignments are owned by the unrelated ClassSubjects module — teacher-to-subject assignment, not homework — and are out of scope here).
1. Documentation Evidence
| Area | Files Inspected | What Was Verified |
|---|---|---|
| Controllers | apps/api/src/modules/assignments/**/*.controller.ts (22 files) | Routes, methods, guards, decorators, status codes |
| DTOs | apps/api/src/modules/assignments/**/dto/*.ts | Request, query, response, validation |
| Services | apps/api/src/modules/assignments/**/*.service.ts | Behavior, side effects, response mapping, errors |
| Schema | packages/db/src/schema/school/assignments.ts | IDs, enums, persisted fields, constraints |
| Error registry | apps/api/src/common/types/error-codes.ts (grep ASSIGNMENT_) | 62 ASSIGNMENT_* error codes (§12) |
| Jobs | packages/jobs/src/index.ts | QueueName.ASSIGNMENTS, QueueName.ASSIGNMENT_FILE_SCAN, job enums, payloads |
| Route baseline | apps/api/test/structure/structure.baseline.json | The authoritative 78-route list this module contributes 76 routes to |
2. Module Summary
| Field | Value |
|---|---|
| Module name | Assignments |
| Module slug | assignments |
| Primary actors | admin, teacher (audience), student (audience), guardian (audience) |
| API surfaces | Admin (/api/assignments*, /api/assignment-*), mobile/portal (/api/mobile/teacher/*, /api/mobile/student/*, /api/mobile/guardian/*) |
| Base route prefixes | /api/assignments, /api/assignment-categories, /api/assignment-settings, /api/assignment-notification-rules, /api/assignment-transfer-reviews, /api/assignment-statistics, /api/assignment-files, /api/mobile/teacher/assignments*, /api/mobile/teacher/assignment-files, /api/mobile/student/assignments*, /api/mobile/student/assignment-files, /api/mobile/guardian/children/:studentId/assignments*, /api/mobile/guardian/children/:studentId/assignment-files |
| Auth model | JwtAuthGuard (global) + RoleGuard; admin routes carry @Permissions("Module_ACTION"); portal routes carry none and are policed by RoleGuard's NO_PERMISSION_ADMIN_HANDLERS allowlist plus the module's own AssignmentAudienceResolver calls |
| Persistence | PostgreSQL (Drizzle), file bytes via @skoolsewa/storage, async work via BullMQ (@skoolsewa/jobs) |
| Runtime source of truth | assignments.status and assignment_students.state/assignment_submission_attempts.status (stored); "active"/"due today"/"overdue" are derived per request, never stored |
| Sibling docs | Backend, Features and flows |
3. Concepts and Terminology
| Term | Meaning | Source File | Used By |
|---|---|---|---|
publicId | External UUIDv7 identifier for an assignment, category, file, or transfer review — never the internal integer PK | assignments.publicId etc. | Every route param named id/fileId/event uses this, except assignment-notification-rules/:event which is keyed by the event string itself |
status | The stored assignment lifecycle: draft/scheduled/published/closed/cancelled | assignmentStatusEnum | List/detail responses, lifecycle routes |
displayStatus | The DERIVED status shown to a viewer: draft/scheduled/active/due_today/overdue/closed/cancelled | ASSIGNMENT_DISPLAY_STATUS | List/detail responses; computed per-request from status+dueAt+latePolicy, never stored |
state | The pupil's own progress on one assignment: assigned/in_progress/submitted/under_review/returned/completed/not_applicable | assignmentStudentStateEnum | Submission roster, pupil detail |
studentDisplayStatus | A richer, pupil-facing label derived from state+timing: not_started/in_progress/submitted/submitted_late/resubmitted/under_review/returned/returned_closed/marked/completed/overdue/not_submitted/not_applicable/cancelled | ASSIGNMENT_STUDENT_DISPLAY_STATUS | Student/guardian list and detail views |
bucket | A roster/dashboard grouping: all/upcoming/due_today/overdue/in_progress/awaiting_review/returned/completed/not_submitted | ASSIGNMENT_STUDENT_BUCKET | Pupil dashboard tile counts, guardian summary counts (overall and per subject), list filters. A cancelled or no-longer-applicable row has no bucket and is left out of every one of those counts. |
attempt | One row of assignment_submission_attempts — a single try at the work, draft through completed/superseded | assignmentAttemptStatusEnum | Submission record, review actions |
version | An opaque optimistic-concurrency token, sent back exactly as last read — never re-parsed as a number by the client | bump_row_version() trigger | Every write route that targets an existing row |
classSubjectId | The id of one class teaching one subject (class_subjects.id) — what an assignment actually targets, not a bare class id | class_subjects schema | Create/update classSubjectIds, class-stat rows |
rowId | assignment_students.id — one pupil's record for one assignment | schema | All :id/submissions/:rowId* routes |
attemptId | assignment_submission_attempts.id, always bound to rowId server-side before any write | schema | Review request DTOs |
clientRequestId/clientSubmissionId/clientUploadId | Client-generated idempotency keys for create/submit/upload | schema unique constraints | Replay-safe create, submit, upload |
4. API Surface Map
| Surface | Method | Path | Actor | Auth/Guard | Permission | Controller | Purpose |
|---|---|---|---|---|---|---|---|
| Admin | GET | /api/assignments/options | Admin | JWT+Role | Assignments_CREATE | AssignmentsController | Create-form catalogue |
| Admin | POST | /api/assignments/preview | Admin | JWT+Role | Assignments_CREATE | AssignmentsController | Preview candidate audience |
| Admin | GET | /api/assignments | Admin | JWT+Role | Assignments_READ | AssignmentsController | List assignments |
| Admin | POST | /api/assignments | Admin | JWT+Role | Assignments_CREATE | AssignmentsController | Create a draft |
| Admin | GET | /api/assignments/:id | Admin | JWT+Role | Assignments_READ | AssignmentsController | One assignment |
| Admin | GET | /api/assignments/:id/options | Admin | JWT+Role | Assignments_UPDATE | AssignmentsController | Edit-form catalogue |
| Admin | PATCH | /api/assignments/:id | Admin | JWT+Role | Assignments_UPDATE | AssignmentsController | Update |
| Admin | DELETE | /api/assignments/:id | Admin | JWT+Role | Assignments_DELETE | AssignmentsController | Delete draft/scheduled |
| Admin | PUT | /api/assignments/:id/teacher | Admin | JWT+Role | Assignments_UPDATE | AssignmentsController | Reassign teacher |
| Admin | GET | /api/assignments/:id/classes | Admin | JWT+Role | Assignments_READ | AssignmentsController | Per-class stats |
| Admin | GET | /api/assignments/:id/history | Admin | JWT+Role | Assignments_READ | AssignmentsController | Event history |
| Admin | POST | /api/assignments/:id/publish | Admin | JWT+Role | AssignmentPublish_CREATE | AssignmentLifecycleController | Publish now/schedule |
| Admin | POST | /api/assignments/:id/unschedule | Admin | JWT+Role | AssignmentPublish_CREATE | AssignmentLifecycleController | Return to draft |
| Admin | POST | /api/assignments/:id/close | Admin | JWT+Role | AssignmentPublish_UPDATE | AssignmentLifecycleController | Close |
| Admin | POST | /api/assignments/:id/reopen | Admin | JWT+Role | AssignmentPublish_UPDATE | AssignmentLifecycleController | Reopen |
| Admin | POST | /api/assignments/:id/cancel | Admin | JWT+Role | AssignmentCancel_UPDATE | AssignmentLifecycleController | Cancel |
| Admin | POST | /api/assignments/:id/archive | Admin | JWT+Role | Assignments_DELETE | AssignmentLifecycleController | Archive |
| Admin | POST | /api/assignments/:id/unarchive | Admin | JWT+Role | Assignments_RESTORE | AssignmentLifecycleController | Unarchive |
| Admin | POST | /api/assignments/:id/attachments | Admin | JWT+Role+IP throttle | Assignments_UPDATE | AssignmentAttachmentsController | Upload attachment |
| Admin | DELETE | /api/assignments/:id/attachments/:fileId | Admin | JWT+Role+IP throttle | Assignments_UPDATE | AssignmentAttachmentsController | Remove attachment |
| Admin | GET | /api/assignments/:id/submissions | Admin | JWT+Role | AssignmentSubmissions_READ | AssignmentSubmissionsController | Submissions roster |
| Admin | GET | /api/assignments/:id/submissions/:rowId | Admin | JWT+Role | AssignmentSubmissions_READ | AssignmentSubmissionsController | One pupil's record |
| Admin | PUT | /api/assignments/:id/submissions/:rowId/review | Admin | JWT+Role | AssignmentReview_UPDATE | AssignmentSubmissionsController | Save review (hold) |
| Admin | POST | /api/assignments/:id/submissions/:rowId/complete | Admin | JWT+Role | AssignmentReview_UPDATE | AssignmentSubmissionsController | Complete/mark |
| Admin | POST | /api/assignments/:id/submissions/:rowId/return | Admin | JWT+Role | AssignmentReview_UPDATE | AssignmentSubmissionsController | Return for correction |
| Admin | POST | /api/assignments/:id/release-marks | Admin | JWT+Role | AssignmentReview_UPDATE | AssignmentSubmissionsController | Bulk-release marks |
| Admin | GET | /api/assignment-categories | Admin | JWT+Role | AssignmentSettings_READ | AssignmentCategoriesController | List categories |
| Admin | POST | /api/assignment-categories | Admin | JWT+Role | AssignmentSettings_CREATE | AssignmentCategoriesController | Create category |
| Admin | PATCH | /api/assignment-categories/:id | Admin | JWT+Role | AssignmentSettings_UPDATE | AssignmentCategoriesController | Update category |
| Admin | DELETE | /api/assignment-categories/:id | Admin | JWT+Role | AssignmentSettings_DELETE | AssignmentCategoriesController | Delete category |
| Admin | GET | /api/assignment-settings | Admin | JWT+Role | AssignmentSettings_READ | AssignmentSettingsController | Read module settings |
| Admin | PATCH | /api/assignment-settings | Admin | JWT+Role | AssignmentSettings_UPDATE | AssignmentSettingsController | Update module settings |
| Admin | GET | /api/assignment-notification-rules | Admin | JWT+Role | AssignmentNotificationSettings_READ | AssignmentNotificationRulesController | List rules |
| Admin | PUT | /api/assignment-notification-rules/:event | Admin | JWT+Role | AssignmentNotificationSettings_UPDATE | AssignmentNotificationRulesController | Update one rule |
| Admin | GET | /api/assignment-transfer-reviews | Admin | JWT+Role | Assignments_READ | AssignmentTransferReviewsController | List open reviews |
| Admin | POST | /api/assignment-transfer-reviews/:id/resolve | Admin | JWT+Role | Assignments_UPDATE | AssignmentTransferReviewsController | Resolve a review |
| Admin | GET | /api/assignment-statistics/dashboard | Admin | JWT+Role | AssignmentStats_READ | AssignmentStatsController | Dashboard summary |
| Admin | GET | /api/assignment-statistics/analytics | Admin | JWT+Role | AssignmentStats_READ | AssignmentStatsController | Analytics + at-risk |
| Admin | GET | /api/assignment-files/:fileId | Admin | JWT+Role+IP throttle | Assignments_READ | AssignmentFilesController (admin) | Download a file |
| Admin | POST | /api/assignment-files/:fileId/rescan | Admin | JWT+Role | AssignmentSettings_UPDATE | AssignmentFilesController (admin) | Re-queue a failed scan |
| Teacher | GET | /api/mobile/teacher/assignments/options | Teacher | JWT+Role, audience | none | TeacherAssignmentsPortalController | Create-form catalogue (own classes) |
| Teacher | POST | /api/mobile/teacher/assignments/preview | Teacher | JWT+Role, audience | none | TeacherAssignmentsPortalController | Preview audience |
| Teacher | GET | /api/mobile/teacher/assignments/dashboard | Teacher | JWT+Role, audience | none | TeacherAssignmentsPortalController | Dashboard tiles |
| Teacher | GET | /api/mobile/teacher/assignments | Teacher | JWT+Role, audience | none | TeacherAssignmentsPortalController | List visible assignments |
| Teacher | POST | /api/mobile/teacher/assignments | Teacher | JWT+Role, audience | none | TeacherAssignmentsPortalController | Create as self |
| Teacher | GET | /api/mobile/teacher/assignments/:id | Teacher | JWT+Role, audience | none | TeacherAssignmentsPortalController | One assignment |
| Teacher | PATCH | /api/mobile/teacher/assignments/:id | Teacher | JWT+Role, audience | none | TeacherAssignmentsPortalController | Update |
| Teacher | DELETE | /api/mobile/teacher/assignments/:id | Teacher | JWT+Role, audience | none | TeacherAssignmentsPortalController | Delete draft/scheduled |
| Teacher | GET | /api/mobile/teacher/assignments/:id/classes | Teacher | JWT+Role, audience | none | TeacherAssignmentsPortalController | Per-class stats |
| Teacher | GET | /api/mobile/teacher/assignments/:id/history | Teacher | JWT+Role, audience | none | TeacherAssignmentsPortalController | Event history |
| Teacher | POST | /api/mobile/teacher/assignments/:id/publish | Teacher | JWT+Role, audience | none | TeacherAssignmentActionsPortalController | Publish now/schedule |
| Teacher | POST | /api/mobile/teacher/assignments/:id/unschedule | Teacher | JWT+Role, audience | none | TeacherAssignmentActionsPortalController | Return to draft |
| Teacher | POST | /api/mobile/teacher/assignments/:id/close | Teacher | JWT+Role, audience | none | TeacherAssignmentActionsPortalController | Close |
| Teacher | POST | /api/mobile/teacher/assignments/:id/reopen | Teacher | JWT+Role, audience | none | TeacherAssignmentActionsPortalController | Reopen |
| Teacher | POST | /api/mobile/teacher/assignments/:id/cancel | Teacher | JWT+Role, audience | none | TeacherAssignmentActionsPortalController | Cancel (if allowed) |
| Teacher | POST | /api/mobile/teacher/assignments/:id/archive | Teacher | JWT+Role, audience | none | TeacherAssignmentActionsPortalController | Archive |
| Teacher | POST | /api/mobile/teacher/assignments/:id/unarchive | Teacher | JWT+Role, audience | none | TeacherAssignmentActionsPortalController | Unarchive |
| Teacher | POST | /api/mobile/teacher/assignments/:id/attachments | Teacher | JWT+Role+IP, audience | none | TeacherAssignmentActionsPortalController | Upload attachment |
| Teacher | DELETE | /api/mobile/teacher/assignments/:id/attachments/:fileId | Teacher | JWT+Role+IP, audience | none | TeacherAssignmentActionsPortalController | Remove attachment |
| Teacher | POST | /api/mobile/teacher/assignments/:id/release-marks | Teacher | JWT+Role, audience | none | TeacherAssignmentActionsPortalController | Release marks |
| Teacher | GET | /api/mobile/teacher/assignments/:id/submissions | Teacher | JWT+Role, audience | none | TeacherAssignmentReviewPortalController | Reviewable roster |
| Teacher | GET | /api/mobile/teacher/assignments/:id/submissions/:rowId | Teacher | JWT+Role, audience | none | TeacherAssignmentReviewPortalController | One pupil's record |
| Teacher | PUT | /api/mobile/teacher/assignments/:id/submissions/:rowId/review | Teacher | JWT+Role, audience | none | TeacherAssignmentReviewPortalController | Save review |
| Teacher | POST | /api/mobile/teacher/assignments/:id/submissions/:rowId/complete | Teacher | JWT+Role, audience | none | TeacherAssignmentReviewPortalController | Complete/mark |
| Teacher | POST | /api/mobile/teacher/assignments/:id/submissions/:rowId/return | Teacher | JWT+Role, audience | none | TeacherAssignmentReviewPortalController | Return for correction |
| Teacher | GET | /api/mobile/teacher/assignment-files/:fileId | Teacher | JWT+Role+IP, audience | none | TeacherAssignmentFilesPortalController | Download a file |
| Student | GET | /api/mobile/student/assignments/dashboard | Pupil | JWT+Role, audience | none | StudentAssignmentsPortalController | Dashboard buckets |
| Student | GET | /api/mobile/student/assignments | Pupil | JWT+Role, audience | none | StudentAssignmentsPortalController | Own list |
| Student | GET | /api/mobile/student/assignments/:id | Pupil | JWT+Role, audience | none | StudentAssignmentsPortalController | One assignment |
| Student | PUT | /api/mobile/student/assignments/:id/submission | Pupil | JWT+Role+IP, audience | none | StudentAssignmentSubmissionPortalController | Save draft |
| Student | POST | /api/mobile/student/assignments/:id/submission/files | Pupil | JWT+Role+IP, audience | none | StudentAssignmentSubmissionPortalController | Attach a file |
| Student | DELETE | /api/mobile/student/assignments/:id/submission/files/:fileId | Pupil | JWT+Role+IP, audience | none | StudentAssignmentSubmissionPortalController | Remove a file |
| Student | POST | /api/mobile/student/assignments/:id/submission/submit | Pupil | JWT+Role+IP, audience | none | StudentAssignmentSubmissionPortalController | Submit |
| Student | GET | /api/mobile/student/assignment-files/:fileId | Pupil | JWT+Role+IP, audience | none | StudentAssignmentFilesPortalController | Download a file |
| Guardian | GET | /api/mobile/guardian/children/:studentId/assignments/summary | Guardian | JWT+Role, audience | none | GuardianAssignmentsPortalController | Child's summary |
| Guardian | GET | /api/mobile/guardian/children/:studentId/assignments | Guardian | JWT+Role, audience | none | GuardianAssignmentsPortalController | Child's list |
| Guardian | GET | /api/mobile/guardian/children/:studentId/assignments/:id | Guardian | JWT+Role, audience | none | GuardianAssignmentsPortalController | One assignment |
| Guardian | GET | /api/mobile/guardian/children/:studentId/assignment-files/:fileId | Guardian | JWT+Role+IP, audience | none | GuardianAssignmentFilesPortalController | Download an attachment only |
Note on Nest route resolution: AssignmentsController, AssignmentLifecycleController and AssignmentSubmissionsController all share the base path assignments. Static segments (options, preview) are declared before :id in AssignmentsController, and every lifecycle/submissions route has a literal segment after :id (:id/publish, :id/submissions/:rowId), so no controller's route is ever shadowed by another's :id catch-all.
5. Auth, Identity, and Permissions
| Surface | Guard/Decorator | Identity Shape | Permission | Guest Allowed | Notes |
|---|---|---|---|---|---|
| Admin | JwtAuthGuard, RoleGuard, @Permissions("Module_ACTION") | req.user.id, active role's permission grants | Per-route (see §4) | No | Standard admin permission model. |
| Teacher portal | JwtAuthGuard, RoleGuard (no @Permissions()) | req.user resolved to a live staff row via resolveTeacher | N/A — AssignmentView_READ gates the panel screen only | No | RoleGuard allows the handler through NO_PERMISSION_ADMIN_HANDLERS; the actual control is AssignmentAudienceResolver.resolveTeacher plus AssignmentAccessService row scoping. |
| Student portal | same | req.user resolved to a live students row via resolveStudent | N/A | No | Identity layer filters deleted_at only; row-scope layer additionally filters record_status='active' and assignment status. |
| Guardian portal | same | req.user resolved + studentId bound to a linked child via resolveGuardianChild | N/A | No | The child binding happens before any row is read — an unlinked studentId answers 404, not 403. |
| File download (all) | JwtAuthGuard, RoleGuard, IpThrottlerGuard | Per-surface actor as above | Assignments_READ (admin only) | No | Scope check happens inside AssignmentFileDownloadService.open, keyed to the resolved actor. |
Every endpoint requires a valid session (JwtAuthGuard is global with no @Public() opt-out anywhere in this module). No guest/anonymous access exists on any assignments route.
6. DTO and Model Reference
6.1 AssignmentCreateBaseDto (shared by AdminCreateAssignmentDto and TeacherCreateAssignmentDto)
| Field | Type | Required | Default | Validation | Example | Source |
|---|---|---|---|---|---|---|
clientRequestId | uuid | No | — | @IsUUID() | — | replay key |
academicSessionId | uuid | Yes | — | @IsUUID("all") | — | |
subjectId | uuid | Yes | — | @IsUUID("all") | — | |
categoryId | uuid | null | No | null | @IsUUID("all") | — | |
title | string | Yes | — | trimmed, ≤200 chars, no leading/trailing/control whitespace | "Algebra worksheet 4" | |
instructionsHtml | string | No | "" | sanitised server-side | "<p>Complete exercises 1-10.</p>" | |
classSubjectIds | uuid[] | Yes | — | ≤60 entries, unique, each a uuid of any version | — | |
links | AssignmentLinkInputDto[] | No | — | ≤10 entries | — | see 6.4 |
dueAt | ISO-8601 datetime | Yes | — | @IsISO8601() | — | |
latePolicy | enum | No | "allowed" | not_allowed|allowed|until_final_deadline | — | |
finalDeadlineAt | ISO-8601 | null | No | — | required iff latePolicy='until_final_deadline'; must be after dueAt (server) | — | |
allowTextResponse | boolean | No | true | — | — | |
allowFileUpload | boolean | No | true | — | — | at least one of the two must end up true |
maxFiles | int | No | 5 | 1..20 | — | |
submissionFileTypes | enum[] | No | 7-type default | each ∈ the 14-type closed list | — | |
resubmissionPolicy | enum | No | "on_request" | none|unlimited|limited|on_request | — | |
maxAttempts | int | null | No | — | required iff resubmissionPolicy='limited'; 2..20 | — | |
isGraded | boolean | No | false | — | — | |
maxMarks | int | null | No | — | required iff isGraded; 1..1000 | — | |
passMarks | int | null | No | — | 0..maxMarks | — | |
marksRelease | enum | No | "on_review" | on_review|manual | — |
AdminCreateAssignmentDto adds one field:
| Field | Type | Required | Notes |
|---|---|---|---|
teacherId | uuid | null | No | A live staff row; a mismatch with target classes is a warning, not a rejection. |
TeacherCreateAssignmentDto adds no fields — teacherId is implicitly the caller and forbidNonWhitelisted (global ValidationPipe) rejects the field if sent.
6.1a AdminUpdateAssignmentDto / TeacherUpdateAssignmentDto
Both are the same class body (AssignmentUpdateBaseDto, authoring/core/dto/update-assignment.dto.ts:47); neither accepts teacherId — PUT :id/teacher is the only writer of that column. Every field is optional except version; a field that is absent keeps the stored value.
| Field | Type | Required | Status restriction | Notes |
|---|---|---|---|---|
version | string | Yes | — | The assignment's version. |
academicSessionId, subjectId | uuid | No | A different value only while draft/scheduled, else 409 ASSIGNMENT_SUBJECT_LOCKED | Resending the current value is accepted in any editable status, so a full edit form can post both. |
categoryId | uuid | null | No | — | An inactive category is accepted only when it is already the stored one. |
title, instructionsHtml | string | No | — | Same rules as create. |
classSubjectIds | uuid[] | No | draft/scheduled only, else 409 ASSIGNMENT_SUBJECT_LOCKED | Replaces the whole target set. |
addClassSubjectIds | uuid[] | No | published/closed only, else 409 ASSIGNMENT_SUBJECT_LOCKED | Re-adding a removed class clears its removed_at and revives applicable pupil rows. |
removeClassSubjectIds | uuid[] | No | published/closed only, else 409 ASSIGNMENT_SUBJECT_LOCKED | Must leave at least one live class, else 409 ASSIGNMENT_NO_CLASSES. Unsubmitted pupil rows of a removed class become not_applicable. |
links | AssignmentLinkInputDto[] | No | — | Each URL normalised; 400 ASSIGNMENT_LINK_INVALID on failure. |
dueAt, latePolicy, finalDeadlineAt, allowTextResponse, allowFileUpload, maxFiles, submissionFileTypes, resubmissionPolicy, maxAttempts | as create | No | — | Validated against the merged row (below). |
isGraded, maxMarks, passMarks, marksRelease | as create | No | Locked once any attempt has been reviewed: 409 ASSIGNMENT_GRADING_LOCKED | |
notifyStudents | boolean | No (default false) | — | When true, the assignment is published, and content or dueAt changed, the updated notification is sent (assignment-update.service.ts:434-445). Otherwise no notification is sent. |
The update service overlays the request on the locked row and validates the merged result (validateMergedAssignmentFields, assignment-merge-validation.util.ts:79-188) before writing. That is where the cross-field codes come from:
| Rule on the merged row | Error |
|---|---|
dueAt changes on a published/closed assignment and is less than 5 minutes away | 400 ASSIGNMENT_DUE_IN_PAST |
dueAt changes on a scheduled assignment and no longer leaves publish lead plus sweep slack after publishAt | 400 ASSIGNMENT_PUBLISH_AT_INVALID |
finalDeadlineAt present without latePolicy='until_final_deadline', absent with it, or not after dueAt | 400 ASSIGNMENT_FINAL_DEADLINE_INVALID |
Both submission methods off; maxFiles outside 1..20; empty or unknown submissionFileTypes; maxAttempts present without resubmissionPolicy='limited', absent with it, or outside 2..20 | 400 ASSIGNMENT_SUBMISSION_SETTINGS_INVALID |
maxMarks present without isGraded, absent with it, or outside (0, 1000]; passMarks without maxMarks or outside [0, maxMarks] | 400 ASSIGNMENT_GRADING_INVALID |
The new attempt allowance (limited → maxAttempts, unlimited → 100, otherwise 1) is below the highest attempt_count any pupil already has | 409 ASSIGNMENT_MAX_ATTEMPTS_BELOW_USED |
6.2 AssignmentDetailDto (response — admin/teacher detail)
| Field | Type | Notes |
|---|---|---|
id | uuid | publicId |
title, instructionsHtml, instructionsText | string | |
status, displayStatus | enum, enum | stored vs derived |
academicSession | {id, name} | |
subject | {id, name} | |
category | {id, name} | null | |
teacher | {id, name, employeeCode} | null | |
dueAt, previousDueAt, dueChangedAt, publishAt, publishedAt, closedAt, cancelledAt, archivedAt | ISO datetime | null | |
cancelReason | string | null | |
latePolicy, finalDeadlineAt | enum, ISO | null | |
allowTextResponse, allowFileUpload, maxFiles, submissionFileTypes | mixed | |
resubmissionPolicy, maxAttempts | enum, int | null | |
isGraded, maxMarks, passMarks, marksRelease, marksReleasedAt | mixed | |
links | {label, url}[] | |
attachments | AssignmentFileDto[] | |
classes | {classSubjectId, className, subjectName, isRemoved}[] | |
version | string | opaque token |
grant | {canManage, canDelete, canCancel, canReassignTeacher, canReadSubmissions, canReview} | per-caller capability flags, never trust-worthy for authorization on the client — the server enforces independently on every write |
academicSessionLocked | boolean | true once the assignment's academic session has locked_at set; every write to the assignment and everything under it (classes, roster rows, submission attempts, links, files) then answers 409 ACADEMIC_SESSION_LOCKED |
createdAt, updatedAt | ISO datetime |
6.3 AssignmentFileDto
| Field | Type | Notes |
|---|---|---|
id | uuid | publicId |
kind | "attachment" | "submission" | |
originalName, mimeType, sizeBytes | mixed | |
scanStatus | "pending"|"clean"|"rejected"|"failed" | Only "clean" files are downloadable. |
uploadedAt, uploadedByName | mixed | |
sortOrder | int |
6.4 AssignmentLinkInputDto
| Field | Type | Required | Validation | Source |
|---|---|---|---|---|
label | string | Yes | ≤120 chars, trimmed, no control chars | |
url | string | Yes | https:// only, no userinfo, ≤2048 chars |
6.5 SaveDraftDto / SubmitAssignmentDto (pupil submission)
| Field | Type | Required | Notes |
|---|---|---|---|
version (SaveDraftDto) | string | No | Absent creates the draft if none exists; present on an existing draft or 409 ASSIGNMENT_DRAFT_VERSION_CONFLICT. |
textHtml | string | null | Yes (may be null) | ≤400,000 raw chars pre-sanitisation; sanitised/derived text is separately bounded by assignment_settings.max_submission_text_chars and ASSIGNMENT_SUBMISSION_TEXT_PLAIN_MAX/_HTML_MAX. |
clientSubmissionId (SubmitAssignmentDto) | uuid | Yes | Idempotency key — a replay with the same key and the same latest attempt is a no-op success. |
draftVersion (SubmitAssignmentDto) | string | No | Required if a draft exists; absent only for a files-less first submit with no draft. |
6.6 SaveReviewDto / CompleteReviewDto / ReturnSubmissionDto
| Field | Type | Required | Validation | Applies To |
|---|---|---|---|---|
attemptId | uuid | Yes | Bound server-side to rowId | all three |
version | string | Yes | The ATTEMPT's version, not the row's | all three |
marks | number | null | Conditional | 2 decimal places max, ≥0, required iff isGraded | save/complete |
grade | string | null | No | ^[A-Za-z0-9+-]{1,8}$ | save/complete |
feedbackHtml | string | null | No (save/complete) / Yes (return) | ≤100,000 chars | all three |
resubmitDueAt | ISO datetime | null | No | Must be in the future when given; required when the base window is already closed | return only |
6.7 AssignmentAudiencePreviewRequestDto / AssignmentAudiencePreviewDto
Request: { academicSessionId: uuid; subjectId: uuid; classSubjectIds: uuid[] } (same shape/limits as the create DTO's targeting fields). Response: { totalPupils: number; byClass: { classSubjectId, className, pupilCount }[] } — a read-only projection, no persistence.
6.8 ListAssignmentsQueryDto / ListAssignmentSubmissionsQueryDto / ListStudentAssignmentsQueryDto
All extend the shared QueryDto (page, size, pagination, sort, search). Assignment-specific additions:
| Field | Type | Default | Notes |
|---|---|---|---|
status/displayStatus filter | enum | — | Filters on the derived status where present |
subjectId, categoryId, teacherId, academicSessionId | uuid | — | admin/teacher list only |
filter (submissions roster) | enum ASSIGNMENT_ROSTER_FILTER | "all" | all|not_submitted|submitted|late|pending_review|marked|returned|resubmitted|not_applicable |
classId (submissions roster) | uuid | — | Narrows the roster to one target class |
bucket (student list) | enum ASSIGNMENT_STUDENT_BUCKET | "all" | See §3 |
6.9 AssignmentTransferReviewDto / ResolveTransferReviewDto
| Field | Type | Notes |
|---|---|---|
id | uuid | The review's publicId. |
student | {id, name, studentCode} | |
assignment | {id, title, subject: {id, name}} | |
rowId | uuid | The pupil's assignment_students.id. |
rowState | enum (AssignmentStudentState) | The row's current state at the time the review is read. |
fromClass | {id, label} | The class the pupil left. |
toClass | {id, label} | null | The pupil's current class, when they still have a live target one. |
detectedAt | ISO datetime | When the roster sweep raised the review. |
resolution | "keep" | "not_applicable" | null | null while open. |
decidedAutomatically | boolean | true for a decision the roster sweep made on its own: a keep/not_applicable opened already-resolved by transfer_mode, or an open review the sweep later auto-resolves keep (reason: "already_submitted" in its history entry) once the pupil has submitted work while the question sat open (shared/assignment-roster-decisions.service.ts) — in every case it only shows up once status=resolved; false for one an administrator resolved through this endpoint. |
resolvedAt | ISO datetime | null | |
resolvedByName | string | null | |
note | string | null | The admin's free-text note, if any. |
schoolTimezone | string | The school's configured timezone (for example "Asia/Kathmandu"), so the client can render detectedAt/resolvedAt in local time without a second lookup. |
ResolveTransferReviewDto (request body for POST :id/resolve): resolution ("keep" | "not_applicable", required) and note (string, optional, ≤500 chars).
7. Enum Reference
| Enum | Value | Meaning | Runtime Effect | Source |
|---|---|---|---|---|
AssignmentStatus | draft|scheduled|published|closed|cancelled | Stored lifecycle | Gates every lifecycle transition | assignment.constants.ts |
AssignmentDisplayStatus | draft|scheduled|active|due_today|overdue|closed|cancelled | Derived, time-sensitive lifecycle shown to viewers | Computed per request, never persisted | same |
AssignmentLatePolicy | not_allowed|allowed|until_final_deadline | Whether/how late submission is accepted | Drives baseOpen/canSubmit | same |
AssignmentResubmissionPolicy | none|unlimited|limited|on_request | How many further attempts are allowed after a first submit | Drives supersedeAllowed | same |
AssignmentMarksRelease | on_review|manual | When a completed mark becomes pupil-visible | Drives result_published_at timing | same |
AssignmentTransferMode | keep|follow_class|flag_for_admin | School policy for a pupil who leaves a target class | Drives roster sweep decisions | same |
AssignmentStudentState | assigned|in_progress|submitted|under_review|returned|completed|not_applicable | Stored per-pupil state | See feature doc §7.2 | same |
AssignmentStudentDisplayStatus | 14 values (§3) | Derived pupil-facing label | Presentation only | same |
AssignmentStudentBucket | all|upcoming|due_today|overdue|in_progress|awaiting_review|returned|completed|not_submitted | Dashboard/list grouping | Filter value | same |
AssignmentNotApplicableReason | transferred|withdrawn|class_removed | Why a row was excluded | Audit/history detail | same |
AssignmentAttemptStatus | draft|submitted|under_review|returned|completed|superseded | Per-attempt state | See feature doc §7.3 | same |
AssignmentFileKind | attachment|submission | Which parent a file belongs to | Determines the FK that must be set | same |
AssignmentScanStatus | pending|clean|rejected|failed | File safety verdict | Only clean is downloadable | same |
AssignmentFileType | pdf,doc,docx,txt,rtf,xls,xlsx,csv,ppt,pptx,jpg,png,webp,gif | Allowed extensions | Cross-checked against sniffed MIME | same |
AssignmentNotificationEvent | published,updated,submitted,reminder_3d,reminder_1d,due_today,overdue,returned,marked,cancelled | Notification rule keys | One assignment_notification_rules row each | same |
AssignmentRosterFilter | all,not_submitted,submitted,late,pending_review,marked,returned,resubmitted,not_applicable | Roster query filter | ListAssignmentSubmissionsQueryDto.filter | same |
AssignmentHistoryEvent | 30 values (see backend doc §5.2) | Audit event names | assignment_history.event | same |
AssignmentScanRejectCode | 13 values (see backend doc §6.7) | Internal reject reason | Never shown to the uploader; only the generic ASSIGNMENT_FILE_SECURITY_MESSAGE is | same |
8. Endpoint Reference
The endpoints below are grouped by capability; every route from §4 is covered either here in full detail or, for the many structurally-identical lifecycle/CRUD siblings, in the route-by-route matrix in §13.1. Every admin route has an exact teacher-portal mirror with the same request/response shape and no @Permissions(); rather than repeat each pair twice, the teacher-portal differences are called out inline where the behavior differs (scope, not shape).
8.1 POST /api/assignments (and POST /api/mobile/teacher/assignments)
Purpose
Creates a new assignment, always as a draft. Used by the admin panel's and the teacher portal's assignment-creation form. The admin route accepts an optional teacherId; the teacher route always uses the caller as the teacher and rejects the field if sent.
Source Evidence
| Evidence | Path |
|---|---|
| Controller | apps/api/src/modules/assignments/authoring/admin/assignments.controller.ts, apps/api/src/modules/assignments/authoring/portal/teacher-assignments-portal.controller.ts |
| DTO | apps/api/src/modules/assignments/authoring/core/dto/create-assignment.dto.ts |
| Service | apps/api/src/modules/assignments/authoring/core/assignment-write.service.ts |
| Schema | packages/db/src/schema/school/assignments.ts (assignments, assignment_classes) |
| Tests | apps/api/src/modules/assignments/__tests__/assignment-authoring.integration.spec.ts |
Auth and Permissions
- Auth: JWT required.
- Guard chain:
JwtAuthGuard,RoleGuard. - Permission:
Assignments_CREATE(admin route only; teacher route carries none —resolveTeacheris the control). - Guest support: none.
- Rate limit: none on this route (upload routes are throttled; this is JSON-only).
- Idempotency:
clientRequestId, unique per(createdByUserId, clientRequestId). Both the admin and the teacher route answer201with"Assignment created."for a genuine insert and200with"Assignment already created."when a replay returns the existing assignment.
Request
| Part | Required | Details |
|---|---|---|
| Headers | Yes | Authorization: Bearer <token> |
| Body | Yes | AdminCreateAssignmentDto or TeacherCreateAssignmentDto (§6.1) |
{
"academicSessionId": "01973a...",
"subjectId": "01973b...",
"title": "Algebra worksheet 4",
"classSubjectIds": ["01973c..."],
"dueAt": "2026-09-22T10:00:00.000Z"
}Full request with optional fields:
{
"clientRequestId": "01973d...",
"academicSessionId": "01973a...",
"subjectId": "01973b...",
"categoryId": "01973e...",
"title": "Algebra worksheet 4",
"instructionsHtml": "<p>Complete exercises 1-10.</p>",
"classSubjectIds": ["01973c...", "01973f..."],
"links": [{ "label": "Reference", "url": "https://example.com/ref" }],
"dueAt": "2026-09-22T10:00:00.000Z",
"latePolicy": "until_final_deadline",
"finalDeadlineAt": "2026-09-24T10:00:00.000Z",
"allowTextResponse": true,
"allowFileUpload": true,
"maxFiles": 3,
"submissionFileTypes": ["pdf", "docx"],
"resubmissionPolicy": "limited",
"maxAttempts": 3,
"isGraded": true,
"maxMarks": 20,
"passMarks": 10,
"marksRelease": "manual",
"teacherId": "01973g..."
}Response
Both routes: 201 with "Assignment created." on genuine creation, 200 with "Assignment already created." when a clientRequestId replay returned the existing row.
{
"success": true,
"message": "Assignment created.",
"data": {
"id": "01973h...",
"status": "draft",
"displayStatus": "draft",
"version": "0"
}
}(Full shape: AssignmentDetailDto, §6.2.)
Side Effects
- Database writes:
assignments(insert),assignment_classes(one row per target). - Cache: none.
- BullMQ jobs: none.
- Realtime events: none.
- Notifications: none (a draft is invisible to anyone but its author/managers).
- Audit logs:
ActivityAuditInterceptorrecords the mutating request;assignment_historygets acreatedentry. - External API calls: none.
Error Cases
| HTTP Status | Error Code | Condition | User-Facing Meaning | Source |
|---|---|---|---|---|
400 | class-validator errors | Missing/invalid field | Fix the request body | global ValidationPipe |
400 | ASSIGNMENT_LINK_INVALID | A links[].url is not a parseable URL, is not https:, carries a username/password, or exceeds 2048 characters after normalisation | Correct the link | shared/assignment-html.util.ts:126-158 (normaliseLinkUrl), called at assignment-write.service.ts:251 |
400 | ASSIGNMENT_FINAL_DEADLINE_INVALID | A final deadline is given without latePolicy='until_final_deadline' (or vice versa), or it is not after dueAt | Fix the late-policy fields together | authoring/core/assignment-merge-validation.util.ts:58-70, called from assignment-write.service.ts:211-215 |
400 | ASSIGNMENT_SUBMISSION_SETTINGS_INVALID | Neither submission method allowed, maxFiles outside 1..20, an empty or unknown submissionFileTypes, or maxAttempts inconsistent with resubmissionPolicy | Fix the submission settings together | same |
400 | ASSIGNMENT_GRADING_INVALID | maxMarks/passMarks inconsistent with isGraded, or out of range | Fix the grading fields together | same |
404 | ACADEMIC_SESSION_NOT_FOUND / SUBJECT_NOT_FOUND | Unknown session or subject public id (codes owned by the academic-structure modules) | Re-pick the session or subject | assignment-write.service.ts:120-137 |
404 | ASSIGNMENT_CATEGORY_NOT_FOUND | categoryId unknown or inactive | Re-pick the category | assignment-write.service.ts:152 |
404 | ASSIGNMENT_TEACHER_NOT_FOUND | (admin only) teacherId is not a live staff row | Pick another teacher | assignment-write.service.ts:169 |
404 | ASSIGNMENT_CLASS_NOT_TAUGHT | (teacher only) A target class-subject is not taught by the caller | Choose a class you teach | AssignmentAccessServiceImpl.assertClassSubjectsUsable (shared/assignment-access.service.ts:287) |
422 | ASSIGNMENT_CLASS_SUBJECT_INVALID | (admin) A target class-subject is inactive, in another session, or teaches a different subject; or (both) a duplicate id was sent | Fix the class selection | same |
422 | ASSIGNMENT_AUDIENCE_TOO_LARGE | More than 60 target classes selected | Reduce the class selection | same |
Edge Cases
- Empty
classSubjectIds: allowed at create (a draft may have zero targets); publishing without any is blocked separately (ASSIGNMENT_NO_CLASSES). - Duplicate class-subject in the array: rejected (
422). - Replayed
clientRequestId: idempotent,200, no second row. - Cross-field rules on create.
AssignmentWriteService.createbuilds the merged field set as adraftwithdueAtChanged: falseandpublishAt: nulland runs it through the samevalidateMergedAssignmentFieldsthatPATCHuses (authoring/core/assignment-write.service.ts:211-215), before the insert. A body that breaks one of those rules —finalDeadlineAtwithoutlatePolicy='until_final_deadline', both submission methods off, orisGraded: truewithoutmaxMarks— returns the same named400code create-time as it does on update:ASSIGNMENT_FINAL_DEADLINE_INVALID,ASSIGNMENT_SUBMISSION_SETTINGS_INVALID, orASSIGNMENT_GRADING_INVALID(§12). The due-date lead-time check (ASSIGNMENT_DUE_IN_PAST/ASSIGNMENT_PUBLISH_AT_INVALID) is not part of this call — a draft's due date is only checked when it is later published or scheduled.
Example Requests
POST /api/assignments HTTP/1.1
Authorization: Bearer TOKEN
Content-Type: application/jsoncurl -X POST "$API_URL/api/assignments" \
-H "Authorization: Bearer TOKEN" \
-H "Content-Type: application/json" \
-d '{"academicSessionId":"...","subjectId":"...","title":"Algebra worksheet 4","classSubjectIds":["..."],"dueAt":"2026-09-22T10:00:00.000Z"}'8.2 POST /api/assignments/:id/publish (and teacher mirror)
Purpose
Publishes an assignment immediately, or schedules a future publish. Publishing now materialises the pupil roster and notifies; scheduling defers both to the publish-due sweep.
Source Evidence
| Evidence | Path |
|---|---|
| Controller | assignment-lifecycle.controller.ts, teacher-assignment-actions-portal.controller.ts |
| DTO | authoring/core/dto/assignment-lifecycle.dto.ts (PublishAssignmentDto) |
| Service | authoring/core/assignment-lifecycle.service.ts, authoring/core/assignment-publish-guards.util.ts |
| Schema | assignments, assignment_students, assignment_history |
| Tests | assignment-authoring.integration.spec.ts |
Auth and Permissions
- Auth: JWT required.
- Guard chain:
JwtAuthGuard,RoleGuard. - Permission:
AssignmentPublish_CREATE(admin); none (audience) for teacher. - Guest support: none.
- Rate limit: none.
- Idempotency: calling publish-now again on an already-published assignment, or schedule again with the same
publishAt, is a no-op returning the current detail. The already-in-target-state check runs before theversioncheck, so this no-op succeeds even with a staleversion— every other lifecycle route (unschedule,close,reopen,cancel,archive,unarchive) follows the same ordering, which is what makes a double-click safe rather than a race to a409.
Request
{ "version": "0", "mode": "now" }or
{ "version": "0", "mode": "schedule", "publishAt": "2026-09-20T06:00:00.000Z" }Response
200 with AssignmentDetailDto, status now published or scheduled.
Side Effects
- Database writes:
assignments(status/publish fields),assignment_students(insert, publish-now only),assignment_history(published/scheduled). - Jobs: none directly (the sweep that later publishes a scheduled assignment is cron-driven, not enqueued here).
- Notifications:
publishedevent to newly-materialised pupils and their guardians (publish-now only).
Error Cases
| HTTP Status | Error Code | Condition | User-Facing Meaning | Source |
|---|---|---|---|---|
404 | ASSIGNMENT_NOT_FOUND | Row not found or out of the actor's manage scope | Assignment not found | lifecycle service |
409 | ASSIGNMENT_VERSION_CONFLICT | version mismatch | Reload and retry | lifecycle service |
409 | ASSIGNMENT_NOT_EDITABLE | Assignment is archived | Unarchive first | lifecycle service |
409 | ASSIGNMENT_INVALID_TRANSITION | Not currently draft/scheduled | Action not available in this state | lifecycle service |
400 | ASSIGNMENT_DUE_IN_PAST | (mode now) due date less than 5 minutes away | Due date too soon to publish now | lifecycle service |
400 | ASSIGNMENT_PUBLISH_AT_INVALID | (mode schedule) missing/too-soon/leaves-no-room publishAt | Fix the schedule time | lifecycle service |
422 | ASSIGNMENT_NO_CLASSES | No live target classes | Add at least one class | assignment-publish-guards.util.ts:60-65 |
422 | ASSIGNMENT_AUDIENCE_TOO_LARGE | More than 60 live target classes, or more than 3000 distinct live pupils across them | Narrow the target classes | assignment-publish-guards.util.ts:66-71, :128-133 |
404 | ASSIGNMENT_CLASS_NOT_TAUGHT | (teacher only) The caller no longer teaches every live target class | Ask an administrator to publish, or remove that class | assignment-publish-guards.util.ts:73-83 |
422 | ASSIGNMENT_TEACHER_REQUIRED | assignment_settings.require_teacher is on and no teacher is assigned | Assign a teacher (PUT :id/teacher) | assignment-publish-guards.util.ts:90-95 |
409 | ASSIGNMENT_HAS_REJECTED_ATTACHMENTS | An attachment that is not removed has scan_status='rejected' | Remove or replace the rejected attachment, then publish | assignment-publish-guards.util.ts:97-112 |
The guards run in the order listed and stop at the first failure. The publish-due sweep re-runs the same guards; a failure there does not reach any HTTP caller — the assignment returns to draft and the creator receives a publish_failed notification.
Edge Cases
- Already published (mode
now): no-op,200, unchanged detail. - Already scheduled at the same
publishAt(modeschedule): no-op. - The scheduled sweep itself hits the same guard set at fire time; a failure there reverts to
draftrather than returning an error to any caller (no caller is waiting — it is a background tick).
8.3 PUT /api/mobile/student/assignments/:id/submission/submit
Purpose
Submits the pupil's current work — text and/or previously-attached files — for review. Called after the pupil has saved a draft and/or attached files, or directly for a files-less first submit.
Source Evidence
| Evidence | Path |
|---|---|
| Controller | portal/student/student-assignment-submission-portal.controller.ts |
| DTO | submissions/core/dto/assignment-submission-request.dto.ts (SubmitAssignmentDto) |
| Service | submissions/core/assignment-submission.service.ts |
| Schema | assignment_submission_attempts, assignment_students |
| Tests | assignment-submission.integration.spec.ts |
Auth and Permissions
- Auth: JWT required.
- Guard chain:
JwtAuthGuard,RoleGuard,IpThrottlerGuard. - Permission: none —
resolveStudentis the control. - Guest support: none.
- Rate limit:
RATE_LIMITS.CUSTOMER_WRITE. - Idempotency:
clientSubmissionId; a replay matching the current latest non-draft attempt is a no-op success.
Request
{ "clientSubmissionId": "01973i...", "textHtml": "<p>My answer...</p>" }Minimal (files-only, no draft yet):
{ "clientSubmissionId": "01973i...", "textHtml": null }Response
200 with the pupil's StudentAssignmentDetailDto (own record + attempt history), re-read after commit.
Side Effects
- Database writes: prior pending attempt (if any) →
superseded; target attempt →submitted;assignment_studentscounters/state;assignment_history(submitted). - Notifications:
submittedto the pupil (confirmation), guardians (low priority), and the teacher.
Error Cases
| HTTP Status | Error Code | Condition |
|---|---|---|
404 | ASSIGNMENT_NOT_FOUND | No visible assignment or no pupil row |
400 | ASSIGNMENT_SUBMISSION_EMPTY | No text and no non-rejected file |
400 | ASSIGNMENT_TEXT_NOT_ALLOWED | allowTextResponse=false but text given |
400 | ASSIGNMENT_TEXT_TOO_LONG | Text exceeds the school's/module's max |
409 | ASSIGNMENT_SUBMISSION_HAS_REJECTED_FILES | A rejected file is still attached |
409 | ASSIGNMENT_SUBMISSION_FILE_LIMIT_EXCEEDED | Too many files on the draft |
409 | ASSIGNMENT_FILES_NOT_ALLOWED | allowFileUpload=false but files attached |
409 | ASSIGNMENT_ALREADY_COMPLETED | Row already completed |
409 | ASSIGNMENT_NOT_ACCEPTING | Row under_review, or window closed with no resubmission path |
409 | ASSIGNMENT_RESUBMISSION_NOT_ALLOWED | Attempt budget exhausted |
409 | ASSIGNMENT_VERSION_CONFLICT | Stale replay of a clientSubmissionId that is no longer the latest |
409 | ASSIGNMENT_DRAFT_VERSION_CONFLICT | draftVersion mismatch, or a draft exists but none was expected |
403 | STUDENT_GRADUATED_READ_ONLY | The pupil's graduate access is grace or ended — checked against the resolved studentId, after resolveStudent, before the write |
409 | ACADEMIC_SESSION_LOCKED | The assignment's academic session is locked |
Edge Cases
- Submitting with no draft and no files, only
textHtml: allowed — creates the first attempt directly. - Every attached file rejected and no text: treated specially — this is
ASSIGNMENT_SUBMISSION_EMPTYonly if it is also the FIRST submission; if it is a resubmission scenario the content-rejection logic (pendingContentRejectedSql) instead grants an extra chance to fix it. - Class removed since assignment: window closes even mid-composition; submit answers
409 ASSIGNMENT_NOT_ACCEPTING. - Save draft, file upload/removal and submit all run the same two checks in the same order — resolve the pupil, then
assertNotGraduatedReadOnly— so a graduated pupil's session and file routes on this controller answer the same403 STUDENT_GRADUATED_READ_ONLYas submit.
8.4 PUT /api/assignments/:id/submissions/:rowId/review (and teacher mirror), POST .../complete, POST .../return
Purpose
The three review actions a teacher/admin takes on one pupil's latest attempt: hold marks/feedback privately (review), finalise and release per policy (complete), or send back for another try (return).
Source Evidence
| Evidence | Path |
|---|---|
| Controller | submissions/admin/assignment-submissions.controller.ts, submissions/portal/teacher-assignment-review-portal.controller.ts |
| DTO | submissions/core/dto/assignment-submission-request.dto.ts |
| Service | submissions/core/assignment-review.service.ts |
| Schema | assignment_submission_attempts, assignment_students |
| Tests | assignment-submission.integration.spec.ts |
Auth and Permissions
- Auth: JWT required.
- Guard chain:
JwtAuthGuard,RoleGuard. - Permission:
AssignmentReview_UPDATE(admin); none (teacher, scoped byreviewRow). - Rate limit: none.
- Idempotency: none beyond the
versionoptimistic check — a duplicate call with a stale version is rejected, not silently accepted.
Request (all three share attemptId + version)
{ "attemptId": "01973j...", "version": "0", "marks": 15, "grade": "A", "feedbackHtml": "<p>Good work.</p>" }Return requires feedbackHtml and accepts an optional deadline:
{ "attemptId": "01973j...", "version": "1", "feedbackHtml": "<p>Please redo question 3.</p>", "resubmitDueAt": "2026-09-25T10:00:00.000Z" }Response
200 with AssignmentSubmissionRecordDto — the full pupil record including attempt history.
Side Effects
review: attempt/row →under_review; historyreview_saved; no notification (nothing pupil-visible yet).complete: attempt/row →completed,finalMarks/finalGradeset;result_published_atset immediately whenmarksRelease='on_review'or the assignment already has amarksReleasedAtfrom an earlier bulk release; historycompleted;markednotification iff visible immediately.return: attempt →returned, row →returned, result cleared,resubmit_due_atset/cleared,extra_attempts_grantedincremented (unless policyunlimited); historyreturned;returnednotification.
Error Cases
| HTTP Status | Error Code | Applies To |
|---|---|---|
404 | ASSIGNMENT_NOT_FOUND | all three |
404 | ASSIGNMENT_SUBMISSION_NOT_FOUND | all three (row or attempt not found/out of scope, or attemptId not bound to rowId) |
409 | ASSIGNMENT_VERSION_CONFLICT | all three |
409 | ASSIGNMENT_ATTEMPT_NOT_REVIEWABLE | all three (not the latest attempt, or wrong status) |
400 | ASSIGNMENT_MARKS_INVALID | review, complete |
400 | ASSIGNMENT_FEEDBACK_REQUIRED | return with feedback that carries no text (also raised for over-length feedback on any of the three) |
409 | ASSIGNMENT_NOT_EDITABLE | all three (assignment not published/closed, or archived) |
409 | ASSIGNMENT_RESUBMISSION_NOT_ALLOWED | return (policy none, or extra-attempt budget exhausted) |
400 | ASSIGNMENT_RESUBMIT_DUE_REQUIRED | return (no deadline given and the base window is already closed) |
400 | ASSIGNMENT_RESUBMIT_DUE_IN_PAST | return (deadline not in the future) |
Edge Cases
returnon an assignment that is onlyclosed(notpublished): rejected —returnSubmissionrequires strictlypublished, unlikereview/completewhich also acceptclosed.completewithmarksRelease='manual'and no prior release on this assignment: the mark is saved but held; a subsequentPOST :id/release-marksis required to make it visible. Once that release has happened once, every latercompleteon the same assignment publishes immediately regardless of the policy.returnwhen the pupil is already at the extra-attempt ceiling (policy notunlimitedandextraAttemptsGranted >= ASSIGNMENT_MAX_EXTRA_ATTEMPTS):409 ASSIGNMENT_RESUBMISSION_NOT_ALLOWED, same code the pupil's own submit uses at the attempt ceiling.- Visibility is checked before status: an assignment outside the reviewer's
visibleAssignmentscope is404 ASSIGNMENT_NOT_FOUNDeven when the row/attempt would otherwise be a valid target — a reviewer never learns an out-of-scope assignment's current status from the shape of the error. - Reviewing an attempt superseded by a newer submission mid-review:
409 ASSIGNMENT_ATTEMPT_NOT_REVIEWABLE.
8.5 File download — GET .../assignment-files/:fileId (all four audiences)
Purpose
Streams a file's bytes: an assignment attachment (any audience whose scope covers the assignment) or a pupil's own submitted file (teacher review, or the pupil themselves — never a guardian, and never a pupil's still-draft attempt even for staff: a submission-file lookup explicitly excludes status='draft').
Source Evidence
| Evidence | Path |
|---|---|
| Controller | files/admin/assignment-files-admin.controller.ts, files/portal/teacher-assignment-files-portal.controller.ts, .../student-assignment-files-portal.controller.ts, .../guardian-assignment-files-portal.controller.ts |
| Service | files/assignment-file-download.service.ts, files/assignment-file-download-headers.ts |
| Schema | assignment_files |
| Tests | assignment-scan-worker.integration.spec.ts |
Auth and Permissions
- Auth: JWT required.
- Guard chain:
JwtAuthGuard,RoleGuard,IpThrottlerGuard(RATE_LIMITS.UPLOAD_READ). - Permission:
Assignments_READ(admin only); none (portal, scoped by actor kind + file kind). - Guardian scope: attachments only — a submission file
filePublicIdfor a guardian actor is refused as404, not a kind-mismatch error, so the response gives no signal about whether the id even exists.
Response
Not a JSON envelope — a binary stream. setAssignmentFileDownloadHeaders (files/assignment-file-download-headers.ts:21-27) sets Content-Type (the stored MIME), X-Content-Type-Options: nosniff, Cache-Control: private, no-store, Content-Security-Policy: sandbox; default-src 'none', Content-Length, and Content-Disposition. Error responses before the stream starts use the normal JSON error envelope.
Error Cases
Scope is checked before scan status, so an id the caller may not see is always 404, whatever its status.
| HTTP Status | Error Code | Condition | Consumer Action | Source |
|---|---|---|---|---|
404 | ASSIGNMENT_FILE_NOT_FOUND | File not found, outside the actor's scope, or (guardian) a submission file | Treat as missing; do not retry | assignment-file-download.service.ts:32-36 |
409 | ASSIGNMENT_FILE_NOT_READY | scan_status='pending' | Show "checking file" and retry after a short delay | assignment-file-download.service.ts:102-108 |
409 | ASSIGNMENT_FILE_SCAN_FAILED | scan_status='failed' | Show "file unavailable"; an administrator can rescan it (POST /api/assignment-files/:fileId/rescan) | assignment-file-download.service.ts:109-114 |
410 | ASSIGNMENT_FILE_REJECTED | scan_status='rejected', or the attachment was removed, or its bytes were purged | Show the security-check message; the file will never become available — upload a replacement | assignment-file-download.service.ts:115-124 |
Edge Cases
- Requesting a
pendingfile mid-scan:409 ASSIGNMENT_FILE_NOT_READY, retry shortly. - Requesting a
rejectedfile:410— rejected bytes are retained for the quarantine window but never streamed to anyone, including an administrator.
8.6 File upload and removal — attachments and submission files
Routes: POST /api/assignments/:id/attachments, DELETE /api/assignments/:id/attachments/:fileId, their teacher mirrors under /api/mobile/teacher/assignments/:id/attachments*, and the pupil's POST /api/mobile/student/assignments/:id/submission/files and DELETE /api/mobile/student/assignments/:id/submission/files/:fileId.
Request
multipart/form-data with one file part and an optional clientUploadId (uuid) field. Replaying the same clientUploadId from the same uploader returns the file already recorded, as long as it belongs to the same assignment (or the same pupil draft) — including when two requests race: the losing insert hits the clientUploadId unique index, and the loser then looks up and returns the winner's row instead of erroring. The uploaded filename is re-decoded as UTF-8 where the raw bytes support it, stripped of control and bidirectional-control characters, and capped at 255 code points before it is stored — never trusted verbatim. Each upload type stages bytes under its own private prefix (uploads/assignment-attachment/staging, uploads/assignment-submission/staging); the generic GET /api/upload/file/* route refuses any key under either prefix with 404 UPLOAD_FILE_KEY_INVALID, so an assignment file is only ever reachable through this module's own download endpoints.
Validation order
Checks run in this order and stop at the first failure (files/assignment-file-upload-attachment.ts:123-141, files/assignment-file-upload-submission.ts:148-166):
- The assignment is visible (and, for attachments, manageable) to the caller.
- (Submission only)
allowFileUploadis on. - The extension maps to a known file type.
- The file is not empty.
- The file is within
assignment_settings.max_file_size_mb. - The type is in
assignment_settings.attachment_file_types(attachments) or the assignment'ssubmission_file_types(submission files). - The uploader has fewer than 20 files still
pendinga scan (checked once outside the transaction, then re-checked inside it under a per-uploaderpg_advisory_xact_lockso two concurrent uploads from the same person cannot both slip past the outer check). - The storage layer accepts the bytes and sniffs their MIME; the sniffed MIME must match what the declared file type requires (a fixed MIME for
txt/csv) or the upload is purged and refused as a security-check failure, even though it already passed the extension/size/allowlist checks above. - Inside the write transaction: the attachment count, or the pupil draft's file count and total bytes (summed over non-
supersededattempts only — a superseded attempt's files stop counting the moment a newer attempt replaces it), is still within its limit.
Error Cases
| HTTP Status | Error Code | Routes | Condition | Consumer Action | Source |
|---|---|---|---|---|---|
404 | ASSIGNMENT_NOT_FOUND | all uploads | Assignment not visible/manageable by the caller | Treat as missing | assignment-file-upload-attachment.ts:65, :177; assignment-file-upload-submission.ts:74 |
409 | ASSIGNMENT_FILES_NOT_ALLOWED | pupil upload | The assignment's allowFileUpload is off | Hide the upload control; submit text instead | assignment-file-upload-submission.ts:77-81 |
409 | ASSIGNMENT_VERSION_CONFLICT | all uploads | The clientUploadId was already used for a different assignment or draft | Generate a fresh clientUploadId and upload again | assignment-file-upload-attachment.ts:116-120; assignment-file-upload-submission.ts:141-145 |
400 | ASSIGNMENT_FILE_TYPE_NOT_ALLOWED | all uploads | Unknown extension, or a type the attachment/submission allowlist does not include | Convert the file to an allowed type | assignment-file-upload.validation.ts:17-28, :51-61 |
400 | ASSIGNMENT_FILE_EMPTY | all uploads | Zero-byte file | Pick the file again; it was empty | assignment-file-upload.validation.ts:30-37 |
413 | ASSIGNMENT_FILE_TOO_LARGE | all uploads | Larger than the school's max_file_size_mb | Compress or split the file | assignment-file-upload.validation.ts:39-49 |
429 | ASSIGNMENT_TOO_MANY_PENDING_FILES | all uploads | The uploader already has 20 or more files awaiting a scan | Wait for earlier files to finish checking, then retry | assignment-file-upload.validation.ts:63-74 |
400 | ASSIGNMENT_FILE_SECURITY_CHECK_FAILED | all uploads | The storage layer's own validation refused the bytes (StorageValidationError), or storedMimeFor finds the sniffed MIME does not match what the declared type requires — for txt/csv specifically, this is any upload whose bytes sniff as a real binary signature instead of text (a PDF or image renamed .txt) | Show the security-check message; do not retry the same file | assignment-file-upload.validation.ts:76-81, raised at assignment-file-upload-attachment.ts:153, :195-201 and assignment-file-upload-submission.ts:178 |
409 | ASSIGNMENT_ATTACHMENT_LIMIT_EXCEEDED | attachment upload | The assignment already carries max_attachments attachments | Remove an attachment first | assignment-file-upload-attachment.ts:192-196 |
409 | ASSIGNMENT_NOT_ACCEPTING | pupil upload | The pupil can no longer edit a draft (window closed, row under review, class removed) | Refresh the assignment; the upload control should be hidden | shared/assignment-draft.service.ts:112-116 |
409 | ASSIGNMENT_SUBMISSION_FILE_LIMIT_EXCEEDED | pupil upload | The draft already holds the assignment's max_files | Remove a file first | assignment-file-upload-submission.ts:228-232 |
409 | ASSIGNMENT_SUBMISSION_STORAGE_LIMIT | pupil upload | This pupil's submission files for the assignment would exceed 200 MiB in total | Remove or shrink files | assignment-file-upload-submission.ts:252-256 |
404 | ASSIGNMENT_FILE_NOT_FOUND | both removals | No such attachment, no current draft, or the file is not on it | Refresh the file list | assignment-file-upload-attachment.ts:287-291; assignment-file-upload-submission.ts:330-353 |
A file accepted here is only the start: its scan verdict arrives later. A file that is later rejected blocks publishing (ASSIGNMENT_HAS_REJECTED_ATTACHMENTS) or submitting (ASSIGNMENT_SUBMISSION_HAS_REJECTED_FILES) until it is removed, and its download answers 410 ASSIGNMENT_FILE_REJECTED.
8.7 PATCH /api/assignments/:id (and PATCH /api/mobile/teacher/assignments/:id)
Purpose
Edits an assignment. The request shape and the per-status restrictions are in §6.1a.
Auth and Permissions
- Permission:
Assignments_UPDATE(admin); none for the teacher mirror, which is scoped by the manage predicate. - Idempotency:
versionoptimistic concurrency.
Error Cases
| HTTP Status | Error Code | Condition | Consumer Action | Source |
|---|---|---|---|---|
404 | ASSIGNMENT_NOT_FOUND | Not found, or outside the caller's manage scope | Treat as missing | assignment-update.service.ts:102 |
409 | ASSIGNMENT_VERSION_CONFLICT | version mismatch | Reload and re-apply the edit | assignment-update.service.ts:103 |
409 | ASSIGNMENT_NOT_EDITABLE | Assignment is archived or cancelled | Unarchive first; a cancelled assignment cannot be edited | assignment-update.service.ts:104-105 |
409 | ASSIGNMENT_SUBJECT_LOCKED | subjectId/academicSessionId changed, or classSubjectIds sent, for a published/closed assignment, or addClassSubjectIds/removeClassSubjectIds sent for a draft/scheduled one | Use the field that matches the status | assignment-merge-validation.util.ts:197-203, raised at assignment-update.service.ts:134, :273, :301, :328 |
404 | SUBJECT_NOT_FOUND / ACADEMIC_SESSION_NOT_FOUND | Unknown or inactive subject, unknown session | Re-pick | assignment-update.service.ts:127-150 |
404 | ASSIGNMENT_CATEGORY_NOT_FOUND | Unknown category, or an inactive one that is not already the stored category | Re-pick the category | assignment-update.service.ts:168-173 |
409 | ASSIGNMENT_GRADING_LOCKED | isGraded/maxMarks/passMarks/marksRelease sent after any attempt has been reviewed | Leave grading unchanged | assignment-merge-validation.util.ts:205-211, raised at assignment-update.service.ts:186 |
409 | ASSIGNMENT_MAX_ATTEMPTS_BELOW_USED | New attempt allowance is below a pupil's existing attempt_count | Choose a higher limit | assignment-merge-validation.util.ts:213-218, raised at assignment-update.service.ts:204 |
400 | ASSIGNMENT_DUE_IN_PAST | Changed dueAt on a live assignment is less than 5 minutes away | Pick a later due date | assignment-merge-validation.util.ts:87-93 |
400 | ASSIGNMENT_PUBLISH_AT_INVALID | Changed dueAt on a scheduled assignment leaves no room after publishAt | Move the due date later or unschedule | assignment-merge-validation.util.ts:94-106 |
400 | ASSIGNMENT_FINAL_DEADLINE_INVALID | Final deadline and late policy disagree, or the final deadline is not after the due date | Fix the late-policy fields together | assignment-merge-validation.util.ts:108-124 |
400 | ASSIGNMENT_SUBMISSION_SETTINGS_INVALID | No submission method, maxFiles out of range, bad submissionFileTypes, or maxAttempts inconsistent with the resubmission policy | Fix the submission settings together | assignment-merge-validation.util.ts:126-164 |
400 | ASSIGNMENT_GRADING_INVALID | maxMarks/passMarks inconsistent with isGraded or out of range | Fix the grading fields together | assignment-merge-validation.util.ts:166-187 |
404 | ASSIGNMENT_CLASS_NOT_TAUGHT | (teacher) A new target class is not taught by the caller | Choose a class you teach | shared/assignment-access.service.ts:287 (assertClassSubjectsUsable) |
422 | ASSIGNMENT_CLASS_SUBJECT_INVALID | Duplicate, invalid, or wrong-session/wrong-subject class-subject; or an addClassSubjectIds entry naming a class-subject that does not exist | Fix the class selection | shared/assignment-access.service.ts:244, :294; AssignmentRosterServiceImpl.reAddClass |
422 | ASSIGNMENT_AUDIENCE_TOO_LARGE | More than 60 classes in one class list, or adding a new target would take the live classes past 60 | Reduce the selection | shared/assignment-access.service.ts:250; AssignmentRosterServiceImpl.reAddClass |
409 | ASSIGNMENT_NO_CLASSES | removeClassSubjectIds would leave no live class | Keep at least one class | assignment-update.service.ts:314-318 |
400 | ASSIGNMENT_LINK_INVALID | A link fails URL normalisation | Correct the link | shared/assignment-html.util.ts:126-158, called at assignment-update.service.ts:347 |
8.8 Remaining route families — error cases
The routes below reuse the DTOs in §6 and are listed with every error code they can return.
| Route(s) | HTTP Status | Error Code | Condition | Consumer Action | Source |
|---|---|---|---|---|---|
GET :id, GET :id/options, GET :id/classes, GET :id/history (admin and teacher) | 404 | ASSIGNMENT_NOT_FOUND | Not found or not visible | Treat as missing | assignment-detail.service.ts:56, assignment-class-stats-history.service.ts:37 |
DELETE :id (admin and teacher) | 404 / 409 / 409 | ASSIGNMENT_NOT_FOUND / ASSIGNMENT_VERSION_CONFLICT / ASSIGNMENT_DELETE_NOT_ALLOWED | Missing; stale version query param; status is not draft/scheduled | Treat as missing / reload / close or cancel instead | assignment-write.service.ts:43, :50, :296-302 |
PUT :id/teacher (admin) | 404 / 409 / 409 / 404 | ASSIGNMENT_NOT_FOUND / ASSIGNMENT_VERSION_CONFLICT / ASSIGNMENT_NOT_EDITABLE / ASSIGNMENT_TEACHER_NOT_FOUND | Missing; stale version; archived or cancelled; teacherId not a live staff row | Treat as missing / reload / unarchive / pick another teacher | assignment-write.service.ts:346, :366 |
POST :id/unschedule, close, reopen, archive, unarchive (admin and teacher) | 404 / 409 / 409 / 409 | ASSIGNMENT_NOT_FOUND / ASSIGNMENT_VERSION_CONFLICT / ASSIGNMENT_INVALID_TRANSITION / ASSIGNMENT_NOT_EDITABLE | Missing; stale version; not in a status the action accepts; archived (close/reopen) | Treat as missing / reload / refresh the action menu / unarchive | assignment-lifecycle.service.ts:37-61 |
POST :id/cancel (admin and teacher) | 403 | ASSIGNMENT_CANCEL_NOT_ALLOWED | (teacher) assignment_settings.teachers_can_cancel is off | Ask an administrator to cancel | assignment-lifecycle.service.ts:333-345 |
POST :id/release-marks (admin and teacher) | 404 / 409 / 409 | ASSIGNMENT_NOT_FOUND / ASSIGNMENT_MARKS_RELEASE_NOT_APPLICABLE / ASSIGNMENT_VERSION_CONFLICT | Not manageable; not graded or not marksRelease='manual'; stale version | Treat as missing / hide the action / reload | assignment-review-release.service.ts:60-78 |
GET :id/submissions (admin and teacher) | 404 / 400 | ASSIGNMENT_NOT_FOUND / PAGINATION_LIMIT_INVALID | Not visible; pagination=false requested | Treat as missing / always paginate | assignment-roster-read.service.ts:88-97, :426-429 |
GET :id/submissions/:rowId (admin and teacher) | 404 | ASSIGNMENT_SUBMISSION_NOT_FOUND | Row not on this assignment or outside review scope | Treat as missing | assignment-roster-read.service.ts:253-256 |
PUT /mobile/student/assignments/:id/submission | 404 / 409 / 409 / 400 / 400 | ASSIGNMENT_NOT_FOUND / ASSIGNMENT_NOT_ACCEPTING / ASSIGNMENT_DRAFT_VERSION_CONFLICT / ASSIGNMENT_TEXT_NOT_ALLOWED / ASSIGNMENT_TEXT_TOO_LONG | No pupil row; draft not editable; stale or missing draft version; text sent where text is off; text over the limit | Treat as missing / refresh / reload the draft / hide the text box / shorten | shared/assignment-draft.service.ts:60-116, assignment-submission.service.ts:113-153, :485-494 |
GET /mobile/student/assignments, GET .../guardian/children/:studentId/assignments | 400 | PAGINATION_LIMIT_INVALID | pagination=false requested | Always paginate | portal/student/student-assignments-portal.service.ts:130-136 |
GET /mobile/student/assignments/:id, guardian summary/list/detail | 404 | ASSIGNMENT_NOT_FOUND | No live pupil for the session, studentId not a uuid or not the guardian's child, or assignment not visible | Treat as missing | shared/assignment-audience.resolver.ts:52-110, portal/student/student-assignment-detail.service.ts:66, :83, portal/guardian/guardian-assignments-portal.service.ts:301, :318 |
GET /assignment-categories | — | none beyond validation | — | — | — |
POST /assignment-categories, PATCH /assignment-categories/:id | 409 / 409 | ASSIGNMENT_CATEGORY_NAME_TAKEN / ASSIGNMENT_CATEGORY_CODE_TAKEN | An active category already has this name (case-insensitive) / this code | Choose another name or code, or reactivate the existing one | assignment-categories.service.ts:55-67 |
PATCH /assignment-categories/:id | 404 / 409 | ASSIGNMENT_CATEGORY_NOT_FOUND / ASSIGNMENT_VERSION_CONFLICT | Unknown id; stale version | Refresh / reload | assignment-categories.service.ts:145-155, :176-180 |
DELETE /assignment-categories/:id | 404 / 409 / 409 | ASSIGNMENT_CATEGORY_NOT_FOUND / ASSIGNMENT_VERSION_CONFLICT / ASSIGNMENT_CATEGORY_IN_USE | Unknown id; stale version; referenced by an assignment | Refresh / reload / deactivate it instead | assignment-categories.service.ts:201-227 |
PATCH /assignment-settings | 409 | ASSIGNMENT_VERSION_CONFLICT | Stale version | Reload and re-apply | assignment-settings.service.ts:93-98 |
PUT /assignment-notification-rules/:event | 404 / 400 / 409 | ASSIGNMENT_NOTIFICATION_RULE_INVALID / ASSIGNMENT_NOTIFICATION_RULE_INVALID / ASSIGNMENT_VERSION_CONFLICT | :event is not one of the ten events (or its row is missing); notifyTeacher: true on an event other than submitted/overdue; stale version | Use a listed event / leave the teacher toggle off / reload | assignment-notification-rules.service.ts:66-97 |
GET /assignment-transfer-reviews | 400 | PAGINATION_LIMIT_INVALID | pagination=false requested | Always paginate | assignment-transfer-reviews.service.ts:187-192 |
POST /assignment-transfer-reviews/:id/resolve | 404 | ASSIGNMENT_TRANSFER_REVIEW_NOT_FOUND | No such review, or it is already resolved | Refresh the list | AssignmentRosterServiceImpl.resolveDecision (shared/assignment-roster.service.ts), assignment-transfer-reviews.service.ts:259-262 |
| same | 409 | ASSIGNMENT_TRANSFER_NO_LONGER_APPLICABLE | The pupil's enrolment changed since the review opened: they are back in a live target class, or no longer have an active enrolment. The stale review is deleted (and a withdrawn pupil's row marked not_applicable) and committed before the 409 is returned | Refresh the list; nothing further is needed for this pupil | AssignmentRosterServiceImpl.resolveDecision |
| same | 409 | ASSIGNMENT_TRANSFER_ALREADY_SUBMITTED | resolution: "not_applicable" for a pupil whose row is past in_progress | Resolve keep instead | AssignmentRosterServiceImpl.resolveDecision |
POST /assignment-files/:fileId/rescan | 404 / 409 | ASSIGNMENT_FILE_NOT_FOUND / ASSIGNMENT_FILE_NOT_RESCANNABLE | Unknown file; scan_status is not failed (checked before and again inside the write) | Refresh / only failed scans can be retried | files/admin/assignment-file-rescan.service.ts:44-57, :92-97 |
9. Flow Diagrams
9.1 Route Ownership
9.2 Request Sequence (representative: submit)
9.3 Error Branch (representative: lifecycle action)
10. Pagination, Sorting, Filtering, and Search
| Endpoint | Pagination Type | Default Size | Max Size | Sort Fields | Filters | Result Cap |
|---|---|---|---|---|---|---|
GET /api/assignments | page/size (pagination=false allowed) | 20 | 100 | status, dueAt, createdAt, title | subjectId, categoryId, teacherId, academicSessionId, status/displayStatus, search | 1000 unpaginated |
GET /mobile/teacher/assignments | page/size | 20 | 100 | same | same (implicitly scoped to the teacher) | 1000 unpaginated |
GET /mobile/student/assignments | page/size (pagination=false refused with 400 PAGINATION_LIMIT_INVALID) | 20 | 100 | dueAt | bucket | — |
GET /mobile/guardian/children/:studentId/assignments | page/size (same service; pagination=false refused) | 20 | 100 | dueAt | bucket | — |
GET :id/submissions | page/size (pagination=false refused with 400 PAGINATION_LIMIT_INVALID) | 20 | 100 | name, rollNumber, lastSubmittedAt, status (falls back to name for anything unrecognised) | filter (roster filter), classId | — |
GET /assignment-transfer-reviews | page/size (pagination=false refused with 400 PAGINATION_LIMIT_INVALID) | 20 | 100 | — | — | — |
GET :id/history | page/size | 20 | 100 | — (chronological) | — | 1000 unpaginated |
GET /assignment-categories | page/size | 20 | 100 | sortOrder ascending (lower numbers first) by default; sort=name honours order | includeInactive | 1000 unpaginated |
GET /assignment-notification-rules | none (all 10 rows) | — | — | — | — | — |
All list/roster/history endpoints use the shared PaginationUtil (common/utils/pagination.util.ts) — offset pagination, {count, page, size} metadata envelope, no hand-rolled offset math anywhere in this module. The admin/teacher assignment list orders by the requested sort column in the requested direction with NULLS LAST, then by assignments.id as a tiebreaker — so two rows with an equal or null value on the sort column, or a page boundary landing between them, always resolve in a stable, repeatable order.
11. Caching, Jobs, and External Integrations
| Integration | Used? | Details | Source |
|---|---|---|---|
| Redis cache | No | No CacheKeyUtil usage anywhere in this module. | grep of apps/api/src/modules/assignments/ |
| BullMQ | Yes | QueueName.ASSIGNMENTS (4 sweep jobs, cron-produced) and QueueName.ASSIGNMENT_FILE_SCAN (1 job, produced on upload via the outbox and by the recovery sweep). See the backend doc §9 for the full contract. | packages/jobs/src/index.ts, workers/* |
| External API | No | File storage goes through the first-party @skoolsewa/storage package, not a third-party API called directly from this module. | files/assignment-file-upload.service.ts |
12. Error Code Reference
Every ASSIGNMENT_* code in apps/api/src/common/types/error-codes.ts (lines 1494-1562), with the HTTP status it is returned with, the routes that can return it, and what a consumer should do. Paths in the Source column are relative to apps/api/src/modules/assignments/. "Admin and teacher" means the admin route and its /api/mobile/teacher mirror.
Three codes are never returned over HTTP — ASSIGNMENT_FILE_PURGE_FAILED and ASSIGNMENT_FILE_SCAN_WORKER_FAILED are written to the server log only, and ASSIGNMENT_SETTINGS_INVALID is defined but not referenced anywhere. They are listed at the end of this table and described in the backend doc §13.
Generic codes owned by other modules also appear on these routes: 400 validation errors from the global ValidationPipe, 400 PAGINATION_LIMIT_INVALID on the lists that refuse pagination=false (§10), 404 SUBJECT_NOT_FOUND and 404 ACADEMIC_SESSION_NOT_FOUND on create and update, 401/403 from the guards, and 429 from IpThrottlerGuard on throttled routes.
Two more shared codes cover every write under a locked academic session or a graduated pupil, on every table this module owns — assignments, assignment_classes, assignment_students, assignment_submission_attempts, assignment_links, and assignment_files for insert and delete (not update, so a pending scan can still finish after a lock):
| Error Code | HTTP Status | Routes | Condition | Consumer Action | Source |
|---|---|---|---|---|---|
ACADEMIC_SESSION_LOCKED | 409 | Every write route on an assignment, its classes, roster, submission attempts, links and file insert/delete | A Postgres trigger refuses the write because the assignment's academic session has locked_at set | Unlock the year to make the correction, or wait for a new one | packages/db/src/migrations/0038_year_lock_records.sql, surfaced by AllExceptionsFilter's GUARD_MESSAGES |
STUDENT_GRADUATED_READ_ONLY | 403 | Pupil portal writes (saveDraft, file upload/removal, submit) | The pupil's graduate access state is grace or ended | Nothing can be submitted for a graduated pupil; read-only access continues until the window ends | apps/api/src/modules/people/shared/graduate-access.query.ts:82-95, called from student-assignment-submission-portal.controller.ts |
| Error Code | HTTP Status | Routes | Condition | Consumer Action | Source |
|---|---|---|---|---|---|
ASSIGNMENT_ALREADY_COMPLETED | 409 | Pupil submit | The pupil's row is already completed | Show the result; no further submission | submissions/core/assignment-submission.service.ts:313 |
ASSIGNMENT_ATTACHMENT_LIMIT_EXCEEDED | 409 | Attachment upload (admin and teacher) | The assignment already has max_attachments attachments | Remove an attachment first | files/assignment-file-upload-attachment.ts:195 |
ASSIGNMENT_ATTEMPT_NOT_REVIEWABLE | 409 | Review save, complete, return (admin and teacher) | The attempt is not the latest non-draft attempt or is in a status the action does not accept; or return on resubmissionPolicy='none' | Reload the pupil record and act on the current attempt | submissions/core/assignment-review.service.ts:240, :449 |
ASSIGNMENT_AUDIENCE_TOO_LARGE | 422 | Create, update, preview, publish (admin and teacher) | More than 60 target classes, or more than 3000 live pupils at publish | Narrow the class selection | shared/assignment-access.service.ts:250, authoring/core/assignment-publish-guards.util.ts:69, :131, AssignmentRosterServiceImpl.reAddClass |
ASSIGNMENT_CANCEL_NOT_ALLOWED | 403 | Teacher cancel | teachers_can_cancel is off | Ask an administrator to cancel | authoring/core/assignment-lifecycle.service.ts:342 |
ASSIGNMENT_CATEGORY_CODE_TAKEN | 409 | Category create, update | Another active category has this code | Choose another code or reactivate the existing category | settings/categories/assignment-categories.service.ts:65 |
ASSIGNMENT_CATEGORY_IN_USE | 409 | Category delete | An assignment still references the category | Deactivate it instead of deleting | settings/categories/assignment-categories.service.ts:226 |
ASSIGNMENT_CATEGORY_NAME_TAKEN | 409 | Category create, update | Another active category has this name (case-insensitive) | Choose another name | settings/categories/assignment-categories.service.ts:59 |
ASSIGNMENT_CATEGORY_NOT_FOUND | 404 | Assignment create, update; category update, delete | Unknown category, or an inactive one being newly chosen | Re-pick from the active list | authoring/core/assignment-write.service.ts:152, authoring/core/assignment-update.service.ts:172, settings/categories/assignment-categories.service.ts:147, :203 |
ASSIGNMENT_CLASS_NOT_TAUGHT | 404 | Teacher create, update, preview, publish | A target class-subject is not taught by the calling teacher | Choose a class you teach, or ask an administrator | shared/assignment-access.service.ts:287, authoring/core/assignment-publish-guards.util.ts:80 |
ASSIGNMENT_CLASS_SUBJECT_INVALID | 422 | Create, update, preview (admin and teacher) | Duplicate id; class-subject inactive, in another session, teaching another subject, or nonexistent | Fix the class selection | shared/assignment-access.service.ts:244, :294, AssignmentRosterServiceImpl.reAddClass |
ASSIGNMENT_DELETE_NOT_ALLOWED | 409 | Delete (admin and teacher) | Status is not draft/scheduled | Close or cancel instead | authoring/core/assignment-write.service.ts:301 |
ASSIGNMENT_DRAFT_VERSION_CONFLICT | 409 | Pupil save draft, submit, submission-file upload | Draft version stale or missing when a draft already exists; a draft could not be created | Reload the assignment, then save or submit with the current draft version | shared/assignment-draft.service.ts:187, submissions/core/assignment-submission.service.ts:118, :124, :152, :249, :255, :399 |
ASSIGNMENT_DUE_IN_PAST | 400 | Publish now; update | Due date less than 5 minutes away | Pick a later due date | authoring/core/assignment-lifecycle.service.ts:65, authoring/core/assignment-merge-validation.util.ts:47 |
ASSIGNMENT_FEEDBACK_REQUIRED | 400 | Review save, complete, return | Return with feedback that carries no text; feedback over 40,000 HTML or 20,000 text characters after sanitising | Add or shorten the feedback | submissions/core/assignment-review.service.ts:273, :279, :286, :519, :526 |
ASSIGNMENT_FILE_EMPTY | 400 | All uploads | Zero-byte file | Pick the file again | files/assignment-file-upload.validation.ts:34 |
ASSIGNMENT_FILE_NOT_FOUND | 404 | All downloads; attachment and submission-file removal; rescan | Unknown file, out of scope, or (guardian) a submission file | Refresh; do not retry | files/assignment-file-download.service.ts:35, files/assignment-file-upload-attachment.ts:290, files/assignment-file-upload-submission.ts:333, :352, files/admin/assignment-file-rescan.service.ts:48 |
ASSIGNMENT_FILE_NOT_READY | 409 | All downloads | scan_status='pending' | Retry after a short delay | files/assignment-file-download.service.ts:100 |
ASSIGNMENT_FILE_NOT_RESCANNABLE | 409 | Rescan | scan_status is not failed | Only failed scans can be rescanned | files/admin/assignment-file-rescan.service.ts:54, :96 |
ASSIGNMENT_FILE_REJECTED | 410 | All downloads | File rejected by the scan, removed, or purged | Upload a replacement; this file never becomes available | files/assignment-file-download.service.ts:118 |
ASSIGNMENT_FILE_SCAN_FAILED | 409 | All downloads | scan_status='failed' | Show "file unavailable"; an administrator can rescan | files/assignment-file-download.service.ts:106 |
ASSIGNMENT_FILE_SECURITY_CHECK_FAILED | 400 | All uploads | The storage layer's validation refused the bytes, or (any type, including txt/csv) the sniffed content does not match the required MIME for the declared type | Show the security-check message; use a different file | files/assignment-file-upload.validation.ts:79, files/assignment-file-upload.validation.ts:81-92 (storedMimeFor) |
ASSIGNMENT_FILES_NOT_ALLOWED | 409 | Pupil submission-file upload, submit | The assignment's allowFileUpload is off | Hide file upload; submit text | files/assignment-file-upload-submission.ts:80, submissions/core/assignment-submission.service.ts:306 |
ASSIGNMENT_FILE_TOO_LARGE | 413 | All uploads | Over assignment_settings.max_file_size_mb | Compress or split the file | files/assignment-file-upload.validation.ts:46 |
ASSIGNMENT_FILE_TYPE_NOT_ALLOWED | 400 | All uploads | Unknown extension, or type not in the attachment/submission allowlist | Convert to an allowed type | files/assignment-file-upload.validation.ts:24, :58 |
ASSIGNMENT_FINAL_DEADLINE_INVALID | 400 | Update | Final deadline and late policy disagree, or final deadline not after the due date | Fix the late-policy fields together | authoring/core/assignment-merge-validation.util.ts:61 |
ASSIGNMENT_GRADING_INVALID | 400 | Update | maxMarks/passMarks inconsistent with isGraded or out of range | Fix the grading fields together | authoring/core/assignment-merge-validation.util.ts:75 |
ASSIGNMENT_GRADING_LOCKED | 409 | Update | Grading fields sent after any attempt was reviewed | Leave grading unchanged | authoring/core/assignment-merge-validation.util.ts:209 |
ASSIGNMENT_HAS_REJECTED_ATTACHMENTS | 409 | Publish (admin and teacher) | A non-removed attachment is rejected | Remove or replace it, then publish | authoring/core/assignment-publish-guards.util.ts:110 |
ASSIGNMENT_INVALID_TRANSITION | 409 | Publish, unschedule, close, reopen, cancel, archive | The current status does not accept the action | Refresh the action menu | authoring/core/assignment-lifecycle.service.ts:53 |
ASSIGNMENT_LINK_INVALID | 400 | Create, update | A link is unparseable, not https:, carries credentials, or exceeds 2048 characters | Correct the link | shared/assignment-html.util.ts:134, :141, :147, :153 |
ASSIGNMENT_MARKS_INVALID | 400 | Review save, complete | Marks missing on a graded assignment, outside [0, maxMarks], or present on an ungraded one | Correct the marks | submissions/core/assignment-review.service.ts:471, :479, :485 |
ASSIGNMENT_MARKS_RELEASE_NOT_APPLICABLE | 409 | Release marks (admin and teacher) | Assignment not graded, or marksRelease is not manual | Hide the action | submissions/core/assignment-review-release.service.ts:70 |
ASSIGNMENT_MAX_ATTEMPTS_BELOW_USED | 409 | Update | New attempt allowance below a pupil's existing attempt count | Choose a higher limit | authoring/core/assignment-merge-validation.util.ts:216 |
ASSIGNMENT_NO_CLASSES | 422 / 409 | Publish (422); update removing every live class (409) | No live target class | Add or keep at least one class | authoring/core/assignment-publish-guards.util.ts:63, authoring/core/assignment-update.service.ts:322 |
ASSIGNMENT_NOT_ACCEPTING | 409 | Pupil save draft, submission-file upload, submit | The submission window is closed, the row is under review, or the class was removed | Refresh; the assignment no longer takes work | shared/assignment-draft.service.ts:115, submissions/core/assignment-submission.service.ts:92, :319, :357 |
ASSIGNMENT_NOT_EDITABLE | 409 | Update, reassign teacher, publish, close, reopen, review save/complete/return | Archived or cancelled (update/reassign); archived (lifecycle); not published/closed or archived (review; return needs published) | Unarchive, or leave the assignment as it is | authoring/core/assignment-lifecycle.service.ts:59, authoring/core/assignment-merge-validation.util.ts:193, authoring/core/assignment-write.service.ts:389, submissions/core/assignment-review.service.ts:390 |
ASSIGNMENT_NOT_FOUND | 404 | Every route taking an assignment id; pupil and guardian routes when no live pupil/child resolves | Missing, out of scope, or no identity match | Treat as missing; do not retry | shared/assignment-audience.resolver.ts:56, :85, :109; authoring/core/assignment-lifecycle.service.ts:40; authoring/core/assignment-write.service.ts:43; and the other throw sites listed in §8 |
ASSIGNMENT_NOTIFICATION_RULE_INVALID | 404 / 400 | Notification rule update | :event not one of the ten events or row missing (404); notifyTeacher on an event other than submitted/overdue (400) | Use a listed event; leave the teacher toggle off | settings/admin/assignment-notification-rules.service.ts:67, :76, :89 |
ASSIGNMENT_PUBLISH_AT_INVALID | 400 | Schedule; update of a scheduled assignment's due date | publishAt missing, under a minute away, or leaving no room before the due date | Pick another time | authoring/core/assignment-lifecycle.service.ts:71, authoring/core/assignment-merge-validation.util.ts:54 |
ASSIGNMENT_RESUBMISSION_NOT_ALLOWED | 409 | Pupil submit; return | Attempt budget exhausted (submit); 50 extra attempts already granted on a non-unlimited policy (return) | Hide resubmit / stop returning this pupil's work | submissions/core/assignment-submission.service.ts:356, submissions/core/assignment-review.service.ts:249 |
ASSIGNMENT_RESUBMIT_DUE_IN_PAST | 400 | Return | resubmitDueAt not in the future | Pick a future deadline | submissions/core/assignment-review.service.ts:292 |
ASSIGNMENT_RESUBMIT_DUE_REQUIRED | 400 | Return | No resubmitDueAt and the assignment's own window is closed | Supply a resubmission deadline | submissions/core/assignment-review.service.ts:299 |
ASSIGNMENT_SUBJECT_LOCKED | 409 | Update | Subject/session/target-set field not allowed for the current status | Use the field that matches the status (§6.1a) | authoring/core/assignment-merge-validation.util.ts:201 |
ASSIGNMENT_SUBMISSION_EMPTY | 400 | Pupil submit | No text and no non-rejected file | Add text or a file | submissions/core/assignment-submission.service.ts:288 |
ASSIGNMENT_SUBMISSION_FILE_LIMIT_EXCEEDED | 409 | Pupil submission-file upload, submit | Draft holds max_files already, or more than max_files at submit | Remove a file | files/assignment-file-upload-submission.ts:231, submissions/core/assignment-submission.service.ts:300 |
ASSIGNMENT_SUBMISSION_HAS_REJECTED_FILES | 409 | Pupil submit | A draft file is rejected | Remove the rejected file | submissions/core/assignment-submission.service.ts:294 |
ASSIGNMENT_SUBMISSION_NOT_FOUND | 404 | Submission record; review save, complete, return | Row not on the assignment or out of review scope; attempt not bound to the row | Treat as missing | submissions/core/assignment-roster-read.service.ts:255, submissions/core/assignment-review.service.ts:408, :425 |
ASSIGNMENT_SUBMISSION_SETTINGS_INVALID | 400 | Update | No submission method, maxFiles out of range, bad file types, or maxAttempts inconsistent with policy | Fix the submission settings together | authoring/core/assignment-merge-validation.util.ts:68 |
ASSIGNMENT_SUBMISSION_STORAGE_LIMIT | 409 | Pupil submission-file upload | Pupil's submission files for this assignment would exceed 200 MiB | Remove or shrink files | files/assignment-file-upload-submission.ts:255 |
ASSIGNMENT_TEACHER_NOT_FOUND | 404 | Admin create, reassign teacher | teacherId is not a live staff row | Pick another teacher | authoring/core/assignment-write.service.ts:169, :346 |
ASSIGNMENT_TEACHER_REQUIRED | 422 | Publish (admin and teacher) | require_teacher is on and no teacher is assigned | Assign a teacher | authoring/core/assignment-publish-guards.util.ts:93 |
ASSIGNMENT_TEXT_NOT_ALLOWED | 400 | Pupil save draft, submit | Text sent while allowTextResponse is off | Hide the text box | submissions/core/assignment-submission.service.ts:135, :266 |
ASSIGNMENT_TEXT_TOO_LONG | 400 | Pupil save draft, submit | Sanitised text over the school limit or module maximum | Shorten the response | submissions/core/assignment-submission.service.ts:487, :493 |
ASSIGNMENT_TOO_MANY_PENDING_FILES | 429 | All uploads | The uploader has 20 or more files awaiting scan | Wait for scans to finish, then retry | files/assignment-file-upload.validation.ts:69 |
ASSIGNMENT_TRANSFER_ALREADY_SUBMITTED | 409 | Resolve transfer review | not_applicable for a pupil past in_progress | Resolve keep | AssignmentRosterServiceImpl.resolveDecision |
ASSIGNMENT_TRANSFER_NO_LONGER_APPLICABLE | 409 | Resolve transfer review | Pupil's enrolment changed since the review opened; the review is deleted before the response | Refresh the list | AssignmentRosterServiceImpl.resolveDecision |
ASSIGNMENT_TRANSFER_REVIEW_NOT_FOUND | 404 | Resolve transfer review | Unknown or already resolved | Refresh the list | AssignmentRosterServiceImpl.resolveDecision, settings/transfers/assignment-transfer-reviews.service.ts:261 |
ASSIGNMENT_VERSION_CONFLICT | 409 | Every write carrying a version; replayed clientUploadId/clientSubmissionId that no longer matches | Stale concurrency token or conflicting replay | Reload, re-apply, and send the new version | authoring/core/assignment-lifecycle.service.ts:46, authoring/core/assignment-update.service.ts:60, authoring/core/assignment-write.service.ts:50, settings/admin/assignment-settings.service.ts:97, settings/admin/assignment-notification-rules.service.ts:96, submissions/core/assignment-review.service.ts:84, and the other sites in §8 |
ASSIGNMENT_FILE_PURGE_FAILED | not returned | — (log only) | Deleting purged bytes from storage failed after commit | None; see backend doc §13 | files/assignment-file-purge.service.ts:42 |
ASSIGNMENT_FILE_SCAN_WORKER_FAILED | not returned | — (log only) | A scan job failed terminally in the worker | None; see backend doc §13 | workers/assignment-file-scan.processor.ts:96 |
ASSIGNMENT_SETTINGS_INVALID | not returned | — (defined, unused) | No code throws it | None | apps/api/src/common/types/error-codes.ts:1541 |
13. Mandatory Deep API Documentation Pack
13.1 Route-by-Route Completeness Matrix
| Route | Controller Method | Service Method | Guards | Permission | Errors (primary) | Documented? |
|---|---|---|---|---|---|---|
GET /api/assignments/options | AssignmentsController.options | AssignmentOptionsService.catalogue | JWT, Role | Assignments_CREATE | — | Yes |
POST /api/assignments/preview | AssignmentsController.preview | AssignmentPreviewService.preview | JWT, Role | Assignments_CREATE | validation | Yes |
GET /api/assignments | AssignmentsController.findAll | AssignmentListService.list | JWT, Role | Assignments_READ | — | Yes |
POST /api/assignments | AssignmentsController.create | AssignmentWriteService.create | JWT, Role | Assignments_CREATE | ASSIGNMENT_CLASS_*, ASSIGNMENT_AUDIENCE_TOO_LARGE | Yes (§8.1) |
GET /api/assignments/:id | AssignmentsController.findOne | AssignmentDetailService.detail | JWT, Role | Assignments_READ | ASSIGNMENT_NOT_FOUND | Yes |
GET /api/assignments/:id/options | AssignmentsController.optionsForAssignment | AssignmentOptionsService.catalogue | JWT, Role | Assignments_UPDATE | ASSIGNMENT_NOT_FOUND | Yes |
PATCH /api/assignments/:id | AssignmentsController.update | AssignmentUpdateService.update | JWT, Role | Assignments_UPDATE | see §8.7 (ASSIGNMENT_SUBJECT_LOCKED, ASSIGNMENT_GRADING_LOCKED, ASSIGNMENT_MAX_ATTEMPTS_BELOW_USED, ASSIGNMENT_FINAL_DEADLINE_INVALID, ASSIGNMENT_SUBMISSION_SETTINGS_INVALID, ASSIGNMENT_GRADING_INVALID, ASSIGNMENT_LINK_INVALID, …) | Yes (§8.7) |
DELETE /api/assignments/:id | AssignmentsController.remove | AssignmentWriteService.remove | JWT, Role | Assignments_DELETE | ASSIGNMENT_NOT_FOUND, ASSIGNMENT_VERSION_CONFLICT, ASSIGNMENT_DELETE_NOT_ALLOWED | Yes (§8.8) |
PUT /api/assignments/:id/teacher | AssignmentsController.reassignTeacher | AssignmentWriteService.reassignTeacher | JWT, Role | Assignments_UPDATE | ASSIGNMENT_TEACHER_NOT_FOUND, ASSIGNMENT_VERSION_CONFLICT | Yes |
GET /api/assignments/:id/classes | AssignmentsController.classes | AssignmentClassStatsHistoryService.classStats | JWT, Role | Assignments_READ | ASSIGNMENT_NOT_FOUND | Yes |
GET /api/assignments/:id/history | AssignmentsController.history | AssignmentClassStatsHistoryService.history | JWT, Role | Assignments_READ | ASSIGNMENT_NOT_FOUND | Yes |
POST /api/assignments/:id/publish | AssignmentLifecycleController.publish | AssignmentLifecycleService.publish | JWT, Role | AssignmentPublish_CREATE | see §8.2 | Yes (§8.2) |
POST /api/assignments/:id/unschedule | AssignmentLifecycleController.unschedule | AssignmentLifecycleService.unschedule | JWT, Role | AssignmentPublish_CREATE | ASSIGNMENT_INVALID_TRANSITION, version conflict | Yes |
POST /api/assignments/:id/close | AssignmentLifecycleController.close | AssignmentLifecycleService.close | JWT, Role | AssignmentPublish_UPDATE | ASSIGNMENT_INVALID_TRANSITION, ASSIGNMENT_NOT_EDITABLE | Yes |
POST /api/assignments/:id/reopen | AssignmentLifecycleController.reopen | AssignmentLifecycleService.reopen | JWT, Role | AssignmentPublish_UPDATE | same | Yes |
POST /api/assignments/:id/cancel | AssignmentLifecycleController.cancel | AssignmentLifecycleService.cancel | JWT, Role | AssignmentCancel_UPDATE | ASSIGNMENT_CANCEL_NOT_ALLOWED, ASSIGNMENT_INVALID_TRANSITION | Yes |
POST /api/assignments/:id/archive | AssignmentLifecycleController.archive | AssignmentLifecycleService.archive | JWT, Role | Assignments_DELETE | ASSIGNMENT_INVALID_TRANSITION | Yes |
POST /api/assignments/:id/unarchive | AssignmentLifecycleController.unarchive | AssignmentLifecycleService.unarchive | JWT, Role | Assignments_RESTORE | version conflict | Yes |
POST /api/assignments/:id/attachments | AssignmentAttachmentsController.upload | AssignmentFileUploadService.uploadAttachment | JWT, Role, IP | Assignments_UPDATE | see §8.6 (ASSIGNMENT_FILE_TYPE_NOT_ALLOWED, ASSIGNMENT_FILE_EMPTY, ASSIGNMENT_FILE_TOO_LARGE, ASSIGNMENT_TOO_MANY_PENDING_FILES, ASSIGNMENT_FILE_SECURITY_CHECK_FAILED, ASSIGNMENT_ATTACHMENT_LIMIT_EXCEEDED, …) | Yes (§8.6) |
DELETE /api/assignments/:id/attachments/:fileId | AssignmentAttachmentsController.remove | AssignmentFileUploadService.removeAttachment | JWT, Role, IP | Assignments_UPDATE | ASSIGNMENT_FILE_NOT_FOUND | Yes |
GET /api/assignments/:id/submissions | AssignmentSubmissionsController.list | AssignmentRosterReadService.listRoster | JWT, Role | AssignmentSubmissions_READ | ASSIGNMENT_NOT_FOUND | Yes |
GET /api/assignments/:id/submissions/:rowId | AssignmentSubmissionsController.record | AssignmentRosterReadService.getRecord | JWT, Role | AssignmentSubmissions_READ | ASSIGNMENT_SUBMISSION_NOT_FOUND | Yes |
PUT /api/assignments/:id/submissions/:rowId/review | AssignmentSubmissionsController.saveReview | AssignmentReviewService.saveReview | JWT, Role | AssignmentReview_UPDATE | see §8.4 | Yes (§8.4) |
POST /api/assignments/:id/submissions/:rowId/complete | AssignmentSubmissionsController.completeReview | AssignmentReviewService.completeReview | JWT, Role | AssignmentReview_UPDATE | see §8.4 | Yes (§8.4) |
POST /api/assignments/:id/submissions/:rowId/return | AssignmentSubmissionsController.returnSubmission | AssignmentReviewService.returnSubmission | JWT, Role | AssignmentReview_UPDATE | see §8.4 | Yes (§8.4) |
POST /api/assignments/:id/release-marks | AssignmentSubmissionsController.releaseMarks | AssignmentReviewOperations.releaseMarks | JWT, Role | AssignmentReview_UPDATE | ASSIGNMENT_MARKS_RELEASE_NOT_APPLICABLE | Yes |
GET /api/assignment-categories | AssignmentCategoriesController.list | AssignmentCategoriesService.list | JWT, Role | AssignmentSettings_READ | — | Yes |
POST /api/assignment-categories | AssignmentCategoriesController.create | AssignmentCategoriesService.create | JWT, Role | AssignmentSettings_CREATE | ASSIGNMENT_CATEGORY_NAME_TAKEN, ASSIGNMENT_CATEGORY_CODE_TAKEN | Yes (§8.8) |
PATCH /api/assignment-categories/:id | AssignmentCategoriesController.update | AssignmentCategoriesService.update | JWT, Role | AssignmentSettings_UPDATE | ASSIGNMENT_CATEGORY_NAME_TAKEN, ASSIGNMENT_CATEGORY_CODE_TAKEN, ASSIGNMENT_CATEGORY_NOT_FOUND, ASSIGNMENT_VERSION_CONFLICT | Yes (§8.8) |
DELETE /api/assignment-categories/:id | AssignmentCategoriesController.remove | AssignmentCategoriesService.remove | JWT, Role | AssignmentSettings_DELETE | ASSIGNMENT_CATEGORY_NOT_FOUND, ASSIGNMENT_VERSION_CONFLICT, ASSIGNMENT_CATEGORY_IN_USE | Yes (§8.8) |
GET /api/assignment-settings | AssignmentSettingsController.get | AssignmentSettingsService.get | JWT, Role | AssignmentSettings_READ | — | Yes |
PATCH /api/assignment-settings | AssignmentSettingsController.update | AssignmentSettingsService.update | JWT, Role | AssignmentSettings_UPDATE | ASSIGNMENT_VERSION_CONFLICT (field validation is DTO-only) | Yes (§8.8) |
GET /api/assignment-notification-rules | AssignmentNotificationRulesController.list | AssignmentNotificationRulesService.list | JWT, Role | AssignmentNotificationSettings_READ | — | Yes |
PUT /api/assignment-notification-rules/:event | AssignmentNotificationRulesController.update | AssignmentNotificationRulesService.update | JWT, Role | AssignmentNotificationSettings_UPDATE | ASSIGNMENT_NOTIFICATION_RULE_INVALID (404 unknown event, 400 teacher not applicable), ASSIGNMENT_VERSION_CONFLICT | Yes (§8.8) |
GET /api/assignment-transfer-reviews | AssignmentTransferReviewsController.list | AssignmentTransferReviewsService.list | JWT, Role | Assignments_READ | PAGINATION_LIMIT_INVALID | Yes |
POST /api/assignment-transfer-reviews/:id/resolve | AssignmentTransferReviewsController.resolve | AssignmentTransferReviewsService.resolve | JWT, Role | Assignments_UPDATE | ASSIGNMENT_TRANSFER_REVIEW_NOT_FOUND, ASSIGNMENT_TRANSFER_NO_LONGER_APPLICABLE, ASSIGNMENT_TRANSFER_ALREADY_SUBMITTED | Yes (§8.8) |
GET /api/assignment-statistics/dashboard | AssignmentStatsController.dashboard | AssignmentAdminStatsService.summary | JWT, Role | AssignmentStats_READ | 404 ACADEMIC_SESSION_NOT_FOUND | Yes |
GET /api/assignment-statistics/analytics | AssignmentStatsController.analytics | AssignmentAdminStatsService.analytics | JWT, Role | AssignmentStats_READ | 404 ACADEMIC_SESSION_NOT_FOUND | Yes |
GET /api/assignment-files/:fileId | AssignmentFilesController.download (admin) | AssignmentFileDownloadService.open | JWT, Role, IP | Assignments_READ | see §8.5 | Yes (§8.5) |
POST /api/assignment-files/:fileId/rescan | AssignmentFilesController.rescan (admin) | AssignmentFileRescanService.rescan | JWT, Role | AssignmentSettings_UPDATE | ASSIGNMENT_FILE_NOT_FOUND, ASSIGNMENT_FILE_NOT_RESCANNABLE | Yes (§8.8) |
All 25 /api/mobile/teacher/assignments* routes | TeacherAssignmentsPortalController, TeacherAssignmentActionsPortalController, TeacherAssignmentReviewPortalController | Same core services as the admin mirrors | JWT, Role (+IP on upload) | none — audience | Same error sets as the admin mirrors, plus 403 (non-teacher session) and 404 (out-of-teach-scope row) | Yes |
GET /api/mobile/teacher/assignment-files/:fileId | TeacherAssignmentFilesPortalController.download | AssignmentFileDownloadService.open | JWT, Role, IP | none | see §8.5 | Yes |
GET /mobile/student/assignments/dashboard | StudentAssignmentsPortalController.dashboard | StudentAssignmentsPortalService.dashboard | JWT, Role | none | — | Yes |
GET /mobile/student/assignments | StudentAssignmentsPortalController.list | StudentAssignmentsPortalService.list | JWT, Role | none | — | Yes |
GET /mobile/student/assignments/:id | StudentAssignmentsPortalController.detail | StudentAssignmentDetailService.detail | JWT, Role | none | ASSIGNMENT_NOT_FOUND | Yes |
PUT /mobile/student/assignments/:id/submission | StudentAssignmentSubmissionPortalController.saveDraft | AssignmentSubmissionService.saveDraft | JWT, Role, IP | none | ASSIGNMENT_NOT_FOUND, ASSIGNMENT_NOT_ACCEPTING, ASSIGNMENT_DRAFT_VERSION_CONFLICT, ASSIGNMENT_TEXT_NOT_ALLOWED, ASSIGNMENT_TEXT_TOO_LONG | Yes (§8.8) |
POST .../submission/files | StudentAssignmentSubmissionPortalController.uploadFile | AssignmentFileUploadService.uploadSubmissionFile | JWT, Role, IP | none | see §8.6 (adds ASSIGNMENT_FILES_NOT_ALLOWED, ASSIGNMENT_NOT_ACCEPTING, ASSIGNMENT_SUBMISSION_FILE_LIMIT_EXCEEDED, ASSIGNMENT_SUBMISSION_STORAGE_LIMIT) | Yes (§8.6) |
DELETE .../submission/files/:fileId | StudentAssignmentSubmissionPortalController.removeFile | AssignmentFileUploadService.removeSubmissionFile | JWT, Role, IP | none | ASSIGNMENT_FILE_NOT_FOUND | Yes |
POST .../submission/submit | StudentAssignmentSubmissionPortalController.submit | AssignmentSubmissionService.submit | JWT, Role, IP | none | see §8.3 | Yes (§8.3) |
GET /mobile/student/assignment-files/:fileId | StudentAssignmentFilesPortalController.download | AssignmentFileDownloadService.open | JWT, Role, IP | none | see §8.5 | Yes |
GET .../assignments/summary | GuardianAssignmentsPortalController.summary | GuardianAssignmentsPortalService.summary | JWT, Role | none | 403/404 on child resolution | Yes |
GET .../assignments | GuardianAssignmentsPortalController.list | StudentAssignmentsPortalService.list | JWT, Role | none | same | Yes |
GET .../assignments/:id | GuardianAssignmentsPortalController.detail | GuardianAssignmentsPortalService.detail | JWT, Role | none | same + ASSIGNMENT_NOT_FOUND | Yes |
GET .../assignment-files/:fileId | GuardianAssignmentFilesPortalController.download | AssignmentFileDownloadService.open | JWT, Role, IP | none | ASSIGNMENT_FILE_NOT_FOUND (incl. submission-file attempts) | Yes |
13.2 Request/Response Exhaustiveness
Covered per-endpoint in §8 for the representative routes (create, publish, submit, review actions, download) and via the shared DTO tables in §6 for every other route, since the remaining lifecycle/CRUD/settings routes reuse those exact shapes. Every route's success envelope follows the repo-standard {success, message, data} (or {success, message, data, meta} for paginated lists) via ResponseDto/ApiPaginatedResponseDto.
13.3 API Diagram Pack
Covered in §9. Additional cache/async-flow diagrams are unnecessary here: this module has no cache layer (§11), and the one async flow (file scan) is diagrammed in the backend doc §7.4.
13.4 Consumer Integration Notes
| Consumer | Required Knowledge | Failure Handling | Contract Stability |
|---|---|---|---|
| Admin panel (web frontend) | Permission codes per route (§4/§5); version tokens must be round-tripped exactly; grant flags on AssignmentDetailDto are UI hints only, not an authorization decision | Display the errorCode from every non-2xx response; a 409 ASSIGNMENT_VERSION_CONFLICT should trigger a reload-and-retry prompt | Stable |
Mobile/portal (same panel, /api/mobile prefix) | No permission codes on portal routes — a 403 there means "wrong audience", not "missing grant"; guest/anonymous access does not exist | Same envelope handling; a 404 on a row the UI just listed usually means it left scope between list and detail fetch (soft-deleted, class removed) | Stable |
| Admin panel | Destructive mutations (DELETE, cancel, archive) all require the current version | Confirm before sending; surface the exact reason code from validation | Stable |
| QA | Fixtures live in apps/api/src/modules/assignments/__tests__/fixtures/ | Reproduce via the integration specs, which seed real rows through the schema | Stable |
| Internal service | None — no other backend module calls into this one's HTTP surface; cross-module reads go through Drizzle table imports directly (e.g. class_subjects, students) | N/A | Stable |
13.5 API Tradeoffs and Rationale
| Decision | Chosen Behavior | Alternatives Considered | Why This Tradeoff | Risk | Mitigation |
|---|---|---|---|---|---|
| Pagination style | Offset (page/size), shared PaginationUtil | Cursor pagination | Consistency with every other list endpoint in this repo; assignment lists are not high-volume enough to need cursor semantics | Deep pages are O(n) | MAX_PAGE_SIZE=100 cap |
| Auth mode | JWT always; audience assertion instead of permission codes on portal routes | Grant portal audiences real Assignment* permission codes | Portal codes would need per-row scoping logic duplicated in the permission layer; the audience+row-scope split keeps one control surface | A portal handler could theoretically be added without registering it in NO_PERMISSION_ADMIN_HANDLERS | Mirrored CI check (route-permissions.spec.ts) fails the build if missed |
| Error shape | Existing {success, message, data} envelope + errorCode | A problem-details (RFC 7807) shape | Consistency with the rest of the API | None | — |
| Response DTO shape | grant flags embedded in AssignmentDetailDto | A separate /permissions sub-resource | One round trip instead of two for the detail screen | A stale grant could be cached client-side past a permission change | grant is recomputed on every detail fetch, never cached server-side |
13.6 API Change Impact
| Change | Affected Consumers | Backend Impact | Data Impact | Migration Needed? | Compatibility Plan |
|---|---|---|---|---|---|
Adding a new assignment_status value | Admin panel, mobile portal (every status-branching UI) | New CHECK constraint value, new lifecycle transition logic | None to existing rows | Yes (migration to widen the enum/CHECK) | Ship backend + both consumer repos together; the status enum is embedded in three CHECK constraints |
| Adding a new notification event | Notification settings screen | New row in assignment_notification_rules, new template kind | Migration seeds the default row | Yes | Backward compatible — an unrecognised event is simply absent from the settings list until seeded |
| Renaming a DTO field | Any consumer reading that field | DTO + mapper change | None | No | Requires coordinated frontend deploy; this repo has no API versioning segment to stage it behind |
14. Zero-Omission API Checklist
- Every controller route is documented (§4 surface map + §13.1 completeness matrix — all 76 in-scope routes).
- Every parent route prefix and runtime URL is documented (§2, §4).
- Every DTO field, nested field, enum, default, and validator is documented (§6, §7).
- Every response field is documented for the representative DTOs (§6.2–6.7); list/roster DTOs reuse the same field vocabulary.
- Every auth, guard, permission, and guest-identity branch is documented (§5).
- Every success, validation, auth, permission, not-found, conflict, and rate-limit branch is documented (§8, §13.1).
- Every database read/write, queue job, and notification path is documented (§8 side effects, backend doc §9).
- Representative routes have minimal/full/success/failure examples (§8.1–8.5); the remaining routes share those exact shapes per §6.
- Route, sequence, and error diagrams are included (§9).
- Tradeoffs and compatibility risk are documented (§13.5, §13.6).
- This doc links to backend and features/flows.
15. Integration Checklist
- Every route from controllers is documented.
- Every DTO field is documented.
- Every enum value is documented (§7).
- Every response envelope is documented.
- Every error code is documented (all 62
ASSIGNMENT_*codes in §12, with per-route lists in §8; the registry isapps/api/src/common/types/error-codes.ts:1494-1562). - Every auth guard and permission is documented.
- Every cache key (none), queue job, and external call is documented (§11).
- Every diagram matches the current code.
- This doc links to backend and features/flows.
See Also
- Backend doc:
/docs/developer/assignments/backend - Features and flows doc:
/docs/developer/assignments/feature
Assignments Backend Documentation
Backend architecture, data model, services, cache, queues, runtime rules, and operational behavior for Assignments.
Reports & Analytics Features and Flows
Complete feature list, actor journeys, state flows, business rules, edge cases, and diagrams for Reports & Analytics.