Skip to content

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-boardgrouped-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.assignmentsnumStudents) 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.
  • homeroom ref on the SG leaf is nullable: 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-groups plain grouped lists left untouched even though grouped-* 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_iteration2 decision 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; block minSelections per 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_WHERE student 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 via resolveTargetPlan (have capped to plan subjects); no-selection students excluded from curriculum counts.
  • E2E specs:
  • test/homerooms-grouped.e2e-spec.ts (renames homerooms-placement-board.e2e-spec.ts) — full-skeleton presence with zero rows; counts after placing/removing students; dept_head sees only their department's skeleton; no students key in payload; old route 404s.
  • test/subject-groups-grouped.e2e-spec.ts (renames subject-groups-placement-board.e2e-spec.ts) — min-required with an option block (minSelections floor); 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.