Skip to content

Grouped courses iteration 5 — dept → grade → cv → track tree

1. Problem distillation

  • Iteration 4 exposed the CV identity as refs on flat rows (subjects[] with optionBlock/track fields). The FE wants the structure as an actual tree: department → grade → curriculum → track, where each track node contains its mandatory subjects and its option blocks — no client-side grouping at all.
  • Grade moves above curriculum (was dept → cv → grade).
  • Track becomes a real node: a curriculum's track-less (common) content lives in a dedicated track: null node; each cv track gets its own node containing the track-scoped mandatory subjects AND track-scoped blocks (both exist).
  • SG leaves nest under their subject's coverage row — an empty group is a row with subjectGroups: []; the curriculumSubjectId join disappears.
  • numMinRequiredSubjectGroups is renamed numOfferedSubjectGroups ("required" overstated it) and becomes per-grade (the cv node is per-grade now).

Decisions fixed by user in chat (2026-07-10, question prompt): 1. Common content → dedicated track: null node (no replication per track). 2. SG leaves → nested per coverage row. 3. Floor → per-grade recompute, renamed numOfferedSubjectGroups. 4. Grade skeleton → cv-covered grades only (today's data, pivoted).

Success criteria (observable behavior that proves this works): - GET /subject-groups/grouped-courses returns departments[] → grades[] → curricula[] → tracks[]; a track node is { track: {id,name} | null, mandatorySubjects[], optionBlocks[] } with optionBlocks[] = { optionBlock: {id,name,ordinalPosition,minSelections,maxSelections}, subjects[] }. - Coverage rows keep curriculumSubject {id,name}, ordinalPosition, weeklyHours, numStudentsExpected, numStudentsPlaced and gain subjectGroups[] (leaf shape unchanged); they LOSE the iteration-4 track/optionBlock refs (implied by tree position). - The common node is always first, then the cv's tracks by ordinalPosition — all emitted catalog-style even when empty in that grade. - A block appears under a (grade, track) iff ≥1 of its alternatives has an hours cell in the grade; rows exist iff the cell exists (invariant kept). - Curriculum node (now per-grade) carries numStudents, numStudentsWithMissingAssignments, numSubjectGroups, numSubjectGroupsWithoutTeacher (same semantics as iteration-3/4 grade node) + numOfferedSubjectGroups = |mandatory subjects offered in the grade| + Σ minSelections over blocks offering ≥1 alternative in the grade. - Dept node unchanged (numSubjectGroups, numStudents, numStudentsWithoutCurriculum); all count semantics byte-identical. - Defensive union kept: an SG whose subject row has no catalog home (hidden cv, uncovered grade, or hours cell removed after creation) creates its grade/cv/track/row chain from its own refs; such a row has weeklyHours: null (mirrors the leaf's "no longer taught" gloss) and 0/0 counts — a leaf is never hidden.

