Grouped courses iteration 3 — grade level + per-subject coverage¶
1. Problem distillation¶
- The
grouped-coursesboard (iteration 2) isdept → curriculumwith flatsubjectGroups[]leaves and no grade level. Coverage counts live only at the curriculum level (numStudentsWithMissingAssignments,numSubjectGroupsWithoutTeacher). The FE list view needs the same coverage signals per grade, and — to be actionable — per subject within a grade. - The two signals the board must surface, uniformly (a course is a course — no optional/mandatory distinction anywhere):
- Student → course coverage: how many students should be enrolled in courses their curriculum choices imply but aren't yet (
have < should), plus the students with no curriculum at all (we can't say anything about their gaps). - Teacher → course coverage: how many courses have no teacher assigned yet.
- Add a grade node (
dept → curriculum → grade, mirroring thegrouped-homeroomsshape) and fold the same uniform counts down to it. Add a per-subject breakdown under each grade (every subject offered in the grade, mandatory and option-block alike) withexpected/placedso the FE can tell which subject has unplaced students → which course to create or staff. - The SG leaves (
subjectGroups[]) move from the curriculum node down to the grade node they belong to (each SG already carries its own grade).
Success criteria (observable behavior that proves this works):
- GET /subject-groups/grouped-courses returns dept → curriculum → grade — every visible department, every READY curriculum, every covered grade — present even when empty. No students[] anywhere.
- Each grade node carries numStudents (ENROLLED, this grade, selection → this curriculum), numStudentsWithMissingAssignments, numSubjectGroups, numSubjectGroupsWithoutTeacher, subjectGroups[] (the leaves, moved here), and subjects[].
- Each subjects[] entry (every CurriculumSubject offered in the grade — mandatory + in-block, catalog-complete, incl. 0/0 rows) carries curriculumSubject {id,name}, numStudentsExpected, numStudentsPlaced, with numStudentsPlaced ≤ numStudentsExpected always.
- Department node keeps numSubjectGroups, numStudents, numStudentsWithoutCurriculum (the no-curriculum bucket stays here — a selection-less student has no curriculum node to sit under). Curriculum node keeps numSubjectGroups, numMinRequiredSubjectGroups, numStudentsWithMissingAssignments, numSubjectGroupsWithoutTeacher (now the sum of their grade nodes — identical in value to iteration 2, partitioned).
- StudentsPolicy NEVER_MATCH_WHERE → all student-derived counts (incl. per-subject) are 0, skeleton intact. RBAC gates and the departmentId filter are unchanged.
Non-goals (in-scope-shaped things this iteration is explicitly not doing):
- No optional/mandatory distinction in the payload — dropped per user direction (see §8). No numEligible/numEnrolled/opz fields.
- No change to grouped-homerooms, the eligible-for pickers, the plain GET /subject-groups, or any write path.
- No per-student drill-down for a grade/subject node (roster modals still use GET /students/eligible-for-subject-group + GET /subject-groups/:id).
- No RBAC, schema, migration, scope, action, or error-code changes.
- No per-SG "should" (ill-defined when a subject has multiple SGs) — "should"/expected is a per-subject metric.
2. Patterns survey¶
| Analogous module/spec | What we'd borrow | What doesn't fit |
|---|---|---|
docs/superpowers/specs/2026-07-02-grouped-boards-iteration-2-design.md (this board's prior iteration) |
Catalog-driven skeleton, roster-free counts, defensive union for rows on demoted curricula, two-scope gate (SUBJECT_GROUPS read + inline STUDENTS read), resolveTargetPlan missing-assignment fold, countMinRequiredSubjectGroups |
It stops at dept → curriculum with flat leaves; this adds the grade level + per-subject breakdown |
src/homerooms/homerooms.service.ts getGroupedHomerooms + dto/grouped-homerooms-response.dto.ts |
The exact dept → curriculum → grade skeleton fold: grade nodes enumerated from curricula[].grades (via findGroupedBoardCurricula), defensive makeGradeNode, dept-vs-grade count split, grade sort by ordinalPosition |
Homerooms counts are placement (with/without homeroom); courses counts are plan-coverage + teacher-coverage + per-subject |
src/curriculum/resolve-target-plan.ts + loadConsistencyStructure (src/curriculum/selection-consistency.ts) |
Per-student should/planSubjectIds for numStudentsWithMissingAssignments, already loaded per emitted curriculum node in getGroupedCourses |
Per-subject expected is selector/plan-membership based (a specific subject), not the block-minSelections aggregate resolveTargetPlan produces — see §7 |
src/subject-groups/subject-groups.queries.ts findSubjectGroupsForBoard + studentCoverageCountSelect + toSubjectGroupListItem |
SG board leaves (standalone + bound, homeroom ref) and the zero-PII student projection — reused; the projection gains curriculumSelection.choices |
Needs a new subject-catalog query for per-subject names (offered subjects with no SG must still appear as 0/0 rows) |
src/curriculum/curriculum.queries.ts groupedBoardCurriculumSelect / findGroupedBoardCurricula |
Already returns grades[] {id,name,ordinalPosition} — the grade skeleton source, no query change |
Fits cleanly |
3. Architecture mapping¶
| Primitive | Apply? | How | Justify |
|---|---|---|---|
| Tenant scope | yes | Every query goes through a policy where (DepartmentsPolicy/CurriculaPolicy/SubjectGroupsPolicy/StudentsPolicy) pinning tenantId; the new subject-catalog query filters curriculumId ∈ emitted (already-visible) curricula and RLS pins tenant |
Same posture as iteration 2 |
| Academic-year scope | yes | resolveActiveYear once; departments/curricula/SGs/students filtered to the active AY; subjectGroupAssignments relation where: { academicYearId } |
Same as iteration 2 |
| RBAC entity key | existing | SUBJECT_GROUPS primary; STUDENTS read asserted inline in getGroupedCourses (counts derive from student data) |
No new keys |
| Scopes | existing read |
@RequireScopes(EntityKey.SUBJECT_GROUPS, 'read') unchanged |
|
| Actions | none | Read-only aggregate | |
| Service base | custom method | Extend SubjectGroupsService.getGroupedCourses in place |
Same placement as iteration 2 |
queries.ts shape |
+1 include field, +1 named function | Add curriculumSelection.choices {optionBlockId, curriculumSubjectId} to studentCoverageCountSelect; add findBoardSubjectsForCurricula(tx, curriculumIds) → {curriculumId, id, name, optionBlockId, effectiveTrackId, offeredGradeIds} |
Names aren't on ConsistencyStructure; catalog-complete 0/0 rows need every offered subject, not only those with an SG |
| Error codes | existing | None new | |
| DTO conventions | reshape response DTO | dto/grouped-courses-response.dto.ts: new GroupedCoursesGradeNodeDto + GroupedCourseSubjectCoverageDto; subjectGroups[] moves onto the grade node; curriculum node loses subjectGroups[], keeps its counts |
Mirrors grouped-homerooms-response.dto.ts grade nesting |
| File-backed sub-resources | n/a | no files | |
| Custom fields | no | n/a | |
| Profile completeness | no | n/a |
4. Data model plan¶
Schema deltas¶
- None. Pure read-surface rework over existing tables.
Migration shape¶
- n/a — no migration.
Indexes and uniqueness¶
- None new. Queries filter on already-indexed FKs (
tenantId,academicYearId,departmentId,gradeId,curriculumId).
5. API surface¶
| Verb | Path | Decorators | Request DTO | Response DTO |
|---|---|---|---|---|
| GET | /subject-groups/grouped-courses |
@RequireScopes(SUBJECT_GROUPS,'read'), @RequireRoles('admin','department_head'), @AppliesPolicyDimensions(SubjectGroupsPolicy), @AggregateResponse() |
GroupedCoursesQueryDto (departmentId?) |
GroupedCoursesResponseDto |
Response shape (contract):
// GET /subject-groups/grouped-courses — dept → curriculum → grade
{
"departments": [{
"department": { "id", "name" },
"numSubjectGroups": 31, // ALL SGs (standalone + homeroom-bound) across the dept's curricula
"numStudents": 49, // ENROLLED active-AY students of the dept
"numStudentsWithoutCurriculum": 4, // no CurriculumSelection — the "can't say anything" bucket
"curricula": [{ // every READY curriculum of the dept — present even if empty
"curriculum": { "id", "name" },
"numSubjectGroups": 17, // = Σ grades
"numMinRequiredSubjectGroups": 22, // static floor from the grid (unchanged, curriculum-level only)
"numStudentsWithMissingAssignments": 9, // = Σ grades (identical to iteration 2, partitioned)
"numSubjectGroupsWithoutTeacher": 3, // = Σ grades
"grades": [{ // every covered grade — present even if empty
"grade": { "id", "name", "ordinalPosition" },
"numStudents": 21, // ENROLLED, this grade, selection → this curriculum
"numStudentsWithMissingAssignments": 5, // have < should (resolveTargetPlan)
"numSubjectGroups": 9, // SGs this grade (standalone + bound)
"numSubjectGroupsWithoutTeacher": 2,
"subjectGroups": [ /* GroupedCourseSubjectGroupDto[] — moved here from the curriculum node */ ],
"subjects": [{ // every subject offered in this grade — mandatory + in-block, incl. 0/0
"curriculumSubject": { "id", "name" },
"numStudentsExpected": 14, // plan says they take it (mandatory-for-track OR chose this option)
"numStudentsPlaced": 11 // and they're in a course for it (≤ expected)
}]
}]
}]
}]
}
Count semantics (normative):
- All student counts: status = 'ENROLLED', active AY, under StudentsPolicy.where(ctx); NEVER_MATCH_WHERE → 0, skeleton returned.
- Dept numStudents / numStudentsWithoutCurriculum: folded directly at dept level over all its ENROLLED students (independent of selection state). A no-selection student increments numStudentsWithoutCurriculum and is not attributed to any curriculum/grade node.
- Grade numStudents: ENROLLED students whose gradeId = this grade AND whose selection targets this curriculum. A selection pointing at a non-READY curriculum, or an uncovered grade, is skipped at the grade fold (dept totals stay truthful).
- Grade numStudentsWithMissingAssignments: per student, resolveTargetPlan(structure, {trackId, gradeId}) → should; have = their AY subjectGroupAssignments capped to planSubjectIds; counted when have < should. Curriculum numStudentsWithMissingAssignments = Σ of its grade nodes (equal to iteration 2's direct value).
- numSubjectGroups / numSubjectGroupsWithoutTeacher (grade): SGs (standalone + bound) whose gradeId = this grade; teacherless = zero SubjectGroupTeacher rows. Curriculum/dept values = sums. % docenti (FE) = (numSubjectGroups − numSubjectGroupsWithoutTeacher) / numSubjectGroups.
- subjects[] (per grade): one entry per CurriculumSubject of this curriculum whose offeredGradeIds includes this grade (from findBoardSubjectsForCurricula), mandatory and in-block alike, sorted by name — present even at 0/0.
- numStudentsExpected — mandatory subject: grade/curriculum ENROLLED students whose track admits it (appliesToTrack(effectiveTrackId, student.trackId)); in-block subject: grade/curriculum ENROLLED students who chose it (a choices[] entry for this curriculumSubjectId).
- numStudentsPlaced — of those expected students, how many hold an AY subjectGroupAssignment for this curriculumSubjectId. A placement for a subject the student's plan doesn't include is counted in neither field (keeps placed ≤ expected).
Swagger considerations¶
- Controller-method JSDoc is FE-facing: document the
dept → curriculum → gradeskeleton (empty nodes present), the count semantics above, thatsubjects[]is catalog-complete (0/0 rows appear), and that rosters come from the pickers. The two-scope RBAC note stays in the method body comment. subjects[]and the per-grade nesting are additive DTO classes; the SG-leafhomeroomref stays@ApiPropertyOptional.
6. RBAC seed plan¶
| Seed file | Delta |
|---|---|
PermissionScope (rbac-catalogue.ts) |
none |
PermissionAction (rbac-catalogue.ts) |
none |
ScopeFieldMapping (rbac-catalogue.ts) |
none |
| Role grants (roles.ts) | none — same entity reads as iteration 2 |
*_SCOPES runtime constant |
none |
7. Divergence ledger¶
| Pattern | We diverge by | Reason | Tradeoff accepted |
|---|---|---|---|
Iteration-2 grouped-courses = flat subjectGroups[] under the curriculum node, no grade level |
Nest SGs under a new grade node (dept → curriculum → grade) |
Product needs per-grade + per-subject coverage badges; grade is the natural node and it aligns the board with grouped-homerooms |
FE-breaking: the course list relocates from the curriculum node to its grade node |
numStudentsWithMissingAssignments uses resolveTargetPlan (block minSelections, have capped to any offered alternative) |
Grade-level count keeps resolveTargetPlan (partition of the curriculum count); per-subject expected/placed is selector/plan-membership based per specific subject |
Grade count must reconcile with the existing curriculum count (Σ grades); per-subject must answer "which subject has unplaced students" precisely | The two metrics don't reconcile arithmetically (a student with 2 gaps = 1 at grade level, 2 across subjects) — intentional, different denominators |
SG write-gate permits placing a student who didn't select an in-block alternative (pending/noSelection buckets) |
Per-subject expected/placed count only plan-member/selector students; an off-plan placement is excluded from both |
An off-plan placement inflating placed past expected would make the badge exceed 100% |
Off-plan placements are invisible in subjects[] (they still appear in subjectGroups[] leaves + rosters) |
8. Pushback log¶
| US says | Conflicts with | Proposed instead | Status |
|---|---|---|---|
Developer note: split coverage by optional vs mandatory — per-grade numStudentsWithMissingOptionalAssignments ("M/N iscritti opz.") + per-optional-subject numEligibleStudents/numEnrolledStudents |
"A course is a course — no distinction in the aggregate between an optional assignment or not" (user, 2026-07-03) | Uniform coverage for all subjects: grade numStudentsWithMissingAssignments (mandatory + chosen options together) + per-subject expected/placed across every offered subject |
Resolved (Fabio, 2026-07-03) |
Developer note field names numTeachingSlots / numTeachingSlotsWithTeacher |
"slot" collides with timetable slots; the value is literally an SG count | numSubjectGroups / numSubjectGroupsWithoutTeacher per grade (consistent with the curriculum-level vocabulary) |
Resolved (Fabio, 2026-07-03) |
Developer note: numEnrolledStudents = "distinct students enrolled in ≥1 course" (unrestricted) |
Would let placed > expected when an off-plan placement exists |
Restrict placed to the expected set so placed ≤ expected |
Resolved (Fabio, 2026-07-03) |
| "surface students that have no curriculum" as a per-node badge | A selection-less student has no curriculum/grade node to sit under | Keep it as the existing dept-level numStudentsWithoutCurriculum |
Resolved (Fabio, 2026-07-03) |
9. Deferrals¶
- Per-node student drill-down (paginated students for one grade/subject) — not requested; pickers +
GET /subject-groups/:idserve roster modals — follow-up: revisit if FE asks. - DB-side aggregation (
groupBy/raw counts) if student volumes make the in-memory fold slow — same fork asproject_command_center_overview_iteration2; the per-subject fold isO(students × plan-size)per grade, fine at K-12 scale — follow-up: revisit under that decision. - Removing the now-superseded plain
GET /subject-groupsgrouped list — FE-coordination question, carried over from iteration 2 — follow-up: next iteration.
10. Open questions¶
None — resolved in chat 2026-07-03: uniform (no optional/mandatory split); grade + per-subject granularity; subjects[] catalog-complete incl. 0/0; placed ≤ expected (off-plan placements excluded); no-curriculum stays a dept-level count; naming numSubjectGroups* not numTeachingSlots*; grade numStudentsWithMissingAssignments stays resolveTargetPlan-based; RBAC/schema unchanged.
11. Verification plan¶
- Unit specs —
src/subject-groups/subject-groups.service.spec.ts(rework thegetGroupedCoursesdescribe-block): - Skeleton nests
dept → curriculum → grade; empty curricula and empty covered grades are present;subjects[]lists every offered subject incl.0/0. - Grade
numStudentscounts only students whose selection targets that curriculum; curriculumnumStudentsWithMissingAssignments= Σ grades and equals the directresolveTargetPlanvalue. - Per-subject: mandatory
expected= track-admitted grade cohort; in-blockexpected= choosers only;placed⊆ expected (an off-plan placement bumps neither);placed ≤ expectedinvariant. - SG leaves moved onto grade nodes (standalone + bound);
numSubjectGroupsWithoutTeacherper grade; defensive union for an SG on a demoted curriculum / uncovered grade. NEVER_MATCH_WHEREstudent visibility → all student-derived + per-subject counts 0, skeleton intact; STUDENTS-read assert still throws.- E2E specs —
test/subject-groups-grouped.e2e-spec.ts(extend): - Full grade skeleton with zero rows;
subjects[]shows an offered subject with selectors but no SG as0/N. - After assigning a student to a course: that subject's
numStudentsPlacedand the grade'snumStudentsWithMissingAssignmentsboth move; teacherless count moves when a teacher is added. dept_headsees only their department's skeleton; per-subject counts respectStudentsPolicynarrowing.- Manual verification: none beyond e2e — FE integration exercises the same contract.
Patterns: chapter 09 (testing); E2E isolation discipline per the existing subject-groups-grouped suite.
12. Sign-off¶
- Approved by: Fabio
- Date: 2026-07-03
- Chat reference: design approved by Fabio in chat 2026-07-03 ("yes" to grade + per-subject, uniform, after the optionality reframe); "go on" to sign off + proceed to the implementation plan after reviewing this written spec.
Until this section is filled, no implementation code is written.