Grouped boards iteration 2 — counts-first grouped-homerooms / grouped-courses¶
1. Problem distillation¶
- The placement boards return every ENROLLED student as a row, which the FE no longer wants: the list views need aggregate counts (placement coverage at a glance), not rosters — and shipping all students in one call is wasteful.
- The current tree is union-driven (a
(dept, curriculum, grade)node exists only if it has ≥1 class row or ≥1 student), so empty grades vanish. The FE needs the full catalog skeleton: every visible department, every READY curriculum, every covered grade — present even when empty. - Both endpoints are renamed (
placement-board→grouped-homerooms/grouped-courses) and keep their class-entity leaves (homerooms[]/subjectGroups[]) so the list views still render actual rows — only the per-student arrays go. - The SG board's per-student coverage math (
assignmentsHave/Should) is folded into a single per-curriculum count: students with at least one missing SG assignment. New curriculum-level counts: SGs created, minimum required SGs (static, from the curriculum grid), SGs without a teacher.
Success criteria (observable behavior that proves this works):
- GET /homerooms/grouped-homerooms returns every visible department; under each, every READY curriculum; under each, every covered grade — even with zero homerooms and zero students. No students[] anywhere in the payload.
- Each grade node carries numHomerooms, numStudentsWithHomeroom, numStudentsWithoutHomeroom, and homerooms[] (list-item leaves, roster-free — numStudents count only).
- Each department node (homerooms board) carries numStudentsWithHomeroom, numStudentsWithoutHomeroom, numStudentsWithoutCurriculum over ALL its ENROLLED active-AY students.
- GET /subject-groups/grouped-courses returns a dept → curriculum tree (no grade level); department nodes carry numSubjectGroups, numStudents, numStudentsWithoutCurriculum; curriculum nodes carry numSubjectGroups, numMinRequiredSubjectGroups, numStudentsWithMissingAssignments, numSubjectGroupsWithoutTeacher, and subjectGroups[] leaves (ALL SGs — standalone + homeroom-bound — each carrying its grade and a nullable homeroom ref).
- numMinRequiredSubjectGroups = Σ over covered grades g of: |mandatory subjects with an hours cell in g| + Σ over option blocks with ≥1 alternative offered in g of block.minSelections.
- Old placement-board routes are gone (404); DTOs/queries/e2e renamed accordingly.
- Parametric managers (dept_head, curriculum_coordinator) see a skeleton narrowed by their dims, with counts computed over the same narrowed row sets as today.
Non-goals (in-scope-shaped things this iteration is explicitly not doing):
- No per-student drill-down endpoint for a grade/curriculum node (FE uses the existing eligible-for pickers for roster modals; a paginated per-node student list is a future iteration if asked).
- No change to the plain grouped list endpoints GET /homerooms / GET /subject-groups, the eligible-for pickers, or any write path.
- No pagination on the boards (payload is now small: catalog skeleton + counts + class leaves).
- No RBAC changes — same gates as the placement boards.
2. Patterns survey¶
| Analogous module/spec | What we'd borrow | What doesn't fit |
|---|---|---|
docs/superpowers/specs/2026-06-15-placement-coverage-boards-design.md (prior iteration) |
Route placement under module controllers, two-scope gate (entity read + inline STUDENTS read assert), @RequireRoles('admin','department_head') + @AppliesPolicy, tree DTO conventions |
Union-driven node generation and per-student rows — both replaced (catalog-driven skeleton, counts) |
src/common/utils/group-board-tree.ts |
Nothing — it exists only for the union tree | Becomes dead code; deleted with its spec (group-board-tree.spec.ts) |
src/curriculum/resolve-target-plan.ts + loadConsistencyStructure (src/curriculum/selection-consistency.ts) |
Per-student should/planSubjectIds for the missing-assignment count — reused verbatim, aggregated instead of surfaced per row |
Static numMinRequiredSubjectGroups needs a sibling helper that does NOT filter by track (counts every track's rows — the school must create SGs for all tracks) |
src/homerooms/homerooms.queries.ts findHomeroomsForTree + homeroomListInclude |
Homeroom leaf rows (HomeroomListItemDto, _count.assignments → numStudents) under HomeroomsPolicy.where(ctx) |
Fits cleanly |
src/subject-groups/subject-groups.queries.ts findSubjectGroupsForTree + toSubjectGroupListItem |
SG leaf rows under SubjectGroupsPolicy.where(ctx) |
Board query drops the homeroomId: null filter (bound SGs now included) and includes a homeroom {id,name} ref on the leaf |
src/departments/departments.policy.ts + src/curriculum/curricula.policy.ts |
Catalog skeleton enumeration under existing visibility: DepartmentsPolicy.where(ctx) for departments, CurriculaPolicy.where(ctx) for READY curricula (+ CurriculumGrade coverage for grade nodes) — parametric dims (DEPARTMENT/CURRICULUM) narrow for free |
Fits cleanly |
src/command-center/ overview (project_command_center_overview_design) |
Precedent for an admin counts-rollup endpoint (counts, not rows) | Command-center is cross-domain; these boards stay in their owning modules |
3. Architecture mapping¶
| Primitive | Apply? | How | Justify |
|---|---|---|---|
| Tenant scope | yes | Every query goes through a policy where whose buildBase pins tenantId; skeleton queries use DepartmentsPolicy/CurriculaPolicy bases |
Same as prior iteration |
| Academic-year scope | yes | resolveActiveYear once; homerooms/SGs/students/curricula filtered to the active AY — and departments are AY-scoped too, so the skeleton departments query filters academicYearId as well |
Same as prior iteration |
| RBAC entity key | existing | HOMEROOMS / SUBJECT_GROUPS primary; STUDENTS read asserted inline in the service (counts still derive from student data) |
No new keys |
| Scopes | existing read |
@RequireScopes(EntityKey.HOMEROOMS, 'read') / (EntityKey.SUBJECT_GROUPS, 'read') unchanged |
|
| Actions | none | Read-only aggregate | |
| Service base | custom methods on existing services | HomeroomsService.getGroupedHomerooms, SubjectGroupsService.getGroupedCourses replace the two getPlacementBoard methods |
Same placement as today |
queries.ts shape |
new named functions | Homerooms: findGroupedHomeroomsSkeleton (READY curricula + dept + covered grades under CurriculaPolicy), findVisibleDepartments (under DepartmentsPolicy), findStudentPlacementCounts (minimal projection: id, departmentId, gradeId, homeroomAssignment.homeroomId, curriculumSelection.curriculumId). SGs: findSubjectGroupsForBoard (all SGs incl. bound, + homeroom ref + teachers + _count.assignments), findStudentCoverageRows (adds curriculumSelection.trackId + subjectGroupAssignments.curriculumSubjectId to the minimal projection) |
Replaces findPlacementBoardStudents / findSgPlacementBoardStudents |
| Error codes | existing | None new — same 401/403 surfaces | |
| DTO conventions | response DTOs per module | dto/grouped-homerooms-response.dto.ts, dto/grouped-courses-response.dto.ts; query DTOs slim to optional departmentId |
Replace placement-board-*.dto.ts |
| File-backed sub-resources | n/a | — no files involved | |
| Custom fields | no | n/a — aggregate counts, no entity read/write surface | |
| Profile completeness | no | n/a — no person fields touched |
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. Count queries filter on already-indexed FKs (
tenantId,academicYearId,departmentId,gradeId).
5. API surface¶
| Verb | Path | Decorators | Request DTO | Response DTO |
|---|---|---|---|---|
| GET | /homerooms/grouped-homerooms |
@RequireScopes(HOMEROOMS,'read'), @RequireRoles('admin','department_head'), @AppliesPolicy(HomeroomsPolicy), @AggregateResponse() |
GroupedHomeroomsQueryDto (departmentId?) |
GroupedHomeroomsResponseDto |
| GET | /subject-groups/grouped-courses |
@RequireScopes(SUBJECT_GROUPS,'read'), @RequireRoles('admin','department_head'), @AppliesPolicy(SubjectGroupsPolicy), @AggregateResponse() |
GroupedCoursesQueryDto (departmentId?) |
GroupedCoursesResponseDto |
| ~~GET~~ | ~~/homerooms/placement-board~~ |
deleted | ||
| ~~GET~~ | ~~/subject-groups/placement-board~~ |
deleted |
Response shapes (contract):
// GET /homerooms/grouped-homerooms
{
"departments": [{
"department": { "id", "name" },
"numStudentsWithHomeroom": 42, // all ENROLLED active-AY students of the dept
"numStudentsWithoutHomeroom": 7, // includes the without-curriculum slice
"numStudentsWithoutCurriculum": 4, // no CurriculumSelection row (always homeroom-less by forward-sync)
"curricula": [{ // every READY curriculum of the dept — present even if empty
"curriculum": { "id", "name" },
"grades": [{ // every CurriculumGrade-covered grade — present even if empty
"grade": { "id", "name", "ordinalPosition" },
"numHomerooms": 2, // == homerooms.length (kept as an explicit count per FE contract)
"numStudentsWithHomeroom": 18, // students whose selection targets this curriculum, in this grade
"numStudentsWithoutHomeroom": 3,
"homerooms": [ /* HomeroomListItemDto[] — numStudents count, no roster */ ]
}]
}]
}]
}
// GET /subject-groups/grouped-courses — dept → curriculum, NO grade level
{
"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,
"curricula": [{ // every READY curriculum of the dept — present even if empty
"curriculum": { "id", "name" },
"numSubjectGroups": 17,
"numMinRequiredSubjectGroups": 22, // static floor from the grid (see §1 formula)
"numStudentsWithMissingAssignments": 9, // selection targets this curriculum ∧ have < should
"numSubjectGroupsWithoutTeacher": 3,
"subjectGroups": [ /* GroupedCourseSubjectGroupDto[] — SubjectGroupListItemDto + homeroom: {id,name}|null; each leaf carries its grade */ ]
}]
}]
}
Count semantics (normative):
- All student counts: status = 'ENROLLED', active AY, under StudentsPolicy.where(ctx); NEVER_MATCH_WHERE → all student counts are 0 (skeleton still returned).
- Dept-level counts include students regardless of selection state; grade-level counts (homerooms board) cover only students whose selection targets that curriculum node — so grade sums = dept totals minus the without-curriculum slice (and minus any selection pointing at a non-READY curriculum, an edge that keeps dept totals truthful).
- numStudentsWithMissingAssignments: per student, resolveTargetPlan(structure, {trackId, gradeId}) → should; have = their subjectGroupAssignments capped to planSubjectIds; counted when have < should. Students without a selection are excluded (they are the separate dept count).
- numSubjectGroupsWithoutTeacher: SGs (standalone + bound) with zero SubjectGroupTeacher rows.
Swagger considerations¶
- JSDoc on both controller methods is FE-facing: document the catalog-driven skeleton (empty nodes ARE present), count semantics above, and that rosters come from the eligible-for pickers — not from these boards. The two-scope RBAC note stays in the method body.
homeroomref on the SG leaf isnullable: true(@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 today |
*_SCOPES runtime constant |
none |
7. Divergence ledger¶
| Pattern | We diverge by | Reason | Tradeoff accepted |
|---|---|---|---|
Union-driven board nodes (groupBoardTree, prior spec §"Union-driven nodes") |
Catalog-driven skeleton: departments from DepartmentsPolicy, READY curricula from CurriculaPolicy, grades from CurriculumGrade coverage |
FE needs empty nodes as actionable gaps ("grade 3 has 0 homerooms") | Skeleton queries added; groupBoardTree + spec deleted (dead code) |
SG list/board scope = standalone only (homeroomId: null) |
grouped-courses counts AND leaves include homeroom-bound child SGs |
Mandatory subjects are covered by bound SGs; standalone-only counts could never reconcile with numMinRequiredSubjectGroups |
Leaf DTO gains a nullable homeroom ref so FE can distinguish bound rows |
resolveTargetPlan filters by the student's track |
numMinRequiredSubjectGroups counts ALL tracks' rows (no track filter) |
It's a school-level creation floor: every track cohort needs its own SGs | New pure helper (countMinRequiredSubjectGroups) instead of reusing resolveTargetPlan per track |
| Boards previously enumerated grades only under curricula that had rows | Grade nodes enumerate the curriculum's covered grades — NOT all department grades | A homeroom for a non-covered grade is impossible (wizard rejects); dept-wide enumeration would render permanently-dead nodes | FE reads the dept's full grade list from /departments if it needs it for layout |
8. Pushback log¶
| US says | Conflicts with | Proposed instead | Status |
|---|---|---|---|
| "for each curriculum … minimum requested courses" — option blocks could be read as one SG per alternative | User clarified in chat | minSelections per block per offered grade (the floor if all students pick the same alternatives), not every-alternative |
Resolved (user, 2026-07-02) |
Route names grouped-homerooms / grouped-courses under module prefixes read redundantly (/homerooms/grouped-homerooms) |
Route naming economy | Keep the user-specified names verbatim under the owning module controllers (guards/policies stay put) | Resolved (user-specified) |
| "include all grades in the response" could be read as ALL department grades under every curriculum | Curriculum wizard's grade-coverage gate | Covered grades only (see divergence ledger row 4) | Resolved (accepted in chat) |
9. Deferrals¶
- Per-node student drill-down (paginated students for one grade/curriculum node) — not requested; the eligible-for pickers already serve the roster modals — follow-up: revisit if FE asks after integrating the counts.
GET /homerooms/GET /subject-groupsplain grouped lists left untouched even thoughgrouped-*now supersets their leaves — removing them is an FE-coordination question — follow-up: revisit at next iteration.- DB-side aggregation (
groupBy/raw counts) if student volumes make the in-memory fold slow — same fork as command-center overview iteration 2 — follow-up:project_command_center_overview_iteration2decision applies here too.
10. Open questions¶
None — all resolved in chat 2026-07-02 (grade enumeration = covered grades; no-selection students = dept-level count, null bucket dropped; bound SGs included; min-required = minSelections; READY curricula only; old routes deleted; departmentId sole filter; RBAC unchanged).
11. Verification plan¶
- Unit specs:
src/curriculum/count-min-required-subject-groups.spec.ts— the static floor: mandatory × offered grades; blockminSelectionsper grade with ≥1 offered alternative; track-scoped rows all counted; empty grid → 0.src/homerooms/homerooms.service.spec.ts(rework board describe-block) — skeleton includes empty curricula/grades; dept counts vs grade counts (without-curriculum slice only at dept level);NEVER_MATCH_WHEREstudent visibility → zeroed counts with intact skeleton; STUDENTS-read assert still throws.src/subject-groups/subject-groups.service.spec.ts(rework board describe-block) — bound SGs included in counts + leaves; no-teacher count; missing-assignment count viaresolveTargetPlan(have capped to plan subjects); no-selection students excluded from curriculum counts.- E2E specs:
test/homerooms-grouped.e2e-spec.ts(renameshomerooms-placement-board.e2e-spec.ts) — full-skeleton presence with zero rows; counts after placing/removing students; dept_head sees only their department's skeleton; nostudentskey in payload; old route 404s.test/subject-groups-grouped.e2e-spec.ts(renamessubject-groups-placement-board.e2e-spec.ts) — min-required with an option block (minSelectionsfloor); bound-SG leaf carries homeroom ref; teacherless count; missing-assignment count moves when a student is assigned.- Manual verification: none beyond e2e — FE integration exercises the same contract.
12. Sign-off¶
- Approved by: Fabio
- Date: 2026-07-02
- Chat reference: approved in chat 2026-07-02 ("go on" after spec walkthrough; minSelections clarification and roster-free leaves settled in the same thread)
Until this section is filled, no implementation code is written.