Auto-seed standalone Subject Groups when a curriculum is offered¶
1. Problem distillation¶
- Building classes starts from an empty Subject-Group catalog. For departments that are not homeroom-driven (option 3 of the brainstorm: a tenant mixes homeroom and non-homeroom departments), the admin has to hand-create one standalone SG per subject before any student/teacher assignment can happen.
- When a curriculum becomes offered (
status = READY), we already know every subject it teaches per grade (the grid cells). We can pre-create one empty standalone SG per(subject, offered grade)so the catalog is ready to fill. - The blocker is an existing invariant: a Subject Group referencing a subject — even an empty one — already hard-blocks deleting/renaming that subject (
assertSubjectsNotInUseBySubjectGroups, keyed on SG existence). Auto-seeding empties everywhere would freeze the offered curriculum's grid solid. So auto-seed is inseparable from re-keying that lock from "an SG exists" → "an SG has students." - This iteration delivers both as one coherent change. The homeroom wizard is untouched; a grade's homeroom-bound SG and the standalone auto-SG coexist (a student lands in exactly one per subject — the
@@unique([studentId, curriculumSubjectId, academicYearId])backstop holds).
Success criteria (observable behavior that proves this works):
- A curriculum entering READY — at three trigger sites: POST /curricula with status: READY; PATCH /curricula/:id on the DRAFT→READY rising edge; and the setup-wizard bulkSync, which (decision B) now forces every setup curriculum to READY and seeds (see §7) — creates exactly one standalone SG (homeroomId IS NULL) per grid cell of every subject — mandatory (optionBlockId IS NULL) and each in-block alternative (optionBlockId IS NOT NULL) — with empty roster and empty teacher list. Idempotent: a (subject, grade) cell that already has a standalone SG is skipped.
- After a curriculum is offered, an admin can still delete or rename a subject whose SGs are all empty — the empty SGs are silently cascade-deleted so the Restrict FK doesn't fire. Deleting/renaming a subject (or removing a grade offering) whose SG has ≥1 student assignment returns a typed 409.
- Removing a grade cell / unlinking a grade follows the same rule: empty → cleanup, has-students → 409.
- Homeroom create/edit behavior is byte-for-byte unchanged. GET /subject-groups lists the new empty standalones.
Non-goals (in-scope-shaped things this iteration is explicitly not doing):
- Selection-window invalidation + resubmission when a chosen (in-block) subject is deleted during an open window — iteration 2 (separate spec).
- Notifications / re-ping of referents — deferred past iteration 2.
- Seeding more than one group per cell (class splits) — we seed "at least one"; admins add extras manually.
- Seeding for DRAFT curricula, or auto-seeding subjects added after the curriculum is already offered (see §7 — seed fires only on the rising edge into READY; post-offer additions are manual or via demote→re-offer).
- A SubjectGroup.origin (AUTO/MANUAL) flag — deliberately avoided (see §4); no schema change this iteration.
2. Patterns survey¶
| Analogous module/spec | What we'd borrow | What doesn't fit |
|---|---|---|
src/homerooms/homerooms.service.ts (create() wizard cascade) |
Creating child SubjectGroups inside one $transaction; the nested-write shape; the "one SG per mandatory cell" enumeration |
Wizard seeds homeroom-bound SGs with a roster from a dedicated endpoint; ours seeds empty standalone SGs as a side-effect of a curriculum write, and includes in-block subjects |
src/curriculum/curriculum-structure-sync.ts (assertSubjectsNotInUseBySubjectGroups, assertTracksNotInUse, onDelete guards) |
The guard-in-onDelete placement, lock-then-check, typed-409 (CURRICULUM_SUBJECT_IN_USE_BY_SUBJECT_GROUPS) shape |
Current guard keys on SG existence; we re-key to student count and add empty-SG cleanup before the subject/cell delete |
src/students/class-selection-sync.ts (applyClassAssignmentToSelection) |
Precedent for a required, non-fail-soft cross-module side-effect inside the existing tx, with idempotent "only fill what's absent" semantics | That syncs selections from roster writes; ours seeds SGs from a curriculum status transition |
src/curriculum/resolve-target-plan.ts + selection-consistency.ts (track/grade applicability predicates) |
Reuse the pure "subject applicable in (track, grade) and offered (has a cell)" enumeration so the seed set can't drift from the selection/coverage logic | resolve-target-plan counts the target plan; we enumerate cells to seed — same predicates, different consumer |
src/curriculum/curriculum.service.ts (assertCurriculumNotInUseLocked, demote path) |
lockCurriculumRow then count-in-use; the READY→DRAFT demote chokepoint |
Adds: block demote on SG-with-students; cleanup empty auto-SGs on demote |
3. Architecture mapping¶
| Primitive | Apply? | How | Justify |
|---|---|---|---|
| Tenant scope | yes | Seeded SGs inherit tenantId from the curriculum; all guard queries scoped by tenant |
SubjectGroup is tenant-scoped already |
| Academic-year scope | yes | Seeded SGs get academicYearId = curriculum's AY (active year); guards count assignments in that AY |
SG + SGA both carry academicYearId |
| RBAC entity key | existing (subject_groups, curricula) |
none new | Reuses both entities |
| Scopes | existing (curriculum.configuration) |
Seed + guards run inside the existing PATCH /curricula/:id (and setup wizard) path, already gated by curriculum.configuration write. No subject_groups scope needed — writes are server-side, not via the SG endpoints |
The admin who offers the curriculum already holds the curriculum-config write |
| Actions | none | No new action keys; read/update implicit |
Lifecycle change rides existing PATCH |
| Service base | custom | CurriculumService + curriculum-structure-sync.ts helpers; new pure helper ensure-standalone-subject-groups.ts |
Matches existing curriculum module shape (named functions, no repo classes) |
queries.ts shape |
extend | New named fns: findOfferableSubjectCells(tx, curriculumId), findExistingStandaloneSGKeys(tx, …), countStudentsInSubjectGroupsForSubjects(tx, subjectIds, ay), deleteEmptyStandaloneSGsForSubjects/Cells(tx, …) |
Keep raw Prisma in *.queries.ts per module convention |
| Error codes | reuse + enrich | Reuse CURRICULUM_SUBJECT_IN_USE_BY_SUBJECT_GROUPS; change semantics to "has students", enrich params with studentCount. Same code covers grade-cell removal (params add gradeId). No new code |
Avoid code proliferation; the dispatcher already maps this token |
| DTO conventions | none | No request/response DTO change — seed/guards are internal to existing write paths | The SG list/detail DTOs already render the new rows |
| File-backed sub-resources | n/a — SGs hold no files | ||
| Custom fields | n/a | Seeded SGs default customFields = {} |
Existing column default |
| Profile completeness | no | Subject groups are not in completeness curation | n/a |
4. Data model plan¶
Schema deltas¶
- None. Seeded rows are ordinary
SubjectGrouprecords (all required columns —tenantId,academicYearId,name,curriculumSubjectId,gradeId— are derivable;homeroomId = NULL,customFields = {}, no teachers/assignments).
Why no origin flag. A naive "auto vs manual" flag would only be needed if we re-seeded on every post-offer edit and had to avoid resurrecting admin-deleted auto-SGs. We sidestep that by seeding only on the rising edge into READY (§7) and treating every empty standalone SG uniformly as disposable scaffolding. So no column, no migration.
Migration shape¶
- n/a — no migration this iteration.
Seeded SG name¶
- Deterministic:
${subjectName} — ${gradeName}(em-dash), truncated toVarChar(150). Distinct from the homeroom-bound name rule (${subjectName} - ${homeroomName}), so a standalone and a bound SG for the same(subject, grade)never collide on the unique key. The name is for display only; identity for idempotency is the(academicYearId, gradeId, curriculumSubjectId, homeroomId IS NULL)tuple, not the name.
Indexes and uniqueness¶
- Relies on existing
@@unique([academicYearId, gradeId, curriculumSubjectId, name])for name-collision safety. Idempotency is enforced explicitly (compute the missing(subject, grade)set by querying existing standalone SGs for the curriculum) rather than by name-collision/skipDuplicates, so an admin-renamed auto-SG is not duplicated on a re-offer.
5. API surface¶
| Verb | Path | Decorators | Request DTO | Response DTO |
|---|---|---|---|---|
| (none new) | — | — | — | — |
The change is entirely behavioral on existing endpoints: PATCH /curricula/:id (status → READY seeds; structure edits re-keyed guard), the setup-wizard curriculum step, and the read side GET /subject-groups (now returns the seeded empties — no shape change).
Swagger considerations¶
- Refresh the error example for
CURRICULUM_SUBJECT_IN_USE_BY_SUBJECT_GROUPSonPATCH /curricula/:id— wording shifts from "referenced by one or more subject groups" to "has students assigned in one or more subject groups", params gainstudentCount. Keep it FE-facing copy only.
6. RBAC seed plan¶
| Seed file | Delta |
|---|---|
PermissionScope (rbac-catalogue.ts) |
none |
PermissionAction (rbac-catalogue.ts) |
none |
ScopeFieldMapping (rbac-catalogue.ts) |
none |
| Role grants (roles.ts) | none |
*_SCOPES runtime constant |
none |
No RBAC catalogue change: no new scope/action/entity; the seed and guards execute inside paths already gated by curriculum.configuration write.
7. Divergence ledger¶
| Pattern | We diverge by | Reason | Tradeoff accepted |
|---|---|---|---|
| On-axis CRUD side-effects (chapter 16 §4–5) | A curriculum write now produces SubjectGroup rows (a new cross-module cascade) and the curriculum-edit lock becomes a new student-keyed write-side gate — both off-axis | The seed is the whole point; it must be a required (not fail-soft) side-effect in the same tx so an offered curriculum can never exist without its skeleton, and the grid can never be edited around a running class | Off-axis cost reviewed and accepted; mitigated by no schema change and reuse of selection-consistency predicates |
| Idempotent top-up on every ready-state write | Seed fires only on the rising edge into READY (status was ≠ READY before, = READY after — incl. create-as-READY / wizard finalize), not on edits while already READY | Re-seeding on every READY-state edit would resurrect admin-deleted empty auto-SGs. Rising-edge-only avoids it without an origin flag. Post-offer subject additions are therefore not auto-seeded (admin creates the SG, or demote→re-offer re-runs the idempotent seed) |
Accept that a subject added to an already-offered curriculum has no auto-SG until a re-offer; accept that demote→re-offer recreates a manually-deleted empty auto-SG (re-offer is a deliberate republish) |
| Existing in-use guard keyed on SG existence | Re-key assertSubjectsNotInUseBySubjectGroups (and grade-cell/grade-unlink removal) to student count, and cascade-delete empty SGs as part of the removal |
Required so offered curricula stay editable while empty auto-SGs exist (the brainstorm's "writable once offered" requirement) | A subject/cell delete now has a write side-effect (deleting empty SGs); contained in the same structure-sync tx |
Setup bulkSync leaves curricula at default status (DRAFT) |
(Decision B) bulkSync now sets status = READY on onCreate (always) and onUpdate (forces READY; seeds on the DRAFT→READY rising edge), so finishing the curriculum setup step offers + seeds every curriculum |
User chose B 2026-06-16: setup curricula should be offered-and-seeded immediately; referents stay gated by the (admin-opened, date-driven) selection window, and the SELECTION_WINDOW_PREREQ_FAILED gate still protects window-open |
Setup curricula are READY (offered) immediately; safe only because the edit-lock is now student-keyed, so empty-SG'd READY curricula remain freely editable. An admin who wants a DRAFT setup curriculum must demote post-setup |
8. Pushback log¶
| US says | Conflicts with | Proposed instead | Status |
|---|---|---|---|
| "autocreate one SG for each subject of all curriculums" (incl. homeroom-driven depts, accept empties) | Guaranteed-dead empties clutter GET /subject-groups + the SG placement/coverage boards for homeroom departments |
User explicitly accepted the empties ("unused at most, acceptable"). Keep seed unconditional across all offered curricula; no hide/filter this iteration | Resolved (user accepted) |
| "create the SGs" implies one moment | Grid stays editable after READY; a one-shot would never cover later edits | Rising-edge-into-READY trigger + explicit idempotent enumeration (re-runs safe on re-offer) | Resolved |
| "all subject groups, not mandatory-only" | Homeroom wizard is mandatory-only by design | Standalone auto-seed covers all subjects incl. in-block alternatives (standalone in-block SGs are already a supported flavour); homeroom wizard stays mandatory-only and untouched | Resolved |
| "setup curricula are always READY" | Code traced: bulkSync never sets status; Curriculum.status defaults to DRAFT; no READY literal in src/setup. Setup curricula are DRAFT today |
Make it true under decision B: bulkSync forces status = READY + seeds (§7). Window-open is still gated by SELECTION_WINDOW_PREREQ_FAILED (selection-window.service.ts:171) |
Resolved (B) |
9. Deferrals¶
- Selection-window invalidation cascade (delete a chosen in-block subject during an open window → invalidate impacted
StudentOptionBlockChoice/ selection → flag for resubmission) — keyed onStudentOptionBlockChoice/selection, a different population than this iteration's SG-assignment lock — follow-up: iteration-2 spec2026-06-1x-selection-window-subject-deletion-invalidation. - Notifications / referent re-ping on invalidation — follow-up: past iteration 2.
- Auto-seed of post-offer subject additions without resurrecting deletions (would need an
origin/seeded-marker) — follow-up: revisit only if manual top-up proves painful. - Hiding guaranteed-empty auto-SGs in homeroom departments — follow-up: revisit if board/list clutter is reported.
- "Only offered curricula visible to referents" — confirmed already enforced (not deferred, nothing to build): window-open scans READY-curriculum coverage and throws
SELECTION_WINDOW_PREREQ_FAILED(selection-window.service.ts:171–184); referent visibility is gated by the date-driven window status. This iteration leaves it untouched.
10. Open questions¶
- None — all resolved (see §7/§8). The one judgment call to confirm at the spec-review gate: rising-edge-into-READY trigger (post-offer subject additions are not auto-seeded this iteration).
11. Verification plan¶
- Unit specs:
src/curriculum/ensure-standalone-subject-groups.spec.ts(new) — seeds one SG per offered cell for mandatory and in-block subjects; skips cells that already have a standalone SG; never touches homeroom-bound SGs; respects track-scoped subject applicability; empty teachers/roster.src/curriculum/curriculum-structure-sync.spec.ts(extend) — subject delete/rename: empty SGs → cascade-deleted + delete proceeds; with-students SG →CURRICULUM_SUBJECT_IN_USE_BY_SUBJECT_GROUPS409 withstudentCount. Same for grade-cell removal / grade unlink.src/curriculum/curriculum.service.spec.ts(extend) — rising-edge trigger (createCurriculumwithstatus: READYseeds;updateCurriculumDRAFT→READY seeds; READY→READY edit does not re-seed; demote blocks on SG-with-students + cleans empty auto-SGs);bulkSyncforcesstatus: READYand seedsonCreate(always) +onUpdate(DRAFT→READY edge only).- E2E specs (
test/curriculum*.e2e-spec.ts/test/subject-groups*.e2e-spec.ts): - Offer a curriculum →
GET /subject-groupsreturns the seeded empties (one per cell, in-block included). - Delete an empty-SG subject → 200; assign a student to its SG then delete the subject → 409.
- Create a homeroom in a seeded grade → bound child SG and standalone auto-SG coexist; a rostered student is in exactly one per subject (no
@@uniqueviolation). - Setup-wizard run that finalizes curricula to READY → subject_groups populated.
- E2E discipline per
feedback_e2e_isolation_patterns.md(unique markers,deleteManycleanup, dedicatedsis_e2eDB). - Manual verification: run the setup wizard locally to READY;
npx prisma studio→ confirmsubject_groupshas one emptyhomeroom_id IS NULLrow per offered cell.
12. Sign-off¶
- Approved by: Fabio Barbieri
- Date: 2026-06-16
- Chat reference: brainstormed + approved by Fabio in chat 2026-06-16 — converged on option-3 (mixed homeroom/non-homeroom depts), all-subjects seed, rising-edge-into-READY trigger, student-keyed lock re-key; iteration-2 (selection-window invalidation) split off.
Until this section is filled, no implementation code is written.