Grouped courses iteration 4 — CV-defined group structure on the board¶
1. Problem distillation¶
- Post homeroom/course decoupling (2026-07-08) nothing auto-creates SGs, so a fresh board has SG rows only where an admin already created courses. QA sees the courses page collapse to "optional courses grouped by block and subject"; every group the curriculum defines but nobody created yet is invisible.
- The board's
subjects[]is already catalog-complete (one row per subject offered in the grade, mandatory + in-block,0/0rows included) — but the rows are anonymous count pairs: nooptionBlockref, notrackref, no mandatory/optional discriminator, noweeklyHours, no CV ordering. The FE cannot rebuild the curriculum-defined block→subject layout from this payload, so an empty group has nothing to render as. - Pre-decoupling this was masked: homeroom creation seeded one SG per mandatory subject, so mandatory sections always had real rows. The fix is NOT to bring seeding back — it is to make the board self-sufficient: every CV-defined group renders (empty when no SG exists) from this one endpoint.
- Same gap on the SG leaves:
subject {curriculumSubjectId, name, weeklyHours}has no block ref, so even created courses can't be block-grouped without a separate curriculum-grid fetch.
Success criteria (observable behavior that proves this works):
- Each subjects[] entry carries optionBlock ({id, name, ordinalPosition,
minSelections, maxSelections} — null = mandatory subject), track
({id, name} effective track — null = common), weeklyHours (this grade's
cell value), and ordinalPosition (the subject's CV order).
- subjects[] is ordered by CV structure: mandatory rows first (subject
ordinalPosition), then blocks by block ordinalPosition (subject
ordinalPosition within), name as final tiebreak — replacing the name sort.
- A curriculum+grade with ZERO subject groups still yields the full set of
renderable sections: the FE derives one section per mandatory subject + one
per block from subjects[] alone, and slots subjectGroups[] leaves in by
subject.curriculumSubjectId === curriculumSubject.id.
- SG leaf subject summary (SubjectGroupSubjectSummaryDto) gains
optionBlock {id, name} | null — on the board, GET /subject-groups, and
the detail response (shared DTO).
- Coverage-count semantics (expected/placed, missing-assignments, teacher
counts) are byte-identical to iteration 3.
Non-goals (in-scope-shaped things this iteration is explicitly not doing):
- No placeholder/virtual SG rows in subjectGroups[] — "empty group" = a
subjects[] row with no matching leaf, not a fake SG entity.
- No return of mandatory-SG auto-seeding (deliberately removed by the
decoupling; creation stays manual, numMinRequiredSubjectGroups stays the
floor signal).
- No per-track duplication of subject rows — a track-scoped subject appears
once per grade with its track ref; the FE decides how to render audience.
- No change to any count, RBAC gate, query filter, schema, or write path.
- No change to grouped-homerooms.
2. Patterns survey¶
| Analogous module/spec | What we'd borrow | What doesn't fit |
|---|---|---|
docs/superpowers/specs/2026-07-03-grouped-courses-iteration-3-design.md (prior iteration) |
The whole board: catalog skeleton, findBoardSubjectsForCurricula + BoardSubjectRow, per-grade subjects[] fold, count semantics |
Its rows are count-only; this iteration adds CV structural identity to them |
docs/superpowers/specs/2026-07-08-homeroom-course-decoupling-design.md |
Root cause context: SG creation is fully manual now, so defined-but-uncreated groups must be visible from the catalog side | It removed structure (homeroom refs); this restores curriculum structure the board never had |
src/subject-groups/subject-groups.queries.ts boardSubjectSelect / subjectGroupListInclude / subjectGroupDetailInclude |
Select-const pattern; boardSubjectSelect already selects optionBlockId, trackId, optionBlock.trackId, hours{gradeId} — widening these selects is the whole query delta |
List include has optionBlockId but not the block's name — needs optionBlock {id, name} |
src/curriculum/curriculum.queries.ts curriculumGridInclude (grid read surface) |
The canonical shape of block refs (name, minSelections, maxSelections, ordinalPosition) and track refs the FE already knows from the grid editor |
Full grid include is far heavier than the board needs; we select refs only |
src/subject-groups/subject-groups.service.ts effectiveTrackOf (service:1436) |
Effective-track resolution (subject.trackId ?? optionBlock.trackId ?? null) — same rule, applied with names |
Operates on detail rows; board rows resolve from boardSubjectSelect fields |
3. Architecture mapping¶
| Primitive | Apply? | How | Justify |
|---|---|---|---|
| Tenant scope | yes | Unchanged — all board queries run behind policy wheres (+ RLS); the widened selects add columns, not rows |
Same posture as iteration 3 |
| Academic-year scope | yes | Unchanged — resolveActiveYear + AY filters as today |
|
| RBAC entity key | existing | SUBJECT_GROUPS + inline STUDENTS read assert, unchanged |
No new keys |
| Scopes | existing read |
@RequireScopes(EntityKey.SUBJECT_GROUPS, 'read') unchanged |
|
| Actions | none | Read-only aggregate | |
| Service base | custom method | SubjectGroupsService.getGroupedCourses reworked in place (fold gains fields + new sort); toSubjectGroupListItem/toDetailDto gain the block ref |
Same placement as iterations 2–3 |
queries.ts shape |
widen 3 selects, no new functions | boardSubjectSelect += ordinalPosition, track {id,name}, optionBlock {id,name,ordinalPosition,minSelections,maxSelections,track{id,name}}, hours {gradeId,weeklyHours}; BoardSubjectRow reshaped accordingly (keeps effectiveTrackId for appliesToTrack, adds effectiveTrack, optionBlock, ordinalPosition, per-grade hours); subjectGroupListInclude + subjectGroupDetailInclude += optionBlock {id,name} |
No new query functions — pure select widening |
| Error codes | existing | None new | |
| DTO conventions | additive fields | dto/grouped-courses-response.dto.ts: new GroupedCourseOptionBlockRefDto + GroupedCourseTrackRefDto; GroupedCourseSubjectCoverageDto += optionBlock, track, weeklyHours, ordinalPosition. dto/scopes/subject-group-composition.dto.ts: SubjectGroupSubjectSummaryDto += optionBlock {id,name} \| null |
Mirrors existing nullable-summary-ref convention (baseRoom) |
| File-backed sub-resources | n/a | no files | |
| Custom fields | no | n/a | |
| Profile completeness | no | n/a |
Field-filter note (in-body, not swagger): board + list are @AggregateResponse
(interceptor skips them); on the detail response the enrichment nests inside the
existing subject field, so no ScopeFieldMapping / scope-fields.ts delta.
4. Data model plan¶
Schema deltas¶
- None. Pure read-surface enrichment over existing tables
(
option_blocks.name/ordinal_position/min_selections/max_selections,curriculum_tracks.name,curriculum_subject_hours.weekly_hours,curriculum_subjects.ordinal_positionall exist).
Migration shape¶
- n/a — no migration.
Indexes and uniqueness¶
- None new. Same query keys as iteration 3.
5. API surface¶
| Verb | Path | Decorators | Request DTO | Response DTO |
|---|---|---|---|---|
| GET | /subject-groups/grouped-courses |
unchanged (@RequireScopes(SUBJECT_GROUPS,'read'), @RequireRoles('admin','department_head'), @AppliesPolicyDimensions(SubjectGroupsPolicy), @AggregateResponse()) |
GroupedCoursesQueryDto (unchanged) |
GroupedCoursesResponseDto (enriched) |
| GET | /subject-groups (+ /:id, POST/PATCH echoes) |
unchanged | unchanged | leaf/detail subject summary gains optionBlock |
Enriched subjects[] entry (contract):
{
"curriculumSubject": { "id": "…", "name": "Mathematics" },
"ordinalPosition": 2, // subject's CV order
"weeklyHours": 4, // this grade's hours cell (row exists ⟺ cell exists)
"track": { "id": "…", "name": "Science" }, // effective track; null = common
"optionBlock": null, // null = mandatory subject
"numStudentsExpected": 14,
"numStudentsPlaced": 11
}
// in-block alternative:
{
"curriculumSubject": { "id": "…", "name": "Spanish" },
"ordinalPosition": 0,
"weeklyHours": 3,
"track": null,
"optionBlock": { "id": "…", "name": "2nd language", "ordinalPosition": 1,
"minSelections": 1, "maxSelections": 1 },
"numStudentsExpected": 6,
"numStudentsPlaced": 0 // defined-but-empty group → renders as empty section
}
Normative semantics:
- Row presence unchanged: a subjects[] row exists for a grade ⟺ the
subject has an hours cell there ("cell presence ⟺ taught"). A block none of
whose alternatives are offered in a grade does not appear for that grade.
- track = effective track (subject.trackId ?? optionBlock.trackId),
with name; null = common to all tracks. In-block subjects always inherit
the block's track (DB CHECK).
- weeklyHours = the (subject, grade) cell value — non-null by the row-
presence rule.
- Ordering: mandatory rows first, by subject ordinalPosition; then blocks
by block ordinalPosition, subjects by ordinalPosition within; name as
final tiebreak. Replaces the iteration-3 name sort (FE-breaking, accepted).
- FE contract: sections = mandatory subjects + distinct optionBlocks of
subjects[]; join subjectGroups[] leaves via subject.curriculumSubjectId
(and now also group leaves by subject.optionBlock). A section with no
matching leaf is an empty group.
Swagger considerations¶
GroupedCourseSubjectCoverageDtonew fields get contract-only JSDoc: the null-discriminator (optionBlock: null= mandatory), effective-track semantics, the row-presence rule, and the CV ordering ofsubjects[].SubjectGroupSubjectSummaryDto.optionBlockis@ApiPropertyOptionalnullable (same shape convention asbaseRoom).- Update the FE-facing controller JSDoc on
getBoardto state the board is now self-sufficient for rendering the CV-defined group structure.
6. RBAC seed plan¶
| Seed file | Delta |
|---|---|
PermissionScope (rbac-catalogue.ts) |
none |
PermissionAction (rbac-catalogue.ts) |
none |
ScopeFieldMapping (rbac-catalogue.ts) |
none — enrichment nests inside the existing subject field |
| Role grants (roles.ts) | none |
*_SCOPES runtime constant |
none |
7. Divergence ledger¶
| Pattern | We diverge by | Reason | Tradeoff accepted |
|---|---|---|---|
Iteration-3 subjects[] sorted by name |
CV-structure sort (mandatory first, block ordinal, subject ordinal) | The array now IS the page structure; alphabetical order would interleave blocks | FE-breaking order change (acceptable per project stance; FE gets the ordinals anyway) |
Board payload kept refs minimal ({id, name} pairs only) |
optionBlock ref carries minSelections/maxSelections/ordinalPosition |
An empty block section should display its selection rule ("choose 1 of 3") without a grid fetch | Slightly fatter ref; still no nested grid |
| None otherwise — fits the iteration-3 board fold end-to-end |
8. Pushback log¶
| US says | Conflicts with | Proposed instead | Status |
|---|---|---|---|
| QA (relayed): "return empty groups but still all the groups as defined for each cv" — reads as missing rows | The board already returns empty skeleton nodes + catalog-complete 0/0 rows (iteration 3); what's missing is structural identity, not rows |
Enrich the existing rows (block/track/hours/ordinals) so each 0/0 row is a renderable empty group; no new row kinds |
Resolved (Fabio, chat 2026-07-10 — "go on with this") |
Implicit alternative: restructure the grade node into nested mandatorySubjects[] + optionBlocks[].subjects[].subjectGroups[] sections |
Bigger break for the same information; duplicates SG leaves or relocates them again (iteration 3 just moved them) | Flat enriched rows + client-side join via curriculumSubjectId |
Resolved (Fabio, chat 2026-07-10 — flat shape approved) |
9. Deferrals¶
- Blocks with zero alternatives offered in a grade stay invisible for that grade (row-presence rule kept) — if QA expects every block on every covered grade regardless, that's a rule change — follow-up: revisit only if QA asks.
- Per-track row duplication (one section per track cohort for track-scoped
subjects, matching how
numMinRequiredSubjectGroupscounts) — FE can derive from thetrackref if needed — follow-up: revisit at next iteration. - Removing the now-superseded plain
GET /subject-groupsgrouped list — FE-coordination question carried since iteration 2 — follow-up: next iteration.
10. Open questions¶
None — resolved in chat 2026-07-10: enrich flat subjects[] (no nested
restructure); block ref carries min/max/ordinal; effective-track ref with name;
weeklyHours on the row; CV-structure ordering replaces name sort; leaf
subject summary gains the optionBlock {id, name} ref.
11. Verification plan¶
- Unit specs —
src/subject-groups/subject-groups.service.spec.ts(getGroupedCoursesdescribe-block, extend): - A mandatory common subject row:
optionBlock: null,track: null,weeklyHours= its grade cell,ordinalPositionechoed. - A track-scoped mandatory subject row:
track {id, name}populated. - An in-block alternative row:
optionBlock {id, name, ordinalPosition, minSelections, maxSelections};trackinherited from the block when the block is track-scoped. - Ordering: mandatory rows (by subject ordinal) precede block rows (by block ordinal, then subject ordinal); name tiebreak.
- Zero-SG curriculum+grade: full enriched
subjects[]present at0/0— the "empty groups" QA case. - Counts unchanged vs iteration-3 fixtures (regression guard).
- Leaf mappers:
toSubjectGroupListItem+ detail DTO emitsubject.optionBlock(nullfor mandatory-anchored SGs). - E2E specs —
test/subject-groups-grouped.e2e-spec.ts(extend): - Board over a curriculum with mandatory + option-block subjects and zero
SGs: every defined group appears with block/track refs at
0/N, in CV order. GET /subject-groupsleaf carriessubject.optionBlockfor an in-block SG andnullfor a mandatory one.- Manual verification: none beyond e2e — FE integration exercises the contract.
Patterns: chapter 09 (testing); E2E isolation per the existing
subject-groups-grouped suite. (Run only when explicitly asked.)
Doc updates on landing: ch14 §grouped-boards table (subjects[] row shape +
iteration-4 spec link), docs/REFERENCE.md if it names the board shape, and an
FE guide docs/fe-guides/2026-07-10-grouped-courses-cv-structure.md (new
fields + ordering change + how to render empty groups).
12. Sign-off¶
- Approved by: Fabio
- Date: 2026-07-10
- Chat reference: "go on with this" (Fabio, chat 2026-07-10) to the exact shape this spec captures — enriched flat
subjects[](block/track/hours/ordinals), CV ordering, leafsubject.optionBlockref, nested-restructure alternative rejected.
Until this section is filled, no implementation code is written. When you fill it, flip the frontmatter status: to Approved in the same edit.