Skip to content

Create courses from a homeroom (batch wizard) + courses-by-homeroom filter

1. Problem distillation

  • Creating a class's courses today is one POST /subject-groups per subject — for a typical grade that is 6–10 mandatory courses plus optionals, each with a hand-picked roster. Product wants a wizard: pick a homeroom, create every missing mandatory course with the whole class as roster in one shot, then optionally create/extend option-block and track courses with a user-assigned roster.
  • The wizard needs one read that answers "what would I create for this class?": the homeroom header + roster, the grade-sliced curriculum structure (common mandatory / blocks / tracks), which subjects already have courses, and per-subject eligibility counts. Today that requires N calls (one eligibility picker call per subject).
  • Product also wants to filter the courses table by homeroom ("the courses of class 2A"). No explicit homeroom↔course relation exists (deliberately removed 2026-07-08); the filter is delivered as a derived roster-overlap predicate, not a re-coupling.
  • The FE pool filter ("students of class X" inside the assign step) needs a stable homeroom id on picker rows; today rows carry only homeroomName.

Success criteria (observable behavior that proves this works): - GET /homerooms/:id/course-blueprint returns, in one call, everything the wizard renders: homeroom + roster, mandatory subjects (common track) with their existing courses, option blocks and tracks with per-subject existing courses + eligibility bucket counts. - POST /subject-groups/from-homeroom creates all requested new courses and appends rosters to the requested existing courses in one transaction, returning { created, updated, skipped }; any hard validation failure creates nothing. - GET /subject-groups/table?homeroomId=<hr> returns exactly the courses (and combined classes) whose roster contains ≥ 1 student of that homeroom. - GET /students/eligible-for-subject-group rows carry homeroomId alongside homeroomName. - Re-running the wizard for the same homeroom is safe: existing courses are merge targets, already-covered students are per-student skips, nothing duplicates.

Non-goals (in-scope-shaped things this iteration is explicitly not doing): - No persisted homeroom↔course relationship (no FK, no junction, no provenance tag). The decoupling invariant — homeroom membership has no effect on any course — stands; the filter is read-time only. - No second-section creation for a subject that already has a course (product decision: merge only, mock as-is). The single POST /subject-groups remains the way to open another section. - No roster removal through the batch (append-only); removals stay on DELETE /subject-groups/:id/students. - No teacher/room requirementteacherIds/baseRoomId are accepted per item (they already exist on single create) but the wizard may leave courses bare. - No changes to the single-create flow ("Da materia" mode reuses POST /subject-groups unchanged).


2. Patterns survey

Analogous module/spec What we'd borrow What doesn't fit
src/students/ bulk curriculum assignment (POST /students/curriculum-selections/bulk-assign, spec 2026-06-16-bulk-curriculum-assignment-design.md) The batch-write-with-report shape: additive, per-item skipped[{id, reason}], never touches what exists It is per-student single-entity; ours is per-course with two write modes (create / append) and all-or-nothing atomicity
src/subject-groups/subject-groups.service.ts single create + addStudents The entire per-item write path: anchor validation (anchorOffersGrade, unit rejection), eligibility gate (assertStudentsEligibleclassifySubjectGroupEligibility), forward selection sync (applyClassAssignmentToSelection), teacher/room validation, name-conflict P2002 mapping Single-item transactions; batch wraps N items in one tx and softens exactly one hidden-reason (already-assigned → per-student skip)
GET /subject-groups/missing-courses (grouped-courses.controller.ts, spec 2026-07-13-subject-groups-missing-courses-design.md) The "already exists" rule — a subject is covered iff ≥ 1 SG on (curriculumSubjectId, gradeId, AY); findCoveredSubjectGradePairs groupBy; catalog loaders findBoardSubjectsForCurricula It prunes to missing subjects tree-wide; the blueprint lists all offered subjects for one (curriculum, grade) with the existing SG refs attached
Eligibility pickers (src/students/eligible-students… mounted by class modules, ch14 §5, spec 2026-06-16-eligibility-classifier-pickers-design.md) Single-sourced classifier + permissive cohort loader (findSubjectGroupEligibilityCohort); the cross-module mounting trick (route under one entity's URL, gated by another entity's scope); admin/department_head allowlist Picker classifies one anchor per call; the blueprint classifies the same cohort against every optional anchor in one pass
Table filters iteration 2 (subject-groups-table-query.dto.ts, spec 2026-07-13-table-lists-and-filters-iteration-2-design.md) Multi-value id filter convention (@TransformToArray, within-field OR), "unknown ids yield an empty page", combined-class rows match via any member fits cleanly — homeroomId is one more filter of the same shape
Grouped boards (ch14 §5.1) The two-scope RBAC shape: decorator entity scope + inline second assert (PermissionsService.checkEntityAccess) because ScopeGuard reads a single key Boards return counts; blueprint returns roster PII, so the policy-narrowed homeroom load is the visibility gate