Non-goals: - No change to grouped-homerooms (keeps dept → cv → grade). - No change to leaves (SubjectGroupListItemDto, incl. iteration-4's subject.optionBlock), pickers, plain GET /subject-groups, write paths, RBAC, schema. - No per-track student counts on the track node (rows already carry expected/placed; revisit if FE asks).


2. Patterns survey

Analogous module/spec What we'd borrow What doesn't fit
2026-07-10-grouped-courses-iteration-4-design.md BoardSubjectRow (CV identity + offeredCells), coverage-fold semantics, leaf mapper Flat rows + refs → repositioned as tree nodes; refs dropped from rows
2026-07-03-grouped-courses-iteration-3-design.md Count semantics (expected/placed, missing via resolveTargetPlan), two-scope gate, defensive union Its dept → cv → grade nesting inverts; grade-node counts move to the per-grade cv node
src/curriculum/curriculum.queries.ts groupedBoardCurriculumSelect Skeleton source — gains tracks {id,name,ordinalPosition} so track nodes are catalog-emitted Query stays shared with grouped-homerooms (extra field is ignored there)
src/curriculum/count-min-required-subject-groups.ts The floor formula (mandatory offered + Σ block min) Whole-cv; the per-grade variant is computed inline from catalog rows → util + spec DELETED (board was its only consumer)

3. Architecture mapping

Primitive Apply? How Justify
Tenant scope yes Unchanged — policy wheres + RLS on every query
Academic-year scope yes Unchanged
RBAC entity key existing SUBJECT_GROUPS + inline STUDENTS read assert, unchanged
Scopes / Actions existing read / none Unchanged
Service base custom method getGroupedCourses fold rewritten in place
queries.ts shape widen 1 select groupedBoardCurriculumSelect += ordered tracks {id,name,ordinalPosition}; board-subjects query unchanged but now fetched for the UNION of emitted cv ids + SG cv ids (defensive placement needs the subject index)
Error codes existing None new
DTO conventions rewrite response DTO GroupedCoursesGradeNodeDto (new content), GroupedCoursesCurriculumNodeDto (per-grade), new GroupedCoursesTrackNodeDto + GroupedCourseOptionBlockNodeDto; coverage row reshaped FE-breaking, accepted
File-backed / custom fields / completeness n/a

4. Data model plan

  • None. Pure read-surface rework; no migration, no indexes.

5. API surface

Verb Path Decorators Request DTO Response DTO
GET /subject-groups/grouped-courses unchanged GroupedCoursesQueryDto (unchanged) GroupedCoursesResponseDto (restructured)

Response shape (contract):

{
  "departments": [{
    "department": { "id", "name" },
    "numSubjectGroups": 12,
    "numStudents": 49,
    "numStudentsWithoutCurriculum": 4,
    "grades": [{                          // union of covered grades of the dept's visible cvs
      "grade": { "id", "name", "ordinalPosition" },
      "curricula": [{                     // cvs covering this grade — empty nodes included
        "curriculum": { "id", "name" },
        "numStudents": 21,                // ENROLLED, this grade, selection → this cv
        "numStudentsWithMissingAssignments": 5,
        "numSubjectGroups": 9,            // SG leaves under this (grade, cv)
        "numSubjectGroupsWithoutTeacher": 2,
        "numOfferedSubjectGroups": 4,     // per-grade: mandatory offered + Σ block minSelections
        "tracks": [{
          "track": null,                  // common node, always first
          "mandatorySubjects": [{
            "curriculumSubject": { "id", "name" },
            "ordinalPosition": 0,
            "weeklyHours": 4,             // null ONLY on defensive rows (no longer offered)
            "numStudentsExpected": 14,
            "numStudentsPlaced": 11,
            "subjectGroups": [ /* GroupedCourseSubjectGroupDto[] — [] = empty group */ ]
          }],
          "optionBlocks": [{
            "optionBlock": { "id", "name", "ordinalPosition", "minSelections", "maxSelections" },
            "subjects": [ /* same coverage-row shape */ ]
          }]
        }, {
          "track": { "id", "name" },      // then cv tracks by ordinalPosition, even if empty
          "mandatorySubjects": [],
          "optionBlocks": []
        }]
      }]
    }]
  }]
}

Normative rules: - Placement: a subject lands in the track node of its effective track (subject.track ?? optionBlock.track ?? null → common). In-block subjects land under their block node inside that track node. - Ordering: departments by ordinal; grades by ordinal; curricula by name; tracks common-first then ordinal; mandatory subjects + block subjects by (ordinal, name); blocks by (ordinal, name); leaves by name. - Counts: identical semantics to iteration 3/4 (grade-node counts now live on the per-grade cv node; dept fold unchanged; expected/placed per row unchanged, incl. placed ≤ expected). - numOfferedSubjectGroups counts catalog-offered rows only (defensive weeklyHours: null rows excluded).

Swagger considerations

  • Operation description rewritten for the new tree + rename; DTO JSDoc contract only (null-track = common node; defensive-row weeklyHours: null gloss).

6. RBAC seed plan

No deltas (scopes/actions/mappings/grants/constants all unchanged).


7. Divergence ledger

Pattern We diverge by Reason Tradeoff accepted
Both grouped boards shared dept → cv → grade Courses board inverts to dept → grade → cv → track; homerooms board untouched FE renders the courses page grade-first, per-track The two boards' shapes diverge
Iteration-4 "flat rows + refs, client joins leaves" Real tree nodes; leaves nested per row User-directed: zero client-side grouping Payload restructure two days in a row (FE-breaking, accepted)
numMinRequiredSubjectGroups cv-wide floor Renamed numOfferedSubjectGroups, per-grade, computed inline from catalog rows "Required" overstated it; cv node is per-grade now countMinRequiredSubjectGroups util + spec deleted (dead code)

8. Pushback log

US says Conflicts with Proposed instead Status
None — user directive is the spec; the 4 open shape forks were resolved via the chat question prompt (common node / nested leaves / renamed per-grade floor / covered grades only) Resolved (Fabio, 2026-07-10)

9. Deferrals

  • Per-track-node student counts (e.g. numStudents per track) — rows already carry expected/placed — follow-up: revisit if FE asks.
  • grouped-homerooms alignment to the new nesting — not requested — follow-up: revisit if FE asks.
  • Enumerating uncovered dept grades as gap nodes — user chose covered-only — follow-up: revisit if QA asks.

10. Open questions

None — all four forks resolved by user (chat 2026-07-10).


11. Verification plan

  • Unit specssubject-groups.service.spec.ts (rewrite the getGroupedCourses describe): tree nesting (dept→grade→cv→track), common node first + all tracks emitted (empty included), block node placement, track-scoped mandatory placement, nested leaves (empty row = []), numOfferedSubjectGroups per grade, counts regression (same fixture values as iteration 4), defensive row (weeklyHours: null), STUDENTS-read assert, NEVER_MATCH_WHERE skeleton. Delete count-min-required-subject-groups.spec.ts.
  • E2E specstest/subject-groups-grouped.e2e-spec.ts (rewrite types + assertions): full tree for the Elementary seed (common node only — seed cv has no tracks), nested leaves, numOfferedSubjectGroups = 4 per grade (3 mandatory + block min 1), teacher/coverage counts unchanged.
  • Manual verification: none beyond e2e.

Doc updates on landing: ch14 grouped-boards section, docs/REFERENCE.md rows, new FE guide (marks the iteration-4 guide superseded), swagger copy.


12. Sign-off

  • Approved by: Fabio
  • Date: 2026-07-10
  • Chat reference: restructure directive ("dep / grade / cv / track, in track the specific track block and mandatory") + explicit answers to the four shape questions (common track: null node, leaves nested per subject, rename to numOfferedSubjectGroups, covered grades only), chat 2026-07-10.

Until this section is filled, no implementation code is written.