Audience: Frontend engineers, mobile engineers, backend engineers, QA, and API consumers.
Scope: Admin routes for the subject catalogue and per-class subject assignments, plus the two audience-scoped consumer reads a pupil and a teacher use for their own subjects.
JwtAuthGuard + RoleGuard on every route. Admin routes additionally require a specific @Permissions() code; the two consumer routes carry no permission code and are gated entirely inside the service by an audience assertion.
Persistence
PostgreSQL only (subjects, class_subjects tables); Redis for the catalogue list cache only. No MongoDB, no BullMQ, no realtime, no external API.
Runtime source of truth
The two Postgres tables; academic_sessions.is_current for "the current session" on the two consumer reads.
A pupil's own subjects for their currently-enrolled class.
Mobile
GET
/api/mobile/teacher/subjects
Teacher
JwtAuthGuard, RoleGuard
None (audience-asserted in-service)
TeacherSubjectsPortalController
A teacher's own teaching load.
Rules applied: GET /api/subjects/options is declared ahead of GET /api/subjects/:publicId in the controller, since Nest matches in declaration order and the reverse would swallow the literal segment as a publicId value. ClassSubjectsController carries no base @Controller() path — every route spells its own full path, because the three path families it serves (subjects/:publicId/assignments, classes/:classPublicId/subjects, class-subjects/:id) cannot share one class-level tag under the swagger gate's "no tag shared by two controllers" rule, and would otherwise need three separate controller classes. StudentSubjectsPortalController/TeacherSubjectsPortalController are each a SECOND controller class sharing their sibling's @Controller("student")/@Controller("teacher") path, purely so the "My Subjects" operation gets its own swagger tag rather than concatenating with "My Enrolments"/"My Classes".
Swagger document membership: every admin route above ships in the admin document at /api/api-docs because AcademicStructureModule — the module that DECLARES SubjectsController and ClassSubjectsController — is named in ADMIN_MODULES. Both consumer routes ship in the mobile document at /api/mobile-docs because StudentPortalModule/TeacherPortalModule are named as concrete leaves in MOBILE_CHILDREN.
Sits on NO_PERMISSION_ADMIN_HANDLERS; the actual gate is assertPortalAudience inside StudentPortalService.listMySubjects.
Mobile consumer (GET /api/mobile/teacher/subjects)
Same
req.user, resolved live staff.id via assertTeacherStaffRow
None
No
Refuses any session not acting as the role named teacher (guardian, student, and a teacher acting as staff included) and a session with no live staff row (e.g. a dismissed teacher who kept another profile).
Whether headers are parsed but not trusted: N/A — no header beyond the standard Authorization: Bearer is consulted anywhere in this module.
Row scope for ClassSubjects_READ (both admin list routes): PeopleAccessService.scopeFor(actor, "classSubjects", heldPermissions) resolves to {kind: "all"} only for scopeKind: "all" roles that hold the permission (staff, teacher) or for superadmin; every other scopeKind — including student and guardian, who never hold the permission and so never reach this method through RoleGuard in the first place — resolves to a predicate of sql\false`(no rows), neverall`.
No person data at all — the field that makes GET /api/subjects/options safe to read unpaginated, and the exact shape embedded in ClassSubjectDto.subject.
The rows live under items, not directly under data, specifically so truncated has somewhere to live — ResponseDto is a closed shape whose constructor only accepts pagination metadata, and this endpoint is unpaginated by definition.
Non-empty, max 128 chars, no surrounding whitespace (/^\S(.*\S)?$/), no control characters
"Mathematics"
subject.dto.ts
code
string
Yes
N/A
/^[A-Z0-9_]{1,16}$/
"MATH101"
same
type
"compulsory" | "optional"
Yes
N/A
IsIn(SUBJECT_TYPES)
"compulsory"
same
defaultMaxMarks
number
No
100 (service-side)
Int, 1-1000
100
same
defaultPassMarks
number
No
40 (service-side)
Int, 0-1000
40
same
isActive
boolean
No
true
IsBoolean
true
same
passMarks <= maxMarks is NOT expressible as a decorator — no class-validator rule compares two fields of one object — so it is enforced in the service against the fully-known values before the insert.
Every field is optional EXCEPT version. null is rejected for every field here — the one place null is meaningful in this module is teacherId on the assignment DTOs, which is why this DTO does not accept it anywhere.
teacher is ClassTeacherDto, reused verbatim from class.dto.ts — it carries id, fullName, employeeCode, employmentStatus and a live-derived isTeaching. This is precisely the field set ClassSubjects_READ is withheld from pupils/guardians for.
Deliberately NOT ClassSubjectDto and deliberately not a Pick<> of it — written field-by-field so an admin field can never silently widen this consumer shape.
IsUUID when present; ValidateIf skips validation for null/absent
Explicit null clears the teacher.
same
type
SubjectType
No
Falls back to the subject's own type
IsIn(SUBJECT_TYPES)
—
same
maxMarks
number
No
Falls back to the subject's defaultMaxMarks
Int, 1-1000
100
same
passMarks
number
No
Falls back to the subject's defaultPassMarks
Int, 0-1000
40
same
isActive
boolean
No
Absent means "insert active, or leave a suspended row suspended"
IsBoolean
Set true to REVIVE a suspended row.
same
version
number
No
Required in practice by the admin grid for every row it displays
Int, >= 0
2
same
Absent is not null, and the difference is load-bearing: on INSERT an absent field takes the subject's default; on the CONFLICT (upsert) branch an absent field KEEPS the existing value, only a field actually supplied is written.
pagination=false is REFUSED at the service layer for both list routes on this DTO — every row embeds staff identity.
—
query.dto.ts
(inherited) page/size/sort/order/search
as QueryDto
No
as QueryDto
sort/order/search are accepted by the DTO but not consulted — both list methods use a fixed orderBy (grade sort order, section sort order, shift, id) and apply no free-text search.
Lists and searches the school-wide subject catalogue. Called by the subject-management screen and by any admin surface offering a subject filter. Supports free-text search over name/code, a type filter, an isActive filter, and standard offset pagination; also servable unpaginated for a select box, capped at 1000 rows.
pagination=false beyond the 1000-row cap → results silently truncated at the cap with no truncated flag on THIS endpoint (unlike options) — the list route has no such field in its response envelope.
Redis unavailable → falls through to Postgres transparently.
Returns a lightweight, unpaginated, person-data-free subject list for populating an assignment picker's dropdown. Declared ahead of :publicId in the controller so it is never swallowed by the parameterised route.
Note the double nesting: ResponseDto.data wraps SubjectOptionsPayloadDto, whose rows are under items. The inner field is items and not data deliberately — ClassOptionsPayloadDto established that shape for the same "unpaginated list plus truncated" problem, and a second envelope for one idea is how a consumer ends up reading the wrong key and silently rendering nothing.
Auth: JWT required. Guard chain: JwtAuthGuard, RoleGuard. Permission: Subjects_CREATE (superadmin only in the seed). Guest support: No. Idempotency: None — a second identical call creates a second row unless refused by the uniqueness constraints.
Auth: JWT required. Guard chain: JwtAuthGuard, RoleGuard. Permission: Subjects_UPDATE. Idempotency: A no-op patch (version only) is safe to repeat, though version itself advances each time.
Assigns one already-created subject to one or more classes, each with its own optional teacher and mark scheme, in a single all-or-nothing transaction. The single-item case (the row-level "save" button in the admin grid) is simply a one-element batch through this same endpoint.
Permission: ClassSubjects_CREATE (superadmin only in the seed). Idempotency: re-sending an identical batch is idempotent per-field; re-sending a SUBSET is a genuine partial edit, not a repeat.
A class the caller's scope excludes: 404, never 403 — resolving through the scope predicate rather than after is what makes the id space non-enumerable.
Permission: None — the boundary is assertTeacherStaffRow, which requires the active role named teacher, resolves the caller's own LIVE staff.id, and refuses any other active role or a session with no live staff row.
Same envelope shape as 8.12; teacherName on every row is always the caller's own name — the WHERE teacherId = :staffId clause already scopes the query to them, so no staff/users join is needed and actor.name is used directly.
No class_subjects row names this teacher: 200 { items: [], truncated: false }.
Dismissed teacher with another live profile (e.g. also a guardian): refused — staff.deleted_at is filtered explicitly, even though the full unique index on staff.user_id means the row still exists.
Fixed: case-folded name, then id (sort/order accepted, ignored)
search, type, isActive
1000 when unpaginated
GET /api/subjects/options
Always unpaginated
N/A
N/A
Fixed: case-folded name, then id
includeInactive
1000, truncated flagged
GET /api/subjects/:publicId/assignments
page/size only — pagination=false REFUSED
20
100
Fixed: grade sort order, section sort order, shift, id
isActive
N/A (always paginated)
GET /api/classes/:classPublicId/subjects
page/size only — pagination=false REFUSED
20
100
Same fixed order
isActive
N/A
GET /api/mobile/student/subjects
Always unpaginated
N/A
N/A
Fixed: subjects.name, then subjects.id
academicSessionId
1000, truncated flagged
GET /api/mobile/teacher/subjects
Always unpaginated
N/A
N/A
Fixed: classes.id, then subjects.name, then subjects.id
academicSessionId
1000, truncated flagged
Shared pagination utility: PaginationUtil (common/utils/pagination.util.ts) — normalize(), getDrizzleParams(), buildMetadata(), constants DEFAULT_SIZE=20, MAX_PAGE_SIZE=100, UNPAGINATED_HARD_CAP=1000.
Broad-search detection: none — search on GET /api/subjects is a plain ILIKE substring match, no relevance scoring.
Cache behavior per query: only GET /api/subjects is cached, keyed deterministically on the full query shape.
Empty result behavior: every list endpoint returns 200 with an empty data array — never a 404 for "no rows matched".
Present: route ownership graph (9.1), a sequence diagram for the most complex family (9.2), an error decision tree (9.3). No cache-flow diagram beyond the backend doc's Section 8 table — a single cached endpoint does not warrant its own diagram. No async/job flow or realtime/event flow diagram — neither exists on this module.
Every admin route requires the specific Subjects_*/ClassSubjects_* code; pagination=false works on the two subject list/detail/options routes but is REFUSED on both assignment list routes.
Map 409 SUBJECT_VERSION_CONFLICT/CLASS_SUBJECT_VERSION_CONFLICT to a "reload and retry" prompt; map 422 CLASS_SUBJECT_BATCH_INVALID to per-row grid error markers using errors[].index.
Stable.
Mobile app (pupil)
GET /api/mobile/student/subjects needs no permission, only an active student-scoped role; an empty result is normal, not an error state.
A 403 here means the session is not acting as a pupil — prompt a role switch, not a retry.
Stable.
Mobile app (teacher)
GET /api/mobile/teacher/subjects needs a live staff row; a dismissed teacher with another live profile is refused even though their account still exists.
Same 403 handling as the pupil route.
Stable.
QA
Reproduce a batch failure by submitting a known-bad item (e.g. passMarks exceeding maxMarks) alongside valid ones and asserting NOTHING was written for the valid items either.
class-subjects-write.service.integration.spec.ts is the reference fixture set.
Stable.
Internal service
No internal service calls this module today — no queue, no webhook, no service token path exists.
Offset (page/size), with an unpaginated escape hatch capped at 1000
Cursor pagination
Matches every other admin list in this codebase; a subject catalogue is small enough that offset drift is not a real concern
Very low
UNPAGINATED_HARD_CAP bounds the worst case
Batch assignment endpoint accepts both single and multi-item bodies
One endpoint, items: [...], min length 1
A separate single-item POST
Avoids two code paths that could drift; a single-item request is simply a one-element array
None observed
N/A
Assignment list routes refuse pagination=false while the subject list allows it
Different defaults per endpoint, on purpose
Uniform pagination policy across the whole module
The assignment rows embed staff identity (employee code, employment status); the subject rows do not
Low — documented explicitly in both the DTO and the service
PAGINATION_LIMIT_INVALID is a clear, named refusal rather than a silent truncation
Consumer routes carry no permission code
Audience assertion in-service instead of @Permissions()
Grant Subjects_READ/ClassSubjects_READ-shaped codes to pupils/teachers for these routes
A permission-gated version would need EITHER pupils holding ClassSubjects_READ (leaking staff identity) or a third permission code just for this narrow slice
Low — the audience assertion is a hard 403, equally strict
Every controller route is documented — 13 routes across four controllers.
Every parent route prefix and runtime URL is documented.
Every DTO field, nested field, enum, default, transform, and validator is documented.
Every response field, nullable field, generated field is documented; no raw entity field is ever returned unmapped — every service maps field-by-field.
Every auth, guard, permission, and guest identity branch is documented, including the two no-permission consumer routes and their audience assertion.
Every success, validation, auth, permission, not-found, conflict, and server-error branch is documented.
Every database read/write and cache hit/miss/write/invalidation is documented; there are no queue jobs, realtime events, notifications, or external calls on this module, stated explicitly rather than omitted.
Every route has minimal/full/success/failure examples where a body applies.
Route ownership, one representative sequence, and an error decision tree are present.
Every tradeoff and compatibility risk is documented.
Links to backend and features/flows docs are present below.