Skip to content

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/0 rows included) — but the rows are anonymous count pairs: no optionBlock ref, no track ref, no mandatory/optional discriminator, no weeklyHours, 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_position all 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

  • GroupedCourseSubjectCoverageDto new fields get contract-only JSDoc: the null-discriminator (optionBlock: null = mandatory), effective-track semantics, the row-presence rule, and the CV ordering of subjects[].
  • SubjectGroupSubjectSummaryDto.optionBlock is @ApiPropertyOptional nullable (same shape convention as baseRoom).
  • Update the FE-facing controller JSDoc on getBoard to 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 numMinRequiredSubjectGroups counts) — FE can derive from the track ref if needed — follow-up: revisit at next iteration.
  • Removing the now-superseded plain GET /subject-groups grouped 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 specssrc/subject-groups/subject-groups.service.spec.ts (getGroupedCourses describe-block, extend):
  • A mandatory common subject row: optionBlock: null, track: null, weeklyHours = its grade cell, ordinalPosition echoed.
  • A track-scoped mandatory subject row: track {id, name} populated.
  • An in-block alternative row: optionBlock {id, name, ordinalPosition, minSelections, maxSelections}; track inherited 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 at 0/0 — the "empty groups" QA case.
  • Counts unchanged vs iteration-3 fixtures (regression guard).
  • Leaf mappers: toSubjectGroupListItem + detail DTO emit subject.optionBlock (null for mandatory-anchored SGs).
  • E2E specstest/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-groups leaf carries subject.optionBlock for an in-block SG and null for 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, leaf subject.optionBlock ref, 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.