Skoolsewa - School Management System Docs
Developer ResourcesAssignments

Assignments Backend Documentation

Backend architecture, data model, services, cache, queues, runtime rules, and operational behavior for Assignments.

Assignments Backend Documentation

1. Documentation Evidence

AreaFiles InspectedVerified Details
Module wiringapps/api/src/modules/assignments/**/*.module.tsProviders, exports, controller composition across authoring/submissions/files/settings/stats/workers
Controllersapps/api/src/modules/assignments/**/*.controller.ts (22 controllers)Route ownership, guard chains
Servicesapps/api/src/modules/assignments/**/*.service.ts, *.util.tsBusiness logic, transactions, side effects
Schemapackages/db/src/schema/school/assignments.ts12 tables, 10 enums, every constraint and index
Migrationspackages/db/src/migrations/0026_class_subjects_assignment_target_unique.sql, 0027_assignment_module.sql, 0028_assignment_module_triggers_and_seeds.sqlDDL, triggers, seed rows
Jobspackages/jobs/src/index.ts (QueueName.ASSIGNMENTS, QueueName.ASSIGNMENT_FILE_SCAN, AssignmentJob, AssignmentFileScanJob)Queue contracts and payload types
Notification templatesapps/api/src/modules/notification/templates/assignment-templates.tsKinds, priority, dedupe policy, channels
Permissionspackages/db/src/authorization/permission-catalog.ts, packages/db/src/seed/seed-auth.tsCodes and role grants

2. Backend Scope and Boundaries

Owns

  • Assignment authoring: create, update, delete (draft/scheduled only), reassign teacher, options catalogue, audience preview, per-class statistics, event history.
  • Lifecycle: publish (now/schedule), unschedule, close, reopen, cancel, archive/unarchive — for both the admin surface and the teacher portal, sharing one service layer.
  • File attachments (teacher-facing) and submission files (pupil-facing): upload, structural/virus-style scan, download, purge, rescan.
  • Roster: materialising per-pupil rows on publish, keeping them in sync as enrolments move (five-minute sweep), and the transfer-review queue for cases the sweep cannot decide automatically.
  • Submissions: pupil draft save, file attach/remove, submit, with attempt history.
  • Review: save (hold), complete (mark, release per policy), return for correction; bulk marks release.
  • Settings: the module policy singleton, categories, per-event notification rules.
  • Statistics: school-wide dashboard/analytics for admins, a teacher's own dashboard tile set.
  • Time-driven work: publish-due sweep, reminder sweep (3-day/1-day/due-today/overdue), file-scan recovery sweep.
  • Notifications: one AssignmentNotifier that resolves recipients from live rows and rule config, and calls the shared NotificationService.