3. Architecture mapping

Primitive Apply? How Justify
Tenant scope yes All reads/writes filter tenantId; blueprint resolves the homeroom in-tenant; batch resolves homeroom, anchors, target SGs, students, teachers, room in-tenant Standard — no cross-tenant surface
Academic-year scope yes Blueprint accepts AcademicYearQueryDto override (read convention); the batch POST targets the ACTIVE year only (write convention — no academicYearId in the body, diverging from the mock) Matches "writes ignore any override" invariant
RBAC entity key existing SUBJECT_GROUPS (+ existing HOMEROOMS policy for visibility) No entity-keys.ts delta Both surfaces are course-creation surfaces; homeroom visibility rides HomeroomsPolicy
Scopes existing subject_groups.composition (read/write) Blueprint gated @RequireScopes(SUBJECT_GROUPS, 'read'); batch merge path asserts composition write inline No new scope
Actions existing create Batch gated @RequireAction(SUBJECT_GROUPS, 'create') (same as single create) read/update implicit per convention
Service base custom (both modules are already bespoke aggregate services) Blueprint: method on HomeroomsService (or a sibling course-blueprint.service.ts in src/homerooms/); batch: src/subject-groups/from-homeroom/ subfolder (module-foldering convention, like combined-classes/) Neither entity uses BaseTenantedCrudService
queries.ts shape reuse + 1–2 named functions Reuse findBoardSubjectsForCurricula, findCoveredSubjectGradePairs, findSubjectGroupEligibilityCohort, homeroom detail loader; new: findExistingSubjectGroupsForSubjects(tenantId, ayId, gradeId, subjectIds) (id/name/numStudents per anchor) and the homeroomId leg in buildSubjectGroupsTableWhere Existing loaders cover ~all of it
Error codes 1 new + existing New SUBJECT_GROUP_BATCH_TARGET_MISMATCH (merge item whose subjectGroupId doesn't anchor the item's curriculumSubjectId on the homeroom's grade+AY). Reuse: HOMEROOM_NOT_FOUND, SUBJECT_GROUP_ANCHOR_IS_UNIT, offered-grade + eligibility + name-conflict codes, VALIDATION_FAILED (duplicate subject in batch). New code lands in error-codes.ts + error-examples.ts + i18n catalog (messages{en,it}) BE owns error i18n
DTO conventions aggregate DTOs (@AggregateResponse() routes) src/homerooms/dto/course-blueprint-response.dto.ts; src/subject-groups/from-homeroom/dto/{create-from-homeroom.dto.ts, from-homeroom-response.dto.ts}; homeroomId?: string[] on SubjectGroupsTableQueryDto; homeroomId added to StudentSummaryWithHomeroomDto Same flat/aggregate shape both modules already use (no scope-grouped reshape)
File-backed sub-resources n/a No files involved
Custom fields yes (pass-through) Batch create items accept optional customFields per item, routed through the existing validateCustomFieldsFlat(…, 'create') — otherwise a tenant with a required-on-create SG custom field could never use the wizard Same validation the single create runs
Profile completeness n/a Not a person entity

4. Data model plan

Schema deltas

  • None. The courses-by-homeroom filter is a derived roster-overlap predicate (assignments.some.student.homeroomAssignment.homeroomId IN (…)); the batch write creates only existing row types (SubjectGroup, SubjectGroupAssignment, SubjectGroupTeacher, selection rows via the forward sync).

Migration shape

  • n/a — no migration.

Indexes and uniqueness

  • No new indexes. The filter traverses SubjectGroupAssignment.studentId → HomeroomAssignment(studentId unique per AY); both sides are already indexed FKs. Existing @@unique([academicYearId, gradeId, curriculumSubjectId, name]) keeps arbitrating create-name conflicts; @@unique([studentId, curriculumSubjectId, academicYearId]) on assignments keeps the one-SG-per-subject-per-student invariant race-free inside the batch tx.

5. API surface

Verb Path Decorators Request DTO Response DTO
GET /homerooms/:id/course-blueprint @RequireScopes(SUBJECT_GROUPS, 'read'), @RequireRoles('admin','department_head'), @AppliesPolicyDimensions(HomeroomsPolicy) (metadata-only, like the pickers — full @AppliesPolicy would widen the role gate), @AggregateResponse() AcademicYearQueryDto CourseBlueprintResponseDto
POST /subject-groups/from-homeroom @RequireAction(SUBJECT_GROUPS, 'create') + @RequireScope(SUBJECT_GROUPS, 'composition', 'write') (stacked decorators — see note), @AppliesPolicy(SubjectGroupsPolicy), @AggregateResponse() CreateFromHomeroomDto FromHomeroomResponseDto
GET /subject-groups/table unchanged SubjectGroupsTableQueryDto + homeroomId?: string[] unchanged
GET /students/eligible-for-subject-group unchanged unchanged rows gain homeroomId: string \| null

Blueprint response shape

{
  "homeroom": { "id", "name", "curriculum": {"id","name"}, "grade": {"id","name"},
                "numStudents", "students": [ /* existing student summary shape */ ] },
  "mandatory": [   // common-track (effective track null) mandatory subjects offered in the grade
    { "curriculumSubjectId", "name", "code", "weeklyHours",
      "existingSubjectGroups": [{ "id", "name", "numStudents" }],   // [] = not created yet
      "eligibleCounts": { "noSelection", "pending", "selected" } }
  ],
  "optionBlocks": [  // common-track blocks with ≥1 alternative offered in the grade
    { "id", "name", "minSelections", "maxSelections", "subjects": [ /* same leaf shape */ ] }
  ],
  "tracks": [
    { "id", "name", "subjects": [ /* track-scoped mandatory, same leaf shape */ ],
      "optionBlocks": [ /* same block shape */ ] }
  ]
}
  • Offered = the anchor has a CurriculumSubjectHours cell for the homeroom's grade (cell presence ⟺ taught). Units (parentSubjectId != null) are excluded; main subjects appear as ordinary leaves (their own cells).
  • existingSubjectGroups is an array (mock had a scalar): multiple sections per (subject, grade) are legal; with merge-only the FE picks the merge target (single entry in the common case). "Already exists" is derived client-side as length > 0 — no redundant boolean.
  • eligibleCounts — one grade-wide cohort load (findSubjectGroupEligibilityCohort projection), then the pure classifySubjectGroupEligibility per (student, anchor) in memory; counts of the three picker buckets, hidden rows not counted. Uniform on every leaf (mandatory included — same pass, three ints).
  • Subject/block/track ordering follows the grouped-courses board conventions (ordinalPosition).
  • The full pool for the assign step stays on the existing picker (eligible-for-subject-group) — the blueprint carries counts, not candidate rows.

Batch request/response shape

// POST /subject-groups/from-homeroom  — ACTIVE year only
{
  "homeroomId": "…",
  "courses": [          // @ArrayMinSize(1) @ArrayMaxSize(100); duplicate curriculumSubjectId → 400
    { "curriculumSubjectId": "…",
      "subjectGroupId": "…",        // present → MERGE (append roster); absent → CREATE
      "studentIds": ["…"],          // may include students of other homerooms in the grade
      "teacherIds": ["…"],          // optional, create items only (merge never touches teachers)
      "baseRoomId": "…",            // optional, create items only
      "customFields": { }           // optional, create items only
    }
  ]
}

// 200
{
  "created": [{ "curriculumSubjectId", "subjectGroupId", "name", "numStudents", "skippedStudentIds": [] }],
  "updated": [{ "curriculumSubjectId", "subjectGroupId", "name", "numAdded", "numStudents", "skippedStudentIds": [] }],
  "skipped": [{ "curriculumSubjectId", "reason": "EMPTY_ROSTER" }]
}

Server behavior (validation pre-tx, writes in ONE $transaction, all-or-nothing):

  1. Resolve homeroom (tenant + ACTIVE AY) → 404. Grade + curriculum come from the homeroom; items never carry gradeId.
  2. Per item, resolve the anchor: must belong to the homeroom's curriculum, not be a unit (SUBJECT_GROUP_ANCHOR_IS_UNIT), and be offered in the homeroom's grade (existing anchorOffersGrade path).
  3. Merge items: the target SG must exist and anchor (item.curriculumSubjectId, homeroom.gradeId, ACTIVE AY) — else SUBJECT_GROUP_BATCH_TARGET_MISMATCH 409.
  4. Rosters run the same single-sourced classifier as single create / add-students, with exactly one softening: a student whose only disqualifier is already assigned to an SG for this subject this AY (including "already in the target SG") becomes a per-student skip (reported in skippedStudentIds) instead of a 409 — this is what makes wizard re-runs idempotent. Every other hidden reason (wrong curriculum/track, block at capacity, not found/not enrolled) stays the usual 409 and fails the whole batch.
  5. Create items with an empty roster (or a roster that skims to empty after step 4? — no: an all-skipped roster still creates the course, only a submitted-empty roster skips) → skipped: EMPTY_ROSTER, not created.
  6. In the tx: create SGs (name = subject name; P2002 → SUBJECT_GROUP_NAME_CONFLICT 409, fails the batch) + assignments + teachers; append merge rosters (dedup via skip rule); run applyClassAssignmentToSelection for every (student, SG) write — identical forward-sync semantics to the existing endpoints.

Table filter semantics (homeroomId)

  • Multi-value, within-field OR: a course matches iff ≥ 1 rostered student currently belongs to any of the given homerooms. Combined-class rows match via any member (consistent with every other filter). Unknown ids → empty page, never 404.
  • GET /subject-groups/missing-courses reuses the same query DTO and ignores homeroomId (like teacherId — a not-yet-created course has no roster), documented in its swagger copy.

Swagger considerations

  • Blueprint + batch are @AggregateResponse() flat DTOs — both entities must already be in FLAT_DTO_ENTITIES (they are; no delta, but verify in the plan).
  • Batch swagger copy must state: ACTIVE-year-only write, all-or-nothing atomicity, append-only merge, the skippedStudentIds idempotency rule, and that unselected courses are simply not sent (no report entry).
  • New error code needs error-examples.ts + i18n catalog entries; SUBJECT_GROUP_BATCH_TARGET_MISMATCH example on the batch route.
  • Blueprint route JSDoc is FE-facing contract copy only (no internals).

6. RBAC seed plan

Seed file Delta
PermissionScope (rbac-catalogue.ts) none
PermissionAction (rbac-catalogue.ts) none — batch rides the existing subject_groups.create
ScopeFieldMapping (rbac-catalogue.ts) none
Role grants (roles.ts) none — admin/department_head already hold subject_groups create + composition write; blueprint's @RequireRoles('admin','department_head') matches the picker/board posture (no hr — not a personnel surface)
*_SCOPES runtime constant none

7. Divergence ledger

Pattern We diverge by Reason Tradeoff accepted
One-entity-per-write CRUD Batch endpoint writing N courses + N roster appends in one tx with a {created, updated, skipped} report Wizard UX; N sequential calls are non-atomic and racy on the forward selection sync A bespoke report DTO; precedent exists (bulk-assign)
Single-sourced eligibility gate is strictly fail-hard The batch softens exactly one hidden-reason (already in an SG for this subject) to a per-student skip Whole-class mandatory rosters would otherwise 409 whenever any student was already placed manually; skip makes re-runs idempotent The classifier stays the single source (the batch still calls it; it maps one verdict differently). Parity spec extended to pin this
ScopeGuard single-key gating Batch needs create action AND composition write → both as stacked decorators (@RequireAction + singular @RequireScope; ActionGuard and ScopeGuard each read their own key). (Implementation note, 2026-07-20: the spec originally proposed decorator + inline assert; the singular @RequireScope turned out to be required anyway — it is what tells FieldWriteGuard the flat batch body is domain-shaped rather than scope-grouped, same posture as the bulk-assign precedent — and it subsumes the inline assert. Requiring composition write on create-only batches is not stricter in practice: the single create's scope-grouped body already demands WRITE on its composition key.) Guard can't AND two keys within one decorator; stacking two guards can None — the gate is fully visible on the decorator surface
Homeroom/course decoupling ("no relationship") A homeroomId filter on the courses table Product needs "courses of class 2A"; roster-overlap answers it without re-coupling (read-time derivation, zero write-side semantics) A course serving two classes shows under both — correct but product should expect it
Blueprint mounts a course-creation read under /homerooms/:id/… gated by SUBJECT_GROUPS scope Cross-entity route/scope split Same trick as the pickers (/students/eligible-for-* gated by class scope); the wizard is anchored on a homeroom ScopeGuard reads the decorator, not the URL — already an accepted pattern

8. Pushback log

US says (mock/API guide) Conflicts with Proposed instead Status
Best-effort batch + failed[] in the report Codebase single-$transaction convention; wizard input is our own blueprint (failure = stale wizard) All-or-nothing; failed[] dropped from the response Resolved (user, 2026-07-20)
Existing-course card allows add and remove; API contract says merge/append Append semantics would silently no-op removals; replace semantics could drop other classes' students Append-only; FE disables removing pre-existing members in the wizard Resolved (user: append-only)
Scalar existingSubjectGroupId per subject DB allows multiple sections per (subject, grade) existingSubjectGroups[] array Resolved
academicYearId? on the POST body "Writes always target the ACTIVE year" invariant Dropped from the body (blueprint GET keeps the standard read override) Resolved
Optional homeroomId query param on eligible-for-subject-group Unneeded server work; FE filters the pool client-side per the mock itself Add homeroomId to the picker row instead (additive); no query param Resolved
Open question: alreadyExists per (subject, grade) or per track? Per (curriculumSubjectId, gradeId, AY), any section — same rule as missing-courses "covered" Resolved
Open question: teacher/room in the wizard? Accepted optionally per create item (already on single create; costs nothing); product may keep the UI bare Resolved
Open question: trust FE studentIds? Single-sourced classifier principle Never — every roster runs the same gate (with the one idempotency softening above) Resolved
Mock UI implies existing courses across homerooms get merged into one course by default Possible product expectation of per-class sections Product confirmed merge-only for this flow; second sections stay on single create Resolved (user: merge only)

9. Deferrals

  • Per-item name override / second-section creation via the batch — merge-only was chosen; if product later wants "each class gets its own Mathematics section", add optional name on create items (additive) — follow-up: revisit on product ask.
  • Persisted homeroom↔course provenance — rejected (wrong cardinality for grade-wide optionals, stale as rosters drift, nulls on every non-wizard course) — follow-up: only if roster-overlap filter semantics prove insufficient in practice.
  • homeroomId on missing-courses — ignored there (no roster to match); if product wants "missing for this class" it needs a different definition (per-student coverage, already served by the boards' numStudentsWithMissingAssignments) — follow-up: revisit at next iteration.
  • Default pre-assignment of eligible students to optional courses — FE concern (the blueprint/picker expose the buckets; the FE decides the default roster).
  • Combined classes in the wizard — not surfaced; combining stays a post-creation act on /combined-classes.

10. Open questions

None — all resolved in chat 2026-07-20 (roster-overlap filter; merge-only; all-or-nothing; append-only).


11. Verification plan

  • Unit specs:
  • homerooms/course-blueprint.service.spec.ts (or within homerooms.service.spec.ts): structure assembly — units excluded, track vs common placement (effective track), existingSubjectGroups[] folding from covered pairs, eligibleCounts bucket math on a synthetic cohort, policy-invisible homeroom → 404.
  • subject-groups/from-homeroom/from-homeroom.service.spec.ts: validation ordering (homeroom 404 → duplicate-subject 400 → anchor checks → target mismatch 409 → eligibility 409); already-assigned softening lands in skippedStudentIds while wrong-curriculum still throws; EMPTY_ROSTER skip; merge never touches teachers/room; all writes inside one tx (mock $transaction assert); forward-sync called per (student, SG).
  • subject-groups.queries.spec.ts: homeroomId leg of the table WHERE (roster-overlap traversal, multi-value IN, combined-class member matching).
  • Extend subject-group-eligibility.parity.spec.ts: the batch maps ALREADY_ASSIGNED-class verdicts to skip and every other hidden reason to the same 409 the gate throws.
  • E2E specs (test/subject-groups-from-homeroom.e2e-spec.ts):
  • Happy path: blueprint → batch (mandatory create + one block merge) → table with homeroomId filter returns exactly those courses; roster + forward-sync side effects visible on GET /students/:id assignment block.
  • Idempotency: re-run the same batch → everything lands in skippedStudentIds/dedup, zero new rows.
  • Atomicity: one wrong-curriculum student in item 3 → 409, zero courses created.
  • RBAC: teacher 403 on blueprint + batch; department_head of another department gets 404 on blueprint and cannot batch into it.
  • Picker rows carry homeroomId.
  • Manual verification: run the FE mock flow against a seeded tenant (Year-10-style grade with blocks + one track): wizard end-to-end, then filter the courses table by the homeroom.

12. Sign-off

  • Approved by: Fabio Barbieri
  • Date: 2026-07-20
  • Chat reference: approved in chat 2026-07-20 after brainstorm (roster-overlap filter, merge-only, all-or-nothing, append-only settled via Q&A; "ok" on the spec walkthrough)

Until this section is filled, no implementation code is written.