Skip to content

Grouped courses iteration 3 — grade level + per-subject coverage

1. Problem distillation

  • The grouped-courses board (iteration 2) is dept → curriculum with flat subjectGroups[] 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 the grouped-homerooms shape) 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) with expected/placed so 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 → grade skeleton (empty nodes present), the count semantics above, that subjects[] 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-leaf homeroom ref 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/:id serve 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 as project_command_center_overview_iteration2; the per-subject fold is O(students × plan-size) per grade, fine at K-12 scale — follow-up: revisit under that decision.
  • Removing the now-superseded plain GET /subject-groups grouped 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 specssrc/subject-groups/subject-groups.service.spec.ts (rework the getGroupedCourses describe-block):
  • Skeleton nests dept → curriculum → grade; empty curricula and empty covered grades are present; subjects[] lists every offered subject incl. 0/0.
  • Grade numStudents counts only students whose selection targets that curriculum; curriculum numStudentsWithMissingAssignments = Σ grades and equals the direct resolveTargetPlan value.
  • Per-subject: mandatory expected = track-admitted grade cohort; in-block expected = choosers only; placed ⊆ expected (an off-plan placement bumps neither); placed ≤ expected invariant.
  • SG leaves moved onto grade nodes (standalone + bound); numSubjectGroupsWithoutTeacher per grade; defensive union for an SG on a demoted curriculum / uncovered grade.
  • NEVER_MATCH_WHERE student visibility → all student-derived + per-subject counts 0, skeleton intact; STUDENTS-read assert still throws.
  • E2E specstest/subject-groups-grouped.e2e-spec.ts (extend):
  • Full grade skeleton with zero rows; subjects[] shows an offered subject with selectors but no SG as 0/N.
  • After assigning a student to a course: that subject's numStudentsPlaced and the grade's numStudentsWithMissingAssignments both move; teacherless count moves when a teacher is added.
  • dept_head sees only their department's skeleton; per-subject counts respect StudentsPolicy narrowing.
  • 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.