Does Not Own

  • Class, class-subject, subject, academic session and student-enrolment data (academic-structure, people modules) — assignments reference them but never write them.
  • Storage bytes themselves (@skoolsewa/storage's StorageManager) — the module owns metadata rows and scan verdicts, not the storage driver.
  • The outbox and BullMQ infrastructure (OutboxService, @skoolsewa/jobs) — the module is a producer/consumer, not the owner.
  • Notification delivery mechanics (channel senders, per-user preference resolution) — NotificationService and its channel drivers own that; this module supplies templates and recipients.
  • Authentication and role/permission evaluation (JwtAuthGuard, RoleGuard) — the module supplies @Permissions() codes and, for portal routes, its own audience assertions.

Source of Truth

ConcernSource of TruthNotes
Assignment lifecycle stateassignments.status (stored)"Active"/"due today"/"overdue" are DERIVED in SQL/TS, never stored — a stored derived value goes stale the instant time passes.
Pupil work stateassignment_students.state and assignment_submission_attempts.statusThe row is the summary; the attempt is the history.
Roster membershipassignment_students rows, kept live by the roster sweep and lifecycle actionsNever recomputed on read for the detail/list routes — it is materialised.
File safetyassignment_files.scan_statusA file is downloadable only when clean; nothing else, including an admin bypass, exists.
Concurrency tokenversion (bigint, bumped by bump_row_version() triggers) on assignments, assignment_students, assignment_submission_attempts, assignment_settings, assignment_categories, assignment_notification_rulesClients send back the last-seen version; a mismatch is 409.
Which recipients/channels an event usesassignment_notification_rules row for that event, intersected with the per-user preference resolverThe rule can only subtract from the module default.

3. Module Composition

ModuleTypePathControllersProvidersExportsResponsibility
AssignmentsSharedModuleLeaf (shared)shared/assignments-shared.module.tsNoneAssignmentAudienceResolver, AssignmentAccessService, AssignmentRosterService, AssignmentHistoryWriter, AssignmentNotifier, AssignmentDraftService, AssignmentRosterDecisionsServiceAll of the above (as abstract-class tokens)The cross-seam contract every other submodule depends on.
AssignmentAuthoringCoreModuleLeafauthoring/core/assignment-authoring-core.module.tsNoneAssignmentListService, AssignmentDetailService, AssignmentOptionsService, AssignmentPreviewService, AssignmentWriteService, AssignmentUpdateService, AssignmentLifecycleService, AssignmentClassStatsHistoryService, AssignmentPublisher (impl)Same servicesAssignment CRUD, lifecycle, options/preview, class stats + history.
AssignmentsAdminModuleLeafauthoring/admin/assignments-admin.module.tsAssignmentsController, AssignmentLifecycleController, AssignmentAttachmentsController——Admin HTTP surface for authoring.
TeacherAssignmentsPortalModuleLeafauthoring/portal/teacher-assignments-portal.module.tsTeacherAssignmentsPortalController, TeacherAssignmentActionsPortalController——Teacher-facing authoring/lifecycle surface.
AssignmentSubmissionsCoreModuleLeafsubmissions/core/assignment-submissions-core.module.tsNoneAssignmentSubmissionService, AssignmentReviewService, AssignmentReviewReleaseService (impl of AssignmentReviewOperations), AssignmentRosterReadService, AssignmentSubmissionMapperSamePupil submit path and teacher/admin review path.
AssignmentSubmissionsAdminModuleLeafsubmissions/admin/assignment-submissions-admin.module.tsAssignmentSubmissionsControllerAssignmentSubmissionsAdminGrantBuilder—Admin roster/review/release HTTP surface.
TeacherAssignmentReviewPortalModule (files: submissions/portal/)Leaf—TeacherAssignmentReviewPortalController——Teacher review HTTP surface.
AssignmentFilesModuleLeaffiles/assignment-files.module.tsNoneAssignmentFileUploadServiceImpl, AssignmentFileDownloadServiceImpl, AssignmentFileMapperImpl, AssignmentFilePurgeServiceImpl, AssignmentFileScanRunnerImplSame (as AssignmentFileUploadService etc. tokens)Upload, download, scan orchestration, purge.
AssignmentFilesAdminModuleLeaffiles/admin/assignment-files-admin.module.tsAssignmentFilesAdminController (class AssignmentFilesController)AssignmentFileRescanService—Admin file download + rescan.
AssignmentFilesPortalModuleLeaffiles/portal/assignment-files-portal.module.tsTeacherAssignmentFilesPortalController, StudentAssignmentFilesPortalController, GuardianAssignmentFilesPortalController——Portal file downloads.
AssignmentSettingsAdminModuleLeafsettings/admin/assignment-settings-admin.module.tsAssignmentSettingsController, AssignmentNotificationRulesControllerAssignmentSettingsService, AssignmentNotificationRulesService—Module policy and notification-rule admin.
AssignmentCategoriesAdminModuleLeafsettings/categories/assignment-categories-admin.module.tsAssignmentCategoriesControllerAssignmentCategoriesService—Category CRUD.
AssignmentTransferReviewsAdminModuleLeafsettings/transfers/assignment-transfer-reviews-admin.module.tsAssignmentTransferReviewsControllerAssignmentTransferReviewsService—Transfer-review list/resolve.
AssignmentStatsCoreModuleLeafstats/core/assignment-stats-core.module.tsNoneAssignmentAdminStatsService, AssignmentStatsService (impl of AssignmentStatsQueries)SameDashboard/analytics and the teacher dashboard tile query.
AssignmentStatsAdminModuleLeafstats/admin/assignment-stats-admin.module.tsAssignmentStatsController——Admin statistics HTTP surface.
StudentAssignmentsPortalModuleLeafportal/student/student-assignments-portal.module.tsStudentAssignmentsPortalController, StudentAssignmentSubmissionPortalControllerStudentAssignmentsPortalService, StudentAssignmentDetailServiceSamePupil read + submit surface.
GuardianAssignmentsPortalModuleLeafportal/guardian/guardian-assignments-portal.module.tsGuardianAssignmentsPortalControllerGuardianAssignmentsPortalService—Guardian read-only surface.
AssignmentWorkersModuleLeafworkers/assignment-workers.module.tsNoneAssignmentScheduler, AssignmentsQueueProcessor, AssignmentPublishSweepService, AssignmentReminderSweepService, AssignmentRosterSweepService—Crons + the one BullMQ processor on QueueName.ASSIGNMENTS. The file-scan processor lives with AssignmentFilesModule.

4. File and Directory Map

apps/api/src/modules/assignments/
  authoring/
    admin/            assignments.controller.ts, assignment-lifecycle.controller.ts, assignment-attachments.controller.ts
    core/              list/detail/options/preview/write/update/lifecycle/class-stats-history services, publish-guards, capabilities/merge-validation/list-sort utils, dto/
    portal/            teacher-assignments-portal.controller.ts, teacher-assignment-actions-portal.controller.ts, teacher-assignment-grant.ts
  submissions/
    core/              assignment-submission.service.ts, assignment-review.service.ts, assignment-review-release.service.ts, assignment-roster-read.service.ts, assignment-submission-mapper.util.ts, dto/
    admin/             assignment-submissions.controller.ts, assignment-submissions-admin-grant.util.ts
    portal/            teacher-assignment-review-portal.controller.ts
  files/
    admin/             assignment-files-admin.controller.ts, assignment-file-rescan.service.ts
    portal/             teacher/student/guardian download controllers
    scanning/           file-scanner.ts, image/office/pdf/rtf/text scanners, scan-worker-pool.ts, scan-process.entry.ts, structural-file-scanner.ts
    assignment-file.multer.ts, assignment-file-upload.service.ts, assignment-file-upload-attachment.ts, assignment-file-upload-submission.ts,
    assignment-file-download.service.ts, assignment-file-download-headers.ts, assignment-file.mapper.ts, assignment-file-purge.service.ts,
    assignment-file-scan-runner.service.ts, assignment-file-scan-staging.ts, assignment-file-scan-verdict.ts, assignment-file-scan-recovery.ts,
    assignment-file-temp-cleanup.interceptor.ts, assignment-image-reencode.ts
  settings/
    admin/             assignment-settings-admin.controller.ts, assignment-settings.service.ts, assignment-notification-rules-admin.controller.ts, assignment-notification-rules.service.ts, dto/
    categories/        assignment-categories-admin.controller.ts, assignment-categories.service.ts, dto/
    transfers/         assignment-transfer-reviews-admin.controller.ts, assignment-transfer-reviews.service.ts, dto/
  stats/
    admin/             assignment-stats-admin.module.ts, assignment-stats.controller.ts
    core/              assignment-admin-stats.service.ts, assignment-stats.service.ts, dto/
  portal/
    student/           student-assignments-portal.controller.ts, student-assignment-submission-portal.controller.ts, student-assignments-portal.service.ts, student-assignment-detail.service.ts, dto/
    guardian/          guardian-assignments-portal.controller.ts, guardian-assignments-portal.service.ts, dto/
  shared/
    assignment-access.service.ts, assignment-actor.ts, assignment-audience.resolver.ts, assignment.constants.ts,
    assignment-draft.service.ts, assignment-history.writer.ts, assignment-html.util.ts, assignment-labels.util.ts,
    assignment-notification-recipients.service.ts, assignment-notifier.service.ts, assignment-roster.service.ts,
    assignment-roster-decisions.service.ts, assignment-roster-lock.util.ts, assignment-pending-content.ts,
    assignment-row-facts.ts, assignment-services.contract.ts,
    assignments-shared.module.ts, assignment-status.sql.ts, dto/
  workers/
    assignment.scheduler.ts, assignment-publish-sweep.service.ts, assignment-reminder-sweep.service.ts,
    assignment-roster-sweep.service.ts, assignments-queue.processor.ts, assignment-file-scan.processor.ts, assignment-workers.module.ts
  __tests__/          integration specs and fixtures
FilePurposeKey ExportsNotes
shared/assignment-services.contract.tsCross-seam abstract-class tokens (contract)AssignmentAudienceResolver, AssignmentAccessService, AssignmentDraftService, AssignmentRosterService, AssignmentHistoryWriter, AssignmentNotifier, AssignmentReviewOperations, AssignmentStatsQueries, AssignmentPublisherDeclared as abstract classes so each doubles as its own Nest DI token.
shared/assignment-access.service.tsEvery SQL access predicateAssignmentAccessServiceImplBuilt directly rather than via PeopleAccessService.scopeFor, which fails open for scopeKind:"all" callers.
shared/assignment-actor.tsThe closed actor unionAssignmentActor, ResolvedStudentId, SYSTEM_HISTORY_ACTORResolvedStudentId is a branded type only the audience resolver can produce.
shared/assignment-roster.service.tsRoster creation/move/not-applicable/revivalAssignmentRosterServiceImplOwns the per-assignment advisory lock protocol.
shared/assignment-status.sql.tsSQL mirrors of the pure-TS window/status functionscanSubmitSql, and siblings for display statusUsed for bulk filtering/counting; the TS versions in apps/api/src/utils/assignment/ are for single-row DTO computation.
authoring/core/assignment-lifecycle.service.tsAll seven lifecycle transitionsAssignmentLifecycleServiceOne method per transition, all sharing lockManaged.
authoring/core/assignment-publish-guards.util.tsRe-checked guards at both publish time and sweep timeassertPublishGuardsThe single source of "is this assignment allowed to go live".
submissions/core/assignment-submission.service.tsPupil draft save + submitAssignmentSubmissionServiceOpens its own transactions; never returns a DTO (caller re-reads detail).
submissions/core/assignment-review.service.tsSave/complete/returnAssignmentReviewServicelockForReview binds attemptId to rowId before anything else runs.
files/scanning/structural-file-scanner.tsOrchestrates per-type structural scansStructuralFileScanner, runStructuralScanDispatches to pdf-scan.ts, office-ooxml-scan.ts, office-legacy-scan.ts, rtf-scan.ts, image-scan.ts, text-scan.ts.
files/scanning/scan-worker-pool.ts / scan-process.entry.tsRuns each scan in its own forked OS process, isolated from the API process—Deadline kill (ASSIGNMENT_SCAN_DEADLINE_MS) and an RSS watchdog (ASSIGNMENT_SCAN_PROCESS_MAX_RSS_MB).
workers/assignment.scheduler.tsFour crons, each enqueueing one sweep jobAssignmentSchedulerJob ids bucketed at the cron's own period via buildJobId.
workers/assignments-queue.processor.tsThe one processor on QueueName.ASSIGNMENTSAssignmentsQueueProcessorRecord<AssignmentJob, handler> — exhaustive by the type system.

5. Data Model

5.1 Schema Source

packages/db/src/schema/school/assignments.ts

One file holds all 12 tables and 10 enums for this domain.

5.2 Tables and Collections

assignment_settings

Singleton (id fixed at 1 by assignment_settings_singleton).

ColumnTypeNullableDefaultIndex/ConstraintNotes
idsmallintNo1PK, CHECK id = 1Singleton row.
max_file_size_mbsmallintNo10CHECK 1..25
max_attachmentssmallintNo5CHECK 1..20
attachment_file_typestext[]No14-type arrayCHECK ⊆ closed type list, cardinality >= 1
default_submission_max_filessmallintNo5CHECK 1..20
default_submission_file_typestext[]No7-type arrayCHECK ⊆ closed list
max_submission_text_charsintegerNo20000CHECK 1000..100000
transfer_modeassignment_transfer_modeNofollow_classenum keep/follow_class/flag_for_admin
warn_non_school_day_duebooleanNotrue
require_teacherbooleanNofalseGates a publish guard.
teachers_can_cancelbooleanNotrueGates the teacher cancel route.
due_today_reminder_hoursmallintNo7CHECK 0..23Local hour the due-today reminder fires.
updated_by_user_iduuidYes—No FKDeliberate: the test harness truncates users with CASCADE; an FK here would wipe the seeded singleton.
versionbigintNo0bumped by trigger
created_at/updated_attimestamptzNonow()

assignment_categories

ColumnTypeNullableDefaultIndex/ConstraintNotes
idserialNo—PKInternal.
public_iduuidNouuidv7()uniqueExternal identifier.
nametextNo—CHECK trimmed, no control chars, ≤64 chars; unique on lower(name) partial on is_activeRetiring frees the name for reuse.
codetextNo—CHECK ^[A-Z0-9_]{1,16}$; unique partial on is_active
sort_ordersmallintNo0CHECK 0..1000
is_activebooleanNotrue
versionbigintNo0bumped by trigger
created_at/updated_attimestamptzNonow()

assignment_notification_rules

ColumnTypeNullableDefaultIndex/ConstraintNotes
eventtextNo—PK; CHECK ∈ the 10-event closed list
enabledbooleanNotrue
channelsjsonbNo—CHECK is a JSON array, ≤4 entriesValues validated against NOTIFICATION_CHANNEL in the service, not the DB.
notify_studentbooleanNotrue
notify_guardiansbooleanNofalse
notify_teacherbooleanNofalseCHECK: only true when event IN ('submitted','overdue')
updated_by_user_iduuidYes—No FK (same reason as settings)
versionbigintNo0bumped by trigger
created_at/updated_attimestamptzNonow()

assignments

ColumnTypeNullableDefaultIndex/ConstraintNotes
idserialNo—PK
public_iduuidNouuidv7()unique
academic_session_idintegerNo—FK academic_sessions.id ON DELETE RESTRICT
subject_idintegerNo—FK subjects.id ON DELETE RESTRICT
category_idintegerYes—FK assignment_categories.id ON DELETE RESTRICT
teacher_iduuidYes—FK staff.id ON DELETE SET NULLGrants management only while still teaching a live target — enforced in code, not the FK.
titlevarchar(200)No—CHECK trimmed, no control chars
instructions_html/instructions_texttextNo''CHECK ≤100000/≤50000 charsSanitised on write.
statusassignment_statusNodraftenum: draft/scheduled/published/closed/cancelled
publish_at/published_attimestamptzYes—CHECK: scheduled requires publish_at; published/closed requires published_at
due_attimestamptzNo—CHECK publish_at < due_at when set
previous_due_at/due_changed_attimestamptzYes—CHECK: both null or both setPowers the due-change banner.
late_policyassignment_late_policyNoallowedenum: not_allowed/allowed/until_final_deadline
final_deadline_attimestamptzYes—CHECK: set iff late_policy='until_final_deadline'; must be > due_at
allow_text_response/allow_file_uploadbooleanNotrueCHECK: at least one true
max_filessmallintNo5CHECK 1..20
submission_file_typestext[]No7-type arrayCHECK ⊆ closed list, cardinality >= 1
resubmission_policyassignment_resubmission_policyNoon_requestenum: none/unlimited/limited/on_request
max_attemptssmallintYes—CHECK: set iff resubmission_policy='limited'; 2..20
is_gradedbooleanNofalseCHECK: is_graded = (max_marks IS NOT NULL)
max_marks/pass_marksnumeric(6,2)Yes—CHECK: max_marks in (0,1000]; pass_marks in [0,max_marks]
marks_releaseassignment_marks_releaseNoon_reviewenum: on_review/manual
marks_released_attimestamptzYes—CHECK: only set when is_graded
closed_at/closed_by_user_idmixedYes—CHECK: status='closed' iff closed_at setFK users.id SET NULL
cancelled_at/cancelled_by_user_id/cancel_reasonmixedYes—CHECK: status='cancelled' iff cancelled_at set; reason required and non-blank when cancelled
archived_at/archived_by_user_idmixedYes—CHECK: archiving only from closed/cancelled
created_by_user_id/created_by_namemixedname required—FK users.id SET NULL
updated_by_user_iduuidYes—FK users.id SET NULL
client_request_iduuidYes—unique partial, scoped (created_by_user_id, client_request_id) where not nullIdempotent create replay.
created_surfacetextNo—CHECK ∈ ('admin','teacher')
versionbigintNo0CHECK >= 0, bumped by trigger
created_at/updated_attimestamptzNonow()

Indexes: assignments_session_status_due_idx (session, status, due_at), assignments_subject_idx, assignments_category_idx, assignments_teacher_idx (partial, non-null teacher), assignments_created_by_idx, assignments_due_publish_idx (partial on status='scheduled', powers the publish-due sweep), assignments_open_due_idx (partial on status='published', powers reminder/overdue queries). assignments_id_subject_session_unique is a unique constraint (not a uniqueIndex) because it is the target of assignment_classes_assignment_fk, and drizzle-kit emits FKs before indexes.

assignment_classes

One class-subject target per assignment. No version column — no client edits it directly.

ColumnTypeNullableNotes
idserialNoPK.
assignment_id/subject_id/academic_session_idintegerNoComposite FK to assignments (id, subject_id, academic_session_id), ON DELETE CASCADE.
class_id/class_subject_idmixedNoComposite FK to class_subjects (id, class_id, subject_id), ON DELETE RESTRICT — the class-subject must belong to this class AND teach this subject.
class_id/academic_session_id——Composite FK to classes (id, academic_session_id), ON DELETE RESTRICT — the class must belong to the assignment's session.
added_at/added_by_user_idmixedYes for userFK users.id SET NULL.
removed_at/removed_by_user_idmixedYesSoft removal — the row stays for history; re-adding clears these rather than inserting a second row.

Unique: (assignment_id, class_id); (id, assignment_id) is the composite target assignment_students_class_fk references. Indexes on class_subject_id, class_id.

assignment_students

The state machine and per-pupil summary.

ColumnTypeNullableNotes
iduuidNoPK, uuidv7().
assignment_idintegerNoFK assignments.id ON DELETE RESTRICT — a published assignment can never be deleted.
assignment_class_idintegerNoComposite FK (assignment_class_id, assignment_id) → assignment_classes (id, assignment_id), ON DELETE RESTRICT.
student_iduuidNoFK students.id ON DELETE CASCADE — a pupil's erasure must not be blocked by homework.
enrollment_iduuidYesFK student_class_enrollments.id ON DELETE SET NULL.
stateassignment_student_stateNoenum: assigned/in_progress/submitted/under_review/returned/completed/not_applicable.
attempt_count/extra_attempts_grantedsmallintNoCHECK 0..100 / 0..50.
first_submitted_at/last_submitted_at/last_is_latemixedYesCHECK: all three set together once state reaches submitted+.
resubmit_due_attimestamptzYesSet by returnSubmission; null clears any deadline.
final_marks/final_grademixedYesCHECK: only set when state='completed'; marks ≥0; grade ^[A-Za-z0-9+-]{1,8}$.
reviewed_at/reviewed_by_user_idmixedYesFK users.id SET NULL.
result_published_attimestamptzYesCHECK: only when state='completed'.
not_applicable_at/not_applicable_reasonmixedYesCHECK: both set together; reason enum transferred/withdrawn/class_removed.
versionbigintNobumped by trigger.

Unique (assignment_id, student_id) — one row per pupil per assignment. Indexes: (student_id, state), (assignment_class_id, state), (assignment_id, state), partial on non-null enrollment_id.

assignment_submission_attempts

The pupil's submission history. Previous attempts stay (superseded, never deleted).

ColumnTypeNullableNotes
iduuidNoPK, uuidv7().
assignment_student_iduuidNoFK assignment_students.id ON DELETE CASCADE.
attempt_nosmallintNoCHECK >= 1.
statusassignment_attempt_statusNoenum: draft/submitted/under_review/returned/completed/superseded.
text_html/text_plaintextYesCHECK ≤200000/≤100000 chars.
submitted_at/is_late/submitted_by_user_idmixedYesCHECK: set together, only once not draft.
client_submission_iduuidYesIdempotency key for submit.
marks/grade/feedback_html/feedback_textmixedYesCHECK: none set while still draft; marks ≥0; grade format constrained.
reviewed_at/reviewed_by_user_id/reviewed_by_namemixedYesCHECK: set once returned/completed.
returned_at/completed_at/superseded_attimestamptzYesCHECK: each matches its own status exactly.
versionbigintNobumped by trigger.

Unique (assignment_student_id, attempt_no). Partial unique indexes enforce at most one draft and at most one pending (submitted/under_review) attempt per row, and one (assignment_student_id, client_submission_id) per non-null key.

assignment_files

Attachments (teacher-owned) and submission files (pupil-owned) share one table, split by a CHECK.

ColumnTypeNullableNotes
idserialNoPK; public_id uuid unique.
kindassignment_file_kindNoattachment/submission.
assignment_idintegerConditionallyFK assignments.id ON DELETE CASCADE; set iff kind='attachment'.
attempt_iduuidConditionallyFK assignment_submission_attempts.id ON DELETE CASCADE; set iff kind='submission'.
file_typetextNoCHECK ∈ 14-type closed list.
original_name/mime_type/size_bytesmixedNoCHECK: name non-blank after trim; size 1..26214400 (25 MiB).
sha256char(64)YesCHECK ^[0-9a-f]{64}$.
storage_keyvarchar(500)YesCHECK: null exactly when purged_at is set.
scan_statusassignment_file_scan_statusNopending/clean/rejected/failed.
scan_attemptssmallintNoCHECK 0..20.
scan_driver/scan_verdict_code/scanned_atmixedYesCHECK: driver+timestamp required once clean/rejected; verdict code required once rejected; storage_key required once clean.
uploaded_by_user_id/uploaded_viamixedvia requiredFK users.id SET NULL; uploaded_via ∈ ('admin','teacher','student').
client_upload_iduuidYesIdempotency key, unique per (uploaded_by_user_id, client_upload_id).
sort_ordersmallintNoCHECK 0..1000.
removed_at/removed_by_user_idmixedYesCHECK: only kind='attachment' may be soft-removed.
purged_attimestamptzYesSet together with clearing storage_key.

Indexes: (assignment_id, sort_order) partial non-null; attempt_id partial non-null; updated_at partial on scan_status='pending' (drives the recovery sweep); uploaded_by_user_id.

External resources on an assignment. https: only, no userinfo in the URL.

ColumnTypeNotes
idserialPK.
assignment_idintegerFK assignments.id ON DELETE CASCADE.
urltextCHECK: ^https://[^\s\x00-\x1f/?#@]+([/?#][^\s\x00-\x1f]*)?$, ≤2048 chars.
labelvarchar(120)CHECK trimmed, no control chars.
sort_ordersmallintCHECK 0..1000.

Index (assignment_id, sort_order).

assignment_history

Append-only event log — see §11 for the trigger. No updated_at.

ColumnTypeNotes
idserialPK.
assignment_idintegerFK assignments.id ON DELETE CASCADE.
assignment_student_iduuidYes; FK assignment_students.id ON DELETE CASCADE.
eventtextCHECK ^[a-z][a-z_]{2,39}$ — one of the 30 values in ASSIGNMENT_HISTORY_EVENT.
actor_user_id/actor_namemixedFK users.id SET NULL; name always recorded even if the user is later deleted.
detailjsonbCHECK is a JSON object; default {}.
occurred_attimestamptzSet in code from the request's captured clock, never now() — so a sweep can backdate correctly.

Indexes: (assignment_id, id), (assignment_student_id, id) partial non-null, actor_user_id.

assignment_reminder_dispatches

Exactly-once bookkeeping for time-driven notifications, keyed on the due date the reminder was sent for.

ColumnTypeNotes
idserialPK.
assignment_idintegerFK assignments.id ON DELETE CASCADE.
eventtextCHECK ∈ ('reminder_3d','reminder_1d','due_today','overdue').
due_attimestamptzThe due date the dispatch is for — a due-date change starts a fresh cycle.
recipient_countintegerCHECK >= 0.
dispatched_attimestamptz—

Unique (assignment_id, event, due_at) — the row IS the dedupe key; inserting it is how the sweep claims "I already sent this".

assignment_transfer_reviews

Decisions about a pupil who left an assignment's classes.

ColumnTypeNotes
idserialPK; public_id unique.
assignment_student_iduuidFK assignment_students.id ON DELETE CASCADE.
from_class_idintegerFK classes.id ON DELETE RESTRICT.
to_class_idintegerYes; FK classes.id ON DELETE RESTRICT.
to_enrollment_id/from_enrollment_iduuidYes each; FK student_class_enrollments.id ON DELETE CASCADE.
detected_attimestamptzNo.
resolutionassignment_transfer_resolutionYes; keep/not_applicable.
decided_automaticallybooleanNo, default false.
resolved_by_user_id/resolved_at/notemixedYes each.

CHECKs: resolution and resolved_at set together; to_class_id <> from_class_id when set; an automatic decision always carries a resolution; to_class_id/to_enrollment_id set together; at least one of to_enrollment_id/from_enrollment_id present. Partial unique: one open review per pupil row (resolution IS NULL), and one per (assignment_student_id, coalesce(to_enrollment_id, from_enrollment_id), to_enrollment_id IS NULL) — keyed per destination, so the sweep never re-opens a question already answered, and a later transfer_mode change only affects transfers detected afterward.

5.3 Relationship Diagram

6. Services and Responsibilities

6.1 AssignmentAudienceResolver (assignment-audience.resolver.ts)

MethodCalled ByReadsWritesSide EffectsErrors
resolveStudent(actor)All pupil-portal handlersstudentsnonenone403 non-pupil session; 404 ASSIGNMENT_NOT_FOUND no live pupil row
resolveGuardianChild(actor, studentId)All guardian-portal handlersguardians, ownership joinnonenone403 non-guardian session; 404 not the guardian's child
resolveTeacher(actor)All teacher-portal handlersstaff (via assertTeacherStaffRow)nonenone403 active role not teacher, or no live staff row
admin(actor)Every admin controllerrole/permissions already checked by @Permissions()nonenone—

The only producer of an AssignmentActor; no other code path constructs one, which is what keeps an unscoped {kind:"all"} actor unreachable.

6.2 AssignmentAccessServiceImpl (assignment-access.service.ts)

MethodCalled ByReadsWritesSide EffectsErrors
visibleAssignment(actor)List/detail/read pathsassignments, assignment_classes, class_subjects, assignment_students, studentsnonenone— (returns a SQL fragment)
manageAssignment(actor)Lifecycle, update, delete, attachment write pathssamenonenone—
reviewRow(actor)Review/roster-read pathsassignment_students, assignments, assignment_classes, class_subjectsnonenone—
assertClassSubjectsUsable(tx, actor, input)Create/update/previewclass_subjects, classesnonenone422 ASSIGNMENT_CLASS_SUBJECT_INVALID (duplicate or invalid), 422 ASSIGNMENT_AUDIENCE_TOO_LARGE (more than 60), 404 ASSIGNMENT_CLASS_NOT_TAUGHT (teacher mismatch)

manage(a) is (a.teacher_id = staff AND ∃ a live target taught by staff) OR (∃ any live target AND every live target is taught by staff) — so a teacher who is the named teacher but has lost every target class no longer manages it, and a teacher who co-owns every live target manages it even without being the named teacher. is_active on the class-subject is deliberately NOT required here (edge case E34 in the source), unlike the stricter teachesForCreate check used at create time.

reviewRow is also the predicate behind a pupil's and a guardian's own reads — StudentAssignmentsPortalService, StudentAssignmentDetailService and GuardianAssignmentsPortalService all call it, not only the teacher review controllers. Its student/guardian branch is assignment_students.student_id = actor.studentId AND EXISTS (students WHERE id = actor.studentId AND record_status = 'active'), so a pupil whose record_status has been set to something other than active does not get a 403 or 404 — their own assignment list, dashboard and detail reads simply come back empty, the same "record-keeping toggle becomes an empty result" shape the row-scope layer uses everywhere else in this codebase.

6.3 AssignmentRosterServiceImpl (assignment-roster.service.ts)

MethodCalled ByReadsWritesSide EffectsErrors
materialisePublish (now/scheduled sweep)assignments, assignment_classes, student_class_enrollments, students, assignment_studentsassignment_students (insert), assignment_historynone—
removeClassUpdate (class removed)sameassignment_classes.removed_at, assignment_students.state='not_applicable', assignment_historynone—
reAddClassUpdate (class re-added)sameassignment_classes.removed_at=null for a previously-removed target, or an inserted assignment_classes row for a class-subject the assignment never targeted; one classes_added history entry per class; inserts missing pupil rows; revives applicable rowsnone422 ASSIGNMENT_AUDIENCE_TOO_LARGE (would take live targets past 60), 422 ASSIGNMENT_CLASS_SUBJECT_INVALID (the class-subject id does not resolve)
reconcileRoster sweepassignments (published only)rows added/moved/marked/revived, assignment_historyNotifies newly-added pupilsReturns {skipped:true} if the advisory lock is held elsewhere
resolveDecisionAdmin transfer resolutionassignment_transfer_reviews, assignment_students, student_class_enrollments, assignment_classesresolution, row state, assignment_historynone404 ASSIGNMENT_TRANSFER_REVIEW_NOT_FOUND, 409 ASSIGNMENT_TRANSFER_NO_LONGER_APPLICABLE, 409 ASSIGNMENT_TRANSFER_ALREADY_SUBMITTED

The per-assignment advisory lock itself lives in shared/assignment-roster-lock.util.ts (lockRosterBlocking/tryLockRoster, both taking the two-key lock (ASSIGNMENT_ROSTER_LOCK_NAMESPACE, hashtext('assignment_roster_<id>')), namespace 710001), shared between AssignmentRosterServiceImpl and AssignmentRosterDecisionsService so the two files derive the same key without duplicating it. Every entry point locks assignments FOR SHARE, then this advisory lock (blocking for materialise/removeClass/reAddClass/resolveDecision, non-blocking tryLockRoster for reconcile), and only then locks or reads individual pupil rows — so the row-level and transfer-review work inside reconcile (delegated to AssignmentRosterDecisionsService.syncActiveRows) always runs already holding both the share lock and the advisory lock.

AssignmentRosterDecisionsService.syncActiveRows (shared/assignment-roster-decisions.service.ts) is what reconcile calls to reconcile pupil rows against current enrolments. For a pupil whose row is stuck on an already-open transfer review but who is no longer assigned/in_progress (they submitted, or were reviewed, after the review opened), it does not overwrite that work as not_applicable — it auto-resolves the review keep, decidedAutomatically: true, with history detail: {resolution: "keep", reason: "already_submitted"}, and leaves the row exactly as it was. reviveApplicableRows — which brings a not_applicable row back to life when the pupil re-enters a live target class — additionally requires the assignment's own submission window still be open (baseOpen) and filters out a pupil who is now soft-deleted or record-inactive; a not_applicable row past the window stays not_applicable rather than reviving into a window the pupil can no longer submit against.

Input normalization: materialise/removeClass/reAddClass lock assignments FOR SHARE before the per-assignment pg_advisory_xact_lock (blocking), so a concurrent lifecycle write and a roster write serialise cleanly. reconcile uses pg_try_advisory_xact_lock (non-blocking) — a busy assignment is simply skipped and retried the next tick, rather than making the whole 5-minute sweep wait on one contended row.

6.4 AssignmentLifecycleService (authoring/core/assignment-lifecycle.service.ts)

One method per transition (publish, unschedule, close, reopen, cancel, archive, unarchive). Every method: locks the row via manageAssignment FOR UPDATE, returns the current detail unchanged before checking version when the row is already in the requested target state (status==='published' for publish-now, status==='draft' for unschedule, and so on — archived_at already set/cleared for archive/unarchive) — this is what makes a repeated or double-clicked call idempotent rather than an error even when the caller is holding a now-stale version. Only past that no-op check does it check version, check the transition is legal from the current status/archived_at, apply the DB write inside the same predicate (AND version = row.version — a second concurrency belt beyond the optimistic check), write history, and re-load the full detail DTO to return. publish additionally re-runs assertPublishGuards and materialises the roster; cancel additionally checks teachers_can_cancel for a teacher actor and notifies.

6.5 AssignmentSubmissionService (submissions/core/assignment-submission.service.ts)

MethodCalled ByReadsWritesSide EffectsErrors
saveDraftStudent submission controllerAssignmentDraftService.lockRowAndDraft, assignment_settingsassignment_submission_attempts (draft text)history draft_started on first write409 ASSIGNMENT_NOT_ACCEPTING, 409 ASSIGNMENT_DRAFT_VERSION_CONFLICT, 400 ASSIGNMENT_TEXT_NOT_ALLOWED
submitStudent submission controllerassignments, assignment_students, assignment_submission_attempts, assignment_files, assignment_classes, assignment_settingsattempt → submitted (new or updated), prior pending attempt → superseded, assignment_students counters/state, assignment_historyAssignmentNotifier.notify(event:"submitted")400 ASSIGNMENT_SUBMISSION_EMPTY/ASSIGNMENT_TEXT_NOT_ALLOWED/ASSIGNMENT_TEXT_TOO_LONG, 409 ASSIGNMENT_SUBMISSION_HAS_REJECTED_FILES/ASSIGNMENT_SUBMISSION_FILE_LIMIT_EXCEEDED/ASSIGNMENT_FILES_NOT_ALLOWED/ASSIGNMENT_ALREADY_COMPLETED/ASSIGNMENT_NOT_ACCEPTING/ASSIGNMENT_RESUBMISSION_NOT_ALLOWED/ASSIGNMENT_VERSION_CONFLICT/ASSIGNMENT_DRAFT_VERSION_CONFLICT

Neither method returns a DTO — the caller re-reads StudentAssignmentDetailDto through StudentAssignmentDetailService after commit, so there is exactly one response builder. Both open their own transaction (the controller does not) and lock in the same order as AssignmentDraftServiceImpl (assignments FOR SHARE → assignment_students FOR UPDATE) so the two paths cannot deadlock against each other.

AssignmentDraftServiceImpl.lockRowAndDraft (shared/assignment-draft.service.ts) decides whether a pupil may open or create a draft while their row is submitted: it is allowed only when supersedeAllowed is true — governed by the resubmission policy and the attempt budget, or by pendingAttemptContentRejected (shared/assignment-pending-content.ts), which is true when the row's pending submitted/under_review attempt carries no text and every one of its files is rejected. When a draft is created this way, the pupil's row state stays submitted; it moves to in_progress only when the row started from assigned. The draft_started history entry is written by the caller (saveDraft, or the submission-file upload path) when LockedDraft.created is true — lockRowAndDraft itself does not write history, because it does not carry the acting user's name.

6.6 AssignmentReviewService (submissions/core/assignment-review.service.ts)

MethodCalled ByReadsWritesSide EffectsErrors
saveReviewAdmin/teacher review controllersattempt/row locked via reviewRowattempt → under_review (marks/grade/feedback), row → under_reviewhistory review_saved only — no notification409 ASSIGNMENT_VERSION_CONFLICT/ASSIGNMENT_ATTEMPT_NOT_REVIEWABLE, 400 ASSIGNMENT_MARKS_INVALID/ASSIGNMENT_FEEDBACK_REQUIRED
completeReviewsamesameattempt → completed, row → completed + final_marks/final_grade, result_published_at per marksReleasenotify(event:"marked") iff marksRelease='on_review'same set
returnSubmissionsamesame + assignment_settings implicitly via resubmission policyattempt → returned, row → returned + cleared result + resubmit_due_at/extra_attempts_grantednotify(event:"returned")409 ASSIGNMENT_ATTEMPT_NOT_REVIEWABLE/ASSIGNMENT_RESUBMISSION_NOT_ALLOWED/ASSIGNMENT_NOT_EDITABLE, 400 ASSIGNMENT_FEEDBACK_REQUIRED/ASSIGNMENT_RESUBMIT_DUE_REQUIRED/ASSIGNMENT_RESUBMIT_DUE_IN_PAST

lockForReview is the shared entry. It checks assignment visibility (visibleAssignment, FOR SHARE) first — an assignment the reviewer cannot see at all is a 404 ASSIGNMENT_NOT_FOUND before any status question is even asked — then locks the target row FOR UPDATE (scoped by reviewRow), then binds and locks attemptId to that row FOR UPDATE. A mismatched attemptId (belongs to another row) answers 404 ASSIGNMENT_SUBMISSION_NOT_FOUND, never revealing whether the attempt exists elsewhere. It also verifies the attempt is the pupil's latest non-draft attempt before allowing any action.

completeReview makes the result visible immediately whenever assignment.marksRelease === 'on_review' or assignment.marksReleasedAt is already set — so once a school has run one bulk release-marks on an assignment, every later complete on that same assignment publishes its result on completion, even under a manual policy; the held state only applies to the first cohort of marks.

6.7 AssignmentFileUploadServiceImpl and the scan pipeline

Thin orchestrator; the substance is in assignment-file-upload-attachment.ts and assignment-file-upload-submission.ts (plain functions sharing injected deps). Each type stages its bytes inside its own upload prefix (<dest>/uploads/assignment-attachment/staging or .../assignment-submission/staging) via a per-type diskStorage, so the storage key the local driver derives from that path (uploads/<type>/staging/<name>) is exactly what getFile/deleteFile resolve later. The uploaded filename is decoded (re-interpreted as UTF-8 when the raw bytes were mojibake-latin1), then stripped of C0 control, DEL and bidirectional-control code points, and capped at 255 code points.

Upload order: validate against assignment_settings/assignments limits (type, size, allowlist) → check the pending-scan budget, re-checked inside the insert transaction under a per-uploader pg_advisory_xact_lock (the two-key lock (ASSIGNMENT_SCAN_BUDGET_LOCK_NAMESPACE, hashtext('assignment_scan_budget_<userId>')), namespace 710002, so a hash collision with the roster family can never make them one lock) so two uploads racing the same budget cannot both pass the outer check → hand the bytes to StorageManager.handleUpload, which sniffs the real MIME → compute the row's stored mimeType via storedMimeFor(fileType, sniffedMime), which returns the type's required MIME only when the sniff matches; a mismatch purges the just-uploaded bytes and throws 400 ASSIGNMENT_FILE_SECURITY_CHECK_FAILED → insert the assignment_files row scan_status='pending' → write it through the outbox so the scan job enqueue cannot be lost if the request fails after commit → return the mapped DTO.

txt/csv have no magic number, so storedMimeFor treats them specially: it accepts anything the sniff reports as empty, text/*, or otherwise textual, and stores the type's fixed MIME (text/plain/text/csv); a sniff that instead detects a real binary signature — a PDF or image renamed to .txt — is refused the same way any other mismatch is, with 400 ASSIGNMENT_FILE_SECURITY_CHECK_FAILED at upload time, before the file ever reaches the scan queue.

A clientUploadId unique-index race (two requests for the same uploader and key) is resolved after the transaction fails: the loser looks up the winner's row by (uploadedByUserId, clientUploadId) and returns it, as long as it belongs to the same assignment — otherwise 409 ASSIGNMENT_VERSION_CONFLICT. On the submission side, the per-pupil storage cap (ASSIGNMENT_MAX_ROW_BYTES, 200 MiB) sums only files on non-superseded attempts, so a superseded attempt's bytes stop counting against the cap the moment a newer one replaces it.

AssignmentFileScanRunner (via the assignment_file_scan BullMQ queue) then runs the structural scanner and writes the verdict — see §6.7a for the runtime.

The generic GET /api/upload/file/* route explicitly refuses any key under an assignment upload prefix with 404 UPLOAD_FILE_KEY_INVALID (modules/upload/upload.service.ts), because that route's ownership check does not know about assignment row scope; every assignment file is downloadable only through this module's own download endpoints.

6.7a The scan pipeline runtime

scan-worker-pool.ts is a child_process.fork() pool (scan-process.entry.ts is the forked entry), not worker_threads — each scan runs in its own OS process so a crash or a runaway allocation cannot touch the API process's heap. Two independent kills bound a scan: a deadline timer (ASSIGNMENT_SCAN_DEADLINE_MS, 60s) SIGKILLs the child outright, and an RSS watchdog polls /proc/<pid>/status for VmRSS every 100ms and SIGKILLs the child the instant it exceeds ASSIGNMENT_SCAN_PROCESS_MAX_RSS_MB (768 MB) — an RSS kill resolves the file as ARCHIVE_BOMB directly, not as a generic failure, because uncontrolled memory growth during a structural scan is exactly what an archive/decompression bomb looks like. A killed or crashed child is replaced lazily, so the pool keeps its full configured concurrency without a dedicated respawn loop.

Scan dispatch (structural-file-scanner.ts) sniffs the type first — a txt/csv file whose bytes carry another type's magic number is rejected TYPE_MISMATCH before any type-specific parser runs — then picks a scanner:

  • pdf-scan.ts: before pdf-lib ever parses the file, one linear pass over the raw bytes records every top-level N G obj header and rejects CORRUPT when the same object number repeats inside one revision — a hidden second definition is exactly the ambiguity pdf-lib's tolerant parser would resolve silently. The set of seen objects resets at each %%EOF between objects, because an incremental update (a filled form, an annotation, a signature) legally redefines objects in a later revision. In the same pass, every /ObjStm or /XRef stream is inflated under a hard per-entry cap (32 MiB) and a running-total cap (256 MiB) as a stop-gap against a decompression bomb pdf-lib's own inflate does not bound; an ObjStm/XRef stream using a filter other than Flate is rejected CORRUPT rather than risking an unsupported decoder. The active-content walk over the parsed object graph is recursive.
  • office-ooxml-scan.ts (docx/xlsx/pptx): rejects any XML part that is not valid UTF-8; parses .rels relationships with a quote-agnostic attribute matcher (both ' and ") so an external target or a TARGETMODE="External" relationship is never missed by a case- or quote-sensitive match; every internal oleObject/package relationship target discovered from a .rels part is CFB-inspected in a second pass, regardless of its filename, and only an OLE-embedded Office Math object is allowed through. Active-field words are searched across the top-level word/*.xml parts (docx), every xl/worksheets/sheetN.xml (xlsx) and every ppt/slides/slideN.xml (pptx), with tags stripped first so a field split across separate runs still matches as one word.
  • office-legacy-scan.ts (doc/xls/ppt, CFB/OLE2 format): walks PowerPoint's own record stream for ExOleObjStg (embedded OLE, record 0x1011) and VbaInfoAtom (macro project, 0x0400); for Excel, any CFB storage named MBD* is the OLE convention for an embedded object and is rejected on sight.
  • rtf-scan.ts: concatenates every \fldinst{...} field-instruction run (an active field can be split across runs) before checking it against the active-field word list.
  • image-scan.ts (jpg/png/webp/gif): re-encodes via assignment-image-reencode.ts to strip any embedded payload.
  • text-scan.ts (txt/csv): validates UTF-8 with no embedded NUL, and separately checks CSV fields for formula injection. A txt/csv upload whose actual bytes sniff as a binary type never reaches this scanner at all — storedMimeFor refuses it synchronously at upload time (above), before a scan job is even queued.

Each returns either an accept or one of the ASSIGNMENT_SCAN_REJECT_CODE values (TYPE_MISMATCH, CORRUPT, ARCHIVE_BOMB, OFFICE_MACRO, EMBEDDED_EXECUTABLE, EMBEDDED_OBJECT, EXTERNAL_REFERENCE, ACTIVE_FIELD, ENCRYPTED, PDF_ACTIVE_CONTENT, CSV_FORMULA_INJECTION, IMAGE_BOMB, POLYGLOT). A rejected pupil-facing message is always the generic ASSIGNMENT_FILE_SECURITY_MESSAGE — the specific reject code is never shown to the uploader, only recorded.

A staff download of a submission file never resolves a pupil's still-draft attempt — the download query excludes status='draft' explicitly, so staff can only ever read work the pupil has actually submitted, never a draft they are mid-composition on.

6.8 AssignmentNotifier (assignment-notifier.service.ts)

Reads the event's assignment_notification_rules row (a disabled rule is a no-op), resolves recipients from live rows via AssignmentNotificationRecipientsService.targetRows (pupil, guardians of that pupil, the assignment's teacher — subject to the rule's notify_* flags), chunks recipients at ASSIGNMENT_NOTIFY_CHUNK (500), and calls NotificationService.send inside the caller's transaction. targetRows excludes any row whose state='not_applicable', so an updated/cancelled notification never reaches a pupil the roster has already excluded from the assignment. file_rejected and publish_failed bypass rule lookup entirely and go straight to actorUserId; file_rejected's dedupe key additionally includes the file's own publicId, so rejecting a second file on the same assignment is not deduplicated against the first. releaseMarks sends one notify call with every released row's id in one list — the chunking and per-chunk dedupe key both happen inside that single call, not as a loop the caller writes. The overdue reminder is the one event that still reaches a pupil who has not submitted even when the assignment's late_policy='not_allowed' — it is the reminder that most needs to reach exactly that pupil.

7. Runtime Flows

7.1 Publish now

StepCode PathBehaviorFailure Case
1AssignmentLifecycleController.publishResolves actor, delegates—
2AssignmentLifecycleService.publishLocks row, checks version/status/due lead409/400 per condition above
3assertPublishGuardsRe-validates teacher requirement, class validity, audience size422/404
4AssignmentRosterServiceImpl.materialiseInserts missing rows— (idempotent via onConflictDoNothing)
5AssignmentHistoryWriter.writeAppends publishedTrigger refuses any later UPDATE
6AssignmentNotifier.notifySends to new rows onlyRule disabled → no-op

7.2 Publish-due sweep

Include: success path (guards pass, published); guard-failure path (reverted, two separate transactions so the revert survives even if the first transaction's guard check itself failed mid-way); no-longer-scheduled (skipped); concurrent double-tick (deduped by the cron's own bucketed jobId, and idempotent because the sweep's own UPDATE ... WHERE status='scheduled' only ever affects a row once).

AssignmentPublishSweepService.run joins academic_sessions and excludes any assignment whose session has locked_at set from the candidate query, so a scheduled assignment in a year that was locked after scheduling is simply left alone rather than failing at publish time. AssignmentRosterSweepService and AssignmentReminderSweepService apply the same isNull(academicSessions.lockedAt) filter to their own candidate queries, so a locked year's roster reconciliation and reminder dispatch are skipped the same way.

7.3 Submit

Covered in detail in the feature doc. Cache-miss/hit branches do not apply (no cache layer); the async branch is the file scan, which submit does NOT wait on — a rejected file already attached simply blocks submission with ASSIGNMENT_SUBMISSION_HAS_REJECTED_FILES until removed, and a still-pending file at submit time is left pending (the pupil can submit text-only or wait).

7.4 File scan

8. Caching

This module has no cache layer — no CacheKeyUtil usage anywhere under apps/api/src/modules/assignments/. Every read goes to PostgreSQL directly. Status/window derivation is computed per-request in SQL (assignment-status.sql.ts) rather than cached, by design — the whole point of deriving rather than storing "active"/"overdue" is that it can never go stale, and caching it would reintroduce exactly that staleness.

9. BullMQ, Schedulers, and Async Work

QueueJobProducerProcessorPayloadRetry/BackoffIdempotency
QueueName.ASSIGNMENTS ("assignments")AssignmentJob.PUBLISH_DUE (assignment.publish_due)AssignmentScheduler (every minute)AssignmentsQueueProcessor → AssignmentPublishSweepService.run{v:1, requestId}BullMQ default retry (concurrency 1 on the processor); job id bucketed per minuteThe sweep's own WHERE status='scheduled' UPDATE — a redelivery affects nothing already published.
sameAssignmentJob.REMINDER_SWEEP (assignment.reminder_sweep)same (every 15 min)→ AssignmentReminderSweepService.run{v:1, requestId}sameClaims assignment_reminder_dispatches unique row before sending.
sameAssignmentJob.ROSTER_SYNC (assignment.roster_sync)same (every 5 min)→ AssignmentRosterSweepService.run{v:1, requestId, assignmentId?}samePer-assignment advisory lock (pg_try_advisory_xact_lock); conditional predicates in every write.
sameAssignmentJob.SCAN_RECOVERY (assignment.scan_recovery)same (every 10 min)→ AssignmentFileScanRunner.recover{v:1, requestId}sameKeyed on the file's own scan_attempts; re-enqueue only for files stuck past ASSIGNMENT_SCAN_STALE_MS.
QueueName.ASSIGNMENT_FILE_SCAN ("assignment_file_scan")AssignmentFileScanJob.SCAN_FILE (assignment.scan_file)AssignmentFileUploadServiceImpl (via outbox, on upload) and the recovery sweep (re-enqueue)Its own processor (assignment-file-scan.processor.ts){v:1, requestId, fileId}Concurrency bounded; scan worker pool has its own memory ceilingscan_status transition is a single conditional UPDATE — a duplicate scan of an already-clean/rejected file is a harmless no-op re-verdict.

The recovery sweep (files/assignment-file-scan-recovery.ts) runs four independent steps per tick, each wrapped so one file's failure cannot stop the rest: (a) files stuck pending past ASSIGNMENT_SCAN_STALE_MS are re-enqueued with a fresh assignment_scan_retry_<filePublicId>_<scanAttempts>_<requestId> job id — touching updated_at on the re-enqueue restarts the stale window, so a file that keeps failing does not get re-queued every tick, only once its new window also expires; (b) rejected files past the 30-day quarantine window have their bytes purged in place, the row itself untouched; (c) attachment-kind files whose removed_at is past that same retention window have both their storage bytes purged and their assignment_files row deleted outright — unlike a rejected file, a removed attachment has no reason to keep a row around; (d) an orphan sweep over unreferenced staging bytes. Every per-row purge/delete inside each step is individually try/caught and logged on failure, so one bad row is skipped rather than aborting the sweep for every file after it.

All four AssignmentJob job ids are built with buildJobId, bucketed at exactly the cron's own period, so a tick firing twice (two API processes, or a restart inside the minute) deduplicates via BullMQ's own jobId uniqueness rather than a database check. Every cron handler swallows and logs a failed queue.add rather than throwing, because a throwing handler would stop the scheduler for every later tick.

All three periodic sweeps — AssignmentPublishSweepService, AssignmentReminderSweepService and AssignmentRosterSweepService — filter their candidate query on isNull(academicSessions.lockedAt), so an assignment whose academic year is locked is excluded from auto-publishing, reminders and roster reconciliation alike; a locked year is historical and none of the three sweeps writes to it.

The general roster sweep (no assignmentId named in the job payload) walks published, unarchived assignments by a keyset cursor on id so every assignment is visited exactly once per sweep, however many there are, and additionally skips any assignment whose own closing time — final_deadline_at when its late policy is until_final_deadline, else due_at — is more than ASSIGNMENT_ROSTER_SWEEP_WINDOW_MS (7 days) in the past: nothing is left to reconcile once every pupil's window has been shut for a week. A targeted run naming one assignmentId in its payload is exempt from that window, since it is always a deliberate, single-assignment call rather than a periodic audit.

10. Realtime and Events

No Socket.IO/SSE events are emitted by this module. packages/db schema comments and CLAUDE.md note that exactly one realtime event survives in the whole repo (feedback.submitted); assignments notify exclusively through NotificationService (in-app feed, push, email, SMS per rule), never through the realtime gateway.

11. Security, Auth, and Abuse Controls

  • Guards. Every controller carries @UseGuards(JwtAuthGuard, RoleGuard); upload and download routes additionally layer IpThrottlerGuard with RATE_LIMITS.UPLOAD_WRITE/UPLOAD_READ/CUSTOMER_WRITE.
  • Permissions (admin surface). @Permissions("Assignments_READ") etc. gate every admin route — see the API doc's Auth section for the full route-to-code table.
  • Guest identity (portal surfaces). No @Permissions() on teacher/student/guardian routes. RoleGuard allows them through its NO_PERMISSION_ADMIN_HANDLERS allowlist (registered per handler in apps/api/src/common/authorization/role.guard.ts), and the sole control is the audience resolver call at the top of each handler (resolveTeacher/resolveStudent/resolveGuardianChild). This allowlist is mirrored in apps/api/test/structure/route-permissions.spec.ts, so a missing entry fails CI rather than only failing at runtime with a silent 403 to every caller.
  • Row scope, not just identity. An audience assertion (resolveTeacher, etc.) proves who is calling; it resolves no row. Every row read goes through AssignmentAccessService — never through PeopleAccessService.scopeFor, whose {kind:"all"} branch would fail open for any scopeKind:"all" caller holding Students_READ (the seeded staff/teacher roles among them).
  • 404 over 403 on a foreign row. An out-of-scope assignment, submission row or file answers 404, never 403 — an id space over a roll of children must not become an enumeration oracle.
  • Sanitisation. instructionsHtml, submission text and feedback all pass through sanitiseAssignmentHtml/deriveAssignmentText (assignment-html.util.ts) before storage; every notification template escapes user-authored text in email bodies via buildParagraph, and never interprets it as markup on push/in-app/SMS channels.
  • File safety. Every upload is staged, scanned by a structural scanner in an isolated, forked OS process (RSS watchdog kill at ASSIGNMENT_SCAN_PROCESS_MAX_RSS_MB=768MB, deadline kill at ASSIGNMENT_SCAN_DEADLINE_MS=60s), and only scan_status='clean' is ever downloadable. Rejected bytes are kept unread for ASSIGNMENT_QUARANTINE_RETENTION_MS (30 days) then purged.
  • Rate limiting. Upload writes and downloads are IP-throttled (RATE_LIMITS.UPLOAD_WRITE/UPLOAD_READ); pupil draft/submit writes use RATE_LIMITS.CUSTOMER_WRITE.
  • Fail-closed defaults. Every lifecycle and review transition defaults to rejecting on any condition it does not explicitly recognise as legal (invalidTransition()), rather than allowing an unlisted state change through.
  • Audit. ActivityAuditInterceptor (global) records every mutating request against this module using the handler's @Permissions() code where present, or its route where not (portal routes). assignment_history is a second, append-only, domain-specific audit trail scoped to one assignment/pupil.
  • Academic-session lock. Postgres triggers from packages/db/src/migrations/0038_year_lock_records.sql refuse INSERT/UPDATE/DELETE on assignments and assignment_classes directly, and on assignment_students, assignment_submission_attempts and assignment_links by resolving up to the parent assignment's academic_session_id, once that session's locked_at is set. assignment_files is covered for INSERT and DELETE only — not UPDATE, so the virus-scan worker can still record a verdict on a file uploaded just before the lock. A refused write raises ACADEMIC_SESSION_LOCKED, which AllExceptionsFilter turns into 409 ACADEMIC_SESSION_LOCKED. A cascade from a pupil's erasure (pg_trigger_depth() > 1) always passes. AssignmentDetailDto.academicSessionLocked (authoring/core/assignment-detail.service.ts:230, dto/assignment-detail.dto.ts:69) is set from the joined academic_sessions.locked_at, so the admin/teacher detail screen can disable editing before a write is even attempted.
  • Graduate read-only. StudentAssignmentSubmissionPortalController resolves the pupil first, then calls assertNotGraduatedReadOnly (apps/api/src/modules/people/shared/graduate-access.query.ts) on every write (saveDraft, file upload, file removal, submit), answering 403 STUDENT_GRADUATED_READ_ONLY once the pupil's graduate access state is grace or ended. The check runs after audience resolution and never on the raw path id, so a graduated pupil's own id does not leak the graduation state to another caller.

13. Error Handling

Every error code returned over HTTP — its status, the routes that return it, the condition, and what a consumer should do — is in the API doc §12. This section covers how errors are produced and the codes that never reach an HTTP response.

13.1 How errors are produced

  • Every domain throw is a Nest HTTP exception whose body carries an errorCode from apps/api/src/common/types/error-codes.ts; AllExceptionsFilter wraps it in the standard error envelope.
  • Statuses used by this module: 400 (bad input the client can fix), 403 (ASSIGNMENT_CANCEL_NOT_ALLOWED, and STUDENT_GRADUATED_READ_ONLY on pupil portal writes), 404 (missing or out of scope — never 403, so ids cannot be enumerated), 409 (state or concurrency conflicts, including ACADEMIC_SESSION_LOCKED on every write once the assignment's academic session is locked), 410 (ASSIGNMENT_FILE_REJECTED only), 413 (ASSIGNMENT_FILE_TOO_LARGE only), 422 (class/audience/teacher guards), 429 (ASSIGNMENT_TOO_MANY_PENDING_FILES, and the IP throttler).
  • Cross-field validation runs in validateMergedAssignmentFields (authoring/core/assignment-merge-validation.util.ts:79-188) before any write, on both create and update. AssignmentWriteService.create calls it against the merged row as a draft with dueAtChanged: false and publishAt: null (authoring/core/assignment-write.service.ts:211-215), so a create body that violates a cross-field rule (a final deadline without the matching late policy, no submission method, an inconsistent grading setup) gets the same named 400 code a PATCH doing the same thing would — ASSIGNMENT_FINAL_DEADLINE_INVALID, ASSIGNMENT_SUBMISSION_SETTINGS_INVALID, or ASSIGNMENT_GRADING_INVALID — never a raw CHECK violation. The due-date lead-time rule is the one exception: it is checked only when an assignment is published or scheduled, not at create.
  • Category name/code conflicts are detected by translating the unique-index name in the database error (settings/categories/assignment-categories.service.ts:55-67), and "category in use" by translating the foreign-key error on delete (:219-227).
  • ASSIGNMENT_TRANSFER_NO_LONGER_APPLICABLE is thrown after its transaction commits, so the stale review's deletion survives the 409 (AssignmentRosterServiceImpl.resolveDecision). The transfer_resolved history entry records reason: "returned_to_targets" when the pupil is back in a live target class, or reason: "withdrawn" when they have no active enrolment (the row is also marked not_applicable with reason withdrawn in that case).

13.2 Codes that are never returned over HTTP

Error CodeKindWhereWhenWhat happens insteadOperator action
ASSIGNMENT_FILE_PURGE_FAILEDLog only (logger.error)apps/api/src/modules/assignments/files/assignment-file-purge.service.ts:42StorageManager.deleteFile throws while deleting an object after the database stopped tracking it — attachment or submission-file removal, an upload rolled back after storage, or replacing the original of a re-encoded imageThe failure is swallowed so a storage outage cannot resurrect a row; the log line carries storageKey, requestId and reason. The object is left orphaned in storageSearch the log for the code and delete the named storage key, or leave it to the orphan sweep inside the scan-recovery job (files/assignment-file-scan-recovery.ts:175-197), which removes unreferenced objects older than 24 hours
ASSIGNMENT_FILE_SCAN_WORKER_FAILEDLog only (logger.error)apps/api/src/modules/assignments/workers/assignment-file-scan.processor.ts:96A assignment.scan_file job fails terminally (BullMQ retries exhausted and JobFailureRecorderService.recordIfTerminal records it)The processor then marks the file failed through AssignmentFileScanRunner.markFailed; downloads of that file answer 409 ASSIGNMENT_FILE_SCAN_FAILEDInspect the recorded job failure, then rescan (POST /api/assignment-files/:fileId/rescan)
ASSIGNMENT_SETTINGS_INVALIDDefined, unusedapps/api/src/common/types/error-codes.ts:1541Nothing throws it; PATCH /api/assignment-settings validates fields through the DTO only and returns ASSIGNMENT_VERSION_CONFLICT for a stale version——

ASSIGNMENT_FILE_SCAN_FAILED looks like a worker code but is not one: it is the 409 a download returns for a file whose scan is failed (files/assignment-file-download.service.ts:106).

14. Observability

SignalLocationPurpose
LogLogger in AssignmentScheduler, AssignmentsQueueProcessor, the file-scan processorEnqueue failures, per-job start/success/failure, unhandled job names
LogService-level throwsEvery domain error carries an errorCode, caught by the global AllExceptionsFilter
Auditassignment_historyDomain-specific, append-only, per-assignment/per-pupil event log
AuditMongo audit_logs (via ActivityAuditInterceptor)Cross-module actor/action/outcome record for every mutating admin/portal request
Job failureJobFailureRecorderService.recordIfTerminalRecords a terminally-failed job (after BullMQ's own retries are exhausted) for operational visibility
Logscan-worker-pool.ts, every line prefixed [assignment.scan] error: a child process crashed (its own error event) or exited non-zero; warn: a deadline kill or an RSS-watchdog kill. A child the pool itself killed during shutdown (onModuleDestroy) is deliberately excluded — its exit event is checked against a shuttingDown flag first and skipped, so a clean shutdown never logs a false crash.

15. Testing and Validation

Test TypeFilesCoverage
Unitauthoring/core/assignment-capabilities.util.spec.ts, assignment-list-sort.util.spec.ts, assignment-merge-validation.util.spec.ts, shared/assignment-html.util.spec.ts, shared/assignment-labels.util.spec.ts, submissions/core/assignment-submission-mapper.util.spec.ts, files/assignment-file.multer.spec.ts, files/scanning/structural-file-scanner.spec.tsPure-function edge cases: sort keys, merge validation, HTML sanitisation, label mapping, multer config, structural scan verdicts.
Integration__tests__/assignment-authoring.integration.spec.ts, assignment-display-status-parity.integration.spec.ts, assignment-reminders.integration.spec.ts, assignment-roster-sync.integration.spec.ts, assignment-scan-worker.integration.spec.ts, assignment-stats.integration.spec.ts, assignment-submission.integration.spec.tsReal-database coverage of create/publish/lifecycle, TS-vs-SQL display-status parity, reminder dispatch dedupe, roster reconciliation, the scan worker pool, statistics aggregation, the full submit/review cycle.
E2ENone dedicated — covered by apps/api/test/structure/structure.baseline.json (route fingerprint) and swagger-coverage.spec.ts (every route documented, correctly tagged).Route/guard/tag contract.
Manualpnpm --filter @skoolsewa/api test:structure after any route changeRegenerates/verifies the route baseline this module's 78 routes live in.

Validation commands:

pnpm --filter @skoolsewa/api test __tests__/assignment
pnpm --filter @skoolsewa/api test:structure
pnpm rules

16. Mandatory Backend Deep-Dive Pack

16.1 Submodule Coverage Matrix

UnitTypeOwnsDepends OnCalled ByCallsState TouchedFailure Modes
AssignmentsControllerControllerAdmin CRUD routesAssignmentAudienceResolver, list/detail/options/preview/write/update/class-stats servicesHTTPservicesassignments, assignment_classes403 (permission), 404, 409, 422
AssignmentLifecycleControllerControllerAdmin lifecycle routesAssignmentLifecycleServiceHTTPserviceassignments.status and relatedas above
AssignmentAttachmentsControllerControllerAdmin attachment upload/removeAssignmentFileUploadServiceHTTPserviceassignment_filesupload validation errors
TeacherAssignmentsPortalControllerControllerTeacher CRUD + read routesAssignmentAudienceResolver, same core servicesHTTPservicessame403 audience, 404 scope
TeacherAssignmentActionsPortalControllerControllerTeacher lifecycle/attachment/release routessame + AssignmentLifecycleService, AssignmentFileUploadService, AssignmentReviewOperationsHTTPservicessamesame
AssignmentSubmissionsController (admin)ControllerAdmin roster/review/release routesAssignmentRosterReadService, AssignmentReviewService, AssignmentReviewOperationsHTTPservicesassignment_students, attempts403 permission, 404, 409
TeacherAssignmentReviewPortalControllerControllerTeacher review routessame services, scoped via reviewRowHTTPservicessame403 audience, 404 scope
StudentAssignmentsPortalControllerControllerPupil dashboard/list/detailAssignmentAudienceResolver, StudentAssignmentsPortalService, StudentAssignmentDetailServiceHTTPservicesreads only403/404
StudentAssignmentSubmissionPortalControllerControllerPupil draft/upload/submitAssignmentSubmissionService, AssignmentFileUploadService, StudentAssignmentDetailServiceHTTPservicesattempts, filesfull error set above
GuardianAssignmentsPortalControllerControllerGuardian summary/list/detailAssignmentAudienceResolver, GuardianAssignmentsPortalService, StudentAssignmentsPortalServiceHTTPservicesreads only403/404
AssignmentFilesController (admin) / portal siblingsControllerDownload + admin rescanAssignmentFileDownloadService, AssignmentFileRescanServiceHTTPservicesassignment_files bytes stream, scan_status403/404 ASSIGNMENT_FILE_NOT_FOUND/409 ASSIGNMENT_FILE_NOT_READY/409 ASSIGNMENT_FILE_SCAN_FAILED/410 ASSIGNMENT_FILE_REJECTED/409 ASSIGNMENT_FILE_NOT_RESCANNABLE
AssignmentSettingsController, AssignmentNotificationRulesController, AssignmentCategoriesController, AssignmentTransferReviewsControllerControllerAdmin-only config surfacestheir *ServiceHTTPservicerespective tables403/404/409/422
AssignmentStatsControllerControllerAdmin dashboard/analyticsAssignmentAdminStatsServiceHTTPservicereads only403
AssignmentAudienceResolver (impl in assignment-audience.resolver.ts)ServiceActor resolutionpeople staff/student lookupsevery controller—none written403/404
AssignmentAccessServiceImplServiceSQL predicatesDrizzle table refs onlymost services—none written (predicates only)none — pure builder
AssignmentRosterServiceImpl + AssignmentRosterDecisionsServiceServiceRoster writesAssignmentHistoryWriter, AssignmentNotifierlifecycle, roster sweep, transfer resolutionwrites as aboveassignment_students, assignment_transfer_reviewsadvisory-lock skip, FK errors surfaced as domain errors
AssignmentHistoryWriter (impl in assignment-history.writer.ts)ServiceAppend assignment_history rowsnonenearly every write pathINSERTassignment_historytrigger refuses UPDATE/DELETE outside cascade
AssignmentNotifier (impl in assignment-notifier.service.ts) + AssignmentNotificationRecipientsServiceServiceRecipient resolution + sendassignment_notification_rules, NotificationServicewrite paths that notifyreads rule, calls NotificationService.sendnone directlyrule disabled → silent no-op (by design)
AssignmentDraftService (impl in assignment-draft.service.ts)ServiceLocks and creates the pupil draftassignments, assignment_students, assignment_submission_attemptsAssignmentSubmissionServiceSELECT...FOR SHARE/UPDATE, INSERT draftattempt row409 ASSIGNMENT_NOT_ACCEPTING, 404 ASSIGNMENT_NOT_FOUND
AssignmentFileUploadServiceImpl + attachment/submission helper functionsServiceUpload validation, staging, insert, outbox enqueueStorageManager, OutboxService, AssignmentDraftService/AssignmentAccessServiceattachment/submission upload routesINSERT assignment_files, stage bytesfile row + storagefull file-error set
AssignmentFileDownloadServiceImplServiceAuth check + stream openAssignmentAccessService, StorageManagerevery download controllerSELECT, storage readnone404 ASSIGNMENT_FILE_NOT_FOUND; 409 ASSIGNMENT_FILE_NOT_READY (pending); 409 ASSIGNMENT_FILE_SCAN_FAILED (failed); 410 ASSIGNMENT_FILE_REJECTED (rejected, removed or purged)
AssignmentFilePurgeServiceImplServiceBest-effort deletion of stored objects after the transaction that stopped tracking them commitsStorageManagerattachment/submission upload and removal paths, scan verdict (image re-encode)StorageManager.deleteFilestorage objectsA storage failure is logged with ASSIGNMENT_FILE_PURGE_FAILED and swallowed; never returned over HTTP
AssignmentFileScanRunner/AssignmentFileScanJob processorService/ProcessorDispatch and record scan verdictsscanning/*, WorkerPoolBullMQ assignment_file_scanUPDATE scan_statusassignment_filesA terminal job failure is logged with ASSIGNMENT_FILE_SCAN_WORKER_FAILED and the file is marked failed; its downloads then answer 409 ASSIGNMENT_FILE_SCAN_FAILED
AssignmentSchedulerScheduler4 cronspackages/jobsNestJS @Cronqueue.addnone (outbox exemption: cron-triggered, no accompanying write)logs and swallows enqueue failure
AssignmentsQueueProcessorProcessorDispatches to the 4 sweep servicessweep servicesBullMQdelegatesas per sweepunhandled job name throws (compile-time exhaustive map)
AssignmentPublishSweepService/AssignmentReminderSweepService/AssignmentRosterSweepServiceServiceThe 4 sweeps' logicAssignmentPublisher, AssignmentRosterService, AssignmentNotifierprocessorbatched reads/writesassignments, assignment_students, assignment_reminder_dispatchesidempotent by construction (see §9)

16.2 UML and Architecture Diagram Pack

See §3 for the full component diagram (module composition).

16.3 Code Flow Narrative — AssignmentLifecycleService.publish

  1. Entry point and caller. AssignmentLifecycleController.publish (admin) or TeacherAssignmentActionsPortalController.publish (teacher), each already holding a resolved AssignmentActor.
  2. DTO/command shape. { version: string; mode: "now" | "schedule"; publishAt?: string }.
  3. Auth/identity assumptions. The caller already passed JwtAuthGuard+RoleGuard (and, for admin, @Permissions("AssignmentPublish_CREATE")); the actor's kind is admin or teacher.
  4. Input normalization. None beyond DTO validation (class-validator on the controller boundary).
  5. Validation order. Row exists and is in the actor's manageAssignment scope → version matches → not archived → (mode now) due date far enough away → (mode schedule) publishAt far enough away and leaves room before due.
  6. Reads performed. assignments row FOR UPDATE, assignment_settings (via the guards util, for require_teacher), class_subjects/classes (via the guards util, for target validity and audience size).
  7. Business decisions. Publish-now vs schedule; whether the current status legally reaches published/scheduled; whether the audience and teacher requirements are satisfied.
  8. Writes performed. UPDATE assignments SET status=...; for publish-now, INSERT assignment_students via roster materialisation; INSERT assignment_history.
  9. Transactions. One transaction for the whole operation (this.db.transaction); the detail reload happens after commit, outside the transaction.
  10. Cache behavior. None — no cache layer exists.
  11. Jobs/events/notifications. No job enqueued directly; AssignmentNotifier.notify(event:"published") runs inside the same transaction for newly-materialised rows only.
  12. Response mapping. AssignmentDetailService.detail(actor, id, grant) builds the full AssignmentDetailDto after the transaction commits.
  13. Error handling. Every failure path throws a Nest HTTP exception carrying an ErrorCodes member; the global filter maps it to the response envelope.
  14. Logs/metrics/audit. ActivityAuditInterceptor records the mutating request globally; assignment_history records the domain event inside the transaction.
  15. Known tradeoffs. Publish-now and schedule-then-sweep-publish share the exact same guard function (assertPublishGuards) so a schedule that was valid when created can still fail at sweep time if circumstances changed — that failure reverts to draft rather than silently publishing with a stale audience.
StepCode LocationWhat HappensWhy It HappensFailure/Edge Case
1AssignmentLifecycleService.publishLock row (manageAssignment scope), check foundPrevent lost updates404 ASSIGNMENT_NOT_FOUND
2(mode now)Already-published no-op return, before the version checkMakes a double-click idempotent instead of a 409 on a stale version— (returns current detail unchanged)
3Version + archived checkOptimistic concurrency, frozen-state guard409 ASSIGNMENT_VERSION_CONFLICT/ASSIGNMENT_NOT_EDITABLE
4Status/lead-time check (mode now)Publishing seconds before due is a defect, not a feature409 ASSIGNMENT_INVALID_TRANSITION, 400 ASSIGNMENT_DUE_IN_PAST
5assertPublishGuardsSingle source of "is this legal to go live"422/404 from the guards util
6UPDATE status, published_at, clear publish_atCommit the transition—
7roster.materialiseCreate the per-pupil rows the rest of the module depends on—
8History write, conditional notifyAudit trail + pupil-facing announcement—

16.4 Data Layer Deep Dive

Covered field-by-field in §5.2 for every table. Highlights not otherwise obvious from the field tables:

  • Money units. N/A — max_marks/pass_marks/marks are numeric(6,2) mark values, not currency.
  • Timezone and date interpretation. All timestamps are timestamptz. ASSIGNMENT_DEFAULT_SCHOOL_TIMEZONE = "Asia/Kathmandu" is the constant used to interpret "due today"/"non-school-day" in the school's local calendar, applied at the presentation/derivation layer, never stored as a naive local time.
  • JSON shapes. assignment_notification_rules.channels is a JSON array of channel strings ("in_app"|"push"|"email"|"sms"), ≤4 entries by CHECK, validated against the closed NOTIFICATION_CHANNEL set in the service layer (the DB only checks it is an array). assignment_history.detail is an arbitrary JSON object, default {}.
  • Versioning fields. bump_row_version() is a shared trigger function (not redefined per table) attached to every table a client sends a version token back for. Tables without a meaningful client-editable identity (assignment_files, assignment_links, assignment_classes, assignment_history, assignment_reminder_dispatches, assignment_transfer_reviews) carry no version column — attaching the trigger to one would fail at runtime, not at migration time, so it is simply never attached.
  • Seed data dependency. assignment_settings (the singleton row), 7 assignment_categories, and 10 assignment_notification_rules rows are seeded by migration 0028, not by db:seed — because a production deploy runs db:migrate and never seeds. ON CONFLICT DO NOTHING throughout, so a database that already holds a row keeps it.

Index rationale:

Index/ConstraintColumnsTypeQuery/Invariant SupportedTradeoff
assignments_session_status_due_idxsession, status, due_atbtreeList/filter by status within a session, ordered by due dateWrite overhead on every status change
assignments_due_publish_idxpublish_atpartial btree, status='scheduled'The publish-due sweep's WHERE status='scheduled' AND publish_at <= now scanCheap — small partial index
assignments_open_due_idxdue_atpartial btree, status='published'Reminder-sweep window queriesCheap — small partial index
assignments_client_request_uniquecreated_by_user_id, client_request_idunique, partial (client_request_id IS NOT NULL)Idempotent create replayNone — partial keeps it cheap
assignment_students_assignment_student_uniqueassignment_id, student_iduniqueOne row per pupil per assignment — the roster invariantNone
assignment_attempts_one_draft_uniqueassignment_student_idunique, partial (status='draft')At most one draft in flightNone
assignment_attempts_one_pending_review_uniqueassignment_student_idunique, partial (status IN ('submitted','under_review'))At most one attempt awaiting a decisionNone
assignment_files_pending_scan_idxupdated_atpartial btree, scan_status='pending'The scan-recovery sweep's stale-scan scanCheap — small partial index
assignment_transfer_reviews_one_open_uniqueassignment_student_idunique, partial (resolution IS NULL)Exactly one open question per pupil row at a timeNone
assignment_categories_name_unique/_code_uniquelower(name) / codeunique, partial (is_active)Retiring a category frees its name/codeNone

16.5 Business Logic and Invariant Catalog

InvariantEnforced ByWhy It ExistsFailure ErrorTests
A published assignment can never be deletedassignment_students.assignment_id FK ON DELETE RESTRICTPer-pupil rows only exist once published; the database itself refuses to orphan work already assignedThe service refuses first with 409 ASSIGNMENT_DELETE_NOT_ALLOWED for any status other than draft/scheduled (assignment-write.service.ts:296-302); the FK is the backstopauthoring integration spec
Subject/class/session cannot disagreeThree composite FKs on assignment_classes (§5.2)Makes "assignment for Math targeting a class that doesn't teach Math, or a class of another year" unrepresentablePostgres FK violationauthoring integration spec
At most one draft, one pending attempt per pupil rowPartial unique indexes on assignment_submission_attemptsThe state machine assumes exactly this shape everywhere it reads "the current attempt"Postgres unique violation → 409 ASSIGNMENT_DRAFT_VERSION_CONFLICT in the service path that would create a secondsubmission integration spec
A file's kind decides which parent it belongs to, exclusivelyassignment_files_owner_matches_kind CHECKOne table serves two purposes without ambiguityPostgres CHECK violation (would only occur on a code defect, never through the API)file multer/upload spec
History is append-onlyassignment_history_append_only triggerAn audit trail that can be edited is not an audit trailassignment_history is append-only (SQLSTATE P0001)migration + roster spec
Notification rule can only subtract from the module defaultAssignmentNotifier.notify reads the rule before every send; assignment_notification_rules_teacher_applicable CHECKPredictable, safe defaults404 ASSIGNMENT_NOTIFICATION_RULE_INVALID (unknown event) or 400 ASSIGNMENT_NOTIFICATION_RULE_INVALID (teacher on a non-applicable event)notifier/settings tests
Exactly-once reminder dispatch per (assignment, event, due_at)assignment_reminder_dispatches unique constraintA due-date change starts a fresh reminder cycle without re-sending stale onesInsert conflict = the sweep's own "already sent" signalreminders integration spec
Roster reconciliation never double-processes one assignment concurrentlypg_try_advisory_xact_lock in reconcileTwo overlapping sweep ticks must not race on the same rowsReturns {skipped:true}, retried next tickroster-sync integration spec
A guardian never reaches a submission fileAssignmentFileDownloadService kind checkSubmission content is the pupil's own work404files portal controller / access service

16.6 Tradeoffs, Alternatives, and ADR Notes

DecisionContextChosen OptionAlternativesWhy ChosenTradeoffsRevisit Trigger
Access predicates hand-built in AssignmentAccessServiceImplPeopleAccessService.scopeFor exists and is used elsewhereBespoke SQL builder per predicateReuse PeopleAccessService.scopeForThat resolver's {kind:"all"} branch fails open for any scopeKind:"all" caller holding Students_READ — would leak every pupil's assignment record to any teacher sessionMore code to maintain per predicateOnly if PeopleAccessService itself changes its all semantics
No cache layerAssignments read paths are moderate volume, status is time-derivedDirect-to-Postgres reads with well-chosen partial indexesCache list/detail responsesA cache would reintroduce exactly the staleness the derived-status design avoidsSlightly more DB loadIf read volume genuinely outgrows the indexes
Structural file scan in a forked child-process pool, not an external AVMalformed office/PDF/image files can hang or OOM a naive parserA child_process.fork() pool with a deadline kill and an RSS watchdog, isolated from the API process's own heapShell out to ClamAV or similar; run scans in worker_threads insteadNo external dependency to provision/monitor; a crashed or runaway scan cannot corrupt the API process's memory the way a shared-heap worker thread couldA determined attacker's payload class not covered by this scanner would need a real AV productIf the school requires a compliance-grade AV integration
Reminder dedupe via a unique constraint insert, not a Redis lockMultiple API processes could race the same sweep tickPostgres unique constraint as the claim mechanismRedis-based distributed lockNo new infrastructure dependency; the DB is already the source of truthSlightly more DB writesIf reminder volume grows enough to need a queue-based fanout
Teacher cancel is a settings toggle, not a separate permission codeSchools disagree on whether teachers should cancel their own workassignment_settings.teachers_can_cancel booleanA dedicated AssignmentCancel grant to teachersSimpler for a school to flip one setting than manage a role grantCoarser than per-teacher granularityIf a school wants per-role cancel control

16.7 Operational Runbook

OperationHow to InspectHealthy StateFailure SignalRecovery
Publish-due sweepServer log [assignments] enqueue/[start] assignment.publish_dueRuns every minute, [success] entriesNo [start] entries for minutes at a time; repeated [failure]Check Redis connectivity; check assignments_due_publish_idx scan cost; a stuck scheduled assignment logs publish_failed history with a reason
Reminder sweepServer log, assignment_reminder_dispatches row countNew rows appearing near due datesNo new rows despite due assignmentsCheck assignment_notification_rules — a disabled event is a legitimate silent no-op, not a failure
Roster syncServer log [start] assignment.roster_sync{added, moved, markedNotApplicable, revived, flagged} non-error resultsRepeated {skipped:true} for one assignment across many ticksA held advisory lock past its transaction's life means a stuck transaction — check for a long-running lifecycle write on that assignment
File scan queueassignment_files where scan_status='pending' and updated_at older than ASSIGNMENT_SCAN_STALE_MSEmpty or shrinkingGrowing backlogThe 10-minute recovery sweep should clear it; check worker pool health/memory if it does not
DB constraintspsql against assignment_* tablesEvery CHECK/FK described in §5.2 holdsA constraint violation surfacing as an unhandled 500 rather than a mapped domain errorTrace back to the service path that skipped a validation the DB is now the only backstop for; file a defect, do not relax the constraint

16.8 Backend Risk Register

RiskAreaImpactCurrent MitigationRemaining Gap
Race between a lifecycle write and a roster sweep tickRoster/lifecycleDuplicate or missed roster rowsShared advisory lock + assignments FOR SHARE/FOR UPDATE lock orderNone identified — the lock order is proven by the deadlock-free design note in the source
A malicious file evading the structural scannerFile uploadA harmful file marked cleanType-specific scanners covering 13 documented reject categories, sniffed-MIME cross-checkNo signature-based AV; a genuinely novel payload class is a residual risk common to any structural-only scanner
Notification storm from a large audienceNotifierExcessive send volume in one transactionChunking at ASSIGNMENT_NOTIFY_CHUNK (500), audience capped at ASSIGNMENT_MAX_AUDIENCE (3000)None identified
Stuck scan worker consuming memoryScan pipelineWorker pool exhaustionBounded old/young generation memory ceilings, ASSIGNMENT_SCAN_DEADLINE_MS/ASSIGNMENT_SCAN_LOCK_DURATION_MSNone identified

17. Zero-Omission Backend Checklist

  • Every file in the module directory is represented (§4) or covered by its owning service in §6/§16.1.
  • Every controller, service, provider, processor, scheduler, helper, mapper, DTO family, enum, and schema table is documented.
  • Every method with business behavior in the core lifecycle/submission/review path has a code-flow narrative or an equivalent table entry.
  • Every table has field-level detail (§5.2).
  • Every index, constraint, relation, and delete behavior has rationale (§5.2, §16.4).
  • Every lifecycle/status transition has a state diagram and transition table (feature doc §7, referenced here).
  • Every read/write/action/job flow has sequence notes (§7, §9).
  • Every business invariant is cataloged (§16.5).
  • Every cache key (none exist — stated), queue job, and external call is documented (§8, §9).
  • Every architectural tradeoff is documented with alternatives and revisit triggers (§16.6).
  • Every operational failure mode has a runbook entry (§16.7).

18. Backend Completion Checklist

  • Module boundaries are documented (§2).
  • Every controller, service, DTO, schema file, job, and event is covered.
  • Every database table has a field table and relationship diagram (§5).
  • Every runtime flow has a diagram and branch notes (§7).
  • API and feature/flows docs are linked (§ See Also).
  • No claim is made without a source file or documented source reference.

See Also

On this page

Assignments Backend Documentation1. Documentation Evidence2. Backend Scope and BoundariesOwnsDoes Not OwnSource of Truth3. Module Composition4. File and Directory Map5. Data Model5.1 Schema Source5.2 Tables and Collectionsassignment_settingsassignment_categoriesassignment_notification_rulesassignmentsassignment_classesassignment_studentsassignment_submission_attemptsassignment_filesassignment_linksassignment_historyassignment_reminder_dispatchesassignment_transfer_reviews5.3 Relationship Diagram6. Services and Responsibilities6.1 AssignmentAudienceResolver (assignment-audience.resolver.ts)6.2 AssignmentAccessServiceImpl (assignment-access.service.ts)6.3 AssignmentRosterServiceImpl (assignment-roster.service.ts)6.4 AssignmentLifecycleService (authoring/core/assignment-lifecycle.service.ts)6.5 AssignmentSubmissionService (submissions/core/assignment-submission.service.ts)6.6 AssignmentReviewService (submissions/core/assignment-review.service.ts)6.7 AssignmentFileUploadServiceImpl and the scan pipeline6.7a The scan pipeline runtime6.8 AssignmentNotifier (assignment-notifier.service.ts)7. Runtime Flows7.1 Publish now7.2 Publish-due sweep7.3 Submit7.4 File scan8. Caching9. BullMQ, Schedulers, and Async Work10. Realtime and Events11. Security, Auth, and Abuse Controls13. Error Handling13.1 How errors are produced13.2 Codes that are never returned over HTTP14. Observability15. Testing and Validation16. Mandatory Backend Deep-Dive Pack16.1 Submodule Coverage Matrix16.2 UML and Architecture Diagram Pack16.3 Code Flow Narrative — AssignmentLifecycleService.publish16.4 Data Layer Deep Dive16.5 Business Logic and Invariant Catalog16.6 Tradeoffs, Alternatives, and ADR Notes16.7 Operational Runbook16.8 Backend Risk Register17. Zero-Omission Backend Checklist18. Backend Completion ChecklistSee Also