Grouped courses iteration 5 — dept → grade → cv → track tree¶
1. Problem distillation¶
- Iteration 4 exposed the CV identity as refs on flat rows (
subjects[]withoptionBlock/trackfields). 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: nullnode; 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: []; thecurriculumSubjectIdjoin disappears. numMinRequiredSubjectGroupsis renamednumOfferedSubjectGroups("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: nullgloss).
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.
numStudentsper track) — rows already carry expected/placed — follow-up: revisit if FE asks. grouped-homeroomsalignment 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 specs —
subject-groups.service.spec.ts(rewrite thegetGroupedCoursesdescribe): 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 =[]),numOfferedSubjectGroupsper grade, counts regression (same fixture values as iteration 4), defensive row (weeklyHours: null), STUDENTS-read assert, NEVER_MATCH_WHERE skeleton. Deletecount-min-required-subject-groups.spec.ts. - E2E specs —
test/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: nullnode, leaves nested per subject, rename tonumOfferedSubjectGroups, covered grades only), chat 2026-07-10.
Until this section is filled, no implementation code is written.