Subjects Backend Documentation
Backend architecture, data model, services, cache, runtime rules, and operational behavior for the school's subject catalogue and its per-class teaching assignments.
Subjects Backend Documentation
1. Documentation Evidence
| Area | Files Inspected | Verified Details |
|---|---|---|
| Module wiring | apps/api/src/modules/academic-structure/academic-structure.module.ts | Imports, providers, controller composition, why the subject surfaces are declared here and not in a module of their own. |
| Controllers | subjects/subjects.controller.ts, class-subjects/class-subjects.controller.ts | Route ownership, permission decorators, thin-controller boundaries. |
| Services | subjects/subjects-read.service.ts, subjects/subjects-write.service.ts, class-subjects/class-subjects-read.service.ts, class-subjects/class-subjects-write.service.ts | Business logic, validation order, transactions, cache, response mapping. |
| DTOs | dto/subject.dto.ts, dto/class-subject.dto.ts | Request/response contracts and validation. |
| Schema | packages/db/src/schema/school/subjects.ts | Tables, enums, constraints, indexes, triggers. |
| Migrations | packages/db/src/migrations/0017_subject_module.sql, 0018_subject_permission_grants.sql | DDL history and the permission-grant backfill for an already-deployed database. |
| Cache | apps/api/src/common/utils/cache-key.util.ts, apps/api/src/services/redis/redis.service.ts | Key construction, TTLs, fail-soft invalidation. |
| Authorization | apps/api/src/modules/people/shared/people-access.service.ts, apps/api/src/modules/mobile/shared/portal-audience.ts, packages/db/src/seed/seed-auth.ts, packages/db/src/authorization/permission-catalog.ts | Row scope, portal-audience assertion, per-role grants, the module's place in the permission catalogue. |
2. Backend Scope and Boundaries
Owns
- The
subjectstable: the school-wide catalogue of what is taught, independent of any class. - The
class_subjectstable: one class teaching one subject, with its own teacher and its own effective mark scheme. - Admin CRUD on both tables (
SubjectsController,ClassSubjectsController), gated by theSubjectsandClassSubjectspermission modules. - Two audience-scoped consumer reads that sit in a different NestJS module (
mobile/student,mobile/teacher) but consume this module's DTOs and tables directly: a pupil's and a teacher's own "my subjects" list. - The Redis cache for the subject catalogue (list, options, single-row reads).
Does Not Own
classes,grades,sections,rooms— owned by the rest ofAcademicStructureModule; this surface only reads them, to join and to validate aclassIdat assignment time.staff,designations,users— owned byPeopleModule; read-only, to validate and render ateacherId.academic_sessions— owned by the school-profile/session module; read-only, to resolve "the current session" for the two consumer reads.- Row-level access scoping logic itself —
PeopleAccessServiceinPeopleModuleowns thescopeFor/applyScopemechanism this module calls. - Portal-audience assertion logic —
portal-audience.tsinmodules/mobile/sharedownsassertPortalAudience/assertTeacherStaffRow. - Permission catalogue definition —
packages/db/src/authorization/permission-catalog.tsis the single source for module/action names; this surface only consumesSubjects_*/ClassSubjects_*codes from it.
Source of Truth
| Concern | Source of Truth | Notes |
|---|---|---|
| A subject's identity and defaults | subjects table | publicId is the only externally-addressable identifier; the integer id never leaves the process. |
| An assignment's effective configuration | class_subjects table | type/maxMarks/passMarks are copied at assignment time — never re-derived from the parent subject on read. |
| Optimistic-concurrency token | subjects.version / class_subjects.version, each server-owned and trigger-maintained | Never accepted or computed by the API; a client only ever echoes back the value it last read. |
| "Is this the current academic session" | academic_sessions.is_current | Consulted by both consumer reads when no session id is supplied; this module does not own that table. |
| "May this actor see this row" | PeopleAccessService.scopeFor(actor, "classSubjects", heldPermissions) for the two admin list reads; assertPortalAudience/assertTeacherStaffRow for the two consumer reads | Two different mechanisms for two different audiences — see 11. |
| Live per-request count of a subject's active assignments | A correlated SQL subquery against class_subjects, computed on every read, never stored | SubjectDto.classCount. |
3. Module Composition
| Module | Type | Path | Controllers | Providers | Exports | Responsibility |
|---|---|---|---|---|---|---|
AcademicStructureModule | Aggregate/leaf hybrid | apps/api/src/modules/academic-structure/academic-structure.module.ts | GradesController, SectionsController, RoomsController, ClassesController, ClassEnrollmentsController, SubjectsController, ClassSubjectsController | All eight services listed below | ClassesReadService | Composes grades/sections/rooms/classes/enrolments/subjects/class-subjects. Declared as the leaf so Swagger's ADMIN_MODULES.include (which does not recurse) sees the subject routes. |
StudentPortalModule | Leaf | apps/api/src/modules/mobile/student/student-portal.module.ts | StudentPortalController, StudentSubjectsPortalController | StudentPortalService | — | Mounted at /api/mobile/student; its subjects controller reads subjects/class_subjects directly without importing anything from AcademicStructureModule. |
TeacherPortalModule | Leaf | apps/api/src/modules/mobile/teacher/teacher-portal.module.ts | TeacherPortalController, TeacherSubjectsPortalController | TeacherPortalService | — | Mounted at /api/mobile/teacher; same pattern as the student portal. |
AcademicStructureModule imports EnrollmentsCoreModule, PeopleModule, RoleModule and ActivityModule. The subject surfaces add no new import: ClassSubjectsReadService needs PeopleAccessService and PeoplePermissionsService, both of which already arrive through the existing PeopleModule import — so the subject controllers introduce no new edge to the module dependency graph.
4. File and Directory Map
apps/api/src/modules/academic-structure/
academic-structure.module.ts
subjects/
subjects.controller.ts
subjects-read.service.ts
subjects-write.service.ts
class-subjects/
class-subjects.controller.ts
class-subjects-read.service.ts
class-subjects-write.service.ts
dto/
subject.dto.ts
class-subject.dto.ts
index.ts
__tests__/
subjects-read.service.integration.spec.ts
subjects-write.service.integration.spec.ts
class-subjects-read.service.integration.spec.ts
class-subjects-write.service.integration.spec.ts
class-subjects-authorization.integration.spec.ts
apps/api/src/modules/mobile/
student/
student-portal.controller.ts (StudentSubjectsPortalController)
student-portal.service.ts (listMySubjects)
student-portal.module.ts
teacher/
teacher-portal.controller.ts (TeacherSubjectsPortalController)
teacher-portal.service.ts (listMySubjects)
teacher-portal.module.ts
shared/
portal-audience.ts
__tests__/
portal-subjects.integration.spec.ts
packages/db/src/schema/school/subjects.ts
packages/db/src/migrations/0017_subject_module.sql
packages/db/src/migrations/0018_subject_permission_grants.sql| File | Purpose | Key Exports | Notes |
|---|---|---|---|
subjects/subjects.controller.ts | Admin CRUD routes for the subject catalogue | SubjectsController | options declared ahead of :publicId deliberately, for route-matching order. |
subjects/subjects-read.service.ts | List/options/single-row reads, caching | SubjectsReadService, SubjectRow | loadDto is public so the write service can re-read inside its own open transaction. |
subjects/subjects-write.service.ts | Create/update/delete, constraint translation | SubjectsWriteService | Splits pre-write application checks (reactivation collisions) from post-write constraint translation (name/code taken). |
class-subjects/class-subjects.controller.ts | Admin routes for assignments, under three different path prefixes | ClassSubjectsController | One controller, @Controller() with no base path, because the swagger gate forbids one tag shared by two controllers. |
class-subjects/class-subjects-read.service.ts | Per-subject and per-class assignment lists, single-row load | ClassSubjectsReadService, ClassSubjectRow | Applies PeopleAccessService scope before every query is built. |
class-subjects/class-subjects-write.service.ts | Batch assign, PATCH, delete | ClassSubjectsWriteService, ResolvedItem, ExistingRow | assign() alone is over the repo's 500-line soft cap and is documented as split-exempt at the top of the file. |
dto/subject.dto.ts | Subject request/response/query shapes | SubjectDto, SubjectOptionDto, SubjectOptionsPayloadDto, CreateSubjectDto, UpdateSubjectDto, ListSubjectsQueryDto, SubjectOptionsQueryDto, SUBJECT_TYPES, SubjectType | Field-level validators mirror the CHECK constraints in JavaScript regex spelling. |
dto/class-subject.dto.ts | Assignment request/response/query shapes, plus the two consumer DTOs | ClassSubjectDto, ClassSubjectClassDto, AssignSubjectItemDto, AssignSubjectToClassesDto, AssignmentResultDto, AssignmentItemErrorDto, UpdateClassSubjectDto, ListClassSubjectsQueryDto, MySubjectDto, MySubjectsPayloadDto, MySubjectsQueryDto, ASSIGNMENT_BATCH_MAX | MySubjectDto is written field-by-field, deliberately not a Pick<> of ClassSubjectDto. |
mobile/student/student-portal.controller.ts | StudentSubjectsPortalController, a second controller class sharing @Controller("student") | StudentSubjectsPortalController | Split out purely so its swagger tag does not concatenate with StudentPortalController's. |
mobile/teacher/teacher-portal.controller.ts | TeacherSubjectsPortalController, same pattern | TeacherSubjectsPortalController | Same reasoning as the student side. |
mobile/shared/portal-audience.ts | Audience assertions for every consumer portal route | assertPortalAudience, assertTeacherStaffRow, assertStaffRow, PortalAudience | Deliberately does NOT delegate to PeopleAccessService.scopeFor — see 11. |
5. Data Model
5.1 Schema Source
packages/db/src/schema/school/subjects.ts
subjectTypeEnum -- "compulsory" | "optional"
subjects -- the catalogue
classSubjects -- one class teaching one subject
subjectsRelations
classSubjectsRelations5.2 Tables and Collections
subjects
| Column/Field | Type | Nullable | Default | Index/Constraint | Relation | Notes |
|---|---|---|---|---|---|---|
id | serial | No | auto-increment | PK | referenced by class_subjects.subject_id | Internal only, never leaves the process. |
public_id | uuid | No | uuid7() (app-generated) | subjects_public_id_unique (full unique) | — | The only externally-addressable identifier. |
name | text | No | — | subjects_name_unique (partial, case-folded, WHERE is_active), subjects_name_format CHECK | — | Max 128 chars, no surrounding whitespace, no control characters. |
code | text | No | — | subjects_code_unique (partial, WHERE is_active), subjects_code_format CHECK | — | ^[A-Z0-9_]{1,16}$, case-sensitive (already restricted to one case, so no lower() expression index needed). Stable across a rename. |
type | subject_type enum | No | — | — | — | compulsory or optional; the school's default for new assignments. |
default_max_marks | integer | No | 100 | subjects_default_marks_bounded CHECK (with default_pass_marks) | — | 1 <= value <= 1000. |
default_pass_marks | integer | No | 40 | same CHECK | — | 0 <= value <= default_max_marks. |
is_active | boolean | No | true | subjects_is_active_idx (btree) | — | Retirement flag; both unique indexes above are partial on this column. |
version | bigint (mode number) | No | 0 | never indexed | — | Optimistic-concurrency token, maintained ONLY by the subjects_bump_row_version trigger. Never written by the ORM or accepted from a client. |
created_at | timestamptz | No | now() | — | — | — |
updated_at | timestamptz | No | now() | — | — | Owned by the same trigger that owns version — $onUpdateFn is deliberately absent from the Drizzle column definition, because a raw-SQL write would never trigger an ORM-level default while the database trigger fires unconditionally. |
Money/marks units: plain integers, no currency, no fractional marks.
Timezone: updated_at/created_at are timestamptz, stored and compared in UTC.
Soft delete: none — this table hard-deletes.
class_subjects
| Column/Field | Type | Nullable | Default | Index/Constraint | Relation | Notes |
|---|---|---|---|---|---|---|
id | uuid (uuidv7 PK) | No | app-generated | PK | — | The uuid IS the public identifier — no separate public_id, matching student_class_enrollments. |
class_id | integer | No | — | FK → classes.id, ON DELETE restrict | many-to-one classes | Restrict, not cascade: a class with assignments cannot be deleted out from under them. |
subject_id | integer | No | — | FK → subjects.id, ON DELETE restrict | many-to-one subjects | Same reasoning; this is also what makes Subjects_DELETE refuse a subject in use. |
teacher_id | uuid | Yes | NULL | FK → staff.id, ON DELETE set null | many-to-one staff | Nullable because an admin often assigns a subject to five classes and fills teachers in afterwards. set null, not restrict, because staff soft-deletes and ON DELETE never fires on that path — the read side reports employmentStatus instead. |
type | subject_type enum | No | — | — | — | The EFFECTIVE type for this class, copied from the subject at assignment time — not kept in sync afterward. |
max_marks | integer | No | — | class_subjects_marks_bounded CHECK (with pass_marks) | — | 1 <= value <= 1000. |
pass_marks | integer | No | — | same CHECK | — | 0 <= value <= max_marks. |
is_active | boolean | No | true | class_subjects_class_active_idx (partial WHERE is_active) | — | Suspension flag — DELETE removes the row; this stops the class running the subject while keeping the configuration. |
version | bigint (mode number) | No | 0 | never indexed | — | Same trigger, same rules as subjects.version. |
created_at | timestamptz | No | now() | — | — | — |
updated_at | timestamptz | No | now() | — | — | Owned by class_subjects_bump_row_version, same $onUpdateFn-absence reasoning as above. |
No JSON columns on either table. No denormalized copy of academic_session_id on class_subjects — deliberately: the table's one invariant ("one assignment per class+subject") does not span the session join the way student_class_enrollments does, so there is nothing a copy would need to keep true.
Index rationale
| Index/Constraint | Columns | Type | Query/Invariant Supported | Tradeoff |
|---|---|---|---|---|
subjects_name_unique | lower(name) | unique, partial WHERE is_active | No two ACTIVE subjects share a name, case-insensitively; a retired name is reusable | Write overhead on every insert/update touching name; the expression form means Postgres cannot use it for a plain equality lookup on the raw column |
subjects_code_unique | code | unique, partial WHERE is_active | No two ACTIVE subjects share a code | Same as above, no case-folding needed since code is already restricted to one case |
subjects_is_active_idx | is_active | btree | The catalogue's own active/archived filter | Small write overhead; the column has low cardinality so this mainly helps the partial-index planner, not a standalone filter scan |
class_subjects_identity_unique | class_id, subject_id | unique, NOT partial | Prevents two rows for one (class, subject) pair existing concurrently, in ANY state — what makes the assign endpoint's upsert target well-defined | A suspended row occupies the identity slot until deleted or revived; a genuinely-new assignment for the same pair must revive it |
class_subjects_class_active_idx | class_id | btree, partial WHERE is_active | The class detail view's "what does this class currently teach" scan | Excludes suspended rows by design — a separate full scan is needed for history |
class_subjects_subject_idx | subject_id | btree, NOT partial | Both the subject detail's assigned-classes list and SubjectDto.classCount's underlying count-all pattern (ON DELETE restrict also counts every row) | Slightly larger index than a partial one would be, traded for correctness of the delete pre-check |
class_subjects_teacher_idx | teacher_id | btree, partial WHERE teacher_id IS NOT NULL | The teacher portal's "my subjects" read (WHERE teacher_id = :staffId) | Skips the common case of an unassigned teacher slot |
5.3 Relationship Diagram
6. Services and Responsibilities
6.1 SubjectsReadService
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
findAll(query) | SubjectsController.findAll | subjects filtered/sorted, correlated class_subjects count | — | Cache read/write (getSoft/setSoft, 300s) | None thrown |
findOptions(query) | SubjectsController.findOptions | subjects, active-only by default | — | None (deliberately uncached — a bounded reference-table scan is already cheap) | None thrown |
findOne(publicId) | SubjectsController.findOne | subjects by publicId, correlated count | — | None | SUBJECT_NOT_FOUND |
loadDto(executor, publicId) | findOne, and SubjectsWriteService after every write | Same as above, but against the caller's DbExecutor (pooled connection or open transaction) | — | None | SUBJECT_NOT_FOUND |
Input normalization: search terms are escaped with escapeLikePattern before being wrapped in %...% for ILIKE, so a literal % or _ in a subject name cannot turn a filter into an unintended wildcard.
Validation order: none at this layer — validation is the DTO pipe's job; this service only reads.
Transaction boundaries: none — every read here is a single statement or a single correlated query; loadDto accepts an executor so a caller inside a transaction can pass its tx instead of racing a second pooled connection against an uncommitted write.
Idempotency: reads are naturally idempotent.
Response mapping: toDto is field-by-field, never a row spread — an added column on subjects cannot leak into the response by accident.
Fail-open/fail-closed: cache failures fail OPEN to the database (a miss, never an error); a genuinely missing row fails CLOSED with 404.
Logger usage: none directly — Redis failures are logged inside RedisCacheService itself.
6.2 SubjectsWriteService
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
create(dto) | SubjectsController.create | none before insert (uniqueness is index-enforced) | subjects (insert) | Cache prefix invalidated | SUBJECT_MARKS_INVALID, SUBJECT_NAME_TAKEN, SUBJECT_CODE_TAKEN, SUBJECT_NAME_INVALID, SUBJECT_CODE_INVALID (backstop), WRITE_RETURNED_NO_ROW |
update(publicId, dto) | SubjectsController.update | subjects (locked inside tx), reactivation-collision pre-checks | subjects (update, version-gated) | Cache prefix invalidated | SUBJECT_NOT_FOUND, SUBJECT_MARKS_INVALID, SUBJECT_NAME_TAKEN_ON_REACTIVATE, SUBJECT_CODE_TAKEN_ON_REACTIVATE, SUBJECT_VERSION_CONFLICT |
remove(publicId) | SubjectsController.remove | subjects (existence), class_subjects (total/active counts) | subjects (delete) | Cache prefix invalidated | SUBJECT_NOT_FOUND, SUBJECT_IN_USE |
Input normalization: name is trimmed server-side on both create and update, independent of the DTO's own no-surrounding-whitespace validator (belt and braces — the validator refuses a padded value before it reaches the service at all, but the service still trims defensively).
Validation order: "not found" is checked first (no constraint could turn an unknown publicId into anything better than a zero-row update); "already taken" is left to the database's partial unique indexes and translated after the fact, which is what makes that check atomic under a race; reactivation collisions are checked application-side, pre-write, because the partial indexes cannot see them yet at check time.
Transaction boundaries: create and update each run inside this.db.transaction(...); remove does not need one — its own pre-check and delete are each single statements, and a TOCTOU race is still caught by the foreign key at delete time.
Idempotency: none claimed — a second identical create produces a second row (subject to the uniqueness constraint) or a conflict.
Retry behavior: none built in; a 409 is the caller's signal to reload and retry with a fresh version.
Response mapping: after every write, the service re-reads the row through SubjectsReadService.loadDto, passing its own transaction handle so the response reflects the just-written, not-yet-committed state.
Fail-open/fail-closed: writes fail closed — any constraint violation or business-rule failure aborts the transaction.
Logger usage: none directly.
6.3 ClassSubjectsReadService
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
findForSubject(actor, subjectPublicId, query) | ClassSubjectsController.findForSubject | subjects (resolve), row-scope predicate, class_subjects joined to classes/grades/sections/staff/users/designations | — | None | SUBJECT_NOT_FOUND, PAGINATION_LIMIT_INVALID |
findForClass(actor, classPublicId, query) | ClassSubjectsController.findForClass | classes resolved THROUGH the scope predicate, then the same joined class_subjects query | — | None | CLASS_NOT_FOUND, PAGINATION_LIMIT_INVALID |
loadDto(executor, id) | Controller callers via the write service, and internally | Same joined query, single row | — | None | CLASS_SUBJECT_NOT_FOUND |
Input normalization: none beyond what the DTO pipe already did.
Validation order: assertPaginated runs FIRST in both list methods, before any query is built — a pagination-disabled request never reaches the database.
Transaction boundaries: none of its own; loadDto accepts an executor so the write service can call it from inside an open transaction.
Idempotency: reads are naturally idempotent.
Retry behavior: none.
Response mapping: toDto/toClassDto are both field-by-field; teacher maps to null when teacherId is absent, never an empty object.
Fail-open/fail-closed: row scope fails CLOSED — default and every unhandled scopeKind in PeopleAccessService.scopeFor resolve to selfOnlyScope, which for classSubjects is sql\false`(no rows), notall`.
Logger usage: none.
6.4 ClassSubjectsWriteService
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
assign(actor, subjectPublicId, items) | ClassSubjectsController.assign | subjects, classes (batch), staff+designations (batch), class_subjects (label collisions, existing rows for the identity pair) | class_subjects (batch insert-or-update) | Cache prefix invalidated after commit | SUBJECT_NOT_FOUND, SUBJECT_INACTIVE, CLASS_SUBJECT_BATCH_INVALID (carrying per-item errors[]), WRITE_RETURNED_NO_ROW |
update(id, dto) | ClassSubjectsController.update | class_subjects (version-gated locked read), subjects (revival precondition), sibling class_subjects (label collision on revival) | class_subjects (update) | Cache prefix invalidated | CLASS_SUBJECT_NOT_FOUND, CLASS_SUBJECT_VERSION_CONFLICT, CLASS_SUBJECT_TEACHER_NOT_FOUND, CLASS_SUBJECT_TEACHER_NOT_TEACHING, SUBJECT_INACTIVE, CLASS_SUBJECT_DUPLICATE_LABEL, CLASS_SUBJECT_MARKS_INVALID |
remove(id) | ClassSubjectsController.remove | class_subjects (existence) | class_subjects (delete) | Cache prefix invalidated | CLASS_SUBJECT_NOT_FOUND |
Input normalization: Object.hasOwn is used throughout assign/update to distinguish an ABSENT field (keep existing value) from an explicit null (clear the teacher) — a plain dto.teacherId !== undefined check cannot make that distinction because undefined covers both "not sent" and, in principle, "sent as null and then coerced", so the code deliberately uses the stricter own-property check.
Validation order in assign, ten numbered steps: (1) resolve the subject, refuse if missing/inactive; (2-4) resolve every class in one query, collect not-found/inactive/duplicate-in-batch; (5) resolve every teacher in one query, collect not-found/not-teaching; (5 cont./7) default absent type/marks from the subject, validate the cross-column marks bound per item; (6) load label collisions and existing rows for version/suspension decisions; (8) if ANY item failed, throw with the full error list — nothing written; (9) upsert every surviving item; (10) invalidate the cache after commit.
Transaction boundaries: assign and update both run inside this.db.transaction(...); remove does not need one.
Idempotency: re-sending the identical batch body is idempotent (every field already matches, the SET clause becomes a no-op that still fires the version trigger); re-sending a SUBSET is a partial edit by design, not an idempotency violation.
Retry behavior: none built in beyond the caller reloading and resubmitting with fresh versions.
Response mapping: assign returns { assigned: ClassSubjectDto[], skippedSuspended: string[] }; every other method returns a single ClassSubjectDto or nothing (remove).
Fail-open/fail-closed: validation collects rather than short-circuits inside assign, but the WRITE itself is fail-closed — any single collected error blocks the entire batch.
Logger usage: none directly.
7. Runtime Flows
7.1 Assign a subject to classes (the module's most complex flow)
| Step | Code Path | Behavior | Failure Case |
|---|---|---|---|
| 1 | ClassSubjectsController.assign | Receives the request, delegates with the caller's AuthUser. | DTO validation error → 400. |
| 2 | ClassSubjectsWriteService.assign, step 1 | Resolves the subject; refuses if missing or archived. | SUBJECT_NOT_FOUND / SUBJECT_INACTIVE. |
| 3 | steps 2-5 | Resolves classes and teachers in two batched queries, collecting per-item errors rather than throwing on the first. | Various item-level codes, collected. |
| 4 | step 6 | Loads label collisions and any existing row per surviving class, to decide version conflicts and suspension. | CLASS_SUBJECT_DUPLICATE_LABEL, CLASS_SUBJECT_VERSION_CONFLICT, collected. |
| 5 | step 8 | If anything was collected, throws with the full list. Nothing has been written yet. | 422 CLASS_SUBJECT_BATCH_INVALID. |
| 6 | step 9 | Upserts every surviving item on the identity index; a suspended row not asked to revive is skipped and reported. | WRITE_RETURNED_NO_ROW on an impossible zero-row RETURNING. |
| 7 | step 10 | Commits, then invalidates the subjects cache prefix (fail-soft). | Redis failure logged, never surfaced to the caller. |
7.2 Update a subject (optimistic concurrency, reactivation)
| Step | Code Path | Behavior | Failure Case |
|---|---|---|---|
| 1 | SubjectsWriteService.update | Opens a transaction, reads the existing row. | SUBJECT_NOT_FOUND. |
| 2 | same | Merges the patch onto the row for the marks cross-check. | SUBJECT_MARKS_INVALID. |
| 3 | same | If reactivating, checks name and code against every OTHER active subject. | SUBJECT_NAME_TAKEN_ON_REACTIVATE / SUBJECT_CODE_TAKEN_ON_REACTIVATE. |
| 4 | same | Runs the version-gated UPDATE. | Zero rows → re-read to tell gone from stale. |
| 5 | same | Re-reads the just-written row inside the same transaction and returns it. | — |
7.3 Pupil reads their own subjects
Success path, validation failures, permission failures, missing entity, duplicate request, cache miss/hit, async enqueue, race/concurrency, guest vs logged-in — covered per flow:
- Success path: as diagrammed above for each of the three write/read families.
- Validation failures: caught by the global
ValidationPipebefore any handler runs — malformedpublicId(not a UUID forclass-subjects/:id), out-of-range marks, bad enum values. - Permission failures:
RoleGuardrefuses before the controller method body runs, for every admin route; the two consumer routes are refused inside the service byassertPortalAudience/assertTeacherStaffRowinstead, since they carry no@Permissions(). - Missing entity: every read/write that resolves a
publicIdoridthrows the entity's own_NOT_FOUNDcode first. - Duplicate request: the assignment upsert is the only place a duplicate submission is meaningfully idempotent — see 6.4.
- Cache hit/miss: only
SubjectsReadService.findAllis cached; every other read in this module is always live. - Async enqueue: not applicable — no queue exists on this module.
- Race/concurrency:
versioncolumns plus the identity unique index are the two mechanisms; see 12.3 in the feature doc. - Guest vs logged-in: no guest branch exists anywhere in this module; the global
JwtAuthGuardrefuses every unauthenticated request before any handler runs.
8. Caching
| Cache Key Pattern | Builder | Value | TTL | Invalidation | Caller |
|---|---|---|---|---|---|
academic-structure:subjects:list:q:<term-or-x>-type:<type-or-x>-active:<bool-or-x>-pagination:<0/1>-page:<n>-size:<n> | CacheKeyUtil.build("academic-structure:subjects:list:", [...]) | { data: SubjectDto[], totalCount: number } | 300s (SubjectsReadService.CACHE_TTL_SECONDS) | Any write on subjects or class_subjects calls delPatternSoft("academic-structure:subjects:*") | SubjectsReadService.findAll |
findOptions and findOne/loadDto are deliberately NOT cached — findOptions is a bounded scan of a reference-sized table, cheap enough on its own; single-row reads are called from inside the write path's own transaction and would need to be invalidated by exactly the same writes anyway.
Deterministic segment order: CacheKeyUtil.build takes an ORDERED tuple list, so the same logical query always produces the same key string — q, type, active, pagination, page, size, in that fixed order.
Cache hit behavior: returned as-is, no re-validation against the database.
Cache miss behavior: full query runs, result is written back with a 300s TTL.
Serialization shape: JSON.stringify/JSON.parse inside RedisCacheService.getSoft/setSoft — Date fields round-trip as ISO strings, which the response DTO's @ApiProperty() readonly createdAt!: Date typing does not itself enforce at runtime.
Invalidation on mutations: EVERY writer on either table calls delPatternSoft("academic-structure:subjects:*") — SubjectsWriteService.create/update/remove AND all three ClassSubjectsWriteService methods, because SubjectDto.classCount is a live count over class_subjects and every one of those writers moves it.
Failure handling: fail-soft in both directions — a cache-read failure is treated as a miss (never surfaced), a cache-write or invalidation failure is logged as a WARN and otherwise swallowed, so a Redis outage degrades this module to "every read hits Postgres", never to an error.
9. BullMQ, Schedulers, and Async Work
Not applicable. No queue, job, or scheduled task reads or writes subjects or class_subjects.
10. Realtime and Events
Not applicable. Neither table participates in the realtime/outbox event contract (modules/realtime/shared/realtime.contract.ts); the only event that surface currently carries is feedback.submitted, unrelated to this module.
11. Security, Auth, and Abuse Controls
- Guards:
JwtAuthGuard(global) andRoleGuardon every admin route inSubjectsController/ClassSubjectsController. The two consumer routes carryJwtAuthGuard+RoleGuardas well, but declare NO@Permissions()—RoleGuardreturnstruefor a handler with no declared permission that sits on theNO_PERMISSION_ADMIN_HANDLERSallowlist, so the actual boundary for those two routes is entirely inside the service. - Permissions:
Subjects_READ/_CREATE/_UPDATE/_DELETE/_RESTOREandClassSubjects_READ/_CREATE/_UPDATE/_DELETE/_RESTORE— five actions each, generated automatically bybuildPermissionCatalog()overPERMISSION_MODULES._RESTOREon both is unreachable: neither table has a soft-delete column, and no controller declares a restore route. - Guest identity: none — no route in this module accepts an unauthenticated caller.
- Admin identity:
req.userplusactiveRole, resolved byJwtAuthGuard/RoleGuard; superadmin bypasses every permission and scope check viaactor.activeRole?.isSuperadmin. - Rate limits: none declared specifically for this module.
- Anti-abuse rules: the 1-100 item cap on
AssignSubjectToClassesDto.items(ASSIGNMENT_BATCH_MAX) bounds the size of a single write, matching the admin grid's own page size so the over-size case is unreachable from the panel and needs no dedicated error code — the global pipe's400covers it. - Input normalization: server-side name trimming on create/update; regex validators on
name/codemirror the database CHECK constraints in JavaScript escape-sequence spelling (the two are deliberately NOT identical spellings —\sinside a Drizzlesqltemplate literal does not survive, so the constraint uses POSIX character classes while the DTO uses standard JS regex). - Sensitive data redaction:
ClassTeacherDto(embedded inClassSubjectDto.teacher) carries an employee code and employment status — this is exactly whyClassSubjects_READis withheld from pupils and guardians, and why the pupil/teacher consumer DTO (MySubjectDto) is written field-by-field rather than reusing orPick<>-ing the admin shape. - Audit logs: every mutating admin route is recorded automatically by the global
ActivityAuditInterceptor, keyed off the handler's@Permissions()code (Subjects_CREATE→ moduleSubjects, actionCREATE). No service in this module calls the audit recorder directly — nothing here needs per-fieldchangesdetail beyond what the interceptor already captures. - Fail-closed behavior:
PeopleAccessService.isVisibleTHROWS for the"classSubjects"entity rather than returningtrueor falling through adefaultarm — deliberately, so that if a future single-row check is ever wired up against this entity by mistake, it fails loudly (an unhandled error) instead of silently granting access. The two list reads never callisVisible; they applyscopeFor+applyScopedirectly to the list query, which is the actual and only boundary forclass_subjectsreads today. - The Parent Portal incident and its fix, restated for this module:
PeopleAccessService.scopeFor's "role decides scope before permission does" ordering exists specifically because the seededstaff/teacherroles hold BOTHStudents_READ(scopeall) ANDClassSubjects_READ. Were the ordering reversed, holding the permission would return{kind:"all"}unconditionally, defeating the whole point of scoping. The two consumer "my subjects" routes go further and never touchscopeForat all — they build their own predicate fromassertPortalAudience/assertTeacherStaffRowplus an explicitWHERE, closing the specific hole where a broadly-scoped staff session reading a route literally named/student/subjectswould otherwise see every pupil's schedule.
13. Error Handling
| Error Code | HTTP Status | Thrown By | Condition | Client Action |
|---|---|---|---|---|
SUBJECT_NOT_FOUND | 404 | SubjectsReadService.loadDto, SubjectsWriteService.update/remove, ClassSubjectsReadService.resolveSubject, ClassSubjectsWriteService.assign | No subjects row with that publicId. | Re-fetch the list; the id is stale or was never valid. |
SUBJECT_NAME_TAKEN | 409 | SubjectsWriteService.translate (from subjects_name_unique) | An active subject already has this name, case-insensitively. | Choose a different name, or reactivate/edit the existing one. |
SUBJECT_CODE_TAKEN | 409 | Same, from subjects_code_unique | An active subject already has this code. | Choose a different code. |
SUBJECT_NAME_TAKEN_ON_REACTIVATE | 409 | SubjectsWriteService.update (application pre-check) | Reactivating a subject whose (merged) name now belongs to a DIFFERENT active subject. | Rename before reactivating, or archive the holder first. Message names the holder's code. |
SUBJECT_CODE_TAKEN_ON_REACTIVATE | 409 | Same, for code | As above, for code. | As above. |
SUBJECT_NAME_INVALID | 409 | SubjectsWriteService.translate (from subjects_name_format) | Backstop only — the DTO's regex validators refuse the same input as a 400 first. | Fix the name; a consumer should map both statuses to the same UI message. |
SUBJECT_CODE_INVALID | 409 | Same, from subjects_code_format | Backstop, as above. | As above. |
SUBJECT_MARKS_INVALID | 409 | SubjectsWriteService.create/update (application check), translate (CHECK backstop) | passMarks > maxMarks, evaluated against the merged/final values. Genuinely reachable — no class-validator decorator can compare two fields of one object on a partial update. | Lower passMarks or raise maxMarks. |
SUBJECT_IN_USE | 409 | SubjectsWriteService.remove (pre-check), translate (FK backstop) | At least one class_subjects row references this subject, active or suspended. Message names both the total and the active count. | Remove or reassign every dependent assignment first, or retire instead of deleting. |
SUBJECT_VERSION_CONFLICT | 409 | SubjectsWriteService.update | The supplied version no longer matches the row's current version. | Reload the row and retry with the fresh version. |
SUBJECT_INACTIVE | 422 | ClassSubjectsWriteService.assign, .update (revival branch) | Assigning a retired subject to a class, or reviving an assignment whose subject has since been archived. | Reactivate the subject first, or choose a different one. |
CLASS_SUBJECT_NOT_FOUND | 404 | ClassSubjectsReadService.loadDto, ClassSubjectsWriteService.update/remove | No class_subjects row with that id. | Re-fetch the assignment list. |
CLASS_SUBJECT_MARKS_INVALID | 409 | ClassSubjectsWriteService.update (application check), translate (CHECK backstop) | passMarks > maxMarks on the assignment, post-defaulting. Attributed to the specific grid row. | Fix the marks on that assignment. |
CLASS_SUBJECT_VERSION_CONFLICT | 409 | ClassSubjectsWriteService.update, item-level inside assign | Optimistic concurrency lost on one assignment. | Re-fetch that one row, not the whole grid, and retry. |
CLASS_SUBJECT_TEACHER_NOT_FOUND | 404 (item-level 404-shaped code inside assign's 422; standalone 404 from update) | ClassSubjectsWriteService.assign (item-level), .update, translate (FK backstop) | The teacherId does not resolve, or resolves to a soft-deleted staff row. | Pick a different, live staff member. |
CLASS_SUBJECT_TEACHER_NOT_TEACHING | 422 (item-level inside assign's 422; standalone from update) | Same two methods | The chosen staff member's designation has is_teaching = false. | Pick a staff member whose designation is a teaching one. |
CLASS_SUBJECT_BATCH_INVALID | 400 (empty batch) / 422 (item failures) | ClassSubjectsWriteService.assign | Zero items, or one or more items failed validation. The 422 form carries a per-index errors[]. | Fix every named index; nothing was written. |
CLASS_SUBJECT_DUPLICATE_CLASS | item-level, inside 422 CLASS_SUBJECT_BATCH_INVALID | ClassSubjectsWriteService.assign | The same class appears twice in one batch, deduped on the resolved integer id. | Remove the duplicate item. |
CLASS_SUBJECT_DUPLICATE_LABEL | item-level 422 (inside assign) / standalone 409 (from update's revival branch) | Both write methods | The class already actively teaches a DIFFERENT subject row sharing this name or code — possible because subject name/code uniqueness is partial on is_active. No database constraint can express this; it is a service-level guard only. | Rename or retire the colliding subject first, or do not assign/revive this one to that class. |
CLASS_SUBJECT_IN_USE | 409 | ClassSubjectsWriteService.translate — registered but currently unreachable | Would fire if a future table referenced class_subjects and its FK were violated on delete. Nothing does today. | N/A today; documented so the first dependent table lands as a named refusal, not an unmapped 500. |
WRITE_RETURNED_NO_ROW | 500 | SubjectsWriteService.create, ClassSubjectsWriteService.assign | A RETURNING clause that must always yield a row on success returned none — refused explicitly rather than asserted with !, which would be a TypeError carrying no errorCode. | Not user-actionable; indicates a driver/database anomaly worth investigating server-side. |
PAGINATION_LIMIT_INVALID | 400 | ClassSubjectsReadService.assertPaginated, StudentPortalService.listOwnEnrollments/TeacherPortalService.refuseUnpaginated (shared pattern, not this module's own throw site for the subjects routes) | pagination=false requested on a route that embeds staff identity. | Page through the result instead. |
CLASS_NOT_FOUND | 404 | ClassSubjectsReadService.findForClass | The classPublicId does not resolve within the caller's scope — indistinguishable from a genuinely absent class. | Re-fetch the class list; the id is stale, invalid, or out of scope. |
PERMISSION_INSUFFICIENT | 403 | RoleGuard (admin routes), assertPortalAudience/assertTeacherStaffRow (consumer routes) | Caller lacks the required permission code, or is acting as the wrong portal audience. | Switch to a role that holds the required grant, or use the correct portal route for that audience. |
AUTH_ACTIVE_ROLE_REQUIRED | 403 | RoleGuard, assertActiveRole (portal audience) | Multi-role session with no active role chosen. | Select an active role before retrying. |
PERMISSION_ROLE_NOT_ASSIGNED | 403 | assertActiveRole | Session holds zero roles at all. | Not user-fixable client-side; an administrator must assign a role. |
14. Observability
| Signal | Location | Purpose |
|---|---|---|
| Log | RedisCacheService WARN lines on any getSoft/setSoft/delPatternSoft failure | The only channel a fail-soft cache failure has — no exception surfaces to the caller or to any gate. |
| Audit | MongoDB audit_logs, written by the global ActivityAuditInterceptor | Every mutating request on the admin routes — actor, module (Subjects/ClassSubjects), action, outcome, status code, duration. Consumer "my subjects" reads are not mutations and are not audited. |
| Metric | None named specifically for this module | Relies on whatever process-wide metrics the platform already emits (request duration, status code counts). |
15. Testing and Validation
| Test Type | Files | Coverage |
|---|---|---|
| Integration | apps/api/src/modules/academic-structure/__tests__/subjects-read.service.integration.spec.ts | List/search/filter, pagination, cache behavior, options, single-row read, classCount correctness. |
| Integration | apps/api/src/modules/academic-structure/__tests__/subjects-write.service.integration.spec.ts | Create, update (including reactivation collisions and version conflicts), delete (including in-use refusal), marks validation. |
| Integration | apps/api/src/modules/academic-structure/__tests__/class-subjects-read.service.integration.spec.ts | Per-subject and per-class assignment listing, pagination refusal, scope application. |
| Integration | apps/api/src/modules/academic-structure/__tests__/class-subjects-write.service.integration.spec.ts | Batch assign (insert, upsert, suspension skip/revive, label collisions, version conflicts, teacher resolution), update, delete. |
| Integration | apps/api/src/modules/academic-structure/__tests__/class-subjects-authorization.integration.spec.ts | Row-scope enforcement for ClassSubjects_READ across roles. |
| Integration | apps/api/src/modules/mobile/__tests__/portal-subjects.integration.spec.ts | The two consumer "my subjects" reads, audience assertion, session resolution, empty-state behavior. |
All of the above connect to the real test database via TEST_DATABASE_URL and run the actual Drizzle migrations — no mock of DatabaseModule, Redis, or any @skoolsewa/* package, per this repo's testing rule.
Validation commands:
pnpm --filter @skoolsewa/api test -- academic-structure/__tests__/subjects
pnpm --filter @skoolsewa/api test -- academic-structure/__tests__/class-subjects
pnpm --filter @skoolsewa/api test -- mobile/__tests__/portal-subjects
pnpm --filter @skoolsewa/api test:structure16. Mandatory Backend Deep-Dive Pack
16.1 Submodule Coverage Matrix
| Unit | Type | Owns | Depends On | Called By | Calls | State Touched | Failure Modes |
|---|---|---|---|---|---|---|---|
SubjectsController | Controller | Admin route surface for subjects | SubjectsReadService, SubjectsWriteService | HTTP | Both services | None directly | DTO validation 400 before reaching the handler |
SubjectsReadService | Service | List/options/single-row reads, subjects cache | DATABASE, RedisCacheService | Controller, SubjectsWriteService | Postgres, Redis | subjects (read) | SUBJECT_NOT_FOUND |
SubjectsWriteService | Service | Create/update/delete, constraint translation | DATABASE, RedisCacheService, SubjectsReadService | Controller | Postgres, Redis, SubjectsReadService.loadDto | subjects (write) | Every SUBJECT_* code above |
ClassSubjectsController | Controller | Admin route surface for class_subjects, under three path prefixes | ClassSubjectsReadService, ClassSubjectsWriteService | HTTP | Both services | None directly | DTO validation 400 |
ClassSubjectsReadService | Service | Per-subject/per-class assignment lists, single-row load, row scoping | DATABASE, PeopleAccessService, PeoplePermissionsService | Controller, ClassSubjectsWriteService | Postgres | class_subjects (read) | SUBJECT_NOT_FOUND, CLASS_NOT_FOUND, CLASS_SUBJECT_NOT_FOUND, PAGINATION_LIMIT_INVALID |
ClassSubjectsWriteService | Service | Batch assign, PATCH, delete | DATABASE, RedisCacheService, ClassSubjectsReadService | Controller | Postgres, Redis, ClassSubjectsReadService.loadDto | class_subjects (write) | Every CLASS_SUBJECT_* code plus SUBJECT_NOT_FOUND/SUBJECT_INACTIVE/WRITE_RETURNED_NO_ROW |
StudentSubjectsPortalController | Controller (mobile) | /api/mobile/student/subjects route only | StudentPortalService | HTTP | Service | None | None — delegates entirely |
StudentPortalService.listMySubjects | Service method | A pupil's own-subjects read and session resolution | DATABASE | Controller | Postgres | class_subjects (read, via enrolment subquery) | Empty result on no session/no enrolment (not an error) |
TeacherSubjectsPortalController | Controller (mobile) | /api/mobile/teacher/subjects route only | TeacherPortalService | HTTP | Service | None | None |
TeacherPortalService.listMySubjects | Service method | A teacher's own-subjects read and session resolution | DATABASE | Controller | Postgres | class_subjects (read, filtered on own teacherId) | Empty result on no session/no assignment (not an error) |
assertPortalAudience | Helper function | Refusing a session not acting as the named scopeKind | AuthUser shape | StudentPortalService, both assertTeacherStaffRow-adjacent flows | Throws | None | PERMISSION_INSUFFICIENT, AUTH_ACTIVE_ROLE_REQUIRED, PERMISSION_ROLE_NOT_ASSIGNED |
assertTeacherStaffRow | Helper function | Resolving the caller's own live staff.id, refusing any session not acting as the role named teacher | DATABASE | TeacherPortalService (both methods, including subjects) | Postgres (staff lookup) | None | PERMISSION_INSUFFICIENT |
16.2 UML and Architecture Diagram Pack
No BullMQ, no Socket.IO gateway, and no external API call anywhere in this module — the deployment/runtime diagram above is deliberately short.
16.3 Code Flow Narrative
See 7.1 for assign()'s full fifteen-point narrative (entry point through logs/tradeoffs) and 7.2 for update()'s. Both are also documented at length in the source files' own docblocks, which this page's tables draw from directly rather than restating differently.
16.4 Data Layer Deep Dive
Covered in full in 5.2 (field tables, business meaning, nullability, validation source, indexes, constraints, FK delete behavior, versioning, timezone) and the Index rationale table immediately after it. Neither table has JSON columns, money units, soft-delete fields, or seed-data dependencies — both are pure operational tables populated entirely through the API.
16.5 Business Logic and Invariant Catalog
| Invariant | Enforced By | Why It Exists | Failure Error | Tests |
|---|---|---|---|---|
| No two active subjects share a name (case-insensitive) | subjects_name_unique, partial index | A select box with two identical labels is unusable and unreportable | SUBJECT_NAME_TAKEN | subjects-write.service.integration.spec.ts |
| No two active subjects share a code | subjects_code_unique, partial index | Code is the stable machine identity every export/report keys on | SUBJECT_CODE_TAKEN | same |
passMarks <= maxMarks, both tables | CHECK constraints + service pre-checks | An assessment scheme where the pass bar exceeds the ceiling is meaningless | SUBJECT_MARKS_INVALID / CLASS_SUBJECT_MARKS_INVALID | both write-service specs |
At most one class_subjects row per (class, subject), in any state | class_subjects_identity_unique, non-partial | History (marks, attendance, timetable) must resolve to exactly one row | Upsert makes a second insert impossible; a genuine 23505 would surface as an unmapped conflict, never reached in practice because the write path always upserts | class-subjects-write.service.integration.spec.ts |
| A subject cannot be hard-deleted while any assignment references it | ON DELETE restrict FK, pre-checked in the service | Deleting a subject out from under a class's recorded marks would orphan history | SUBJECT_IN_USE | subjects-write.service.integration.spec.ts |
| Reactivation cannot silently collide with a relabeled active row | Application-only pre-write check (the partial index cannot see it) | The partial-unique-index retirement pattern would otherwise move its dead end from create to reactivate | SUBJECT_NAME_TAKEN_ON_REACTIVATE / _CODE_TAKEN_ON_REACTIVATE | same |
ClassSubjects_READ never widens to pupils/guardians via a portal role's all scope | Positive-grant switch in PeopleAccessService.scopeFor, no fallthrough | The Parent Portal incident this ordering was written to prevent | Row scope resolves to sql\false`for these roles onclassSubjects` | class-subjects-authorization.integration.spec.ts |
Cache invalidation is complete across every writer that moves classCount | All four write-service methods that touch class_subjects, plus all three on subjects, call the same delPatternSoft | A one-directional invalidation would leave the subjects list serving a stale count after any assignment write | Fail-soft; a missed invalidation only shows up as staleness within the 300s TTL, never an error | Documented in code comments; not separately asserted by a test |
| Suspension is never implicitly reversed by a bulk operation | assign() step 9's skippedSuspended branch | A deliberate suspension must survive an unrelated bulk re-assign of the same grade | Reported, not silently overwritten | class-subjects-write.service.integration.spec.ts |
16.6 Tradeoffs, Alternatives, and ADR Notes
| Decision | Context | Chosen Option | Alternatives | Why Chosen | Tradeoffs | Revisit Trigger |
|---|---|---|---|---|---|---|
| Subject vs class-subject as two tables | Modeling "what a class is taught" | Two tables, defaults copied at assignment time | One subjects table with a teacherId column | The normal case is one subject taught by different teachers in different classes; a single-teacher column cannot represent it | Slightly more joins to render "what does this class teach" | Would only reopen if a school ran exactly one section per grade forever |
Optimistic concurrency via version + trigger | Preventing lost updates on shared admin rows | Server-owned bigint counter, bumped by bump_row_version | A updated_at timestamp comparison | Millisecond timestamps collide under concurrent commits; a counter cannot | One more required field on every PATCH DTO | Would only reopen if the trigger itself needed replacing for a performance reason, which is not observed |
Partial unique indexes on subjects.name/code | Allowing retirement-and-reuse of a label | WHERE is_active partial index | A full unique index | A full index makes archiving a dead end — the name can never be reused | Reactivation needs its own application-side collision check the partial index cannot express | Would only reopen if the school never needed to reuse a retired subject's name, which is not the observed case |
Total (non-partial) unique index on class_subjects identity | Allowing suspend-and-revive without duplicate rows | Non-partial (class_id, subject_id) unique index, upsert on write | A partial index (mirroring subjects) | A partial index would let a suspended row and a new "active" row coexist for one pair — exactly the ambiguity a total index prevents | The identity slot is occupied even while suspended, so revival (not insertion) is the only path back to active | Would only reopen if two independent configurations per (class, subject) were ever wanted, which contradicts the table's own purpose |
| Batch assignment is one all-or-nothing transaction | A grid save of up to 100 rows | Collect every error, write nothing if any exists | Per-row commit with partial success | A half-saved 100-row grid is a worse operator experience than a clearly-listed set of fixes | A single bad row blocks 99 good ones until corrected | Would only reopen if grid size grew enough that partial-failure UX became necessary |
Consumer subject reads bypass PeopleAccessService.scopeFor | Authorizing /student/subjects and /teacher/subjects | Purpose-built audience assertion plus a hand-written predicate | Reusing scopeFor with a students/classSubjects entity | scopeFor can return {kind:"all"} for a staff/teacher session, which is exactly the leak these routes must not have | A second authorization pattern alongside the admin one, to keep in sync conceptually (not literally — the two are intentionally different mechanisms) | Would only reopen if scopeFor grew a portal-safe mode that could not return all for these two audiences |
16.7 Operational Runbook
| Operation | How to Inspect | Healthy State | Failure Signal | Recovery |
|---|---|---|---|---|
| Cache | redis-cli KEYS "academic-structure:subjects:*" against the configured Redis instance; server log grep -nE "Redis unavailable" | Keys present after a list read, cleared shortly after a write | Repeated WARN lines from RedisCacheService | No action needed for correctness (fail-soft to Postgres); restart/repair Redis to restore caching performance |
| DB constraints | psql against the target database; \d subjects, \d class_subjects to confirm indexes/constraints match 0017_subject_module.sql | All indexes and CHECKs present | An unmapped 500 from a constraint violation whose name is missing from translate() | Add the missing constraint-name branch to the relevant translate() method |
| Permission grants on an existing deployment | SELECT * FROM role_permission rp JOIN permission p ON p.id = rp.permission_id WHERE p.code LIKE 'Subjects_%' OR p.code LIKE 'ClassSubjects_%' | superadmin holds all ten codes; staff/teacher hold the two _READ codes; student holds Subjects_READ only | A role renamed after 0018_subject_permission_grants.sql ran shows zero grants for the two _READ codes | Grant Subjects_READ (and ClassSubjects_READ for staff/teacher) by hand through the Roles/Permissions admin screen — the migration's own DO $$ ... RAISE WARNING block names exactly this case in the deploy log |
16.8 Backend Risk Register
| Risk | Area | Impact | Current Mitigation | Remaining Gap |
|---|---|---|---|---|
Renamed staff/teacher role on an already-deployed database | Permission grants | Every staff/teacher session silently loses Subjects_READ/ClassSubjects_READ | 0018's DO $$ block emits a RAISE WARNING naming the missing role in the deploy log | The warning is easy to miss if nobody reads the deploy log, per this repo's own "read the server log" rule |
Cache staleness window on classCount | Redis cache | An operator sees a subject's assignment count up to 300 seconds out of date after an assignment write elsewhere | 300s TTL bounds the window; every writer invalidates the prefix | No push-based invalidation across horizontally-scaled Redis instances beyond what delPatternSoft's SCAN already provides |
CLASS_SUBJECT_IN_USE unreachable today | Forward compatibility | None currently — registered for a future FK that does not exist yet | Named ahead of time specifically to avoid an unmapped 500 later | Whoever adds the first table referencing class_subjects must remember to add its constraint name to ClassSubjectsWriteService.translate |
17. Zero-Omission Backend Checklist
- Every file in the module directory is represented or explicitly marked non-runtime.
- Every controller, service, DTO, enum, and schema is documented.
- Every method with business behavior has a code-flow narrative.
- Every table has field-level detail.
- Every index, constraint, relation, and delete behavior has rationale.
- Every lifecycle/status transition has a state diagram and transition table — see the feature doc's Section 7.
- Every read/write/action flow has a sequence diagram.
- Every business invariant is cataloged.
- Every cache key and invalidation path is documented; there is no queue, realtime event, or external call to document.
- Every architectural tradeoff is documented with alternatives and revisit triggers.
- Every operational failure mode has a runbook entry.
18. Backend Completion Checklist
- Module boundaries are documented.
- Every controller, service, DTO, schema file, and cache key is covered.
- Every database table has a field table and relationship diagram.
- Every runtime flow has a diagram and branch notes.
- API, feature/flows docs are linked; no TDD document exists for this module.
- No claim is made without a source file or documented source reference.
See Also
- API doc:
/docs/developer/subjects/api - Features and flows doc:
/docs/developer/subjects/feature
Subjects Features and Flows
Complete feature list, actor journeys, state flows, business rules, edge cases, and diagrams for the school's subject catalogue and its per-class teaching assignments.
Subjects API Reference
Complete API contracts for the subjects module, including routes, auth, DTOs, responses, errors, examples, and integration notes.