Complete feature list, actor journeys, state flows, business rules, edge cases, and diagrams for Assignments.
| Source Type | Files or Docs | What Was Extracted |
|---|
| Schema | packages/db/src/schema/school/assignments.ts | Tables, enums, constraints, lifecycle rules and their rationale |
| Migrations | packages/db/src/migrations/0027_assignment_module.sql, 0028_assignment_module_triggers_and_seeds.sql | Append-only history trigger, version-bump triggers, seeded categories and notification rules |
| Controllers | apps/api/src/modules/assignments/**/*.controller.ts | Route surface, actors, guard chain |
| Services | apps/api/src/modules/assignments/**/*.service.ts | Business rules, side effects, state transitions |
| Constants | apps/api/src/modules/assignments/shared/assignment.constants.ts | Enum vocabulary and numeric limits |
| Notification templates | apps/api/src/modules/notification/templates/assignment-templates.ts | Notification events, recipients, channels, copy |
| Field | Value |
|---|
| Module | Assignments |
| Submodule | authoring, submissions, files, settings, stats, portal (mobile) |
| Primary user value | A teacher (or an administrator) sets homework for one or more classes of one subject; each pupil does the work, submits it, and gets it marked; a guardian watches their child's assignments. |
| Actors | Administrator, teacher (a live staff row), pupil (a live students row), guardian (linked to a pupil), the scheduler/worker system. |
| Main entry points | /api/assignments* (admin), /api/mobile/teacher/assignments*, /api/mobile/student/assignments*, /api/mobile/guardian/children/:studentId/assignments*, the assignments BullMQ queue, the assignment_file_scan queue, four crons. |
| Main outputs | Assignment records, per-pupil rows and submission attempts, files (attachments and submissions) with a virus/structural scan verdict, in-app/push/email/SMS notifications, an append-only history log, school-wide statistics. |
| Related docs | Backend, API |
| Actor | Can Do | Cannot Do | Auth Requirement | Notes |
|---|
| Administrator | Create/edit/publish/schedule/close/reopen/cancel/archive any assignment in the school; reassign the teacher; read and review any pupil's submission; release marks; manage categories, settings, notification rules, and transfer reviews; rescan a failed file. | Nothing is scoped away from an administrator holding the relevant Assignments_*/AssignmentReview_* codes. | JWT + Assignments_* / AssignmentPublish_* / AssignmentCancel_* / AssignmentSubmissions_READ / AssignmentReview_UPDATE / AssignmentStats_READ / AssignmentSettings_* / AssignmentNotificationSettings_* permission codes, per route. | The admin surface is /api/assignments*, /api/assignment-categories*, /api/assignment-settings, /api/assignment-notification-rules*, /api/assignment-transfer-reviews*, /api/assignment-statistics/*, /api/assignment-files/*. |
| Teacher | Create/edit/publish/close/reopen/cancel (if teachers_can_cancel)/archive an assignment they manage (see §12.3 for the exact rule); review and mark work for classes they teach; upload/remove their own attachments; see their own dashboard. | Manage or review an assignment none of whose live target classes they teach; reassign the teacher; see the admin categories/settings/notification-rules/statistics screens; act on a class-subject they do not teach. | JWT only — no @Permissions(). resolveTeacher resolves a live staff row (staff.user_id, deleted_at IS NULL — staff_user_id_unique guarantees at most one). Every route then scopes rows through AssignmentAccessService. | Reached at /api/mobile/teacher/assignments*. AssignmentView_READ gates the panel screen, not any route. |
| Pupil (student) | Read their own visible assignments (published/closed/cancelled with a live row); save a draft, upload/remove draft files, submit; download attachments of their visible assignments and their own submission files. | See another pupil's row; see a draft/scheduled assignment; write outside their own row; download another pupil's submission file. | JWT only. resolveStudent resolves students.user_id, deleted_at IS NULL (identity layer) — no record_status filter, so a suspended pupil still sees their own list; row-scope predicates then filter record_status = 'active'. | Reached at /api/mobile/student/assignments*, /api/mobile/student/assignment-files/*. |
| Guardian | Read a linked child's assignment summary, list and detail; download the child's assignment attachments (never submission files). | Read/act for a non-linked child; download a submission file; write anything. | JWT only. resolveGuardianChild(actor, studentId) binds the child before any row read; a non-owned child answers 404. | Reached at /api/mobile/guardian/children/:studentId/assignments*. |
| Worker/system | Enqueue and run the publish sweep, reminder sweep, roster sync sweep and file-scan-recovery sweep on a cron; run one file scan per queued file; auto-decide most transfer reviews. | Bypass any lifecycle guard the human paths enforce — the sweeps re-check the same guards. | Internal (BullMQ), no HTTP surface. | AssignmentScheduler enqueues; AssignmentsQueueProcessor (queue assignments) and the file-scan processor (queue assignment_file_scan) consume. |
| Capability | Surface | Actor | Route/Trigger | State Read | State Written | Linked API Section |
|---|
| Create assignment (draft) | Admin/teacher | Admin, teacher | POST /api/assignments, POST /api/mobile/teacher/assignments | Session, subject, categories, class-subjects | assignments row | 8.1 |
| Preview audience | Admin/teacher | Admin, teacher | POST /api/assignments/preview | class_subjects, student_class_enrollments | none | 8.2 |
| Update assignment | Admin/teacher | Admin, teacher (manage) | PATCH /api/assignments/:id | assignments | assignments, target-class deltas | 8.x |
| Delete draft/scheduled | Admin/teacher | Admin, teacher (manage) | DELETE /api/assignments/:id | assignments | cascade-deletes targets, links, files, history | 8.x |
| Reassign teacher | Admin | Admin | PUT /api/assignments/:id/teacher | staff | assignments.teacher_id | 8.x |
| Publish now / schedule | Admin/teacher | Admin, teacher (manage) | POST /assignments/:id/publish | assignments, publish guards | assignments.status, roster materialisation | 9.1 |
| Unschedule | Admin/teacher | Admin, teacher (manage) | POST /assignments/:id/unschedule | assignments | assignments.status='draft' | 9.x |
| Close / reopen | Admin/teacher | Admin, teacher (manage) | POST /assignments/:id/close, /reopen | assignments | assignments.status, closed_at | 9.x |
| Cancel | Admin/teacher | Admin, teacher (if teachers_can_cancel) | POST /assignments/:id/cancel | assignments, assignment_settings | assignments.status='cancelled' | 9.x |
| Archive / unarchive | Admin/teacher | Admin, teacher (manage) | POST /assignments/:id/archive, /unarchive | assignments | assignments.archived_at | 9.x |
| Upload/remove attachment | Admin/teacher | Admin, teacher (manage) | POST/DELETE :id/attachments* | assignment_settings, assignment_files | assignment_files row + storage bytes + scan queue enqueue | 10.x |
| Save draft | Pupil | Pupil (own row) | PUT /mobile/student/assignments/:id/submission | assignments, assignment_students, draft assignment_submission_attempts | draft attempt text | 11.x |
| Upload/remove draft file | Pupil | Pupil (own row) | POST/DELETE .../submission/files* | assignment_settings | assignment_files (kind=submission) | 11.x |
| Submit | Pupil | Pupil (own row) | POST .../submission/submit | window/eligibility rules | new/updated attempt submitted, assignment_students.state | 11.x |
| Review roster / record | Admin/teacher | Admin (AssignmentSubmissions_READ), teacher (reviewRow) | GET :id/submissions* | assignment_students, assignment_submission_attempts | none | 12.x |
| Save review (hold) | Admin/teacher | Admin (AssignmentReview_UPDATE), teacher | PUT :id/submissions/:rowId/review | latest non-draft attempt | attempt → under_review | 12.x |
| Complete review (mark) | Admin/teacher | same | POST :id/submissions/:rowId/complete | same | attempt/row → completed, marks release per policy | 12.x |
| Return for correction | Admin/teacher | same | POST :id/submissions/:rowId/return | same, resubmission policy | attempt → returned, row → returned | 12.x |
| Release marks (bulk) | Admin/teacher | same | POST :id/release-marks | graded rows with a held result | result_published_at set for every graded, un-released row | 12.x |
| Resolve transfer review | Admin | Admin | POST /assignment-transfer-reviews/:id/resolve | assignment_transfer_reviews, live enrolment | row keep/not_applicable | 13.x |
| Categories CRUD | Admin | Admin | /assignment-categories* | assignment_categories | create/update/soft-retire | 14.x |
| Settings read/update | Admin | Admin | /assignment-settings | assignment_settings | policy singleton | 14.x |
| Notification rules read/update | Admin | Admin | /assignment-notification-rules* | assignment_notification_rules | one row per event | 14.x |
| Dashboard/analytics | Admin | Admin | /assignment-statistics/* | derived aggregates | none | 15.x |
| File download | All | scoped per audience | GET .../assignment-files/:fileId | assignment_files, scan status | none | 16.x |
| File rescan | Admin | Admin | POST /assignment-files/:fileId/rescan | assignment_files.scan_status='failed' | scan_status='pending', re-enqueue | 16.x |
| Publish-due sweep | Worker | System | Cron, every minute | assignments.status='scheduled', due | publish or revert-to-draft | Backend §9 |
| Reminder sweep | Worker | System | Cron, every 15 min | due windows, assignment_reminder_dispatches | one dispatch row + notifications | Backend §9 |
| Roster sync sweep | Worker | System | Cron, every 5 min | live targets, enrolments | roster add/move/not_applicable/revive | Backend §9 |
| File-scan recovery sweep | Worker | System | Cron, every 10 min | stuck pending scans | re-enqueue or mark failed | Backend §9 |
| File scan | Worker | System | assignment_file_scan queue, on upload | staged bytes | scan_status, storage bytes committed or purged | Backend §9 |
A teacher opens the create form, picks a subject and one or more of their own class-subjects, writes instructions, sets a due date and submission rules, and publishes — either immediately or on a schedule. Publishing materialises one row per enrolled pupil and notifies them.
- The teacher is acting as the role named
teacher and has a live staff row (assertTeacherStaffRow).
- Every targeted
class_subject_id is active, belongs to an active class of the chosen academic session, teaches the chosen subject, and is taught by the teacher (teachesForCreate).
dueAt is far enough in the future to publish now (ASSIGNMENT_PUBLISH_MIN_LEAD_MS = 5 minutes) or, for a schedule, publishAt is at least a minute away and leaves at least publish-lead-plus-slack room before dueAt.
- At least one of
allowTextResponse / allowFileUpload is true.
| Step | Actor/System | Action | Result | Source |
|---|
| 1 | Teacher | POST /api/mobile/teacher/assignments with title, instructions, due date, target class-subjects | assignments row created, status='draft', assignment_classes rows for each target | AssignmentWriteService.create |
| 2 | Teacher | POST :id/publish with mode: "now" | Publish guards re-checked; status='published', published_at set | AssignmentLifecycleService.publish |
| 3 | Backend | Materialise roster | One assignment_students row per pupil actively enrolled in a live target class | AssignmentRosterService.materialise |
| 4 | Backend | Write history, notify | published history entry; published notification to the new rows' pupils and their guardians | AssignmentHistoryWriter, AssignmentNotifier |
| 5 | Backend | Return detail | Full AssignmentDetailDto | AssignmentDetailService |
| Branch | Condition | Behavior | Error/Result |
|---|
| Class not taught | A target class-subject's teacher_id is not the caller | Reject before insert | 404 ASSIGNMENT_CLASS_NOT_TAUGHT |
| No classes selected | Empty classSubjectIds | Publish is refused; create accepts an empty list | 422 ASSIGNMENT_NO_CLASSES at publish; 409 ASSIGNMENT_NO_CLASSES when an update would remove every live class |
| Too many classes | More than ASSIGNMENT_MAX_CLASSES (60) | Reject before any DB read | 422 ASSIGNMENT_AUDIENCE_TOO_LARGE |
| Audience too large | Live pupil count across targets exceeds ASSIGNMENT_MAX_AUDIENCE (3000) | Publish guard rejects | 422 ASSIGNMENT_AUDIENCE_TOO_LARGE |
| Due too soon | dueAt less than 5 minutes away | Reject publish-now | 400 ASSIGNMENT_DUE_IN_PAST |
| Idempotent create | Same clientRequestId from the same creator replayed | Returns the existing draft, 200 instead of 201 | created:false in the response |
| Already published | publish called again on an already-published assignment | No-op, returns current detail — checked before version, so a double-click succeeds even with a stale version in hand | 200, no state change |
| Version conflict | version sent does not match the row's current version, on an action that WOULD change something | Reject | 409 ASSIGNMENT_VERSION_CONFLICT |
A pupil opens an assigned piece of work, writes a response and/or attaches files, then submits. The system decides whether the submission is late and whether a further submission is allowed at all.
- The assignment is
published, not archived, and its class is still live (or the pupil's assignment_students.state is returned with an open resubmission window).
- The pupil has a live
assignment_students row for the assignment (created by roster materialisation).
- At least one non-empty content item exists at submit time: non-blank text, or a non-rejected file.
| Step | Actor/System | Action | Result | Source |
|---|
| 1 | Pupil | PUT .../submission to save draft text | A draft attempt is created or updated; row moves assigned → in_progress on first write | AssignmentSubmissionService.saveDraft, AssignmentDraftService.lockRowAndDraft |
| 2 | Pupil | POST .../submission/files to attach a file | File row pending scan; queued to assignment_file_scan | AssignmentFileUploadService.uploadSubmissionFile |
| 3 | Worker | Scan runs | scan_status becomes clean, rejected or failed | AssignmentFileScanRunner / AssignmentsQueueProcessor sibling processor |
| 4 | Pupil | POST .../submission/submit with clientSubmissionId | Draft (or a fresh attempt) becomes submitted; row state='submitted', attemptCount incremented, isLate computed | AssignmentSubmissionService.submit |
| 5 | Backend | Notify | submitted notification to pupil, guardians (low priority) and teacher | AssignmentNotifier |
| Branch | Condition | Behavior | Error/Result |
|---|
| Empty submission | No text and no non-rejected file | Reject | 400 ASSIGNMENT_SUBMISSION_EMPTY |
| Rejected file still attached | A draft file's scan_status='rejected' | Reject; pupil must remove it first | 409 ASSIGNMENT_SUBMISSION_HAS_REJECTED_FILES |
| Too many files | Draft file count exceeds assignments.max_files | Reject | 409 ASSIGNMENT_SUBMISSION_FILE_LIMIT_EXCEEDED |
| Files when not allowed | allowFileUpload=false but files attached | Reject | 409 ASSIGNMENT_FILES_NOT_ALLOWED |
| Already completed | assignment_students.state='completed' | Reject | 409 ASSIGNMENT_ALREADY_COMPLETED |
| Pending review | state='under_review' | Reject — nothing to resubmit onto | 409 ASSIGNMENT_NOT_ACCEPTING |
| Resubmission window closed | state='submitted' and no attempts remain and no content-rejected pending attempt | Reject | 409 ASSIGNMENT_RESUBMISSION_NOT_ALLOWED |
| Resubmission window closed (never submitted) | Past due, late not allowed, no resubmit deadline | Reject | 409 ASSIGNMENT_NOT_ACCEPTING |
| Duplicate submit | Same clientSubmissionId replayed and it is still the latest non-draft attempt | No-op success (idempotent replay) | Returns current detail, no duplicate attempt |
| Stale replay | Same clientSubmissionId but a newer attempt now exists | Reject | 409 ASSIGNMENT_VERSION_CONFLICT |
| Content-rejected pending attempt | The pupil's only submitted content was files that all got rejected, and no replacement text exists | Treated as effectively unsubmitted — allowed to submit again even without spare attempts | supersedeAllowed special case |
| Class removed mid-submission | The pupil's target class was removed from the assignment | classLive=false; submission window closes | 409 ASSIGNMENT_NOT_ACCEPTING |
A teacher (or administrator) opens the submissions roster for an assignment, opens one pupil's record, and either saves in-progress marking (under_review, held from the pupil), completes it (releasing the result per the assignment's marks-release policy), or returns it for correction.
- The assignment is
published or closed (returning requires strictly published), and not archived.
- The caller may review the row: admin always; teacher when they manage the assignment OR teach the class-subject the row's
assignment_class_id targets (even if that class has since been removed).
- The attempt being acted on is the pupil's latest non-draft attempt and in an allowed status for the action.
| Step | Actor/System | Action | Result | Source |
|---|
| 1 | Teacher | GET :id/submissions | Roster of pupil rows, filterable/sortable | AssignmentRosterReadService.listRoster |
| 2 | Teacher | PUT :id/submissions/:rowId/review | Marks/grade/feedback saved on the attempt; attempt/row → under_review; nothing visible to the pupil yet | AssignmentReviewService.saveReview |
| 3 | Teacher | POST :id/submissions/:rowId/complete | Attempt/row → completed; result visible immediately (and a marked notification fires) when marksRelease='on_review', or when marks have already been released once for this assignment | AssignmentReviewService.completeReview |
| 3' | Teacher | POST :id/submissions/:rowId/return | Attempt → returned; row → returned, resubmit_due_at set or window reopened; a returned notification fires | AssignmentReviewService.returnSubmission |
| Branch | Condition | Behavior | Error/Result |
|---|
| Not the latest attempt | A newer attempt superseded the one referenced | Reject | 409 ASSIGNMENT_ATTEMPT_NOT_REVIEWABLE |
| Marks required | isGraded=true and no marks supplied | Reject | 400 ASSIGNMENT_MARKS_INVALID |
| Marks out of range | marks outside [0, maxMarks] | Reject | 400 ASSIGNMENT_MARKS_INVALID |
| Marks on ungraded assignment | isGraded=false but marks supplied | Reject | 400 ASSIGNMENT_MARKS_INVALID |
| Return without feedback | feedbackHtml carries no text (an empty editor's <p></p> counts as empty) | Reject | 400 ASSIGNMENT_FEEDBACK_REQUIRED |
| Return not allowed | resubmissionPolicy='none' | Reject | 409 ASSIGNMENT_ATTEMPT_NOT_REVIEWABLE |
| Return budget exhausted | resubmissionPolicy not unlimited and extra_attempts_granted >= 50 | Reject | 409 ASSIGNMENT_RESUBMISSION_NOT_ALLOWED |
| Resubmit deadline required | No resubmitDueAt supplied and the base due window is already closed | Reject | 400 ASSIGNMENT_RESUBMIT_DUE_REQUIRED |
| Resubmit deadline in the past | resubmitDueAt <= now | Reject | 400 ASSIGNMENT_RESUBMIT_DUE_IN_PAST |
| Assignment not open for review | status not in published/closed, or archived | Reject | 409 ASSIGNMENT_NOT_EDITABLE |
Terminal state. Requires AssignmentCancel_UPDATE for an admin, or assignment_settings.teachers_can_cancel = true for a teacher. A non-empty reason is required and stored. Clears any pending schedule and closes-state, sets status='cancelled', and notifies every pupil and (at low priority) every guardian.
Archiving is only valid from closed or cancelled and freezes the row from further lifecycle actions (ASSIGNMENT_NOT_EDITABLE on any attempt to publish/close/reopen/cancel a non-null archived_at). Unarchiving clears the flag with no other state change. Both are idempotent no-ops when already in the target state.
PUT /api/assignments/:id/teacher changes assignments.teacher_id. Reassigning to a teacher who does not teach any live target class is accepted but surfaced as a warning by the write path (the FK's ON DELETE SET NULL and the manage predicate mean a mismatched teacher simply cannot manage the assignment through the teacher portal afterward).
When the roster sweep cannot automatically decide what happens to a pupil who left a target class (the school's transfer_mode='flag_for_admin', or an ambiguous move), it opens an assignment_transfer_reviews row. An administrator resolves it keep (row stays, re-targeted to the new class if the pupil is still in the assignment's classes) or not_applicable (row is excluded from the assignment). Resolving after the pupil's enrolment has changed again (moved back into a live target, or lost their active enrolment) is refused with 409 ASSIGNMENT_TRANSFER_NO_LONGER_APPLICABLE; before that response the stale review row is deleted (and, for a pupil with no active enrolment, the row is marked not_applicable with reason withdrawn) and the change is committed, so it is not left open forever. Resolving not_applicable on a pupil who already submitted work is refused with 409 ASSIGNMENT_TRANSFER_ALREADY_SUBMITTED.
Categories (assignment_categories) are school-defined, not an enum, so a school adds its own without a migration. AssignmentSettings_CREATE/_UPDATE/_DELETE gate create/update/delete; name and code uniqueness are partial on is_active, so retiring a category frees its name and code for reuse. Deleting a category still referenced by an assignment is refused (409 ASSIGNMENT_CATEGORY_IN_USE).
assignment_settings is a singleton (id=1). It governs file limits, default submission types, the maximum submission text length, the transfer_mode policy, whether a non-school-day due date warns, whether an assignment requires an assigned teacher, whether teachers may cancel, and the reminder hour. assignment_notification_rules has one row per event and can only subtract recipients/channels from the module default — it can disable an event, or narrow notify_student/notify_guardians/notify_teacher, never widen beyond what AssignmentNotifier would otherwise send.
POST /api/assignment-files/:fileId/rescan (AssignmentSettings_UPDATE) re-queues a file whose scan_status='failed' for another pass, resetting it to pending. Refused with 409 ASSIGNMENT_FILE_NOT_RESCANNABLE for any other status (a clean, rejected or already-pending file is not eligible).
| Entity | From | Event/Action | To | Guard Condition | Side Effects |
|---|
assignments | draft | Publish now | published | Due ≥ 5 min away; publish guards pass | Roster materialised, published notification |
assignments | draft | Schedule | scheduled | publishAt ≥ 1 min away, leaves room before due; publish guards pass | scheduled history entry |
assignments | scheduled | Unschedule | draft | — | unscheduled history entry |
assignments | scheduled | Sweep fires at publishAt | published | Guards re-checked | Roster materialised; on guard failure, reverted to draft with publish_failed history + notification to the creator |
assignments | published | Close | closed | — | closed_at set |
assignments | closed | Reopen | published | Not archived | closed_at cleared |
assignments | scheduled/published/closed | Cancel | cancelled | Teacher requires teachers_can_cancel | Notifies pupils + guardians |
assignments | closed/cancelled | Archive | (same status) + archived_at set | — | Freezes further lifecycle actions |
assignments | (archived) | Unarchive | archived_at cleared | — | — |
| Entity | From | Event/Action | To | Guard Condition | Side Effects |
|---|
assignment_students | (none) | Roster materialise | assigned | Live enrolment in a live target class | New row, added_to_roster history |
assignment_students | assigned | Save first draft | in_progress | — | Draft attempt created |
assignment_students | assigned/in_progress | Submit | submitted | Window open, non-empty content | Attempt submitted, counters updated |
assignment_students | submitted | Teacher saves review | under_review | Latest attempt | — |
assignment_students | submitted/under_review | Complete review | completed | — | final_marks/final_grade set; result_published_at per policy |
assignment_students | submitted/under_review/completed | Return | returned | Assignment strictly published; resubmission allowed | Result cleared, resubmit_due_at set/cleared |
assignment_students | returned | Resubmit | submitted | Within resubmit window | New/updated attempt |
assignment_students | assigned/in_progress | Class removed / withdrawn / transferred | not_applicable | — | Excluded from active work |
assignment_students | not_applicable | Pupil re-enters a live target | assigned or in_progress | Has a draft or not | revived history |
draft → submitted → under_review → completed, with returned reachable from submitted/under_review/completed, and superseded applied to any prior submitted/under_review attempt when a fresh one is accepted. At most one draft and one pending (submitted/under_review) attempt exist per pupil row at a time (assignment_attempts_one_draft_unique, assignment_attempts_one_pending_review_unique).
| Flow | DB Writes | Cache Effects | Jobs | Realtime | Analytics | Notifications |
|---|
| Create | assignments, assignment_classes | none | none | none | none | none |
| Publish now | assignments, assignment_students (insert), assignment_history | none | none | none | none | published to new rows' pupils + guardians |
| Schedule | assignments, assignment_history | none | none | none | none | none |
| Publish-due sweep | assignments, assignment_students, assignment_history | none | Job: assignment.publish_due | none | none | published or publish_failed |
| Update | assignments, assignment_classes (add/remove), assignment_students (not_applicable on removal), assignment_history | none | none | none | none | updated when the pupil-visible shape changes |
| Cancel | assignments, assignment_history | none | none | none | none | cancelled |
| Attachment upload | assignment_files, storage staging | none | Job: assignment.scan_file | none | none | none directly (rejection later notifies) |
| Save draft | assignment_submission_attempts | none | none | none | none | none |
| Submit | assignment_submission_attempts, assignment_students, assignment_history | none | none | none | none | submitted to pupil, guardians, teacher |
| File scan | assignment_files.scan_status, storage commit/purge | none | consumes assignment.scan_file; may notify | none | none | file_rejected on a rejected verdict |
| Save review | assignment_submission_attempts, assignment_students, assignment_history | none | none | none | none | none (held) |
| Complete review | same + result_published_at | none | none | none | none | marked when visible on completion |
| Return | same | none | none | none | none | returned |
| Release marks | assignment_students.result_published_at (bulk) | none | none | none | none | marked per released row |
| Roster sync sweep | assignment_students, assignment_transfer_reviews, assignment_history | none | Job: assignment.roster_sync | none | none | published for newly-added rows |
| Reminder sweep | assignment_reminder_dispatches | none | Job: assignment.reminder_sweep | none | none | reminder_3d/reminder_1d/due_today/overdue |
| Scan-recovery sweep | assignment_files.scan_status/scan_attempts | none | Job: assignment.scan_recovery, re-enqueues assignment.scan_file | none | none | none directly |
| Scenario | Trigger | User/System Experience | Recovery | Source |
|---|
| Rate limited | Too many upload/write requests from one IP | 429 response | Retry after the window | IpThrottlerGuard with RATE_LIMITS.UPLOAD_WRITE/CUSTOMER_WRITE |
| Version conflict | Concurrent edit or stale version sent | 409 with the conflicting resource's code (ASSIGNMENT_VERSION_CONFLICT, ASSIGNMENT_DRAFT_VERSION_CONFLICT) | Reload and retry | Optimistic-concurrency triggers + matchesVersion |
| Scheduled publish fails its guards | Class removed, teacher lost the class, audience now empty, etc. | Assignment silently reverts to draft; creator is notified | Fix the issue, republish or reschedule | AssignmentPublisher.publishScheduled |
| File fails structural scan | Malformed office file, embedded macro, archive bomb, etc. | Pupil/teacher sees the file rejected, generic security message | Remove and re-upload a clean file | structural-file-scanner.ts, ASSIGNMENT_FILE_SECURITY_MESSAGE |
Scan stuck pending | Worker crash, queue delay | File shows as still scanning past ASSIGNMENT_SCAN_STALE_MS (15 min) | The 10-minute recovery sweep re-enqueues or marks failed after ASSIGNMENT_SCAN_ATTEMPTS (3) | assignment-file-scan-recovery.ts |
| Roster reconciliation contention | Two sweeps target the same assignment at once | The second sweep is skipped (pg_try_advisory_xact_lock fails) | Picked up on the next 5-minute tick | AssignmentRosterServiceImpl.reconcile |
| Transfer review stale by resolution time | Pupil's enrolment changed again between flagging and resolving | Resolve is refused, review auto-closes | Admin re-checks the pupil's current state; a fresh review opens if still ambiguous | AssignmentRosterServiceImpl.resolveDecision |
| Academic session locked | Any write to a locked year's assignment, its classes, roster, submission attempts, links, or a file insert/delete | 409 ACADEMIC_SESSION_LOCKED: "That academic year is locked, so its records can no longer change." | An administrator unlocks the year to make the correction | packages/db/src/migrations/0038_year_lock_records.sql |
| Pupil has graduated | Draft save, file upload/removal, or submit by a pupil whose graduate access is grace or ended | 403 STUDENT_GRADUATED_READ_ONLY: nothing can be submitted for them | None — the portal stays read-only for that pupil until an administrator reopens access | student-assignment-submission-portal.controller.ts, graduate-access.query.ts |
Covered above: actor capability (§3), high-level create→publish→submit→review flow (§5), sequence diagrams per major flow (§5), state machines for assignment status and pupil row state (§7), data side-effect table (§9), error branches for cancel (§6.1) and submit/review edge cases (§5.2, §5.3).
| Feature | Minor Behavior | Actor | Trigger | User/System Result | Backend Side Effect | Source |
|---|
| Create | Idempotent replay by clientRequestId | Admin/teacher | Duplicate POST with the same key | 200 with created:false, same assignment returned | No second row inserted | assignments_client_request_unique |
| Create | Instructions HTML sanitised | Admin/teacher | Any create/update with instructionsHtml | Stored HTML is stripped of disallowed tags; a plain-text mirror is derived | instructions_text kept in sync | assignment-html.util.ts |
| Audience preview | Excludes pupils already excluded (not_applicable) | Admin/teacher | POST /assignments/preview | Count reflects only live, eligible pupils | Read-only | AssignmentPreviewService |
| Publish | Teacher requirement | Admin/teacher | Publish while require_teacher is on and no teacher is assigned | 422 ASSIGNMENT_TEACHER_REQUIRED; an admin-chosen teacherId that is not a live staff row is refused earlier, at create or reassign, with 404 ASSIGNMENT_TEACHER_NOT_FOUND | Publish blocked until a teacher is assigned | assignment-publish-guards.util.ts:90-95, assignment-write.service.ts:169, :346 |
| Update | Non-school-day due date | Admin/teacher | warnNonSchoolDayDue=true and the new dueAt falls on a day the school calendar marks non-teaching | Non-blocking warning surfaced by the write service | none | assignment_settings.warn_non_school_day_due |
| Update | Due-date change banner | Any viewer | dueAt changed after publish | previousDueAt/dueChangedAt retained and surfaced for ASSIGNMENT_DUE_CHANGED_BANNER_MS (7 days) | updated notification with old/new due date | assignments.previous_due_at, constants |
| Attachment upload | Duplicate upload replay | Admin/teacher | Same clientUploadId from the same uploader | Returns the existing file row instead of duplicating | assignment_files_client_upload_unique | assignment-file-upload-attachment.ts |
| Attachment upload | Pending-scan throttle | Any uploader | More than ASSIGNMENT_MAX_PENDING_SCANS_PER_USER (20) files awaiting scan | Reject | 429 ASSIGNMENT_TOO_MANY_PENDING_FILES | files/assignment-file-upload.validation.ts:63-74 |
| Submission | Empty draft save allowed | Pupil | Save with no text and no files yet | Draft row exists with null text | none | AssignmentDraftService |
| Submission | Late flag | Pupil | Submit after dueAt under latePolicy='allowed' or within the final deadline | isLate=true recorded, surfaced to reviewer | assignment_students.last_is_late | submission-window.util.ts |
| Submission | Content-rejected resubmit | Pupil | Every file of the last submission was rejected and no text was given | Treated as not-yet-submitted for resubmission purposes | pendingContentRejectedSql | assignment-status.sql.ts |
| Review | History-only save | Teacher/admin | saveReview | No notification (nothing visible to the pupil) | review_saved history entry only | AssignmentReviewService.saveReview |
| Review | Marks release timing | Teacher/admin | marksRelease='manual' and no marks have ever been released for this assignment | Result held until an explicit release-marks call | result_published_at stays null on complete | assignment-review-release.service.ts |
| Review | Marks release stays open after one release | Teacher/admin | marksRelease='manual' but the assignment already has a marksReleasedAt from an earlier bulk release | Every later complete on that assignment publishes its result immediately | result_published_at set on complete, no further release-marks call needed | assignment-review.service.ts |
| Transfer | Automatic keep | System | transfer_mode='keep' | Row stays on its old target with no review opened | none | assignment-roster-decisions.service.ts |
| Transfer | Automatic follow | System | transfer_mode='follow_class' and the new class is already a live target | Row silently re-targeted | moved_class history | same |
| Transfer | Automatic self-resolve | System | A review sits open while its pupil's row leaves assigned/in_progress (they submitted, or were reviewed) | Review auto-resolves keep; the submitted work is never overwritten | history reason: "already_submitted", decidedAutomatically: true | assignment-roster-decisions.service.ts |
| Transfer | Revival respects the window | System | A not_applicable row's pupil re-enters a live target class after the assignment's own submission window has already closed | Row stays not_applicable rather than reviving into a window the pupil can no longer submit against | none | assignment-roster.service.ts |
| Categories | Retire and reuse | Admin | Deactivate a category then create a new one with the same name/code | Allowed — partial unique indexes are scoped to is_active | assignment_categories_name_unique/_code_unique | schema |
| Files | Quarantine retention | System | A file is rejected | Bytes kept (never served) for ASSIGNMENT_QUARANTINE_RETENTION_MS (30 days), then purged | storage_key/purged_at set together | assignment-file-purge.service.ts |
| Notifications | Teacher-applicable events only | Admin | Attempt to enable notify_teacher on an event other than submitted/overdue | Rejected in the service before the CHECK constraint is reached | 400 ASSIGNMENT_NOTIFICATION_RULE_INVALID (an unknown event is 404 with the same code) | assignment-notification-rules.service.ts:66-82, assignment_notification_rules_teacher_applicable |
| Rule | Business Reason | Actor Impact | Enforced In | API Impact | Backend Impact | Tests |
|---|
| A teacher manages an assignment only when they own its teacher slot AND teach at least one live target, or every live target is theirs | Prevents one teacher silently editing another's homework while co-teaching classes | Teacher sees 404 (never 403) for out-of-scope assignments | AssignmentAccessServiceImpl.manageExprOverAssignments | All manage-gated routes | Row-level WHERE clause | assignment-authoring.integration.spec.ts |
| Publishing needs a live target with a non-empty, bounded audience | An assignment nobody can see is a defect, not a draft | Publish blocked with a clear reason | assignment-publish-guards.util.ts | POST :id/publish | ASSIGNMENT_NO_CLASSES/ASSIGNMENT_AUDIENCE_TOO_LARGE | integration specs |
| A soft-deleted or record-inactive pupil is invisible to every read | Data integrity: a withdrawn pupil's homework should not clutter live rosters | Pupil disappears from lists, not 403 | visibleAssignment/roster queries filter record_status='active', deleted_at IS NULL | list/detail routes | WHERE clause | assignment-display-status-parity.integration.spec.ts |
| A guardian never sees a submission file | Submission content is the pupil's own work; a guardian sees outcomes, not raw files | 404 on submission-file download attempts by a guardian | AssignmentFileDownloadService | GET guardian/.../assignment-files/:fileId | kind check | file download service |
| Staff never see a pupil's draft file | A teacher/admin reviews handed-in work, not a pupil's in-progress composition | 404 when a staff download targets a file on a still-draft attempt | AssignmentFileDownloadService | GET .../assignment-files/:fileId (admin/teacher) | attempt-status filter | file download service |
| Marks release policy decides when a result becomes visible | Some schools want results held for moderation before parents see them | Pupil/guardian see "reviewed" without a mark until released | marksRelease on assignments, enforced in completeReview/releaseMarks | POST :id/release-marks | result_published_at timing | review integration spec |
| History is append-only | An audit trail that can be edited is not an audit trail | None visible | assignment_history_append_only trigger | GET :id/history | DB-level refusal | migration 0028, roster/authoring specs |
| Notification rules can only subtract | Prevents an administrator accidentally emailing every parent about something the school intentionally keeps low-key | Fewer notifications than the module default, never more | AssignmentNotifier.notify reads the rule before sending | none directly | rule read gates every send | notifier tests |
| Teachers cancelling is a school-level toggle | Some schools want cancellation restricted to admins even though teachers own their assignments | Teacher gets 403 ASSIGNMENT_CANCEL_NOT_ALLOWED when disabled | assignment_settings.teachers_can_cancel | POST :id/cancel (teacher route) | settings lookup inside the transaction | lifecycle spec |
| Product Decision | User Benefit | Engineering Benefit | Alternative | Tradeoff | Risk |
|---|
| Status is stored; "active"/"due today"/"overdue" are derived | The stored lifecycle never goes stale | One SQL expression (assignment-status.sql.ts) is the single source, reused by list, stats and portal reads | Store a computed display status | Slightly heavier queries | Low — indexed on (session, status, due_at) |
| Notification rules subtract only | Predictable, safe defaults; a school cannot accidentally over-notify by editing one row | Simple mental model: module default ∩ rule ∩ per-user preference | Rules could add recipients too | Less flexible for a school wanting to widen a specific event | Low, by design |
| Soft transfer resolution (keep/follow/flag) instead of always deleting the row | A pupil who changes class does not lose their homework history | One reconciliation sweep handles the common cases automatically | Always require manual resolution | More sweep complexity | Contained by the advisory lock and idempotent predicates |
| File quarantine retention (30 days) instead of immediate purge | Gives a security reviewer time to inspect a rejected file if needed | Simple storage lifecycle | Purge immediately | Slightly more storage held | Bounded by the sweep and retention constant |
assignment_files splits attachment vs submission by a CHECK, not two tables | One download/scan pipeline serves both kinds | Less code duplication | Separate tables | A stricter split would be more self-documenting | Low — the CHECK is exhaustive |
| Flow | Edge Case | Trigger | Expected Behavior | User/System Feedback | Source |
|---|
| Publish | Empty state | No live target classes | Blocked | 422 ASSIGNMENT_NO_CLASSES | publish guards |
| Roster materialise | First use | First publish of a brand-new assignment | Every actively-enrolled pupil of every live target class gets a row | added_to_roster history per row | roster service |
| Submit | Last attempt | attemptCount equals the resolved max allowed | Further submit blocked unless a content-rejected exception applies | 409 ASSIGNMENT_RESUBMISSION_NOT_ALLOWED | supersedeAllowedSql |
| Submit | Duplicate action | Same clientSubmissionId sent twice | Second call is a no-op | 200, same result | idempotency check |
| Roster sync | Concurrent action | Two sweep ticks for the same assignment overlap | Second is skipped, not queued | {skipped:true} | advisory lock |
| Return | Expired state | Resubmission window's base due date has already passed and no explicit deadline given | Reject, force an explicit deadline | 400 ASSIGNMENT_RESUBMIT_DUE_REQUIRED | review service |
| Files | Permission mismatch | A teacher who does not teach the row's class tries to open a submission file | Refused | 404 (never 403) | reviewRow scope |
| Guardian read | Guest/guardian limitation | Guardian requests a submission file | Refused regardless of ownership | 404 | download service |
| Publish | Missing dependency | A teacher stops teaching one of the target classes between creating and publishing | Publish guard re-checks teaching and refuses | 404 ASSIGNMENT_CLASS_NOT_TAUGHT (teacher publish only) | assignment-publish-guards.util.ts:73-83 |
| File scan | Cache stale/miss | N/A — no cache layer in this module | — | — | — |
| Scan | Queue failure | Scan job repeatedly errors | Recovery sweep retries up to ASSIGNMENT_SCAN_ATTEMPTS then marks failed | file shows failed, admin can rescan | scan-recovery |
| List | Unsupported filter/sort | An unrecognised sort value on the submissions roster | Falls back to name | No error | resolveSubmissionRosterSort |
| Flow | Reads | Writes | Cache | Jobs/Events | Response Fields |
|---|
| Submit | assignments, assignment_students, assignment_submission_attempts, assignment_files | assignment_submission_attempts, assignment_students, assignment_history | none | submitted notification | full detail incl. state, attempt history |
| Review complete | assignments, assignment_students, assignment_submission_attempts | same tables + result_published_at | none | marked notification (conditional) | finalMarks, finalGrade, resultPublishedAt |
| Publish-due sweep | assignments (status='scheduled') | assignments, assignment_students, assignment_history | none | published/publish_failed notification | n/a (background) |