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 requirement — teacherIds/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 (assertStudentsEligible → classifySubjectGroupEligibility), 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
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):
- Resolve homeroom (tenant + ACTIVE AY) → 404. Grade + curriculum come from the homeroom; items never carry
gradeId.
- 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).
- Merge items: the target SG must exist and anchor
(item.curriculumSubjectId, homeroom.gradeId, ACTIVE AY) — else SUBJECT_GROUP_BATCH_TARGET_MISMATCH 409.
- 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.
- 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.
- 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.