US-33 + US-33.1 — Homerooms & Subject Groups (V1)¶
Iteration note (2026-05-25) — Per-subject teacher assignment shifted from single FK to M:N via a new
SubjectGroupTeacherjunction with explicitordinalPosition(0 = primary). Motivation: co-teaching is common in Italian high schools (lead + support; split-class). DTO contract is nowteacherIds: string[]on the wizard'ssubjectTeachers[]entries and on standalone-SG create / PATCH. PATCH uses set-semantics — array fully replaces; absent = no change;[]= clear all teachers. The Homeroom-levelhomeroomTeacherId(tutor) stays singular — different concept.
1. Problem distillation¶
- Schools need a way to declare administrative student groupings (Homerooms — classi) where a fixed set of students stays together for mandatory subjects, and a way to declare curriculum-driven teaching units (Subject Groups — gruppi materia) where students partition into teachable cohorts per Subject. Both must precede the Timetable Engine (US-35 onwards).
- A Homeroom is a roster + structural metadata that, when created, synchronously materialises one child Subject Group per mandatory Subject in its bound Curriculum, each inheriting the Homeroom's roster. Optional-block Subject Groups and Subject Groups for Departments that don't use Homerooms are independent (no
homeroomId) and built from study-plan selections. - Admin needs a teacher-selector UX during creation that surfaces budget signals (departments, assignment count, working days, contract hours allocated vs. total, historically-taught subjects) — all advisory; never blocking. The selector is shared between the Homeroom wizard's "teacher per mandatory subject" step and the Subject Group creation flow.
- Teachers and Staff need read access to all Homerooms and Subject Groups (V1 scope is open across the AY); Students and Parents see only their own/child's records. Creation is Admin-only.
- Two upstream gaps from US-4 must be filled in this iteration to unblock the warnings the UX requires:
Department.maxStudentsPerClassdoes not exist on the schema and is referenced by US-33 S1 + US-33.1 S6. TheHomeroomRoleDepartment enum (NONE | ADMINISTRATIVE | SCHEDULING) is orphaned post-US-33-rewrite and must be retired in the same migration.
Success criteria (observable behavior that proves this works):
- Admin can POST /homerooms with a single atomic payload (header + roster + per-mandatory-subject teacher assignment) and observe one Homeroom row + N HomeroomAssignment rows + N SubjectGroup rows (one per mandatory Subject) created in one transaction.
- Admin can POST /subject-groups for a standalone Subject Group (no homeroomId) with a roster drawn from study-plan-selection state; assignment respects "exactly one Subject Group per Subject per Student per AY".
- GET /homerooms returns the tree department → curriculum → grade → homerooms[] with numStudents on each leaf; filters department, curriculum, grade narrow the tree.
- GET /homerooms/:id returns header + roster + per-mandatory-subject teacher slots (each slot showing the assigned Teacher or null).
- GET /subject-groups returns the same tree shape; GET /subject-groups/:id returns the roster with each Student's homeroom name (when any).
- From the Homeroom detail page, Admin can: change the homeroom teacher (PATCH /homerooms/:id), change the teaching teacher of a mandatory-subject slot (PATCH /subject-groups/:id on the homeroom-bound child SG), add students to the roster, remove students from the roster, and move a set of students to another Homeroom of the same (Curriculum, Grade) — atomic per operation.
- From the Subject Group detail page (standalone), Admin can: change the teaching teacher, add/remove students, and move a set of students to another Subject Group bound to the same studyPlanSubjectId.
- The Homeroom-wizard "select students" step (and the add-students-to-existing-Homeroom picker) is powered by GET /homerooms/eligible-students — returns students enrolled in the target (department, curriculum, grade, currentAY) with status = ENROLLED who are not currently assigned to any Homeroom. The picker shows only the eligible pool (no "already-assigned" entries leak through).
- The Subject Group-creation "select students" step (and the add-students-to-existing-standalone-SG picker) is powered by GET /subject-groups/eligible-students?studyPlanSubjectId=… — returns all students for whom this Subject is in their study plan (mandatory subject of the student's bound study plan, or selected via Option Block), each carrying an alreadyAssigned: boolean flag (true if currently in any Subject Group for the same studyPlanSubjectId). Sorted by alreadyAssigned ascending (not-yet-assigned first), then lastName/firstName. The FE picker renders already-assigned rows as visible-but-disabled.
- Adding/removing students from a Homeroom cascades to all homeroom-bound child Subject Groups in the same transaction (roster of homeroom-bound SGs derives from the Homeroom).
- Teacher record's declaredSubjects (optional list) is updated inline when the creation/edit payload requests it, gated by teachers.employment:write; the create operation succeeds regardless of whether the declared-subjects side-effect is permitted.
- Department CRUD + setup wizard accept maxStudentsPerClass; Department.homeroomRole column and HomeroomRole enum are removed.
- StudyPlanSubject.weeklyLessons / yearLessons are renamed to weeklyHours / yearHours end-to-end (schema + DTO + tests + import code).
Non-goals (in-scope-shaped things this iteration is explicitly not doing):
- Pre/post timetable-generation history & confirmation flows (US-33 S7-S8, US-33.1 S11-S12). V1 stubs hasPublishedTimetable(academicYearId) as false; the "replace, no history" branch is the only one wired.
- Auto-removal of Homeroom assignment on Student status change to non-Enrolled (US-33 S9). Deferred; admin removes manually.
- Subject cancellation cascade (US-33.1 S8) — depends on a US-32 hook we haven't built; deferred.
- "Manually resolved" flag on Subject Group assignments for students with overdue curriculum selection (US-33.1 S4 second half). V1 blocks the assignment if any student in the cohort has pending curriculum selection.
- Subject Group split UX (US-33.1 S6). The Admin creates as many Subject Groups as needed via POST /subject-groups; we don't ship a server-side "split" operation in V1.
- Subject removal lock post-timetable (US-33.1 S14). The Curriculum module already guards Subject removal against StudyPlanSubject usage; the timetable-aware lock is deferred until US-38 lands.
- Teacher tutor-only assignment (US-37 split). homeroomTeacherId on Homeroom is optional at creation; it absorbs what US-37 calls "Tutor Teacher". Full US-37 (subject-teacher assignment + supervision) is its own iteration.
- Side effects of teacher-assignment changes. Changing Homeroom.homeroomTeacherId or any SubjectGroup.teacherId is a flat column write in V1 — no history-row insert, no hour-budget re-validation, no overlap/conflict check against other assignments, no notification fan-out, no scheduled-timetable reconciliation, no archiving-blocking guards (US-37 S6-S8). The wizard's teacher-budget signals are advisory at creation time only; subsequent teacher swaps don't re-run those checks. All such side-effects are explicitly deferred to future iterations (most belong with US-37).
2. Patterns survey¶
| Analogous module/spec | What we'd borrow | What doesn't fit |
|---|---|---|
src/curriculum/ |
Multi-table tenant+AY-scoped entity with nested sub-resources (Curriculum + StudyPlan + StudyPlanSubject); single configuration scope; aggregate detail endpoint shape; queries.ts with include/select consts + named functions; tight FK cascade between parent and dependents |
Homeroom has roster (M2M HomeroomAssignment table) which Curriculum doesn't; mandatory-subject auto-materialisation is novel |
src/academic-years/ |
@AggregateResponse() precedent — AggregateResponseDto-based DTOs bypass FieldFilterInterceptor so derived fields (numStudents) ride free; nested grouped responses (periods within year) |
AY is a singleton-ish admin object; Homeroom is a leaf in a 4-level grouped tree |
docs/superpowers/specs/2026-05-20-rus4-curriculum-selection-design.md |
"Dual-surface scope" precedent (a scope gates both field-level access and a sub-resource); single-atomic-payload pattern with no intermediate state; deferral discipline around "next iteration" follow-ups | RUS-4 is a single-student sub-resource; this iteration creates a new top-level entity family |
src/departments/ |
Single configuration scope with create/delete actions + standard CRUD layout; nested sub-resource (Grade) under Department; customFields JSONB pattern; ordinalPosition-based sort defaults |
Department doesn't have a wizard-style atomic creation with cascade-materialised dependents |
src/rooms/ |
Tenant+AY-scoped configuration entity with M2M sub-resource (CanteenLunchShift); FK-with-deny-delete pattern (onDelete: Restrict) that we'll mirror on Homeroom→BaseRoom and SubjectGroup→Teacher |
Rooms don't carry a roster of person-entities |
3. Architecture mapping¶
| Primitive | Apply? | How | Justify |
|---|---|---|---|
| Tenant scope | yes | tenantId filter on every query; cascade from AcademicYear |
All new tables follow the canonical multi-tenant pattern (prisma/schema.prisma tenant convention) |
| Academic-year scope | yes | academicYearId FK on Homeroom, SubjectGroup; all assignments inherit AY via parent |
Both entities are per-AY snapshots, matching Student/Teacher snapshot convention |
| RBAC entity key | new: homerooms, subject_groups |
Append to src/common/constants/entity-keys.ts (HOMEROOMS = 'homerooms', SUBJECT_GROUPS = 'subject_groups') |
Two distinct entities — separate role-grant matrix rows + separate seed entries |
| Scopes | new: homerooms.composition, subject_groups.composition |
Single-scope per entity in rbac-catalogue.ts SCOPES; not configuration — the entity isn't admin-only config, it's a composition of students × teacher × room × subject-teachers |
Splitting into multiple scopes (e.g. identity + roster) buys nothing in V1 since Teachers + Staff see the whole aggregate; the naming composition is more honest than configuration |
| Actions | new: homerooms.create, homerooms.delete, subject_groups.create, subject_groups.delete |
Append to rbac-catalogue.ts ACTIONS map; each action's scopeKeys references the corresponding *.composition |
Standard 2-action shape; read/update are implicit (per feedback_rbac_actions_convention) |
| Service base | custom | Both services extend a bespoke service class — not BaseTenantedCrudService — because the wizard POST is a multi-table atomic operation that doesn't map to the generic CRUD shape; will use PrismaService.$transaction directly |
Cascade-materialise pattern + roster join-table writes require explicit transaction control |
queries.ts shape |
include/select consts + named functions | One queries.ts per module (src/homerooms/queries.ts, src/subject-groups/queries.ts) with LIST_INCLUDE, DETAIL_INCLUDE, and named functions (findHomeroomsGrouped, findHomeroomDetail, etc.) |
Matches the convention codified in docs/05-crud-patterns.md and memory Queries convention |
| Error codes | new | Creation: HOMEROOM_NAME_CONFLICT, STUDENT_ALREADY_IN_HOMEROOM, STUDENT_NOT_IN_ELIGIBLE_POOL, CURRICULUM_HAS_NO_STUDY_PLAN, MANDATORY_SUBJECT_TEACHER_INCOMPATIBLE, SUBJECT_GROUP_NAME_CONFLICT, STUDENT_ALREADY_IN_SUBJECT_GROUP, SUBJECT_GROUP_REQUIRES_STUDY_PLAN_SUBJECT, BASE_ROOM_CAPACITY_EXCEEDED (warning, returned as non-blocking advisory), CURRICULUM_SELECTION_PENDING_FOR_COHORT. Roster ops: SUBJECT_GROUP_ROSTER_DERIVED (operating on a homeroom-bound SG's roster directly), SUBJECT_GROUP_HOMEROOM_BOUND_NOT_DELETABLE (deleting a homeroom-bound SG independently of its Homeroom). Move ops: HOMEROOM_MOVE_GRADE_MISMATCH, HOMEROOM_MOVE_CURRICULUM_MISMATCH, SUBJECT_GROUP_MOVE_SUBJECT_MISMATCH. Immutability: HOMEROOM_FIELD_IMMUTABLE (PATCH attempt on departmentId/curriculumId/gradeId), SUBJECT_GROUP_FIELD_IMMUTABLE (PATCH attempt on studyPlanSubjectId/homeroomId). |
Domain-coded; surfaced via existing global filter (chapter 06) |
| DTO conventions | scope sub-DTOs for writes; aggregate DTOs for reads | dto/scopes/homeroom-composition.dto.ts + dto/scopes/subject-group-composition.dto.ts for create/update; dto/homeroom-list-response.dto.ts + dto/homeroom-detail-response.dto.ts (both extending AggregateResponseDto) for reads |
Matches src/departments/dto/scopes/department-configuration.dto.ts pattern for writes + src/academic-years/dto/academic-year-detail.dto.ts pattern for reads |
| File-backed sub-resources | n/a — Homerooms / SubjectGroups carry no documents | — | No file uploads in this surface |
| Custom fields | yes | customFields: Json? on both Homeroom and SubjectGroup; new pickCustomFields plug-in entries in src/common/constants/entity-keys.ts CUSTOM_FIELD_ENTITY_KEYS (extend) and seed others scope |
Aligns with existing pattern on Department, Grade, Room, Curriculum |
| Profile completeness | no | n/a — Homerooms/SubjectGroups don't surface in person-record completeness checks | The COMPLETION_REQUIRED_REGISTRY is for person entities; Homeroom is an admin-configured grouping, not a person record |
4. Data model plan¶
Schema deltas¶
New tables:
homeroomsidUUID PK,tenantIdUUID FK→Tenant,academicYearIdUUID FK→AcademicYearnameVarChar(150)departmentIdUUID FK→Department (onDelete: Restrict)curriculumIdUUID FK→Curriculum (onDelete: Restrict) — immutable post-creationgradeIdUUID FK→Grade (onDelete: Restrict)baseRoomIdUUID? FK→Room (onDelete: SetNull) — optionalhomeroomTeacherIdUUID? FK→Teacher (onDelete: SetNull) — optional at creationcustomFieldsJson?@default("{}")createdAt,updatedAttimestamps- Indexes:
(tenantId),(tenantId, academicYearId),(departmentId),(gradeId),(curriculumId),(homeroomTeacherId) -
Unique:
(academicYearId, gradeId, name)— homeroom name unique per Grade per AY -
homeroom_assignments idUUID PK,homeroomIdUUID FK→Homeroom (onDelete: Cascade),studentIdUUID FK→Student (onDelete: Cascade),academicYearIdUUID FK→AcademicYearcreatedAt,updatedAt- Indexes:
(homeroomId),(studentId),(academicYearId) -
Unique:
(academicYearId, studentId)— a Student belongs to at most one Homeroom per AY -
subject_groups idUUID PK,tenantIdUUID FK→Tenant,academicYearIdUUID FK→AcademicYearnameVarChar(150)studyPlanSubjectIdUUID FK→StudyPlanSubject (onDelete: Restrict) — points at the (Subject × Curriculum × Grade × AY) row that defines what is being taughtteacherIdUUID? FK→Teacher (onDelete: SetNull) — optional at creationhomeroomIdUUID? FK→Homeroom (onDelete: Cascade) — when set, marks this SG as a homeroom-bound mandatory-subject group; roster is derived from HomeroomcustomFieldsJson?@default("{}")createdAt,updatedAt- Indexes:
(tenantId),(tenantId, academicYearId),(studyPlanSubjectId),(teacherId),(homeroomId) -
Unique:
(academicYearId, studyPlanSubjectId, name)— name unique per Subject per AY;(homeroomId, studyPlanSubjectId)— at most one homeroom-bound SG per (Homeroom, Subject) -
subject_group_assignments idUUID PK,subjectGroupIdUUID FK→SubjectGroup (onDelete: Cascade),studentIdUUID FK→Student (onDelete: Cascade),academicYearIdUUID FK→AcademicYearcreatedAt,updatedAt- Indexes:
(subjectGroupId),(studentId),(academicYearId) - Unique:
(academicYearId, studentId, subjectGroupId)— idempotency; the "one Subject Group per Subject per Student per AY" invariant is enforced at service level by joining withStudyPlanSubject.studyPlanSubjectId(Postgres can't express it cleanly without a redundant subjectId column — see Divergence Ledger)
Mutations to existing tables:
Department- Add:
maxStudentsPerClassInt?@map("max_students_per_class")— optional, drives non-blocking sizing warnings -
Drop:
homeroomRolecolumn +HomeroomRoleenum entirely -
StudyPlanSubjectandOptionBlock(both carry the same shared-hours fields) -
Rename:
weeklyLessons→weeklyHours(Float?),yearLessons→yearHours(Float?) on both tables. Column rename migration + all DTO/test/import refactors. Confirmed during planning — keeping naming consistent across the StudyPlan + OptionBlock pair -
Teacher - Add:
declaredSubjectsString[]@default([])@map("declared_subjects")— Postgres text array; per-AY snapshot field on the Employment scope
Migration shape¶
- Type: mixed — additive (new tables, new fields) + destructive (drop
homeroomRolecolumn +HomeroomRoleenum) + renaming (weeklyLessons/yearLessons→weeklyHours/yearHours). - Data backfill:
Department.homeroomRole: no data to preserve — the column is informational only (no behavioural consequence today, per the gap audit indocs/coverage/per-us/US33-define-homerooms-and-assign-students.mdpreamble). Drop directly.StudyPlanSubject.weeklyLessons/yearLessons: rename in-place viaALTER COLUMN ... RENAME TO ...— preserves data, zero-downtime.Teacher.declaredSubjects: default empty array; no backfill.- Hazards from chapter 12 checklist:
- Hazard: dropping
homeroomRoleenum. Postgres enums can't be dropped while a column references them. Migration order: (1)ALTER TABLE departments DROP COLUMN homeroom_role;(2)DROP TYPE "HomeroomRole";. Verify no remaining references in setup-wizard state (auditsrc/setup/). - Hazard: column rename on
StudyPlanSubject. The setup wizard, Curriculum CRUD DTOs, import pipeline, and any e2e tests reference the old names. Refactor in a single PR so the migration is atomic with the code change. - Hazard: 4 new tables with FKs into existing Student / Teacher / Department / Grade / Curriculum / Room / StudyPlanSubject. Verify each
onDeleterule before merge:Homeroom.departmentId/curriculumId/gradeId→ Restrict (deleting a Dept/Curriculum/Grade with active Homerooms must fail loudly)Homeroom.baseRoomId→ SetNull (Room deletion downgrades the base assignment, doesn't break the Homeroom)Homeroom.homeroomTeacherId→ SetNull (Teacher archival downgrades; doesn't orphan)HomeroomAssignment.homeroomId/studentId→ Cascade (deletes ride with parent)SubjectGroup.studyPlanSubjectId→ RestrictSubjectGroup.teacherId→ SetNullSubjectGroup.homeroomId→ Cascade (homeroom-bound SGs are owned by their Homeroom)SubjectGroupAssignment.subjectGroupId/studentId→ Cascade
Indexes and uniqueness¶
Already enumerated above per table. Highlights:
- homerooms (academicYearId, gradeId, name) UNIQUE — name uniqueness scope = Grade × AY (per US-33 S3)
- homeroom_assignments.studentId @unique — Student in at most one Homeroom per AY (per US-33 S1). The shipped schema uses a single-column unique on studentId rather than a composite (academicYearId, studentId); this is functionally equivalent because Student.id is AY-scoped at the row level (a Student row exists per AY). Composite was the original draft; single-column is the canonical truth.
- subject_groups (academicYearId, studyPlanSubjectId, name) UNIQUE — name uniqueness scope = Subject × AY (per US-33.1 S6 Notes)
- subject_groups (homeroomId, studyPlanSubjectId) UNIQUE — at most one homeroom-bound SG per (Homeroom, Subject)
- subject_group_assignments (academicYearId, studentId, subjectGroupId) UNIQUE — assignment idempotency; the "one SG per Subject per Student" invariant lives in service-layer validation (see Divergences)
5. API surface¶
| Verb | Path | Decorators | Request DTO | Response DTO |
|---|---|---|---|---|
| POST | /homerooms |
@RequireAction(HOMEROOMS, 'create') |
CreateHomeroomDto (scope-grouped under composition) |
HomeroomDetailDto (AggregateResponseDto) |
| GET | /homerooms/eligible-students |
@RequireScopes(HOMEROOMS, 'read') + @AggregateResponse() |
ListEligibleHomeroomStudentsQueryDto (required: departmentId, curriculumId, gradeId) |
EligibleHomeroomStudentsResponseDto — { students: [{ id, firstName, lastName, identificationCode? }] }. Filters to Student.status = ENROLLED AND Student.academicYearId = currentAY AND NOT EXISTS HomeroomAssignment(studentId). Sorted by lastName, firstName. |
| GET | /homerooms |
@RequireScopes(HOMEROOMS, 'read') + @AggregateResponse() |
ListHomeroomsQueryDto (filters: departmentId?, curriculumId?, gradeId?) |
HomeroomListResponseDto — tree [ { department, curricula: [ { curriculum, grades: [ { grade, homerooms: [...] } ] } ] } ] |
| GET | /homerooms/:id |
@RequireScopes(HOMEROOMS, 'read') + @AggregateResponse() |
— | HomeroomDetailDto — { id, name, department, curriculum, grade, baseRoom?, homeroomTeacher?, students: [{ id, firstName, lastName }], subjects: [{ studyPlanSubjectId, subjectGroupId, name, weeklyHours, teacher? }] } |
| PATCH | /homerooms/:id |
@RequireScopes(HOMEROOMS, 'write') |
UpdateHomeroomDto (scope-grouped under composition; mutable: name, baseRoomId, homeroomTeacherId, customFields. Immutable: departmentId, curriculumId, gradeId. Roster is NOT mutated through this endpoint — use the /students sub-resource) |
HomeroomDetailDto |
| DELETE | /homerooms/:id |
@RequireAction(HOMEROOMS, 'delete') |
— | 204 No Content |
| POST | /homerooms/:id/students |
@RequireScopes(HOMEROOMS, 'write') |
AddStudentsToHomeroomDto — { studentIds: string[] } |
HomeroomDetailDto |
| DELETE | /homerooms/:id/students |
@RequireScopes(HOMEROOMS, 'write') |
RemoveStudentsFromHomeroomDto — { studentIds: string[] } (body on DELETE; documented in Swagger) |
HomeroomDetailDto |
| POST | /homerooms/:id/students/move |
@RequireScopes(HOMEROOMS, 'write') |
MoveStudentsBetweenHomeroomsDto — { studentIds: string[], targetHomeroomId: string } |
{ source: HomeroomDetailDto, target: HomeroomDetailDto } |
| POST | /subject-groups |
@RequireAction(SUBJECT_GROUPS, 'create') |
CreateSubjectGroupDto (scope-grouped under composition) |
SubjectGroupDetailDto (AggregateResponseDto) |
| GET | /subject-groups/eligible-students |
@RequireScopes(SUBJECT_GROUPS, 'read') + @AggregateResponse() |
ListEligibleSubjectGroupStudentsQueryDto (required: studyPlanSubjectId) |
EligibleSubjectGroupStudentsResponseDto — { students: [{ id, firstName, lastName, identificationCode?, alreadyAssigned: boolean, assignedSubjectGroupId?: string, homeroomName?: string }] }. Returns the full eligible cohort (any student whose bound study plan contains this Subject as mandatory OR whose Option Block selection includes it). Sorted by alreadyAssigned asc, then lastName, firstName. |
| GET | /subject-groups |
@RequireScopes(SUBJECT_GROUPS, 'read') + @AggregateResponse() |
ListSubjectGroupsQueryDto (filters: departmentId?, curriculumId?, gradeId?) |
SubjectGroupListResponseDto — tree as Homerooms (only standalone homeroomId IS NULL) |
| GET | /subject-groups/:id |
@RequireScopes(SUBJECT_GROUPS, 'read') + @AggregateResponse() |
— | SubjectGroupDetailDto — works for both standalone AND homeroom-bound child SGs; response: { id, name, ..., homeroomId?, students: [{ id, firstName, lastName, homeroomName? }] } |
| PATCH | /subject-groups/:id |
@RequireScopes(SUBJECT_GROUPS, 'write') |
UpdateSubjectGroupDto (scope-grouped under composition). For standalone (homeroomId IS NULL): mutable name, teacherId, customFields; studyPlanSubjectId immutable. For homeroom-bound (homeroomId IS NOT NULL): only teacherId + customFields mutable (rest of fields are owned by the parent Homeroom). Body includes optional addToDeclaredSubjects flag for the teacher-prompt persistence |
SubjectGroupDetailDto |
| DELETE | /subject-groups/:id |
@RequireAction(SUBJECT_GROUPS, 'delete') |
— | 204 No Content. Rejected with SUBJECT_GROUP_HOMEROOM_BOUND_NOT_DELETABLE (409) when homeroomId IS NOT NULL — homeroom-bound SGs die with their parent Homeroom |
| POST | /subject-groups/:id/students |
@RequireScopes(SUBJECT_GROUPS, 'write') |
AddStudentsToSubjectGroupDto — { studentIds: string[] }. Rejected when homeroomId IS NOT NULL — roster derives from Homeroom; use POST /homerooms/:hrId/students instead |
SubjectGroupDetailDto |
| DELETE | /subject-groups/:id/students |
@RequireScopes(SUBJECT_GROUPS, 'write') |
RemoveStudentsFromSubjectGroupDto — { studentIds: string[] }. Rejected when homeroomId IS NOT NULL |
SubjectGroupDetailDto |
| POST | /subject-groups/:id/students/move |
@RequireScopes(SUBJECT_GROUPS, 'write') |
MoveStudentsBetweenSubjectGroupsDto — { studentIds: string[], targetSubjectGroupId: string }. Target MUST reference the same studyPlanSubjectId; both source and target must be standalone (homeroomId IS NULL) |
{ source: SubjectGroupDetailDto, target: SubjectGroupDetailDto } |
Wizard request body shape (CreateHomeroomDto):
{
composition: {
name: string,
departmentId: string,
curriculumId: string,
gradeId: string,
baseRoomId?: string | null,
homeroomTeacherId?: string | null,
studentIds: string[], // initial roster
subjectTeachers: { // teacher per mandatory subject in the bound study plan
studyPlanSubjectId: string,
teacherId: string | null,
}[],
addToDeclaredSubjects?: { // opt-in inline persistence of "record this choice" prompt
teacherId: string,
subject: string, // free-text subject name (case-insensitive trim+dedupe at write)
}[],
customFields?: Record<string, unknown>,
}
}
Service materialises: 1 Homeroom row + N HomeroomAssignment rows + N SubjectGroup rows (one per subjectTeachers entry whose study-plan-subject isMandatory = true; service validates this) + optional Teacher.declaredSubjects mutation (guarded — see §6).
Standalone request body (CreateSubjectGroupDto):
{
composition: {
name: string,
studyPlanSubjectId: string, // any Subject (mandatory or optional); homeroomId stays null
teacherId?: string | null,
studentIds: string[],
addToDeclaredSubjects?: { teacherId, subject }[],
customFields?: Record<string, unknown>,
}
}
Eligible-students picker semantics¶
The Homeroom wizard step 2 and the Subject Group creation flow both need a server-driven list of "students this picker can offer". The two queries differ semantically:
GET /homerooms/eligible-students — filtered list (no flags):
- Eligibility predicate: Student.tenantId = :tenantId AND Student.academicYearId = :currentAY AND Student.departmentId = :departmentId AND Student.gradeId = :gradeId AND Student.status = ENROLLED AND NOT EXISTS (SELECT 1 FROM homeroom_assignments WHERE studentId = student.id). Curriculum match runs through the student's StudentCurriculumSelection.curriculumId = :curriculumId (or the student's bound study plan when selection is not yet recorded — service joins through StudyPlan).
- "Already-assigned" students are excluded entirely. The picker shows only the placeable pool.
- Used by both the create-Homeroom wizard step 2 AND the POST /homerooms/:id/students add-students-picker (the current Homeroom's own roster is shown separately on the detail page, so it doesn't need to appear in this picker — admins are adding students not in any Homeroom).
GET /subject-groups/eligible-students — full cohort with alreadyAssigned flag:
- Eligibility predicate: students whose study plan contains the target studyPlanSubjectId as mandatory, OR whose StudentOptionBlockChoice resolves to a Subject equivalent to the target studyPlanSubjectId (via the option block subject mapping). Service joins through StudyPlan + StudentCurriculumSelection.
- For each eligible student, compute alreadyAssigned by checking if a SubjectGroupAssignment exists for any Subject Group with this studyPlanSubjectId. If true, also return assignedSubjectGroupId for FE display.
- Optional homeroomName per student (joined through HomeroomAssignment) — helps the admin recognise who's where.
- Sorted by alreadyAssigned ascending (unassigned first), then lastName, firstName. FE renders already-assigned rows visible-but-disabled.
- Used by both the create-standalone-SG wizard AND the POST /subject-groups/:id/students add-students-picker.
Both endpoints are tenant + AY scoped automatically. Neither paginates in V1.
Roster-operation semantics¶
POST /homerooms/:id/students (atomic):
1. Validate every studentId is in the source eligibility pool: Student.departmentId = Homeroom.departmentId AND Student.gradeId = Homeroom.gradeId AND Student.curriculumId = Homeroom.curriculumId AND Student.status = ENROLLED AND Student.academicYearId = Homeroom.academicYearId.
2. Reject with STUDENT_ALREADY_IN_HOMEROOM (409) if any studentId already has a HomeroomAssignment for this AY (regardless of which Homeroom).
3. Insert one HomeroomAssignment per student.
4. Cascade: for every child homeroom-bound SubjectGroup of this Homeroom, insert one SubjectGroupAssignment per newly-added student. Same transaction.
DELETE /homerooms/:id/students (atomic):
1. Reject with 404 if any studentId is not currently assigned to this Homeroom.
2. Delete the HomeroomAssignment rows for the listed students.
3. Cascade: delete the matching SubjectGroupAssignment rows in every homeroom-bound child SG. Same transaction.
4. The student's standalone Subject Group memberships (for optional subjects, or for departments without Homerooms) are preserved.
POST /homerooms/:id/students/move (atomic):
1. Validate source ≠ target.
2. Validate target.gradeId = source.gradeId AND target.curriculumId = source.curriculumId AND target.academicYearId = source.academicYearId — moves across Grade/Curriculum are NOT allowed (that's enrollment change, not homeroom move).
3. Reject with 404 if any studentId is not currently in the source Homeroom.
4. Delete the source-Homeroom assignments + source-child-SG assignments for the listed students.
5. Insert the target-Homeroom assignments + target-child-SG assignments. Same transaction.
6. Returns both detail DTOs so the FE can refresh both panes.
POST /subject-groups/:id/students (atomic, standalone only):
1. Reject with SUBJECT_GROUP_ROSTER_DERIVED (409) if homeroomId IS NOT NULL.
2. Validate every studentId follows a study plan that includes the SG's studyPlanSubjectId (join through StudentCurriculumSelection for option-block subjects + the mandatory-subject set of the student's study plan for mandatory ones).
3. Reject with STUDENT_ALREADY_IN_SUBJECT_GROUP (409) if any studentId is already assigned to another SG with the same studyPlanSubjectId (the "one SG per Subject per Student per AY" invariant).
4. Reject with CURRICULUM_SELECTION_PENDING_FOR_COHORT (409) if any studentId has a pending curriculum selection for the relevant option block.
5. Insert SubjectGroupAssignment rows.
DELETE /subject-groups/:id/students (standalone only): symmetric to Homeroom remove; same SUBJECT_GROUP_ROSTER_DERIVED rejection for homeroom-bound.
POST /subject-groups/:id/students/move (standalone only, atomic):
1. Reject if source or target has homeroomId IS NOT NULL.
2. Validate target.studyPlanSubjectId = source.studyPlanSubjectId — moves are within the same Subject only.
3. Move the assignments transactionally.
Teacher-change semantics (no side effects)¶
PATCH /homerooms/:idwith newhomeroomTeacherId: single-column update; no history insert, no notification, no schedule probe, no archiving guard. The previous teacher'sdeclaredSubjectsis not mutated (declaredSubjects is additive only).PATCH /subject-groups/:idwith newteacherId: same — flat column update. The optionaladdToDeclaredSubjectspayload appends to the new teacher's declared list (gated byteachers.employment:write, fail-soft).
Swagger considerations¶
- Both detail DTOs extend
AggregateResponseDto. JSDoc on controller methods is FE-facing copy (per memoryfeedback_swagger_jsdoc_is_public) — backend internals (transaction shape, cascade materialisation logic) stay in method bodies. - Document the
addToDeclaredSubjectsopt-in flag clearly in the create + PATCH DTO JSDoc — FE needs to know to prompt the admin first. - Add
oneOfschemas for the list-response tree levels (department wrapper / curriculum wrapper / grade wrapper / item) — Scalar autoinspection will infer most of this from the DTO class hierarchy. - Error examples: include
HOMEROOM_NAME_CONFLICT(HTTP 409) andSTUDENT_ALREADY_IN_HOMEROOM(HTTP 409) on POST;SUBJECT_GROUP_ROSTER_DERIVED(HTTP 409) on roster ops against homeroom-bound SGs;SUBJECT_GROUP_HOMEROOM_BOUND_NOT_DELETABLE(HTTP 409) on DELETE. DELETE /homerooms/:id/studentscarries a request body — explicitly document this in Swagger (some HTTP clients drop bodies on DELETE; FE must use a client that preserves them, or we fall back toPOST /homerooms/:id/students/removeif FE tooling resists. Decide at implementation time).
6. RBAC seed plan¶
| Seed file | Delta |
|---|---|
permission_entities (rbac-catalogue.ts ENTITIES) |
Add { key: 'homerooms', label: 'Homerooms', description: 'Administrative groupings of students per grade', sortOrder: 10 } and { key: 'subject_groups', label: 'Subject Groups', description: 'Teaching units bound to a study-plan subject', sortOrder: 11 } |
permission_scopes (rbac-catalogue.ts SCOPES) |
Add homerooms: [{ key: 'composition', label: 'Composition', description: 'Homeroom structure, roster, and subject-teacher assignments', sortOrder: 1 }] and same for subject_groups |
permission_actions (rbac-catalogue.ts ACTIONS) |
Add 'homerooms.create', 'homerooms.delete', 'subject_groups.create', 'subject_groups.delete' — each { scopeKeys: ['<entity>.composition'] } |
scope_field_mappings (rbac-catalogue.ts SCOPE_FIELD_MAPPINGS) |
{ entityKey: 'homerooms', scopeKey: 'composition', tableName: 'homerooms', fields: HOMEROOM_SCOPES.composition } + analogous row for subject_groups. Fields per scope = the persistable columns enumerated in §4 (name, departmentId, curriculumId, gradeId, baseRoomId, homeroomTeacherId, customFields for Homeroom; name, studyPlanSubjectId, teacherId, homeroomId, customFields for SubjectGroup). |
| Role grants (roles.ts ROLE_PERMISSIONS) | Following the matrix in §RBAC Design: • Admin / HR / Secretary: R/W + create + delete on both• Principal / Internal Teacher / External Teacher / Internal Staff / External Staff / Admissions Officer: R only on both• Student: R (self-record only — record-level filtering in service layer)• Parent (Referent): R (linked-child records only)• Accountant: no access |
*_SCOPES runtime constants (src/common/constants/scope-fields.ts) |
Add HOMEROOM_SCOPES = { composition: ['name', 'departmentId', 'curriculumId', 'gradeId', 'baseRoomId', 'homeroomTeacherId', 'customFields'] as const } and SUBJECT_GROUP_SCOPES = { composition: ['name', 'studyPlanSubjectId', 'teacherId', 'homeroomId', 'customFields'] as const }. Register in ENTITY_SCOPE_REGISTRY. rbac-catalogue.drift.spec.ts will assert these match the seed. |
CUSTOM_FIELD_ENTITY_KEYS (entity-keys.ts) |
Append EntityKey.HOMEROOMS and EntityKey.SUBJECT_GROUPS |
teachers.employment scope extension: add declaredSubjects to TEACHER_SCOPES.employment + corresponding seed mapping. No new scope; this is an Employment-scope field.
Cross-cutting: declaredSubjects side-effect guard. When composition.addToDeclaredSubjects is non-empty on a Homeroom/SubjectGroup write, the service:
1. Compiles the requesting user's permissions for teachers.employment (read from request.permissions).
2. If access < WRITE: skip the side-effect silently (log info) and let the Homeroom/SG operation complete. The FE prompt was an opt-in; missing permission downgrades to "noted but not persisted".
3. If access >= WRITE: normalise (trim, dedupe, case-insensitive) the new subjects against the existing Teacher.declaredSubjects array; append novel ones in the same transaction.
Record-level visibility for Student / Parent (V1):
- Student: SELECT * FROM homerooms h JOIN homeroom_assignments ha ON ha.homeroom_id = h.id WHERE ha.student_id = :requestingUserStudentId AND h.tenant_id = :tenantId AND h.academic_year_id = :currentAY and analogously for SubjectGroups via subject_group_assignments. Returns only the records the student is enrolled in.
- Parent (Referent): same query but joined through Referent.linkedStudents so the result spans all children the Referent is linked to.
- Both implemented at the service-layer query (Layer 4 in the RBAC model). The handler is the same; the service branches on request.user.activeProfileType.
7. Divergence ledger¶
| Pattern | We diverge by | Reason | Tradeoff accepted |
|---|---|---|---|
Single-scope entities use configuration (Departments, Grades, Rooms, Curricula, Academic Years, School, EvaluationScales) |
Naming our single scope composition instead |
configuration is a catch-all bucket for "admin settings"; Homerooms and SubjectGroups are operational data (roster + teaching), not configuration. composition is semantically honest |
Slight inconsistency in scope naming across the catalogue; will need a doc note in 04-rbac.md |
Reads use scope-grouped DTOs that pass through FieldFilterInterceptor |
GETs marked @AggregateResponse() — DTOs extend AggregateResponseDto; the interceptor bypasses scope-stripping |
The natural response shape (department → curriculum → grade → items[] with derived numStudents and embedded students[]/subjects[]) doesn't decompose into scope groups. Same precedent as src/academic-years/ |
Derived fields aren't gated by FieldFilterInterceptor at the response layer — the ScopeGuard at entry remains the authorization boundary. Bit-rot assertion catches drift |
| Person-entity scopes are multi-faceted (Student has 7, Teacher has 5) | Both new entities have a single scope | No legitimate use case for splitting Homeroom/SG visibility in V1 (Teachers + Staff see all; Students/Parents see own/child via record-level filter, not scope split) | Future Principal-sees-headers-only or Substitute-sees-only-today UX will need a scope split + migration. Documented as a future iteration |
Teacher.declaredSubjects would normally require teachers.employment:write via FieldWriteGuard on every mutation |
We allow the side-effect on Homeroom/SG create+update with a service-layer fail-soft guard | The "record this choice" prompt is part of the assignment UX; a separate PATCH call to /teachers/:id would break atomicity and force the FE into two-call sequencing |
Privilege-creep risk is bounded (info-only field); admins typically have teachers.employment:write anyway. Documented in §6 |
| "One row per logical concept" — usually a single Postgres UNIQUE expresses the invariant | "Exactly one SubjectGroup per Subject per Student per AY" is enforced at service layer, not via DB UNIQUE | Would require denormalising subjectId onto subject_group_assignments (currently you reach Subject via subject_group_assignments → subject_groups → studyPlanSubjectId → StudyPlanSubject.id). The denormalisation creates a consistency burden if Subjects move in the StudyPlan |
Service-level check at write time; e2e test covers concurrency. Documented in §11 |
| US-37's "Tutor Teacher" is a separate assignment surface | We fold tutor-teacher into Homeroom.homeroomTeacherId directly |
Aligns with user's mental model (a Homeroom HAS a homeroom_teacher; tutor and homeroom-teacher are the same role). Simplifies US-37 scope when it lands | US-37 will need a small spec amendment to acknowledge the field already exists |
8. Pushback log¶
| US says | Conflicts with | Proposed instead | Status |
|---|---|---|---|
| US-33 Notes: "A Homeroom can contain students following different Curricula" | User's model: Homeroom is curriculum-bound (S1 wizard requires curriculumId; eligible-pool filter is by dept+curriculum+grade) | Homeroom binds to a single Curriculum; eligible pool filtered accordingly | Resolved — user confirmed |
US-33.1 S2/S3/S5: Subject Group composition keyed on homeroom_role = with_homeroom | none Department flag |
HomeroomRole enum is being retired; no Department-level mode flag in user's model |
Mode is determined per-Homeroom — if a Homeroom row exists for a given (Dept, Curriculum, Grade) cohort, that cohort uses Homeroom-bound mandatory SGs; otherwise mandatory SGs are standalone. Mixed mode within a Department is allowed |
Resolved — user explicit |
| US-33.1 S1: Subject Groups "auto-generated from Curriculum configuration" as a system pass | User's model: SGs are created synchronously by Admin via wizard (homeroom-bound) or explicit POST (standalone) | No autonomous "generation" pass; SGs are explicit POST operations |
Resolved — user confirmed by describing creation flows |
| US-33 S8 (post-timetable reassign with history) and US-33.1 S12 (same) | US-38 (timetable) doesn't exist; no history tables, no draft/published state probe | Stub hasPublishedTimetable(AY) as false; only the "replace, no history" branch is wired. History tables deferred |
Resolved — non-goal in §1 |
US-33.1 S4: "students with overdue or unfilled fields can be assigned manually" with a manuallyResolved flag |
Adds a flag column for a v1 we can avoid | V1 blocks assignment if any student in the cohort has pending curriculum selection; admin resolves the selection first | Resolved — non-goal in §1 |
| US-33.1 S6: "Admin can split an oversized Subject Group" with auto-derived names | Server-side split operation is complex; redundant with explicit POST | No split endpoint; admin creates separate SGs via repeated POST /subject-groups |
Resolved — non-goal in §1 |
| US-33 S9: auto-remove HomeroomAssignment on Student.status change | Lifecycle hook in students.service.ts; deferred to keep this iteration tight |
Manual removal by Admin for now; revisit with a future "lifecycle hooks" spec | Resolved — non-goal in §1 |
| US-33.1 S8: Subject cancellation cascade (removes SG assignments, invalidates curriculum selection, notifies Referent + Admin) | Hooks into US-32 cancellation surface which doesn't expose the right event | Deferred; revisit when US-32 grows a cancellation event | Resolved — non-goal in §1 |
| US-33.1 S14: Subject removal locked post-timetable | Guard lives in Curriculum module; depends on US-38 | Deferred until US-38 lands | Resolved — non-goal in §1 |
9. Deferrals¶
- Pre/post timetable history & confirmation flows (US-33 S7-S8, US-33.1 S11-S12) — depends on US-38 timetable existence probe. Follow-up: address in the US-38 iteration spec.
- Auto-removal of HomeroomAssignment on Student status non-Enrolled (US-33 S9) — requires a lifecycle hook in
students.service.ts. Follow-up: revisit with the next "student lifecycle hooks" iteration; track via memory entryproject_homeroom_lifecycle_hooks_deferred(to write on PR merge). - Subject cancellation cascade (US-33.1 S8) — depends on US-32 emitting a "Subject cancelled" event we can subscribe to. Follow-up: when US-32 grows the event surface (currently it lands in the curriculum-selection sub-resource only).
manuallyResolvedflag on assignments for overdue curriculum selections (US-33.1 S4 second half) — V1 blocks instead. Follow-up: revisit if admins report friction; trivial column add when needed.- Subject Group split UX (US-33.1 S6) — admin uses repeated POSTs. Follow-up: bundle with future "subject group composition tools" iteration if Italian-school workflow demands it.
- Subject removal post-timetable lock (US-33.1 S14) — guard in Curriculum module. Follow-up: lands with US-38.
- Custom Slot Types and other US-35 / US-36 surfaces — entirely out of scope for this iteration; the original US-35/36 design conversation is paused pending this iteration's completion. Follow-up: resume the US-35/36 design after homerooms + SGs land.
- Multi-scope visibility split (e.g. principal-sees-headers-only) — single
compositionscope today. Follow-up: revisit if a role's actual access pattern legitimately needs it. - Pagination on list endpoints — not paginated in V1 per user's explicit decision. Follow-up: revisit if list sizes grow (IB-style departments could push subject_groups counts into the hundreds).
- Hour-based teacher budget display —
StudyPlanSubject.weeklyHoursisFloat?(nullable). The teacher-selector's "allocated/non-allocated" budget will display "—" when hours are missing; falling back toyearHours / weeksInAYis a follow-up nicety. Track if admins report friction. - All side-effects of teacher-assignment changes — listed exhaustively in §1 Non-goals. Specifically: no history-row insert on teacher swap (US-37 S6-S7), no hour-budget re-validation, no overlap/conflict detection across the teacher's other assignments, no notification fan-out (FE-visible alert), no archiving guard ("Teacher cannot be archived while assigned" — US-37 S8), no draft-timetable invalidation (US-38 warning-cases table). Follow-up: bundle with US-37 + US-38 iterations as those build out the missing infrastructure.
- Subject-teacher slot deletion on Homeroom — there's no
DELETEfor a homeroom-bound child SG (it dies with the Homeroom). To "unassign" a mandatory subject's teacher, the Admin clears the field viaPATCH /subject-groups/:idwithteacherId: null. Documented in API surface notes.
10. Open questions¶
All resolved. (Empty per template gate requirement.)
11. Verification plan¶
- Unit specs:
src/homerooms/homerooms.service.spec.ts— wizard cascade (1 Homeroom + N HomeroomAssignment + N SubjectGroup in one transaction); name uniqueness per (Grade, AY); student exclusivity per AY; mandatory-subject-only filter onsubjectTeachers; declaredSubjects fail-soft guard; curriculum immutability on update; roster add/remove cascade to homeroom-bound child SG assignments; move atomicity (transaction rolls back on any per-student validation failure); move grade+curriculum match enforcement; teacher swap is a flat update (no history row, no notification — asserted by spying on side-effect callsites and confirming zero calls).src/subject-groups/subject-groups.service.spec.ts— standalone create (nohomeroomId); "one SG per Subject per Student per AY" service-layer invariant; curriculum-selection-pending block; deletion blocked when assignments exist; teacher SetNull behavior; roster ops rejected on homeroom-bound SGs; DELETE blocked on homeroom-bound SGs; move requires samestudyPlanSubjectId; PATCH on homeroom-bound restricts mutable field set.src/homerooms/queries.spec.ts— grouped tree shape; filters narrow correctly; sort order (dept → curriculum by name → grade by ordinalPosition);numStudentsderived correctly.src/subject-groups/queries.spec.ts— same suite shape, with the additional assertion thathomeroomId IS NULLis enforced in list.src/permissions/rbac-catalogue.drift.spec.ts— auto-extended; assertHOMEROOM_SCOPES.compositionmatches seed mapping fields.src/departments/departments.service.spec.ts— extend withmaxStudentsPerClassround-trip + setup-wizard write path.-
src/curriculum/curriculum.service.spec.ts— refactor existingweeklyLessons/yearLessonsspecs to use new names; assert no leftover references. -
E2E specs:
test/homerooms.e2e-spec.ts— covers US-33 Scenarios 1 (create + assign), 2 (base room + capacity + designation warn), 3 (name uniqueness), 4 (unassigned overview via filter), 5 (overview filters), 6 (late enrollment is identical create), 7 (replace without history), 10 (delete blocked by downstream — stubbed; cannot test today against missing US-36/US-37). Eligible-students picker:GET /homerooms/eligible-studentsreturns only students enrolled in the target (dept, curriculum, grade) AND not currently in any Homeroom; assert exclusion of an already-assigned Student; status≠ENROLLED Student excluded; curriculum-mismatched Student excluded. Roster sub-resource: add student viaPOST /homerooms/:id/students(golden path + duplicate-409 + ineligible-pool rejection); remove viaDELETE /homerooms/:id/students(golden path + not-in-homeroom 404); cascade assertion on homeroom-bound child SG rosters after add/remove. Move sub-resource: move within same Grade+Curriculum (golden); cross-Grade rejected withHOMEROOM_MOVE_GRADE_MISMATCH; cross-Curriculum rejected withHOMEROOM_MOVE_CURRICULUM_MISMATCH. Teacher swap: PATCHhomeroomTeacherIdreturns 200 with new ID; assert no audit/history row created (probehomeroom_assignment_historydoesn't exist or is empty).test/subject-groups.e2e-spec.ts— covers US-33.1 Scenarios 1 (mandatory groups materialise via Homeroom wizard, optional groups via standalone POST), 4 (curriculum-selection-pending block), 5 (assignment per Subject), 7 (move between groups for same Subject — invariant holds), 9 (overview filters), 10 (late enrollment), 11 (replace without history), 13 (prerequisite gate stubbed). Eligible-students picker:GET /subject-groups/eligible-students?studyPlanSubjectId=…returns full eligible cohort; assert mandatory-subject students appear; assert option-block-selected students appear; assert students without the subject in their study plan are excluded;alreadyAssignedflag flips correctly after assignment; sort order has unassigned first;assignedSubjectGroupIdandhomeroomNamepopulated correctly. Roster sub-resource for standalone SGs: add/remove golden path + duplicate rejection; homeroom-bound SG roster ops rejected withSUBJECT_GROUP_ROSTER_DERIVED. Move sub-resource: move within samestudyPlanSubjectId(golden); mismatch rejected. PATCH restricted on homeroom-bound: teacherId mutates fine; attempting to PATCHnameorstudyPlanSubjectIdrejected withSUBJECT_GROUP_FIELD_IMMUTABLE. DELETE: standalone deletes fine; homeroom-bound rejected withSUBJECT_GROUP_HOMEROOM_BOUND_NOT_DELETABLE.test/teachers.declared-subjects.e2e-spec.ts— covers the inline declaredSubjects mutation path: (a) admin withteachers.employment:writeadds subject during homeroom create AND on subsequentPATCH /subject-groups/:idteacher swap → Teacher row updated; (b) role without that scope adds subject during homeroom create → Homeroom created, Teacher untouched.test/departments.max-students-per-class.e2e-spec.ts— covers the new field on POST/PATCH/setup-wizard.-
Follow
feedback_e2e_isolation_patterns: stampDate.now()-suffixed names on Homeroom/SG creation to avoid collisions;prisma.deleteManycleanup for the singleton-shaped setup state inbeforeAll/afterAll. -
Manual verification:
- Spin a fresh tenant via
npx prisma db seed, log in as admin, walk the Homeroom wizard end-to-end with 5 students and a curriculum that has 3 mandatory subjects + 1 optional. Verify the resulting tree response includes 1 Homeroom with 3 child SGs; the optional subject does NOT auto-materialise. - Create a standalone Subject Group for an optional subject across 2 different Homerooms; verify the detail response shows
homeroomNameper student correctly. - Toggle the "Add to declared subjects" prompt on creation; verify the Teacher record reflects the addition only when the role has
teachers.employment:write. - Migrate down/up cycle locally to verify the
HomeroomRoledrop and theweeklyLessonsrename succeed cleanly with seeded data.
12. Sign-off¶
- Approved by: Fabio Barbieri
- Date: 2026-05-25
- Chat reference: Approved during planning walkthrough on 2026-05-25 — Homeroom + Subject Group flows agreed iteratively (terminology pinned, wizard semantics, GETs grouped tree, RBAC composition scope, aggregate response pattern, detail-page roster + move + teacher-swap endpoints, all side-effects of teacher changes deferred); OptionBlock rename extension confirmed during plan phase; implementation plan approved at
~/.claude/plans/good-now-plan-for-jazzy-neumann.md.
Until this section is filled, no implementation code is written.